Claude Code hooks:v2.1.141で解消した3つの厄介なバグ

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

公開日

Claude Code hooks:v2.1.141で解消した3つの厄介なバグ

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フックは次のようになっていました。

JSON
{
  "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を迂回できます。アップグレード後は次の形です。

JSON
{
  "hooks": {
    "Notification": [{
      "command": "node hooks/notify.mjs"
    }]
  }
}
JavaScript
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より前は、次の設定でした。

JSON
{
  "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を文字列配列で渡すと、途中にシェルを挟まずランタイムがコマンドを直接起動します。

JSON
{
  "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より前は、この拒否フローが正しく機能しませんでした。

JSON
{
  "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が追加されました。

JSON
{
  "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で処理を分岐できます。

JavaScript
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ゲートのためにフック出力を利用しています。

JSON
{
  "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"]
    }]
  }
}
JSON
{
  "devDependencies": {
    "@anthropic-ai/claude-code": "2.1.141"
  }
}

インストールして確認します。

Bash
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して、ランタイムが想定どおり受け取っていることを確かめます。

推奨する順序は次のとおりです。

  1. パッケージを2.1.141へアップグレードします。
  2. command:""を使うフックを1つだけargs:[]へ変換し、実行されることを確認します。
  3. terminalSequenceをNotificationフックへ追加し、バックグラウンドのtmuxペインからベルが鳴ることを確認します。
  4. continueOnBlockを最も支障の大きいPostToolUseフックへ追加します。実際のセッションを1回観察し、Claudeが理由を受け取って自己修正することを確認します。
  5. 残りのフックを数回に分けて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:""の解析へフォールバックします。フック自体は実行され続けますが、新しい挙動は失われます。依存する前に、ブランチ上でダウングレード手順を検証してください。

公開日
カテゴリー
Build
ACPとは?Agent Client ProtocolでエディタとAIエージェントをつなぐ

ACPとは?Agent Client ProtocolでエディタとAIエージェントをつなぐ

ACPとは何か、エディタとコーディングエージェントをつなぐ仕組みから解説します。Zed、JetBrains、Android Studioの対応状況、Claude Agentの接続手順、MCPとの違いを整理。認証や設定、セッションの復元、権限管理の制約も押さえ、チームで試験導入する際の判断に役立てられます。2026年11月3日Build
Vercelの料金と代替サービス6選:Next.js・AIアプリの移行先を比較【2026年】

Vercelの料金と代替サービス6選:Next.js・AIアプリの移行先を比較【2026年】

Vercelの料金が気になる開発者向けに、Cloudflare、Render、Railway、Netlify、Fly.io、AWS Amplifyの6社を比較。Next.jsやAIアプリの実行環境、無料枠、課金方式、ストリーミング対応を整理し、移行先の選び方とVercelを使い続ける条件、移行前の確認手順を解説します。2026年11月3日Build
Retell AIなどVapiの代替サービスを比較:料金・連携・移行の選び方【2026年】

Retell AIなどVapiの代替サービスを比較:料金・連携・移行の選び方【2026年】

Retell AI、Bland AI、ElevenLabs Agents、Synthflow、Vida Globalを、料金体系・電話接続・外部連携・移行時の作業で比較します。Vapiの代替選びで見落としやすい従量料金や同時通話の制限、人への引き継ぎ、既存ワークフローを維持するための検証ポイントを整理します。2026年11月3日Build
Browser Useの使い方:ブラウザ自動化の仕組みとAIエージェントの作り方

Browser Useの使い方:ブラウザ自動化の仕組みとAIエージェントの作り方

Browser Useの使い方を、導入から最初の実行まで解説します。AIによるブラウザ自動化の仕組み、対応モデル、オープンソース版とCloudの違いを整理。Pythonのサンプルに加え、ログイン状態の管理、プロンプトインジェクション対策、送信前の承認と結果確認など、本番運用で押さえたい注意点を紹介します。2026年11月2日Build
SNS自動投稿を組み込むなら?Zernioの料金・対応APIをレビュー

SNS自動投稿を組み込むなら?Zernioの料金・対応APIをレビュー

SNS自動投稿を自社サービスに組み込みたい開発者向けに、Zernioの対応SNS、アカウント要件、料金、Webhook、MCPを解説します。接続アカウント単位の課金や運用上の制約を整理し、Ayrshare、Postiz、各SNSのネイティブAPIとの違いから、用途に合う選び方を検討します。2026年11月1日Build
AirtableとNotionの比較:6つの用途でわかる違いと選び方

AirtableとNotionの比較:6つの用途でわかる違いと選び方

AirtableとNotionの比較を、CRM、コンテンツ管理、プロジェクト管理、社内Wiki、在庫管理、クライアントポータルの6用途で解説します。料金だけでなく、AI、自動化、APIの制限や共有権限、併用時の役割分担、移行で引き継げない情報まで整理し、チームの業務に合う選び方を示します。2026年11月1日Build
Google Julesがサービス終了へ:データ保存と移行先の選び方

Google Julesがサービス終了へ:データ保存と移行先の選び方

Google Julesのサービス終了に向け、読み取り専用への切り替え日程、エクスポートの手順、履歴とAPIの注意点を整理します。Antigravity、Codex、Claude Code、GitHub Copilotへの移行方法と、実行環境・テスト・レビューで確認すべき点を解説します。2026年11月1日Build
AIエージェントフレームワーク「smolagents」の使い方と選び方

AIエージェントフレームワーク「smolagents」の使い方と選び方

AIエージェントフレームワークのsmolagentsを、Pythonで行動を組み立てる仕組みから解説。CodeAgentとToolCallingAgentの使い分け、Dockerで試す計算例、サンドボックスと権限設計の注意点を整理。LangGraphやOpenAI Agents SDKとの比較で、用途別の選び方を紹介。2026年10月31日Build
ニュースレター

毎週日曜、一通の手紙。動くシステムの話。感想戦ではなく。

週刊。スパムなし。いつでも解除できます。