Skip to content

SessionStart hook chain emits "Failed with non-blocking status code: No stderr output" twice on cold session start #2125

Description

@Harkanovac

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:

  1. 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.

  2. 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:

  1. Quick health probe (curl -sf --max-time 2 .../health) → if up, echo '{...}' and exit 0 immediately.
  2. If down, spawn the daemon in background (node bun-runner.js ... start &), disown, immediately echo, exit 0.
  3. 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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions