SLOPSHOPPER

blast-radius

Holds risky Bash commands and shows what they would change before they run.

newpanebandguardprocess
★ 167v0.2.2MITupdated 2026-10-04hamzafer/claude-code-mods/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: held this command a │ │ ● 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

<h1 align="center">Claude Code mods</h1>

<a href="LICENSE"><img src="https://img.shields.io/badge/license-MIT-blue.svg" alt="MIT"></a> <a href="https://github.com/hamzafer/claude-code-mods/actions/workflows/ci.yml"><img src="https://github.com/hamzafer/claude-code-mods/actions/workflows/ci.yml/badge.svg?branch=main" alt="CI"></a>

<a href="#-install">Install</a> · <a href="#-the-mods">All mods</a> · <a href="docs/mods.md">Docs</a> · <a href="https://claude.dev/blog/getting-started-with-claude-code-mods/">What are mods?</a>

<table> <tr> <td align="center" width="33%"><a href="docs/mods.md#-context-bar"><img src="images/context-bar.png" alt="context-bar: Claude Code context window usage as a stacked bar, a color per category" width="260"></a><br>📊 <b>context-bar</b><br>what fills your context</td> <td align="center" width="33%"><a href="docs/mods.md#-review-watch"><img src="images/review-watch.png" alt="review-watch: live lines for running Codex and subagent code reviews in Claude Code" width="260"></a><br>🔍 <b>review-watch</b><br>running code reviews, live</td> <td align="center" width="33%"><a href="docs/mods.md#-md-preview"><img src="images/md-preview.png" alt="md-preview: Markdown that Claude Code edits, rendered like GitHub next to the diff" width="260"></a><br>📝 <b>md-preview</b><br>Markdown rendered like GitHub</td> </tr> <tr> <td align="center" width="33%"><a href="docs/mods.md#-blast-radius"><img src="images/gallery/blast-radius.png" alt="blast-radius: a Claude Code hook holds rm -rf and lists the files it would delete" width="260"></a><br>💥 <b>blast-radius</b><br>see what <code>rm -rf</code> would delete</td> <td align="center" width="33%"><a href="docs/mods.md#-now-playing"><img src="images/now-playing.png" alt="now-playing: Spotify track, progress bar and synced lyrics inside Claude Code" width="260"></a><br>🎵 <b>now-playing</b><br>Spotify and its lyrics, live</td> <td align="center" width="33%"><a href="docs/mods.md#-reels-and-snake"><img src="images/reels-demo.gif" alt="reels: YouTube Shorts in a Claude Code pane while it works" width="260"></a><br>📱 <b>reels</b><br>Shorts while Claude works</td> </tr> <tr> <td align="center" width="33%"><a href="docs/mods.md#-where-am-i"><img src="images/gallery/where-am-i.png" alt="where-am-i: the session goal, current step and what waits on you, above the Claude Code prompt" width="260"></a><br>📍 <b>where-am-i</b><br>goal, now, waiting on you</td> <td align="center" width="33%"><a href="docs/mods.md#-lines-above-the-prompt"><img src="images/gallery/lines.png" alt="token-weather, usage-meter and other Claude Code status lines stacked above the prompt" width="260"></a><br>🌦️ <b>token-weather and friends</b><br>lines above the prompt</td> <td align="center" width="33%"><a href="#mission-control"><img src="images/gallery/mission-control.png" alt="mission-control: Claude Code subagents, tool calls and the files they touch, live" width="260"></a><br>🛰️ <b>mission-control</b><br>agents and the code they touch</td> </tr> </table>

🚀 Install

Add the marketplace once, then install any mod by name:

claude plugin marketplace add hamzafer/claude-code-mods
claude plugin install context-bar@claude-code-mods

Or install the general-purpose set in one go:

for m in context-bar token-weather usage-meter where-am-i next-steps agent-radar review-watch replay-theater md-preview blast-radius mission-control; do
  claude plugin install "$m@claude-code-mods"
done

Restart Claude Code after installing. To try one without installing:

git clone https://github.com/hamzafer/claude-code-mods && cd claude-code-mods
claude --plugin-dir mods/context-bar

Needs Claude Code 2.1.287+. A few mods need more (Chrome, gh, a connector); the tables say which.

🧩 The mods

👀 See what's happening

