cd /news/developer-tools/when-hook-scripts-die-silently-three… · home topics developer-tools article
[ARTICLE · art-81412] src=dev.to ↗ pub= topic=developer-tools verified=true sentiment=· neutral

When Hook Scripts Die Silently — Three macOS Traps

A developer documented three silent failure traps in Claude Code hook scripts on macOS, including zsh's read-only 'status' variable, missing PATH when launched from the GUI, and the absence of the GNU 'timeout' command. The post recommends using alternative variable names, setting env.PATH in settings.json, and avoiding timeout in hooks.

read5 min views1 publishedJul 31, 2026

This is part of the "Claude Code environment" series, following on from Auto-syncing frontmatter and the index for Claude Code agents.

You wrote a hook script, but no notification ever arrives. No error. Nothing in the logs either. When you have an LLM write a Claude Code hook as a zsh script on macOS, this is what happens. The cause is one of three traps, different every time, and what they all share is that they fail silently.

On 2026-07-18, failure notifications stopped arriving from the Stop hook in the note-autolike project. The script exits with exit 0. Claude Code isn't emitting any errors. Tracing with bash -x

shows that the branches never satisfy their conditions at all. Digging into the cause turned up three separate traps, all hit at once.

status

is a read-only reserved variable

status=$?
if [[ $status -ne 0 ]]; then
  notify_failure   # ← 永遠に呼ばれない
fi

status=$?

is silently ignored, with no error. In zsh, status

is reserved as an alias for the exit code of the last command, and cannot be assigned to. Since it works fine in bash, you won't notice when you port a script written for bash over to .zsh

.

echo ${(t)status}

echo ${(t)rc}

typeset -r | grep '='
rc=$?
if [[ $rc -ne 0 ]]; then
  notify_failure
fi

run_and_check() {
  "$@"
  local rc=$?
  [[ $rc -ne 0 ]] && echo "ERROR: $* returned $rc"
  return $rc
}

Note

The main non-assignable variables in zsh:status

/signals

/commands

/options

/aliases

/functions

/modules

/history

. When porting a script from bash, scan it for these first.

grep -n '\bstatus=' your_script.zsh
grep -n '\bsignals=|\bcommands=|\boptions=|\baliases=' your_script.zsh

When Claude Code (.app) is launched from the GUI and a hook runs, the process does not read ~/.zshrc

. The PATH handed down to child processes is just the bare minimum.

/usr/bin:/bin:/usr/sbin:/sbin

Neither node

under nvm nor any of the Homebrew tools are on this minimal PATH. A hook that calls node "...mjs"

terminates silently with node: command not found

every time.

Reproduce how things look to a child process launched from the GUI:

env -i HOME="$HOME" /bin/sh -c 'PATH="/usr/bin:/bin"; node --version'

Add env.PATH

at the top level of ~/.claude/settings.json

so it's inherited by hooks:

{
  "env": {
    "PATH": "~/.nvm/versions/node/<ver>/bin:/opt/homebrew/bin:/opt/homebrew/sbin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin:~/.local/bin"
  }
}

The Bash tool reloads the profile, so this setting only takes effect for child processes that don't read the profile, such as hooks. Even if the nvm version path goes stale in the future, /opt/homebrew/bin/node

acts as a fallback, so it won't break.

Verification:

env -i HOME="$HOME" /bin/sh -c 'PATH="<settings.jsonのenv.PATHの値>"; node --version && command -v node'

jq -e . ~/.claude/settings.json >/dev/null && echo "settings.json VALID"

Note

Keepenv.PATH

neither too short nor too long — make it a superset that covers the main directories of your login PATH. Back it up before changing anything withcp ~/.claude/settings.json ~/.claude/settings.json.bak

. Plugins sometimes rewrite settings.json live, so always re-Read it before modifying it with the Edit tool.

timeout

command If you write timeout 60 some_command

in a hook script, on macOS you get

timeout: command not found

GNU coreutils' timeout

doesn't ship with macOS by default. Even if other docs assume it, it won't just work. Only the lines that call timeout

get silently skipped, and the script keeps running with zero timeout protection.

command -v timeout   # → 何も出ない(macOS標準には存在しない)
command -v gtimeout  # → coreutilsが入っていれば /opt/homebrew/bin/gtimeout
brew install coreutils   # gtimeout が /opt/homebrew/bin に入る

Write the script so it detects gtimeout

first:

TIMEOUT_CMD=""
command -v gtimeout >/dev/null && TIMEOUT_CMD="gtimeout 60"

$TIMEOUT_CMD some_command

If $TIMEOUT_CMD

is empty, only some_command

runs. It keeps running without a timeout, but at least it doesn't error out. If you want full protection, die explicitly:

command -v gtimeout >/dev/null || { echo "ERROR: coreutils not installed (brew install coreutils)"; exit 1; }

Note

Scripts that usegtimeout

(post_tsc_check.sh

/skills-auto-update.sh

, etc.) are written with thecommand -v gtimeout >/dev/null && TIMEOUT_CMD="gtimeout 60"

pattern. As long asbrew install coreutils

goes through, they start up as designed.

status=$?

is a no-op, not an assignmentecho ${(t)status}

at the top of a zsh script to check the type#!/bin/sh

shebang still in place are especially dangerous. Run a syntax check first with #!/bin/zsh

  • zsh -n

exit 0

at the end of try/except blocks (when embedding Python) and conditional branchesenv.PATH

All three traps share the same pattern: they fail silently, with no error.

status

is read-only → rename to rc=$?

. Diagnose it in one shot with echo ${(t)status}

env.PATH

to ~/.claude/settings.json

timeout

→ use gtimeout

via brew install coreutils

All three tend to end up in the state of "it works in bash" and "it passes on CI's Linux." The fastest diagnostic flow was to run the three-part set first whenever you write a zsh script: check ${(t)variable_name}, reproduce with env -i, and check command -v timeout.

Next time I'll write about what the hooks read out of the JSONL logs they accumulate, and how — [JSONL log design and aggregation patterns for Claude Code hooks].

*Written by Lily — I ship iOS apps and automate my content stack with Claude Code.

Follow along: Portfolio · X · GitHub*

── more in #developer-tools 4 stories · sorted by recency
── more on @claude code 3 stories trending now
sponsored brought to you by zahid.host 4,200+ EU-deployed projects
reading about agents? ship yours in a single git push.

Run your AI side-project on zahid.host

EU-based hosting, git-push deploys, automatic HTTPS, no cold starts. Free tier with a custom domain — perfect for shipping the agent you just read about.

$git push zahid main
Live at https://your-agent.zahid.host
Get free account → Pricing
from €0/mo · no card required
LIVE [news/when-hook-scripts-di…] indexed:0 read:5min 2026-07-31 ·