# When Hook Scripts Die Silently — Three macOS Traps

> Source: <https://dev.to/bokuwalily/when-hook-scripts-die-silently-three-macos-traps-3cgh>
> Published: 2026-07-31 05:00:02+00:00

This is part of the "Claude Code environment" series, following on from [Auto-syncing frontmatter and the index for Claude Code agents](https://zenn.dev/bokuwalily/articles/agents-frontmatter-index).

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

```
# ❌ zsh では status は read-only 予約変数
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}
# → "integer-readonly-special"  ← special を含めば代入禁止

echo ${(t)rc}
# → ""  ← 未定義 = 安全に使える

# zsh の read-only 変数一覧
typeset -r | grep '='
# ✅ rc / exit_code / _rc など予約されていない名前を使う
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=&#124;\bcommands=&#124;\boptions=&#124;\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'
# → sh: node: command not found
```

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.PATH の値で再現テスト
env -i HOME="$HOME" /bin/sh -c 'PATH="<settings.jsonのenv.PATHの値>"; node --version && command -v node'
# → v24.x.x
# → ~/.nvm/versions/node/.../bin/node

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

Note

Keep`env.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 with`cp ~/.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 use`gtimeout`

(`post_tsc_check.sh`

/`skills-auto-update.sh`

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

pattern. As long as`brew install coreutils`

goes through, they start up as designed.

`status=$?`

is a no-op, not an assignment`echo ${(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 branches`env.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](https://bokuwalily.com) · [X](https://x.com/bokuwalily) · [GitHub](https://github.com/bokuwalily)*
