SLOPSHOPPER

blast-radius

Holds risky shell commands (rm -rf, git reset --hard, git clean, force push, migrations) and shows what they would change, with Run anyway and Keep my work…

newpanebandguardtoastmodel
★ 2v?MITupdated 2026-10-16partalemre/MODZ/mods/blast-radius
A shopper browsing a rack in a slop shop
README

blast-radius

Holds a risky shell command before it runs and shows you what it would change, so you decide whether it goes ahead. It also holds an edit to a file another chat on this machine changed in the last half hour, and, when you write house rules, a command that may break one.

Install

/plugin marketplace add partalemre/MODZ
/plugin install blast-radius@modz

Or in one step: /plugin install blast-radius --marketplace partalemre/MODZ.

What you see

When Claude calls Bash with one of the commands below, the call stops and a card opens. It shows the command, one plain sentence about what it would throw away, a banner saying whether that can be undone, and the affected files grouped by folder with their sizes. Two buttons answer it:

  • Run anyway (r) lets the command run as written.
  • Keep my work (k) refuses it. It has the focus, so Enter is always the safe answer. Esc and the pane's close mark do the same, and a toast confirms nothing was thrown away.

d shows the rest of a long list. Claude is told the command was held and why, and is asked not to retry unless you say so.

CommandWhat the card lists
rm -r, rm -f, rm -rfThe files it would delete, with sizes, the total, and how many git tracks. Globs and ~ are expanded.
git reset --hardFiles with uncommitted changes, with added and removed lines.
git checkout -- ., git restore .Files with unstaged changes.
git cleanThe untracked paths it would remove (from git clean -n with the same flags).
git push --force (also -f, --force-with-lease, +ref)Commits on the remote branch that your HEAD lacks, which the push would drop.
manage.py migrate, db:migrate, alembic upgrade, prisma migrateThe pending migrations, from the tool's own status command.
any other migrateA note that pending migrations can't be listed for that tool.

Every other command runs as normal. The card follows cd dir &&, pushd/popd and git -C dir on the same line and measures in that folder.

On the desktop app the card is drawn as a picture; in the terminal it is text. In a terminal too narrow for a side pane (under about 144 columns) the card moves into the band above the prompt. Focus the band first (click it, or ctrl+x then tab); letters never answer from the prompt, so a stray keystroke can't delete anything.

House rules

Write the rules you want Claude to keep in .claude/house-rules.md at the project root, one per line or list item, in plain words:

- never touch the production database
- no network calls to *.internal

Headings, <!-- comments --> and blank lines are not rules. With the file there, each Bash command that the table above doesn't hold and that isn't a plain read is put to a judge with your rules as its instructions. Plain reads never are: ls, cat, head, tail, wc, pwd, echo, grep, rg, which, stat, file, tree, du, df, date, whoami, git status, git diff, git log, git show and git blame, alone or joined by pipes, && or ;, with no redirect, $(...) or --output. The judge answers one of three ways:

  • allow: the command runs.
  • ask: the command is held on an amber card that says which rule ("House rule: no network calls to *.internal"; "house rules" when the judge can't name one). Keep my work (k) has the focus; Run anyway (r) runs it. With nobody there to answer (claude -p, the SDK) the command is refused.
  • block: the command is refused, and Claude reads which rule it breaks.

The verdict for each exact command is kept for the session, and dropped when the rules change. If the judge can't answer (no model, a timeout, a reply it can't read), the command runs and a line goes to the debug log: a judge that failed never stops your work. Up to eight rules, the judge names the rule it matched; with more it answers by kind alone. Rules past 4,000 characters are not sent.

Token cost: at most one haiku call (effort low, a reply of at most 32 tokens) per Bash command that is neither held by the table nor a plain read, on your own account, and only while .claude/house-rules.md exists; a repeated command costs nothing. Without the file this costs nothing.

TypeSafe Jev (opt-in): set MODZ_JUDGE=jev and TYPESAFE_API_KEY in the env block of ~/.claude/settings.json and the judge asks TypeSafe Jev instead of the model. The command and your rules are then sent to api.typesafe.ai, after KEY=value assignments with secret-looking names, --token/--password values, URL passwords, bearer tokens and long key-like strings are masked. Without both variables nothing is sent there.

Another chat's edit

When two chats work in the same files, the second one can write over what the first just changed. Each edit that lands (Write, Edit, NotebookEdit, and MultiEdit where the build has it) is noted by the file's real path, so a symlink and its target count as one file. If Claude is about to edit a file that another chat on this machine edited in the last 30 minutes, the edit is held on an amber card:

  • The headline says which file and when ("Another chat edited client.ts 12 min ago"); the right of the card names that chat's project folder.
  • The file is listed with its uncommitted lines from git diff --numstat, when it is in git and has any.
  • Skip this edit (s) has the focus: the edit is refused and Claude is told another chat may still be working on the file. Edit anyway (e) lets it through.

With nobody there to answer (claude -p, the SDK) the edit goes ahead and a line goes to the debug log. If the guard itself fails, the edit goes ahead too: this is a caution, not a lock.

The notes live in the plugin's own store under ~/.claude, one entry per chat, so two chats never write over each other's. An entry holds at most 200 paths and drops edits older than 30 minutes; a chat removes its entry when it ends, and an entry left by a chat that crashed is removed after a day.