ModWhat it doesCommand
🛰️mission-controlLive map of agents, tool calls and the code they touch/mission
📊context-barYour context window as one stacked bar, a color per category, with token counts and where it compacts/context-bar
🌦️token-weatherContext fill from Clear to Compact soon, plus a prompt-cache countdown
⏱️cache-clockA prompt-cache line under your status line, from Claude Code's own figures: time left, hit rate and misses, and the tokens your next message re-caches once it goes cold. Needs Node and Claude Code 2.1.251+/cache-clock setup
📍where-am-iGoal, doing now, waiting on you, next step/where
➡️next-steps2 or 3 likely next prompts after each turn, one key to draft one1 2 3, 0 hides
💰usage-meter5-hour and 7-day plan usage, the reset countdown and the session's cost
💳openai-balanceYour OpenAI API credit: an estimated balance with a gauge, today's spend, where the money mostly went, and the last call. Needs an OpenAI organization Admin key/openai-balance
📡agent-radarOne live line per running subagent/radar
🔍review-watchOne live line per running code review (Codex or a review subagent) with the model, target, elapsed time and Codex's latest output. A toast lists the findings when it ends
🌐browser-lanesWhether this session has a browser, and who holds it/browser
🕌prayer-timesThe current prayer and how long is left, the next one, and zawal. Computed on your computer, Hanafi or standard Asr/prayers
🎬replay-theaterSteps through the last turn's edits, one diff at a time/replay
📝md-previewRenders the Markdown files Claude edits like GitHub does, with before and after side by side. Needs Chrome and a terminal that shows images/md

🛡️ Guard your repo

ModWhat it doesCommand
💥blast-radiusHolds rm -r, force pushes and migrations, shows what they'd delete, cancels after 60 s with no answer

💸 Spend less

ModWhat it doesCommand
🔀switchboardPicks the model for each subagent that doesn't name one, with OpenAI's Decisions API or Jev, from its short label only. Shows what every subagent cost/route

🔧 My setup (fork and adapt)

These are built around my own tools and rules. Fork them and change the rules to yours.

ModWhat it doesCommand
👀glanceOne line with what needs you: next meeting, PRs, Linear issues, Slack DMs. Needs gh and the Google Calendar, Linear and Slack connectors/glance
🚦merge-gateHolds gh pr merge until CI is green and Codex reviewed once. Needs gh and the Codex CLI. Reviews run on one fixed model; change it to yours/gate
📏rulebook-guardEnforces my writing and git rules: rewrites em dashes, asks before --amend, unformatted pushes, emails and phone numbers in notes
💾session-saverSaves where you left off, shows it on resume. Needs unpause/park [note]

🎮 For fun (opt-in)

ModWhat it doesCommand
📱reelsYouTube Shorts while Claude works, pauses when it's done/reels
🐍snakeSnake while Claude works/snake
🎵now-playingWhat Spotify is playing, with a progress bar, the lyric being sung, and ⏮ ⏸ ⏭ buttons. Needs macOS and the Spotify app/music

<a id="mission-control"></a>

🛰️ Flagship: mission-control

Every subagent, every tool call and every file they touch, in a pane next to the chat. Shown at 4x: two subagents building a logout feature across four files.

mission-control at 4x: two subagents and a logout feature landing across four files

Install it like any mod, restart, and type /mission (or /mission code to open the code map). q closes it.

  • 🤖 w shows the agents and every tool call, live
  • 🗺️ c shows the code map, with import arrows
  • 🔵 Blue while the agent reads a file
  • 🟠 Orange while it writes
  • 🟢 Green when done, with one line on what changed

The Code view also needs macOS, Google Chrome and a terminal that shows images (Ghostty, kitty, iTerm2). The Who view works everywhere.

📚 More

