SLOPSHOPPER

blast-radius

Holds risky Bash (migrations, force-push, DROP, hard reset, rm -rf, deleting .env) and shows a dry-run pane with Proceed / Cancel.

newpanebandguardcommandprocess
v0.1.0no licenseupdated 2026-10-06yogeshvar/mod-claude/plugins/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 │ ┃ ╭──────────────────────────────────────────╮ │ ┃ │ ◆ Force-push │ ⏺ Read(src/auth.ts) │ ┃ │ This force-pushes and can overwrite │ ⎿ Read 6 lines │ ┃ │ remote history. │ ⏺ Update(src/auth.ts) │ ┃ │ rm -rf build && git push --force origin │ ⎿ Added 2 lines, removed 1 line │ ┃ │ main │ ⏺ Bash(rm -rf build && git push --force origin main) │ ┃ │ M src/auth.ts │ ⎿ Denied by blast-radius: Blast Radius hit an error; the co │ ┃ │ ?? src/auth.test.ts │ │ ┃ │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ │ [ Proceed ] [ Cancel ] │ │ ┃ ╰──────────────────────────────────────────╯ ✻ Worked for 42s · done 4:20 PM │ │ › /blast-radius │ ⎿ blast-radius: Nothing is held. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Blast Radius · while holding a tool call
╭──────────────────────────────────────────────────────────╮ │ ◆ Force-push │ │ This force-pushes and can overwrite remote history. │ │ rm -rf build && git push --force origin main │ │ M src/auth.ts │ │ ?? src/auth.test.ts │ │ │ │ [ Proceed ] [ Cancel ] │ ╰──────────────────────────────────────────────────────────╯
README

Claude Code mods

A small CLI cockpit for teams that already have agents, hooks, and instructions, and that still use Claude Code in the terminal (including Bedrock). Mods draw live UI and can hold a tool call. They do not replace those files.

  1. context-board — compact context card above the prompt, plus a one-line kit strip of what this session actually loaded
  2. agents-side — a pane of the main loop and every subagent it spawns
  3. session-map — files Claude read, edited, or wrote this session, grouped by monorepo app
  4. blast-radius — holds risky Bash until you Proceed or Cancel

Needs Claude Code 2.1.287+ (written against 2.1.290). If mods do not load, export CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1.

Install

claude plugin marketplace add yogeshvar/mod-claude
claude plugin install agents-side@mod-claude
claude plugin install context-board@mod-claude
claude plugin install session-map@mod-claude
claude plugin install blast-radius@mod-claude

Or clone and load for one session:

git clone https://github.com/yogeshvar/mod-claude.git
cd mod-claude
./start-claude

Then:

  1. Stretch the terminal to at least 110 columns (144 if you want unasked panes to auto-dock). Run /tui fullscreen so panes sit beside the transcript.
  2. The context card sits above the prompt: used vs window, compact threshold, a stacked color bar, a small legend, and a kit line (skills 4 · agents 2 · CLAUDE.md · plugins …).
  3. Ask Claude to spawn a couple of subagents. If the agents pane did not auto-open, run /agents-side.
  4. Run /session-map after Claude has read files. Rows group under apps/api, apps/web, apps/mobile, and root.
  5. A pnpm migrate, git reset --hard, git push --force, DROP TABLE, rm -rf, or rm .env opens Blast Radius. 1 proceeds, 2 cancels. This is a safety net, not a permission system — keep hard denies in managed settings.

Commands

PluginCommandWhat it does
agents-side/agents-sideOpen or close the pane
agents-side/agents-side clearDrop finished agents
agents-side/agents-side closeClose the pane
context-board/context-boardOpen or close the pane (the bar stays)
context-board/context-board refreshRecount every category with the token-count API
context-board/context-board paneForce-open the pane
context-board/context-board closeClose the pane
session-map/session-mapOpen or close the file pane
session-map/session-map clearDrop the file list
session-map/session-map closeClose the pane
blast-radius/blast-radiusRe-open the hold pane if a command is waiting
blast-radius/blast-radius closeCancel the held command

On the agents pane, keys 1–9 select a row (focus the pane first: click it, or ctrl+x tab). c clears finished agents, x closes it.

On the context pane: r refresh, x close.

On the session map: c clear, x close.

On Blast Radius: 1 Proceed, 2 Cancel.

