SLOPSHOPPER

blast-radius

Holds risky shell commands (rm -rf, git reset --hard, git clean, force push, migrations) and shows what they would hit.

newpanebandguardtoastprocess
★ 1v0.7.1Apache-2.0updated 2026-10-26LupusMalusDeviant/mod-box/plugins/blast-radius
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · blast-radius
│ ┃ Blast Radius ✕ › fix the failing auth test and add an audit log call │ ┃ ╭─────────────────────────────────────────── │ ┃ │ ⚠ Blast Radius · rm -rf ⏺ Read(src/auth.ts) │ ┃ │ Command rm -rf build && git push --for… ⎿ Read 6 lines │ ┃ │ Would delete nothing: no file matches ⏺ Update(src/auth.ts) │ ┃ │ build ⎿ Added 2 lines, removed 1 line │ ┃ │ ⏺ Bash(rm -rf build && git push --force origin main) │ ┃ │ The paths don't exist, so rm has nothing ⎿ Denied by blast-radius: Blast Radius held this command an │ ┃ │ to remove. │ ┃ │ ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ │ 1: Proceed 2: Cancel Claude is waiting o │ ┃ ╰─────────────────────────────────────────── ✻ Worked for 42s · done 4:20 PM │ │ │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Blast Radius · while holding a tool call
╭──────────────────────────────────────────────────────────╮ │ ⚠ Blast Radius · rm -rf │ │ Command rm -rf build && git push --force origin main │ │ Would delete nothing: no file matches build │ │ │ │ The paths don't exist, so rm has nothing to remove. │ │ │ │ 1: Proceed 2: Cancel Claude is waiting on your answer │ ╰──────────────────────────────────────────────────────────╯
README

Blast Radius

Holds risky shell commands (rm -rf, git reset --hard, git clean, force push, migrations) and shows what they would hit.

Fork. Based on anthropics/claude-code-playground by Anthropic PBC (Apache-2.0); modified for the Mod Box: it works on Windows (PowerShell wait loop, Git Bash, Windows cd targets). Each changed file says so at the top.

Install

In a terminal Claude Code session (2.1.287 or newer):

/plugin install blast-radius --marketplace LupusMalusDeviant/mod-box

Or from a shell:

claude plugin marketplace add LupusMalusDeviant/mod-box
claude plugin install blast-radius@mod-box

Hooks into

PreToolUse, PostToolUse

License

Apache-2.0, see LICENSE.

