[BUG] StopFailure command hooks are killed ~10-50ms after the event fires (2.1.246+, still present in 2.1.296)
Summary
On Claude Code 2.1.250 and later (verified up to 2.1.296), a StopFailure command hook that spawns an external process never runs to completion in headless (-p) mode. The hook is dispatched, but the process tree is torn down ~10–50 ms after the event fires, so a hook command that needs to start an interpreter (e.g. python3) never reaches its first line. A hook command that is fast enough (~5 ms, or a shell builtin) still completes — which is what makes this look intermittent at first.
Every other event is unaffected: on 2.1.282, SessionStart, UserPromptSubmit, PreToolUse, MessageDisplay, Stop and SessionEnd all run a 300 ms sleep and a python3 spawn to completion.
Environment
- Claude Code 2.1.170 / 2.1.190 / 2.1.210 / 2.1.226 / 2.1.235 / 2.1.245 / 2.1.250 / 2.1.282 / 2.1.296 (npm)
- macOS, arm64; headless:
claude -p "<prompt>" --plugin-dir <plugin-dir> - API deliberately failing: a local HTTP server answering
POST /v1/messageswith a 400
Steps to reproduce
Minimal plugin hooks/hooks.json:
{
"hooks": {
"StopFailure": [
{
"matcher": "",
"hooks": [
{ "type": "command", "command": "sleep 0.05; echo s05 >> /tmp/probe.log" },
{ "type": "command", "command": "sleep 0.3; echo s30 >> /tmp/probe.log" },
{ "type": "command", "command": "python3 --version >> /tmp/probe.log 2>&1" }
]
}
]
}
}
Run a headless session whose API call fails, then inspect /tmp/probe.log.
Observed
Version bisect, same probe and machine:
| Version | sleep 0.05 | sleep 0.3 | python3 |
| :- | :- | :- | :- |
| 2.1.170 – 2.1.245 | yes | yes | yes |
| 2.1.250 – 2.1.296 | no | no | no |
On 2.1.250+ none of the three commands leave any trace. A finer ladder puts the window between 10 ms and 50 ms (sleep 0.01 completes, sleep 0.05 does not).
Evidence that the hook is dispatched, then killed
- With
--debug,<config-dir>/debug/<session-id>.txtshows, for each StopFailure hook:
StopFailure:server_error [python3 ".../hook.py"] completed with status 1
while every non-StopFailure hook in the same session logs completed with status 0.
- A probe whose command is only
env >> /tmp/env.log(~5 ms) succeeds and dumps the full environment (128 vars,HOMEand all custom variables present). The next hook in the same array —python3 --version >> /tmp/out.log 2>&1; echo rc=$? >> /tmp/out.log— leaves/tmp/out.log0 bytes: the shell was killed mid-command, before the trailingecho. - It cannot be worked around from the plugin side:
nohup python3 ... &and( python3 ... & )both fail as well. CLAUDE_CODE_SESSIONEND_HOOKS_TIMEOUT_MS=60000has no effect on StopFailure.- The change is not mentioned anywhere in the 2.1.170 → 2.1.296 changelog entries.
Expected
A StopFailure command hook should run to completion like hooks on every other event — the docs give command hooks a 600 s default timeout and only say StopFailure's output and exit code are ignored, not that the process itself is not awaited. If StopFailure hooks are meant to be best-effort, that deserves an explicit note in the docs.
Workaround
Adding "async": true to the StopFailure hook entry makes it run to completion. Verified on 2.1.282: a callback that writes a JSON payload and then runs an external CLI with retries (≈17 s of total work) completed fully.
{ "type": "command", "async": true, "command": "python3 \"${CLAUDE_PLUGIN_ROOT}/scripts/hooks/callback.py\"" }
Impact
Any plugin — or user settings.json hook — that does real work on StopFailure (writing a failure payload, notifying an orchestrator, updating job state, model-failover bookkeeping) silently stops working for headless sessions on current versions, with nothing surfaced to the user.