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

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.
Needs Claude Code 2.1.287+ (written against 2.1.290). If mods do not load, export CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1.
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:
/tui fullscreen so panes sit beside the transcript.skills 4 · agents 2 · CLAUDE.md · plugins …)./agents-side./session-map after Claude has read files. Rows group under apps/api, apps/web, apps/mobile, and root.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.| Plugin | Command | What it does |
|---|---|---|
| agents-side | /agents-side | Open or close the pane |
| agents-side | /agents-side clear | Drop finished agents |
| agents-side | /agents-side close | Close the pane |
| context-board | /context-board | Open or close the pane (the bar stays) |
| context-board | /context-board refresh | Recount every category with the token-count API |
| context-board | /context-board pane | Force-open the pane |
| context-board | /context-board close | Close the pane |
| session-map | /session-map | Open or close the file pane |
| session-map | /session-map clear | Drop the file list |
| session-map | /session-map close | Close the pane |
| blast-radius | /blast-radius | Re-open the hold pane if a command is waiting |
| blast-radius | /blast-radius close | Cancel 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.
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:
sec-default in prependPlugins.$(…) and scripts that call rm can still get past it).$.session.usage() may omit cost. The context card then shows tokens and the window only — it does not invent dollars.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.
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-radiushooks/register.ts 176 lines1import { 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}
176hooks/classify.ts 114 lines1export 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}
114hooks/draw.ts 64 lines1import { 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