SLOPSHOPPER

blast-radius-win

Windows fork of blast-radius: holds risky Bash and PowerShell commands (recursive deletes, force pushes, migrations), shows what they would change, and fails…

newpanebandguardprocess
v0.2.3-win.1MITupdated 2026-10-23THEROCKSSS/claude-mods/mods/blast-radius-win
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · blast-radius-win
│ ┃ blast-radius-win ✕ › fix the failing auth test and add an audit log call │ ┃ Nothing held. │ ⏺ Read(src/auth.ts) │ ⎿ Read 6 lines │ ⏺ Update(src/auth.ts) │ ⎿ Added 2 lines, removed 1 line │ ⏺ Bash(rm -rf build && git push --force origin main) │ ⎿ Denied by blast-radius-win: blast-radius-win: held this c │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · blast-radius-win
Nothing held.
README

<img src=".claude-plugin/icon.png" width="72" align="right" alt="">

blast-radius-win

A Windows fork of hamzafer's blast-radius. It holds risky commands before they run, shows what they would change, and waits for Cancel or Proceed. The risky commands are:

  • recursive deletes
  • force pushes
  • database migrations

Why a fork

On Windows, upstream failed open. Its hold loop called sleep, which is not on Windows' PATH. The call threw, and a hook that throws with no .catch is skipped, so the risky command ran unchecked.

This fork:

  • waits on $.clock.sleep
  • previews through Git Bash
  • guards the PowerShell tool as well as Bash (Remove-Item -Recurse, rmdir and the rest)
  • fails closed: if the guard itself errors, the command is blocked

In a session with no screen to ask on (the desktop app, for now), it refuses the command and says what it would have done.

Settings

SettingDefault
timeoutSeconds60How long a held command waits before it is cancelled. 0 waits forever.

MIT. Upstream's copyright is kept in LICENSE. On macOS or Linux, use the original.