openOnStart (agents pane; session-map, off by default) and openPaneOnStart (context pane, off by default) are plugin options under /config.

Bedrock and team machines

Claude Code on Amazon Bedrock / an API key does not load the built-in sec-default guard unless you push managed settings. Without that, a user-installed mod can override deny rules.

For the team:

  1. Allow this marketplace (and block unknown ones) in managed settings.
  2. Put sec-default in prependPlugins.
  3. Keep production denies in permission rules. Blast Radius only holds commands whose text looks risky ($(…) and scripts that call rm can still get past it).
  4. On Bedrock, $.session.usage() may omit cost. The context card then shows tokens and the window only — it does not invent dollars.

What each one shows

context-board calls $.session.usage({ breakdown: "summary" }) after each main-loop turn (local estimates, no extra API). /context-board refresh uses breakdown: "full". The kit strip reads enabled plugins from settings, CLAUDE.md / AGENTS.md from the instruction walk, skill and agent counts from usage, and the last Skill tool name.

agents-side watches agent.spawn, tool.call, turn.start, turn.complete, and session.measure. It never blocks those events.

session-map observes Read, Edit, Write, and Glob after they run. It never blocks.

blast-radius classifies Bash, dry-runs git status / git clean -n when it can, and holds the call until Proceed or Cancel.

Check the plugins