Limits

  • It reads the command text and does not parse shell fully. $(...), aliases, eval, bash -c "...", xargs rm, find -delete, scripts that call rm, and wrappers such as timeout 5 rm or doas rm are not caught.
  • Only the first risky part of a command line is measured. Run anyway runs the whole line as written.
  • One command is held at a time; a second risky call (a subagent's) waits for the first answer.
  • If nobody answers within 10 minutes, or you interrupt the turn, the command is refused. If the hook itself fails, a risky command is refused too.
  • Only Bash commands and file edits are watched. Other tools are not held.
  • The edit guard sees chats on this machine only, and only edits made through Claude's file tools; a file another chat changed with a shell command is not noted.
  • Counts are approximate. Sizes come from du -k and wc -c; a very large tree can take a few seconds to measure.
  • The force-push list uses your last fetch of the remote branch; without one it says it can't list the dropped commits.
  • Migration status commands (for example python3 manage.py showmigrations) load your project's code before you answer.
  • Needs bash, git, find and du on your PATH.
  • This is a safety net, not a permission system. Use permission rules for a hard block.

Without .claude/house-rules.md it uses no model tokens. It has no settings beyond the two Jev variables above.

Language

The interface is English. Set MODZ_LANG=tr in the env block of ~/.claude/settings.json for Turkish.

Credits

Adapted from anthropics/claude-code-playground (claude-code/mods/blast-radius), licensed Apache-2.0. The license file is in this folder.

Source 26 files
hooks/register.tsx 221 lines
1// Copyright 2026 Anthropic PBC
2// SPDX-License-Identifier: Apache-2.0
3//
4// Blast Radius: holds a risky Bash command and shows what it would change.
5//
6// tool.call (Bash): if the command is risky, work out its blast radius, open a
7// pane with Run anyway and Keep my work, and hold the call until one is pressed.
8// A command that would change nothing (no such file, no changes) runs with no card.
9// Any other command is weighed against the person's house rules, when they wrote
10// some (house.ts). Edits another chat just made are guarded in collide.ts.
11// ui.render (Pane): draws the card. If the surface won't place the pane (a
12// narrow terminal), the same card is drawn in the AbovePrompt band, above the
13// other mods' rows.
14//
15// Anything unexpected while holding a risky command refuses it (hold.ts).
16//
17// The host reads `on(...)` and `$.noun.method(...)` from source, so `$` stays in
18// the hooks modules; the measuring modules get a `run` closure instead.
19
20import type { EngineInterface, Register } from 'claude-code'
21
22import { desktopCard, terminalCard } from './card'
23import type { CardProps } from './card'
24import { classify } from './classify'
25import type { Risk } from './classify'
26import { editHooks } from './collide'
27import { PANE_ID, SLEEP_TIMEOUT_MS, closeHeld, current, hold, take } from './hold'
28import type { Decision, Held, HoldIo } from './hold'
29import { houseHooks, resetHouse } from './house'
30import { setLang } from './lang'
31import { measure, missingFolder } from './measure'
32import type { Report, Run } from './report'
33import { pinned } from './pin'
34import { resolveLight } from './theme'
35import { viewOf } from './view'
36import { words } from './words'
37
38const CD_TIMEOUT_MS = 5000
39
40const theme = { isLight: false }
41
42// The hold loop's calls, spelled here: the engine follows `$` only within one file.
43const holdIo = ($: EngineInterface): HoldIo => ({
44  sleep: seconds => $.process.run(['sleep', seconds], { timeoutMs: SLEEP_TIMEOUT_MS }),
45  now: () => $.clock.now(),
46  open: async rows => (await $.ui.open({ id: PANE_ID, title: 'Blast Radius', focus: true, closeOnEscape: true, rows })).isPlaced,
47  close: () => $.ui.close({ id: PANE_ID }),
48  redraw: () => $.ui.invalidate('ui.render'),
49})
50
51const WHY: Record<Exclude<Decision, 'proceed' | 'cancel'>, string> = {
52  closed: 'the user closed the Blast Radius pane',
53  timeout: 'no answer within 10 minutes',
54  interrupted: 'the turn was interrupted',
55  error: 'Blast Radius hit an error while holding it',
56}
57
58const refusal = (why: string, summary: string): { deny: string } => ({
59  deny: `Blast Radius held this command and did not run it: ${why}. It would have: ${summary}. Do not retry it unless the user asks you to.`,
60})
61
62function whyOf(decision: Decision | null): string {
63  if (decision === 'cancel') return words().cancelWhy
64  if (decision === null || decision === 'proceed') return WHY.error
65
66  return WHY[decision]
67}
68
69// Resolves a `cd` target to an absolute folder, or null if it doesn't exist.
70// The target is passed as an argument, never as source.
71const CD_SCRIPT = `unset CDPATH; d="$1"; case "$d" in "~") d="$HOME";; "~/"*) d="$HOME/\${d#\\~/}";; esac; cd -- "$d" 2>/dev/null && pwd -P`
72
73async function resolveDir($: EngineInterface, sessionCwd: string, dir: string): Promise<string | null> {
74  if (dir === '-') return null // `cd -` depends on the shell's history
75  const ran = await $.process.run(['bash', '-c', CD_SCRIPT, 'blast-radius', dir], { cwd: sessionCwd, timeoutMs: CD_TIMEOUT_MS })
76  const out = ran.stdout.trim()
77
78  return ran.exitCode === 0 && out !== '' ? out : null
79}
80
81type Risky = { mine: Held; risk: Risk; summary: string }
82
83/** Where the command will run (the session folder, moved by `cd dir &&` or `git -C dir`), and what it would change there. */
84async function survey($: EngineInterface, risky: Risky): Promise<boolean> {
85  const { mine, risk } = risky
86  const sessionCwd = await $.session.cwd()
87  const dir = risk.dir ? await resolveDir($, sessionCwd, risk.dir) : sessionCwd
88  const run: Run = (argv, options) => $.process.run(argv, options)
89  const cwd = dir ?? sessionCwd
90  const report: Report = dir === null ? missingFolder(risk) : await measure(run, risk, dir)
91  risky.summary = report.summary
92  mine.view = isExpanded => viewOf({ command: mine.command, risk, report, cwd, isExpanded })
93
94  return !report.isEmpty
95}
96
97async function refreshTheme($: EngineInterface): Promise<void> {
98  const rows = await $.config.list()
99  const hints = { override: await $.env.get('MODZ_THEME'), theme: rows.find(row => row.key === 'theme')?.value, colorfgbg: await $.env.get('COLORFGBG') }
100  if (resolveLight(hints) === resolveLight({ ...hints, systemDark: false })) {
101    theme.isLight = resolveLight(hints)
102
103    return
104  }
105  const mac = await $.process.run(['defaults', 'read', '-g', 'AppleInterfaceStyle'], { timeoutMs: 2000 })
106  theme.isLight = resolveLight({ ...hints, systemDark: mac.exitCode === 0 ? mac.stdout.trim() === 'Dark' : false })
107}
108
109type Ready = Held & { view: NonNullable<Held['view']> }
110type DrawInput = { ui: Record<string, any>; surface: string; columns: number; place: Held['where']; redraw: () => void }
111
112function draw(mine: Ready, input: DrawInput) {
113  // The buttons answer the call this card was drawn for, never whichever one is held now.
114  const decide = (choice: Decision) => () => {
115    if (mine.decision === null) mine.decision = choice
116  }
117  const props: CardProps = {
118    view: mine.view(mine.isExpanded),
119    phase: mine.phase,
120    place: input.place,
121    isExpanded: mine.isExpanded,
122    isLight: theme.isLight,
123    columns: input.columns,
124    onRun: decide('proceed'),
125    onCancel: decide('cancel'),
126    onToggle: () => {
127      mine.isExpanded = !mine.isExpanded
128      input.redraw()
129    },
130  }
131
132  return input.surface !== 'terminal' && 'Svg' in input.ui ? desktopCard(input.ui, props) : terminalCard(input.ui, props)
133}
134
135const isReady = (mine: Held | null): mine is Ready => mine !== null && mine.view !== null
136
137/** Once the person kept their work, a line says nothing was lost. */
138function sayKept($: EngineInterface, decision: Decision | null): void {
139  if (decision === 'cancel' || decision === 'closed') $.ui.toast(words().kept)
140}
141
142type On = Parameters<Register>[0]
143
144function holdHooks(on: On): void {
145  on('session.start', async ($, e, next) => {
146    setLang(await $.env.get('MODZ_LANG').catch(() => undefined))
147    resetHouse()
148    try {
149      await refreshTheme($)
150    } catch {
151      $.ui.log('blast-radius: could not read the theme, drawing dark', { to: 'debug' })
152    }
153
154    return next(e)
155  })
156
157  on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
158    const command = String(e.command ?? '')
159    const risk = classify(command)
160    if (risk === null) return next(e)
161    const mine = await take(holdIo($), { command, signal: next.signal, canStrike: true })
162    if (mine === null) return refusal(WHY.interrupted, risk.label)
163    const risky: Risky = { mine, risk, summary: risk.label }
164    await hold(holdIo($), mine, { signal: next.signal, prepare: () => survey($, risky) })
165    if (mine.decision === 'proceed') return next(e)
166    sayKept($, mine.decision)
167
168    return refusal(whyOf(mine.decision), risky.summary)
169  }).catch(($, e, next) => {
170    // Fail closed: a hook that threw, overran or was re-entered refuses a risky command.
171    if (String(next.error.kind) !== 're-entry' && next.called) return next(e)
172    if (classify(String(e.command ?? '')) === null) return next(e)
173
174    return refusal(WHY.error, 'a risky command')
175  })
176}
177
178function drawHooks(on: On): void {
179  on('ui.close', ($, e, next) => {
180    // Closing the pane (Esc, its close mark) keeps the work, as Keep my work does.
181    if (e.id === PANE_ID && e.origin.kind === 'person') closeHeld()
182
183    return next(e)
184  })
185
186  // Only its own pane waits out a focus flip: every other mod's pane passes through this hook too.
187  on('ui.render', { component: 'Pane' }, async ($, e, next) => {
188    const mine = current()
189    if (e.requestId !== PANE_ID || !isReady(mine)) return next(e)
190    const redraw = () => $.ui.invalidate('ui.render')
191    const ui = $.ui.resolve(e)
192
193    return pinned(e, draw(mine, { ui, surface: e.surface, columns: e.props.bodyColumns, place: 'pane', redraw }))
194  })
195
196  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
197    const mine = current()
198    if (!isReady(mine) || mine.where !== 'band' || e.props.hasSurvey) return next(e)
199    const theirs = await next(e)
200    const ui = $.ui.resolve(e)
201    const { Box } = ui
202    const redraw = () => $.ui.invalidate('ui.render')
203    const card = pinned(e, draw(mine, { ui, surface: e.surface, columns: e.props.bodyColumns, place: 'band', redraw }))
204
205    // A decision card goes above the other mods' rows (DESIGN.md §6).
206    return (
207      <Box flexDirection="column">
208        {card}
209        {theirs}
210      </Box>
211    )
212  })
213}
214
215export const register: Register = on => {
216  holdHooks(on)
217  houseHooks(on)
218  editHooks(on)
219  drawHooks(on)
220}
221
hooks/card.tsx 205 lines
1// The confirm card, drawn the same in the pane and in the band: Svg parts on desktop,
2// the same words as text on the terminal. The safe answer (Keep my work) holds the focus.
3// A danger card has a single Flare frame; a caution card a round Amber one (DESIGN.md §4).
4
5import { paneMotion } from './kit/moving'
6import type { MoveOpts, Pic } from './kit/moving'
7import { upper } from './lang'
8import { headerSvg, bannerSvg, listSvg } from './svg-card'
9import { palette, role, TERMINAL_KEY } from './theme'
10import type { Row, View } from './view'
11import { words } from './words'
12
13export type Ui = Record<string, any>
14
15export type CardProps = {
16  view: View
17  // `striking`: Delete was pressed; the rows are struck and the command is about to run.
18  phase: 'asking' | 'striking'
19  place: 'pane' | 'band'
20  isExpanded: boolean
21  isLight: boolean
22  columns: number
23  onRun: () => void
24  onCancel: () => void
25  onToggle: () => void
26}
27
28const CARD_CHROME = 6
29const PX_PER_COLUMN = 8
30const MIN_WIDTH = 300
31const MAX_WIDTH = 720
32const BAND_ROWS = 3
33const TERMINAL_BAR = 10
34const FRAME: Record<View['tone'], string> = { danger: 'single', warn: 'round' }
35const STATUS_KEY: Record<string, keyof typeof TERMINAL_KEY> = { A: 'ok', M: 'warn', D: 'danger', R: 'read', C: 'read', U: 'danger' }
36
37export const svgWidth = (columns: number): number => Math.max(MIN_WIDTH, Math.min(MAX_WIDTH, (columns - CARD_CHROME) * PX_PER_COLUMN))
38
39/** The band shows the first rows only, and says how many it left for the pane. */
40function bandRows(rows: Row[]): Row[] {
41  if (rows.length <= BAND_ROWS + 1) return rows
42  const last = rows[rows.length - 1]
43  const more = last?.kind === 'more' ? last.name : words().moreRows(rows.length - BAND_ROWS)
44
45  return [...rows.slice(0, BAND_ROWS), { kind: 'more', name: more, tag: '', count: '', size: '', share: null, icon: 'file' }]
46}
47
48function toggleOf(ui: Ui, props: CardProps) {
49  const { Box, Button } = ui
50  if (props.place === 'band' || props.view.toggleCount <= 0 || props.phase === 'striking') return null
51  const label = props.isExpanded ? words().showLess : words().showMore
52
53  return (
54    <Box key="toggle">
55      <Button key="toggle" label={label} hotkey="d" plain dimColor onPress={props.onToggle} />
56    </Box>
57  )
58}
59
60function actions(ui: Ui, props: CardProps, lo: string | undefined) {
61  const { Box, Button, Text } = ui
62  const said = words()
63  if (props.phase === 'striking') {
64    const doing = said.doing(props.view.icon === 'trash')
65
66    return <Text key="doing" color={TERMINAL_KEY.danger}>{doing}</Text>
67  }
68
69  return (
70    <Box key="actions" flexDirection="row" flexWrap="wrap" gap={1} alignItems="center">
71      <Button key="run" label={props.view.action.label} hotkey={props.view.action.hotkey} variant="secondary" onPress={props.onRun} />
72      <Button key="cancel" label={props.view.cancel.label} hotkey={props.view.cancel.hotkey} variant="secondary" autoFocus onPress={props.onCancel} />
73      {props.place === 'pane' ? null : <Box key="holder" marginLeft={1}><Text {...(lo === undefined ? { dimColor: true } : { color: lo })}>{said.holder}</Text></Box>}
74    </Box>
75  )
76}
77
78// The pane's drawings on the desktop (kit/moving.ts): reset as the pane opens, so they cascade in each time. The
79// band above the prompt is no pane, so its drawings stay plain.
80export const motion = paneMotion()
81
82function picture(ui: Ui, props: CardProps, drawing: { key: string; pic: Pic & { isInteractive?: boolean }; opts?: MoveOpts }) {
83  const { Svg } = ui
84  const { key, pic, opts } = drawing
85
86  return props.place === 'pane' ? <Svg key={key} {...motion.pic(key, pic, opts)} /> : <Svg key={key} {...pic} />
87}
88
89export function desktopCard(ui: Ui, props: CardProps) {
90  const { Box } = ui
91  const width = svgWidth(props.columns)
92  const { view } = props
93  const neutral = palette(props.isLight)
94  const rows = props.place === 'band' ? bandRows(view.rows) : view.rows
95  const head = headerSvg(view, width)
96  const banner = view.banner === null ? null : bannerSvg(view.banner, width)
97  // The strike plays its own motion as Delete runs, so the list sweeps only when its rows change (Show more).
98  const isStruck = props.phase === 'striking'
99  const list = listSvg(rows, width, isStruck)
100  const headAlt = view.headline ?? [view.command, `${view.payload}${view.tail}`].filter(Boolean).join(' · ')
101  const alt = rows.map(row => [row.name, row.count, row.size].filter(Boolean).join(' ')).join('; ')
102
103  return (
104    <Box flexDirection="column" borderStyle={FRAME[view.tone]} borderColor={role(view.tone, props.isLight).line} backgroundColor={neutral.card} paddingX={2} paddingY={1} gap={1}>
105      {picture(ui, props, { key: 'head', pic: { source: head.source, alt: headAlt, width, height: head.height } })}
106      {banner === null ? null : picture(ui, props, { key: 'banner', pic: { source: banner.source, alt: view.banner?.text ?? '', width, height: banner.height } })}
107      {rows.length === 0 ? null : picture(ui, props, { key: 'list', pic: { source: list.source, alt, width, height: list.height, isInteractive: isStruck ? true : undefined }, opts: { shows: { rows, width } } })}
108      {toggleOf(ui, props)}
109      {actions(ui, props, neutral.lo)}
110    </Box>
111  )
112}
113
114const barText = (share: number | null): string => {
115  if (share === null) return ''
116  const filled = Math.max(share > 0 ? 1 : 0, Math.round(share * TERMINAL_BAR))
117
118  return '━'.repeat(filled) + '─'.repeat(TERMINAL_BAR - filled)
119}
120
121/** `A`/`M`/`D` in their role colour, a commit hash dim. */
122function tagText(ui: Ui, tag: string) {
123  const { Text } = ui
124  const role = tag.length <= 2 ? STATUS_KEY[tag.charAt(0)] : undefined
125
126  return role === undefined ? <Text dimColor>{`${tag} `}</Text> : <Text bold color={TERMINAL_KEY[role]}>{`${tag} `}</Text>
127}
128
129const DIFF = /^\+(\d+) −(\d+)$/
130
131/** `+61 −12` with the added count in success and the removed one in error; zeros and sizes stay dim. */
132function sizeText(ui: Ui, row: Row) {
133  const { Text } = ui
134  const isFolder = row.kind === 'folder'
135  const diff = DIFF.exec(row.size)
136  if (diff === null) return <Text bold={isFolder} dimColor={!isFolder}>{row.size}</Text>
137  const part = (text: string, count: string | undefined, key: keyof typeof TERMINAL_KEY) =>
138    count === '0' ? <Text dimColor>{text}</Text> : <Text color={TERMINAL_KEY[key]}>{text}</Text>
139
140  return <Text>{part(`+${diff[1]}`, diff[1], 'ok')}{part(` −${diff[2]}`, diff[2], 'danger')}</Text>
141}
142
143const TERMINAL_INDENT: Record<Row['kind'], string> = { file: '    ', more: '', folder: '▸ ', item: '▸ ' }
144
145function terminalRow(ui: Ui, row: Row, place: { index: number; isStruck: boolean }) {
146  const { Box, Text } = ui
147  const { index, isStruck } = place
148  const isFolder = row.kind === 'folder'
149  const strike = isStruck && !isFolder ? { strikethrough: true, dimColor: true } : {}
150  const indent = TERMINAL_INDENT[row.kind]
151
152  return (
153    <Box key={`r${index}`} flexDirection="row" gap={2}>
154      <Box flexGrow={1}>
155        <Text wrap="truncate-middle" bold={isFolder} dimColor={row.kind === 'more'} {...strike}>
156          {indent}
157          {row.tag === '' ? null : tagText(ui, row.tag)}
158          {row.name}
159        </Text>
160      </Box>
161      {row.count === '' ? null : <Text dimColor>{row.count}</Text>}
162      {row.size === '' ? null : sizeText(ui, row)}
163      {row.share === null ? null : <Text dimColor={!isFolder}>{barText(row.share)}</Text>}
164    </Box>
165  )
166}
167
168/** The command and what it does, or the card's own sentence when it holds something else. */
169function headlineText(ui: Ui, view: View) {
170  const { Text } = ui
171  if (view.headline !== null) return <Text bold wrap="wrap">{view.headline}</Text>
172
173  return (
174    <Text wrap="truncate-end">
175      <Text bold>{view.command}</Text>
176      {view.payload === '' ? null : <Text dimColor> · </Text>}
177      {view.payload === '' ? null : <Text bold {...(view.isPayloadEmpty ? { dimColor: true } : { color: TERMINAL_KEY.danger })}>{view.payload}</Text>}
178      {view.tail}
179    </Text>
180  )
181}
182
183export function terminalCard(ui: Ui, props: CardProps) {
184  const { Box, Text } = ui
185  const { view } = props
186  const rows = props.place === 'band' ? bandRows(view.rows) : view.rows
187  const tone = TERMINAL_KEY[view.tone]
188  const frame = props.place === 'band' ? { borderStyle: FRAME[view.tone], borderColor: tone } : {}
189
190  return (
191    <Box flexDirection="column" paddingX={1} {...frame}>
192      <Box flexDirection="row" justifyContent="space-between" gap={2}>
193        <Text bold color={tone}>{`● ${upper(view.eyebrow)}`}</Text>
194        {view.context === '' ? null : <Text dimColor wrap="truncate-start">{view.context}</Text>}
195      </Box>
196      {headlineText(ui, view)}
197      {view.fullCommand === null ? null : <Text dimColor wrap="truncate-end">{words().fullCommand(view.fullCommand)}</Text>}
198      {view.banner === null ? null : <Text color={TERMINAL_KEY[view.banner.tone]}>{`! ${view.banner.text}`}</Text>}
199      <Box flexDirection="column" marginTop={1}>{rows.map((row, i) => terminalRow(ui, row, { index: i, isStruck: props.phase === 'striking' }))}</Box>
200      {toggleOf(ui, props)}
201      <Box marginTop={1}>{actions(ui, props, undefined)}</Box>
202    </Box>
203  )
204}
205
hooks/classify.ts 190 lines
1// Copyright 2026 Anthropic PBC
2// SPDX-License-Identifier: Apache-2.0
3//
4// What counts as risky: reads a shell command line and returns its first risky
5// segment, following `cd`, `pushd`, `popd` and `git -C` to where it would run.
6
7export type GitKind = 'git-reset' | 'git-clean' | 'git-push-force' | 'git-checkout'
8
9export type GitRisk = { kind: GitKind; label: string; args: string[]; dir: string | null }
10
11export type Risk =
12  | { kind: 'rm'; label: string; targets: string[]; dir: string | null }
13  | GitRisk
14  | { kind: 'migrate'; tool: string; label: string; dir: string | null }
15
16// A segment that only moves the folder: where to, null for the session folder.
17type Move = { cd: string | null }
18
19type Step = Risk | Move | null
20
21// sudo options that take a value, so the value isn't read as the command.
22const SUDO_VALUE_OPTIONS = new Set(['-u', '-g', '-C', '-D', '-h', '-p', '-r', '-t', '-T', '-U'])
23// Commands that only read, so a bare word "migrate" in them isn't a migration.
24const READ_ONLY = new Set(['ls', 'cat', 'echo', 'printf', 'grep', 'rg', 'find', 'less', 'head', 'tail', 'cd', 'git'])
25// Words that can come before the real command without changing what it does.
26const PREFIXES = new Set(['command', 'exec', 'env', 'nohup', 'time', 'then', 'do', 'else', '!'])
27const ASSIGNMENT = /^[A-Za-z_][A-Za-z0-9_]*=/
28
29const isMove = (step: Step): step is Move => step !== null && 'cd' in step
30
31/** A folder a later `cd arg` moves to, given the folder so far (null = the session folder). */
32export function joinDir(dir: string | null, arg: string | undefined): string {
33  if (arg === undefined || arg === '~' || arg.startsWith('/') || arg.startsWith('~/')) {
34    return arg ?? '~'
35  }
36
37  return dir ? `${dir}/${arg}` : arg
38}
39
40/** Splits one segment into words, honouring quotes. Good enough to read flags and paths. */
41export function tokenize(text: string): string[] {
42  const words: string[] = []
43  for (const match of text.matchAll(/"((?:[^"\\]|\\.)*)"|'([^']*)'|(\S+)/g)) {
44    words.push(match[1] ?? match[2] ?? match[3] ?? '')
45  }
46
47  return words
48}
49
50/** `NAME=value` segments (or `export NAME=value`) set earlier on the line, so `rm -rf $NAME` can be measured. */
51function readAssignments(segment: string, vars: Map<string, string>): void {
52  const words = tokenize(segment.trim())
53  if (words[0] === 'export') words.shift()
54  if (words.length === 0 || !words.every(word => ASSIGNMENT.test(word))) return
55  for (const word of words) {
56    const at = word.indexOf('=')
57    vars.set(word.slice(0, at), expandVars(word.slice(at + 1), vars))
58  }
59}
60
61/** `$NAME` and `${NAME}` from earlier assignments on the line; anything else stays as written. */
62export function expandVars(text: string, vars: Map<string, string>): string {
63  return text.replace(/\$(?:\{([A-Za-z_][A-Za-z0-9_]*)\}|([A-Za-z_][A-Za-z0-9_]*))/g, (whole, braced, bare) => vars.get(braced ?? bare) ?? whole)
64}
65
66/** The first risky segment of a shell command, or null. */
67export function classify(command: string): Risk | null {
68  let dir: string | null = null
69  const scopes: (string | null)[] = [] // dir to restore when a ( subshell ) closes
70  const pushed: (string | null)[] = [] // pushd stack, for popd
71  const vars = new Map<string, string>()
72  for (const raw of command.split(/&&|\|\||;|\||\n/)) {
73    readAssignments(raw, vars)
74    const opens = raw.match(/^\s*\(+/)?.[0].trim().length ?? 0
75    // Trailing redirects and & don't hide a closing ) : `(cd sub && make) > log`.
76    const tail = raw.replace(/(?:\s*(?:\d*>>?|&>>?|<)\s*\S+|\s*&)+\s*$/, '')
77    const closes = tail.match(/\)+\s*$/)?.[0].trim().length ?? 0
78    for (let k = 0; k < opens; k += 1) scopes.push(dir)
79    const step = classifySegment(expandVars(raw, vars), dir, pushed)
80    if (step !== null && !isMove(step)) return step
81    if (isMove(step)) dir = step.cd // a cd, pushd or popd moved the folder
82    for (let k = 0; k < closes && scopes.length > 0; k += 1) dir = scopes.pop() ?? null // a cd inside ( ... ) doesn't outlive it
83  }
84
85  return null
86}
87
88/** The words of a segment with sudo, VAR=value, nice and harmless prefixes taken off. */
89function commandWords(segment: string): string[] {
90  const words = tokenize(segment.trim().replace(/^[({]+\s*/, '').replace(/\s*[)}]+$/, ''))
91  while (words.length > 0 && ASSIGNMENT.test(words[0] ?? '')) words.shift()
92  if (words[0] === 'sudo') {
93    words.shift()
94    while (words.length > 0 && (words[0] ?? '').startsWith('-')) {
95      const option = words.shift() ?? ''
96      if (SUDO_VALUE_OPTIONS.has(option)) words.shift()
97    }
98  }
99  while (words.length > 0 && (PREFIXES.has(words[0] ?? '') || ASSIGNMENT.test(words[0] ?? ''))) words.shift()
100  if (words[0] === 'nice') {
101    words.shift()
102    const option: string = words[0] ?? ''
103    if (option === '-n') words.splice(0, 2)
104    else if (/^-\d+$/.test(option)) words.shift()
105  }
106
107  return words
108}
109
110/** One segment: a risk, a folder change, or null. */
111function classifySegment(segment: string, dir: string | null, pushed: (string | null)[]): Step {
112  const words = commandWords(segment)
113  const [first, ...args] = words
114  if (first === undefined) return null
115  const cmd = first.replace(/^\\/, '') // \rm skips aliases; it's still rm
116  if (cmd === 'cd') return { cd: args[0] === '-' ? '-' : joinDir(dir, args[0]) }
117  if (cmd === 'pushd') {
118    pushed.push(dir)
119
120    return { cd: joinDir(dir, args[0]) }
121  }
122  if (cmd === 'popd') return { cd: pushed.length > 0 ? (pushed.pop() ?? null) : '-' }
123  if (cmd === 'rm' || cmd.endsWith('/rm')) {
124    const risk = classifyRm(args, dir)
125    if (risk !== null) return risk
126  }
127  if (cmd === 'git') {
128    const risk = classifyGit(args, dir)
129    if (risk !== null) return risk
130  }
131
132  return classifyMigrate(words, cmd, dir)
133}
134
135function classifyRm(args: string[], dir: string | null): Risk | null {
136  const flags = args.filter(a => a.startsWith('-'))
137  const recursive = flags.some(f => f === '--recursive' || (/^-[^-]/.test(f) && /[rR]/.test(f)))
138  const force = flags.some(f => f === '--force' || (/^-[^-]/.test(f) && f.includes('f')))
139  if (!recursive && !force) return null
140  const targets = args.filter(a => !a.startsWith('-') || a === '-')
141
142  return { kind: 'rm', label: `rm ${flags.join(' ')}`.trim(), targets, dir }
143}
144
145/** Git's own options come before the subcommand; -C moves where it runs. */
146function gitSubcommand(args: string[], dir: string | null): { at: number; dir: string | null } {
147  let gitDir = dir
148  let at = 0
149  while (at < args.length && (args[at] ?? '').startsWith('-')) {
150    if (args[at] === '-C' && at + 1 < args.length) {
151      gitDir = joinDir(gitDir, args[at + 1])
152      at += 2
153    } else if (args[at] === '-c' && at + 1 < args.length) {
154      at += 2
155    } else {
156      at += 1
157    }
158  }
159
160  return { at, dir: gitDir }
161}
162
163function classifyGit(args: string[], dir: string | null): Risk | null {
164  const found = gitSubcommand(args, dir)
165  const sub = args[found.at]
166  const rest = args.slice(found.at + 1)
167  const at = found.dir
168  if (sub === 'reset' && rest.includes('--hard')) return { kind: 'git-reset', label: 'git reset --hard', args: rest, dir: at }
169  if (sub === 'clean') return { kind: 'git-clean', label: 'git clean', args: rest, dir: at }
170  const isForce = rest.some(a => a === '--force' || a === '-f' || a.startsWith('--force-with-lease') || /^\+/.test(a))
171  if (sub === 'push' && isForce) return { kind: 'git-push-force', label: 'git push --force', args: rest, dir: at }
172  const isStagedOnly = sub === 'restore' && rest.includes('--staged') && !rest.includes('--worktree') && !rest.includes('-W')
173  if ((sub === 'checkout' || sub === 'restore') && rest.includes('.') && !isStagedOnly) {
174    return { kind: 'git-checkout', label: `git ${sub} -- .`, args: rest, dir: at }
175  }
176
177  return null
178}
179
180function classifyMigrate(words: string[], cmd: string, dir: string | null): Risk | null {
181  const joined = words.join(' ')
182  if (/\balembic\s+upgrade\b/.test(joined)) return { kind: 'migrate', tool: 'alembic', label: 'alembic upgrade', dir }
183  if (/\bdb:migrate(?!:status\b)/.test(joined)) return { kind: 'migrate', tool: 'rails', label: 'db:migrate', dir }
184  if (/\bprisma\s+migrate\b/.test(joined)) return { kind: 'migrate', tool: 'prisma', label: 'prisma migrate', dir }
185  if (/\bmanage\.py\s+migrate\b/.test(joined)) return { kind: 'migrate', tool: 'django', label: 'manage.py migrate', dir }
186  if (!READ_ONLY.has(cmd) && words.slice(1).includes('migrate')) return { kind: 'migrate', tool: 'unknown', label: 'migrate', dir }
187
188  return null
189}
190
hooks/collide.ts 142 lines
1// The collision guard: an edit to a file another chat on this machine changed within the last
2// half hour is held on an Amber card, "Skip this edit" in focus. Every edit that lands is logged
3// under this session's own store key (edits.ts) for the others to see. With nobody there to
4// answer (a -p run, the SDK) the edit goes ahead: a caution never stalls a headless run.
5
6import type { EngineInterface, Register } from 'claude-code'
7
8import { clashView, minutesSince } from './caution'
9import type { LineCount } from './caution'
10import { editsKey, findClash, recordEdit } from './edits'
11import type { Edit, EditStore } from './edits'
12import { PANE_ID, SLEEP_TIMEOUT_MS, hold, take } from './hold'
13import type { Decision, Held, HoldIo } from './hold'
14import { parseNumstat } from './measure-git'
15import { words } from './words'
16
17type On = Parameters<Register>[0]
18
19// MultiEdit is matched by name for builds that still have it.
20const EDIT_TOOLS = /^(Write|Edit|MultiEdit|NotebookEdit)$/
21const DIFF_TIMEOUT_MS = 5000
22
23const WHY: Record<Exclude<Decision, 'proceed' | 'cancel' | 'error'>, string> = {
24  closed: 'the user closed the Blast Radius pane',
25  timeout: 'no answer within 10 minutes',
26  interrupted: 'the turn was interrupted',
27}
28
29// The hold loop's calls, spelled here: the engine follows `$` only within one file.
30const holdIo = ($: EngineInterface): HoldIo => ({
31  sleep: seconds => $.process.run(['sleep', seconds], { timeoutMs: SLEEP_TIMEOUT_MS }),
32  now: () => $.clock.now(),
33  open: async rows => (await $.ui.open({ id: PANE_ID, title: 'Blast Radius', focus: true, closeOnEscape: true, rows })).isPlaced,
34  close: () => $.ui.close({ id: PANE_ID }),
35  redraw: () => $.ui.invalidate('ui.render'),
36})
37
38// `$` stays in this file: the store logic gets closures over it.
39const storeOf = ($: EngineInterface): EditStore => ({
40  get: key => $.store.get(key),
41  set: (key, value) => $.store.set(key, value),
42  delete: key => $.store.delete(key),
43  keys: () => $.store.keys(),
44})
45
46function editPath(e: unknown): string {
47  const args = e as Record<string, unknown>
48  const path = args.file_path ?? args.notebook_path
49
50  return typeof path === 'string' ? path : ''
51}
52
53const baseName = (path: string): string => path.split('/').filter(Boolean).pop() ?? path
54
55/** Where the path lands, so two spellings of one file (a symlink, `./`) meet; a new file by its folder's. */
56async function realPathOf($: EngineInterface, given: string): Promise<string> {
57  const path = given.startsWith('/') ? given : `${await $.session.cwd()}/${given}`
58  const own = await $.fs.stat(path, { resolve: true }).catch(() => undefined)
59  if (own?.realPath !== undefined) return own.realPath
60  const cut = path.lastIndexOf('/')
61  const folder = cut < 0 ? '.' : path.slice(0, cut + 1)
62  const dir = await $.fs.stat(folder, { resolve: true }).catch(() => undefined)
63
64  return dir?.realPath === undefined ? path : `${dir.realPath.replace(/\/$/, '')}/${path.slice(cut + 1)}`
65}
66
67/** Lines the file has uncommitted now, or null outside git or when it has none. */
68async function diffOf($: EngineInterface, real: string): Promise<LineCount | null> {
69  const folder = real.slice(0, Math.max(1, real.lastIndexOf('/')))
70  const ran = await $.process.run(['git', 'diff', '--numstat', '--', real], { cwd: folder, timeoutMs: DIFF_TIMEOUT_MS })
71  if (ran.exitCode !== 0) return null
72  const [first] = parseNumstat(ran.stdout).values()
73
74  return first ?? null
75}
76
77type Clash = { clash: Edit; real: string; signal: AbortSignal }
78
79async function prepare($: EngineInterface, mine: Held, input: Clash): Promise<boolean> {
80  const cwd = await $.session.cwd()
81  const shown = input.real.startsWith(`${cwd}/`) ? input.real.slice(cwd.length + 1) : input.real
82  const card = { clash: input.clash, now: await $.clock.now(), shown, diff: await diffOf($, input.real) }
83  mine.view = () => clashView(card)
84
85  return true
86}
87
88/** Asks the person; a deny when they skip it, null when the edit goes ahead. */
89async function settle($: EngineInterface, input: Clash): Promise<{ deny: string } | null> {
90  const minutes = minutesSince(input.clash.at, await $.clock.now())
91  if ((await $.session.surfaces()).length === 0) {
92    $.ui.log(`blast-radius: another chat edited ${baseName(input.real)} ${minutes} min ago; nobody to ask, so the edit goes ahead`, { to: 'debug' })
93
94    return null
95  }
96  const mine = await take(holdIo($), { command: input.real, signal: input.signal, canStrike: false })
97  const decision = mine === null ? 'interrupted' : await held($, mine, input)
98  // A guard that broke lets the edit through: this is a caution, not a lock.
99  if (decision === 'proceed' || decision === 'error') return null
100  const why = decision === 'cancel' ? words().edit.cancelWhy : WHY[decision]
101  if (decision === 'cancel' || decision === 'closed') $.ui.toast(words().edit.skipped)
102
103  return {
104    deny: `Blast Radius held this edit and did not make it: ${why}. Another session edited ${input.real} ${minutes} min ago and may still be working on it. Do not retry it unless the user asks you to.`,
105  }
106}
107
108async function held($: EngineInterface, mine: Held, input: Clash): Promise<Decision> {
109  await hold(holdIo($), mine, { signal: input.signal, prepare: () => prepare($, mine, input) })
110
111  return mine.decision ?? 'error'
112}
113
114async function projectOf($: EngineInterface): Promise<string> {
115  return baseName(await $.session.root())
116}
117
118export function editHooks(on: On): void {
119  on('tool.call', { tool: EDIT_TOOLS }, async ($, e, next) => {
120    const path = editPath(e)
121    if (path === '') return next(e)
122    const real = await realPathOf($, path)
123    const sessionId = await $.session.id()
124    const clash = await findClash(storeOf($), { path: real, sessionId, now: await $.clock.now() })
125    const refused = clash === null ? null : await settle($, { clash, real, signal: next.signal })
126    if (refused !== null) return refused
127    const result = await next(e)
128    if (result.deny === undefined && result.isError !== true) {
129      await recordEdit(storeOf($), { path: real, sessionId, project: await projectOf($), at: await $.clock.now() })
130    }
131
132    return result
133  }).catch(($, e, next) => next(e))
134
135  // An ended chat is no longer in any file.
136  on('session.end', async ($, e, next) => {
137    await $.store.delete(editsKey(e.sessionId))
138
139    return next(e)
140  }).catch(($, e, next) => next(e))
141}
142
hooks/hold.ts 124 lines
1// Holding one call while the person answers its card: a risky command, an edit another chat
2// just made, a command a house rule asks about. One at a time; the card itself is drawn by
3// register's ui.render hooks from `current()`.
4//
5// A hook has 10 s of its own time, but time spent inside a `$` call is free. So the hold loop
6// waits on a short `$.process.run(["sleep", ...])` until a button's onPress sets the decision.
7// The engine follows `$` only within one file, so each hooks module hands in its own `HoldIo`.
8
9import { motion } from './card'
10import type { CardProps } from './card'
11import type { View } from './view'
12
13export type Decision = 'proceed' | 'cancel' | 'closed' | 'timeout' | 'interrupted' | 'error'
14
15export type Held = {
16  command: string
17  // What the card shows, once it is ready to draw; null while it is being measured.
18  view: ((isExpanded: boolean) => View) | null
19  decision: Decision | null
20  where: 'pane' | 'band'
21  phase: CardProps['phase']
22  isExpanded: boolean
23  // Only a delete strikes its rows before it runs.
24  canStrike: boolean
25}
26
27// The hold loop's calls on `$`, as closures from the hooks module.
28export type HoldIo = {
29  // `$.process.run(['sleep', seconds], { timeoutMs: SLEEP_TIMEOUT_MS })`
30  sleep: (seconds: string) => Promise<unknown>
31  now: () => Promise<number>
32  // `$.ui.open` of PANE_ID with these rows: whether a surface placed it.
33  open: (rows: number) => Promise<boolean>
34  close: () => Promise<void>
35  redraw: () => void
36}
37
38export type HoldOptions = {
39  signal: AbortSignal
40  // Measures and sets `view`; false when there is nothing to ask, and the call goes ahead.
41  prepare: () => Promise<boolean>
42}
43
44export const PANE_ID = 'blast-radius'
45const POLL_SECONDS = '0.25'
46const STRIKE_SECONDS = '0.9'
47export const SLEEP_TIMEOUT_MS = 5000
48const HOLD_LIMIT_MS = 10 * 60 * 1000
49const PANE_ROWS_MAX = 24
50const PANE_ROWS_CHROME = 9
51
52// The call being held, or null. One at a time: a second call waits for the first answer.
53let held: Held | null = null
54
55export const current = (): Held | null => held
56
57/** Waits for any earlier card to be answered, then claims the hold; null when the turn was interrupted meanwhile. */
58export async function take(io: HoldIo, input: { command: string; signal: AbortSignal; canStrike: boolean }): Promise<Held | null> {
59  while (held !== null) {
60    if (input.signal.aborted) return null
61    await io.sleep(POLL_SECONDS)
62  }
63  // Claimed with no await between the check and the claim, so two waiting calls can't both get through.
64  const mine: Held = { command: input.command, view: null, decision: null, where: 'pane', phase: 'asking', isExpanded: false, canStrike: input.canStrike }
65  held = mine
66
67  return mine
68}
69
70async function waitForDecision(io: HoldIo, mine: Held, signal: AbortSignal): Promise<void> {
71  const startedAt = await io.now()
72  while (mine.decision === null) {
73    if (signal.aborted) {
74      mine.decision = 'interrupted'
75    } else if ((await io.now()) - startedAt > HOLD_LIMIT_MS) {
76      mine.decision = 'timeout'
77    } else {
78      await io.sleep(POLL_SECONDS)
79    }
80  }
81}
82
83function paneRows(mine: Held): number {
84  const rows = mine.view === null ? 0 : mine.view(false).rows.length
85
86  return Math.min(PANE_ROWS_MAX, PANE_ROWS_CHROME + rows)
87}
88
89/** Opens the card, waits for the answer and lets the hold go, whatever happens. */
90export async function hold(io: HoldIo, mine: Held, options: HoldOptions): Promise<void> {
91  let isPlaced = false
92  try {
93    if (!(await options.prepare())) {
94      mine.decision = 'proceed'
95
96      return
97    }
98    // Every card's pane opens anew, so its drawings cascade in each time (kit/moving.ts).
99    motion.reset()
100    isPlaced = await io.open(paneRows(mine))
101    if (!isPlaced) mine.where = 'band'
102    io.redraw()
103    await waitForDecision(io, mine, options.signal)
104    if (mine.decision === 'proceed' && mine.canStrike) {
105      // The rows it is about to delete get struck through, then the pane goes.
106      mine.phase = 'striking'
107      io.redraw()
108      await io.sleep(STRIKE_SECONDS)
109    }
110  } catch {
111    mine.decision = 'error'
112  } finally {
113    // Close this call's pane before releasing the hold, so the next call's pane can't be the one closed.
114    if (isPlaced) await io.close().catch(() => undefined)
115    if (held === mine) held = null
116    io.redraw()
117  }
118}
119
120/** Closing the pane (Esc, its close mark) answers as the safe button does. */
121export function closeHeld(): void {
122  if (held !== null && held.decision === null) held.decision = 'closed'
123}
124
hooks/house.ts 110 lines
1// House rules for the gray zone: a Bash command blast-radius's own rules don't hold and that is
2// not plainly read-only is weighed against `.claude/house-rules.md`, when the person wrote one.
3// One judge call per command at most (kit/judge.ts, haiku on the person's own account; Jev when
4// they opted in), its verdict kept for the session. allow runs it, ask holds it on the Amber
5// card, block refuses it. No verdict (no model, a timeout, a stray reply) runs it: a judge that
6// failed never stops the person's work.
7
8import type { EngineInterface, Register } from 'claude-code'
9
10import { ruleView } from './caution'
11import { classify } from './classify'
12import { PANE_ID, SLEEP_TIMEOUT_MS, hold, take } from './hold'
13import type { Decision, Held, HoldIo } from './hold'
14import { judge } from './kit/judge'
15import type { JudgeIo } from './kit/judge'
16import { RULES_FILE, instructionsFor, isReadOnly, labelsFor, parseRules, redact, verdictOf } from './rules'
17import type { Verdict } from './rules'
18import { words } from './words'
19
20type On = Parameters<Register>[0]
21
22const WHY: Record<Exclude<Decision, 'proceed' | 'cancel' | 'error'>, string> = {
23  closed: 'the user closed the Blast Radius pane',
24  timeout: 'no answer within 10 minutes',
25  interrupted: 'the turn was interrupted',
26}
27
28// Verdicts for the session, by exact command; dropped when the rules change.
29const cache = { rules: '', verdicts: new Map<string, Verdict>() }
30
31// The hold loop's calls, spelled here: the engine follows `$` only within one file.
32const holdIo = ($: EngineInterface): HoldIo => ({
33  sleep: seconds => $.process.run(['sleep', seconds], { timeoutMs: SLEEP_TIMEOUT_MS }),
34  now: () => $.clock.now(),
35  open: async rows => (await $.ui.open({ id: PANE_ID, title: 'Blast Radius', focus: true, closeOnEscape: true, rows })).isPlaced,
36  close: () => $.ui.close({ id: PANE_ID }),
37  redraw: () => $.ui.invalidate('ui.render'),
38})
39
40/** The rules in the session's project, or none when it has no rules file. */
41async function rulesOf($: EngineInterface): Promise<string[]> {
42  const text = await $.fs.read(`${await $.session.root()}/${RULES_FILE}`).catch(() => '')
43
44  return typeof text === 'string' ? parseRules(text) : []
45}
46
47async function judged($: EngineInterface, input: { command: string; rules: string[] }): Promise<Verdict | null> {
48  const key = input.rules.join('\n')
49  if (cache.rules !== key) cache.verdicts.clear()
50  cache.rules = key
51  const known = cache.verdicts.get(input.command)
52  if (known !== undefined) return known
53  const env = { backend: await $.env.get('MODZ_JUDGE'), typesafeKey: await $.env.get('TYPESAFE_API_KEY') }
54  const io: JudgeIo = { complete: request => $.model.complete(request), fetch: (url, init) => $.http.fetch(url, init), sleep: ms => $.clock.sleep(ms), redact, env }
55  const answer = await judge(io, { text: input.command, labels: labelsFor(input.rules), instructions: instructionsFor(input.rules) })
56  const verdict = answer === undefined ? null : verdictOf(answer.label, input.rules)
57  if (verdict !== null) cache.verdicts.set(input.command, verdict)
58
59  return verdict
60}
61
62type Ask = { command: string; rule: string; signal: AbortSignal }
63
64async function prepare($: EngineInterface, mine: Held, input: Ask): Promise<boolean> {
65  const card = { command: input.command, rule: input.rule, cwd: await $.session.cwd() }
66  mine.view = () => ruleView(card)
67
68  return true
69}
70
71/** Holds the command on the card; a deny unless the person runs it. */
72async function ask($: EngineInterface, input: Ask): Promise<{ deny: string } | null> {
73  const noAnswer = `Blast Radius held this command and did not run it: a house rule (${input.rule}) asks the user first`
74  if ((await $.session.surfaces()).length === 0) return { deny: `${noAnswer}, and nobody is here to answer. Do not retry it.` }
75  const mine = await take(holdIo($), { command: input.command, signal: input.signal, canStrike: false })
76  if (mine !== null) await hold(holdIo($), mine, { signal: input.signal, prepare: () => prepare($, mine, input) })
77  const decision = mine?.decision ?? 'interrupted'
78  // A card that broke lets the command through: a house rule is a caution, not a lock.
79  if (decision === 'proceed' || decision === 'error') return null
80  if (decision === 'cancel' || decision === 'closed') $.ui.toast(words().kept)
81  const why = decision === 'cancel' ? words().cancelWhy : WHY[decision]
82
83  return { deny: `Blast Radius held this command and did not run it: ${why}. House rule: ${input.rule}. Do not retry it unless the user asks you to.` }
84}
85
86/** A new session judges afresh; register's session.start calls it. */
87export function resetHouse(): void {
88  cache.verdicts.clear()
89}
90
91export function houseHooks(on: On): void {
92  // Risky commands are blast-radius's own (register.tsx); plain reads are never judged.
93  on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
94    const command = String(e.command ?? '')
95    if (classify(command) !== null || isReadOnly(command)) return next(e)
96    const rules = await rulesOf($)
97    if (rules.length === 0) return next(e)
98    const verdict = await judged($, { command, rules })
99    if (verdict === null) $.ui.log('blast-radius: the house rules got no verdict for a command, so it runs', { to: 'debug' })
100    if (verdict === null || verdict.kind === 'allow') return next(e)
101    const rule = verdict.rule ?? words().house.anyRule
102    if (verdict.kind === 'block') {
103      return { deny: `Blast Radius blocked this command: it breaks a house rule in ${RULES_FILE} (${rule}). Do not retry it or work around it; ask the user if it is needed.` }
104    }
105    const refused = await ask($, { command, rule, signal: next.signal })
106
107    return refused ?? next(e)
108  }).catch(($, e, next) => next(e))
109}
110
hooks/lang.ts 24 lines
1// MODZ kit — the interface language: English, or Turkish when the person sets MODZ_LANG=tr (the "env" of
2// ~/.claude/settings.json). One value per mod: its register file reads the variable at session.start with
3// `setLang(await $.env.get('MODZ_LANG'))` (the engine only admits an env read whose name is a literal), and every
4// drawing reads it back through `pick` and `upper`.
5export type Lang = 'en' | 'tr'
6
7let current: Lang = 'en'
8
9export const langOf = (value: unknown): Lang => (typeof value === 'string' && /^tr/i.test(value.trim()) ? 'tr' : 'en')
10
11export function setLang(value: unknown): Lang {
12  current = langOf(value)
13
14  return current
15}
16
17export const lang = (): Lang => current
18
19// The current language's entry of a table that fills in both: `pick({ en: 'Open', tr: 'Aç' })`.
20export const pick = <T>(table: Readonly<Record<Lang, T>>): T => table[current]
21
22// Capitals for eyebrows and labels: Turkish keeps its dotted İ, English never gets one.
23export const upper = (text: string): string => text.toLocaleUpperCase(current)
24
hooks/measure.ts 29 lines
1// Measures a risky command's blast radius. Never throws: a failed read is said, not hidden.
2
3import { measureClean, measureRm } from './measure-files'
4import { measureChanges, measureMigrations, measurePush } from './measure-git'
5import { emptyReport } from './report'
6import type { Report, Run } from './report'
7import type { Risk } from './classify'
8import { words } from './words'
9
10const ERROR_CHARS = 160
11
12export async function measure(run: Run, risk: Risk, cwd: string): Promise<Report> {
13  try {
14    if (risk.kind === 'rm') return await measureRm(run, risk, cwd)
15    if (risk.kind === 'git-clean') return await measureClean(run, risk, cwd)
16    if (risk.kind === 'migrate') return await measureMigrations(run, risk, cwd)
17    if (risk.kind === 'git-push-force') return await measurePush(run, risk, cwd)
18
19    return await measureChanges(run, risk, cwd)
20  } catch (error) {
21    const reason = String(error instanceof Error ? error.message : error).slice(0, ERROR_CHARS)
22
23    return emptyReport('unknown', `${risk.label} (could not measure it)`, words().notes.unmeasured(reason))
24  }
25}
26
27export const missingFolder = (risk: Risk): Report =>
28  emptyReport('unknown', `${risk.label} in ${risk.dir}`, words().notes.missingFolder(String(risk.dir)))
29
hooks/report.ts 69 lines
1// What a measurement found, as data: the drawing turns it into words, and
2// `summary` is the sentence Claude reads when the command is refused.
3
4export type FileRow = { name: string; bytes: number }
5
6// Files grouped by the folder they sit in, biggest first; `files` is the head of the list.
7export type Group = { dir: string; count: number; bytes: number; files: FileRow[] }
8
9// One line of any other report: a changed file, a commit, a migration.
10export type Item = { name: string; tag: string; meta: string; weight: number }
11
12export type ReportKind = 'files' | 'changes' | 'commits' | 'migrations' | 'unknown'
13
14export type Report = {
15  kind: ReportKind
16  summary: string
17  count: number
18  bytes: number | null
19  groups: Group[]
20  items: Item[]
21  // How many of `count` are not in `items` (for `files`, not in any group's `files`).
22  more: number
23  // `files`: how many of them git tracks, so they could be restored.
24  tracked: number | null
25  // `commits`: the remote branch; `changes`: lines added and removed.
26  where: string | null
27  added: number
28  removed: number
29  // A line for the person, in their language, when something could not be measured.
30  note: string | null
31  // Measured, and the command would change nothing: it runs without a card.
32  isEmpty: boolean
33}
34
35export type RunResult = { exitCode: number; stdout: string; stderr: string }
36
37// `$.process.run`, handed in by the hooks module: measuring never touches `$` itself.
38export type Run = (argv: string[], options: { cwd?: string; timeoutMs?: number }) => Promise<RunResult>
39
40export const LIST_MAX = 10
41
42const KIB = 1024
43const DECIMAL_LIMIT = 10
44const UNITS = ['KB', 'MB', 'GB', 'TB']
45
46/** `286 KB`, `6.3 KB`; `mark` is the language's decimal mark. */
47export function formatSize(bytes: number, mark: string): string {
48  if (!Number.isFinite(bytes) || bytes < KIB) return `${bytes || 0} B`
49  let n = bytes
50  let i = -1
51  while (n >= KIB && i < UNITS.length - 1) {
52    n /= KIB
53    i += 1
54  }
55
56  return `${n.toFixed(n < DECIMAL_LIMIT ? 1 : 0).replace('.', mark)} ${UNITS[i]}`
57}
58
59export function emptyReport(kind: ReportKind, summary: string, note: string | null): Report {
60  return { kind, summary, count: 0, bytes: null, groups: [], items: [], more: 0, tracked: null, where: null, added: 0, removed: 0, note, isEmpty: false }
61}
62
63/** A measurement that found nothing the command could lose. */
64export const nothingReport = (kind: ReportKind, summary: string): Report => ({ ...emptyReport(kind, summary, null), isEmpty: true })
65
66export const plural = (count: number, one: string, many: string): string => `${count} ${count === 1 ? one : many}`
67
68export const lines = (text: string): string[] => text.split('\n').filter(line => line !== '')
69
hooks/pin.ts 48 lines
1// MODZ kit — pinned press handles. Every element the JSX runtime builds gets a fresh handle, and the
2// host releases a drawing's old handles the moment it redraws. A desktop holds the tree it last
3// painted, so a click that lands just after any redraw (a state write, a focus flip, a poll) names a
4// released handle and the engine drops it ("ui_press not handled"). The host keeps a handle alive
5// across redraws when the new tree carries the same one, so each element here keeps the first handle
6// its site and key ever got: every tree a surface still holds stays pressable, with the latest closure.
7import type { RenderElement } from 'claude-code'
8
9export type PinSite = { surface: string; component?: string; requestId?: string }
10
11type Node = { type?: string; props?: { key?: unknown }; press?: { plugin: string; handle: number }; children?: unknown[] }
12
13// Far above the runtime's own counter, which starts at 1 and climbs by one per element drawn.
14const FIRST_PINNED = 1_000_000_000
15const pins = new Map<string, number>()
16
17function handleFor(id: string): number {
18  let handle = pins.get(id)
19  if (handle === undefined) {
20    handle = FIRST_PINNED + pins.size
21    pins.set(id, handle)
22  }
23
24  return handle
25}
26
27function pinNode(node: unknown, ctx: { site: string; seen: Map<string, number> }): unknown {
28  if (typeof node !== 'object' || node === null) return node
29  const el = node as Node
30  const children = el.children?.map(child => pinNode(child, ctx))
31  const withKids = children === undefined ? el : { ...el, children }
32  // Only this plugin's own elements: the runtime names the plugin of every tree `next` returned.
33  if (el.press?.plugin !== '' || typeof el.props?.key !== 'string') return withKids
34  const base = `${ctx.site}\u0000${el.type}\u0000${el.props.key}`
35  const nth = ctx.seen.get(base) ?? 0
36  ctx.seen.set(base, nth + 1)
37
38  return { ...withKids, press: { plugin: '', handle: handleFor(`${base}\u0000${nth}`) } }
39}
40
41// Return a render hook's tree through this: `return pinned(e, tree)`.
42export function pinned<T extends RenderElement | null | undefined>(site: PinSite, tree: T): T {
43  if (tree === null || tree === undefined) return tree
44  const ctx = { site: `${site.surface}\u0000${site.component ?? ''}\u0000${site.requestId ?? ''}`, seen: new Map<string, number>() }
45
46  return pinNode(tree, ctx) as T
47}
48
hooks/theme.ts 76 lines
1// MODZ "Tidemark" tokens (DESIGN.md §2), one copy per mod until the shared kit lands.
2// Svg picks its palette with prefers-color-scheme; Box and Text take hex from `palette(isLight)`.
3
4export type Neutrals = { sheet: string; card: string; inset: string; line: string; lineStrong: string; track: string; hi: string; mid: string; lo: string }
5export type Role = { fg: string; tint: string; line: string }
6export type RoleName = 'read' | 'edit' | 'run' | 'ok' | 'warn' | 'danger' | 'plan'
7
8const DARK: Neutrals = { sheet: '#191917', card: '#1F1F1C', inset: '#171715', line: '#2E2E2A', lineStrong: '#45443F', track: '#34332F', hi: '#F0EEE8', mid: '#C2BEB4', lo: '#8E8A80' }
9const LIGHT: Neutrals = { sheet: '#F4F2EC', card: '#FFFFFF', inset: '#F6F4EF', line: '#E4E0D6', lineStrong: '#CFC9BC', track: '#ECE8DF', hi: '#1C1B19', mid: '#4F4B44', lo: '#77726A' }
10
11const ROLES: Record<RoleName, { dark: Role; light: Role }> = {
12  read: { dark: { fg: '#7FB6E8', tint: '#1E2B37', line: '#2F4A63' }, light: { fg: '#2464A0', tint: '#E7F0F9', line: '#B9D3EC' } },
13  edit: { dark: { fg: '#E58A5F', tint: '#38251D', line: '#6A3E2D' }, light: { fg: '#A94A26', tint: '#FBEDE6', line: '#EFC6B4' } },
14  run: { dark: { fg: '#B19CF3', tint: '#29243D', line: '#4A3F75' }, light: { fg: '#6243C4', tint: '#F0ECFC', line: '#CFC3F3' } },
15  ok: { dark: { fg: '#6FC593', tint: '#1B2F25', line: '#2F5A42' }, light: { fg: '#1F6E43', tint: '#E4F4EA', line: '#B5DEC4' } },
16  warn: { dark: { fg: '#E9B949', tint: '#332D19', line: '#66552A' }, light: { fg: '#8A6000', tint: '#FBF2D9', line: '#EBD48F' } },
17  danger: { dark: { fg: '#F2727F', tint: '#3B2023', line: '#74343C' }, light: { fg: '#C0263A', tint: '#FCE8EA', line: '#F1B9C0' } },
18  plan: { dark: { fg: '#E07BC0', tint: '#351F2E', line: '#6B3A5C' }, light: { fg: '#A8337F', tint: '#FBE9F4', line: '#EDBFDC' } },
19}
20
21// Terminal theme keys per role (DESIGN.md §2, terminal mapping).
22export const TERMINAL_KEY: Record<RoleName, string> = {
23  read: 'suggestion', edit: 'claude', run: 'permission', ok: 'success', warn: 'warning', danger: 'error', plan: 'merged',
24}
25
26export const palette = (isLight: boolean): Neutrals => (isLight ? LIGHT : DARK)
27export const role = (name: RoleName, isLight: boolean): Role => ROLES[name][isLight ? 'light' : 'dark']
28
29export const FONT = `-apple-system, BlinkMacSystemFont, 'SF Pro Text', Inter, system-ui, sans-serif`
30export const MONO = `ui-monospace, 'SF Mono', Menlo, Consolas, monospace`
31
32function vars(isLight: boolean): string {
33  const n = palette(isLight)
34  const roles = (Object.keys(ROLES) as RoleName[]).map(name => {
35    const r = role(name, isLight)
36
37    return `--${name}:${r.fg};--${name}-tint:${r.tint};--${name}-line:${r.line}`
38  })
39
40  return `--card:${n.card};--inset:${n.inset};--line:${n.line};--line-strong:${n.lineStrong};--track:${n.track};--hi:${n.hi};--mid:${n.mid};--lo:${n.lo};${roles.join(';')}`
41}
42
43// The <style> every Svg opens with: both palettes as custom properties, light first.
44export function svgStyle(extra = ''): string {
45  return [
46    `<style>svg{${vars(true)}}`,
47    `@media (prefers-color-scheme: dark){svg{${vars(false)}}}`,
48    `text{font-family:${FONT}}.mono{font-family:${MONO}}.num{font-variant-numeric:tabular-nums}`,
49    extra,
50    '@media (prefers-reduced-motion: reduce){*{animation:none!important;transition:none!important}}</style>',
51  ].join('')
52}
53
54// ---- Light or dark, for Box and Text (skins' resolveLight) ----------------
55
56const named = (value: unknown): boolean | undefined => {
57  const name = typeof value === 'string' ? value.trim().toLowerCase() : ''
58
59  if (name.includes('light')) return true
60
61  return name.includes('dark') ? false : undefined
62}
63
64// `COLORFGBG` is `<fg>;<bg>`; background 7 or 15 is a light one.
65const terminalLight = (colorfgbg: string | undefined): boolean | undefined => {
66  const bg = Number(colorfgbg?.split(';').at(-1))
67
68  return colorfgbg === undefined || Number.isNaN(bg) ? undefined : bg === 7 || bg === 15
69}
70
71export type ThemeHints = { override?: string; theme?: unknown; colorfgbg?: string; systemDark?: boolean }
72
73// `MODZ_THEME` wins, then Claude's theme setting, then the terminal, then the system. Dark when nothing says.
74export const resolveLight = (hints: ThemeHints): boolean =>
75  named(hints.override) ?? named(hints.theme) ?? terminalLight(hints.colorfgbg) ?? (hints.systemDark === undefined ? false : !hints.systemDark)
76
hooks/view.ts 120 lines
1// A report in the person's words: the headline, the risk banner, the rows to list and
2// the button that runs it. Pure, so the pane, the band and the terminal share it.
3
4import type { Risk } from './classify'
5import type { IconName } from './svg-kit'
6import type { Report } from './report'
7import { words } from './words'
8
9export type RowKind = 'folder' | 'file' | 'item' | 'more'
10export type Row = { kind: RowKind; name: string; tag: string; count: string; size: string; share: number | null; icon: IconName }
11export type Banner = { tone: 'danger' | 'read' | 'warn'; text: string }
12export type Choice = { label: string; hotkey: string }
13
14// Flare for what can't be undone; Amber for a caution (another chat's edit, a house rule).
15export type CardTone = 'danger' | 'warn'
16
17export type View = {
18  tone: CardTone
19  eyebrow: string
20  icon: IconName
21  command: string
22  // A sentence in place of the command line, for a card that holds something other than a command.
23  headline: string | null
24  payload: string
25  isPayloadEmpty: boolean
26  tail: string
27  context: string
28  fullCommand: string | null
29  banner: Banner | null
30  rows: Row[]
31  // Rows the toggle would add (collapsed) or take away (expanded); 0 hides it.
32  toggleCount: number
33  action: Choice
34  // The safe answer; it holds the focus.
35  cancel: Choice
36}
37
38const FILES_COLLAPSED = 6
39const GROUPS_COLLAPSED = 4
40const ITEMS_COLLAPSED = 6
41
42const baseName = (path: string): string => path.replace(/\/+$/, '').split('/').pop() || path
43
44function shownCommand(risk: Risk): string {
45  if (risk.kind === 'rm') return [risk.label, ...risk.targets].join(' ')
46  if (risk.kind === 'git-clean') return ['git clean', ...risk.args].join(' ')
47  if (risk.kind === 'git-push-force') return ['git push', ...risk.args].join(' ')
48
49  return risk.label
50}
51
52const ICON_OF: Record<Risk['kind'], IconName> = {
53  rm: 'trash', 'git-clean': 'branch', 'git-reset': 'rewind', 'git-checkout': 'rewind', 'git-push-force': 'push', migrate: 'database',
54}
55
56function fileRows(report: Report, isExpanded: boolean): { rows: Row[]; listed: number; shown: number } {
57  const said = words()
58  const total = report.bytes || 1
59  const groups = isExpanded ? report.groups : report.groups.slice(0, GROUPS_COLLAPSED)
60  const rows: Row[] = []
61  let budget = isExpanded ? Number.MAX_SAFE_INTEGER : FILES_COLLAPSED
62  let shown = 0
63  let shownBytes = 0
64  groups.forEach((group, index) => {
65    const later = groups.length - index - 1
66    const take = Math.max(Math.min(1, budget), Math.min(group.files.length, budget - later))
67    rows.push({ kind: 'folder', name: group.dir, tag: '', count: said.folderCount(group.count), size: said.size(group.bytes), share: group.bytes / total, icon: 'folder' })
68    for (const file of group.files.slice(0, take)) {
69      rows.push({ kind: 'file', name: file.name, tag: '', count: '', size: said.size(file.bytes), share: file.bytes / total, icon: 'file' })
70      shownBytes += file.bytes
71    }
72    shown += Math.min(take, group.files.length)
73    budget -= take
74  })
75  const hidden = report.count - shown
76  if (hidden > 0) rows.push({ kind: 'more', name: said.moreFiles(hidden, Math.max(0, (report.bytes ?? 0) - shownBytes)), tag: '', count: '', size: '', share: null, icon: 'file' })
77  const listed = report.groups.reduce((sum, group) => sum + group.files.length, 0)
78
79  return { rows, listed, shown }
80}
81
82const ITEM_ICON: Partial<Record<Report['kind'], IconName>> = { commits: 'commit', migrations: 'database' }
83
84function itemRows(report: Report, isExpanded: boolean): { rows: Row[]; listed: number; shown: number } {
85  const icon = ITEM_ICON[report.kind] ?? 'file'
86  const totalWeight = report.items.reduce((sum, item) => sum + item.weight, 0)
87  const items = isExpanded ? report.items : report.items.slice(0, ITEMS_COLLAPSED)
88  const rows: Row[] = items.map(item => ({ kind: 'item', name: item.name, tag: item.tag, count: '', size: item.meta, share: totalWeight > 0 ? item.weight / totalWeight : null, icon }))
89  const hidden = report.count - items.length
90  if (hidden > 0) rows.push({ kind: 'more', name: words().moreItems(hidden, report.kind), tag: '', count: '', size: '', share: null, icon })
91
92  return { rows, listed: report.items.length, shown: items.length }
93}
94
95export function viewOf(held: { command: string; risk: Risk; report: Report; cwd: string; isExpanded: boolean }): View {
96  const { report, risk } = held
97  const rowsOf = report.groups.length > 0 ? fileRows : itemRows
98  const list = rowsOf(report, held.isExpanded)
99  const collapsed = rowsOf(report, false)
100  const command = shownCommand(risk)
101  const said = words()
102
103  return {
104    tone: 'danger',
105    eyebrow: said.eyebrow,
106    icon: ICON_OF[risk.kind],
107    command,
108    headline: null,
109    ...said.headline(report),
110    isPayloadEmpty: report.count === 0,
111    context: said.context({ where: report.where, folder: held.cwd === '' ? '' : baseName(held.cwd) }),
112    fullCommand: held.command.trim() === command ? null : held.command.trim(),
113    banner: said.banner(report),
114    rows: list.rows,
115    toggleCount: collapsed.listed - collapsed.shown,
116    action: said.action(report),
117    cancel: said.cancel,
118  }
119}
120