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

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.
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
PreToolUse, PostToolUse
Apache-2.0, see LICENSE.
hooks/entry.ts 9 lines1// 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}
9mods/blast-radius/hooks/blast-radius.mjs 590 lines1// 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