Source 3 files
hooks/register.tsx 361 lines
1// Blast Radius (Windows): holds risky Bash and PowerShell commands and shows what they would change.
2// Forked from hamzafer/claude-code-mods blast-radius 0.2.3. What differs:
3//   - waits on $.clock.sleep, not a `sleep` program (Windows has none on PATH)
4//   - previews through Git Bash by absolute path (`bash` on PATH is WSL's)
5//   - guards the PowerShell tool too: Remove-Item -Recurse and its aliases, force pushes, migrations
6//   - previews paths with spaces whole, and reads USERPROFILE where HOME is unset
7//   - fails closed: a guard that throws blocks the command instead of letting it run
8import { atom, read, update } from 'claude-code'
9import type { EngineInterface, Register } from 'claude-code'
10
11import type { HeldCommand } from '../types'
12import { bandsFile, parseOrder, stackBands } from './bands'
13
14const NAME = 'blast-radius-win'
15const PANE = NAME
16const MAX_LISTED = 200
17const DEFAULT_TIMEOUT_SECONDS = 60
18const POLL_MS = 250
19const GIT_BASH = 'C:/Program Files/Git/bin/bash.exe'
20
21// Held by the host, so the drawing redraws when a command is held or released.
22const held = atom({ plugin: 'blast-radius-win', key: 'held' } as const, null as HeldCommand | null)
23
24const MIGRATION =
25  /\b(prisma\s+(migrate|db\s+push)|supabase\s+(db\s+(reset|push)|migration\s+up)|drizzle-kit\s+(push|migrate)|knex\s+migrate|sequelize(-cli)?\s+db:migrate|rails\s+db:(migrate|reset|drop)|alembic\s+(upgrade|downgrade)|typeorm\s+migration:run)\b/
26
27// PowerShell's delete cmdlet and every alias it ships with.
28const PS_DELETE = new Set(['remove-item', 'rm', 'del', 'erase', 'ri', 'rd', 'rmdir'])
29// Parameters of Remove-Item whose value is not a path to delete.
30const PS_VALUE_PARAMS = /^-(include|exclude|filter|credential|stream)$/i
31
32type Shell = 'bash' | 'powershell'
33type Risk = { risk: HeldCommand['risk']; shell: Shell; cwd?: string; targets?: string[]; hasQuotes?: boolean }
34
35let bandOrder: Promise<string[]> | undefined // read once per session: restart to apply
36let bashBin: Promise<string> | undefined
37
38// How long a held command waits for a press before it is cancelled; 0 waits forever. Set by register.
39let timeoutSeconds = DEFAULT_TIMEOUT_SECONDS
40// The press of a Button, by held command id.
41const decisions = new Map<string, 'proceed' | 'cancel'>()
42// The call holding the pane now. Checked and claimed in one step, so two waiting calls can't both take it.
43let holder: string | null = null
44
45export const register: Register = (on, options) => {
46  timeoutSeconds = timeoutFrom(options.timeoutSeconds)
47  decisions.clear()
48  holder = null
49
50  on('tool.call', { tool: 'Bash' }, ($, e, next) => guard($, e, next, classify(e.command))).catch(failClosed)
51  on('tool.call', { tool: 'PowerShell' }, ($, e, next) => guard($, e, next, classifyPowerShell(e.command))).catch(failClosed)
52
53  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
54    const one = await read($, held)
55    const { Text } = $.ui.resolve(e)
56    if (!one) return <Text dimColor>Nothing held.</Text>
57    return report($, e, one, decide)
58  })
59
60  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
61    const one = await read($, held)
62    if (!one || one.where !== 'band' || e.props.hasSurvey) return next(e)
63    // a held command takes the whole band until answered; the key lets the band order place it
64    return stackBands(undefined, NAME, report($, e, one, decide), 'below', await (bandOrder ??= readBandOrder($).catch(() => [])))
65  })
66}
67
68// The hook's loop picks the press up.
69function decide(id: string, decision: 'proceed' | 'cancel') {
70  decisions.set(id, decision)
71}
72
73// Holds a risky command until a press, a timeout or an abort; passes anything else straight on.
74async function guard($: EngineInterface, e: { command: string; tool_use_id: string }, next: any, found: Risk | null) {
75  if (!found) return next(e)
76
77  // Nothing draws the session (a plain `claude -p` run): nobody can see the buttons, so refuse now.
78  if ((await $.session.surfaces()).length === 0) {
79    const { summary } = await measure($, found)
80    return {
81      deny:
82        `${NAME}: cancelled this command because this session has no screen, so nobody can answer. ` +
83        `It would have: ${summary}. Ask the user to run it themselves.`,
84    }
85  }
86
87  // One command is held at a time; a second waits its turn (the first one's timeout bounds the wait).
88  for (;;) {
89    // The call ended while it waited its turn: leave the held one alone.
90    if (next.signal.aborted) return { deny: `${NAME}: held this command, but the call was stopped before it was shown.` }
91    if (holder === null && (await read($, held)) === null && holder === null) break
92    await $.clock.sleep(POLL_MS, { signal: next.signal })
93  }
94  holder = e.tool_use_id
95  try {
96    const measured = await measure($, found)
97    const one: HeldCommand = {
98      id: e.tool_use_id,
99      command: e.command,
100      risk: found.risk,
101      ...measured,
102      where: 'pane',
103      secondsLeft: timeoutSeconds > 0 ? timeoutSeconds : null,
104    }
105    await update($, held, () => one)
106
107    const opened = await $.ui.open({ id: PANE, title: 'Blast Radius', focus: true })
108    if (!opened.isPlaced) {
109      await update($, held, h => (h ? { ...h, where: 'band' as const } : h)) // too narrow for a pane: draw above the prompt
110    }
111
112    // Time spent inside $ calls doesn't count against the hook's time limit.
113    // Nobody pressing within the timeout counts as Cancel, so an unattended session never stalls.
114    const deadline = timeoutSeconds > 0 ? (await $.clock.now()) + timeoutSeconds * 1000 : null
115    let isTimedOut = false
116    while (!decisions.has(one.id) && !next.signal.aborted) {
117      if (deadline !== null) {
118        const left = Math.ceil((deadline - (await $.clock.now())) / 1000)
119        if (left <= 0) {
120          isTimedOut = !decisions.has(one.id) // a press that landed at the deadline still counts
121          break
122        }
123        if (left !== one.secondsLeft) {
124          one.secondsLeft = left
125          await update($, held, h => (h && h.id === one.id ? { ...h, secondsLeft: left } : h))
126        }
127      }
128      await $.clock.sleep(POLL_MS, { signal: next.signal }).catch(() => {}) // an abort ends the loop on its next check
129    }
130    const decision = isTimedOut ? 'cancel' : (decisions.get(one.id) ?? 'cancel')
131    decisions.delete(one.id)
132    await update($, held, () => null)
133    await $.ui.close({ id: PANE })
134
135    if (decision === 'proceed') return next(e) // runs as written
136    if (isTimedOut) {
137      return {
138        deny:
139          `${NAME}: held this command and nobody answered within ${timeoutSeconds} s, so it was cancelled. ` +
140          `It would have: ${one.summary}. Don't retry it on your own: ask the user to run it, or run it again once they're back.`,
141      }
142    }
143    return {
144      deny: `${NAME}: the user pressed Cancel on this command. It would have: ${one.summary}.`,
145    }
146  } finally {
147    holder = null
148    // If the hold failed partway, don't leave its command blocking the next call.
149    await update($, held, h => (h && h.id === e.tool_use_id ? null : h)).catch(() => {})
150  }
151}
152
153// A guard that throws is skipped by the engine, and the command would run unchecked: refuse instead.
154function failClosed(_$: EngineInterface, e: any, next: any) {
155  if (next.called) return next(e)
156  const why = next.error ? `${next.error.kind}${next.error.message ? `: ${String(next.error.message).slice(0, 200)}` : ''}` : 'unknown'
157  return { deny: `${NAME}: the guard failed before it could check this command (${why}), so it was blocked. Ask the user to run it themselves.` }
158}
159
160// HOME, else USERPROFILE with forward slashes; '' with neither.
161async function homeOf($: EngineInterface): Promise<string> {
162  const home = (await $.env.get('HOME').catch(() => undefined)) ?? (await $.env.get('USERPROFILE').catch(() => undefined)) ?? ''
163  return home.replace(/\\/g, '/')
164}
165
166// Git Bash where it is installed, else whatever `bash` is on PATH.
167function bashOf($: EngineInterface): Promise<string> {
168  return (bashBin ??= $.fs.exists(GIT_BASH).then(isThere => (isThere ? GIT_BASH : 'bash'), () => 'bash'))
169}
170
171// The band order file, as the claude-code-mods checklist saves it; [] on any error.
172async function readBandOrder($: EngineInterface): Promise<string[]> {
173  const file = bandsFile(await $.env.get('CLAUDE_CONFIG_DIR'), (await homeOf($)) || undefined)
174  return file && (await $.fs.exists(file)) ? parseOrder(await $.fs.read(file)) : []
175}
176
177// Forgets the cached band order and bash, for tests.
178export function resetBandOrder() {
179  bandOrder = undefined
180  bashBin = undefined
181}
182
183function report(
184  $: EngineInterface,
185  e: Parameters<EngineInterface['ui']['resolve']>[0],
186  one: HeldCommand,
187  decide: (id: string, d: 'proceed' | 'cancel') => void,
188) {
189  const { Box, Text, Button } = $.ui.resolve(e)
190  const label = { delete: 'Deletes files', 'force-push': 'Force push', migration: 'Database migration' }[one.risk]
191  const shown = one.where === 'band' ? one.details.slice(0, 5) : one.details
192  return (
193    <Box flexDirection="column" paddingX={1}>
194      <Text>
195        <Text color="red" bold>{'⚠ Blast Radius held a command '}</Text>
196        <Text dimColor>{`(${label})`}</Text>
197      </Text>
198      <Text color="yellow" wrap="truncate-end">{`$ ${one.command}`}</Text>
199      <Text bold>{one.summary}</Text>
200      {shown.map(line => (
201        <Text dimColor wrap="truncate-end">{`  ${line}`}</Text>
202      ))}
203      {one.details.length > shown.length && <Text dimColor>{`  … ${one.details.length - shown.length} more`}</Text>}
204      <Box flexDirection="row" gap={1}>
205        <Button key="cancel" label="Cancel" hotkey="c" variant="primary" autoFocus onPress={() => decide(one.id, 'cancel')} />
206        <Button key="proceed" label="Proceed" hotkey="y" onPress={() => decide(one.id, 'proceed')} />
207        {one.secondsLeft != null && (
208          <Text key="countdown" color={one.secondsLeft <= 10 ? 'yellow' : undefined} dimColor={one.secondsLeft > 10}>
209            {`auto-cancels in ${one.secondsLeft} s`}
210          </Text>
211        )}
212      </Box>
213    </Box>
214  )
215}
216
217// The timeoutSeconds option as whole seconds: a number or a numeric string, 0 to wait forever.
218export function timeoutFrom(value: unknown): number {
219  const n = typeof value === 'string' && value.trim() !== '' ? Number(value) : value
220  if (typeof n !== 'number' || !Number.isFinite(n) || n < 0) return DEFAULT_TIMEOUT_SECONDS
221  return Math.ceil(n) // a positive fraction stays a timeout, never 0 (wait forever)
222}
223
224// Which risky kind a Bash command is, if any, looking at each part of a compound command.
225export function classify(command: string): Risk | null {
226  let cwd: string | undefined
227  for (const part of command.split(/&&|\|\||;|\n/)) {
228    const words = split(part.trim())
229    if (words[0] === 'cd' && words[1]) cwd = words[1]
230    const at = words.findIndex(w => w === 'rm')
231    if (at !== -1 && (at === 0 || words[at - 1] === 'sudo')) {
232      const args = words.slice(at + 1)
233      const flags = args.filter(a => a.startsWith('-'))
234      const isRecursive = flags.some(f => f === '--recursive' || (/^-[a-zA-Z]+$/.test(f) && /[rR]/.test(f)))
235      // Quotes are gone after split: remember they were there, so '~' or '$HOME' are not expanded as if bare.
236      if (isRecursive) return { risk: 'delete', shell: 'bash', cwd, targets: args.filter(a => !a.startsWith('-')), ...(/['"\\]/.test(command) ? { hasQuotes: true } : {}) }
237    }
238    if (isForcePush(words)) return { risk: 'force-push', shell: 'bash', cwd }
239    if (MIGRATION.test(part)) return { risk: 'migration', shell: 'bash', cwd }
240  }
241  return null
242}
243
244// Which risky kind a PowerShell command is, if any. Cmdlet names and parameters are case-insensitive,
245// and a parameter may be any unambiguous prefix (-Recurse, -rec, -r).
246export function classifyPowerShell(command: string): Risk | null {
247  let cwd: string | undefined
248  for (const part of command.split(/&&|\|\||;|\n|\|/)) {
249    const words = split(part.trim())
250    const verb = (words[0] ?? '').toLowerCase()
251    if (['cd', 'set-location', 'sl', 'chdir', 'push-location', 'pushd'].includes(verb) && words[1]) cwd = words[1]
252    if (PS_DELETE.has(verb)) {
253      const args = words.slice(1)
254      const isRecursive = args.some(a => /^-r(e(c(u(r(s(e)?)?)?)?)?)?$/i.test(a) || /^-recurse:\$true$/i.test(a))
255      if (isRecursive) {
256        const targets: string[] = []
257        for (let i = 0; i < args.length; i++) {
258          const a = args[i]!
259          if (PS_VALUE_PARAMS.test(a)) i++ // its value names a pattern or a credential, not a path
260          else if (!a.startsWith('-')) targets.push(a)
261        }
262        return { risk: 'delete', shell: 'powershell', cwd, targets, ...(/['"`]/.test(command) ? { hasQuotes: true } : {}) }
263      }
264    }
265    if (isForcePush(words)) return { risk: 'force-push', shell: 'powershell', cwd }
266    if (MIGRATION.test(part)) return { risk: 'migration', shell: 'powershell', cwd }
267  }
268  return null
269}
270
271function isForcePush(words: string[]) {
272  return words[0] === 'git' && words.includes('push') && words.some(w => w === '-f' || w.startsWith('--force') || /^\+/.test(w))
273}
274
275// What the command would touch, from the tools' own commands.
276// Paths the preview can't resolve without running the command: shell variables, command substitution, ~user.
277const UNRESOLVED = /[$`]|^~[^/]/
278const UNRESOLVED_QUOTED = /[$`~]/ // with quotes around, even ~ and $HOME are unknown
279
280async function measure($: EngineInterface, found: Risk): Promise<Pick<HeldCommand, 'summary' | 'details'>> {
281  // ~ and the home variables are expanded here; anything else with a variable is reported as unknown, never as "nothing".
282  const home = await homeOf($)
283  const isPowerShell = found.shell === 'powershell'
284  // With quotes in a Bash command we can't tell '~' (literal) from ~ (home): don't expand, report it instead.
285  // PowerShell expands ~ and $HOME inside double quotes too, so its paths are expanded either way.
286  const canExpand = home !== '' && (isPowerShell || !found.hasQuotes)
287  const expand = (p: string) => {
288    const slashed = isPowerShell ? p.replace(/\\/g, '/') : p
289    if (!canExpand) return slashed
290    return slashed
291      .replace(/^~(?=\/|$)/, home)
292      .replace(/\$\{HOME\}|\$HOME\b/gi, home)
293      .replace(/\$env:(USERPROFILE|HOME)\b/gi, home)
294  }
295  const cwd = found.cwd ? expand(found.cwd) : undefined
296  const unresolved = found.hasQuotes && !isPowerShell ? UNRESOLVED_QUOTED : UNRESOLVED
297  const init = cwd && !unresolved.test(cwd) ? { cwd, timeoutMs: 10_000 } : { timeoutMs: 10_000 }
298  try {
299    if (found.risk === 'delete') {
300      const targets = (found.targets ?? []).map(expand)
301      const unknown = targets.filter(t => unresolved.test(t))
302      if (unknown.length > 0 || (cwd !== undefined && unresolved.test(cwd))) {
303        const list = unknown.length > 0 ? unknown : [`cd ${found.cwd}`]
304        return { summary: `can't preview: ${list.length === 1 ? 'a path uses' : `${list.length} paths use`} a shell variable, check by hand`, details: list.slice(0, MAX_LISTED) }
305      }
306      // Unquoted $t expands globs, and nothing else, without running the command; IFS of a newline keeps a path with spaces whole.
307      const script =
308        "IFS=$'\\n'; shopt -s nullglob; for t in \"$@\"; do for p in $t; do [ -e \"$p\" ] || continue; " +
309        'echo "S $(du -sk "$p" | cut -f1)"; find "$p" -type f | head -n 5000 | sed "s/^/F /"; done; done'
310      const r = await $.process.run([await bashOf($), '-c', script, NAME, ...targets], init)
311      const lines = r.stdout.split('\n')
312      const files = lines.filter(l => l.startsWith('F ')).map(l => short(l.slice(2), targets))
313      const kb = lines.filter(l => l.startsWith('S ')).reduce((sum, l) => sum + Number(l.slice(2)), 0)
314      if (files.length === 0 && kb === 0) return { summary: 'delete nothing that exists right now', details: [] }
315      return {
316        summary: `delete ${files.length >= 5000 ? '5000+' : files.length} file${files.length === 1 ? '' : 's'} (${size(kb)})`,
317        details: files.slice(0, MAX_LISTED),
318      }
319    }
320    if (found.risk === 'force-push') {
321      const lost = await $.process.run(['git', 'log', '--oneline', 'HEAD..@{u}'], init)
322      const ahead = await $.process.run(['git', 'log', '--oneline', '@{u}..HEAD'], init)
323      const gone = lost.stdout.split('\n').filter(Boolean)
324      const added = ahead.stdout.split('\n').filter(Boolean)
325      return {
326        summary: `overwrite the remote branch: ${gone.length} remote commit${gone.length === 1 ? '' : 's'} lost, ${added.length} pushed (as of the last fetch)`,
327        details: [...gone.map(c => `lost   ${c}`), ...added.map(c => `pushed ${c}`)].slice(0, MAX_LISTED),
328      }
329    }
330    const status = await $.process.run(['git', 'status', '--porcelain'], init)
331    const touched = status.stdout.split('\n').filter(l => /migrat|schema|prisma|supabase|drizzle/i.test(l))
332    return {
333      summary: 'change the database schema (not previewable here)',
334      details: touched.map(l => `uncommitted ${l.trim()}`).slice(0, MAX_LISTED),
335    }
336  } catch {
337    return { summary: `run a risky ${found.risk} command (could not measure it)`, details: [] }
338  }
339}
340
341// Shell-like words: quotes kept together, the quotes themselves dropped.
342function split(text: string) {
343  return (text.match(/"[^"]*"|'[^']*'|\S+/g) ?? []).map(w => w.replace(/^["']|["']$/g, ''))
344}
345
346// A path from the folder being deleted (build/chunk-1.js), not the whole path.
347function short(file: string, targets: string[]) {
348  for (const t of targets) {
349    const base = t.replace(/\/+$/, '')
350    const parent = base.includes('/') ? base.slice(0, base.lastIndexOf('/') + 1) : ''
351    if (parent && file.startsWith(parent)) return file.slice(parent.length)
352  }
353  return file
354}
355
356function size(kb: number) {
357  if (kb >= 1024 * 1024) return `${(kb / 1024 / 1024).toFixed(1)} GB`
358  if (kb >= 1024) return `${(kb / 1024).toFixed(1)} MB`
359  return `${kb} KB`
360}
361
hooks/bands.ts 73 lines
1// Band order: every band mod ships this same file (canonical copy: shared/bands.ts in the repo).
2//   The order file, <configDir>/claude-code-mods/bands.json, lists mod names top to bottom
3//   (the last sits right above the prompt). Each band mod wraps its band in a Box keyed
4//   `band:<name>` and returns one column keyed `bands`; the next band mod unpacks that
5//   column, adds its own band and sorts again, so the last one to draw sorts them all.
6//   Edit shared/bands.ts, then copy it over every mod's hooks/bands.ts (a repo test checks).
7//   Reading the file needs $, which Claude Code follows only within one file, so each mod
8//   reads it in its own register file with bandsFile and parseOrder, once per session.
9import type { RenderElement, RenderNode } from 'claude-code'
10
11const COLUMN = 'bands'
12const PREFIX = 'band:'
13
14// Where the checklist saves the order: CLAUDE_CONFIG_DIR, or ~/.claude. Undefined with neither.
15export function bandsFile(configDir: string | undefined, home: string | undefined): string | undefined {
16  const dir = configDir ? configDir.replace(/\/+$/, '') : home ? `${home}/.claude` : ''
17  return dir ? `${dir}/claude-code-mods/bands.json` : undefined
18}
19
20// The names in an order file; [] when it is no JSON or not { order: string[] }.
21export function parseOrder(text: string): string[] {
22  try {
23    const order = (JSON.parse(text) as { order?: unknown } | null)?.order
24    return Array.isArray(order) && order.every(n => typeof n === 'string') ? order : []
25  } catch {
26    return []
27  }
28}
29
30/**
31 * One column of bands: `mine` wrapped in a Box keyed `band:<name>`, placed
32 * `at` (above or below) what `next(e)` returned, then sorted by `order`.
33 * With an order, items that are no band come first, then bands not in the
34 * order (in their current order), then the listed ones in file order. With
35 * no order nothing moves, so the screen looks as it did before.
36 */
37export function stackBands(rest: RenderElement | null | undefined, name: string, mine: RenderNode, at: 'above' | 'below', order: string[]): RenderElement {
38  const own: RenderElement = { type: 'Box', props: { key: PREFIX + name, flexDirection: 'column' }, children: [mine] }
39  const others = unpack(rest)
40  const items = at === 'above' ? [own, ...others] : [...others, own]
41  return { type: 'Box', props: { key: COLUMN, flexDirection: 'column' }, children: order.length ? sorted(items, order) : items }
42}
43
44// Another band mod's column gives up its items; anything else is one item.
45function unpack(rest: RenderElement | null | undefined): RenderNode[] {
46  if (!rest) return []
47  if (!isColumn(rest)) return [rest]
48  const kids = rest.children ?? []
49  const owner = rest.part?.plugin
50  // The engine stamped the column with its owner: that stamp belongs on the owner's own band.
51  return kids.map(k => (owner && typeof k !== 'string' && bandName(k) === owner && !('part' in k && k.part) ? { ...k, part: rest.part } : k))
52}
53
54function isColumn(el: RenderElement): el is RenderElement & { type: 'Box' } {
55  return el.type === 'Box' && el.props?.key === COLUMN
56}
57
58function bandName(node: RenderNode): string | undefined {
59  if (typeof node === 'string' || node.type !== 'Box') return undefined
60  const key = node.props?.key
61  return typeof key === 'string' && key.startsWith(PREFIX) ? key.slice(PREFIX.length) : undefined
62}
63
64function sorted(items: RenderNode[], order: string[]): RenderNode[] {
65  const rank = (n: RenderNode) => {
66    const name = bandName(n)
67    if (name === undefined) return -2 // not a band: first
68    const i = order.indexOf(name)
69    return i < 0 ? -1 : i // a band not in the order: on top of the listed ones
70  }
71  return items.map((n, i) => ({ n, i, r: rank(n) })).sort((a, b) => a.r - b.r || a.i - b.i).map(x => x.n)
72}
73
types/index.d.ts 17 lines
1export type HeldCommand = {
2  id: string
3  command: string
4  risk: 'delete' | 'force-push' | 'migration'
5  summary: string
6  details: string[]
7  where: 'pane' | 'band'
8  /** Seconds until the hold auto-cancels; null when it waits for a press forever. */
9  secondsLeft: number | null
10}
11
12declare module 'claude-code' {
13  interface PluginState {
14    'blast-radius-win': { held: HeldCommand | null }
15  }
16}
17