28 Hours of Green Logs, Zero Replies: How a Single `echo` Line Swallowed Every Exit Code A developer who built an autonomous Claude Code environment discovered that a single `echo` line in a shell script silently swallowed every exit code, causing hooks to appear successful while actually failing to block Claude's actions. The bug, caused by command substitution overwriting `$?` before it was read, affected three scripts and made all green logs meaningless. The developer fixed the issue and warns others about similar silent failures in hook wiring. For 24 hours, every dashboard was green. Every launchd job reported exit 0 , and the logs lined up neatly with exit 0 on every row. Meanwhile, DM replies on X formerly Twitter were at zero for the entire day, and YouTrust was the same. The failures were happening. The exit codes just never made it back to the caller. Back in university I stacked freelance gigs up to ¥600k/month, then got laid off and went back to zero. Over the following six months I built an autonomous Claude Code environment, and I'm now at ¥1.2M/month in revenue. This article is about the hook wiring that holds that environment together — more precisely, about the time I thought I'd wired it up and nothing was actually plugged in. Claude Code has two kinds of hook points: PreToolUse and Stop . PreToolUse interrupts right before Claude invokes a tool; Stop runs right before Claude tries to finish a response. If a hook script returns exit 1 , Claude blocks that tool call or Stop action — by specification. When I learned about this, I immediately wanted to use it as a guardrail for my automation environment. "Don't let an implementation be marked complete without an audit." "Stop if a secret is about to slip into a commit." "Cancel the Action if a designated script fails." You write these in code and control Claude's behavior from the outside. Speaking frankly as someone who runs this environment: with 171 launchd jobs and multiple Claude Code sessions running at once, something breaks every day without hooks. Config mistakes, environment differences, model whims — hooks let you land each of them. Which is exactly why a state where a hook is only pretending to work and isn't actually stopping anything is the same as a cliff with no guardrail. If you use Claude Code, there's a good chance you'll get caught by one of these. Pattern A: You wrote your hook as a shell script. The script returns exit 1 , but Claude doesn't stop. Check the logs and the exit code reads 0 — even though you're sure you wrote the script correctly. Pattern B: You call your hook through a JS wrapper. The JS wrapper uses child process.exec or $ command substitution to call the inner shell script. The inner script returns exit 1 , but the outer JS process receives 0 . Pattern C: You call the script inside a pipeline. You pass stdin through a pipe, like cat input.json | ./hook-script.sh . Without set -o pipefail , only the exit code of the right-hand side of the pipe reaches the caller. In every case: "I wrote the hook," "it's running," "the logs are there" — and the guard still isn't working. Visually green, actually protecting nothing. That's the essence of a silent bug. On the day of the incident, every line of the DM system's log read exit 0 . But the problem was in that log line. This is the code that was actually running: node "$SCRIPT" "$@" echo " $ date '+%F %T' $LANE done exit $? " echo 's arguments are evaluated left to right. $ date '+%F %T' spawns a subshell — and succeeds — and returns. At that instant, $? is overwritten with 0 . $? is read after that. In other words, whether the preceding node died with exit 3 or exit 4 , this line is syntactically incapable of printing anything but exit 0 . As evidence, I confirmed via a reproduction test that before the fix exit 4 displayed as 0 , and after the fix it displayed as 4 . The frightening part is that it's retroactive. As long as that line is in there, not a single exit 0 in past logs counts as evidence. "It was green last week and last month too" just means you kept running code that prints green. Scanning the 328 shell scripts under ~/dev and ~/.claude/scripts turned up a total of 3 instances of this trap. One around Claude Code's hooks, one in the note paid-bonus ZIP attachment script, and one in a dotfiles snapshot script. All of them were in the state of "the logs were printing, but the exit code was dead." First, let's confirm the path by which Claude Code's Stop/PreToolUse hooks call shell scripts. Claude Code(本体プロセス) │ │ hook event JSON payload を stdin に渡す ▼ JS hook dispatcher(settings.json で指定) │ │ child process.spawn または exec ▼ ~/.claude/scripts/hooks/run-with-flags-shell.sh │ │ stdin → パイプ経由で渡す │ HOOK ID / REL SCRIPT PATH / PROFILES CSV を引数で受け取る ▼ check-hook-enabled.js(このhookが有効か確認) │ │ enabled ならば ▼ $SCRIPT PATH(実際のフックロジック) │ │ exit 0 / exit 1 ▼ run-with-flags-shell.sh(終了コードを返す) │ ▼ JS dispatcher(終了コードを受け取る → Claude本体へ) │ ▼ Claude Code(exit 1 なら動作をブロック) The point of this diagram is whether each arrow passes the exit code correctly. If even one link breaks, the terminal exit 1 never reaches Claude. run-with-flags-shell.sh Here is the actual wrapper script ~/.claude/scripts/hooks/run-with-flags-shell.sh . bash /usr/bin/env bash set -euo pipefail HOOK ID="${1:-}" REL SCRIPT PATH="${2:-}" PROFILES CSV="${3:-standard,strict}" SCRIPT DIR="$ cd "$ dirname "${BASH SOURCE 0 }" " && pwd " PLUGIN ROOT="${CLAUDE PLUGIN ROOT:-$ cd "${SCRIPT DIR}/../.." && pwd }" Preserve stdin for passthrough or script execution INPUT="$ cat " if -z "$HOOK ID" || -z "$REL SCRIPT PATH" ; then printf '%s' "$INPUT" exit 0 fi Ask Node helper if this hook is enabled ENABLED="$ node "${PLUGIN ROOT}/scripts/hooks/check-hook-enabled.js" "$HOOK ID" "$PROFILES CSV" 2 /dev/null || echo yes " if "$ENABLED" = "yes" ; then printf '%s' "$INPUT" exit 0 fi SCRIPT PATH="${PLUGIN ROOT}/${REL SCRIPT PATH}" if -f "$SCRIPT PATH" ; then echo " Hook Script not found for ${HOOK ID}: ${SCRIPT PATH}" &2 printf '%s' "$INPUT" exit 0 fi Extract phase prefix from hook ID e.g., "pre:observe" - "pre", "post:observe" - "post" HOOK PHASE="${HOOK ID%%: }" printf '%s' "$INPUT" | "$SCRIPT PATH" "$HOOK PHASE" set -euo pipefail is at the top of the file. That's the strictest setting: "exit immediately if a command fails, error on undefined variables, catch failures inside pipes too." The last line, printf '%s' "$INPUT" | "$SCRIPT PATH" "$HOOK PHASE" , uses a pipe, but thanks to pipefail , if the right-hand $SCRIPT PATH returns exit 1 , run-with-flags-shell.sh itself also ends with exit 1 — within this script alone . The problem is outside this script. Point of interest ①: the check-hook-enabled.js call on line 19 ENABLED="$ node "${PLUGIN ROOT}/scripts/hooks/check-hook-enabled.js" "$HOOK ID" "$PROFILES CSV" 2 /dev/null || echo yes " If the node inside $ dies from some error, || echo yes fires and ENABLED becomes "yes" . In other words, even when the hook-check script itself is broken, the hook proceeds down the execution path as "enabled." This is intentional as a fallback design, but it's a structure where errors in the hook-check logic get swallowed silently. Point of interest ②: the pipe on the last line printf '%s' "$INPUT" | "$SCRIPT PATH" "$HOOK PHASE" Because pipefail is on, $SCRIPT PATH 's exit 1 does propagate properly into this script's exit code. But depending on how the JS dispatcher calls this script , whether that code reaches Claude changes. If the JS uses child process.exec , it determines success or failure by whether the callback's first argument error is null. Because exec interposes a shell internally, the shell's exit code arrives as error.code — however, when the exec options set shell: true, there are cases where the shell itself handles the exit 1 and it never reaches the parent process . If it uses child process.spawn , you can get the exit code from the code argument of the close event. That's mostly accurate, but if the spawned process ends via SIGTERM or SIGKILL , code becomes null . And one more: if JS calls it via command substitution like $ run-with-flags-shell.sh ... — as explained earlier — $? is reliably destroyed. The following was confirmed for the incident on 2026-08-29. | Channel | Launches | Actual replies | Alerts fired | |---|---|---|---| | X formerly Twitter | 6 all ABORT | 0 | Fired from the 3rd onward 14:33 / 16:15 / 18:15 / 20:15 | | YouTrust | 6 all failed to launch | 0 | Not a single one | On the X side, the exit code made it through part of the path to the monitoring layer, so from the third run onward a notification appeared in Discord's 01 alerts . On the YouTrust side, a process.exit 3 in the library layer bypassed the caller's catch block, so the read failures counter was never incremented and not a single notification reached Discord. Same day, same root cause Chrome launch failure , same architectural philosophy — one rang and the other was completely silent. The difference is a single line in a library. That one line silenced 24 hours' worth of outreach DMs. Here's a minimal sample of the structure where a hook's JS wrapper calls a shell script using $ . js // ❌ $ 経由では exit code が潰れる const { execSync } = require 'child process' ; function runHook scriptPath, input { try { // execSync はデフォルトで throws on non-zero exit // しかし内部で $ を重ねると話が変わる const result = execSync echo '${input}' | ${scriptPath} , { encoding: 'utf8', shell: true, // ← ここが問題の温床になりやすい } ; return { success: true, output: result }; } catch e { // e.status が null になるケースがある return { success: false, code: e.status }; } } The shell: true option passes the command string to /bin/sh -c "..." . Whether that shell wrapper propagates exit 1 as the process's exit code depends on the shell's implementation and how the arguments are assembled. In particular, when you pass a pipe like echo '...' | script.sh with shell: true , pipefail is not inherited into that shell session, so a failure on the left-hand side gets swallowed. Meanwhile, the pipe that run-with-flags-shell.sh itself uses on its last line — printf '%s' "$INPUT" | "$SCRIPT PATH" "$HOOK PHASE" — is under the control of the set -euo pipefail at the top of the script, so $SCRIPT PATH 's exit 1 correctly surfaces as the script's exit code. This script on its own is correct. The problem is in the calling layer above it. All 3 traps found in the 328-script scan were the same pattern: "an echo line inside the script mixing $ date and $? ." The detection query can be used as-is. grep -rn 'exit \$?' --include=' .sh' ~/dev ~/.claude/scripts | grep '\$ ' This query picks up lines containing exit $? where $ also appears on the same line. Assignment patterns like || rc=$? x="$ cmd " || rc=$? are correct usage, so that distinction alone can't be made mechanically — it needs a human eye. The first half traced the structure of "why exit codes don't arrive." From here we'll look concretely, with real code, at "how to rewrite it so they do." There are 3 fix patterns. Each is a change of 2 lines or less, and each covers a different path. set -euo pipefail Alone Doesn't Save the "Outside" The top of ~/.claude/scripts/hooks/run-with-flags-shell.sh is as follows. bash /usr/bin/env bash set -euo pipefail This line guarantees that " pipefail is enabled within this script's execution context." Indeed, the final line printf '%s' "$INPUT" | "$SCRIPT PATH" "$HOOK PHASE" is under pipefail 's control, so if $SCRIPT PATH returns exit 1 , run-with-flags-shell.sh itself also ends with exit 1 . That part is correct. The problem is "how the JS dispatcher launches this shell script." If the JS passes shell: true to child process.exec , the command string is internally wrapped in /bin/sh -c "..." . That /bin/sh session does not inherit pipefail . From JS's point of view, the process tree isn't " /bin/sh → run-with-flags-shell.sh " but "shell wrapper → run-with-flags-shell.sh as its child process." The shell wrapper's own exit code is normally 0 . And another: when JS extracts a result string via execSync in a command-substitution-like way — js const out = execSync cat payload.json | ${hookScript} , { shell: true } ; — as long as the left-hand cat payload.json succeeds, whatever the right-hand hookScript returns has no effect on execSync 's error determination. The combination of shell: true + a pipe + no pipefail quietly discards the right-hand side's exit code. $? Before Any $ ... The lightest fix. It applies broadly to "cases where a log line mixes $ date and $? ." ❌ Before:$? が $ date で上書きされる node "$SCRIPT" "$@" echo " $ date '+%F %T' $LANE done exit $? " ✅ After:STATUS に退避してから $ date を展開する node "$SCRIPT" "$@" STATUS=$? echo " $ date '+%F %T' $LANE done exit $STATUS " STATUS=$? is just a variable assignment, so it doesn't spawn a subshell. $? is secured before the next line's $ date destroys it. That alone eliminates "the divergence between the code the log prints and the actual exit code." There is a caveat, though. The assignment form x="$ cmd " has the exit status of the whole assignment become that of $ cmd , so this is correct usage. ✅ これは正しい。x の代入ステータスは node のステータスと同じ x="$ node "$SCRIPT" "$@" " STATUS=$? On the other hand, $? on an echo line that contains $ will reliably be zero. The difference between the two forms can't be distinguished mechanically with grep — after a regex hit, one step of visual inspection is required. Detection query identical to the one shown in the first half : grep -rn 'exit \$?' --include=' .sh' ~/dev ~/.claude/scripts | grep '\$ ' Among the lines this query returns, exclude the x="$ ... " form. Everything else needs fixing. PIPESTATUS to Capture Every Command's Code Use this when you can't rewrite the pipe. PIPESTATUS is a bash-specific array that holds the exit codes of each command in the preceding pipeline, in left-to-right order. printf '%s' "$INPUT" | "$SCRIPT PATH" "$HOOK PHASE" PIPE STATUS= "${PIPESTATUS @ }" PIPE STATUS 0 = printf の終了コード PIPE STATUS 1 = $SCRIPT PATH の終了コード if "${PIPE STATUS 1 }" -ne 0 ; then exit "${PIPE STATUS 1 }" fi Since run-with-flags-shell.sh has set -euo pipefail at the top, there's currently no need to add this — with pipefail , a failure on the right-hand side takes the script itself down. You'd make PIPESTATUS explicit when "there's a reason you can't turn on pipefail " or when "you want to record which of the commands failed in the log." If your design records channel-name-and-exit-code pairs in launchd job logs, writing PIPESTATUS 1 directly into the log makes later tracing easier. The most fundamental fix. Replace shell: true + exec with spawn so no shell wrapper is involved. js // ❌ Before:shell: true のため $ 内の pipefail が死ぬ const { execSync } = require 'child process' ; const out = execSync echo '${payload}' | ${hookScript} , { shell: true } ; // ✅ After:spawn で直接呼ぶ。終了コードは close イベントの code で取れる const { spawn } = require 'child process' ; function runHook hookScript, payload { return new Promise resolve, reject = { const child = spawn hookScript, , { stdio: 'pipe', 'pipe', 'inherit' } ; child.stdin.write payload ; child.stdin.end ; child.on 'close', code = { if code == 0 { reject new Error hook exited with ${code} ; } else { resolve ; } } ; } ; } Used without shell: true , spawn launches the command directly via execve 2 . Since no shell wrapper is interposed, the set -euo pipefail inside run-with-flags-shell.sh stays in effect. The close event's code argument is either null terminated by signal or an integer normal termination . When it's null , check the signal argument to see what happened. exit 1 Repro You can't stop at "I fixed it." Verify with a minimal reproduction case that Claude's behavior actually gets blocked. Three steps. Step 1: Create a dummy hook that always returns exit 1. bash /usr/bin/env bash /tmp/test-hook.sh exit 1 chmod +x /tmp/test-hook.sh Step 2: Register that hook as a Stop hook in settings.json. { "hooks": { "Stop": { "matcher": "", "hooks": { "type": "command", "command": "/tmp/test-hook.sh" } } } } Step 3: Open Claude Code and have it answer something. The Stop hook runs at the moment Claude tries to finish its response. If /tmp/test-hook.sh returns exit 1 , Claude emits a message saying the hook blocked it and loops again. If you see that, the wiring is alive. If you're calling through a JS dispatcher, passing this test is a separate question from whether the actual hook script goes through spawn . To check, look at what Claude is launching with ps aux | grep hook , or trace execve -family system calls with strace / dtruss . On macOS you can check the system calls of child processes a process launches with dtruss -p