Stop hook fires repeatedly when system reminders interleave with stop attempts (stop_hook_active not propagating)

Resolved 💬 1 comment Opened Apr 28, 2026 by NizamApp Closed May 30, 2026

Summary

A Stop hook configured in .claude/settings.json fires multiple times within a single conversation turn when system reminders (UserPromptSubmit hook output, task notifications, etc.) interleave with stop attempts. The stop_hook_active flag in the hook's JSON input is false on each fire instead of being set to true on subsequent fires within the same turn.

Expected behavior

Per the documented hook contract: when a Stop hook has already fired once in a conversation turn, subsequent fires within the same turn should pass stop_hook_active: true in the JSON input so the hook can short-circuit and avoid an infinite verification loop.

Actual behavior

In a session where:

  • A custom Stop hook gates stops with a verification prompt and exits 2 unless stop_hook_active=true
  • The model produces multiple stop attempts in succession (verification text → stop attempt → hook fires → model produces more verification text → stop attempt → hook fires again)
  • System reminders inject between stop attempts (e.g., UserPromptSubmit hook output for task notifications, "task tools haven't been used recently" reminders, persisted-output truncation messages)

…the hook receives stop_hook_active: false on each fire. The model has no way to break the loop because every stop attempt re-triggers the hook from a fresh state.

Reproduction

  1. Configure a Stop hook in .claude/settings.json that exits 2 unless .stop_hook_active == true:
{
  "hooks": {
    "Stop": [{
      "hooks": [{
        "type": "command",
        "command": "$CLAUDE_PROJECT_DIR/.claude/hooks/stop-test.sh"
      }]
    }]
  }
}
#!/bin/bash
INPUT=$(cat)
ACTIVE=$(echo "$INPUT" | jq -r '.stop_hook_active // false')
if [ "$ACTIVE" = "true" ]; then exit 0; fi
echo "verification reminder" >&2
exit 2
  1. Configure a UserPromptSubmit hook that injects context (any non-empty stdout).
  1. Have the model attempt to stop after work that triggers a <task-notification> or persisted-output system reminder.
  1. Observe: the Stop hook fires 5+ times within ~10 minutes despite no new user prompts. Each fire sees stop_hook_active: false.

Impact

For agentic deployments that use Stop hooks for verification gating, the loop causes:

  • Token burn (model re-runs verification protocol on each fire)
  • Compounding output that can interact with downstream substrate (in our case, a watchdog parsed numbered verification output as permission-prompt options and false-positived an alert to the operator)
  • Model drift toward producing minimal "verified, holding" responses, which weakens the verification gate

Workaround

Add a substrate-level rate limit to the Stop hook (e.g., 60-second sentinel) that suppresses fires within the cooldown window. This breaks the loop but is a workaround, not a fix — the documented stop_hook_active propagation contract should hold across system-reminder injections in the same turn.

Environment

  • Claude Code 2.1.121
  • macOS Darwin 25.2.0
  • Custom Stop + UserPromptSubmit hooks
  • Claude Opus 4.6 (1m context window)

Suggested fix

Treat stop_hook_active as a property of the conversation-turn lifecycle: once set to true on the first fire, it stays true for all hook fires within the same turn, regardless of intervening system reminders or hook outputs. Reset to false only when the user provides new input (a real prompt, not a system-injected reminder).

View original on GitHub ↗

This issue has 1 comment on GitHub. Read the full discussion on GitHub ↗