SessionStart hook chain emits "Failed with non-blocking status code: No stderr output" twice on cold session start
Environment
- claude-mem
12.1.5 (also reproducible by reading plugin/hooks/hooks.json on main @ HEAD as of 2026-04-25)
- Claude Code, macOS 25.3 (Darwin), bun installed at
~/.bun/bin/bun
Symptom
Every cold session start (first session after reboot, after long idle, or any time the bun worker daemon isn't already healthy) shows three SessionStart entries in the transcript: one success and two errors.
SessionStart:startup hook success: [ready] ← user's own hook
SessionStart:startup hook error: Failed with non-blocking status code: No stderr output
SessionStart:startup hook error: Failed with non-blocking status code: No stderr output
The two errors come from the second and third commands in the SessionStart:startup|clear|compact group of plugin/hooks/hooks.json. I confirmed by extracting attachment.command from ~/.claude/projects/<dir>/<session>.jsonl — both failing entries match the two long shell-command strings starting with export PATH="$($SHELL -lc 'echo $PATH'...". Both reported exitCode: 1, empty stdout, empty stderr (only the framework's "Failed with non-blocking status code" text).
Root cause
Both commands rely on the worker daemon being reachable at http://$HOST:$PORT/health. On cold start the daemon isn't running yet. The chain:
-
CMD#2 runs node bun-runner.js worker-service.cjs start, then a 20×curl health-poll loop, then a final echo '{"continue":true,"suppressOutput":true}'. The trailing echo is the load-bearing piece that should guarantee exit 0 — but my transcript logs show empty stdout, meaning the echo never executed. The hook's timeout: 60 is the most plausible cause: when bun-runner.js has to bootstrap bun (slow network / first run) plus the 20s health-poll runs, the hook can exceed 60 s and be killed by the framework. SIGKILL produces no stderr and exit 1, exactly matching what we see.
-
CMD#3 is gated: if curl -sf .../health; then node ... hook claude-code context || true; fi. There's no else and no trailing ; true. If the if-condition curl fails (no daemon yet), the script's exit code is the curl's exit code (e.g. 7 = couldn't connect), not 0. The || true only protects the inner branch.
Why "No stderr output"
Every internal command in the chain has >/dev/null 2>&1. So when bun-runner stalls or curl fails, no diagnostic text escapes the hook, and the framework only sees a non-zero exit. This makes the bug invisible without log diving.
Reproduction
# Ensure cold state
launchctl unload ~/Library/LaunchAgents/com.tiren.claude-mem-worker.plist 2>/dev/null # if you set up supervision
pgrep -f "worker-service.cjs --daemon" | xargs -r kill -9
# Start a fresh Claude Code session — observe two failures + the success line
Reproducibility is timing-dependent: if the daemon happens to be up from a previous session it doesn't trigger. Most reliable on first session after reboot.
Proposed fixes (ranked)
A. Make the hooks fire-and-forget; don't block session start on daemon health.
The right place to do work is in the daemon, not in the SessionStart shell wrapper. CMD#2 should:
- Quick health probe (
curl -sf --max-time 2 .../health) → if up, echo '{...}' and exit 0 immediately.
- If down, spawn the daemon in background (
node bun-runner.js ... start &), disown, immediately echo, exit 0.
- The daemon's job is to be ready when CMD#3 wants to inject context — but CMD#3 should also be no-op-on-not-ready instead of error.
This eliminates both the timeout window and the gating-curl exit-code bug.
B. Belt-and-braces: append ; true to both commands so the worst case is a silent no-op rather than a framework error.
Minimal patch, zero risk:
-... node "$_R/scripts/bun-runner.js" "$_R/scripts/worker-service.cjs" start; for i in 1 ... ; sleep 1; done; ... ; echo '{"continue":true,"suppressOutput":true}'
+... { node "$_R/scripts/bun-runner.js" "$_R/scripts/worker-service.cjs" start; for i in 1 ... ; sleep 1; done; ... ; echo '{"continue":true,"suppressOutput":true}'; } ; true
-... if curl -sf .../health >/dev/null 2>&1; then node "$_R/scripts/bun-runner.js" "$_R/scripts/worker-service.cjs" hook claude-code context || true; fi
+... if curl -sf .../health >/dev/null 2>&1; then node "$_R/scripts/bun-runner.js" "$_R/scripts/worker-service.cjs" hook claude-code context || true; fi; true
C. Document the workaround. Users on macOS / Linux can supervise the worker via launchd / systemd. Once the daemon is always up, the race window disappears. A 30-line launchctl plist + wrapper script eliminates the failures end-to-end (verified locally: SIGKILL the worker → respawn within 7-9 s, no SessionStart errors). I'd happily contribute a docs/worker-supervision.md if useful.
Related
I have draft patches for option A and option B if either approach is welcome. Happy to send a PR.
SessionStart hook chain emits "Failed with non-blocking status code: No stderr output" twice on cold session start
Environment
12.1.5(also reproducible by readingplugin/hooks/hooks.jsononmain@ HEAD as of 2026-04-25)~/.bun/bin/bunSymptom
Every cold session start (first session after reboot, after long idle, or any time the bun worker daemon isn't already healthy) shows three SessionStart entries in the transcript: one success and two errors.
The two errors come from the second and third commands in the
SessionStart:startup|clear|compactgroup ofplugin/hooks/hooks.json. I confirmed by extractingattachment.commandfrom~/.claude/projects/<dir>/<session>.jsonl— both failing entries match the two long shell-command strings starting withexport PATH="$($SHELL -lc 'echo $PATH'...". Both reportedexitCode: 1, emptystdout, emptystderr(only the framework's"Failed with non-blocking status code"text).Root cause
Both commands rely on the worker daemon being reachable at
http://$HOST:$PORT/health. On cold start the daemon isn't running yet. The chain:CMD#2 runs
node bun-runner.js worker-service.cjs start, then a 20×curl health-poll loop, then a finalecho '{"continue":true,"suppressOutput":true}'. The trailingechois the load-bearing piece that should guarantee exit 0 — but my transcript logs show empty stdout, meaning theechonever executed. The hook'stimeout: 60is the most plausible cause: whenbun-runner.jshas to bootstrap bun (slow network / first run) plus the 20s health-poll runs, the hook can exceed 60 s and be killed by the framework. SIGKILL produces no stderr and exit 1, exactly matching what we see.CMD#3 is gated:
if curl -sf .../health; then node ... hook claude-code context || true; fi. There's noelseand no trailing; true. If the if-condition curl fails (no daemon yet), the script's exit code is the curl's exit code (e.g. 7 = couldn't connect), not 0. The|| trueonly protects the inner branch.Why "No stderr output"
Every internal command in the chain has
>/dev/null 2>&1. So when bun-runner stalls or curl fails, no diagnostic text escapes the hook, and the framework only sees a non-zero exit. This makes the bug invisible without log diving.Reproduction
Reproducibility is timing-dependent: if the daemon happens to be up from a previous session it doesn't trigger. Most reliable on first session after reboot.
Proposed fixes (ranked)
A. Make the hooks fire-and-forget; don't block session start on daemon health.
The right place to do work is in the daemon, not in the SessionStart shell wrapper. CMD#2 should:
curl -sf --max-time 2 .../health) → if up,echo '{...}'and exit 0 immediately.node bun-runner.js ... start &),disown, immediately echo, exit 0.This eliminates both the timeout window and the gating-curl exit-code bug.
B. Belt-and-braces: append
; trueto both commands so the worst case is a silent no-op rather than a framework error.Minimal patch, zero risk:
C. Document the workaround. Users on macOS / Linux can supervise the worker via launchd / systemd. Once the daemon is always up, the race window disappears. A 30-line
launchctlplist + wrapper script eliminates the failures end-to-end (verified locally: SIGKILL the worker → respawn within 7-9 s, no SessionStart errors). I'd happily contribute adocs/worker-supervision.mdif useful.Related
I have draft patches for option A and option B if either approach is welcome. Happy to send a PR.