claude plugin validate .
claude plugin validate ./plugins/agents-side
claude plugin validate ./plugins/context-board
claude plugin validate ./plugins/session-map
claude plugin validate ./plugins/blast-radius
claude plugin test ./plugins/agents-side
claude plugin test ./plugins/context-board
claude plugin test ./plugins/session-map
claude plugin test ./plugins/blast-radius
Source 3 files
hooks/register.ts 176 lines
1import { classify, clip, linesOf, type Risk } from './classify.ts'
2import { drawHold, type Hold } from './draw.ts'
3
4export const PANE = 'blast-radius'
5
6type Decision = 'proceed' | 'cancel' | null
7
8type Held = Hold & {
9  decision: Decision
10  where: 'pane' | 'band'
11}
12
13let held: Held | null = null
14
15function pin($: { ui: { invalidate: (event: 'ui.render') => void } }) {
16  $.ui.invalidate('ui.render')
17}
18
19function paneArgs() {
20  return { id: PANE, title: 'Blast Radius', columns: 56, rows: 14, focus: true as const }
21}
22
23function processResult(result: { stdout?: string; stderr?: string }): string[] {
24  const out = `${result.stdout ?? ''}\n${result.stderr ?? ''}`.trim()
25  if (!out) return []
26  return linesOf(out)
27}
28
29async function measure(
30  $: { process: { run: (argv: readonly string[]) => Promise<{ stdout: string; stderr: string; exitCode: number }> } },
31  risk: Risk,
32): Promise<string[]> {
33  try {
34    if (risk.kind === 'git-clean') {
35      const result = await $.process.run(['git', 'clean', '-nd'])
36      const lines = processResult(result)
37      return lines.length > 0 ? lines : ['git clean -n reported nothing']
38    }
39    if (risk.kind === 'reset-hard' || risk.kind === 'force-push') {
40      const result = await $.process.run(['git', 'status', '--porcelain'])
41      const lines = processResult(result)
42      return lines.length > 0 ? lines : ['working tree clean']
43    }
44    if (risk.kind === 'migrate' || risk.kind === 'migrate-down') {
45      return ['Dry-run is the command text. Confirm the database before proceeding.']
46    }
47    if (risk.kind === 'drop' || risk.kind === 'truncate') {
48      return ['SQL will run as written. This is not a permission deny.']
49    }
50    if (risk.kind === 'rm-rf' || risk.kind === 'env-delete') {
51      return ['Check the path in the command before proceeding.']
52    }
53  } catch {
54    return ['Could not dry-run; the command is still held.']
55  }
56  return []
57}
58
59function decide(
60  which: Decision,
61  $: { ui: { close: (args: { id: string }) => Promise<unknown>; invalidate: (event: 'ui.render') => void } },
62) {
63  if (!held) return
64  held.decision = which
65  pin($)
66  void $.ui.close({ id: PANE })
67}
68
69export function register(
70  on: (
71    event: string,
72    matcher?: unknown,
73    hook?: (...args: never[]) => unknown,
74  ) => void,
75) {
76  on('session.start', async ($, e, next) => {
77    const result = await next(e)
78    await $.command.register({
79      name: 'blast-radius',
80      description: 'Show the last held command, if any',
81      argumentHint: '[close]',
82      immediate: true,
83    })
84    return result
85  })
86
87  on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
88    const command = String(e.command ?? '')
89    const risk = classify(command)
90    if (risk === null) return next(e)
91    if (held && held.decision === null) {
92      return { deny: 'Blast Radius is already holding another command.' }
93    }
94
95    try {
96      const report = await measure($, risk)
97      held = { command, risk, report, decision: null, where: 'pane' }
98      const opened = await $.ui.open(paneArgs())
99      if (!opened.isPlaced) held.where = 'band'
100      pin($)
101
102      while (held && held.decision === null && !next.signal?.aborted) {
103        await $.process.run(['sleep', '0.25'])
104      }
105
106      const snapshot = held
107      held = null
108      pin($)
109      void $.ui.close({ id: PANE })
110
111      if (snapshot?.decision === 'proceed') return next(e)
112      return {
113        deny: `Blast Radius held this command: the user pressed Cancel. It would have: ${clip(risk.summary, 160)}`,
114      }
115    } catch {
116      held = null
117      return { deny: 'Blast Radius hit an error; the command did not run.' }
118    }
119  }).catch(() => ({ deny: 'Blast Radius hit an error; the command did not run.' }))
120
121  on('command.run', { command: 'blast-radius' }, async ($, e) => {
122    if (e.args.trim() === 'close') {
123      if (held) held.decision = 'cancel'
124      await $.ui.close({ id: PANE })
125      pin($)
126      return { text: 'Released the held command.' }
127    }
128    if (!held) return { text: 'Nothing is held.' }
129    await $.ui.open(paneArgs())
130    return { text: `Holding: ${held.risk.title}` }
131  })
132
133  on('ui.render', { component: 'Pane' }, ($, e, next) => {
134    if (e.requestId !== PANE || !held) return next(e)
135    const { Box, Text, Button } = $.ui.resolve(e)
136    return drawHold(Box, Text, Button, held, {
137      bodyColumns: e.props.bodyColumns,
138      showButtons: true,
139      onProceed: () => {
140        if (held) held.decision = 'proceed'
141      },
142      onCancel: () => {
143        if (held) held.decision = 'cancel'
144      },
145    })
146  })
147
148  on('ui.render', { component: 'AbovePrompt' }, ($, e, next) => {
149    if (!held || held.where !== 'band' || e.props.hasSurvey) return next(e)
150    const { Box, Text, Button } = $.ui.resolve(e)
151    return drawHold(Box, Text, Button, held, {
152      bodyColumns: e.props.bodyColumns,
153      showButtons: true,
154      onProceed: () => {
155        if (held) held.decision = 'proceed'
156      },
157      onCancel: () => {
158        if (held) held.decision = 'cancel'
159      },
160    })
161  })
162
163  on('ui.press', async ($, e, next) => {
164    if (e.plugin !== 'blast-radius') return next(e)
165    if (e.element === 'proceed') {
166      decide('proceed', $)
167      return {}
168    }
169    if (e.element === 'cancel') {
170      decide('cancel', $)
171      return {}
172    }
173    return next(e)
174  })
175}
176
hooks/classify.ts 114 lines
1export type RiskKind =
2  | 'migrate'
3  | 'migrate-down'
4  | 'force-push'
5  | 'reset-hard'
6  | 'git-clean'
7  | 'drop'
8  | 'truncate'
9  | 'rm-rf'
10  | 'env-delete'
11
12export type Risk = {
13  kind: RiskKind
14  title: string
15  summary: string
16}
17
18export function classify(command: string): Risk | null {
19  const c = command
20  if (/\bmigrate:make\b/.test(c)) return null
21
22  if (/\bmigrate:down\b|\bmigrate:rollback\b|\bknex\s+migrate:rollback\b/.test(c)) {
23    return {
24      kind: 'migrate-down',
25      title: 'Rollback migrations',
26      summary: 'This rolls back Knex migrations against the current database.',
27    }
28  }
29
30  if (
31    /\b(?:pnpm|npm|yarn)\s+(?:run\s+)?migrate\b/.test(c) ||
32    /\bknex\s+migrate(?:\s|:latest)\b/.test(c)
33  ) {
34    return {
35      kind: 'migrate',
36      title: 'Run migrations',
37      summary: 'This runs database migrations against the current connection.',
38    }
39  }
40
41  if (/\bgit\s+push\b/.test(c) && /(?:\s|^)(?:--force|-f|--force-with-lease)(?:\s|=|$)/.test(c)) {
42    return {
43      kind: 'force-push',
44      title: 'Force-push',
45      summary: 'This force-pushes and can overwrite remote history.',
46    }
47  }
48
49  if (/\bgit\s+reset\b/.test(c) && /--hard\b/.test(c)) {
50    return {
51      kind: 'reset-hard',
52      title: 'Hard reset',
53      summary: 'This discards uncommitted work to match a commit.',
54    }
55  }
56
57  if (/\bgit\s+clean\b/.test(c)) {
58    return {
59      kind: 'git-clean',
60      title: 'Git clean',
61      summary: 'This deletes untracked files from the working tree.',
62    }
63  }
64
65  if (/\bDROP\s+(TABLE|DATABASE|SCHEMA)\b/i.test(c)) {
66    return {
67      kind: 'drop',
68      title: 'DROP statement',
69      summary: 'This drops database objects.',
70    }
71  }
72
73  if (/\bTRUNCATE\b/i.test(c)) {
74    return {
75      kind: 'truncate',
76      title: 'TRUNCATE statement',
77      summary: 'This empties database tables.',
78    }
79  }
80
81  if (/\brm\s+-[a-zA-Z]*[rf][a-zA-Z]*[rf]\b/.test(c)) {
82    return {
83      kind: 'rm-rf',
84      title: 'Recursive delete',
85      summary: 'This recursively deletes files.',
86    }
87  }
88
89  if (/(?:^|[\s;&|])(?:rm|unlink)\s+[^\n]*\.env(?:\b|['"])/.test(c)) {
90    return {
91      kind: 'env-delete',
92      title: 'Delete .env',
93      summary: 'This deletes an environment file that may hold secrets.',
94    }
95  }
96
97  return null
98}
99
100export function clip(text: string, max: number): string {
101  const t = text.replace(/\s+/g, ' ').trim()
102  if (t.length <= max) return t
103  if (max <= 1) return '…'
104  return `${t.slice(0, max - 1)}…`
105}
106
107export function linesOf(text: string, max = 8): string[] {
108  return text
109    .split('\n')
110    .map(line => line.trimEnd())
111    .filter(line => line.length > 0)
112    .slice(0, max)
113}
114
hooks/draw.ts 64 lines
1import { clip, type Risk } from './classify.ts'
2
3export type Node = unknown
4export type El = (props: Record<string, unknown>) => Node
5
6export type Hold = {
7  command: string
8  risk: Risk
9  report: string[]
10}
11
12export function drawHold(
13  Box: El,
14  Text: El,
15  Button: El,
16  hold: Hold,
17  args: {
18    bodyColumns: number
19    showButtons: boolean
20    onProceed?: () => void
21    onCancel?: () => void
22  },
23): Node {
24  const width = Math.max(24, args.bodyColumns - 4)
25  const kids: Node[] = [
26    Box({
27      flexDirection: 'row',
28      children: [
29        Text({ color: '#fc8181', children: '◆ ' }),
30        Text({ bold: true, children: hold.risk.title }),
31      ],
32    }),
33    Text({ dimColor: true, children: clip(hold.risk.summary, width) }),
34    Text({ children: clip(hold.command, width) }),
35  ]
36
37  for (const line of hold.report.slice(0, 10)) {
38    kids.push(Text({ dimColor: true, children: clip(line, width) }))
39  }
40
41  if (args.showButtons) {
42    kids.push(
43      Box({
44        flexDirection: 'row',
45        columnGap: 2,
46        marginTop: 1,
47        children: [
48          Button({ key: 'proceed', label: 'Proceed', hotkey: '1', onPress: args.onProceed ?? (() => undefined) }),
49          Button({ key: 'cancel', label: 'Cancel', hotkey: '2', onPress: args.onCancel ?? (() => undefined) }),
50        ],
51      }),
52    )
53  }
54
55  return Box({
56    flexDirection: 'column',
57    borderStyle: 'round',
58    borderColor: 'red',
59    paddingX: 1,
60    paddingY: 0,
61    children: kids,
62  })
63}
64