SLOPSHOPPER

blast-radius

Holds risky shell commands (rm -rf, git reset --hard, git clean, force push, migrations) and shows what they would change, with Proceed and Cancel buttons.

newpanebandguardtoastprocess
v0.1.0NOASSERTIONupdated 2026-10-09cGradying/claude-code-cockpit/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

claude-code-cockpit

A one-line band above the Claude Code prompt, a roomy Cockpit panel beside the conversation, and seven community mods, installed as one plugin. The band shows only what you glance at. The panel has everything else: prompt-cache countdown, context, plan limits with their reset times, session cost, your next tasks and project shortcuts. Everything that spends tokens or redraws your screen starts off.

 ◔ 42m   ctx 72.5k   Next  Write docs/specs/PLAN-2.md for the phase 2 slices        Continue   Details

The band is drawn with Claude's own text and buttons, so it takes the app's fonts and theme, truncates the task by itself, and cannot flash or resize. Details (or /cockpit) opens the panel as a tab in the right-hand side panel.

<img src="docs/images/band.png" alt="The band above the prompt in the Claude desktop app: working, context 704.8k at 70 percent, the next task, and a Details button" width="720">

<img src="docs/images/panel.png" alt="The Cockpit panel in the side panel: prompt cache with a green bar, context at 70 percent, plan limits with reset times, session cost, next up and shortcuts" width="320">

These are screenshots of the real desktop app, showing the dark theme.

What is inside

PluginWhat it doesDefault hereOrigin
cockpitThe band, the Cockpit panel, next-task list, Continue / What's next / Compact buttons, guards, and a cheat-sheet skillonthis repo (MIT)
cache-taxShows the rewrite price before a cold send; opt-in keep-warm for the prompt cachewarns, never drops; keep-warm offkaranb192/cache-tax
claude-reflect (reflect-mod)Notices corrections and offers to save them as rules in CLAUDE.mdpausedBayramAnnakov/claude-reflect
skinsRedraws the transcript: tool rows, diff cards, table and code cards, spinneroff, its own band offhellosverre/claude-skins
filetreeIDE-style file tree in the sidebar with git status and read/write activityopens with /filetree (does not dock by itself)data-goblin/claude-code-filetree
replay-theaterStep through the last turn's file edits, one diff at a timeonanthropics/claude-code-playground
blast-radiusHolds rm -r, git reset --hard, git clean, force push and migrations until you press Proceedonanthropics/claude-code-playground
savvy-progressProgress bar and agents panel for flows and subagentsidle until something reportsJohnnyVizz/claude-kit

The seven community mods are vendored here at pinned commits, with small patches (listed in each folder's SOURCE.md) so they start quiet and stack above each other instead of replacing each other's band. Their original licences are kept.

Install

Needs Claude Code 2.1.287 or later.

/plugin marketplace add cGradying/claude-code-cockpit
/plugin install cockpit@janvin-mods

or from a terminal:

claude plugin marketplace add cGradying/claude-code-cockpit
claude plugin install cockpit@janvin-mods --scope user

cockpit lists the seven mods as dependencies, so installing it installs them all. If a dependency does not install by itself, install it by name, for example claude plugin install skins@janvin-mods. Then restart Claude Code (or the desktop app) and open a new session.

To remove everything: /plugin, pick cockpit, uninstall, and do the same for each mod you no longer want. Each one is independent.

A quick tour

The band. One line above the prompt: the cache countdown (◔ 42m, green, then amber, then red), context, and the next task. The 5-hour and weekly limits join the line only once they pass 60%, and 9router down shows only if it was up and stopped. Buttons sit at the right; click them, or focus the band (ctrl+x then tab) and press the key.

ButtonKeyDoes
ContinuecSends "continue with the next task" for the first open line of .claude/NEXT.md, and asks Claude to check it off when done. Hidden while a turn runs.
DetailsdOpens the Cockpit panel.
CompactkAppears past 70% context. Runs /compact and tells it to keep the plan, open tasks, PRs, decisions and open bugs.

The Cockpit panel. One tab in the side panel with room for each number: prompt cache (time left, a bar, what happens if it lapses), context (tokens, percent, window size, Compact), plan limits (each with a bar and when it resets), this session (cost, 9router), your next five tasks (Continue, What's next?), and your project shortcuts. Bars are solid boxes sized in percent and follow the app's light or dark theme. Open it with Details or /cockpit.

Guards that run quietly. A shell command carrying a Claude or Anthropic co-author line is refused. A prompt containing a pasted API key is held once (press Enter again to send it anyway). Prompts about UI or design get a hidden nudge toward the impeccable skill if you have it.

Commands. /cockpit, /next, /keepwarm, /cache-tax, /reflect-pause, /reflect-queue, /skin, /filetree, /replay, /agents-info. Details in docs/COMMANDS.md; inside Claude Code, the cockpit:help skill prints the same cheat sheet.

Quiet by default

Three things in the bundle can cost tokens or change your screen, so they start off:

  • Keep-warm (cache-tax) sends a real model request about every 50 minutes while armed. It is off; you arm it with /keepwarm. The cold-send guard only warns by default: it shows the rewrite price and still sends your message.
  • Correction checking (claude-reflect) sends short prompts to Haiku. It starts paused; /reflect-pause turns it on. remember: <rule> works either way and needs no model.
  • Skins redraw the whole transcript. It starts off; /skin noir turns it on.

How to change each: docs/CONFIGURATION.md.

Documentation

Status and limits

  • Written for Claude Code's mods feature, which is early access and may change between releases.
  • The band and panel were built by looking at the real desktop app, and are tested with Claude Code's plugin test kit on the terminal and desktop surfaces. They have been run in the desktop app's Code tab; the terminal layout is covered by tests but has had less eyes on it. Please open an issue with a screenshot if something looks wrong.
  • The file tree opens with /filetree (set autoOpen to on to dock it at session start). It needs a wide window, and upstream says it does not load in WSL sessions of the desktop app.
  • Cache timings assume the one-hour prompt cache. With a five-minute cache the countdown will be wrong.

Credits and licences

The code in cockpit/ is MIT, Copyright (c) 2026 cGradying (see LICENSE). Each vendored mod keeps its own licence file and authorship: the two Anthropic mods are Apache-2.0, the rest are MIT. See THIRD_PARTY_NOTICES.md. Thanks to the authors of all seven.

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