{"slug": "28-hours-of-green-logs-zero-replies-how-a-single-echo-line-swallowed-every-exit", "title": "28 Hours of Green Logs, Zero Replies: How a Single `echo` Line Swallowed Every Exit Code", "summary": "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.", "body_md": "For 24 hours, every dashboard was green. Every launchd job reported `exit 0`\n\n, and the logs lined up neatly with `(exit 0)`\n\non 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.\n\nBack 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.\n\nClaude 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`\n\n, Claude blocks that tool call or Stop action — by specification.\n\nWhen 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.\n\nSpeaking 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.\n\nIf you use Claude Code, there's a good chance you'll get caught by one of these.\n\n**Pattern A: You wrote your hook as a shell script.** The script returns `exit 1`\n\n, but Claude doesn't stop. Check the logs and the exit code reads `0`\n\n— even though you're sure you wrote the script correctly.\n\n**Pattern B: You call your hook through a JS wrapper.** The JS wrapper uses `child_process.exec()`\n\nor `$()`\n\ncommand substitution to call the inner shell script. The inner script returns `exit 1`\n\n, but the outer JS process receives `0`\n\n.\n\n**Pattern C: You call the script inside a pipeline.** You pass stdin through a pipe, like `cat input.json | ./hook-script.sh`\n\n. Without `set -o pipefail`\n\n, only the exit code of the right-hand side of the pipe reaches the caller.\n\nIn 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.\n\nOn the day of the incident, every line of the DM system's log read `(exit 0)`\n\n. But the problem was in that log line. This is the code that was actually running:\n\n```\nnode \"$SCRIPT\" \"$@\"\necho \"[$(date '+%F %T')] $LANE done (exit $?)\"\n```\n\n`echo`\n\n's arguments are evaluated left to right. `$(date '+%F %T')`\n\nspawns a subshell — and **succeeds** — and returns. At that instant, `$?`\n\nis overwritten with `0`\n\n. `$?`\n\nis read after that. In other words, whether the preceding `node`\n\ndied with `exit 3`\n\nor `exit 4`\n\n, this line is syntactically incapable of printing anything but `(exit 0)`\n\n.\n\nAs evidence, I confirmed via a reproduction test that before the fix `exit 4`\n\ndisplayed as `0`\n\n, and after the fix it displayed as `4`\n\n.\n\n**The frightening part is that it's retroactive.** As long as that line is in there, not a single `(exit 0)`\n\nin past logs counts as evidence. \"It was green last week and last month too\" just means you kept running code that prints green.\n\nScanning the 328 shell scripts under `~/dev`\n\nand `~/.claude/scripts`\n\nturned 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.\"\n\nFirst, let's confirm the path by which Claude Code's Stop/PreToolUse hooks call shell scripts.\n\n```\nClaude Code（本体プロセス）\n    │\n    │  hook event (JSON payload を stdin に渡す)\n    ▼\nJS hook dispatcher（settings.json で指定）\n    │\n    │  child_process.spawn() または exec()\n    ▼\n~/.claude/scripts/hooks/run-with-flags-shell.sh\n    │\n    │  stdin → パイプ経由で渡す\n    │  HOOK_ID / REL_SCRIPT_PATH / PROFILES_CSV を引数で受け取る\n    ▼\ncheck-hook-enabled.js（このhookが有効か確認）\n    │\n    │  enabled ならば\n    ▼\n$SCRIPT_PATH（実際のフックロジック）\n    │\n    │  exit 0 / exit 1\n    ▼\nrun-with-flags-shell.sh（終了コードを返す）\n    │\n    ▼\nJS dispatcher（終了コードを受け取る → Claude本体へ）\n    │\n    ▼\nClaude Code（exit 1 なら動作をブロック）\n```\n\nThe point of this diagram is whether each arrow passes the exit code correctly. If even one link breaks, the terminal `exit 1`\n\nnever reaches Claude.\n\n`run-with-flags-shell.sh`\n\nHere is the actual wrapper script (`~/.claude/scripts/hooks/run-with-flags-shell.sh`\n\n).\n\n``` bash\n#!/usr/bin/env bash\nset -euo pipefail\n\nHOOK_ID=\"${1:-}\"\nREL_SCRIPT_PATH=\"${2:-}\"\nPROFILES_CSV=\"${3:-standard,strict}\"\nSCRIPT_DIR=\"$(cd \"$(dirname \"${BASH_SOURCE[0]}\")\" && pwd)\"\nPLUGIN_ROOT=\"${CLAUDE_PLUGIN_ROOT:-$(cd \"${SCRIPT_DIR}/../..\" && pwd)}\"\n\n# Preserve stdin for passthrough or script execution\nINPUT=\"$(cat)\"\n\nif [[ -z \"$HOOK_ID\" || -z \"$REL_SCRIPT_PATH\" ]]; then\n  printf '%s' \"$INPUT\"\n  exit 0\nfi\n\n# Ask Node helper if this hook is enabled\nENABLED=\"$(node \"${PLUGIN_ROOT}/scripts/hooks/check-hook-enabled.js\" \"$HOOK_ID\" \"$PROFILES_CSV\" 2>/dev/null || echo yes)\"\nif [[ \"$ENABLED\" != \"yes\" ]]; then\n  printf '%s' \"$INPUT\"\n  exit 0\nfi\n\nSCRIPT_PATH=\"${PLUGIN_ROOT}/${REL_SCRIPT_PATH}\"\nif [[ ! -f \"$SCRIPT_PATH\" ]]; then\n  echo \"[Hook] Script not found for ${HOOK_ID}: ${SCRIPT_PATH}\" >&2\n  printf '%s' \"$INPUT\"\n  exit 0\nfi\n\n# Extract phase prefix from hook ID (e.g., \"pre:observe\" -> \"pre\", \"post:observe\" -> \"post\")\nHOOK_PHASE=\"${HOOK_ID%%:*}\"\n\nprintf '%s' \"$INPUT\" | \"$SCRIPT_PATH\" \"$HOOK_PHASE\"\n```\n\n`set -euo pipefail`\n\nis 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\"`\n\n, uses a pipe, but thanks to `pipefail`\n\n, if the right-hand `$SCRIPT_PATH`\n\nreturns `exit 1`\n\n, `run-with-flags-shell.sh`\n\nitself also ends with `exit 1`\n\n— **within this script alone**.\n\nThe problem is **outside** this script.\n\n**Point of interest ①: the check-hook-enabled.js call on line 19**\n\n```\nENABLED=\"$(node \"${PLUGIN_ROOT}/scripts/hooks/check-hook-enabled.js\" \"$HOOK_ID\" \"$PROFILES_CSV\" 2>/dev/null || echo yes)\"\n```\n\nIf the `node`\n\ninside `$()`\n\ndies from some error, `|| echo yes`\n\nfires and `ENABLED`\n\nbecomes `\"yes\"`\n\n. 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.\n\n**Point of interest ②: the pipe on the last line**\n\n```\nprintf '%s' \"$INPUT\" | \"$SCRIPT_PATH\" \"$HOOK_PHASE\"\n```\n\nBecause `pipefail`\n\nis on, `$SCRIPT_PATH`\n\n's `exit 1`\n\ndoes propagate properly into this script's exit code. But **depending on how the JS dispatcher calls this script**, whether that code reaches Claude changes.\n\nIf the JS uses `child_process.exec()`\n\n, it determines success or failure by whether the callback's first argument (`error`\n\n) is null. Because `exec`\n\ninterposes a shell internally, the shell's exit code arrives as `error.code`\n\n— **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**.\n\nIf it uses `child_process.spawn()`\n\n, you can get the exit code from the `code`\n\nargument of the `close`\n\nevent. That's mostly accurate, but if the spawned process ends via `SIGTERM`\n\nor `SIGKILL`\n\n, `code`\n\nbecomes `null`\n\n.\n\nAnd one more: if JS calls it via command substitution like `$(run-with-flags-shell.sh ...)`\n\n— as explained earlier — `$?`\n\nis reliably destroyed.\n\nThe following was confirmed for the incident on 2026-08-29.\n\n| Channel | Launches | Actual replies | Alerts fired |\n|---|---|---|---|\n| X (formerly Twitter) | 6 (all ABORT) | 0 |\nFired from the 3rd onward (14:33 / 16:15 / 18:15 / 20:15) |\n| YouTrust | 6 (all failed to launch) | 0 | Not a single one |\n\nOn 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`\n\n. On the YouTrust side, a `process.exit(3)`\n\nin the library layer bypassed the caller's `catch`\n\nblock, so the `read_failures`\n\ncounter was never incremented and not a single notification reached Discord.\n\n**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.\n\nHere's a minimal sample of the structure where a hook's JS wrapper calls a shell script using `$()`\n\n.\n\n``` js\n// ❌ $() 経由では exit code が潰れる\nconst { execSync } = require('child_process');\n\nfunction runHook(scriptPath, input) {\n  try {\n    // execSync はデフォルトで throws on non-zero exit\n    // しかし内部で $() を重ねると話が変わる\n    const result = execSync(`echo '${input}' | ${scriptPath}`, {\n      encoding: 'utf8',\n      shell: true,   // ← ここが問題の温床になりやすい\n    });\n    return { success: true, output: result };\n  } catch (e) {\n    // e.status が null になるケースがある\n    return { success: false, code: e.status };\n  }\n}\n```\n\nThe `shell: true`\n\noption passes the command string to `/bin/sh -c \"...\"`\n\n. Whether that shell wrapper propagates `exit 1`\n\nas 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`\n\nwith `shell: true`\n\n, `pipefail`\n\nis not inherited into that shell session, so a failure on the left-hand side gets swallowed.\n\nMeanwhile, the pipe that `run-with-flags-shell.sh`\n\nitself uses on its last line —\n\n```\nprintf '%s' \"$INPUT\" | \"$SCRIPT_PATH\" \"$HOOK_PHASE\"\n```\n\n— is under the control of the `set -euo pipefail`\n\nat the top of the script, so `$SCRIPT_PATH`\n\n's `exit 1`\n\ncorrectly surfaces as the script's exit code. This script on its own is correct. The problem is in the calling layer above it.\n\nAll 3 traps found in the 328-script scan were the same pattern: \"an `echo`\n\nline inside the script mixing `$(date)`\n\nand `$?`\n\n.\" The detection query can be used as-is.\n\n```\ngrep -rn 'exit \\$?' --include='*.sh' ~/dev ~/.claude/scripts | grep '\\$('\n```\n\nThis query picks up lines containing `exit $?`\n\nwhere `$(`\n\nalso appears on the same line. Assignment patterns like `|| rc=$?`\n\n(`x=\"$(cmd)\" || rc=$?`\n\n) are correct usage, so that distinction alone can't be made mechanically — it needs a human eye.\n\nThe 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.\n\n`set -euo pipefail`\n\nAlone Doesn't Save the \"Outside\"\nThe top of `~/.claude/scripts/hooks/run-with-flags-shell.sh`\n\nis as follows.\n\n``` bash\n#!/usr/bin/env bash\nset -euo pipefail\n```\n\nThis line guarantees that \"`pipefail`\n\nis enabled within this script's execution context.\" Indeed, the final line\n\n```\nprintf '%s' \"$INPUT\" | \"$SCRIPT_PATH\" \"$HOOK_PHASE\"\n```\n\nis under `pipefail`\n\n's control, so if `$SCRIPT_PATH`\n\nreturns `exit 1`\n\n, `run-with-flags-shell.sh`\n\nitself also ends with `exit 1`\n\n. That part is correct.\n\nThe problem is \"how the JS dispatcher launches this shell script.\" If the JS passes `shell: true`\n\nto `child_process.exec()`\n\n, the command string is internally wrapped in `/bin/sh -c \"...\"`\n\n. That `/bin/sh`\n\nsession does not inherit `pipefail`\n\n. From JS's point of view, the process tree isn't \"`/bin/sh`\n\n→ `run-with-flags-shell.sh`\n\n\" but \"shell wrapper → `run-with-flags-shell.sh`\n\nas its child process.\" The shell wrapper's own exit code is normally `0`\n\n.\n\nAnd another: when JS extracts a result string via `execSync`\n\nin a command-substitution-like way —\n\n``` js\nconst out = execSync(`cat payload.json | ${hookScript}`, { shell: true });\n```\n\n— as long as the left-hand `cat payload.json`\n\nsucceeds, whatever the right-hand `hookScript`\n\nreturns has no effect on `execSync`\n\n's error determination. The combination of `shell: true`\n\n+ a pipe + no `pipefail`\n\nquietly discards the right-hand side's exit code.\n\n`$?`\n\nBefore Any `$(...)`\n\nThe lightest fix. It applies broadly to \"cases where a log line mixes `$(date)`\n\nand `$?`\n\n.\"\n\n```\n# ❌ Before：$? が $(date) で上書きされる\nnode \"$SCRIPT\" \"$@\"\necho \"[$(date '+%F %T')] $LANE done (exit $?)\"\n\n# ✅ After：STATUS に退避してから $(date) を展開する\nnode \"$SCRIPT\" \"$@\"\nSTATUS=$?\necho \"[$(date '+%F %T')] $LANE done (exit $STATUS)\"\n```\n\n`STATUS=$?`\n\nis just a variable assignment, so it doesn't spawn a subshell. `$?`\n\nis secured before the next line's `$(date)`\n\ndestroys it. That alone eliminates \"the divergence between the code the log prints and the actual exit code.\"\n\nThere is a caveat, though. The assignment form `x=\"$(cmd)\"`\n\nhas **the exit status of the whole assignment become that of $(cmd)**, so this is correct usage.\n\n```\n# ✅ これは正しい。x の代入ステータスは node のステータスと同じ\nx=\"$(node \"$SCRIPT\" \"$@\")\"\nSTATUS=$?\n```\n\nOn the other hand, `$?`\n\non an `echo`\n\nline that contains `$()`\n\nwill reliably be zero. The difference between the two forms can't be distinguished mechanically with `grep`\n\n— after a regex hit, one step of visual inspection is required.\n\nDetection query (identical to the one shown in the first half):\n\n```\ngrep -rn 'exit \\$?' --include='*.sh' ~/dev ~/.claude/scripts | grep '\\$('\n```\n\nAmong the lines this query returns, exclude the `x=\"$(...)\"`\n\nform. Everything else needs fixing.\n\n`PIPESTATUS`\n\nto Capture Every Command's Code\nUse this when you can't rewrite the pipe. `PIPESTATUS`\n\nis a bash-specific array that holds the exit codes of each command in the preceding pipeline, in left-to-right order.\n\n```\nprintf '%s' \"$INPUT\" | \"$SCRIPT_PATH\" \"$HOOK_PHASE\"\nPIPE_STATUS=(\"${PIPESTATUS[@]}\")\n\n# PIPE_STATUS[0] = printf の終了コード\n# PIPE_STATUS[1] = $SCRIPT_PATH の終了コード\n\nif [[ \"${PIPE_STATUS[1]}\" -ne 0 ]]; then\n  exit \"${PIPE_STATUS[1]}\"\nfi\n```\n\nSince `run-with-flags-shell.sh`\n\nhas `set -euo pipefail`\n\nat the top, there's currently no need to add this — with `pipefail`\n\n, a failure on the right-hand side takes the script itself down. You'd make `PIPESTATUS`\n\nexplicit when \"there's a reason you can't turn on `pipefail`\n\n\" 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]`\n\ndirectly into the log makes later tracing easier.\n\nThe most fundamental fix. Replace `shell: true`\n\n+ `exec`\n\nwith `spawn`\n\nso no shell wrapper is involved.\n\n``` js\n// ❌ Before：shell: true のため $() 内の pipefail が死ぬ\nconst { execSync } = require('child_process');\nconst out = execSync(`echo '${payload}' | ${hookScript}`, { shell: true });\n\n// ✅ After：spawn で直接呼ぶ。終了コードは close イベントの code で取れる\nconst { spawn } = require('child_process');\n\nfunction runHook(hookScript, payload) {\n  return new Promise((resolve, reject) => {\n    const child = spawn(hookScript, [], { stdio: ['pipe', 'pipe', 'inherit'] });\n    child.stdin.write(payload);\n    child.stdin.end();\n\n    child.on('close', (code) => {\n      if (code !== 0) {\n        reject(new Error(`hook exited with ${code}`));\n      } else {\n        resolve();\n      }\n    });\n  });\n}\n```\n\nUsed without `shell: true`\n\n, `spawn`\n\nlaunches the command directly via `execve(2)`\n\n. Since no shell wrapper is interposed, the `set -euo pipefail`\n\ninside `run-with-flags-shell.sh`\n\nstays in effect. The `close`\n\nevent's `code`\n\nargument is either `null`\n\n(terminated by signal) or an integer (normal termination). When it's `null`\n\n, check the `signal`\n\nargument to see what happened.\n\n`exit 1`\n\nRepro\nYou can't stop at \"I fixed it.\" Verify with a minimal reproduction case that Claude's behavior actually gets blocked. Three steps.\n\n**Step 1: Create a dummy hook that always returns exit 1.**\n\n``` bash\n#!/usr/bin/env bash\n# /tmp/test-hook.sh\nexit 1\nchmod +x /tmp/test-hook.sh\n```\n\n**Step 2: Register that hook as a Stop hook in settings.json.**\n\n```\n{\n  \"hooks\": {\n    \"Stop\": [\n      {\n        \"matcher\": \"\",\n        \"hooks\": [\n          {\n            \"type\": \"command\",\n            \"command\": \"/tmp/test-hook.sh\"\n          }\n        ]\n      }\n    ]\n  }\n}\n```\n\n**Step 3: Open Claude Code and have it answer something.**\n\nThe Stop hook runs at the moment Claude tries to finish its response. If `/tmp/test-hook.sh`\n\nreturns `exit 1`\n\n, Claude emits a message saying the hook blocked it and loops again. If you see that, the wiring is alive.\n\nIf you're calling through a JS dispatcher, passing this test is a separate question from whether the actual hook script goes through `spawn`\n\n. To check, look at what Claude is launching with `ps aux | grep hook`\n\n, or trace `execve`\n\n-family system calls with `strace`\n\n/ `dtruss`\n\n. On macOS you can check the system calls of child processes a process launches with `dtruss -p <PID>`\n\n.\n\nFrom 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.\"\n\n**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`\n\n. The log file had\n\n```\n[2026-08-29 06:15:32] inbox-agent done (exit 0)\n[2026-08-29 08:15:17] inbox-agent done (exit 0)\n[2026-08-29 10:15:41] inbox-agent done (exit 0)\n```\n\nlined up neatly. Green. But when I checked the actual reply count through X's own interface, it was zero for the entire day.\n\n**Cause.** `inbox-agent/run-medium.sh`\n\ncontained the following line.\n\n```\nnode \"$SCRIPT\" \"$@\"\necho \"[$(date '+%F %T')] $LANE done (exit $?)\"\n```\n\nThis. `node \"$SCRIPT\"`\n\nwas dying with an error. `$?`\n\nholds `node`\n\n's exit code — right up until **just before** this line is evaluated. When expanding `echo`\n\n's arguments, bash evaluates left to right. `$(date '+%F %T')`\n\nspawns a subshell, returns a datetime string, and succeeds. At that instant, `$?`\n\nis rewritten to `0`\n\n. Then `$?`\n\nis read. So no matter what happens, the log says `(exit 0)`\n\n.\n\n**Fix.**\n\n```\nnode \"$SCRIPT\" \"$@\"\nSTATUS=$?\necho \"[$(date '+%F %T')] $LANE done (exit $STATUS)\"\n```\n\nJust add one line to save it into `STATUS`\n\n. After this fix, reproducing the same Chrome launch failure printed `(exit 3)`\n\n. launchd received `exit 3`\n\n, and a notification arrived in the Discord alert channel.\n\n**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.\"\n\n**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.\n\n```\ngrep -rn 'exit \\$?' --include='*.sh' ~/dev ~/.claude/scripts | grep '\\$('\n```\n\nThree hits. `inbox-agent/run-medium.sh`\n\n(the main culprit this time), `~/.claude/scripts/dotfiles-snapshot.sh`\n\n(exit code on commit failure), and `note-autolike/scripts/retry-attach-kit.sh`\n\n.\n\nChecking `retry-attach-kit.sh`\n\n, 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`\n\n. But because the same trap was in its `echo`\n\nline, launchd recorded it as `exit 0`\n\n.\n\nHow 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.\n\n**Cause and fix.** Same pattern.\n\n```\n# ❌ Before\npython3 attach_kit.py \"$POST_ID\" \"$ZIP_PATH\"\necho \"[$(date '+%F %T')] attach done (exit $?)\"\n\n# ✅ After\npython3 attach_kit.py \"$POST_ID\" \"$ZIP_PATH\"\nSTATUS=$?\necho \"[$(date '+%F %T')] attach done (exit $STATUS)\"\n```\n\nAfter the fix, I made it raise an alert to Discord when `STATUS`\n\nends at `1`\n\nor higher. Failures that affect people who bought a paid note aren't something I can afford not to notice until the next morning.\n\n**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.\n\n`process.exit()`\n\n**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.\n\n**Cause.** Line 31 of `outreach-multi/src/yt/api.mjs`\n\nhad `process.exit(3)`\n\n.\n\n```\n// outreach-multi/src/yt/api.mjs（修正前）\nasync function fetchMessages(page) {\n  const resp = await browser.fetch(YT_API_ENDPOINT);\n  if (!resp.ok) {\n    console.error(`[yt/api] fetch failed: ${resp.status}`);\n    process.exit(3);  // ← ここ\n  }\n  return resp.json();\n}\n```\n\n`process.exit()`\n\nis not an exception. Unlike `throw`\n\n, it doesn't walk back up the call stack. The caller's `try/catch`\n\nis never executed once.\n\nThe caller's code looked like this.\n\n``` js\n// エントリポイント（修正前）\ntry {\n  const messages = await fetchMessages(page);\n  await handleMessages(messages);\n} catch (err) {\n  // ここが走ると思っていた\n  read_failures++;\n  if (read_failures >= ALERT_THRESHOLD) {\n    await discord.alert(`YouTrust failure: ${err.message}`);\n  }\n}\n```\n\nThe instant `process.exit(3)`\n\nis called, the node process terminates immediately. The `catch`\n\nblock is never reached. `read_failures`\n\nis 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.\n\nMeanwhile, the reason the X side could fire alerts from the third run onward is that the X module was written with `throw new Error(...)`\n\n. The `catch`\n\nblock ran, `read_failures`\n\nincremented, 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.\n\n**Fix.**\n\n```\n// ✅ After：ライブラリ層は throw する。process.exit() しない\nasync function fetchMessages(page) {\n  const resp = await browser.fetch(YT_API_ENDPOINT);\n  if (!resp.ok) {\n    throw new Error(`yt/api fetch failed: ${resp.status}`);\n  }\n  return resp.json();\n}\n```\n\nThere was an option to branch on an environment variable `YT_API_THROW=1`\n\n, but since there's no reason to write `process.exit`\n\nin the library layer in the first place, I simply rewrote it to `throw`\n\n.\n\n**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`\n\n/ `sys.exit`\n\n/ `os.Exit`\n\n, 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>/`\n\nwhen adding a new dependency.\n\nThe symptom in every case was \"the logs are green, the actual result is zero or failed.\" But the layer of the cause differs.\n\n| Case | Symptom | Layer of the cause | Fix |\n|---|---|---|---|\n| inbox-agent | Zero DM replies, all-green logs | The `echo` line in a shell script |\nSave first with `STATUS=$?`\n|\n| note-autolike | ZIP attachment failure unnoticed for 3 weeks | Same as above | Same as above + added a Discord alert |\n| YouTrust | 6 silent failures out of 6 |\n`process.exit` in a Node.js library layer |\nRewritten to `throw`\n|\n\nThe 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`\n\n?\" — not by green logs.\n\nThe 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.\n\n**(1) Any line where echo \"... $?\" has $(...) mixed in is out**\n\nNo matter how short the line, if you write `echo \"[$(date)] done (exit $?)\"`\n\n, that line is structurally incapable of printing anything but `(exit 0)`\n\n. The instant `date`\n\n's subshell succeeds, `$?`\n\nis 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=$?`\n\nfirst, or to drive `$?`\n\nout of the `echo`\n\n.\n\n**(2) set -euo pipefail only affects the current shell context**\n\nEven with `set -euo pipefail`\n\nat the top of `run-with-flags-shell.sh`\n\n, if JS passes `shell: true`\n\nto `child_process.exec()`\n\nto call this script, a shell wrapper `/bin/sh -c \"...\"`\n\nis interposed. That `/bin/sh`\n\nsession does not inherit `pipefail`\n\n. Calling the script alone with `bash run-with-flags-shell.sh`\n\nworks 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.\n\n**(3) The || echo yes fallback swallows checker failures**\n\nLine 19 of `run-with-flags-shell.sh`\n\nreads like this.\n\n```\nENABLED=\"$(node \"${PLUGIN_ROOT}/scripts/hooks/check-hook-enabled.js\" \"$HOOK_ID\" \"$PROFILES_CSV\" 2>/dev/null || echo yes)\"\n```\n\nIf `check-hook-enabled.js`\n\nbreaks and dies, `|| echo yes`\n\nfires and `ENABLED`\n\nbecomes `\"yes\"`\n\n. 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.\n\n**(4) A missing script also passes through with exit 0**\n\nLook at lines 25–30 of the same script.\n\n```\nSCRIPT_PATH=\"${PLUGIN_ROOT}/${REL_SCRIPT_PATH}\"\nif [[ ! -f \"$SCRIPT_PATH\" ]]; then\n  echo \"[Hook] Script not found for ${HOOK_ID}: ${SCRIPT_PATH}\" >&2\n  printf '%s' \"$INPUT\"\n  exit 0\nfi\n```\n\nIf the hook script's path is wrong and the file doesn't exist, it prints an error to `stderr`\n\nand ends with `exit 0`\n\n. 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.\"\n\n**(5) The instant process.exit() is called in a library layer, every observation path is wiped out**\n\nLine 31 of `outreach-multi/src/yt/api.mjs`\n\nwas this pattern. The caller's `try/catch`\n\nblock never executed once, the `read_failures`\n\ncounter was never incremented, and not a single notification reached Discord. In contrast, the X side was written with `throw new Error(...)`\n\n, so it rang. Two modules written with the same architectural philosophy split into \"rings / completely silent\" over a one-line difference.\n\n**(6) PIPESTATUS is bash-specific — the name differs in zsh**\n\n`PIPESTATUS`\n\nis a bash-only array. In zsh, `pipestatus`\n\n(lowercase) is available as the equivalent array, but with a `#!/bin/sh`\n\nshebang there are shells where it's unusable. `run-with-flags-shell.sh`\n\n's shebang is `#!/usr/bin/env bash`\n\n, so no problem there, but if the hook script side is written in `sh`\n\n, referencing `PIPESTATUS`\n\nwill come back empty.\n\n**(7) The exit code launchd receives and the script's exit code are different things**\n\nThe 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`\n\n, launchd records `exit 0`\n\nno matter how many `exit 1`\n\ns happened inside. \"launchd shows `exit 0`\n\nfor everything\" is not equal to \"everything down to the end of the hook chain was `exit 0`\n\n.\"\n\n**(8) The trap of assuming execSync's default behavior means \"an exception means failure\"**\n\n`execSync`\n\nthrows on a non-zero exit code, but when you're using a pipe with `shell: true`\n\n, the shell wrapper can return exit code `0`\n\n. No exception occurred ≠ success; no exception occurred = the shell returned `0`\n\n. The inner script's failure isn't transparent through it.\n\n**(9) A new npm package may call process.exit()**\n\nWhen an external library calls `process.exit()`\n\ninternally, the entry point's `try/catch`\n\ngets 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.\"\n\n**(10) \"The logs are printing\" is not \"the guard is landing\"**\n\nOn the day of the incident, the reason no notification appeared in Discord's `#01_alerts`\n\nwas 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.\n\n**(11) grep can mechanically knock these out, but the || rc=$? form needs human eyes**\n\nThe detection query `grep -rn 'exit \\$?' --include='*.sh' ~/dev ~/.claude/scripts | grep '\\$('`\n\nis effective, but the form `x=\"$(cmd)\" || rc=$?`\n\nis correct usage, so it must be excluded from the hits. Since an assignment statement's exit status becomes that of the command substitution, reading `$?`\n\non the line after `x=\"$(node ...)\"`\n\nis correct. Only the cases mixing `$(...)`\n\nand `$?`\n\ninside an `echo`\n\nline are the problem. You can't knock everything out automatically — one step of visually inspecting the hits remains.\n\n**(12) Even after switching to throw with YT_API_THROW, it's unproven until a failure actually occurs**\n\nThe YouTrust fix I wrote about in the middle section — rewriting `process.exit(3)`\n\nto `throw new Error(...)`\n\n— has been confirmed statically with `node --check`\n\nand `grep`\n\n, but whether `read_failures`\n\nactually 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.\n\nHere 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.\n\n**1. Drill the rule of taking STATUS=$? first into your body**\n\nIf you want to reference `$?`\n\nimmediately after a command, always save it on one line with `STATUS=$?`\n\n. Physically forbid `$(...)`\n\nand `$?`\n\nfrom coexisting inside an `echo`\n\n. Make \"writing a shell script = avoiding this combination\" a reflex.\n\n**2. Write set -euo pipefail at the top of every shell script**\n\nWhen creating a new script, the first two lines are always\n\n``` bash\n#!/usr/bin/env bash\nset -euo pipefail\n```\n\nWhen 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.\"\n\n**3. Use spawn when calling external scripts from JS**\n\nCalls that pass `shell: true`\n\nto `child_process.exec()`\n\nrisk the shell wrapper swallowing the exit code. Using `child_process.spawn()`\n\nwithout `shell: true`\n\nlaunches directly via `execve(2)`\n\n. The `close`\n\nevent's `code`\n\nargument is the exit code. When it's `null`\n\n, check the `signal`\n\nargument for signal termination.\n\n**4. Don't write process.exit() / sys.exit() / os.Exit() in a library layer**\n\nOnly the entry point may terminate the process. The instant a library calls `process.exit`\n\n, the `catch`\n\nblocks, notifications, counter increments, and cleanup the entry point prepared are all bypassed. The iron rule is: the library layer `throw`\n\ns exceptions and passes them upward.\n\n**5. Check for process.exit when adding a new dependency**\n\n```\ngrep -r 'process\\.exit' node_modules/<packagename>/\n```\n\nMake it a habit to run this query before adding. If the library calls `process.exit`\n\ninternally, it bypasses the entry point's `catch`\n\nblock. Checking at addition time is cheaper than finding out later via the symptom \"alerts stopped ringing.\"\n\n**6. Measure whether \"the guard lands\" with a minimal exit 1 dummy**\n\nOnce you've written a hook, always measure before trusting the wiring.\n\n``` bash\n#!/usr/bin/env bash\n# /tmp/test-hook.sh\nexit 1\n```\n\nRegister this dummy as a Stop hook in `settings.json`\n\nand 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.\n\n**7. Generalize the instant you find one**\n\nThe same code pattern always exists in multiple places. Because I immediately scanned everything the moment I found it in `inbox-agent/run-medium.sh`\n\n, I discovered that the paid-note bonus ZIP attachment failure in `note-autolike/scripts/retry-attach-kit.sh`\n\nhad been going on for over 3 weeks. Had I stopped at one fix, buyers might still not be receiving their bonus.\n\nThe detection query can be used as-is.\n\n```\ngrep -rn 'exit \\$?' --include='*.sh' ~/dev ~/.claude/scripts | grep '\\$('\n```\n\nVisually inspect the hits and exclude the `x=\"$(cmd)\" || rc=$?`\n\nform; everything else needs fixing.\n\n**8. Don't treat \"no alert fired\" as evidence of health**\n\nOn the X side, notifications came into Discord's `#01_alerts`\n\nfrom 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.\"\n\n**9. Know the script-not-found path in run-with-flags-shell.sh**\n\nLines 27–30 of the real code print an error to `stderr`\n\nand end with `exit 0`\n\nwhen 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`\n\nlog first.\n\n**10. Know the || echo yes fallback in check-hook-enabled.js**\n\nThe fallback on line 19 of `run-with-flags-shell.sh`\n\nis 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.\n\n**11. If you use PIPESTATUS, align the shebang and the environment**\n\n`PIPESTATUS`\n\nis bash-specific. There are shells where it can't be used in a `#!/bin/sh`\n\nscript. If you want to record each pipeline command's exit code individually, set the shebang to `#!/usr/bin/env bash`\n\nand make `PIPESTATUS`\n\nexplicit.\n\n```\nprintf '%s' \"$INPUT\" | \"$SCRIPT_PATH\" \"$HOOK_PHASE\"\nPIPE_STATUS=(\"${PIPESTATUS[@]}\")\n# PIPE_STATUS[1] が $SCRIPT_PATH の終了コード\n```\n\nWith `pipefail`\n\n, `$SCRIPT_PATH`\n\n's failure automatically propagates into the script's exit code, but if your design records \"which command failed\" in a log, make `PIPESTATUS`\n\nexplicit.\n\n**12. Don't treat launchd's all- exit 0 as primary evidence**\n\nThe exit code launchd records is that of the outermost process. In an environment with multi-layer wrappers, \"launchd says `exit 0`\n\n\" ≠ \"everything inside was `exit 0`\n\n\" either. Confirm actual success or failure with measured values from what the job produced — reply counts, presence of attached files, API responses.\n\n**13. Distinguish \"the code is written correctly\" from \"the path went through in production\"**\n\nEven 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.\"\n\nThe structure of the problem in one line: **\"The failures were happening. The exit codes just never arrived at the caller.\"**\n\nThe single line `echo \"[$(date)] $LANE done (exit $?)\"`\n\nin `inbox-agent/run-medium.sh`\n\nhad no syntax errors as a shell script, printed logs, and looked normal. But the `(exit 0)`\n\nthat line was printing wasn't the result of the preceding `node`\n\n— it was the result of `$(date)`\n\nsucceeding. The cause of 28 hours of zero outreach DM replies was that one line being \"code that only prints green.\"\n\nThe `process.exit(3)`\n\nat `outreach-multi/src/yt/api.mjs:31`\n\nis 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.\n\nWhen 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`\n\n, 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.\n\nIt 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.\n\nAn exit code doesn't arrive unless every layer of the propagation path is written correctly. The shell `$?`\n\nproblem, the JS `shell: true`\n\nproblem, the library-layer `process.exit`\n\nproblem — all of them share the same root. Once you write a hook, verify with a minimal `exit 1`\n\ndummy that it actually lands. That alone crushes most silent bugs like this one in advance.\n\nI've written up the full picture of the system, the breakdown of ¥1.2M/month, and the 30-day procedure in a paid note.\n\n📕 [Claude Code自律環境で、実際どう稼ぐか ― 仕組み・実例・始め方・サポート](https://note.com/bokuwalily/n/n849b3a07784a)\n\n*Written by **Lily** — I ship iOS apps and automate my content stack with Claude Code.\n\nFollow along: [Portfolio](https://bokuwalily.com) · [X](https://x.com/bokuwalily) · [GitHub](https://github.com/bokuwalily)*", "url": "https://wpnews.pro/news/28-hours-of-green-logs-zero-replies-how-a-single-echo-line-swallowed-every-exit", "canonical_source": "https://dev.to/bokuwalily/28-hours-of-green-logs-zero-replies-how-a-single-echo-line-swallowed-every-exit-code-48gb", "published_at": "2026-08-31 05:00:06+00:00", "updated_at": "2026-08-31 05:21:33.371508+00:00", "lang": "en", "topics": ["developer-tools", "ai-agents"], "entities": ["Claude Code", "Anthropic"], "alternates": {"html": "https://wpnews.pro/news/28-hours-of-green-logs-zero-replies-how-a-single-echo-line-swallowed-every-exit", "markdown": "https://wpnews.pro/news/28-hours-of-green-logs-zero-replies-how-a-single-echo-line-swallowed-every-exit.md", "text": "https://wpnews.pro/news/28-hours-of-green-logs-zero-replies-how-a-single-echo-line-swallowed-every-exit.txt", "jsonld": "https://wpnews.pro/news/28-hours-of-green-logs-zero-replies-how-a-single-echo-line-swallowed-every-exit.jsonld"}}