SLOPSHOPPER

Holdpane

Holds risky Bash and PowerShell commands (rm -rf, Remove-Item -Recurse, git reset --hard, git clean, force push, migrations) and shows what they would change…

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

Draws

Pane · Holdpane · while holding a tool call
╭──────────────────────────────────────────────────────────╮ │ ⚠ Holdpane · 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. │ │ │ │ Proceed 2: Cancel Claude is waiting on your answer │ ╰──────────────────────────────────────────────────────────╯
README

Holdpane

Holdpane: holds risky Bash and PowerShell commands before they run

See what a command will touch, before it runs. A Claude Code mod that holds risky shell commands, shows what they would change, and waits for your Proceed or Cancel. It works for Bash and PowerShell, and it is built to run on Windows.

When Claude proposes rm -rf build, Remove-Item -Recurse -Force build or git reset --hard, the command stops. A pane opens with the files it would delete, the count and the size. Cancel is pre-selected, so a stray Enter refuses the command. Claude gets the refusal and the reason.

The Holdpane pane holding a Remove-Item -Recurse -Force command: it lists the five files it would delete (about 402 B) and shows Proceed and Cancel, with Cancel pre-selected

Based on Blast Radius from anthropics/claude-code-playground (Apache-2.0). Not an official Anthropic product. See what changed.

Install

In a terminal session of Claude Code:

/plugin install holdpane --marketplace FlyinSteve/holdpane

Answer y to add the marketplace, then pick a scope (user scope loads it in every session). From a shell:

claude plugin install holdpane --marketplace FlyinSteve/holdpane

In the desktop app, where /plugin does not run: click + next to the prompt box, then Plugins, Add marketplace with FlyinSteve/holdpane, then Add plugin.

To remove it: claude plugin uninstall holdpane.

Try it: see Examples.

Using the pane: press 2 to Cancel, or click Proceed (or Tab to it and press Enter). Proceed has no hotkey on purpose, so a typed 1 can never run a command. No answer within 10 minutes refuses the command, and so does interrupting the turn (Esc).

Examples

In a throwaway folder (a copy of a project is fine), ask Claude:

  • "Delete the build folder with rm -rf build." On PowerShell: "Delete the build folder with Remove-Item -Recurse -Force build."
  • "Run git reset --hard to throw away my uncommitted changes." The pane lists the files with uncommitted changes.
  • "Run git clean -fd to remove everything untracked." The pane lists what git clean -n would remove.

Press 2 to Cancel in each case, and check that Claude reports the refusal.

What it holds

ToolCommands
Bash, PowerShellgit reset --hard, git checkout -- . and -f, git switch -f, git restore ., git clean, git push --force (also -fu, +ref), migrations (manage.py migrate, alembic upgrade, db:migrate, prisma migrate)
Bashrm -r, rm -f, xargs rm -rf, bash -c '...', powershell -Command, cmd /c rd /s, anything after a single &
PowerShellRemove-Item (also rm, ri, del, rd, erase) with -Recurse or -Force, abbreviated (-r -fo) or across lines with backticks, $x = ..., void, Start-Process git -ArgumentList ..., cmd /c rd /s/q, powershell -Command
Monitorthe command, read as Bash and as PowerShell
allcommand lines over 8000 characters, -EncodedCommand and Remove-Item @splat are held without being read

Every other command runs as normal. If a cd, pushd, Set-Location or git -C comes first on the line, Holdpane measures in that folder.

What the pane shows

CommandWhat you see
rm, Remove-Item, rd /sthe files it would delete, with count and size; globs, -LiteralPath, ~ (in Bash) and Windows paths (C:\... and C:/...) are resolved
git reset --hard, git checkout -- .the files with uncommitted changes and the diff summary
git cleanthe untracked paths from git clean -n with the same flags
git push --forcethe commits on the remote branch that your branch doesn't have (from your last fetch)
migrationsthe tool's own status command to check by hand (see below)
anything it cannot measurean honest note instead of a guess: variables, ( ) expressions, %VAR%, network paths and del /s are reported as not measurable, never as "deletes nothing"

What it runs and what it sends

While it measures, Holdpane starts only these programs, and only to read: git (status, diff, log, clean -n, rev-parse), Git's bash, find, du and sleep, and powershell.exe (pwsh on macOS and Linux). It sends nothing anywhere: there is no network access, no telemetry, and it stores nothing. A command you answer Proceed to is run by Claude Code, not by Holdpane.

What is different from the original

The original is a good small example, written for macOS and Linux. This version:

  • Hooks PowerShell and Monitor too, and reads PowerShell syntax: parameter abbreviations, -Recurse:$false, -WhatIf, backtick line continuation, script blocks like try{...}, comments, here-strings, Start-Process, nested powershell -Command and cmd /c.
  • Finds its tools on Windows instead of assuming them. It locates Git's bash, find, du and sleep through git --exec-path. On a normal Windows PATH, find is the Windows find and bash can be the WSL launcher, which would measure wrongly.
  • Measures PowerShell natively with powershell.exe. Paths travel as ASCII JSON on stdin, so umlauts survive and nothing in a path can run as code. Junctions and symbolic links are counted, not followed.
  • Fails closed. Every hook has a .catch: if the mod itself throws, the command is refused instead of slipping through. Command lines over 8000 characters are held unread, and the line reader runs in bounded time on hostile input.
  • <a id="measuring-only-reads"></a>Measuring only reads. The measuring happens before you answer, even before the permission prompt. So it never runs a project's own tools (a migration status command loads your project's code) and it starts Git with core.fsmonitor=false. Network paths (\\server\share) are not touched.
  • Reads the command line properly. It splits at ;, |, &&, ||, & and line breaks, but not inside quotes. Quotes in comments, heredocs and here-strings don't confuse it, and if a quote is left open it reads the line again without regard to quotes.

Requirements

  • Claude Code with mods support (the original names 2.1.287 or later). Tested with 2.1.310.
  • Windows: Git for Windows (it brings bash, find, du, sleep) and Windows PowerShell 5.1, which is part of Windows. Tested on Windows 11 with Git 2.55.
  • macOS and Linux: the Bash part is the original's and should work as before; this version has not been tested there. The PowerShell part uses pwsh.
  • No packages to install, no configuration.

How far to trust it

This is a safety net, not a permission system. It reads the text of a command, it does not run a shell parser, and it cannot see what a script does. Use permission rules for a hard block.

It has been tested on a real Windows setup, including live sessions with Claude Code, and reviewed twice by an AI reviewer (Claude Opus). That is not an independent security audit.

Known limits:

  • Not recognised: Invoke-Expression, eval, [IO.Directory]::Delete, scripts and functions that delete by themselves, find -delete and find -exec rm, commands inside $(...) or aliases, a backslash-escaped space in a Bash program path (Program\ Files), -WhatIf:0.
  • Only the first risky part of a line is measured, and the pane cuts the command line off. Proceed runs the whole line: rm -rf build⏎git push --force shows only the first part.
  • Remove-Item through a pipeline and del /s are held but not measured. -Include, -Exclude and -Filter are not applied when counting.
  • Git Bash re-parses arguments at start, so a Bash path with ' or {} may be reported as "not found". The command is still held.
  • A Git filter (filter.<name>.clean) from a changed .git/config could run code during git status. Claude Code protects .git, so this is theoretical.
  • git and powershell.exe are found through PATH, not by fixed paths.
  • Without a user interface (claude -p), a held command waits the full 10 minutes.

Support

Questions, bugs, and commands that should have been held: open an issue. Security problems: see SECURITY.md. To help, see CONTRIBUTING.md; changes are listed in CHANGELOG.md.

Deutsch (Kurzfassung)

Holdpane hält riskante Befehle an, bevor sie laufen, und zeigt, was sie treffen würden: rm -rf, Remove-Item -Recurse -Force, git reset --hard, git clean, Force-Push und Migrationen. In einer Pane siehst du die betroffenen Dateien mit Anzahl und Größe und entscheidest mit Proceed oder Cancel. Cancel ist vorausgewählt (Taste 2); Proceed hat bewusst keine Taste.

Gegenüber dem Original (macOS/Linux) funktioniert es mit Bash und PowerShell unter Windows: Es findet Git-Bash, find und du selbst, misst PowerShell-Löschungen nativ (Umlaute, Junctions, C:\-Pfade), liest auch mehrzeilige und verschachtelte Befehle und lehnt einen Befehl ab, wenn der Mod selbst einen Fehler hat. Die Messung liest nur: Sie führt keine Werkzeuge deines Projekts aus, bevor du geantwortet hast.

