Skip to content

[BUG] False "Hook Error" labels cause Claude to prematurely end turns (exit 0, no stderr, valid JSON) #34713

@doublefx

Description

@doublefx

[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:

  1. Functional: Turns terminate prematurely — Claude abandons multi-step tasks
  2. Alarm fatigue: Users can't distinguish real errors from false ones
  3. Trust erosion: Users blame their plugins for "errors" that don't exist
  4. 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

  1. Add a trivial hook to ~/.claude/settings.json:
{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Read",
        "hooks": [
          {
            "type": "command",
            "command": "cat > /dev/null && echo '{\"hookSpecificOutput\":{\"hookEventName\":\"PreToolUse\"}}'"
          }
        ]
      }
    ]
  }
}
  1. Run any session that uses the Read tool
  2. Observe: PreToolUse:Read hook error in the transcript

Real-world reproduction (plugin ecosystem)

  1. Install magic-claude plugin (~15 hooks across all events)
  2. Install context-mode plugin (~10 hooks)
  3. Run any session
  4. 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)

Metadata

Metadata

Assignees

No one assigned

    Labels

    area:hooksarea:tuibugSomething isn't workingduplicateThis issue or pull request already existshas reproHas detailed reproduction steps

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions