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.
Why This Mechanism Works
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.
Scenarios Readers Will Hit
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.
Not "No Failure Occurred," but "The Failure Code Never Arrived"
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."
The Overall Flow
First, let's confirm the path by which Claude Code's Stop/PreToolUse hooks call shell scripts.
The Full Call Chain
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.
The Actual Code of run-with-flags-shell.sh
Here is the actual wrapper script (~/.claude/scripts/hooks/run-with-flags-shell.sh).
#!/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.
What Actually Happens Along the Chain
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 Actual Incident Numbers
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.
What's Happening at the Code Level
Here's a minimal sample of the structure where a hook's JS wrapper calls a shell script using $().
// ❌ $() 経由では 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.
Implementation Details
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.
Why set -euo pipefail Alone Doesn't Save the "Outside"
The top of ~/.claude/scripts/hooks/run-with-flags-shell.sh is as follows.
#!/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 —
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.
Pattern 1: Save $? 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.
Pattern 2: Use 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.
Pattern 3: Rewrite the JS Dispatcher to Call Directly
The most fundamental fix. Replace shell: true + exec with spawn so no shell wrapper is involved.
// ❌ 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.
Verify the Guard Lands With a Minimal 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.
#!/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 <PID>.
Where I Got Stuck
From here I'll write in the order I actually got stuck. Three cases, each as a set of three: symptom, cause, fix. All of them are stories of getting stuck in the state of "I wrote the hook, it's running, the logs are there."
Case 1: 28 Hours of All-Green Logs, Zero Actual Replies
Symptom. On 2026-08-29, I started digging from a single remark: "Aren't DM replies on X (formerly Twitter) slow lately?" Checking the launchd job list, everything was exit 0. The log file had
[2026-08-29 06:15:32] inbox-agent done (exit 0)
[2026-08-29 08:15:17] inbox-agent done (exit 0)
[2026-08-29 10:15:41] inbox-agent done (exit 0)
lined up neatly. Green. But when I checked the actual reply count through X's own interface, it was zero for the entire day.
Cause. inbox-agent/run-medium.sh contained the following line.
node "$SCRIPT" "$@"
echo "[$(date '+%F %T')] $LANE done (exit $?)"
This. node "$SCRIPT" was dying with an error. $? holds node's exit code — right up until just before this line is evaluated. When expanding echo's arguments, bash evaluates left to right. $(date '+%F %T') spawns a subshell, returns a datetime string, and succeeds. At that instant, $? is rewritten to 0. Then $? is read. So no matter what happens, the log says (exit 0).
Fix.
node "$SCRIPT" "$@"
STATUS=$?
echo "[$(date '+%F %T')] $LANE done (exit $STATUS)"
Just add one line to save it into STATUS. After this fix, reproducing the same Chrome launch failure printed (exit 3). launchd received exit 3, and a notification arrived in the Discord alert channel.
What was frightening. All logs from the period that line was in place are invalid. "It was green last week and last month too" is not evidence. It only means "last week and last month, I kept running code that only prints green."
Case 2: The note Paid-Bonus ZIP Attachment Failure Went Unnoticed for 3 Weeks
Symptom. After fixing case 1, I thought "if I've come this far, other scripts might be stepping in the same trap," and scanned 328 shell scripts.
grep -rn 'exit \$?' --include='*.sh' ~/dev ~/.claude/scripts | grep '\$('
Three hits. inbox-agent/run-medium.sh (the main culprit this time), ~/.claude/scripts/dotfiles-snapshot.sh (exit code on commit failure), and note-autolike/scripts/retry-attach-kit.sh.
Checking retry-attach-kit.sh, it was a retry script for attaching the note paid-bonus ZIP to a post. When the attachment API returned 503, the script was written to return exit 1. But because the same trap was in its echo line, launchd recorded it as exit 0.
How long had people who bought the paid note been unable to download the bonus ZIP? Going back through the logs, everything is green, so you can't tell. The only option was to cross-reference the actual date the attachment script last succeeded against the attachment file's update date in note's admin screen. The result: it had been putting out green logs while in a failed state for at least 3 weeks.
Cause and fix. Same pattern.
# ❌ Before
python3 attach_kit.py "$POST_ID" "$ZIP_PATH"
echo "[$(date '+%F %T')] attach done (exit $?)"
# ✅ After
python3 attach_kit.py "$POST_ID" "$ZIP_PATH"
STATUS=$?
echo "[$(date '+%F %T')] attach done (exit $STATUS)"
After the fix, I made it raise an alert to Discord when STATUS ends at 1 or higher. Failures that affect people who bought a paid note aren't something I can afford not to notice until the next morning.
What I took away. If I hadn't immediately generalized the moment I found one instance, this job would still be failing silently today. The same code pattern always exists in multiple places. The correct procedure is not to stop at "one fix" but to scan the whole repository right then.
Case 3: YouTrust Was Failing in "Complete Silence" — A Library's process.exit()
Symptom. On that same August 29, I confirmed that on the X side "alerts appeared in Discord from the third run onward." But on the YouTrust side, not a single notification reached Discord. Six launches, six failures, and zero alerts.
Cause. Line 31 of outreach-multi/src/yt/api.mjs had process.exit(3).
// outreach-multi/src/yt/api.mjs(修正前)
async function fetchMessages(page) {
const resp = await browser.fetch(YT_API_ENDPOINT);
if (!resp.ok) {
console.error(`[yt/api] fetch failed: ${resp.status}`);
process.exit(3); // ← ここ
}
return resp.json();
}
process.exit() is not an exception. Unlike throw, it doesn't walk back up the call stack. The caller's try/catch is never executed once.
The caller's code looked like this.
// エントリポイント(修正前)
try {
const messages = await fetchMessages(page);
await handleMessages(messages);
} catch (err) {
// ここが走ると思っていた
read_failures++;
if (read_failures >= ALERT_THRESHOLD) {
await discord.alert(`YouTrust failure: ${err.message}`);
}
}
The instant process.exit(3) is called, the node process terminates immediately. The catch block is never reached. read_failures is never incremented. Nothing goes to Discord. launchd sees "the process terminated," but because that exit code went down a path where it was recorded nowhere, everything went silent.
Meanwhile, the reason the X side could fire alerts from the third run onward is that the X module was written with throw new Error(...). The catch block ran, read_failures incremented, crossed the threshold, and reached Discord. Same root cause of "Chrome launch failure," same architectural philosophy, but with the wiring differing by one line, one rang and the other was completely silent.
Fix.
// ✅ After:ライブラリ層は throw する。process.exit() しない
async function fetchMessages(page) {
const resp = await browser.fetch(YT_API_ENDPOINT);
if (!resp.ok) {
throw new Error(`yt/api fetch failed: ${resp.status}`);
}
return resp.json();
}
There was an option to branch on an environment variable YT_API_THROW=1, but since there's no reason to write process.exit in the library layer in the first place, I simply rewrote it to throw.
The general rule I kept. The library layer must not hold the authority to terminate. Only the entry point may end the process. The instant a lower layer calls process.exit / sys.exit / os.Exit, all the observation, cleanup, and notification the upper layer prepared gets bypassed. Frameworks and external libraries sometimes do this too, so I've made it a habit to check grep -r 'process\.exit' node_modules/<packagename>/ when adding a new dependency.
The Common Pattern Across All 3
The symptom in every case was "the logs are green, the actual result is zero or failed." But the layer of the cause differs.
| Case | Symptom | Layer of the cause | Fix |
|---|---|---|---|
| inbox-agent | Zero DM replies, all-green logs | The echo line in a shell script |
Save first with STATUS=$?
|
| note-autolike | ZIP attachment failure unnoticed for 3 weeks | Same as above | Same as above + added a Discord alert |
| YouTrust | 6 silent failures out of 6 |
process.exit in a Node.js library layer |
Rewritten to throw
|
The shell trap and the Node.js trap look different, but the root is the same: "written without providing a path for the failure signal to reach the caller." Whether a hook's guard actually lands can only be determined by measuring "does the caller stop when it actually returns exit 1?" — not by green logs.
Pitfalls
The first and middle sections traced 3 incidents, but there are more places where you can step in the same trap. Here I cover the pitfalls I found scanning 328 scripts, plus the patterns I keep getting caught by in Claude Code hook wiring.
(1) Any line where echo "... $?" has $(...) mixed in is out
No matter how short the line, if you write echo "[$(date)] done (exit $?)", that line is structurally incapable of printing anything but (exit 0). The instant date's subshell succeeds, $? is rewritten. The desire to "write it in one line" is right, but this particular combination simply cannot work. Your only options are to take STATUS=$? first, or to drive $? out of the echo.
(2) set -euo pipefail only affects the current shell context
Even with set -euo pipefail at the top of run-with-flags-shell.sh, if JS passes shell: true to child_process.exec() to call this script, a shell wrapper /bin/sh -c "..." is interposed. That /bin/sh session does not inherit pipefail. Calling the script alone with bash run-with-flags-shell.sh works correctly, but calling it from JS doesn't — "the script was written correctly" is the truth, and "the way JS calls it breaks it" is the cause.
(3) The || echo yes fallback swallows checker failures
Line 19 of run-with-flags-shell.sh reads like this.
ENABLED="$(node "${PLUGIN_ROOT}/scripts/hooks/check-hook-enabled.js" "$HOOK_ID" "$PROFILES_CSV" 2>/dev/null || echo yes)"
If check-hook-enabled.js breaks and dies, || echo yes fires and ENABLED becomes "yes". It's an intentional fail-safe design, but the state of "the hook-check script itself is broken, yet the hook proceeds as enabled" happens silently. Whether hooks are being judged correctly is predicated on this checker working properly.
(4) A missing script also passes through with exit 0
Look at lines 25–30 of the same script.
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
If the hook script's path is wrong and the file doesn't exist, it prints an error to stderr and ends with exit 0. Claude doesn't stop. When you've misconfigured something, the logs alone can't distinguish "the hook isn't wired in" from "the hook judged correctly and let it pass."
(5) The instant process.exit() is called in a library layer, every observation path is wiped out
Line 31 of outreach-multi/src/yt/api.mjs was this pattern. The caller's try/catch block never executed once, the read_failures counter was never incremented, and not a single notification reached Discord. In contrast, the X side was written with throw new Error(...), so it rang. Two modules written with the same architectural philosophy split into "rings / completely silent" over a one-line difference.
(6) PIPESTATUS is bash-specific — the name differs in zsh
PIPESTATUS is a bash-only array. In zsh, pipestatus (lowercase) is available as the equivalent array, but with a #!/bin/sh shebang there are shells where it's unusable. run-with-flags-shell.sh's shebang is #!/usr/bin/env bash, so no problem there, but if the hook script side is written in sh, referencing PIPESTATUS will come back empty.
(7) The exit code launchd receives and the script's exit code are different things
The exit code shown in a launchd job log is that of the job's outermost process. If there's a multi-layer call chain and the outermost layer ends with exit 0, launchd records exit 0 no matter how many exit 1s happened inside. "launchd shows exit 0 for everything" is not equal to "everything down to the end of the hook chain was exit 0."
(8) The trap of assuming execSync's default behavior means "an exception means failure"
execSync throws on a non-zero exit code, but when you're using a pipe with shell: true, the shell wrapper can return exit code 0. No exception occurred ≠ success; no exception occurred = the shell returned 0. The inner script's failure isn't transparent through it.
(9) A new npm package may call process.exit()
When an external library calls process.exit() internally, the entry point's try/catch gets bypassed. Without the habit of checking when you add a dependency, it surfaces as the symptom "alerts that used to ring stopped ringing after the addition."
(10) "The logs are printing" is not "the guard is landing"
On the day of the incident, the reason no notification appeared in Discord's #01_alerts was not "no failure occurred." It was "the failure code never reached the path that rings the alert." Logs printing = the script launched; the guard landing = the exit code propagated to the caller. Those are two different facts.
(11) grep can mechanically knock these out, but the || rc=$? form needs human eyes
The detection query grep -rn 'exit \$?' --include='*.sh' ~/dev ~/.claude/scripts | grep '\$(' is effective, but the form x="$(cmd)" || rc=$? is correct usage, so it must be excluded from the hits. Since an assignment statement's exit status becomes that of the command substitution, reading $? on the line after x="$(node ...)" is correct. Only the cases mixing $(...) and $? inside an echo line are the problem. You can't knock everything out automatically — one step of visually inspecting the hits remains.
(12) Even after switching to throw with YT_API_THROW, it's unproven until a failure actually occurs
The YouTrust fix I wrote about in the middle section — rewriting process.exit(3) to throw new Error(...) — has been confirmed statically with node --check and grep, but whether read_failures actually increments and shows up in Discord when Chrome really fails to launch will only be proven the next time a launch failure occurs. "The code is correct" and "the path went through in production" are two different facts.
Best Practices
Here are the lessons from 3 incidents and a 328-script scan, organized as guidance for the next time I build the same kind of environment.
1. Drill the rule of taking STATUS=$? first into your body
If you want to reference $? immediately after a command, always save it on one line with STATUS=$?. Physically forbid $(...) and $? from coexisting inside an echo. Make "writing a shell script = avoiding this combination" a reflex.
2. Write set -euo pipefail at the top of every shell script
When creating a new script, the first two lines are always
#!/usr/bin/env bash
set -euo pipefail
When touching an existing script, check the top first when you open the file. Read scripts that lack this as "ready and prepared to fail silently."
3. Use spawn when calling external scripts from JS
Calls that pass shell: true to child_process.exec() risk the shell wrapper swallowing the exit code. Using child_process.spawn() without shell: true launches directly via execve(2). The close event's code argument is the exit code. When it's null, check the signal argument for signal termination.
4. Don't write process.exit() / sys.exit() / os.Exit() in a library layer
Only the entry point may terminate the process. The instant a library calls process.exit, the catch blocks, notifications, counter increments, and cleanup the entry point prepared are all bypassed. The iron rule is: the library layer throws exceptions and passes them upward.
5. Check for process.exit when adding a new dependency
grep -r 'process\.exit' node_modules/<packagename>/
Make it a habit to run this query before adding. If the library calls process.exit internally, it bypasses the entry point's catch block. Checking at addition time is cheaper than finding out later via the symptom "alerts stopped ringing."
6. Measure whether "the guard lands" with a minimal exit 1 dummy
Once you've written a hook, always measure before trusting the wiring.
#!/usr/bin/env bash
# /tmp/test-hook.sh
exit 1
Register this dummy as a Stop hook in settings.json and have Claude answer something. If a message saying the hook blocked it appears, the wiring is alive. If it doesn't, the exit code is dead somewhere in the call chain. Take this one step before trusting green logs.
7. Generalize the instant you find one
The same code pattern always exists in multiple places. Because I immediately scanned everything the moment I found it in inbox-agent/run-medium.sh, I discovered that the paid-note bonus ZIP attachment failure in note-autolike/scripts/retry-attach-kit.sh had been going on for over 3 weeks. Had I stopped at one fix, buyers might still not be receiving their bonus.
The detection query can be used as-is.
grep -rn 'exit \$?' --include='*.sh' ~/dev ~/.claude/scripts | grep '\$('
Visually inspect the hits and exclude the x="$(cmd)" || rc=$? form; everything else needs fixing.
8. Don't treat "no alert fired" as evidence of health
On the X side, notifications came into Discord's #01_alerts from the third run onward. On the YouTrust side, not one arrived across 6 launch failures. "Discord was quiet" is not "there was no problem" — it includes the possibility that "the failure code never reached the path that rings the alert." When you add an alert, run the actual alert path once in something close to production before saying "monitoring is working."
9. Know the script-not-found path in run-with-flags-shell.sh
Lines 27–30 of the real code print an error to stderr and end with exit 0 when the script file doesn't exist. If you get the hook's configured path wrong, the error goes to stderr but Claude doesn't stop. When a hook feels like it "isn't working," check the stderr log first.
10. Know the || echo yes fallback in check-hook-enabled.js
The fallback on line 19 of run-with-flags-shell.sh is designed to proceed with the hook enabled even when the hook-check script is broken. That's an intentional fail-safe, but the state of "the checker itself is broken" proceeds silently. If a hook isn't being disabled as expected, check whether this checker is working properly.
11. If you use PIPESTATUS, align the shebang and the environment
PIPESTATUS is bash-specific. There are shells where it can't be used in a #!/bin/sh script. If you want to record each pipeline command's exit code individually, set the shebang to #!/usr/bin/env bash and make PIPESTATUS explicit.
printf '%s' "$INPUT" | "$SCRIPT_PATH" "$HOOK_PHASE"
PIPE_STATUS=("${PIPESTATUS[@]}")
# PIPE_STATUS[1] が $SCRIPT_PATH の終了コード
With pipefail, $SCRIPT_PATH's failure automatically propagates into the script's exit code, but if your design records "which command failed" in a log, make PIPESTATUS explicit.
12. Don't treat launchd's all-exit 0 as primary evidence
The exit code launchd records is that of the outermost process. In an environment with multi-layer wrappers, "launchd says exit 0" ≠ "everything inside was exit 0" either. Confirm actual success or failure with measured values from what the job produced — reply counts, presence of attached files, API responses.
13. Distinguish "the code is written correctly" from "the path went through in production"
Even if the fixed code is syntactically correct, the error-case path actually runs "the next time a failure happens in production." Reproduce the failure in a test environment, or deliberately cause a failure and confirm the alert arrives, before saying "fix complete." "The code is correct" is not "verified working."
Summary
The structure of the problem in one line: "The failures were happening. The exit codes just never arrived at the caller."
The single line echo "[$(date)] $LANE done (exit $?)" in inbox-agent/run-medium.sh had no syntax errors as a shell script, printed logs, and looked normal. But the (exit 0) that line was printing wasn't the result of the preceding node — it was the result of $(date) succeeding. The cause of 28 hours of zero outreach DM replies was that one line being "code that only prints green."
The process.exit(3) at outreach-multi/src/yt/api.mjs:31 is the same. It detected the error. But because it terminated the process at the place of detection, neither the counter nor the notification the entry point had prepared ever ran. From the same root cause of a Chrome launch failure, the X side rang Discord from the third run onward, and the YouTrust side was completely silent across all 6. The difference is a single line of wiring.
When measuring the "health" of an automation environment, the number of green log lines is not trustworthy. The only trustworthy thing is the measurement: "when it actually returns exit 1, does that signal reach the calling Claude Code and cause a block?" In an environment where 171 launchd jobs and multiple Claude Code sessions run simultaneously, a hook that hasn't had this measurement done is the same as a cliff with no guardrail.
It took several hours to scan 328 scripts, find 3 instances of the same trap, and commit fixes to 3 repositories. But the fact that "buyers of the paid note hadn't been able to download the bonus ZIP for over 3 weeks" only came to light because I scanned. Had I stopped at one, the green would still be lining up today.
An exit code doesn't arrive unless every layer of the propagation path is written correctly. The shell $? problem, the JS shell: true problem, the library-layer process.exit problem — all of them share the same root. Once you write a hook, verify with a minimal exit 1 dummy that it actually lands. That alone crushes most silent bugs like this one in advance.
I've written up the full picture of the system, the breakdown of ¥1.2M/month, and the 30-day procedure in a paid note.
📕 Claude Code自律環境で、実際どう稼ぐか ― 仕組み・実例・始め方・サポート
Written by **Lily* — I ship iOS apps and automate my content stack with Claude Code.
Follow along: Portfolio · X · GitHub*
Top comments (0)