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

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

Saturday, September 5, 2026Omid Saffari
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:""の解析へフォールバックします。フック自体は実行され続けますが、新しい挙動は失われます。依存する前に、ブランチ上でダウングレード手順を検証してください。

最終更新
2026年9月5日
カテゴリー
Build

Googleでこのサイトを優先する

omidsaffari.comをGoogle検索の優先ソースに追加

omidsaffari.comを優先ソースに設定すると、GoogleがTop Stories・AI Overviews・AI Modeであなたのために優先表示します。

Cursor 料金を検証:Rolloutsは無料で使えるのか

Cursor 料金を検証:Rolloutsは無料で使えるのか

Cursor Rolloutsは無料ではなく、Teamsは1ユーザー月額$40、Enterpriseは個別見積もりです。10日間のローンチクレジットでTeamsは約50件、Enterpriseは約500件の変更を試せますが、その後の単価は未公表。料金の仕組みと導入判断を整理します。2026年9月24日Build
AIエージェント基盤「Unreal Agent」を実務で評価する方法

AIエージェント基盤「Unreal Agent」を実務で評価する方法

AIエージェント実行基盤「Unreal Agent」をコマンドラインで安全に試す手順を解説します。リリース、モデル、推論レベルを固定し、JSONL、実行時間、終了ステータス、トークン使用量、差分を記録して、既存エージェントとの品質・コスト比較や、ランナーとGoライブラリの選び分けまで判断できる実践ガイドです。2026年9月24日Build
JetBrains Airの使い方:Alphaプラグイン導入・設定・レビュー手順

JetBrains Airの使い方:Alphaプラグイン導入・設定・レビュー手順

JetBrains Airの使い方を、Air Alphaの導入からエージェント接続、@file:でのコンテキスト指定、差分レビュー、テスト、元に戻す判断まで実践的に解説します。Standard Accessで小さな修正から安全に始める手順に加え、料金、互換性、具体的な活用例も分かりやすくまとめました。2026年9月23日Build
JetBrains Airは無料?料金の仕組みと支払元を徹底整理

JetBrains Airは無料?料金の仕組みと支払元を徹底整理

JetBrains AirのAir Alphaプラグインは無料ですが、IDE本体や接続するAIエージェント、API利用料まで無料とは限りません。Junie Lite、既存アカウント、APIキー、JetBrains AIという4つの認証経路ごとに、誰が料金を負担するのかを公開情報と具体的な料金比較で整理します。2026年9月23日Build
Firecrawl APIをセルフホストする方法:Docker導入と料金比較

Firecrawl APIをセルフホストする方法:Docker導入と料金比較

Firecrawl APIをセルフホストする手順を、v2.11.162への固定、実スクレイプと再起動後の検証、証跡保存まで詳しく解説します。さらにFirecrawl Cloudとの機能差と30日間の費用を比較し、セルフホストを選ぶべき条件、運用負荷、導入前に確認すべきセキュリティと本番要件を整理します。2026年9月22日Build
生成AI ガバナンスの盲点:有料リトライを人が承認する設計

生成AI ガバナンスの盲点:有料リトライを人が承認する設計

AIエージェントが自らの品質判定で有料レンダリングを再実行し、人が確認する前に$5.48を消費した事例を解説します。生成AI ガバナンスで見落としやすいのは、レビュー権限と購入権限の違いです。人だけが承認を書き込めるフィールドを設け、有料ツール呼び出しの直前で再購入を拒否する実装と、その適用範囲を具体的に整理します。2026年9月22日Build
AIエージェント 作り方ガイド:MindStudioの料金・機能・運用コストを検証

AIエージェント 作り方ガイド:MindStudioの料金・機能・運用コストを検証

MindStudioでAIエージェントをどう作るのか。ノーコードのワークフロー設計、料金、モデル利用料、レビュー工数、競合ツールとの違いを公開情報から検証します。1,000件・10,000件のコスト試算、Businessプランの壁、導入前に実施すべき20件の合成データテストまで、購入判断に必要な条件を解説します。2026年9月22日Build
音声文字起こしツール比較:SuperwhisperとWispr Flow、どちらを選ぶ?

音声文字起こしツール比較:SuperwhisperとWispr Flow、どちらを選ぶ?

SuperwhisperとWispr Flowを、ローカル/クラウド処理、対応デバイス、チーム管理、修正作業、料金で比較。業務向けの音声文字起こし・音声入力ではどちらを選ぶべきか、検証済みの仕様と価格、20発話の修正プロトコルから判断します。速度や精度を断定せず、用途別の選び方と長期コストを整理します。2026年9月22日Build
ニュースレター

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

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