Source 2 files
hooks/entry.ts 9 lines
1// Entry of the standalone Blast Radius: the Mod Box code, registered on its own.
2import type { Register } from 'claude-code'
3
4import { register as inner } from '../mods/blast-radius/hooks/blast-radius.mjs'
5
6export const register: Register = (on, options) => {
7  ;(inner as any)(on, options)
8}
9
mods/blast-radius/hooks/blast-radius.mjs 590 lines
1// Copyright 2026 Anthropic PBC
2// SPDX-License-Identifier: Apache-2.0
3// Modified for mod-box (https://github.com/LupusMalusDeviant/mod-box): Windows support (PowerShell wait loop, Git Bash, Windows cd targets), state/matchers/paths rewritten for the single plugin, the held call offered to the Desktop Pet (blast:held / blast:pet in $.store).
4// Original: https://github.com/anthropics/claude-code-playground/tree/main/claude-code/mods/blast-radius (commit 569c528).
5//
6// Blast Radius: holds a risky Bash command and shows what it would change.
7//
8// tool.call (Bash): if the command is risky, work out its blast radius, open a
9// pane with Proceed and Cancel, and hold the call until one is pressed.
10// ui.render (Pane): draws the report. If the surface won't place the pane (a
11// narrow terminal), the same report is drawn in the AbovePrompt band instead.
12//
13// Holding: a hook has 10 s of its own time, but time spent inside a `$` call is
14// free. So the hold loop waits on a short `$.process.run(["sleep", ...])` until
15// a button's onPress sets the decision.
16//
17// The host reads `on(...)` and `$.noun.method(...)` from source, so they are
18// spelled literally, and helpers that take `$` are top-level functions.
19
20const PANE_ID = "blast-radius";
21const POLL_SECONDS = "0.25";
22const HOLD_LIMIT_MS = 10 * 60 * 1000;
23const LIST_MAX = 10;
24
25// The call being held, or null. One at a time: Bash calls in a turn run in order.
26let held = null;
27
28export function register(on) {
29  on("tool.call", { tool: "Bash" }, async ($, e, next) => {
30    const risk = classify(String(e.command ?? ""));
31    if (risk === null) {
32      return next(e);
33    }
34    // One hold at a time. If another risky call is already held (a subagent's,
35    // say), wait until it is answered. `held` is claimed with no await between
36    // the check and the claim, so two waiting calls can't both get through.
37    while (held !== null) {
38      if (next.signal.aborted) {
39        return { deny: "Blast Radius held this command and did not run it: the turn was interrupted. Do not retry it unless the user asks you to." };
40      }
41      await pause($);
42    }
43    const mine = { command: String(e.command), risk, report: null, decision: null, where: "pane" };
44    held = mine;
45
46    let opened = { isPlaced: false };
47    let decision;
48    let summary = risk.label;
49    try {
50      // Measure where the command will run: the session folder, moved by any
51      // `cd dir &&` or `git -C dir` earlier in the same command line.
52      const sessionCwd = await $.session.cwd();
53      const cwd = risk.dir ? await resolveDir($, sessionCwd, risk.dir) : sessionCwd;
54      mine.report = cwd === null
55        ? { summary: `${risk.label} in ${risk.dir}`, lines: [], note: `Couldn't find the folder ${risk.dir}, so I couldn't measure what this would change.` }
56        : await measure($, risk, cwd);
57      summary = mine.report.summary;
58
59      opened = await $.ui.open({ id: PANE_ID, title: "Blast Radius", focus: true, rows: paneRows(mine.report) });
60      if (!opened.isPlaced) {
61        mine.where = "band";
62      }
63      $.ui.invalidate("ui.render");
64      // Mod Box: the Desktop Pet shows the held call (and, when it may answer, its buttons).
65      petKey = `blast:held:${await $.session.id().catch(() => "")}`;
66      mine.id = String(await $.clock.now());
67      await $.store.set(petKey, { id: mine.id, label: risk.label, summary, command: mine.command }).catch(() => {});
68
69      const startedAt = await $.clock.now();
70      while (mine.decision === null) {
71        await fromPet($, mine);
72        if (mine.decision !== null) {
73          break;
74        }
75        if (next.signal.aborted) {
76          mine.decision = "interrupted";
77          break;
78        }
79        if ((await $.clock.now()) - startedAt > HOLD_LIMIT_MS) {
80          mine.decision = "timeout";
81          break;
82        }
83        await pause($);
84      }
85    } catch {
86      mine.decision = "error"; // anything unexpected refuses the command
87    } finally {
88      decision = mine.decision;
89      // Close this call's pane before releasing the hold, so the next call's
90      // pane can't be the one that gets closed.
91      try {
92        if (opened.isPlaced) {
93          await $.ui.close({ id: PANE_ID });
94        }
95      } catch {
96        // the pane is already gone
97      }
98      if (held === mine) {
99        held = null;
100      }
101      await $.store.delete(petKey).catch(() => {});
102      $.ui.invalidate("ui.render");
103    }
104
105    if (decision === "proceed") {
106      $.ui.toast("Blast Radius: running it");
107      return next(e);
108    }
109    const why = {
110      cancel: "the user pressed Cancel",
111      timeout: "no answer within 10 minutes",
112      interrupted: "the turn was interrupted",
113      error: "Blast Radius hit an error while holding it",
114    }[decision] ?? "no answer was recorded";
115    return {
116      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.`,
117    };
118  });
119
120  on("ui.render", { component: "Pane" }, ($, e, next) => {
121    if (e.requestId !== PANE_ID || held === null || held.report === null) {
122      return next(e);
123    }
124    return draw($.ui.resolve(e), held);
125  });
126
127  on("ui.render", { component: "AbovePrompt" }, ($, e, next) => {
128    if (held === null || held.report === null || held.where !== "band") {
129      return next(e);
130    }
131    return draw($.ui.resolve(e), held);
132  });
133}
134
135// ---- What counts as risky -------------------------------------------------
136
137// sudo options that take a value, so the value isn't read as the command.
138const SUDO_VALUE_OPTIONS = new Set(["-u", "-g", "-C", "-D", "-h", "-p", "-r", "-t", "-T", "-U"]);
139// Commands that only read, so a bare word "migrate" in them isn't a migration.
140const READ_ONLY = new Set(["ls", "cat", "echo", "printf", "grep", "rg", "find", "less", "head", "tail", "cd", "git"]);
141
142/** A folder a later `cd arg` moves to, given the folder so far (null = the session folder). */
143function joinDir(dir, arg) {
144  if (arg === undefined || arg === "~" || arg.startsWith("/") || arg.startsWith("~/")) {
145    return arg ?? "~";
146  }
147  return dir ? `${dir}/${arg}` : arg;
148}
149
150/** The first risky segment of a shell command, or null. */
151function classify(command) {
152  let dir = null; // where a `cd` earlier on the line moved to; null means the session folder
153  const scopes = []; // dir to restore when a ( subshell ) closes
154  const pushed = []; // pushd stack, for popd
155  for (const raw of command.split(/&&|\|\||;|\||\n/)) {
156    const opens = (raw.match(/^\s*\(+/)?.[0].trim().length) ?? 0;
157    // Trailing redirects and & don't hide a closing ) : `(cd sub && make) > log`.
158    const tail = raw.replace(/(?:\s*(?:\d*>>?|&>>?|<)\s*\S+|\s*&)+\s*$/, "");
159    const closes = (tail.match(/\)+\s*$/)?.[0].trim().length) ?? 0;
160    for (let k = 0; k < opens; k += 1) {
161      scopes.push(dir);
162    }
163    const risk = classifySegment(raw, dir, pushed);
164    if (risk !== null && risk.cd === undefined) {
165      return risk;
166    }
167    if (risk !== null) {
168      dir = risk.cd; // a cd, pushd or popd moved the folder
169    }
170    for (let k = 0; k < closes && scopes.length > 0; k += 1) {
171      dir = scopes.pop(); // a cd inside ( ... ) doesn't outlive it
172    }
173  }
174  return null;
175}
176
177// Words that can come before the real command without changing what it does.
178const PREFIXES = new Set(["command", "exec", "env", "nohup", "time", "then", "do", "else", "!"]);
179
180/** One segment: a risk, { cd } for a folder change, or null. */
181function classifySegment(segment, dir, pushed) {
182  {
183    const words = tokenize(segment.trim().replace(/^[({]+\s*/, "").replace(/\s*[)}]+$/, ""));
184    while (words.length > 0 && /^[A-Za-z_][A-Za-z0-9_]*=/.test(words[0])) {
185      words.shift(); // leading VAR=value
186    }
187    if (words[0] === "sudo") {
188      words.shift();
189      while (words.length > 0 && words[0].startsWith("-")) {
190        const option = words.shift();
191        if (SUDO_VALUE_OPTIONS.has(option)) {
192          words.shift();
193        }
194      }
195    }
196    while (words.length > 0 && (PREFIXES.has(words[0]) || /^[A-Za-z_][A-Za-z0-9_]*=/.test(words[0]))) {
197      words.shift();
198    }
199    if (words[0] === "nice") {
200      words.shift();
201      if (words[0] === "-n") {
202        words.splice(0, 2);
203      } else if (/^-\d+$/.test(words[0] ?? "")) {
204        words.shift();
205      }
206    }
207    const [first, ...args] = words;
208    if (first === undefined) {
209      return null;
210    }
211    const cmd = first.replace(/^\\/, ""); // \rm skips aliases; it's still rm
212    if (cmd === "cd") {
213      return { cd: args[0] === "-" ? "-" : joinDir(dir, args[0]) };
214    }
215    if (cmd === "pushd") {
216      pushed.push(dir);
217      return { cd: joinDir(dir, args[0]) };
218    }
219    if (cmd === "popd") {
220      return { cd: pushed.length > 0 ? pushed.pop() : "-" };
221    }
222    if (cmd === "rm" || cmd.endsWith("/rm")) {
223      const flags = args.filter((a) => a.startsWith("-"));
224      const recursive = flags.some((f) => f === "--recursive" || (/^-[^-]/.test(f) && /[rR]/.test(f)));
225      const force = flags.some((f) => f === "--force" || (/^-[^-]/.test(f) && f.includes("f")));
226      if (recursive || force) {
227        const targets = args.filter((a) => !a.startsWith("-") || a === "-");
228        return { kind: "rm", label: `rm ${flags.join(" ")}`.trim(), targets, dir };
229      }
230    }
231    if (cmd === "git") {
232      // Git's own options come before the subcommand; -C moves where it runs.
233      let gitDir = dir;
234      let i = 0;
235      while (i < args.length && args[i].startsWith("-")) {
236        if (args[i] === "-C" && i + 1 < args.length) {
237          gitDir = joinDir(gitDir, args[i + 1]);
238          i += 2;
239        } else if (args[i] === "-c" && i + 1 < args.length) {
240          i += 2;
241        } else {
242          i += 1;
243        }
244      }
245      const sub = args[i];
246      const rest = args.slice(i + 1);
247      if (sub === "reset" && rest.includes("--hard")) {
248        return { kind: "git-reset", label: "git reset --hard", args: rest, dir: gitDir };
249      }
250      if (sub === "clean") {
251        return { kind: "git-clean", label: "git clean", args: rest, dir: gitDir };
252      }
253      if (sub === "push" && rest.some((a) => a === "--force" || a === "-f" || a.startsWith("--force-with-lease") || /^\+/.test(a))) {
254        return { kind: "git-push-force", label: "git push --force", args: rest, dir: gitDir };
255      }
256      const stagedOnly = sub === "restore" && rest.includes("--staged") && !rest.includes("--worktree") && !rest.includes("-W");
257      if ((sub === "checkout" || sub === "restore") && rest.includes(".") && !stagedOnly) {
258        return { kind: "git-checkout", label: `git ${sub} -- .`, args: rest, dir: gitDir };
259      }
260    }
261    const joined = words.join(" ");
262    if (/\balembic\s+upgrade\b/.test(joined)) {
263      return { kind: "migrate", tool: "alembic", label: "alembic upgrade", dir };
264    }
265    if (/\bdb:migrate(?!:status\b)/.test(joined)) {
266      return { kind: "migrate", tool: "rails", label: "db:migrate", dir };
267    }
268    if (/\bprisma\s+migrate\b/.test(joined)) {
269      return { kind: "migrate", tool: "prisma", label: "prisma migrate", dir };
270    }
271    if (/\bmanage\.py\s+migrate\b/.test(joined)) {
272      return { kind: "migrate", tool: "django", label: "manage.py migrate", dir };
273    }
274    if (!READ_ONLY.has(cmd) && args.includes("migrate")) {
275      return { kind: "migrate", tool: "unknown", label: "migrate", dir };
276    }
277  }
278  return null;
279}
280
281// Resolves a `cd` target to an absolute folder, or null if it doesn't exist.
282// The target is passed as an argument, never as source.
283const CD_SCRIPT = `unset CDPATH; d="$1"; case "$d" in "~") d="$HOME";; "~/"*) d="$HOME/\${d#\\~/}";; esac; cd -- "$d" 2>/dev/null && { pwd -W 2>/dev/null || pwd -P; }`;
284// Mod Box: under Git Bash (Windows) `pwd -W` prints the Windows path (X:/…) that $.process.run
285// accepts as working folder; elsewhere it fails and `pwd -P` answers as in the original.
286
287async function resolveDir($, sessionCwd, dir) {
288  if (dir === "-") {
289    return null; // `cd -` depends on the shell's history
290  }
291  const run = await $.process.run([await bashExe($), "-c", CD_SCRIPT, "blast-radius", dir], { cwd: sessionCwd, timeoutMs: 5000 });
292  const out = run.stdout.trim();
293  return run.exitCode === 0 && out !== "" ? out : null;
294}
295
296/** Splits one segment into words, honouring quotes. Good enough to read flags and paths. */
297function tokenize(text) {
298  const words = [];
299  const re = /"((?:[^"\\]|\\.)*)"|'([^']*)'|(\S+)/g;
300  let m;
301  while ((m = re.exec(text)) !== null) {
302    words.push(m[1] ?? m[2] ?? m[3]);
303  }
304  return words;
305}
306
307// ---- Measuring the blast radius -------------------------------------------
308
309/** { summary, lines, note } for the pane. Never throws: a failed read is said, not hidden. */
310async function measure($, risk, cwd) {
311  try {
312    if (risk.kind === "rm") {
313      return await measureRm($, risk, cwd);
314    }
315    if (risk.kind === "migrate") {
316      return await measureMigrations($, risk, cwd);
317    }
318    return await measureGit($, risk, cwd);
319  } catch (error) {
320    return { summary: `${risk.label} (could not measure it)`, lines: [], note: `Could not measure: ${String(error?.message ?? error).slice(0, 200)}` };
321  }
322}
323
324// The paths are passed to bash as arguments, never as source, so nothing in
325// them runs. compgen -G expands a glob without command substitution.
326const RM_SCRIPT = `
327shopt -s nullglob dotglob
328paths=()
329for p in "$@"; do
330  case "$p" in "~"|"~/"*) p="$HOME\${p#\\~}";; esac
331  if [[ "$p" == *[*?[]* ]]; then
332    while IFS= read -r m; do paths+=("$m"); done < <(compgen -G "$p")
333  elif [[ -e "$p" || -L "$p" ]]; then
334    paths+=("$p")
335  fi
336done
337if (( \${#paths[@]} == 0 )); then echo "0 0 0"; exit 0; fi
338# A relative path gets ./ in front, so find never reads a name like -delete as an action.
339for i in "\${!paths[@]}"; do case "\${paths[$i]}" in /*) ;; *) paths[$i]="./\${paths[$i]}";; esac; done
340files=$(find "\${paths[@]}" \\( -type f -o -type l \\) 2>/dev/null | wc -l | tr -d ' ')
341kb=$(du -skc "\${paths[@]}" 2>/dev/null | tail -n1 | cut -f1)
342echo "$files $(( \${kb:-0} * 1024 )) \${#paths[@]}"
343find "\${paths[@]}" \\( -type f -o -type l \\) 2>/dev/null | head -n ${LIST_MAX}
344`;
345
346async function measureRm($, risk, cwd) {
347  if (risk.targets.length === 0) {
348    return { summary: "rm with no paths", lines: [], note: "No paths to expand." };
349  }
350  const run = await $.process.run([await bashExe($), "-c", RM_SCRIPT, "blast-radius", ...risk.targets], { cwd, timeoutMs: 15000 });
351  const [head, ...rest] = run.stdout.split("\n").filter((l) => l !== "");
352  const [files, bytes, found] = (head ?? "0 0 0").split(" ").map(Number);
353  if (!found) {
354    return { summary: `delete nothing: no file matches ${risk.targets.join(" ")}`, lines: [], note: "The paths don't exist, so rm has nothing to remove." };
355  }
356  if (!files) {
357    return { summary: `delete ${found} ${found === 1 ? "path" : "paths"} with no files in ${found === 1 ? "it" : "them"}`, lines: [], note: `Paths: ${risk.targets.join(" ")}` };
358  }
359  return {
360    summary: `delete ${files} ${files === 1 ? "file" : "files"} (about ${size(bytes)})`,
361    lines: rest.map((l) => l.replace(/^\.\//, "")),
362    more: Math.max(0, files - rest.length),
363    note: `Paths: ${risk.targets.join(" ")}`,
364  };
365}
366
367async function measureGit($, risk, cwd) {
368  if (risk.kind === "git-push-force") {
369    return await measurePush($, risk, cwd);
370  }
371  if (risk.kind === "git-clean") {
372    const flags = [];
373    const paths = [];
374    for (let i = 0; i < risk.args.length; i += 1) {
375      const a = risk.args[i];
376      if (a === "--") {
377        paths.push(...risk.args.slice(i + 1));
378        break;
379      }
380      if (a === "-e" || a === "--exclude") {
381        flags.push(a, risk.args[i + 1] ?? "");
382        i += 1;
383      } else if (a.startsWith("--exclude=") || /^-e./.test(a)) {
384        flags.push(a);
385      } else if (/^-[a-zA-Z]+$/.test(a)) {
386        const kept = a.replace(/[finq]/g, ""); // -n is added below; -f, -i and -q would change the dry run
387        if (kept !== "-") {
388          flags.push(kept);
389        }
390      } else if (!a.startsWith("-")) {
391        paths.push(a);
392      }
393    }
394    const run = await $.process.run(["git", "clean", "-n", ...flags, "--", ...paths], { cwd, timeoutMs: 15000 });
395    if (run.exitCode !== 0) {
396      return { summary: "git clean (could not dry-run it)", lines: [], note: run.stderr.trim().slice(0, 200) };
397    }
398    const gone = run.stdout.split("\n").filter((l) => l.startsWith("Would remove ")).map((l) => l.slice(13));
399    return {
400      summary: gone.length === 0 ? "remove nothing: no untracked files match" : `remove ${gone.length} untracked ${gone.length === 1 ? "path" : "paths"}`,
401      lines: gone.slice(0, LIST_MAX),
402      more: Math.max(0, gone.length - LIST_MAX),
403      note: "From git clean -n. Untracked files are not in git, so they can't be recovered.",
404    };
405  }
406  const status = await $.process.run(["git", "status", "--porcelain"], { cwd, timeoutMs: 15000 });
407  if (status.exitCode !== 0) {
408    return { summary: `${risk.label} (not a git repo here?)`, lines: [], note: status.stderr.trim().slice(0, 200) };
409  }
410  const rows = status.stdout.split("\n").filter((l) => l.length > 3 && !l.startsWith("??"));
411  // reset --hard drops staged and unstaged changes; checkout -- . drops unstaged ones.
412  const lost = risk.kind === "git-reset" ? rows : rows.filter((l) => l[1] !== " ");
413  const stat = await $.process.run(["git", "diff", "--shortstat", risk.kind === "git-reset" ? "HEAD" : "--"], { cwd, timeoutMs: 15000 });
414  return {
415    summary: lost.length === 0 ? "discard nothing: no uncommitted changes" : `discard uncommitted changes in ${lost.length} ${lost.length === 1 ? "file" : "files"}`,
416    lines: lost.slice(0, LIST_MAX).map((l) => `${l.slice(0, 2)} ${l.slice(3)}`),
417    more: Math.max(0, lost.length - LIST_MAX),
418    note: stat.stdout.trim() !== "" ? `${stat.stdout.trim()}. Uncommitted changes can't be recovered.` : "From git status --porcelain.",
419  };
420}
421
422async function measurePush($, risk, cwd) {
423  const positional = risk.args.filter((a) => !a.startsWith("-"));
424  const remote = positional[0] ?? "origin";
425  // A refspec is src:dst. With no colon, the local branch of the same name is pushed.
426  const spec = (positional[1] ?? "").replace(/^\+/, "");
427  let [source, branch] = spec.includes(":") ? spec.split(":") : [spec, spec];
428  branch = (branch ?? "").replace(/^refs\/heads\//, "");
429  if (!branch) {
430    const head = await $.process.run(["git", "rev-parse", "--abbrev-ref", "HEAD"], { cwd, timeoutMs: 10000 });
431    branch = head.stdout.trim();
432    source = "HEAD";
433  } else if (branch === "HEAD") {
434    // `git push origin HEAD` pushes the current branch to its namesake.
435    const head = await $.process.run(["git", "rev-parse", "--abbrev-ref", "HEAD"], { cwd, timeoutMs: 10000 });
436    branch = head.stdout.trim();
437    source = "HEAD";
438  }
439  source = source || "HEAD";
440  const ref = `${remote}/${branch}`;
441  const known = await $.process.run(["git", "rev-parse", "--verify", "--quiet", ref], { cwd, timeoutMs: 10000 });
442  if (known.exitCode !== 0) {
443    return { summary: `force-push to ${ref}`, lines: [], note: `No local copy of ${ref}, so I can't tell which commits the push would drop. Run git fetch first.` };
444  }
445  const log = await $.process.run(["git", "log", "--oneline", "--no-decorate", `${source}..${ref}`], { cwd, timeoutMs: 15000 });
446  const dropped = log.stdout.split("\n").filter((l) => l !== "");
447  return {
448    summary: dropped.length === 0 ? `force-push to ${ref}: drops no commits` : `force-push to ${ref}: drops ${dropped.length} ${dropped.length === 1 ? "commit" : "commits"}`,
449    lines: dropped.slice(0, LIST_MAX),
450    more: Math.max(0, dropped.length - LIST_MAX),
451    note: `Commits on ${ref} that ${source} doesn't have, as of the last fetch.`,
452  };
453}
454
455const MIGRATION_LISTERS = {
456  django: { argv: ["python3", "manage.py", "showmigrations", "--plan"], pending: (l) => l.startsWith("[ ]"), strip: (l) => l.slice(4) },
457  alembic: { argv: ["alembic", "history", "-r", "current:head"], pending: (l) => l.includes("->"), strip: (l) => l },
458  rails: { argv: ["bin/rails", "db:migrate:status"], pending: (l) => /^\s*down\b/.test(l), strip: (l) => l.trim() },
459  prisma: { argv: ["npx", "--no-install", "prisma", "migrate", "status"], pending: (l) => /^\s{2}\S/.test(l), strip: (l) => l.trim() },
460};
461
462async function measureMigrations($, risk, cwd) {
463  const lister = MIGRATION_LISTERS[risk.tool];
464  if (lister === undefined) {
465    return { summary: "run migrations", lines: [], note: "I can't list the pending migrations for this tool, so the list is not shown." };
466  }
467  let run;
468  try {
469    run = await $.process.run(lister.argv, { cwd, timeoutMs: 20000 });
470  } catch (error) {
471    run = { exitCode: -1, stdout: "", stderr: String(error?.message ?? error) };
472  }
473  if (run.exitCode !== 0) {
474    return { summary: `run ${risk.label}`, lines: [], note: `Couldn't list pending migrations (${lister.argv.join(" ")} failed).` };
475  }
476  const pending = run.stdout.split("\n").filter(lister.pending).map(lister.strip);
477  return {
478    summary: pending.length === 0 ? `run ${risk.label}: nothing pending` : `apply ${pending.length} pending ${pending.length === 1 ? "migration" : "migrations"}`,
479    lines: pending.slice(0, LIST_MAX),
480    more: Math.max(0, pending.length - LIST_MAX),
481    note: `From ${lister.argv.join(" ")}.`,
482  };
483}
484
485function size(bytes) {
486  if (!Number.isFinite(bytes) || bytes < 1024) {
487    return `${bytes || 0} B`;
488  }
489  const units = ["KB", "MB", "GB", "TB"];
490  let n = bytes;
491  let i = -1;
492  while (n >= 1024 && i < units.length - 1) {
493    n /= 1024;
494    i += 1;
495  }
496  return `${n.toFixed(n < 10 ? 1 : 0)} ${units[i]}`;
497}
498
499// ---- Drawing --------------------------------------------------------------
500
501function paneRows(report) {
502  return Math.min(24, 9 + report.lines.length + (report.more ? 1 : 0));
503}
504
505// Mod Box: where the held call waits for the Desktop Pet; the pet's answer lands beside it.
506let petKey = "blast:held:";
507async function fromPet($, mine) {
508  const key = petKey.replace("blast:held:", "blast:pet:");
509  const a = await $.store.get(key).catch(() => null);
510  if (!a || typeof a !== "object") {
511    return;
512  }
513  await $.store.delete(key).catch(() => {});
514  if (a.id === mine.id && (a.choice === "proceed" || a.choice === "cancel") && mine.decision === null) {
515    mine.decision = a.choice;
516  }
517}
518
519function draw(t, state) {
520  const { Box, Text, Button } = t;
521  const { report } = state;
522  const list = report.lines.map((line, i) => Text({ key: `l${i}`, children: `  ${line}`, wrap: "truncate-end" }));
523  if (report.more) {
524    list.push(Text({ key: "more", dimColor: true, children: `  + ${report.more} more` }));
525  }
526  // The buttons answer the call this pane was drawn for, never whichever one is held now.
527  const decide = (choice) => () => {
528    if (state.decision === null) {
529      state.decision = choice;
530    }
531  };
532  return Box({
533    flexDirection: "column",
534    borderStyle: "round",
535    borderColor: "yellow",
536    paddingX: 1,
537    children: [
538      Text({ key: "title", bold: true, color: "yellow", children: `⚠ Blast Radius · ${state.risk.label}` }),
539      Text({ key: "cmd", children: [Text({ dimColor: true, children: "Command  " }), Text({ bold: true, children: state.command })], wrap: "truncate-end" }),
540      Text({ key: "sum", children: [Text({ dimColor: true, children: "Would    " }), Text({ color: "red", bold: true, children: report.summary })] }),
541      Box({ key: "list", flexDirection: "column", marginTop: 1, children: list }),
542      report.note ? Text({ key: "note", dimColor: true, italic: true, children: report.note, wrap: "wrap" }) : null,
543      Box({
544        key: "buttons",
545        marginTop: 1,
546        gap: 2,
547        children: [
548          Button({ key: "proceed", label: "Proceed", hotkey: "1", plain: true, onPress: decide("proceed") }),
549          Button({ key: "cancel", label: "Cancel", hotkey: "2", plain: true, autoFocus: true, onPress: decide("cancel") }),
550          Text({ key: "hint", dimColor: true, children: "Claude is waiting on your answer" }),
551        ],
552      }),
553    ],
554  });
555}
556
557// Mod Box: wait in a process so it doesn't count against the hook's time budget (as in the
558// original). Windows has no "sleep" on PATH; PowerShell does it there.
559let sleepArgv = null;
560async function pause($) {
561  if (sleepArgv === null) {
562    sleepArgv = (await $.env.get("OS")) === "Windows_NT"
563      ? ["powershell", "-NoProfile", "-NonInteractive", "-Command", `Start-Sleep -Milliseconds ${Math.round(Number(POLL_SECONDS) * 1000)}`]
564      : ["sleep", POLL_SECONDS];
565  }
566  await $.process.run(sleepArgv, { timeoutMs: 10000 });
567}
568
569// Mod Box: on Windows, "bash" on PATH is often the WSL launcher, which doesn't know Windows paths.
570// Git Bash ships the GNU tools the scripts above need (find, head, du).
571let bashPath = null;
572async function bashExe($) {
573  if (bashPath === null) {
574    bashPath = "bash";
575    if ((await $.env.get("OS")) === "Windows_NT") {
576      const candidates = [
577        `${(await $.env.get("ProgramFiles")) ?? "C:/Program Files"}/Git/bin/bash.exe`,
578        `${(await $.env.get("LOCALAPPDATA")) ?? ""}/Programs/Git/bin/bash.exe`,
579      ];
580      for (const c of candidates) {
581        if (await $.fs.exists(c).catch(() => false)) {
582          bashPath = c;
583          break;
584        }
585      }
586    }
587  }
588  return bashPath;
589}
590