claude-code icon indicating copy to clipboard operation
claude-code copied to clipboard

[BUG] "Stop hook error" displayed despite hooks producing zero output (v2.0.28)

Open terrylica opened this issue 2 months ago • 9 comments

[BUG] "Stop hook error" displayed despite hooks producing zero output (v2.0.28)

Summary

Claude Code v2.0.28 displays "āŽæ Stop hook error" after every response, even though all Stop hooks produce exactly 0 bytes of output when tested manually. The error persists across fresh sessions despite comprehensive fixes including output redirection, process cleanup, and single-instance protection.

Environment

  • Claude Code Version: 2.0.28
  • Platform: macOS Darwin 24.6.0
  • Terminal: iTerm.app / Ghostty
  • Installation: npm global (~/.claude/local/node_modules/.bin/claude)
  • Configuration: ~/.claude/settings.json

Bug Description

Observed Behavior

> Tell me a joke

āŗ Why do programmers prefer dark mode?

  Because light attracts bugs! šŸ›šŸ’”
  āŽæ  Stop hook error        <-- ERROR APPEARS EVERY TIME

Error appears:

  • āœ… On every Stop event (end of Claude's response)
  • āœ… Across fresh sessions (not cached)
  • āœ… In multiple workspaces
  • āœ… Even with --debug mode enabled

Debug log shows:

[DEBUG] Getting matching hook commands for Stop with query: undefined
[DEBUG] Found 1 hook matchers in settings
[DEBUG] Matched 3 unique hooks for query "no match query" (3 before deduplication)
[DEBUG] Hook output does not start with {, treating as plain text
[DEBUG] Hook output does not start with {, treating as plain text
[DEBUG] Hook output does not start with {, treating as plain text

Expected Behavior

Stop hooks should execute silently with no error message since they:

  1. Produce 0 bytes of output (verified)
  2. Exit with code 0
  3. Have proper output redirection (> /dev/null 2>&1 &)
  4. Are configured correctly per documentation

Actual Behavior

"Stop hook error" appears after every response, despite hooks functioning correctly (they execute, produce no output, and complete successfully).

Reproduction Steps

1. Configure Stop Hooks

~/.claude/settings.json:

{
  "hooks": {
    "Stop": [
      {
        "hooks": [
          {
            "type": "command",
            "command": "$HOME/.claude/automation/hook-1.sh"
          },
          {
            "type": "command",
            "command": "$HOME/.claude/automation/hook-2.sh"
          },
          {
            "type": "command",
            "command": "$HOME/.claude/automation/hook-3.sh"
          }
        ]
      }
    ]
  }
}

2. Create Minimal Test Hook

/tmp/test-hook.sh:

#!/usr/bin/env bash
set -euo pipefail

# Background process with output redirection
{
    # All operations redirected
    echo "test" > /dev/null 2>&1
} > /dev/null 2>&1 &

# Exit immediately
exit 0
chmod +x /tmp/test-hook.sh

3. Verify Hook Produces Zero Output

$ echo '{}' | /tmp/test-hook.sh 2>&1 | wc -c
0

$ echo '{"session_id":"test","hook_event_name":"Stop"}' | /tmp/test-hook.sh 2>&1 | wc -c
0

4. Start Claude Code

$ claude --debug
> Tell me a joke
# ERROR APPEARS: "āŽæ Stop hook error"

5. Check Debug Log

$ grep "Hook output" ~/.claude/debug/*.txt | tail -5
[DEBUG] Hook output does not start with {, treating as plain text
[DEBUG] Hook output does not start with {, treating as plain text
[DEBUG] Hook output does not start with {, treating as plain text

Verification Tests Conducted

Test 1: Manual Hook Execution

All hooks verified to produce 0 bytes when executed manually:

$ echo '{"session_id":"test","hook_event_name":"Stop"}' | ./hook.sh 2>&1 | wc -c
0

Test 2: Delayed Output Check

$ echo '{}' | ./hook.sh > /tmp/out 2>&1
$ sleep 5  # Wait for background processes
$ wc -c < /tmp/out
0

Test 3: Process Inspection

$ ps aux | grep hook | grep -v grep
# No stale processes found

Test 4: File Handle Check

$ lsof | grep hook
# No open file handles to old hook versions

Attempted Fixes (All Verified, None Resolved Issue)

  1. āœ… Added block-level output redirection to all background processes

    } > /dev/null 2>&1 &
    
  2. āœ… Suppressed tool debug output (environment variables)

    export UV_NO_PROGRESS=1
    export RUST_LOG=error
    
  3. āœ… Redirected all subprocess stdout/stderr

    subprocess_command >> /dev/null 2>> "$log_file"
    
  4. āœ… Added explicit exit 0 to all hooks

  5. āœ… Killed stale cached processes (24+ hour old bash processes)

  6. āœ… Implemented single-instance protection (prevents duplicate processes)

  7. āœ… Added stale process cleanup (auto-kills processes >1 hour old)

  8. āœ… Verified no cached file descriptors (killed all hook processes, restarted fresh)

Root Cause Analysis

Theory 1: Claude Code UI Bug

Claude Code reports "Hook output does not start with {" even when hooks produce zero bytes of output. This may be:

  • A regression from v2.0.17+ hook display issues (#9602)
  • False positive detection
  • Race condition in output capture
  • Incorrect handling of empty output

Theory 2: JSON Output Expectation Mismatch

Error message "Hook output does not start with {" suggests Claude Code expects JSON output from Stop hooks, but documentation indicates:

  • Hooks can output nothing (exit 0 = success, no output)
  • JSON output is optional for structured responses
  • Plain exit codes should be valid

Question: Are Stop hooks required to output JSON? If so, this should be documented. If not, empty output should not trigger an error.

Related Issues

  • #9679 - Hook status messages displayed on every API response (v2.0.19-2.0.20)
  • #9602 - Stop Hook Regression in 2.0.17-2.0.20 - Fires Multiple Times
  • #10401 - All hooks require --debug flag in v2.0.27
  • #9052 - Stop Hooks Causing Unexpected Processing State Interruption

Impact

  • Severity: Medium (cosmetic but persistent)
  • User Experience: Error messages clutter every interaction
  • Functionality: Hooks work correctly despite error messages
  • Workaround: None (error appears regardless of fixes)

Requested Fix

  1. If hooks produce 0 bytes output, do not show "Stop hook error"
  2. Clarify documentation: Are Stop hooks required to output JSON?
  3. Fix detection logic: "Hook output does not start with {" should not trigger for empty output
  4. Alternative: If JSON is required, provide clear error message: "Stop hooks must output JSON starting with { or produce no output"

Questions for Claude Team

  1. Are Stop hooks required to output JSON starting with {?
  2. Should empty output (0 bytes, exit 0) trigger "Stop hook error"?
  3. Is this related to the v2.0.17+ hook display regression (#9602)?
  4. Can you reproduce with minimal test hook shown above?

Report Date: 2025-10-27 Reproducibility: 100% (consistent across all sessions) Blocker: No (hooks work, error is cosmetic) Versions Affected: v2.0.28 (possibly earlier versions)

terrylica avatar Oct 27 '25 22:10 terrylica

@terrylica did you manage to find a solution around this?

I'm hitting the same exact problem with UserPromptSubmit but it's even more odd, because I have two scripts, one runs fine the other doesn't.

"UserPromptSubmit": [
	{
		"hooks": [
			{
				"type": "command",
				"command": "~/.claude/hooks/log-event.sh UserPromptSubmit"
			},
			{
				"type": "command",
				"command": "~/.claude/hooks/aggregate-prompts.sh UserPromptSubmit"
			}
		]
	}
],

log-event.sh runs but aggregate-prompts.sh doesn't and I'm on 2.0.34

Even if I switch the order aggregate-prompt.sh never runs but log-event.sh and I see UserPromptSubmit hook error in the TUI.

No matter what, even if I have an empty script with exit 0

ahmedelgabri avatar Nov 13 '25 13:11 ahmedelgabri

Hi @ahmedelgabri @terrylica,

Excuse me, do you have any updates on this issue? May I ask how you fixed it? I am also encountering the same problem now.

Thank you!

kevinwei0410 avatar Nov 25 '25 08:11 kevinwei0410

@kevinwei0410, unfortunately and frustratingly, I didn't find any solution or workaround.

ahmedelgabri avatar Nov 25 '25 15:11 ahmedelgabri

Additional Findings: PreToolUse Hooks + stdout_len: 0

Adding diagnostic information from investigating this issue with PreToolUse hooks using the cchooks Python SDK.

Environment

  • Claude Code: v2.0.58 (latest as of Dec 10, 2025)
  • Platform: Linux (Ubuntu 24.04)
  • Hook implementation: Python with cchooks SDK (context.output.allow())

Symptoms

ā— Bash(grep -c ...)
  āŽæ  PreToolUse:Bash hook error
  āŽæ  PreToolUse:Bash hook error
  āŽæ  0

Two "hook error" lines appear because two PreToolUse hooks run. The command executes successfully.

Key Finding: stdout_len: 0 Despite SDK Output

Our hook logging captures full execution details. Here's what we see for successful hooks:

{
  "hook_name": "01-gated_tool_access_control.py",
  "execution": {
    "exit_code": 0,
    "success": true
  },
  "hook_output": {
    "decision": "allow",
    "reason": "Required skill 'tool-access' is invoked"
  }
}

However, checking stdout length shows:

{"hook": "01-gated_tool_access_control.py", "stderr": null, "stdout_len": 0}
{"hook": "gate_sequence_commands.py", "stderr": null, "stdout_len": 0}

The hooks use context.output.allow() from the cchooks SDK, which should output valid JSON to stdout. But stdout_len: 0 suggests Claude Code isn't receiving it.

Hypothesis

This may not be "treating non-JSON as error" but rather "not receiving stdout at all". Possible causes:

  1. Buffering issue - Python stdout not flushed before Claude Code reads
  2. Race condition - Claude Code reads stdout before hook finishes writing
  3. SDK issue - cchooks not writing to stdout in a way Claude Code captures

Why This Matters

  • Affects PreToolUse hooks (not just Stop/SessionStart as previously reported)
  • Occurs with structured SDK usage, not just manual bash scripts
  • The stdout_len: 0 observation suggests a different root cause than JSON parsing
  • Multiple hooks per tool invocation = multiple spurious "error" lines (UX impact compounds)

Reproduction

Any PreToolUse hook using the cchooks SDK pattern:

from cchooks import create_context, PreToolUseContext

def main():
    context = create_context(PreToolUseContext)
    # ... validation logic ...
    context.output.allow(reason="Tool access permitted")

if __name__ == "__main__":
    main()

Happy to provide more details or test diagnostic builds if helpful.

kitaekatt avatar Dec 10 '25 19:12 kitaekatt

Update: stdout flush workaround tested - doesn't help

Tested adding sys.stdout.flush() after context.output.allow() calls. Result: No change - "hook error" still displayed.

Direct Hook Testing

Tested hooks directly via stdin pipe:

$ echo '{"tool_name": "Bash", ...}' | ./gate_sequence_commands.py 2>/dev/null
{"continue": true, "suppressOutput": false, "hookSpecificOutput": {"hookEventName": "PreToolUse", "permissionDecision": "allow", "permissionDecisionReason": ""}}
$ echo '{"tool_name": "Bash", ...}' | ./gate_sequence_commands.py 2>/dev/null | xxd | head -1
00000000: 7b22 636f 6e74 696e 7565 223a 2074 7275  {"continue": tru

Findings

  1. Hooks output valid JSON to stdout āœ“
  2. JSON starts with { (hex 7b) - no BOM, no whitespace, no hidden characters āœ“
  3. No stderr output during normal execution āœ“
  4. sys.stdout.flush() doesn't help - the issue isn't Python buffering

Conclusion

The bug is in Claude Code's hook handler, not in hook implementations. Hooks are:

  • Exiting with code 0
  • Outputting valid JSON starting with {
  • Not writing to stderr

Yet Claude Code displays "hook error". This rules out:

  • āŒ Python stdout buffering
  • āŒ Hidden characters before JSON
  • āŒ stderr pollution

The issue must be in how Claude Code spawns/reads from hook subprocesses.

kitaekatt avatar Dec 10 '25 20:12 kitaekatt

Update: Root Cause Analysis & Partial Workaround

After extensive investigation, I've identified what resolved MY case and discovered this is part of a larger issue affecting 14+ related GitHub issues.

My Resolution (Async Output Leak)

Root cause: Background blocks with } & produce output AFTER the hook exits. Claude Code captures this delayed output and flags it as "hook error."

Fix:

  • Use } > /dev/null 2>&1 & instead of } &
  • Add explicit exit 0 at end of script
  • Ensure nested scripts also suppress output

Example (defense-in-depth pattern):

{
    # Background work here
    some_command > /dev/null 2>&1
} > /dev/null 2>&1 &  # Block-level redirection catches any leaks

exit 0  # Explicit exit before async output appears

Different Failure Mode (@kitaekatt's case)

Your diagnostic proves a DIFFERENT bug exists:

  • Hooks output valid JSON (verified with hex dump)
  • No format issues, BOM, or hidden characters
  • Bug appears to be in Claude Code's subprocess reader

My fix may not help your case - this seems to be a separate issue in how Claude Code reads hook subprocess output.

Related Issues (14+ Found)

Output Handling:

  • #9602 - Hook fires once but UI shows 4-7x duplicates
  • #9679 - SessionStart hook message repeats every response
  • #12667 - "error" displayed for intentional {"decision": "block"}
  • #13912 - UserPromptSubmit stdout causes error (docs say it shouldn't)
  • #11224 - PostToolUse visibility depends on exit code + stream combo

Execution Failures:

  • #10401 - All hooks require --debug flag (v2.0.27)
  • #10450 - No hooks work on Windows
  • #11716 - Background processes cause 11.6x token waste

Regression Pattern:

  • v2.0.15: Hooks worked correctly āœ…
  • v2.0.17+: Regressions introduced

Request for Anthropic Team

  1. Documentation: What is the expected hook output contract? (JSON vs plain text vs zero output)
  2. Visibility rules: How do exit codes (0, 1, 2) and streams (stdout, stderr) affect what Claude sees?
  3. Size limits: Are there undocumented size limits or character filters on hook output?
  4. Background processes: Best practices for async/background work in hooks?

Would be great to have official hook authoring guidelines in the docs.

cc: @ahmedelgabri @kevinwei0410 @kitaekatt

tainora avatar Dec 16 '25 23:12 tainora

Additional Case Report - SessionStart Hook

Claude Code Version: Latest (Dec 2025) OS: macOS Darwin 24.6.0 Hook Type: SessionStart (also affects UserPromptSubmit)

Same bug pattern, different hook type. Adding diagnostic evidence:

Evidence

Added diagnostic logging to prove hook executes successfully:

=== Hook started at 1765953992.9169059 ===
Input received: {'session_id': '...', 'source': 'resume', ...}
Source: resume
SUCCESS: Completed in 0.086s, output size: 3001 chars

Key metrics:

  • Execution time: 86ms (well within any timeout)
  • Output: Valid JSON with hookSpecificOutput.additionalContext
  • Exit code: 0 (success)

Attempted Fixes (None Worked)

Version Change Result
v2.4.3 Redirect stderr to /dev/null Error persists
v2.4.4 Increase timeout 5s→8s + memory cache Error persists
v2.4.5 Remove cache invalidation Error persists
v2.4.6 Disable auto_sync function Error persists
v2.4.7 Add diagnostic logging Proved hook works correctly

Conclusion

The "SessionStart:resume hook error" message is a false positive. The hook:

  • Executes successfully in <100ms
  • Outputs valid JSON
  • Returns exit code 0

This bug affects multiple hook types (Stop, SessionStart, UserPromptSubmit) with the same pattern: successful execution shown as error in UI.

davidmontemayort avatar Dec 17 '25 07:12 davidmontemayort