hookスクリプトが黙って死ぬ ― macOSで踏んだ3つの罠
前作「Claude Codeエージェントのfrontmatterとインデックスを自動同期する」に続く「Claude Code環境」シリーズです。
hookスクリプトを書いたのに通知が来ない。エラーも出ない。ログにも何も残らない。macOSでClaude CodeのhookをzshスクリプトとしてLLMに書かせると、こういうことが起きる。理由は毎回違う3つの罠のどれかで、いずれも無音で失敗するのが共通点だ。
困りごと:エラーゼロのまま通知が止まった
2026-07-18、note-autolikeプロジェクトのStopフックから失敗通知が届かなくなった。スクリプトは exit 0 で終わっている。Claude Codeはエラーを吐いていない。bash -x でトレースを取ると、分岐が全く条件を満たしていない。原因を掘ると3つ別々の罠に同時に引っかかっていた。
罠1:zshの status は読み取り専用予約変数
何が起きるか
# ❌ zsh では status は read-only 予約変数
status=$?
if [[ $status -ne 0 ]]; then
notify_failure # ← 永遠に呼ばれない
fi
status=$? はエラーなしに無視される。zshでは status が最後のコマンド終了コードの別名として予約されており、代入できない。bashでは普通に動くため、bashで書いたスクリプトを .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
}
zshの主な代入禁止変数:
status/signals/commands/options/aliases/functions/modules/history。bashから移植するスクリプトはこれらを使っていないか先に走査する。grep -n '\bstatus=' your_script.zsh grep -n '\bsignals=\|\bcommands=\|\boptions=\|\baliases=' your_script.zsh
罠2:GUIから起動したhookは最小PATHしか持たない
何が起きるか
Claude Code (.app) をGUIで起動してhookが動くとき、プロセスは ~/.zshrc を読まない。子プロセスに渡るPATHは最小値だけだ。
/usr/bin:/bin:/usr/sbin:/sbin
nvm下の node もHomebrewの各ツールも、この最小PATHには入っていない。node "...mjs" を呼ぶhookは毎回 node: command not found で無音終了する。
診断
GUIから起動した子プロセスの見え方を再現する:
env -i HOME="$HOME" /bin/sh -c 'PATH="/usr/bin:/bin"; node --version'
# → sh: node: command not found
修正
~/.claude/settings.json のトップレベルに env.PATH を追加してhookに継承させる:
{
"env": {
"PATH": "~/.nvm/versions/node/<ver>/bin:/opt/homebrew/bin:/opt/homebrew/sbin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin:~/.local/bin"
}
}
Bashツール側はプロファイルを再読み込みするので、この設定はhookなどプロファイル非読み込みの子プロセスにだけ効く。nvmのバージョンパスが将来staleになっても /opt/homebrew/bin/node がフォールバックするので壊れない。
確認:
# 設定後に 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"
env.PATHは短すぎず長すぎず、ログインPATHの主要ディレクトリを網羅したsupersetにする。設定前にcp ~/.claude/settings.json ~/.claude/settings.json.bakでバックアップ。settings.jsonはプラグインがライブ書き換えすることがあるので、Editツールで変更する前に必ず再Readする。
罠3:macOSに timeout コマンドがない
何が起きるか
hookスクリプトで timeout 60 some_command と書くと、macOSでは
timeout: command not found
GNU coreutilsの timeout はmacOSに標準搭載されない。他のドキュメントが前提にしていても、そのまま動かない。timeout 呼び出しの行だけが無音でスキップされ、タイムアウト保護がゼロのままスクリプトが走り続ける。
診断
command -v timeout # → 何も出ない(macOS標準には存在しない)
command -v gtimeout # → coreutilsが入っていれば /opt/homebrew/bin/gtimeout
修正
brew install coreutils # gtimeout が /opt/homebrew/bin に入る
スクリプトは gtimeout を優先検出するパターンにする:
TIMEOUT_CMD=""
command -v gtimeout >/dev/null && TIMEOUT_CMD="gtimeout 60"
# ...
$TIMEOUT_CMD some_command
$TIMEOUT_CMD が空なら some_command だけが実行される。タイムアウトなしで動き続けるが少なくともエラーにはならない。完全に保護したい場合は明示的に死ぬ:
command -v gtimeout >/dev/null || { echo "ERROR: coreutils not installed (brew install coreutils)"; exit 1; }
gtimeoutを使うスクリプト(post_tsc_check.sh/skills-auto-update.shなど)はcommand -v gtimeout >/dev/null && TIMEOUT_CMD="gtimeout 60"のパターンで書いてある。brew install coreutilsさえ通れば設計通りに起動する。
踏んだ落とし穴
status=$?は代入ではなくno-op: 分岐が全部スルーされるので「ロジックが間違っている」と勘違いして時間を溶かす。zshスクリプトは冒頭でecho ${(t)status}を打って型を確認する習慣をつける- bashで動く ≠ zshで動く: shebangが
#!/bin/shのままzshに持ち込んだスクリプトは特に危険。#!/bin/zsh+zsh -nでシンタックスチェックを先に通す - hookの終了コードはClaude Codeへのシグナル: 非0で終わると操作がブロックされる。try/except(Python埋め込みの場合)や条件分岐の末尾は必ず
exit 0で返す env.PATHの長さ: 実在しないディレクトリを大量に列挙すると起動に影響する場合がある。nvmのバージョンパスは1つに絞り、Homebrew fallbackで安全網を張る- settings.jsonのライブ書き換え: agentmemoryなどプラグインが設定を更新することがある。Editツールで書き換える前に再Readして衝突を避ける
まとめ
3つの罠はいずれもエラーなしに黙って失敗するという共通パターンを持つ。
- zshの
statusは読み取り専用 →rc=$?に改名。診断はecho ${(t)status}で一発 - GUIアプリから起動されるhookは最小PATH →
~/.claude/settings.jsonにenv.PATHを足す - macOSに
timeoutは存在しない →brew install coreutilsでgtimeoutを使う
3つとも「bashでは動く」「CIのLinuxでは通る」状態になりやすい。zshスクリプトを書いたら ${(t)変数名} 確認・env -i 再現・command -v timeout 確認の3点セットを先に打つのが最速の診断フローだった。
次回は、hookが蓄積したJSONLログから何をどう読み出しているか ―― Claude Code hookのJSONLログ設計と集計パターンを書きます。
Lily(@bokuwalily)― 個人開発者。Claude Code で自動化基盤を組みながら、iOSアプリやWebサービスを量産しています
- AIで「寝てても回る仕組み」を作って月120万にした話は noteの有料記事 に💰
- OSS: github.com/bokuwalily 🐙
- 最新情報・お問い合わせは X @bokuwalily へ🌍
皆さんの ❤️ やシェアが励みになります!