[BUG] False "Hook Error" labels cause Claude to prematurely end turns (exit 0, no stderr, valid JSON)
Preflight Checklist
What's Wrong?
Claude Code displays "hook error" in the transcript for every hook execution, regardless of whether the hook succeeded. This affects all hook events (PreToolUse, PostToolUse, UserPromptSubmit, etc.) and all matchers.
The hooks:
- Exit with code 0
- Produce valid JSON on stdout (with
hookSpecificOutput)
- Produce zero bytes on stderr (fd 2 is redirected to
/dev/null before any code runs)
- Complete in <40ms (well under any timeout)
This is NOT just cosmetic — it causes premature turn termination
In plugin-heavy setups (e.g., magic-claude with ~15 hooks + context-mode with ~10 hooks), every single tool call shows 2-4 "hook error" lines. These false error labels are visible in the transcript and appear to be fed back into the model's context. The model interprets this accumulation of "error" signals as an indication that something is wrong and prematurely ends its turn, especially after Bash tool calls where multiple hooks fire (PreToolUse + PostToolUse from both plugins).
Observed behavior: After a Bash command completes successfully (exit 0, correct output), Claude stops responding mid-task. The transcript shows:
● Bash(echo "hello")
⎿ PreToolUse:Bash hook error ← false error (hook exited 0)
⎿ hello
⎿ PostToolUse:Bash hook error ← false error (hook exited 0)
Claude sees 2+ "error" labels per tool call. After several tool calls, the accumulated "error" signals cause Claude to abandon its plan and end the turn, even though everything worked correctly.
Impact escalation:
- Functional: Turns terminate prematurely — Claude abandons multi-step tasks
- Alarm fatigue: Users can't distinguish real errors from false ones
- Trust erosion: Users blame their plugins for "errors" that don't exist
- Plugin ecosystem: Discourages hook-based plugins — more hooks = more false errors = worse UX
Version
Claude Code latest (as of 2026-03-15), Linux (WSL2)
Steps to Reproduce
Minimal reproduction
- Add a trivial hook to
~/.claude/settings.json:
{
"hooks": {
"PreToolUse": [
{
"matcher": "Read",
"hooks": [
{
"type": "command",
"command": "cat > /dev/null && echo '{\"hookSpecificOutput\":{\"hookEventName\":\"PreToolUse\"}}'"
}
]
}
]
}
}
- Run any session that uses the Read tool
- Observe:
PreToolUse:Read hook error in the transcript
Real-world reproduction (plugin ecosystem)
- Install magic-claude plugin (~15 hooks across all events)
- Install context-mode plugin (~10 hooks)
- Run any session
- Every tool call shows multiple "hook error" lines:
● Read 2 files (ctrl+o to expand)
⎿ PreToolUse:Read hook error
⎿ PostToolUse:Read hook error
⎿ PreToolUse:Read hook error
⎿ PostToolUse:Read hook error
● plugin:context-mode — ctx_batch_execute (MCP)
⎿ PreToolUse:mcp__plugin_context-mode_context-mode__ctx_batch_execute hook error
⎿ PostToolUse:mcp__plugin_context-mode_context-mode__ctx_batch_execute hook error
Investigation Details
Hooks verified working correctly
All hooks were tested manually outside Claude Code and produce correct results:
# context-mode PreToolUse — exits 0, valid JSON, 0 bytes stderr
$ echo '{"tool_name":"Read","tool_input":{"file_path":"/tmp/test.txt"}}' | \
node ~/.claude/plugins/cache/context-mode/context-mode/1.0.26/hooks/pretooluse.mjs 2>/tmp/stderr.txt
{"hookSpecificOutput":{"hookEventName":"PreToolUse","additionalContext":"<context_guidance>..."}}
$ echo "exit=$? stderr=$(wc -c < /tmp/stderr.txt)b"
exit=0 stderr=0b
# context-mode PostToolUse — exits 0, no output needed, 0 bytes stderr
$ echo '{"tool_name":"Read","tool_input":{},"tool_response":"content"}' | \
node ~/.claude/plugins/cache/context-mode/context-mode/1.0.26/hooks/posttooluse.mjs 2>/tmp/stderr.txt
$ echo "exit=$? stderr=$(wc -c < /tmp/stderr.txt)b"
exit=0 stderr=0b
# magic-claude hooks — all exit 0 with 0 bytes stderr
$ echo '{"tool_name":"Bash","tool_input":{"command":"echo test"},"tool_response":"output"}' | \
CLAUDE_PLUGIN_ROOT="$PLUGIN_ROOT" node "$PLUGIN_ROOT/scripts/hooks/pr-url-logger.cjs" 2>/tmp/stderr.txt
$ echo "exit=$? stderr=$(wc -c < /tmp/stderr.txt)b"
exit=0 stderr=0b
Timing verified (no timeout)
pretooluse.mjs: ~34ms
posttooluse.mjs: ~29ms
stderr suppression verified
context-mode hooks use an fd-level stderr suppression (suppress-stderr.mjs) that closes fd 2 and reopens it to /dev/null before any code runs. This prevents native C++ modules (like better-sqlite3) from writing to stderr. The suppression works correctly — verified with 0 bytes on stderr in all tests.
Expected Behavior
- Exit 0 + valid JSON stdout + no stderr → no error label (or a success label like "hook: PreToolUse → allow")
- Exit 0 + no stdout + no stderr → no label at all (silent success)
- Exit 2 + stderr → "hook error" with stderr content shown
Actual Behavior
All hook executions show "hook error" regardless of exit code, stdout, or stderr content.
Impact — Severity: HIGH (functional, not cosmetic)
| Plugin setup |
Hooks per tool call |
False "hook error" lines per tool call |
Turn survival |
| No plugins |
0 |
0 |
Normal |
| magic-claude only |
~2-4 |
~2-4 |
Occasional premature stops |
| magic-claude + context-mode |
~4-8 |
~4-8 |
Frequent premature stops |
With 50+ tool calls per session, this means 200-400 false "hook error" lines injected into the model's context per session. The model treats these as real errors and progressively loses confidence, eventually refusing to continue multi-step tasks.
This effectively puts a ceiling on the number of plugins a user can install, because each plugin's hooks multiply the false error signals that cause turn termination.
Related Issues
All of these report the same root cause: Claude Code's hook status label logic doesn't properly distinguish success from failure.
Suggested Fix
Two things need to change:
1. Fix the label logic
Based on the hooks documentation, the status label logic should be:
if exit_code == 0:
if stdout has valid JSON with hookSpecificOutput:
label = "hook: {event} → {decision}" # or no label
else:
label = none # silent success
elif exit_code == 2:
label = "hook error: {stderr_content}"
else:
label = "hook warning" (non-blocking, verbose only)
Currently it appears to unconditionally label everything as "hook error".
2. Don't feed hook status labels into the model's context
Even if the label display is fixed, hook execution metadata should not be injected into the model's conversation context. The model has no ability to act on hook errors — only the user or the hook itself can. Feeding "hook error" into the context just wastes tokens and confuses the model into thinking something failed.
Hook status should be:
- Visible to the user in the transcript (for debugging)
- Invisible to the model (not part of the conversation context)
[BUG] False "Hook Error" labels cause Claude to prematurely end turns (exit 0, no stderr, valid JSON)
Preflight Checklist
What's Wrong?
Claude Code displays "hook error" in the transcript for every hook execution, regardless of whether the hook succeeded. This affects all hook events (PreToolUse, PostToolUse, UserPromptSubmit, etc.) and all matchers.
The hooks:
hookSpecificOutput)/dev/nullbefore any code runs)This is NOT just cosmetic — it causes premature turn termination
In plugin-heavy setups (e.g., magic-claude with ~15 hooks + context-mode with ~10 hooks), every single tool call shows 2-4 "hook error" lines. These false error labels are visible in the transcript and appear to be fed back into the model's context. The model interprets this accumulation of "error" signals as an indication that something is wrong and prematurely ends its turn, especially after Bash tool calls where multiple hooks fire (PreToolUse + PostToolUse from both plugins).
Observed behavior: After a Bash command completes successfully (exit 0, correct output), Claude stops responding mid-task. The transcript shows:
Claude sees 2+ "error" labels per tool call. After several tool calls, the accumulated "error" signals cause Claude to abandon its plan and end the turn, even though everything worked correctly.
Impact escalation:
Version
Claude Code latest (as of 2026-03-15), Linux (WSL2)
Steps to Reproduce
Minimal reproduction
~/.claude/settings.json:{ "hooks": { "PreToolUse": [ { "matcher": "Read", "hooks": [ { "type": "command", "command": "cat > /dev/null && echo '{\"hookSpecificOutput\":{\"hookEventName\":\"PreToolUse\"}}'" } ] } ] } }PreToolUse:Read hook errorin the transcriptReal-world reproduction (plugin ecosystem)
Investigation Details
Hooks verified working correctly
All hooks were tested manually outside Claude Code and produce correct results:
Timing verified (no timeout)
stderr suppression verified
context-mode hooks use an fd-level stderr suppression (
suppress-stderr.mjs) that closes fd 2 and reopens it to/dev/nullbefore any code runs. This prevents native C++ modules (like better-sqlite3) from writing to stderr. The suppression works correctly — verified with 0 bytes on stderr in all tests.Expected Behavior
Actual Behavior
All hook executions show "hook error" regardless of exit code, stdout, or stderr content.
Impact — Severity: HIGH (functional, not cosmetic)
With 50+ tool calls per session, this means 200-400 false "hook error" lines injected into the model's context per session. The model treats these as real errors and progressively loses confidence, eventually refusing to continue multi-step tasks.
This effectively puts a ceiling on the number of plugins a user can install, because each plugin's hooks multiply the false error signals that cause turn termination.
Related Issues
All of these report the same root cause: Claude Code's hook status label logic doesn't properly distinguish success from failure.
Suggested Fix
Two things need to change:
1. Fix the label logic
Based on the hooks documentation, the status label logic should be:
Currently it appears to unconditionally label everything as "hook error".
2. Don't feed hook status labels into the model's context
Even if the label display is fixed, hook execution metadata should not be injected into the model's conversation context. The model has no ability to act on hook errors — only the user or the hook itself can. Feeding "hook error" into the context just wastes tokens and confuses the model into thinking something failed.
Hook status should be: