v0.4.0
Main-thread time guard for Claude Code Bash commands: catches foreground polling and watching, and slow commands about to be killed by an inadequate timeout.
Important
Two changes make the guard prompt where it previously stayed silent, and one config form now needs a rewrite. Backgrounding a command no longer exempts it from poll detection, and a slow.commands pattern that matches an argument rather than a command now needs a .* prefix — it fails quiet, so nothing tells you it stopped matching. Read Upgrading below.
Highlights
Unattended permission modes deny instead of asking. In auto, dontAsk, and bypassPermissions, a would-be ask is now emitted as a deny. Nothing new is blocked — every affected call was already an ask, i.e. already blocking. Only the direction of failure changed: instead of parking on a prompt nobody is there to answer, the guard returns its reason to the agent, which retries with run_in_background: true, one snapshot, or a timeout bound. Previously only bypassPermissions escalated, and auto was the friction — the guard prompted, you rejected it, and the fix went nowhere but into your clipboard. Attended modes (default, acceptEdits, plan) still ask; someone is there, and an ask is the lighter interruption.
Backgrounding no longer switches off the poll detector. run_in_background: true returned before either analyzer ran, so it exempted Class A along with Class B. That is right for Class B — backgrounding is the fix that class teaches — and wrong for Class A: a detached gh run watch or sleep-loop moves the wait off the main thread without removing it, holds a task slot for the whole run, and hands back output the agent cannot date. Class A now analyzes backgrounded calls and words its reasons for a detached wait. Its own exemptions still hold when backgrounded: a trailing &, a timeout N wrap, below-floor sleeps, and poll.exempt_watch_patterns.
Class B patterns match at a command position. Registering a slow script used to flag every command that mentioned its path, not just one that ran it — grep -n foo scripts/gate.sh, wc -l over the same file, a git commit -m quoting it. The advice was unfollowable there: you cannot fix a grep by giving it an hour-long timeout. Class B now routes through the same segmentation Class A already used, requiring the pattern's leftmost match to begin inside a segment's command word. Invocation forms all still match — env prefixes, nohup/time/timeout wrappers, bash gate.sh, bash -c '...' bodies, absolute paths — and heredoc bodies naming the path no longer do.
The friction report stops counting sibling guards' overrides. Its override pattern matched override acknowledged without a guard name, and every co-installed guard phrases that prefix identically apart from the name. Under --plugin all it reported 49 uses of an override that had never been set once — a fabricated statistic arguing for loosening thresholds that were fine, with nothing in the output looking wrong. The pattern is now anchored to this guard's own name.
Upgrading
Install or update as usual — there is no migration for the plugin itself, and no config file needs to exist for the defaults to be correct.
1. If slow.commands has an argument-shaped pattern, prefix it with .*. A bare -race no longer matches go test -race, because patterns now have to hit a command word. Write .*-race\b to opt back into matching anywhere within a segment.
Caution
This one fails quiet. A pattern that stopped matching produces no prompt and no error — the guard simply defers, and a slow command runs under a short timeout again. Grep your .claude/foreground-guard.json for entries that do not begin with a command name. Patterns already anchored as a workaround for the old behaviour ((^|[;&|]\s*)scripts/gate\.sh) keep working; there is a regression test holding that.
2. Expect one new prompt on the deliberate "background a tail -f, read it later with BashOutput" pattern. It is an ask, never a deny, in attended modes. A repo that wants a specific form quiet in every mode lists it in poll.exempt_watch_patterns.
Nothing is deprecated or removed in this release. No config key changed name or default.
Configuration surface
No keys were added or removed — the table in Configuration is unchanged from v0.3.0. One key changed meaning: slow.commands regexes are matched at a command position rather than against the raw Bash string, which is step 1 of Upgrading above.
Two behaviours around the override escape hatch changed with the mode work. FOREGROUND_GUARD_OVERRIDE now downgrades any deny where a prompt can still be answered, mode-escalated as well as config-escalated. In dontAsk and bypassPermissions it is inert and now says so — previously it printed a deny claiming it had been "downgraded to a confirmation prompt", which was false.
Every deny now carries one tail naming the override, the friction report, and the issue tracker, so a wrong verdict gets reported upstream rather than worked around.
Everything since v0.3.0
Four PRs. Listed flat rather than folded — there is no volume here to hide.
- feat(modes): deny instead of ask in unattended permission modes by @karlkfi in #18
- fix(hook): stop
run_in_backgroundfrom exempting Class A by @karlkfi in #17 (fixes #15) - fix(class-b): match slow-command patterns at a command position by @karlkfi in #19 (closes #16)
- fix(friction-report): count only this guard's override downgrades by @karlkfi in #14 (fixes #13)
README, CLAUDE.md, the friction-report command, and the reduce-foreground-guard-prompts skill were updated in lockstep with the behaviour changes — the category hints no longer advertise backgrounding as the thing that quiets a Class A prompt, because it isn't one now.
Validation
135 tests, green on the tag commit across Python 3.10, 3.11, 3.12, and 3.13 (run) — 117 at v0.3.0. Three layers: unit tests over heredoc stripping, tokenizing, segment splitting and sleep parsing; end-to-end tests running the hook as a subprocess against a fixture $HOME and fixture project config, so the developer's real ~/.claude can never leak into a verdict; and wiring tests over hooks.json, plugin.json, and marketplace.json, including the version lockstep that makes a one-sided bump fail the suite.
The never-allow invariant is asserted on every end-to-end call: the hook returns ask, deny, or nothing. An allow would bypass your permission settings and override the sibling guards evaluating the same command.
What the suite does not assert
- No live Claude Code session runs in CI. Permission-mode behaviour is asserted against synthetic hook payloads carrying each mode string. The mode set was read off the installed CLI (2.1.220) rather than assumed, but nothing here proves the CLI dispatches a hook
denythe way this release expects it to inauto. scripts/cut-release.shis untested. It is not covered by the suite, and cutting this release surfaced a bug in it: with--notes-file, it returns exit 1 after a completely successful release, because itsEXITtrap's cleanup short-circuits to a non-zero status. The v0.4.0 artifacts are unaffected and were verified against the remote by hand. A fix is queued.- Class B's registry is per-repo by design, so no test can tell you whether your slow commands are registered — only that a registered one behaves. The friction report is the tool for that.
For #19 the regression tests were confirmed to be regression tests rather than tests written to the new code: stashing the script change alone fails exactly the four behaviour-change assertions and leaves the nine invocation-form assertions green.
Security
This is a productivity guard, not a security boundary — that framing is deliberate and load-bearing. Parsing uncertainty defers, and infrastructure errors (bad JSON, unreadable config, an unexpected exception) fail open to a silent defer, because a guard that breaks the session is worse than a missed prompt. A missed poll costs you waiting, not an outage. Do not deploy it as a control that something must not run.
No dependencies, and no telemetry. The hook is a single stdlib-only Python script; the friction report re-reads the decisions Claude Code already wrote to your local session transcripts and sends nothing anywhere. See PRIVACY.md.
Installing this release
/plugin marketplace add karlkfi/claude-foreground-guard
/plugin install foreground-guard@foreground-guard
Note
An existing install does not pick this up on its own. Claude Code auto-updates official Anthropic marketplaces only; a third-party GitHub marketplace stays pinned at the version you installed. Either set autoUpdate for the marketplace in ~/.claude/settings.json once, or update by hand — both are in Keeping it updated.
claude plugin marketplace update foreground-guard
claude plugin update foreground-guard@foreground-guard
Restart Claude Code (or /reload-plugins) to apply. Requires python3 on your PATH.
The plugin pairs with the sibling guards — workspace-guard, branch-guard, and prod-guard — which is why this one never emits allow.
Full changelog: v0.3.0...v0.4.0