Installation im Claude-Code-Terminal: /plugin install holdpane --marketplace FlyinSteve/holdpane

Es startet nur git, bash, find, du, sleep und powershell.exe, liest nur, sendet nichts und speichert nichts. Es ist ein Sicherheitsnetz, kein Berechtigungssystem: Harte Sperren gehören in die Permission-Regeln. Die Grenzen stehen oben unter Known limits.

License and credits

Apache License 2.0, see LICENSE and NOTICE. Based on "Blast Radius", Copyright 2026 Anthropic PBC, from anthropics/claude-code-playground. Modifications Copyright 2026 FlyinSteve. Not affiliated with or endorsed by Anthropic. Git is a trademark of Software Freedom Conservancy; PowerShell and Windows are trademarks of Microsoft.

Source 1 files
hooks/holdpane.mjs 1291 lines
1// Based on Blast Radius from anthropics/claude-code-playground (Apache-2.0,
2// Copyright 2026 Anthropic PBC). Changed for Windows and PowerShell.
3//
4// Holdpane: holds a risky Bash or PowerShell command and shows what it would change.
5//
6// tool.call (Bash, PowerShell): if the command is risky, work out its blast
7// radius, open a pane with Proceed and Cancel, and hold the call until one is
8// pressed. A hook that fails is skipped by the host and the tool then runs, so
9// both registrations carry a `.catch` that refuses in the hook's place.
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` of a sleep until a
15// button's onPress sets the decision. ($.clock.sleep would use up the budget.)
16//
17// Measuring happens BEFORE the user has answered, so it only reads: it never runs
18// the project's own tools (migration status commands run project code), and git
19// runs with fsmonitor off.
20//
21// Windows: the tools are looked up, not assumed. Bash measuring runs in Git's
22// bash (found through `git --exec-path`), and its scripts put /usr/bin first on
23// PATH, so `find` is not the Windows find. PowerShell measuring runs in
24// powershell.exe, with the paths passed as ASCII JSON on stdin, never as source.
25//
26// The host reads `on(...)` and `$.noun.method(...)` from source, so they are
27// spelled literally, and helpers that take `$` are top-level functions.
28
29const PANE_ID = "holdpane";
30const POLL_SECONDS = "0.25";
31const HOLD_LIMIT_MS = 10 * 60 * 1000;
32const STALE_SLACK_MS = 30 * 1000;
33const LIST_MAX = 10;
34// Longer command lines are held without being read: the reading is where a
35// pathological input could cost time, and no real risky command is this long.
36const MAX_COMMAND = 8000;
37// git for the measuring: fsmonitor runs a program named in the repository's config.
38const GIT = ["git", "-c", "core.fsmonitor=false", "-c", "log.showSignature=false"];
39
40// The call being held, or null. One at a time: Bash calls in a turn run in order.
41let held = null;
42// Where bash, sleep and PowerShell live on this machine; looked up on first use.
43let tools = null;
44
45export function register(on) {
46  on("tool.call", { tool: "Bash" }, async ($, e, next) => hold($, e, next, "bash")).catch(($, e, next) => refuse(e, next));
47  on("tool.call", { tool: "PowerShell" }, async ($, e, next) => hold($, e, next, "ps")).catch(($, e, next) => refuse(e, next));
48  // Monitor runs a shell command too; which shell is not known here, so it is read as both.
49  on("tool.call", { tool: "Monitor" }, async ($, e, next) => hold($, e, next, "any")).catch(($, e, next) => refuse(e, next));
50
51  on("ui.render", { component: "Pane" }, ($, e, next) => {
52    if (e.requestId !== PANE_ID || held === null || held.report === null) {
53      return next(e);
54    }
55    return draw($.ui.resolve(e), held);
56  });
57
58  on("ui.render", { component: "AbovePrompt" }, ($, e, next) => {
59    if (held === null || held.report === null || held.where !== "band") {
60      return next(e);
61    }
62    return draw($.ui.resolve(e), held);
63  });
64}
65
66/** What the `.catch` answers when the hook itself failed: refuse, unless the command was already let through. */
67function refuse(e, next) {
68  return next.called ? next(e) : { deny: "Holdpane hit an error and did not run this command. Do not retry it unless the user asks you to." };
69}
70
71async function hold($, e, next, shell) {
72  const text = String(e.command ?? "");
73  const risk = shell === "any" ? (classify(text, "bash", null) ?? classify(text, "ps", null)) : classify(text, shell, null);
74  if (risk === null) {
75    return next(e);
76  }
77  // One hold at a time. If another risky call is already held (a subagent's,
78  // say), wait until it is answered. `held` is claimed with no await between
79  // the check and the claim, so two waiting calls can't both get through.
80  let since = await $.clock.now();
81  const waitStart = since;
82  while (held !== null) {
83    if (next.signal.aborted) {
84      return { deny: "Holdpane held this command and did not run it: the turn was interrupted. Do not retry it unless the user asks you to." };
85    }
86    if (since - held.since > HOLD_LIMIT_MS + STALE_SLACK_MS) {
87      held = null; // the call that claimed it is gone without releasing it
88      break;
89    }
90    if (since - waitStart > HOLD_LIMIT_MS) {
91      return { deny: "Holdpane did not run this command: another held command was not answered within 10 minutes. Do not retry it unless the user asks you to." };
92    }
93    await pause($);
94    since = await $.clock.now();
95  }
96  const mine = { command: String(e.command), risk, report: null, decision: null, where: "pane", since };
97  held = mine;
98
99  let opened = { isPlaced: false };
100  let decision;
101  let summary = risk.label;
102  try {
103    await findTools($);
104    // Measure where the command will run: the session folder, moved by any
105    // `cd dir &&` or `git -C dir` earlier in the same command line.
106    const sessionCwd = await $.session.cwd();
107    let cwd = sessionCwd;
108    if (risk.dir) {
109      try {
110        cwd = await resolveDir($, sessionCwd, risk.dir, risk.shell);
111      } catch {
112        cwd = null; // no tool to look the folder up with: the pane still opens and says so
113      }
114    }
115    mine.report = cwd === null
116      ? { summary: `${risk.label} in ${risk.dir}`, lines: [], note: `Couldn't find the folder ${risk.dir}, so I couldn't measure what this would change.` }
117      : await measure($, risk, cwd);
118    summary = mine.report.summary;
119
120    opened = await $.ui.open({ id: PANE_ID, title: "Holdpane", focus: true, rows: paneRows(mine.report) });
121    if (!opened.isPlaced) {
122      mine.where = "band";
123    }
124    $.ui.invalidate("ui.render");
125
126    const startedAt = await $.clock.now();
127    mine.since = startedAt; // the 10 minutes run from here, so a waiting call must not take over before that
128    while (mine.decision === null) {
129      if (next.signal.aborted) {
130        mine.decision = "interrupted";
131        break;
132      }
133      if ((await $.clock.now()) - startedAt > HOLD_LIMIT_MS) {
134        mine.decision = "timeout";
135        break;
136      }
137      await pause($);
138    }
139  } catch {
140    mine.decision = "error"; // anything unexpected refuses the command
141  } finally {
142    decision = mine.decision;
143    // Close this call's pane before releasing the hold, so the next call's
144    // pane can't be the one that gets closed. A pane that was not placed waits
145    // unseen and could appear later, so it is closed too. If the hold was taken
146    // over as stale, the pane belongs to the new holder and is left alone.
147    if (held === mine) {
148      try {
149        await $.ui.close({ id: PANE_ID });
150      } catch {
151        // the pane is already gone
152      }
153      if (held === mine) {
154        held = null;
155      }
156    }
157    try {
158      $.ui.invalidate("ui.render");
159    } catch {
160      // nothing to redraw
161    }
162  }
163
164  if (decision === "proceed") {
165    $.ui.toast("Holdpane: running it");
166    return next(e);
167  }
168  const why = {
169    cancel: "the user pressed Cancel",
170    timeout: "no answer within 10 minutes",
171    interrupted: "the turn was interrupted",
172    error: "Holdpane hit an error while holding it",
173  }[decision] ?? "no answer was recorded";
174  return {
175    deny: `Holdpane held this command and did not run it: ${why}. It would have: ${summary}. Do not retry it unless the user asks you to.`,
176  };
177}
178
179// ---- Where the tools are ----------------------------------------------------
180
181/**
182 * Looks up bash, sleep and PowerShell. Never throws. On Windows the plain name
183 * `bash` can be the WSL launcher, so without Git's bash there is none (null) and the
184 * result is not kept: the next call looks again.
185 */
186async function findTools($) {
187  if (tools !== null) {
188    return tools;
189  }
190  const found = { win: false, bash: "bash", sleep: ["sleep", POLL_SECONDS], ps: "pwsh" };
191  let complete = true;
192  try {
193    found.win = (await $.env.get("OS")) === "Windows_NT";
194    if (found.win) {
195      found.ps = "powershell.exe";
196      found.bash = null;
197      complete = false;
198      const git = await $.process.run(["git", "--exec-path"], { timeoutMs: 5000 });
199      const exec = git.stdout.trim().replace(/[\\/]+$/, "");
200      const root = exec.replace(/[\\/](?:(?:mingw64|mingw32|clangarm64)[\\/])?libexec[\\/]git-core$/i, "");
201      if (git.exitCode === 0 && root !== exec) {
202        const usr = `${root}/usr/bin`;
203        const probe = await $.process.run([`${usr}/bash.exe`, "-c", "echo ok"], { timeoutMs: 8000 });
204        if (probe.exitCode === 0 && probe.stdout.trim() === "ok") {
205          found.bash = `${usr}/bash.exe`;
206          found.sleep = [`${usr}/sleep.exe`, POLL_SECONDS];
207          complete = true;
208        }
209      }
210    }
211  } catch {
212    // keep what was found
213  }
214  try {
215    await $.process.run(found.sleep, { timeoutMs: 5000 });
216  } catch {
217    found.sleep = [found.ps, "-NoProfile", "-NonInteractive", "-Command", "Start-Sleep -Milliseconds 250"];
218  }
219  if (complete) {
220    tools = found;
221  }
222  return found;
223}
224
225async function pause($) {
226  const t = await findTools($);
227  await $.process.run(t.sleep, { timeoutMs: 5000 });
228}
229
230/** JSON that is pure ASCII, so no code page can change a path on its way into PowerShell. */
231function asciiJson(value) {
232  return JSON.stringify(value).replace(/[\u007f-\uffff]/g, (c) => `\\u${c.charCodeAt(0).toString(16).padStart(4, "0")}`);
233}
234
235async function runBash($, script, args, init) {
236  const t = await findTools($);
237  if (t.bash === null) {
238    throw new Error("Git's bash was not found, so the paths can't be measured with it");
239  }
240  return await $.process.run([t.bash, "-c", script, "holdpane", ...args], init);
241}
242
243// The script has single quotes only and no line breaks that matter, so the command
244// line survives Windows argument quoting. Its input arrives as ASCII JSON on stdin.
245async function runPowerShell($, script, input, init) {
246  const t = await findTools($);
247  return await $.process.run([t.ps, "-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-Command", script], { ...init, stdin: asciiJson(input) });
248}
249
250// ---- Cutting a command line apart ---------------------------------------------
251
252/**
253 * Cuts a command line at `;` `|` `&&` `||` and line breaks, in Bash also at a single `&`,
254 * in PowerShell also at the braces of a script block. Nothing is cut inside quotes, and a
255 * brace that sits inside a word (`C:\x\{guid}`, `${env:TEMP}`, `@{a=1}`) is not a block.
256 * Comments, Bash heredocs and PowerShell here-strings are text, not commands: their quotes
257 * are skipped. Returns { parts, open }; `open` is true when a quote was never closed, which
258 * means the scan lost track, and the caller then also reads the line without regard to quotes.
259 */
260function splitCommand(command, shell) {
261  const ps = shell === "ps";
262  const parts = [];
263  const blocks = []; // for each open `{`: whether it was cut at (a block) or part of a word
264  const heredocs = []; // Bash: delimiters whose bodies start after the current line
265  let cur = "";
266  let quote = null;
267  const cut = () => {
268    parts.push(cur);
269    cur = "";
270  };
271  for (let i = 0; i < command.length; i += 1) {
272    const ch = command[i];
273    const after = command[i + 1];
274    const before = command[i - 1];
275    if (quote !== null) {
276      cur += ch;
277      if (ch === "`" && ps && quote === "\"" && after !== undefined) {
278        cur += after;
279        i += 1;
280      } else if (ch === "\\" && !ps && quote === "\"" && after !== undefined) {
281        cur += after;
282        i += 1;
283      } else if (ch === quote) {
284        quote = null;
285      }
286      continue;
287    }
288    if (ch === "#" && (i === 0 || /[\s;|&(]/.test(before))) {
289      const end = command.indexOf("\n", i); // a comment runs to the end of the line
290      i = end < 0 ? command.length : end - 1;
291    } else if (ps && ch === "<" && after === "#") {
292      const end = command.indexOf("#>", i + 2); // <# block comment #>
293      i = end < 0 ? command.length : end + 1;
294    } else if (ps && ch === "@" && (after === "'" || after === "\"") && /^[ \t]*\r?\n/.test(command.slice(i + 2, i + 40))) {
295      const close = command.indexOf(`\n${after}@`, i + 2); // @' here-string '@
296      const stop = close < 0 ? command.length : close + 3;
297      cur += command.slice(i, stop);
298      i = stop - 1;
299    } else if (!ps && ch === "<" && after === "<" && command[i + 2] !== "<") {
300      const m = /^<<(-?)[ \t]*(?:'([^'\n]*)'|"([^"\n]*)"|\\?([A-Za-z0-9_.-]+))/.exec(command.slice(i, i + 200));
301      if (m === null) {
302        cur += ch;
303      } else {
304        heredocs.push({ delim: m[2] ?? m[3] ?? m[4], strip: m[1] === "-" });
305        cur += m[0];
306        i += m[0].length - 1;
307      }
308    } else if (ch === "'" || ch === "\"") {
309      quote = ch;
310      cur += ch;
311    } else if ((ch === "`" && ps) || (ch === "\\" && !ps)) {
312      cur += ch;
313      if (after !== undefined) {
314        cur += after;
315        i += 1;
316      }
317    } else if (ps && ch === "$" && after === "{") {
318      const end = command.indexOf("}", i);
319      const stop = end < 0 ? command.length : end + 1;
320      cur += command.slice(i, stop);
321      i = stop - 1;
322    } else if ((ch === "&" && after === "&") || (ch === "|" && after === "|")) {
323      cut();
324      i += 1;
325    } else if (ch === ";" || ch === "|" || ch === "\n") {
326      cut();
327      if (ch === "\n" && heredocs.length > 0) {
328        let pos = i + 1;
329        for (const h of heredocs) {
330          pos = skipHeredoc(command, pos, h.delim, h.strip);
331        }
332        heredocs.length = 0;
333        i = pos - 1;
334      }
335    } else if (ch === "&" && !ps && before !== ">" && before !== "<" && after !== ">") {
336      cut();
337    } else if (ps && ch === "{") {
338      // A block follows whitespace, ( ; | & { }, a closing ), %, ? or a keyword (try{ else{ ForEach-Object{).
339      const isBlock = before === undefined || /[\s(;|&{})%?]/.test(before) || /(?:^|[\s;|&(){}])(?:try|catch|finally|else|do|begin|process|end|dynamicparam|foreach-object|where-object)$/i.test(cur);
340      blocks.push(isBlock);
341      if (isBlock) {
342        cut();
343      } else {
344        cur += ch;
345      }
346    } else if (ps && ch === "}") {
347      if (blocks.pop() === true) {
348        cut();
349      } else {
350        cur += ch;
351      }
352    } else {
353      cur += ch;
354    }
355  }
356  parts.push(cur);
357  return { parts, open: quote !== null };
358}
359
360/** The index after the line that ends a heredoc body (the line that is just the delimiter). */
361function skipHeredoc(command, from, delim, strip) {
362  let pos = from;
363  while (pos < command.length) {
364    const end = command.indexOf("\n", pos);
365    let line = command.slice(pos, end < 0 ? command.length : end).replace(/\r$/, "");
366    if (strip) {
367      line = line.replace(/^\t+/, "");
368    }
369    pos = end < 0 ? command.length : end + 1;
370    if (line === delim) {
371      return pos;
372    }
373  }
374  return command.length;
375}
376
377/** The line cut at every operator, with no regard to quotes: what the scan falls back on when it lost track of them. */
378function splitNaively(command, shell) {
379  return command.split(shell === "ps" ? /&&|\|\||;|\||\n|[{}]/ : /&&|\|\||;|\||\n|(?<![<>&])&(?![>&])/);
380}
381
382/** The segment without trailing redirects and `&`: `(cd sub && make) > log` still shows its closing ). */
383function stripRedirects(segment) {
384  let text = segment;
385  for (let guard = 0; guard < 50; guard += 1) {
386    // Bounded white space keeps this linear-ish on a line of thousands of spaces.
387    const trimmed = text.replace(/\s{0,64}(?:\d*>>?&?|&>>?|<)\s{0,64}[^\s<>&]+\s{0,64}$/, "").replace(/\s{0,64}&\s{0,64}$/, "");
388    if (trimmed === text) {
389      break;
390    }
391    text = trimmed;
392  }
393  return text;
394}
395
396// ---- What counts as risky -------------------------------------------------
397
398// sudo options that take a value, so the value isn't read as the command.
399const SUDO_VALUE_OPTIONS = new Set(["-u", "-g", "-C", "-D", "-h", "-p", "-r", "-t", "-T", "-U"]);
400// Commands that only read, so a bare word "migrate" in them isn't a migration.
401const READ_ONLY = new Set(["ls", "cat", "echo", "printf", "grep", "rg", "find", "less", "head", "tail", "cd", "git", "get-content", "select-string", "write-host", "write-output", "get-childitem", "gci", "dir"]);
402
403/** A risk the command line can't be read for: it is held and the pane says so. */
404function opaque(shell, label, note, dir) {
405  return { kind: "opaque", shell, label, note, dir: dir ?? null };
406}
407
408/** A folder a later `cd arg` moves to, given the folder so far (null = the session folder). */
409function joinDir(dir, arg) {
410  if (arg === undefined || arg === "~" || arg.startsWith("/") || arg.startsWith("\\") || /^[A-Za-z]:/.test(arg) || arg.startsWith("~/") || arg.startsWith("~\\")) {
411    return arg ?? "~";
412  }
413  return dir ? `${dir}/${arg}` : arg;
414}
415
416/** The first risky segment of a shell command, or null. `shell` is "bash" or "ps". */
417function classify(command, shell, startDir) {
418  if (command.length > MAX_COMMAND) {
419    return opaque(shell, "a very long command", `The command line has ${command.length} characters. Commands over ${MAX_COMMAND} are held without being read, so I can't tell what this does.`, startDir);
420  }
421  // PowerShell reads en and em dashes as a hyphen in front of a parameter.
422  const text = shell === "ps" ? command.replace(/[–—―]/g, "-") : command;
423  // A line continuation joins the lines before the command is cut apart.
424  const joined = shell === "ps" ? text.replace(/`\r?\n/g, " ") : text.replace(/\\\r?\n/g, " ");
425  const { parts, open } = splitCommand(joined, shell);
426  const risk = scanSegments(parts, shell, startDir);
427  if (risk !== null || !open) {
428    return risk;
429  }
430  // A quote was never closed: the scan lost track (a quote inside a $( ) heredoc, say).
431  // Read the line again without regard to quotes, so nothing hides behind the lost quote.
432  return scanSegments(splitNaively(joined, shell), shell, startDir);
433}
434
435/** The first risky one of the segments, following cd, pushd and popd; null if none. */
436function scanSegments(segments, shell, startDir) {
437  let dir = startDir; // where a `cd` earlier on the line moved to; null means the session folder
438  const scopes = []; // dir to restore when a ( subshell ) closes
439  const pushed = []; // pushd stack, for popd
440  for (const raw of segments) {
441    const opens = (raw.match(/^\s*\(+/)?.[0].trim().length) ?? 0;
442    const closes = (stripRedirects(raw).match(/\)+\s*$/)?.[0].trim().length) ?? 0;
443    for (let k = 0; k < opens; k += 1) {
444      scopes.push(dir);
445    }
446    const risk = shell === "ps" ? classifyPsSegment(raw, dir, pushed) : classifySegment(raw, dir, pushed);
447    if (risk !== null && risk.cd === undefined) {
448      return risk;
449    }
450    if (risk !== null) {
451      dir = risk.cd; // a cd, pushd or popd moved the folder
452    }
453    for (let k = 0; k < closes && scopes.length > 0; k += 1) {
454      dir = scopes.pop(); // a cd inside ( ... ) doesn't outlive it
455    }
456  }
457  return null;
458}
459
460// Words that can come before the real command without changing what it does.
461const PREFIXES = new Set(["command", "exec", "env", "nohup", "time", "then", "do", "else", "!"]);
462
463/** Whether the flags of an `rm` make it recursive, forced, or both. */
464function rmFlags(flags) {
465  return {
466    recursive: flags.some((f) => f === "--recursive" || (/^-[^-]/.test(f) && /[rR]/.test(f))),
467    force: flags.some((f) => f === "--force" || (/^-[^-]/.test(f) && f.includes("f"))),
468  };
469}
470
471/** A git risk for `git args...` run in `dir`, or null. Git's own options come first; -C moves where it runs. */
472function classifyGit(args, dir) {
473  let gitDir = dir;
474  let i = 0;
475  while (i < args.length && args[i].startsWith("-")) {
476    if (args[i] === "-C" && i + 1 < args.length) {
477      gitDir = joinDir(gitDir, args[i + 1]);
478      i += 2;
479    } else if ((args[i] === "-c" || args[i] === "--git-dir" || args[i] === "--work-tree" || args[i] === "--namespace") && i + 1 < args.length) {
480      i += 2;
481    } else {
482      i += 1;
483    }
484  }
485  const sub = args[i];
486  const rest = args.slice(i + 1);
487  if (sub === "reset" && rest.includes("--hard")) {
488    return { kind: "git-reset", label: "git reset --hard", args: rest, dir: gitDir };
489  }
490  if (sub === "clean") {
491    return { kind: "git-clean", label: "git clean", args: rest, dir: gitDir };
492  }
493  // -f may sit in a bundle of short flags (-fu, -uf).
494  if (sub === "push" && rest.some((a) => a === "--force" || a.startsWith("--force-with-lease") || /^\+/.test(a) || /^-[a-zA-Z]*f[a-zA-Z]*$/.test(a))) {
495    return { kind: "git-push-force", label: "git push --force", args: rest, dir: gitDir };
496  }
497  const stagedOnly = sub === "restore" && rest.includes("--staged") && !rest.includes("--worktree") && !rest.includes("-W");
498  if ((sub === "checkout" || sub === "restore") && rest.includes(".") && !stagedOnly) {
499    return { kind: "git-checkout", label: `git ${sub} -- .`, args: rest, dir: gitDir };
500  }
501  // checkout -f and switch -f throw away local changes.
502  if ((sub === "checkout" && rest.some((a) => a === "-f" || a === "--force")) || (sub === "switch" && rest.some((a) => a === "-f" || a === "--force" || a === "--discard-changes"))) {
503    return { kind: "git-checkout", label: `git ${sub} --force`, args: rest, dir: gitDir };
504  }
505  return null;
506}
507
508/** A migration risk for the words of one segment, or null. */
509function classifyMigration(cmd, args, joined, dir, shell) {
510  if (/\balembic\s+upgrade\b/.test(joined)) {
511    return { kind: "migrate", shell, tool: "alembic", label: "alembic upgrade", dir };
512  }
513  if (/\bdb:migrate(?!:status\b)/.test(joined)) {
514    return { kind: "migrate", shell, tool: "rails", label: "db:migrate", dir };
515  }
516  if (/\bprisma\s+migrate\b/.test(joined)) {
517    return { kind: "migrate", shell, tool: "prisma", label: "prisma migrate", dir };
518  }
519  if (/\bmanage\.py\s+migrate\b/.test(joined)) {
520    return { kind: "migrate", shell, tool: "django", label: "manage.py migrate", dir };
521  }
522  if (!READ_ONLY.has(cmd) && args.includes("migrate")) {
523    return { kind: "migrate", shell, tool: "unknown", label: "migrate", dir };
524  }
525  return null;
526}
527
528/** `powershell -Command "..."` (args as strings): the text is classified as PowerShell; an encoded command is held unread. */
529function classifyPowerShellCall(args, dir) {
530  const named = (a, full, min) => /^-[A-Za-z]+$/.test(a) && a.length - 1 >= min && full.startsWith(a.slice(1).toLowerCase());
531  const at = args.findIndex((a) => named(a, "command", 1));
532  if (at >= 0) {
533    return classify(args.slice(at + 1).join(" "), "ps", dir);
534  }
535  if (args.some((a) => named(a, "encodedcommand", 1) || a.toLowerCase() === "-ec")) {
536    return opaque("ps", "an encoded PowerShell command", "The command is passed base64-encoded, so I can't read what it does.", dir);
537  }
538  // Without -Command, the first word that is neither an option nor an option's value is the command
539  // (Windows PowerShell's default parameter). A script given with -File is not read.
540  const valued = ["executionpolicy", "ep", "windowstyle", "outputformat", "inputformat", "version", "configurationname", "workingdirectory", "psconsolefile", "custompipename"];
541  for (let i = 0; i < args.length; i += 1) {
542    if (args[i].startsWith("-")) {
543      if (named(args[i], "file", 1)) {
544        return null;
545      }
546      if (valued.some((v) => v.startsWith(args[i].slice(1).toLowerCase()))) {
547        i += 1;
548      }
549    } else {
550      return classify(args.slice(i).join(" "), "ps", dir);
551    }
552  }
553  return null;
554}
555
556/** One segment: a risk, { cd } for a folder change, or null. */
557function classifySegment(segment, dir, pushed) {
558  // Redirects at the end go first, so `(cd sub && rm -rf out) > log 2>&1` leaves `rm -rf out`.
559  // A closing } is only dropped when the segment opened a { group: in `rm -rf {a,b}` it is part of a path.
560  const trimmed = stripRedirects(segment.trim());
561  const words = tokenize(trimmed.replace(/^[({]+\s*/, "").replace(/\s*\)+$/, "").replace(trimmed.startsWith("{") ? /\s*}+$/ : /(?!)/, ""));
562  while (words.length > 0 && /^[A-Za-z_][A-Za-z0-9_]*=/.test(words[0])) {
563    words.shift(); // leading VAR=value
564  }
565  if (words[0] === "sudo") {
566    words.shift();
567    while (words.length > 0 && words[0].startsWith("-")) {
568      const option = words.shift();
569      if (SUDO_VALUE_OPTIONS.has(option)) {
570        words.shift();
571      }
572    }
573  }
574  let prefixed = false;
575  while (words.length > 0 && (PREFIXES.has(words[0]) || /^[A-Za-z_][A-Za-z0-9_]*=/.test(words[0]))) {
576    words.shift();
577    prefixed = true;
578  }
579  while (prefixed && words.length > 0 && words[0].startsWith("-")) {
580    words.shift(); // env -i, time -p
581  }
582  if (words[0] === "timeout") {
583    words.shift();
584    while (words.length > 0 && words[0].startsWith("-")) {
585      const option = words.shift();
586      if (option === "-s" || option === "-k" || option === "--signal" || option === "--kill-after") {
587        words.shift();
588      }
589    }
590    words.shift(); // the duration
591  }
592  if (words[0] === "nice") {
593    words.shift();
594    if (words[0] === "-n") {
595      words.splice(0, 2);
596    } else if (/^-\d+$/.test(words[0] ?? "")) {
597      words.shift();
598    }
599  }
600  const [first, ...args] = words;
601  if (first === undefined) {
602    return null;
603  }
604  const cmd = first.replace(/^\\/, ""); // \rm skips aliases; it's still rm
605  if (cmd === "cd") {
606    return { cd: args[0] === "-" ? "-" : joinDir(dir, args[0]) };
607  }
608  if (cmd === "pushd") {
609    pushed.push(dir);
610    return { cd: joinDir(dir, args[0]) };
611  }
612  if (cmd === "popd") {
613    return { cd: pushed.length > 0 ? pushed.pop() : "-" };
614  }
615  // The program's name without its folder and .exe, so /c/Program Files/Git/cmd/git.exe is git.
616  const base = cmd.replace(/^.*[\\/]/, "").replace(/\.exe$/i, "");
617  if (base === "rm") {
618    const flags = args.filter((a) => a.startsWith("-"));
619    const { recursive, force } = rmFlags(flags);
620    if (recursive || force) {
621      const targets = args.filter((a) => (!a.startsWith("-") || a === "-") && !/^(?:\d*>|&>|<)/.test(a));
622      return { kind: "rm", shell: "bash", label: `rm ${flags.join(" ")}`.trim(), targets, dir };
623    }
624  }
625  if (base === "xargs") {
626    const at = args.findIndex((a) => a.replace(/^.*[\\/]/, "") === "rm");
627    if (at >= 0) {
628      const flags = args.slice(at + 1).filter((a) => a.startsWith("-"));
629      const { recursive, force } = rmFlags(flags);
630      if (recursive || force) {
631        return { kind: "rm", shell: "bash", label: `xargs rm ${flags.join(" ")}`.trim(), targets: [], piped: true, dir };
632      }
633    }
634  }
635  if (base === "git") {
636    const risk = classifyGit(args, dir);
637    if (risk !== null) {
638      return { ...risk, shell: "bash" };
639    }
640  }
641  if (["bash", "sh", "zsh", "dash"].includes(base)) {
642    const at = args.findIndex((a) => /^-[a-zA-Z]*c[a-zA-Z]*$/.test(a));
643    if (at >= 0 && args[at + 1] !== undefined) {
644      const risk = classify(args[at + 1], "bash", dir);
645      if (risk !== null) {
646        return risk;
647      }
648    }
649  }
650  if (base.toLowerCase() === "powershell" || base.toLowerCase() === "pwsh") {
651    const risk = classifyPowerShellCall(args, dir);
652    if (risk !== null) {
653      return risk;
654    }
655  }
656  if (base.toLowerCase() === "cmd") {
657    const at = args.findIndex((a) => /^\/{1,2}[ck]$/i.test(a));
658    if (at >= 0) {
659      const risk = classifyCmd(args.slice(at + 1).join(" "), dir);
660      if (risk !== null) {
661        return risk;
662      }
663    }
664  }
665  return classifyMigration(base, args, words.join(" "), dir, "bash");
666}
667
668// ---- PowerShell -------------------------------------------------------------
669
670const PS_REMOVE = new Set(["remove-item", "ri", "rm", "rmdir", "rd", "del", "erase"]);
671const PS_CD = new Set(["cd", "chdir", "sl", "set-location"]);
672const PS_PUSH = new Set(["pushd", "push-location"]);
673const PS_POP = new Set(["popd", "pop-location"]);
674const PS_START = new Set(["start-process", "saps", "start"]);
675// Parameters that take a value, so the value isn't read as a path. PowerShell accepts
676// any unambiguous start of a name; a start of one of these is taken as theirs.
677const PS_VALUE_PARAMS = ["path", "literalpath", "filter", "include", "exclude", "credential", "stream", "erroraction", "errorvariable", "warningaction", "warningvariable", "informationaction", "informationvariable", "outvariable", "outbuffer", "pipelinevariable", "ea", "ev", "wa", "wv", "infa", "iv", "ov", "ob", "pv"];
678const PS_START_VALUE_PARAMS = ["workingdirectory", "redirectstandardinput", "redirectstandardoutput", "redirectstandarderror", "verb", "windowstyle", "credential", "environment"];
679
680/**
681 * Splits one PowerShell segment into words, honouring quotes ('' and "" inside them,
682 * a backtick escape in double quotes). A word is { raw, parts }: the text as written,
683 * and its comma-separated pieces unquoted (`a,'b c'` is two pieces).
684 */
685function psTokenize(text) {
686  const words = [];
687  let i = 0;
688  while (i < text.length) {
689    if (/\s/.test(text[i])) {
690      i += 1;
691      continue;
692    }
693    const parts = [""];
694    let raw = "";
695    while (i < text.length && !/\s/.test(text[i])) {
696      const ch = text[i];
697      if (ch === "\"" || ch === "'") {
698        let j = i + 1;
699        let value = "";
700        while (j < text.length) {
701          if (ch === "\"" && text[j] === "`" && j + 1 < text.length) {
702            value += text[j + 1];
703            j += 2;
704          } else if (text[j] === ch) {
705            if (text[j + 1] === ch) {
706              value += ch;
707              j += 2;
708            } else {
709              break;
710            }
711          } else {
712            value += text[j];
713            j += 1;
714          }
715        }
716        parts[parts.length - 1] += value;
717        raw += text.slice(i, j + 1);
718        i = j + 1;
719      } else if (ch === ",") {
720        parts.push("");
721        raw += ch;
722        i += 1;
723      } else if (ch === "`" && i + 1 < text.length) {
724        parts[parts.length - 1] += text[i + 1];
725        raw += text.slice(i, i + 2);
726        i += 2;
727      } else {
728        parts[parts.length - 1] += ch;
729        raw += ch;
730        i += 1;
731      }
732    }
733    words.push({ raw, parts });
734  }
735  return words;
736}
737
738/** { name, value } for `-Name` or `-Name:value` (name lower-cased), or null for any other word. */
739function psParam(word) {
740  const m = /^-([A-Za-z]+)(?::(.*))?$/.exec(word.raw);
741  return m === null ? null : { name: m[1].toLowerCase(), value: m[2] };
742}
743
744/** Whether a parameter name is a start of `full` at least `min` letters long. */
745function isParam(name, full, min) {
746  return name.length >= min && full.startsWith(name);
747}
748
749/** Whether a word as written is worked out by PowerShell or cmd at run time: a variable, a ( ) expression, @splat or %VAR%. */
750function isDynamic(raw) {
751  return (!raw.startsWith("'") && raw.includes("$")) || /^[(@]/.test(raw) || /%[A-Za-z_][^%\s]*%/.test(raw);
752}
753
754/** The unquoted pieces of the value in `-Name:value`. */
755function psValueParts(value) {
756  return psTokenize(value)[0]?.parts ?? [];
757}
758
759/** One PowerShell segment: a risk, { cd } for a folder change, or null. */
760function classifyPsSegment(segment, dir, pushed) {
761  const text = stripRedirects(segment.trim())
762    .replace(/^(?:\[[\w.,[\]\s]+\]\s*)?\$[\w:.]+(?:\[[^\]]*\])?\s*[+\-*/%]?=\s*/, "") // $out = cmd, [string]$o = cmd, $x[0] = cmd
763    .replace(/^\$[\w:.]+\s*=\s*/, "") // $a = $b = cmd
764    .replace(/^\[[\w.,[\]\s]+\]\s*/, "") // [void](cmd)
765    .replace(/^[&.](?=["'])/, "") // &"git" with no space
766    .replace(/^[({]+\s*/, "")
767    .replace(/\s*[)}]+$/, "");
768  const words = psTokenize(text);
769  while (words.length > 0 && (words[0].raw === "&" || words[0].raw === ".")) {
770    words.shift(); // call operator and dot-sourcing
771  }
772  if (words.length === 0) {
773    return null;
774  }
775  const cmd = words[0].parts.join(",").replace(/^.*[\\/]/, "").replace(/\.(?:exe|cmd|bat)$/i, "").toLowerCase();
776  const args = words.slice(1);
777
778  if (PS_CD.has(cmd) || PS_PUSH.has(cmd) || PS_POP.has(cmd)) {
779    if (PS_POP.has(cmd)) {
780      return { cd: pushed.length > 0 ? pushed.pop() : "-" };
781    }
782    let target;
783    for (let i = 0; i < args.length && target === undefined; i += 1) {
784      const p = psParam(args[i]);
785      if (p === null && !args[i].raw.startsWith("-")) {
786        target = args[i].parts.join(",");
787      } else if (p !== null && (isParam(p.name, "path", 1) || isParam(p.name, "literalpath", 1))) {
788        target = p.value !== undefined ? psValueParts(p.value).join(",") : args[i + 1]?.parts.join(",");
789      }
790    }
791    if (PS_PUSH.has(cmd)) {
792      pushed.push(dir);
793    }
794    if (target === undefined) {
795      return { cd: dir }; // a bare cd stays where it is
796    }
797    return { cd: target === "-" ? "-" : joinDir(dir, target) };
798  }
799
800  if (PS_REMOVE.has(cmd)) {
801    const name = cmd === "remove-item" ? "Remove-Item" : cmd;
802    if (args.some((w) => w.raw.startsWith("@"))) {
803      return opaque("ps", `${name} with splatted parameters`, "The parameters come from a variable (@name), so I can't tell what this removes.", dir);
804    }
805    let recurse = false;
806    let force = false;
807    let whatIf = false;
808    let filters = false;
809    const flags = [];
810    const targets = [];
811    for (let i = 0; i < args.length; i += 1) {
812      const p = psParam(args[i]);
813      if (p === null) {
814        if (/^\d*>/.test(args[i].raw)) {
815          continue; // a redirect, not a path
816        }
817        for (const part of args[i].parts) {
818          if (part !== "") {
819            targets.push({ p: part, lit: false, dyn: isDynamic(args[i].raw) });
820          }
821        }
822        continue;
823      }
824      const enabled = p.value === undefined ? true : !/^\$?false$/i.test(p.value);
825      if (isParam(p.name, "recurse", 1)) {
826        recurse = enabled;
827        flags.push(args[i].raw);
828      } else if (isParam(p.name, "force", 2)) {
829        force = enabled;
830        flags.push(args[i].raw);
831      } else if (isParam(p.name, "whatif", 2)) {
832        whatIf = enabled;
833      } else if (isParam(p.name, "path", 2) || isParam(p.name, "literalpath", 1)) {
834        const lit = isParam(p.name, "literalpath", 1);
835        const parts = p.value !== undefined ? psValueParts(p.value) : (args[i + 1]?.parts ?? []);
836        const dyn = isDynamic(p.value !== undefined ? p.value : (args[i + 1]?.raw ?? ""));
837        if (p.value === undefined) {
838          i += 1;
839        }
840        for (const part of parts) {
841          if (part !== "") {
842            targets.push({ p: part, lit, dyn });
843          }
844        }
845      } else if (PS_VALUE_PARAMS.some((n) => n.startsWith(p.name))) {
846        filters = filters || isParam(p.name, "filter", 2) || isParam(p.name, "include", 3) || isParam(p.name, "exclude", 2);
847        if (p.value === undefined) {
848          i += 1; // its value
849        }
850      }
851    }
852    if (whatIf || (!recurse && !force)) {
853      return null;
854    }
855    return { kind: "rm", shell: "ps", label: `${name} ${flags.join(" ")}`.trim(), targets, filters, dir };
856  }
857
858  if (cmd === "git") {
859    const risk = classifyGit(args.map((w) => w.parts.join(",")), dir);
860    if (risk !== null) {
861      return { ...risk, shell: "ps" };
862    }
863  }
864
865  if (PS_START.has(cmd)) {
866    // Start-Process git -ArgumentList 'reset','--hard' runs git as well.
867    let file;
868    let list = [];
869    for (let i = 0; i < args.length; i += 1) {
870      const p = psParam(args[i]);
871      if (p === null) {
872        if (file === undefined) {
873          file = args[i].parts.join(",");
874        } else if (list.length === 0) {
875          list = args[i].parts; // the second positional parameter is ArgumentList
876        }
877      } else if (isParam(p.name, "filepath", 2)) {
878        file = p.value !== undefined ? psValueParts(p.value).join(",") : args[i + 1]?.parts.join(",");
879        i += p.value === undefined ? 1 : 0;
880      } else if (isParam(p.name, "argumentlist", 1) || p.name === "args") {
881        list = p.value !== undefined ? psValueParts(p.value) : (args[i + 1]?.parts ?? []);
882        i += p.value === undefined ? 1 : 0;
883      } else if (PS_START_VALUE_PARAMS.some((n) => n.startsWith(p.name)) && p.value === undefined) {
884        i += 1;
885      }
886    }
887    if (file !== undefined) {
888      const risk = classify([file, ...list].join(" "), "ps", dir);
889      if (risk !== null) {
890        return risk;
891      }
892    }
893  }
894
895  // powershell -Command "..." and cmd /c "..." run text of their own.
896  if (cmd === "powershell" || cmd === "pwsh") {
897    const risk = classifyPowerShellCall(args.map((w) => w.parts.join(",")), dir);
898    if (risk !== null) {
899      return risk;
900    }
901  }
902  if (cmd === "cmd") {
903    const at = args.findIndex((w) => /^\/[ck]$/i.test(w.raw));
904    if (at >= 0) {
905      const risk = classifyCmd(args.slice(at + 1).map((w) => w.parts.join(",")).join(" "), dir);
906      if (risk !== null) {
907        return risk;
908      }
909    }
910  }
911
912  return classifyMigration(cmd, args.map((w) => w.parts.join(",")), words.map((w) => w.parts.join(",")).join(" "), dir, "ps");
913}
914
915/** A risk in cmd.exe text: `rd /s` or `rmdir /s`, also `/s/q`; `del /s` too, which is held but not measured. */
916function classifyCmd(text, dir) {
917  for (const part of text.split(/&&|\|\||&|\|/)) {
918    const words = psTokenize(part.trim());
919    const cmd = (words[0]?.parts.join(",") ?? "").toLowerCase();
920    if (!["rd", "rmdir", "del", "erase"].includes(cmd)) {
921      continue;
922    }
923    const args = words.slice(1);
924    const switches = args.filter((w) => w.raw.startsWith("/")).flatMap((w) => w.raw.split("/").filter((s) => s !== ""));
925    if (!switches.some((s) => /^s$/i.test(s))) {
926      continue;
927    }
928    const lit = cmd === "rd" || cmd === "rmdir"; // del matches wildcards, rd does not
929    const targets = args.filter((w) => !w.raw.startsWith("/")).flatMap((w) => w.parts.filter((p) => p !== "").map((p) => ({ p, lit, dyn: isDynamic(w.raw) })));
930    return { kind: "rm", shell: "ps", label: `${cmd} /s`, targets, filters: false, unmeasurable: !lit, dir };
931  }
932  return null;
933}
934
935// Resolves a `cd` target to an absolute folder, or null if it doesn't exist.
936// The target is passed as an argument, never as source.
937const CD_SCRIPT = `PATH="/usr/bin:$PATH"; 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; }`;
938const PS_CD_SCRIPT = "$ErrorActionPreference='Stop'; $d=([Console]::In.ReadToEnd()|ConvertFrom-Json).d; if($d -eq '~'){$d=$HOME}elseif($d.StartsWith('~/') -or $d.StartsWith('~\\')){$d=Join-Path $HOME $d.Substring(2)}; try{Set-Location -LiteralPath $d; [Uri]::EscapeDataString((Get-Location).ProviderPath)}catch{exit 1}";
939
940/** A \\server\share or //server/share path: even looking at it opens a network connection. */
941function isNetworkPath(p) {
942  return /^(?:\\\\|\/\/)/.test(p);
943}
944
945async function resolveDir($, sessionCwd, dir, shell) {
946  if (dir === "-" || isNetworkPath(dir)) {
947    return null; // `cd -` depends on the shell's history; a network folder is not touched before the answer
948  }
949  if (shell === "ps") {
950    const run = await runPowerShell($, PS_CD_SCRIPT, { d: dir }, { cwd: sessionCwd, timeoutMs: 15000 });
951    const out = run.stdout.trim();
952    return run.exitCode === 0 && out !== "" ? decodeURIComponent(out) : null;
953  }
954  const run = await runBash($, CD_SCRIPT, [dir], { cwd: sessionCwd, timeoutMs: 5000 });
955  const out = run.stdout.trim();
956  return run.exitCode === 0 && out !== "" ? out : null;
957}
958
959/** Splits one segment into words, honouring quotes. Good enough to read flags and paths. */
960function tokenize(text) {
961  const words = [];
962  const re = /"((?:[^"\\]|\\.)*)"|'([^']*)'|(\S+)/g;
963  let m;
964  while ((m = re.exec(text)) !== null) {
965    words.push(m[1] ?? m[2] ?? m[3]);
966  }
967  return words;
968}
969
970// ---- Measuring the blast radius -------------------------------------------
971
972/** { summary, lines, note } for the pane. Never throws: a failed read is said, not hidden. */
973async function measure($, risk, cwd) {
974  try {
975    if (risk.kind === "opaque") {
976      return { summary: `${risk.label}: I can't tell what it does`, lines: [], note: risk.note };
977    }
978    if (risk.kind === "rm") {
979      return risk.shell === "ps" ? await measureRmPs($, risk, cwd) : await measureRm($, risk, cwd);
980    }
981    if (risk.kind === "migrate") {
982      return measureMigrations(risk);
983    }
984    return await measureGit($, risk, cwd);
985  } catch (error) {
986    return { summary: `${risk.label} (could not measure it)`, lines: [], note: `Could not measure: ${String(error?.message ?? error).slice(0, 200)}` };
987  }
988}
989
990/** A report for paths that hold a variable or an expression: they are not expanded, so nothing can be said. */
991function unexpandedReport(risk, targets) {
992  return {
993    summary: `${risk.label}: the target is worked out when it runs`,
994    lines: [],
995    note: `Paths: ${targets.join(" ")}. They use a variable or an expression, which I can't expand, so I can't tell what this would delete.`,
996  };
997}
998
999/** A report for a network path: looking at it would open a connection before you have answered. */
1000function networkReport(risk, targets) {
1001  return {
1002    summary: `${risk.label}: the target is on the network`,
1003    lines: [],
1004    note: `Paths: ${targets.join(" ")}. I don't touch network paths before you answer, so I can't tell what this would delete.`,
1005  };
1006}
1007
1008// The paths are passed to bash as arguments, never as source, so nothing in
1009// them runs. compgen -G expands a glob without command substitution.
1010const RM_SCRIPT = `
1011PATH="/usr/bin:$PATH"  # Git's find and du, not the Windows find
1012shopt -s nullglob dotglob
1013paths=()
1014for p in "$@"; do
1015  case "$p" in [A-Za-z]:?*) p=$(cygpath -u "$p");; esac  # C:\\x or C:/x
1016  case "$p" in "~"|"~/"*) p="$HOME\${p#\\~}";; esac
1017  if [[ "$p" == *[*?[]* ]]; then
1018    while IFS= read -r m; do paths+=("$m"); done < <(compgen -G "$p")
1019  elif [[ -e "$p" || -L "$p" ]]; then
1020    paths+=("$p")
1021  fi
1022done
1023if (( \${#paths[@]} == 0 )); then echo "0 0 0"; exit 0; fi
1024# A relative path gets ./ in front, so find never reads a name like -delete as an action.
1025for i in "\${!paths[@]}"; do case "\${paths[$i]}" in /*) ;; *) paths[$i]="./\${paths[$i]}";; esac; done
1026files=$(find "\${paths[@]}" \\( -type f -o -type l \\) 2>/dev/null | wc -l | tr -d ' ')
1027kb=$(du -skc "\${paths[@]}" 2>/dev/null | tail -n1 | cut -f1)
1028echo "$files $(( \${kb:-0} * 1024 )) \${#paths[@]}"
1029find "\${paths[@]}" \\( -type f -o -type l \\) 2>/dev/null | head -n ${LIST_MAX}
1030`;
1031
1032async function measureRm($, risk, cwd) {
1033  if (risk.targets.length === 0) {
1034    return risk.piped
1035      ? { summary: `${risk.label} on what an earlier command lists`, lines: [], note: "The paths come from an earlier command, so I can't list them." }
1036      : { summary: "rm with no paths", lines: [], note: "No paths to expand." };
1037  }
1038  if (risk.targets.some(isNetworkPath)) {
1039    return networkReport(risk, risk.targets);
1040  }
1041  if (risk.targets.some((t) => /\$|`|[{}]/.test(t))) {
1042    return unexpandedReport(risk, risk.targets);
1043  }
1044  const run = await runBash($, RM_SCRIPT, risk.targets, { cwd, timeoutMs: 15000 });
1045  if (run.exitCode !== 0 && run.stdout.trim() === "") {
1046    return { summary: `${risk.label} (could not measure it)`, lines: [], note: `bash failed: ${run.stderr.trim().slice(0, 200)}` };
1047  }
1048  const [head, ...rest] = run.stdout.split("\n").filter((l) => l !== "");
1049  const [files, bytes, found] = (head ?? "0 0 0").split(" ").map(Number);
1050  if (!found) {
1051    return { summary: `delete nothing: no file matches ${risk.targets.join(" ")}`, lines: [], note: "The paths don't exist, so rm has nothing to remove." };
1052  }
1053  if (!files) {
1054    return { summary: `delete ${found} ${found === 1 ? "path" : "paths"} with no files in ${found === 1 ? "it" : "them"}`, lines: [], note: `Paths: ${risk.targets.join(" ")}` };
1055  }
1056  return {
1057    summary: `delete ${files} ${files === 1 ? "file" : "files"} (about ${size(bytes)})`,
1058    lines: rest.map((l) => l.replace(/^\.\//, "")),
1059    more: Math.max(0, files - rest.length),
1060    note: `Paths: ${risk.targets.join(" ")}`,
1061  };
1062}
1063
1064// Counts the files below the given paths without following junctions or symbolic links.
1065// Output: "files bytes found links", then up to LIST_MAX percent-encoded file paths.
1066const PS_RM_SCRIPT = [
1067  "$ErrorActionPreference='SilentlyContinue'",
1068  "$in=([Console]::In.ReadToEnd()|ConvertFrom-Json).t",
1069  "$n=0;$bytes=0;$found=0;$links=0",
1070  "$list=New-Object Collections.ArrayList;$stack=New-Object Collections.Stack",
1071  "foreach($t in @($in)){ if($t.lit){$r=@(Get-Item -LiteralPath $t.p -Force)}else{$r=@(Get-Item -Path $t.p -Force)}; foreach($i in $r){ $found++; if($i -is [IO.DirectoryInfo]){ if($i.Attributes -band 1024){$links++}else{$stack.Push($i)} }else{ $n++; $bytes+=$i.Length; if($list.Count -lt " + LIST_MAX + "){[void]$list.Add($i.FullName)} } } }",
1072  "while($stack.Count -gt 0){ $d=$stack.Pop(); try{ foreach($c in $d.EnumerateFileSystemInfos()){ if($c -is [IO.DirectoryInfo]){ if($c.Attributes -band 1024){$links++}else{$stack.Push($c)} }else{ $n++; $bytes+=$c.Length; if($list.Count -lt " + LIST_MAX + "){[void]$list.Add($c.FullName)} } } }catch{} }",
1073  "'{0} {1} {2} {3}' -f $n,$bytes,$found,$links",
1074  "foreach($p in $list){[Uri]::EscapeDataString($p)}",
1075].join("; ");
1076
1077async function measureRmPs($, risk, cwd) {
1078  const paths = risk.targets.map((t) => t.p);
1079  if (risk.unmeasurable) {
1080    return { summary: `${risk.label}: deletes every matching file in the folder and below it`, lines: [], note: `Paths: ${paths.join(" ")}. I don't measure del /s, because it matches names at every level.` };
1081  }
1082  if (paths.length === 0) {
1083    return { summary: `${risk.label} on what the pipeline passes it`, lines: [], note: "The paths come from an earlier command, so I can't list them." };
1084  }
1085  if (paths.some(isNetworkPath)) {
1086    return networkReport(risk, paths);
1087  }
1088  if (risk.targets.some((t) => t.dyn)) {
1089    return unexpandedReport(risk, paths);
1090  }
1091  const run = await runPowerShell($, PS_RM_SCRIPT, { t: risk.targets.map(({ p, lit }) => ({ p, lit })) }, { cwd, timeoutMs: 25000 });
1092  if (run.exitCode !== 0 && run.stdout.trim() === "") {
1093    return { summary: `${risk.label} (could not measure it)`, lines: [], note: `PowerShell failed: ${run.stderr.trim().slice(0, 200)}` };
1094  }
1095  const [head, ...rest] = run.stdout.split("\n").map((l) => l.trim()).filter((l) => l !== "");
1096  const [files, bytes, found, links] = (head ?? "0 0 0 0").split(" ").map(Number);
1097  const base = cwd.replace(/\\/g, "/").replace(/\/+$/, "").toLowerCase();
1098  const lines = rest.map((l) => {
1099    const full = decodeURIComponent(l).replace(/\\/g, "/");
1100    return full.toLowerCase().startsWith(`${base}/`) ? full.slice(base.length + 1) : full;
1101  });
1102  const extra = [
1103    risk.filters ? "Include, Exclude and Filter are not applied here, so the real number may be smaller." : "",
1104    links ? `${links} junction or symbolic link folder${links === 1 ? "" : "s"} not followed.` : "",
1105  ].filter((s) => s !== "").join(" ");
1106  if (!found) {
1107    return { summary: `delete nothing: no file matches ${paths.join(" ")}`, lines: [], note: "The paths don't exist, so there is nothing to remove." };
1108  }
1109  if (!files) {
1110    return { summary: `delete ${found} ${found === 1 ? "path" : "paths"} with no files in ${found === 1 ? "it" : "them"}`, lines: [], note: `Paths: ${paths.join(" ")}. ${extra}`.trim() };
1111  }
1112  return {
1113    summary: `delete ${files} ${files === 1 ? "file" : "files"} (about ${size(bytes)})`,
1114    lines,
1115    more: Math.max(0, files - lines.length),
1116    note: `Paths: ${paths.join(" ")}. ${extra}`.trim(),
1117  };
1118}
1119
1120async function measureGit($, risk, cwd) {
1121  if (risk.kind === "git-push-force") {
1122    return await measurePush($, risk, cwd);
1123  }
1124  if (risk.kind === "git-clean") {
1125    const flags = [];
1126    const paths = [];
1127    for (let i = 0; i < risk.args.length; i += 1) {
1128      const a = risk.args[i];
1129      if (a === "--") {
1130        paths.push(...risk.args.slice(i + 1));
1131        break;
1132      }
1133      if (a === "-e" || a === "--exclude") {
1134        flags.push(a, risk.args[i + 1] ?? "");
1135        i += 1;
1136      } else if (a.startsWith("--exclude=") || /^-e./.test(a)) {
1137        flags.push(a);
1138      } else if (/^-[a-zA-Z]+$/.test(a)) {
1139        const kept = a.replace(/[finq]/g, ""); // -n is added below; -f, -i and -q would change the dry run
1140        if (kept !== "-") {
1141          flags.push(kept);
1142        }
1143        if (a.endsWith("e")) {
1144          flags.push(risk.args[i + 1] ?? ""); // -fde 'pattern': the e takes the next word
1145          i += 1;
1146        }
1147      } else if (!a.startsWith("-")) {
1148        paths.push(a);
1149      }
1150    }
1151    const run = await $.process.run([...GIT, "clean", "-n", ...flags, "--", ...paths], { cwd, timeoutMs: 15000 });
1152    if (run.exitCode !== 0) {
1153      return { summary: "git clean (could not dry-run it)", lines: [], note: run.stderr.trim().slice(0, 200) };
1154    }
1155    const gone = run.stdout.split("\n").filter((l) => l.startsWith("Would remove ")).map((l) => l.slice(13).trim());
1156    return {
1157      summary: gone.length === 0 ? "remove nothing: no untracked files match" : `remove ${gone.length} untracked ${gone.length === 1 ? "path" : "paths"}`,
1158      lines: gone.slice(0, LIST_MAX),
1159      more: Math.max(0, gone.length - LIST_MAX),
1160      note: "From git clean -n. Untracked files are not in git, so they can't be recovered.",
1161    };
1162  }
1163  const status = await $.process.run([...GIT, "-c", "core.quotepath=false", "status", "--porcelain"], { cwd, timeoutMs: 15000 });
1164  if (status.exitCode !== 0) {
1165    return { summary: `${risk.label} (not a git repo here?)`, lines: [], note: status.stderr.trim().slice(0, 200) };
1166  }
1167  const rows = status.stdout.split("\n").map((l) => l.replace(/\r$/, "")).filter((l) => l.length > 3 && !l.startsWith("??"));
1168  // reset --hard drops staged and unstaged changes; checkout -- . drops unstaged ones.
1169  const lost = risk.kind === "git-reset" ? rows : rows.filter((l) => l[1] !== " ");
1170  const stat = await $.process.run([...GIT, "diff", "--no-ext-diff", "--no-textconv", "--shortstat", risk.kind === "git-reset" ? "HEAD" : "--"], { cwd, timeoutMs: 15000 });
1171  return {
1172    summary: lost.length === 0 ? "discard nothing: no uncommitted changes" : `discard uncommitted changes in ${lost.length} ${lost.length === 1 ? "file" : "files"}`,
1173    lines: lost.slice(0, LIST_MAX).map((l) => `${l.slice(0, 2)} ${l.slice(3)}`),
1174    more: Math.max(0, lost.length - LIST_MAX),
1175    note: stat.stdout.trim() !== "" ? `${stat.stdout.trim()}. Uncommitted changes can't be recovered.` : "From git status --porcelain.",
1176  };
1177}
1178
1179async function measurePush($, risk, cwd) {
1180  const positional = risk.args.filter((a) => !a.startsWith("-"));
1181  const remote = positional[0] ?? "origin";
1182  // A refspec is src:dst. With no colon, the local branch of the same name is pushed.
1183  const spec = (positional[1] ?? "").replace(/^\+/, "");
1184  let [source, branch] = spec.includes(":") ? spec.split(":") : [spec, spec];
1185  branch = (branch ?? "").replace(/^refs\/heads\//, "");
1186  if (source.startsWith("-") || branch.startsWith("-") || remote.startsWith("-")) {
1187    return { summary: "force-push", lines: [], note: "The branch names start with a dash, so I can't tell which commits the push would drop." };
1188  }
1189  if (!branch) {
1190    const head = await $.process.run([...GIT, "rev-parse", "--abbrev-ref", "HEAD"], { cwd, timeoutMs: 10000 });
1191    branch = head.stdout.trim();
1192    source = "HEAD";
1193  } else if (branch === "HEAD") {
1194    // `git push origin HEAD` pushes the current branch to its namesake.
1195    const head = await $.process.run([...GIT, "rev-parse", "--abbrev-ref", "HEAD"], { cwd, timeoutMs: 10000 });
1196    branch = head.stdout.trim();
1197    source = "HEAD";
1198  }
1199  source = source || "HEAD";
1200  const ref = `${remote}/${branch}`;