Source 2 files
hooks/register.tsx 265 lines
1// Blast Radius: holds risky Bash commands and shows what they would change.
2import { atom, read, update } from 'claude-code'
3import type { EngineInterface, Register } from 'claude-code'
4
5import type { HeldCommand } from '../types'
6
7const PANE = 'blast-radius'
8const MAX_LISTED = 200
9const DEFAULT_TIMEOUT_SECONDS = 60
10
11// Held by the host, so the drawing redraws when a command is held or released.
12const held = atom({ plugin: 'blast-radius', key: 'held' } as const, null as HeldCommand | null)
13
14const MIGRATION =
15  /\b(prisma\s+(migrate|db\s+push)|supabase\s+(db\s+(reset|push)|migration\s+up)|drizzle-kit\s+(push|migrate)|knex\s+migrate|sequelize(-cli)?\s+db:migrate|rails\s+db:(migrate|reset|drop)|alembic\s+(upgrade|downgrade)|typeorm\s+migration:run)\b/
16
17type Risk = { risk: HeldCommand['risk']; cwd?: string; targets?: string[]; hasQuotes?: boolean }
18
19export const register: Register = (on, options) => {
20  // How long a held command waits for a press before it is cancelled; 0 waits forever.
21  const timeoutSeconds = timeoutFrom(options.timeoutSeconds)
22  // The press of a Button, by held command id.
23  const decisions = new Map<string, 'proceed' | 'cancel'>()
24  // The call holding the pane now. Checked and claimed in one step, so two waiting calls can't both take it.
25  let holder: string | null = null
26
27  on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
28    const found = classify(e.command)
29    if (!found) return next(e)
30
31    // Nothing draws the session (a plain `claude -p` run): nobody can see the buttons, so refuse now.
32    if ((await $.session.surfaces()).length === 0) {
33      const { summary } = await measure($, found)
34      return {
35        deny:
36          `blast-radius: cancelled this command because this session has no screen, so nobody can answer. ` +
37          `It would have: ${summary}. Ask the user to run it themselves.`,
38      }
39    }
40
41    // One command is held at a time; a second waits its turn (the first one's timeout bounds the wait).
42    for (;;) {
43      // The call ended while it waited its turn: leave the held one alone.
44      if (next.signal.aborted) return { deny: 'blast-radius: held this command, but the call was stopped before it was shown.' }
45      if (holder === null && (await read($, held)) === null && holder === null) break
46      await $.process.run(['sleep', '0.25'])
47    }
48    holder = e.tool_use_id
49    try {
50      const measured = await measure($, found)
51      const one: HeldCommand = {
52        id: e.tool_use_id,
53        command: e.command,
54        risk: found.risk,
55        ...measured,
56        where: 'pane',
57        secondsLeft: timeoutSeconds > 0 ? timeoutSeconds : null,
58      }
59      await update($, held, () => one)
60
61      const opened = await $.ui.open({ id: PANE, title: 'Blast Radius', focus: true })
62      if (!opened.isPlaced) {
63        await update($, held, h => (h ? { ...h, where: 'band' as const } : h)) // too narrow for a pane: draw above the prompt
64      }
65
66      // Time spent inside $ calls doesn't count against the hook's time limit.
67      // Nobody pressing within the timeout counts as Cancel, so an unattended session never stalls.
68      const deadline = timeoutSeconds > 0 ? (await $.clock.now()) + timeoutSeconds * 1000 : null
69      let isTimedOut = false
70      while (!decisions.has(one.id) && !next.signal.aborted) {
71        if (deadline !== null) {
72          const left = Math.ceil((deadline - (await $.clock.now())) / 1000)
73          if (left <= 0) {
74            isTimedOut = !decisions.has(one.id) // a press that landed at the deadline still counts
75            break
76          }
77          if (left !== one.secondsLeft) {
78            one.secondsLeft = left
79            await update($, held, h => (h && h.id === one.id ? { ...h, secondsLeft: left } : h))
80          }
81        }
82        await $.process.run(['sleep', '0.25'])
83      }
84      const decision = isTimedOut ? 'cancel' : (decisions.get(one.id) ?? 'cancel')
85      decisions.delete(one.id)
86      await update($, held, () => null)
87      await $.ui.close({ id: PANE })
88
89      if (decision === 'proceed') return next(e) // runs as written
90      if (isTimedOut) {
91        return {
92          deny:
93            `blast-radius: held this command and nobody answered within ${timeoutSeconds} s, so it was cancelled. ` +
94            `It would have: ${one.summary}. Don't retry it on your own: ask the user to run it, or run it again once they're back.`,
95        }
96      }
97      return {
98        deny: `blast-radius: the user pressed Cancel on this command. It would have: ${one.summary}.`,
99      }
100    } finally {
101      holder = null
102      // If the hold failed partway, don't leave its command blocking the next call.
103      await update($, held, h => (h && h.id === e.tool_use_id ? null : h)).catch(() => {})
104    }
105  })
106
107  // The hook's loop picks the press up.
108  const decide = (id: string, decision: 'proceed' | 'cancel') => {
109    decisions.set(id, decision)
110  }
111
112  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
113    const one = await read($, held)
114    const { Text } = $.ui.resolve(e)
115    if (!one) return <Text dimColor>Nothing held.</Text>
116    return report($, e, one, decide)
117  })
118
119  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
120    const one = await read($, held)
121    if (!one || one.where !== 'band' || e.props.hasSurvey) return next(e)
122    return report($, e, one, decide) // a held command takes the whole band until answered
123  })
124}
125
126function report(
127  $: EngineInterface,
128  e: Parameters<EngineInterface['ui']['resolve']>[0],
129  one: HeldCommand,
130  decide: (id: string, d: 'proceed' | 'cancel') => void,
131) {
132  const { Box, Text, Button } = $.ui.resolve(e)
133  const label = { delete: 'Deletes files', 'force-push': 'Force push', migration: 'Database migration' }[one.risk]
134  const shown = one.where === 'band' ? one.details.slice(0, 5) : one.details
135  return (
136    <Box flexDirection="column" paddingX={1}>
137      <Text>
138        <Text color="red" bold>{'⚠ Blast Radius held a command '}</Text>
139        <Text dimColor>{`(${label})`}</Text>
140      </Text>
141      <Text color="yellow" wrap="truncate-end">{`$ ${one.command}`}</Text>
142      <Text bold>{one.summary}</Text>
143      {shown.map(line => (
144        <Text dimColor wrap="truncate-end">{`  ${line}`}</Text>
145      ))}
146      {one.details.length > shown.length && <Text dimColor>{`  … ${one.details.length - shown.length} more`}</Text>}
147      <Box flexDirection="row" gap={1}>
148        <Button key="cancel" label="Cancel" hotkey="c" variant="primary" autoFocus onPress={() => decide(one.id, 'cancel')} />
149        <Button key="proceed" label="Proceed" hotkey="y" onPress={() => decide(one.id, 'proceed')} />
150        {one.secondsLeft != null && (
151          <Text key="countdown" color={one.secondsLeft <= 10 ? 'yellow' : undefined} dimColor={one.secondsLeft > 10}>
152            {`auto-cancels in ${one.secondsLeft} s`}
153          </Text>
154        )}
155      </Box>
156    </Box>
157  )
158}
159
160// The timeoutSeconds option as whole seconds: a number or a numeric string, 0 to wait forever.
161export function timeoutFrom(value: unknown): number {
162  const n = typeof value === 'string' && value.trim() !== '' ? Number(value) : value
163  if (typeof n !== 'number' || !Number.isFinite(n) || n < 0) return DEFAULT_TIMEOUT_SECONDS
164  return Math.ceil(n) // a positive fraction stays a timeout, never 0 (wait forever)
165}
166
167// Which risky kind a command is, if any, looking at each part of a compound command.
168export function classify(command: string): Risk | null {
169  let cwd: string | undefined
170  for (const part of command.split(/&&|\|\||;|\n/)) {
171    const words = split(part.trim())
172    if (words[0] === 'cd' && words[1]) cwd = words[1]
173    const at = words.findIndex(w => w === 'rm')
174    if (at !== -1 && (at === 0 || words[at - 1] === 'sudo')) {
175      const args = words.slice(at + 1)
176      const flags = args.filter(a => a.startsWith('-'))
177      const isRecursive = flags.some(f => f === '--recursive' || (/^-[a-zA-Z]+$/.test(f) && /[rR]/.test(f)))
178      // Quotes are gone after split: remember they were there, so '~' or '$HOME' are not expanded as if bare.
179      if (isRecursive) return { risk: 'delete', cwd, targets: args.filter(a => !a.startsWith('-')), ...(/['"\\]/.test(command) ? { hasQuotes: true } : {}) }
180    }
181    if (words[0] === 'git' && words.includes('push') && words.some(w => w === '-f' || w.startsWith('--force') || /^\+/.test(w))) {
182      return { risk: 'force-push', cwd }
183    }
184    if (MIGRATION.test(part)) return { risk: 'migration', cwd }
185  }
186  return null
187}
188
189// What the command would touch, from the tools' own commands.
190// Paths the preview can't resolve without running the command: shell variables, command substitution, ~user.
191const UNRESOLVED = /[$`]|^~[^/]/
192const UNRESOLVED_QUOTED = /[$`~]/ // with quotes around, even ~ and $HOME are unknown
193
194async function measure($: EngineInterface, found: Risk): Promise<Pick<HeldCommand, 'summary' | 'details'>> {
195  // ~ and $HOME are expanded here; anything else with a variable is reported as unknown, never as "nothing".
196  const home = (await $.env.get('HOME').catch(() => undefined)) ?? ''
197  // With quotes in the command we can't tell '~' (literal) from ~ (home): don't expand, report it instead.
198  const expand = (p: string) => (home && !found.hasQuotes ? p.replace(/^~(?=\/|$)/, home).replace(/\$\{HOME\}|\$HOME\b/g, home) : p)
199  const cwd = found.cwd ? expand(found.cwd) : undefined
200  const unresolved = found.hasQuotes ? UNRESOLVED_QUOTED : UNRESOLVED
201  const init = cwd && !unresolved.test(cwd) ? { cwd, timeoutMs: 10_000 } : { timeoutMs: 10_000 }
202  try {
203    if (found.risk === 'delete') {
204      const targets = (found.targets ?? []).map(expand)
205      const unknown = targets.filter(t => unresolved.test(t))
206      if (unknown.length > 0 || (cwd !== undefined && unresolved.test(cwd))) {
207        const list = unknown.length > 0 ? unknown : [`cd ${found.cwd}`]
208        return { summary: `can't preview: ${list.length === 1 ? 'a path uses' : `${list.length} paths use`} a shell variable, check by hand`, details: list.slice(0, MAX_LISTED) }
209      }
210      // Unquoted $t expands globs, and nothing else, without running the command.
211      const script =
212        'shopt -s nullglob; for t in "$@"; do for p in $t; do [ -e "$p" ] || continue; ' +
213        'echo "S $(du -sk "$p" | cut -f1)"; find "$p" -type f | head -n 5000 | sed "s/^/F /"; done; done'
214      const r = await $.process.run(['bash', '-c', script, 'blast-radius', ...targets], init)
215      const lines = r.stdout.split('\n')
216      const files = lines.filter(l => l.startsWith('F ')).map(l => short(l.slice(2), targets))
217      const kb = lines.filter(l => l.startsWith('S ')).reduce((sum, l) => sum + Number(l.slice(2)), 0)
218      if (files.length === 0 && kb === 0) return { summary: 'delete nothing that exists right now', details: [] }
219      return {
220        summary: `delete ${files.length >= 5000 ? '5000+' : files.length} file${files.length === 1 ? '' : 's'} (${size(kb)})`,
221        details: files.slice(0, MAX_LISTED),
222      }
223    }
224    if (found.risk === 'force-push') {
225      const lost = await $.process.run(['git', 'log', '--oneline', 'HEAD..@{u}'], init)
226      const ahead = await $.process.run(['git', 'log', '--oneline', '@{u}..HEAD'], init)
227      const gone = lost.stdout.split('\n').filter(Boolean)
228      const added = ahead.stdout.split('\n').filter(Boolean)
229      return {
230        summary: `overwrite the remote branch: ${gone.length} remote commit${gone.length === 1 ? '' : 's'} lost, ${added.length} pushed (as of the last fetch)`,
231        details: [...gone.map(c => `lost   ${c}`), ...added.map(c => `pushed ${c}`)].slice(0, MAX_LISTED),
232      }
233    }
234    const status = await $.process.run(['git', 'status', '--porcelain'], init)
235    const touched = status.stdout.split('\n').filter(l => /migrat|schema|prisma|supabase|drizzle/i.test(l))
236    return {
237      summary: 'change the database schema (not previewable here)',
238      details: touched.map(l => `uncommitted ${l.trim()}`).slice(0, MAX_LISTED),
239    }
240  } catch {
241    return { summary: `run a risky ${found.risk} command (could not measure it)`, details: [] }
242  }
243}
244
245// Shell-like words: quotes kept together, the quotes themselves dropped.
246function split(text: string) {
247  return (text.match(/"[^"]*"|'[^']*'|\S+/g) ?? []).map(w => w.replace(/^["']|["']$/g, ''))
248}
249
250// A path from the folder being deleted (build/chunk-1.js), not the whole path.
251function short(file: string, targets: string[]) {
252  for (const t of targets) {
253    const base = t.replace(/\/+$/, '')
254    const parent = base.includes('/') ? base.slice(0, base.lastIndexOf('/') + 1) : ''
255    if (parent && file.startsWith(parent)) return file.slice(parent.length)
256  }
257  return file
258}
259
260function size(kb: number) {
261  if (kb >= 1024 * 1024) return `${(kb / 1024 / 1024).toFixed(1)} GB`
262  if (kb >= 1024) return `${(kb / 1024).toFixed(1)} MB`
263  return `${kb} KB`
264}
265
types/index.d.ts 17 lines
1export type HeldCommand = {
2  id: string
3  command: string
4  risk: 'delete' | 'force-push' | 'migration'
5  summary: string
6  details: string[]
7  where: 'pane' | 'band'
8  /** Seconds until the hold auto-cancels; null when it waits for a press forever. */
9  secondsLeft: number | null
10}
11
12declare module 'claude-code' {
13  interface PluginState {
14    'blast-radius': { held: HeldCommand | null }
15  }
16}
17