[BUG] Stop hook blocks and bills a full turn when a background agent is mid-write; no way to distinguish in-progress work from abandoned work

Status Open
Maintainer reply None cached
Activity 0 comments · opened Sep 11, 2026

Preflight Checklist

  • [x] I have searched existing issues and this hasn't been reported yet
  • [x] This is a single bug report (please file separate reports for different bugs)
  • [x] I am using the latest version of Claude Code

What's Wrong?

The dirty-tree check fires whenever the working tree has uncommitted or untracked
files at the moment the main agent's turn ends:

if ! git diff --quiet || ! git diff --cached --quiet; then
  echo "There are uncommitted changes in the repository. ..." >&2
  exit 2

With background subagents, a dirty tree at turn-end is the normal, correct
state
, not a warning sign. The main agent dispatches work and ends its turn;
the subagent writes files for several minutes and commits as its final step. The
hook cannot tell that apart from work abandoned mid-edit, so it fires on every
turn for the whole duration of the subagent's run.

Because it is a Stop hook exiting 2, the message is injected as a synthetic
user turn and the model is re-invoked against the entire conversation. In a
long session that is a full context re-read per firing.

Acting on the message would be actively harmful: committing a subagent's
half-written tree produces a broken intermediate commit and races the subagent's
own git merge / commit sequence.

Measured cost

From one session's own transcript (~/.claude/projects/<id>.jsonl), summing
usage across every model call in each hook-forced turn. Claude Opus 5 at
$5.00/$25.00 per MTok, cache reads 0.1x ($0.50), 1-hour cache writes 2x ($10.00):

| Firing | Model calls | Output tok | Cache-read tok | Cost |
|---|---|---|---|---|
| unpushed (false positive, see Part 1) | 6 | 4,682 | 1,685,080 | $1.02 |
| uncommitted | 3 | 1,330 | 1,029,849 | $0.57 |
| uncommitted | 6 | 5,882 | 2,081,049 | $1.28 |
| uncommitted | 2 | 703 | 698,667 | $0.38 |

  • Total: $3.25, 5,525,683 billable tokens, 4 firings.
  • 85% of the cost is cache reads ($2.75) — re-reading the conversation, not

generating output. Output was 12,597 tokens, $0.32.

  • One firing is not one model call. These four produced 17, because each

response ran tool calls (git status, comparisons against origin/main) and
every round re-reads context.

  • Cost scales with session age: the cheapest firing re-read 0.7M tokens, the

dearest 2.1M. Long agent-driven sessions are both where it fires most and where
each firing costs most.

Honest bounds on the above: some of that spend overlapped with investigation the
user had separately asked for, and a terser response would cost less. The
cleanest pure-overhead subset is the first two firings, $1.59.

The hook never fires for subagents

Checked all four subagent transcripts in the same session:

agent-a4069a2edb3c034ff: 0
agent-a412057e8d404a77e: 0
agent-a56fa69c51a64f6b3: 0
agent-ade7f33500b9f6163: 0

This bounds the problem — the tax is once per main-session turn, not multiplied
per agent. It also sharpens it: the session being charged is the one not
touching the working tree, and it is charged against the largest context in the
system, while the agents actually writing are exempt.

What this report does not claim

  • No cross-session data. The container is ephemeral (firstStartTime

2026-09-11T20:28:41Z) and prior sessions' transcripts are reclaimed with their
containers, so the $3.25 is one session, not a historical total.

  • The hook is not wrong in general. In an ephemeral container, uncommitted

usually does mean at-risk. The defect is that it cannot distinguish "abandoned"
from "in progress by design", and that being a blocking hook makes each
misjudgement cost a full context re-read.

Related

  • #83924 — same script, unpushed-commit false positive (Part 1 adds a trigger)
  • #85787 — exit-time uncommitted warning, path exclusion; different surface

(non-blocking TUI warning) and different ask

  • #69586 — same script, signature/Unverified check on branch divergence
  • #40442 — a different infinite Stop-hook loop (plugin cache dir)

What Should Happen?

  1. Do not block while a background agent holds the tree. The harness knows

whether subagents are live; skip the dirty-tree check when any are running, or
downgrade it to non-blocking (exit 0 with a printed note).

  1. Make it advisory rather than blocking after the first firing. The recursion

guard (stop_hook_active) already prevents an infinite loop; extending it so
the same unchanged tree does not re-block on every subsequent turn would remove
most of the repeated cost.

  1. Apply the #83924 fix (git rev-list HEAD --not --remotes) to the unpushed

check — see Part 1.

  1. Path exclusion, as requested in #85787, would help adjacent cases but not

this one; the paths here are genuine source files that are genuinely mid-edit.

Error Messages/Logs

Steps to Reproduce

  1. Start a Claude Code on the web session in a git repo with a remote.
  2. Dispatch a background subagent that edits files over several minutes.
  3. End the main agent's turn while the subagent is still writing.
  4. The Stop hook fires with "There are uncommitted changes in the repository.

Please commit and push these changes to the remote branch."

  1. It fires again on each subsequent turn until the subagent commits.

Claude Model

Opus

Is this a regression?

Yes, this worked in a previous version

Last Working Version

_No response_

Claude Code Version

Latest web version

Platform

Anthropic API

Operating System

Ubuntu/Debian Linux

Terminal/Shell

Other

Additional Information

_No response_

View original on GitHub ↗