Claude Code hooks:v2.1.141で解消した3つの厄介なバグ
Claude Code hooksで通知漏れ、シェルのクォート崩れ、PostToolUseの拒否によるターン停止を解消。v2.1.139〜2.1.141で加わったterminalSequence、args:[]、continueOnBlockの設定を、完全なsettings.json付きで解説します。

Q1で特に悩まされたClaude Code hooksのバグ3件が、わずか7日間のうちにひっそり解消されました。翌朝、管理用リポジトリから3つのシェル回避策を削除しました。
Claude Code hooksの実害バグ3件が消えた1週間
2026年5月6日から13日にかけて、AnthropicはClaude Code 2.1.132から2.1.141までをリリースしました。変更の大半はフックシステムに関するものです。
この更新に気づいたのは、私のリポジトリに// TODO: remove when claude-code fixes thisというコメント付きの応急処置が3つ残っていたからです。それが1週間ですべて不要になりました。
実害が大きかった順に、3件のバグを見ていきます。
1つ目は、デスクトップ通知の取りこぼしです。長いコンパクションが終わったときに気づけるよう、私のNotificationフックはOSのベルをstdoutへ出力していました。Claude CodeがフォアグラウンドTTYを占有しているときは動く一方、tmuxの分割画面、VS Codeの統合ターミナル、別ペインにフォーカスがあるWezTermでは何も起きません。2.1.141のterminalSequenceで解消しました。
2つ目は、シェルのクォート崩れです。私のStopフックではbash -lc "node post-stop.js --reason '$CLAUDE_STOP_REASON'"を実行していましたが、Claudeが返す理由にアポストロフィが1つ入るだけで壊れました。2.1.139で追加されたargs: string[]形式が解決策です。
3つ目は、PostToolUseによる拒否がClaudeへの差し戻しにならず、ターン自体を終了させてしまう問題です。ランタイムがblock判定を致命的エラーとして扱っていたため、スキーマ検証フックを丸ごと無効にせざるを得ませんでした。2.1.139のcontinueOnBlockを使うと、ブロック理由がコンテキストへ渡され、再試行シグナルとして処理されます。
末尾には完全なsettings.jsonを載せています。途中に余計な話は挟みません。
Claude Code hooksの仕組みを90秒で理解する
Claude Code 2.1.xには、SessionStart、PreToolUse、PostToolUse、UserPromptSubmit、Notification、Stop、SubagentStop、PreCompact、SessionEndの9種類のフックイベントがあります。各フックは、仕様で定められた環境とstdinペイロードを受け取ってランタイムが起動するコマンドです。ランタイムはフックのstdoutをJSONとして解析し、特定のフィールドによって挙動を制御します。具体的にはdecision(allow/deny/block)、reason(Claudeへ返す、またはユーザーへ表示する文字列)、terminalSequence(制御TTYへ送る生バイト列)のほか、イベント固有のフィールドがいくつかあります。
押さえるべき要点は1つです。ランタイムはフックのstdoutを正とみなします。解析されないフィールドは何の効果も持たず、解析されるフィールドは次のターンでClaudeが受け取る内容を変えます。仕組みの本質はこれだけです。
terminalSequence:TTYを占有せずにデスクトップ通知を鳴らす
2.1.141より前のNotificationフックは次のようになっていました。
{
"hooks": {
"Notification": [{
"command": "bash -lc 'printf \"\\a\" && notify-send \"Claude\" \"$CLAUDE_MESSAGE\"'"
}]
}
}printf "\a"はターミナルベルを鳴らすための処理でした。しかし実際には、フックプロセスが継承したファイルディスクリプタへ\aを書き込むだけです。Claude Codeがその時点でフォアグラウンドにいなければ、そこはユーザーのターミナルではありません。Claudeがペイン2で動き、私がペイン1で編集しているtmux分割環境では、ベルが外側のターミナルまで届きませんでした。notify-send自体は動きますが、GNOMEの通知トレイに収まるため見落としてしまいます。
2.1.141では、フックがstdoutへ返すJSONにterminalSequenceフィールドが追加されました。ランタイムがその文字列を制御端末デバイスへ直接書き込むため、フックプロセスのstdioを迂回できます。アップグレード後は次の形です。
{
"hooks": {
"Notification": [{
"command": "node hooks/notify.mjs"
}]
}
}import { execSync } from "node:child_process";
const payload = JSON.parse(
await new Response(process.stdin).text()
);
execSync(`notify-send "Claude" ${JSON.stringify(payload.message)}`);
process.stdout.write(JSON.stringify({
terminalSequence: ""
}));はBELです。ランタイムが制御TTYへ直接書き込むので、Claudeがバックグラウンドのペインにいても外側のターミナルでベルが鳴ります。私の検証ではmacOS Terminal、iTerm2、WezTerm、Alacrittyのすべてで正しく動作しました。tmuxはBELをそのまま通します。OSC 52も使うなら、いずれにせよset -g allow-passthrough onという設定をtmux.confに残しておきます。
注意すべき例外が1つあります。VS Codeの統合ターミナルは、デフォルトではBELを破棄します。そこでベルを鳴らす場合は、ユーザー設定で"terminal.integrated.enableBell": trueを指定してください。
args: string[]でフックコマンドのシェルエスケープをなくす
Stopフックは、ターンの終了時に実行されます。私は過去の実行履歴をgrepできるよう、セッションのメタデータをCloudflare D1へ記録する用途で使っています。
2.1.139より前は、次の設定でした。
{
"hooks": {
"Stop": [{
"command": "bash -lc \"node scripts/post-stop.js --session $CLAUDE_SESSION_ID --reason '$CLAUDE_STOP_REASON'\""
}]
}
}最初の1か月だけで、次の3種類のエスケープ問題に遭遇しました。
$CLAUDE_STOP_REASON内のアポストロフィがシングルクォートを閉じ、残りの文字列がシェルトークンとして解釈されます。Claudeがuser's request completedのような理由を返すとフックがクラッシュし、セッションログが失われました。
ツール名に含まれるバッククォートも問題です。Claudeが応答内でそう記述したため、フック実行時の$CLAUDE_TOOL_NAMEに`bash`という文字列が含まれると、シェルはバッククォート内をサブシェルとして実行しようとしました。このケースでは無害でしたが、仕組みとしては看過できません。
ユーザープロンプト内のUnicodeにも影響がありました。通常、UTF-8はbash -lcを問題なく往復しますが、特定のロケール設定と特定のCJKコードポイントが組み合わさると、黙ってバイトが欠落しました。
2.1.139でexec形式のコマンドが加わりました。argsを文字列配列で渡すと、途中にシェルを挟まずランタイムがコマンドを直接起動します。
{
"hooks": {
"Stop": [{
"args": [
"node",
"scripts/post-stop.js",
"--session", "$CLAUDE_SESSION_ID",
"--reason", "$CLAUDE_STOP_REASON"
]
}]
}
}ランタイムは自身の環境から$CLAUDE_*変数を解決してからexecveを呼びます。シェルも、シェル展開も、クォート処理もありません。user's request completedという文字列は、Claudeが生成したとおり、単一のargv[5]要素としてNodeスクリプトへ届きます。
ツール呼び出しのたびに走るPreToolUseフックでは、この違いが効いてきます。
シェル形式を残すべきなのは、パイプ、リダイレクト、グロブが必要な場合です。1つのフックエントリでargs:[]とcommand:""は同時に使えません。node x.js | jq | tee logが必要ならcommand:""を使い、エスケープのコストを受け入れることになります。それでも90%のフックにはexec形式が適しています。
continueOnBlock:PostToolUseの拒否を本当に再試行へつなげる
これは、特に長く待ち望んでいた修正です。私はディスクへ書き込むツール呼び出しを検証するPostToolUseフックを動かしています。ClaudeがTypeScriptファイルを書いた場合はtsc --noEmitを実行し、型エラーがあれば拒否します。
2.1.139より前は、この拒否フローが正しく機能しませんでした。
{
"hooks": {
"PostToolUse": [{
"matcher": "Write|Edit",
"command": "node hooks/validate-ts.mjs"
}]
}
}validate-ts.mjsが{ "decision": "block", "reason": "tsc failed: ..." }を返すと、ランタイムはターンを終了させました。Claudeには理由が渡りません。ユーザーには内容の分からない「hook blocked the operation」というメッセージだけが表示され、エラーを貼り付けて手動で再度プロンプトを送る必要がありました。これで実作業中のターンが止まる事態を3セッション経験し、私はフックを無効にしました。
2.1.139では、フック単位の設定としてcontinueOnBlockが追加されました。
{
"hooks": {
"PostToolUse": [{
"matcher": "Write|Edit",
"args": ["node", "hooks/validate-ts.mjs"],
"continueOnBlock": true,
"maxAttempts": 3
}]
}
}これでblock判定が返ると、reason文字列はツール結果のエラーとしてClaudeのコンテキストへ送られます。Claudeはtsc failed: src/api.ts(14,3): error TS2322: Type 'string' is not assignable to type 'number'を読み、次のターンで自ら修正できます。ループは内部で完結するため、ユーザーには何も表示されません。
maxAttemptsは厳格な上限です。これがないと、断続的に停止するリモートAPIへ依存する検証など、結果が一定しない処理が無限に再試行され、コンテキストを消費します。私は3に設定しています。3回失敗すると、フックはハードブロックへ切り替わり、ユーザーに表示されます。
アンチパターンとして、判定が時刻に依存するフックでcontinueOnBlockを有効にしてはいけません。デプロイ時間中の書き込みを拒否するフックは、Claudeが再試行した時点でもデプロイが続いていれば永久にループします。$CLAUDE_EFFORTで条件を制御するか、フックスクリプト内に試行カウンターを組み込んでください。
もう2つの改善:$CLAUDE_EFFORTとCLAUDE_PROJECT_DIR
この期間には、目立たないものの実用的な環境変数が2つ追加されました。
2.1.133では、フックの環境へ$CLAUDE_EFFORTが注入されるようになりました。値は現在のターンに設定されたClaudeのeffort levelに対応するlow、medium、high、xhighです。これにより、プロンプトを解析せずにeffort levelで処理を分岐できます。
const effort = process.env.CLAUDE_EFFORT;
if (effort === "low" || effort === "medium") {
// skip expensive tsc check on quick edits
process.stdout.write(JSON.stringify({ decision: "allow" }));
process.exit(0);
}
// run full tsc --noEmit on high/xhigh私はlow effortの簡単な編集ではtscを省略し、1回あたり約800ms短縮しています。Claudeが12ファイルを書き込むhighのプランニングターンでは完全なチェックが走るため、実際のバグを検出できます。
2.1.139では、ランタイムが起動するstdio MCPサーバーの環境にCLAUDE_PROJECT_DIRが追加されました。それ以前のstdio MCPサーバーはprocess.cwd()からワークスペースのルートを推測するしかなく、ユーザーがサブディレクトリからClaude Codeを起動すると正しく動きませんでした。現在は、どのMCPサーバーもprocess.env.CLAUDE_PROJECT_DIRを読み、ワークスペース相対パスを正しく解決できます。
MCPサーバーを保守している場合は、マニフェストのパス解決でCLAUDE_PROJECT_DIRを使い、古いクライアント向けにcwd()へフォールバックしてください。2行の修正で、この種のバグをまとめて取り除けます。
Claude Code 設定の実例:実際に使っているsettings.json
以下はomidsaffari-adminで本番運用している設定です。一部だけ伏せています。6つのDOと1つのWorkflowが、デスクトップ通知とCIゲートのためにフック出力を利用しています。
{
"model": "claude-sonnet-4-7-20260501",
"permissions": {
"edit": "ask"
},
"hooks": {
"SessionStart": [{
"args": ["node", "hooks/session-start.mjs"]
}],
"PreToolUse": [{
"matcher": "Bash",
"args": ["node", "hooks/gate-bash.mjs"]
}],
"PostToolUse": [{
"matcher": "Write|Edit",
"args": ["node", "hooks/validate-ts.mjs"],
"continueOnBlock": true,
"maxAttempts": 3
}],
"Notification": [{
"args": ["node", "hooks/notify.mjs"]
}],
"Stop": [{
"args": [
"node",
"scripts/post-stop.js",
"--session", "$CLAUDE_SESSION_ID",
"--reason", "$CLAUDE_STOP_REASON"
]
}],
"PreCompact": [{
"args": ["node", "hooks/notify.mjs"]
}]
}
}{
"devDependencies": {
"@anthropic-ai/claude-code": "2.1.141"
}
}インストールして確認します。
pnpm add -D @anthropic-ai/claude-code@2.1.141
claude --version # expect: 2.1.141
claude config doctor # expect: 0 hook warnings要点は次のとおりです。continueOnBlockとmaxAttemptsは2.1.139で追加された再試行ループの組み合わせです。シェル機能を必要としないすべてのフック、つまりこの設定にある全フックでargs:[]形式を使っています。NotificationとPreCompactはnotify.mjsを共用し、どちらも同じterminalSequence出力でデスクトップ通知を鳴らします。
導入チェックリストと見送るべき条件
事前準備として、現在のsettings.jsonをスナップショットに保存します。command:""を使うフックと、すでにargs:[]へ移行済みのフックを確認し、接続しているイベント種別をすべて一覧にしてください。
イベント種別ごとに移行し、各移行後は24時間運用します。claude config doctorでフックの警告を監視し、ログからdecisionとreasonをgrepして、ランタイムが想定どおり受け取っていることを確かめます。
推奨する順序は次のとおりです。
- パッケージを2.1.141へアップグレードします。
command:""を使うフックを1つだけargs:[]へ変換し、実行されることを確認します。terminalSequenceをNotificationフックへ追加し、バックグラウンドのtmuxペインからベルが鳴ることを確認します。continueOnBlockを最も支障の大きいPostToolUseフックへ追加します。実際のセッションを1回観察し、Claudeが理由を受け取って自己修正することを確認します。- 残りのフックを数回に分けて
args:[]へ移行します。
親SDKによって管理され、まだ2.1.141の検証が済んでいない環境にバージョン固定されている場合、または2.1.141で非推奨になったフックフィールドへ依存している場合は、アップグレードを見送ってください。執筆時点では、5月の更新で既存フィールドを壊す変更はありません。ただし、チームへ展開する前にバージョンを固定し、ブランチ上で検証します。
6つの本番エージェントで実践しているバージョン固定、フック戦略、プロジェクト構築の全手順は、Claude Code + Codex Setup Checklistにまとめています。同じsettings.jsonパターンに加え、フックが制御するエージェント側の基盤(Workflows、DOs、Vectorize bindings)までを一通り扱います。
リモートサンドボックスでこれらのエージェントを動かす開発ツール面の解説は、Cursor Cloud Agent環境とCloudflare Workersの比較をご覧ください。このsettings.jsonを支える本番スタックの背景は、Cloudflare 100x engineerの記事で解説しています。
terminalSequenceはtmuxでも動作しますか?
はい。ただし注意点が1つあります。ランタイムはシーケンスを制御端末デバイスへ書き込みます。set -g allow-passthrough onがtmux.confにある場合、またはシーケンスが単純なBEL()の場合、tmuxは外側のターミナルへ転送します。BELは無条件で通ります。OSCシーケンスにはpassthroughの有効化が必要です。
同じフック設定でargs:[]とcommand:''を併用できますか?
いいえ。1つのフックエントリでは相互排他です。直接起動するコマンドにはexec形式(args:[])、パイプ、リダイレクト、グロブが必要な処理にはシェル形式(command:"")を選びます。両方が必要なら、exec形式で呼び出すラッパースクリプトを用意し、その内部にシェル機能を実装してください。
Claudeが同じ検証で失敗し続けると、continueOnBlockは無限ループしますか?
maxAttemptsを設定すれば無限ループしません。ランタイムは指定回数で再試行を打ち切り、その後はハードブロックへ切り替えます。maxAttemptsがなければ、結果が一定しないバリデーターでコンテキストを消費し続ける可能性があります。必ず設定してください。型チェックのような検証では3、リモートの状態に依存する処理では1が妥当です。
$CLAUDE_EFFORTはすべてのフックイベントで使えますか?
はい。2.1.133以降、ランタイムが起動するすべてのフックの環境へ注入されます。値は現在のターンのeffort levelを反映するため、SessionStartではユーザーが起動時に選んだ値、PostToolUseではツール実行時に有効だった値を受け取ります。
2.1.138へダウングレードすると何が動かなくなりますか?
terminalSequence、args:[]、continueOnBlock、CLAUDE_PROJECT_DIRのすべてが通知なく機能しなくなります。ランタイムは未知のJSONフィールドを無視し、command:""の解析へフォールバックします。フック自体は実行され続けますが、新しい挙動は失われます。依存する前に、ブランチ上でダウングレード手順を検証してください。
2026年9月5日







