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には、SessionStartPreToolUsePostToolUseUserPromptSubmitNotificationStopSubagentStopPreCompactSessionEndの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に対応するlowmediumhighxhighです。これにより、プロンプトを解析せずに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

要点は次のとおりです。continueOnBlockmaxAttemptsは2.1.139で追加された再試行ループの組み合わせです。シェル機能を必要としないすべてのフック、つまりこの設定にある全フックでargs:[]形式を使っています。NotificationPreCompactnotify.mjsを共用し、どちらも同じterminalSequence出力でデスクトップ通知を鳴らします。

導入チェックリストと見送るべき条件

事前準備として、現在のsettings.jsonをスナップショットに保存します。command:""を使うフックと、すでにargs:[]へ移行済みのフックを確認し、接続しているイベント種別をすべて一覧にしてください。

イベント種別ごとに移行し、各移行後は24時間運用します。claude config doctorでフックの警告を監視し、ログからdecisionreasonをgrepして、ランタイムが想定どおり受け取っていることを確かめます。

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

  1. パッケージを2.1.141へアップグレードします。
  2. command:""を使うフックを1つだけargs:[]へ変換し、実行されることを確認します。
  3. terminalSequenceNotificationフックへ追加し、バックグラウンドの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 ontmux.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へダウングレードすると何が動かなくなりますか?

terminalSequenceargs:[]continueOnBlockCLAUDE_PROJECT_DIRのすべてが通知なく機能しなくなります。ランタイムは未知のJSONフィールドを無視し、command:""の解析へフォールバックします。フック自体は実行され続けますが、新しい挙動は失われます。依存する前に、ブランチ上でダウングレード手順を検証してください。

最終更新

2026年9月5日

カテゴリーBuild

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

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

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

ニュースレター

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

AIベンチャーのポートフォリオ運営から生まれるビルドログ、稼働中のシステム、現場ノート。

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