[BUG] StopFailure command hooks are killed ~10-50ms after the event fires (2.1.246+, still present in 2.1.296)

Status Open
Reported on v2.1.250
Maintainer reply None cached
Activity 0 comments · opened Oct 12, 2026

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/messages with 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

  1. With --debug, <config-dir>/debug/<session-id>.txt shows, 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.

  1. A probe whose command is only env >> /tmp/env.log (~5 ms) succeeds and dumps the full environment (128 vars, HOME and 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.log 0 bytes: the shell was killed mid-command, before the trailing echo.
  2. It cannot be worked around from the plugin side: nohup python3 ... & and ( python3 ... & ) both fail as well.
  3. CLAUDE_CODE_SESSIONEND_HOOKS_TIMEOUT_MS=60000 has no effect on StopFailure.
  4. 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.

View original on GitHub ↗