[BUG] "Stop hook error" displayed despite hooks producing zero output (v2.0.28)
[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:
- Produce 0 bytes of output (verified)
- Exit with code 0
- Have proper output redirection (
> /dev/null 2>&1 &) - 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)
-
ā Added block-level output redirection to all background processes
} > /dev/null 2>&1 & -
ā Suppressed tool debug output (environment variables)
export UV_NO_PROGRESS=1 export RUST_LOG=error -
ā Redirected all subprocess stdout/stderr
subprocess_command >> /dev/null 2>> "$log_file" -
ā Added explicit exit 0 to all hooks
-
ā Killed stale cached processes (24+ hour old bash processes)
-
ā Implemented single-instance protection (prevents duplicate processes)
-
ā Added stale process cleanup (auto-kills processes >1 hour old)
-
ā 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
- If hooks produce 0 bytes output, do not show "Stop hook error"
- Clarify documentation: Are Stop hooks required to output JSON?
- Fix detection logic: "Hook output does not start with {" should not trigger for empty output
- Alternative: If JSON is required, provide clear error message: "Stop hooks must output JSON starting with { or produce no output"
Questions for Claude Team
- Are Stop hooks required to output JSON starting with
{? - Should empty output (0 bytes, exit 0) trigger "Stop hook error"?
- Is this related to the v2.0.17+ hook display regression (#9602)?
- 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 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
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, unfortunately and frustratingly, I didn't find any solution or workaround.
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:
- Buffering issue - Python stdout not flushed before Claude Code reads
- Race condition - Claude Code reads stdout before hook finishes writing
- 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: 0observation 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.
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
- Hooks output valid JSON to stdout ā
-
JSON starts with
{(hex7b) - no BOM, no whitespace, no hidden characters ā - No stderr output during normal execution ā
-
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.
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 0at 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
--debugflag (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
- Documentation: What is the expected hook output contract? (JSON vs plain text vs zero output)
- Visibility rules: How do exit codes (0, 1, 2) and streams (stdout, stderr) affect what Claude sees?
- Size limits: Are there undocumented size limits or character filters on hook output?
- 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
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.