はじめに / 対象と前提
Claude Code でコーディング以外の自律タスク(長時間のリサーチ、多段階のバッチ処理、繰り返しのエージェント運用)を組んでいて、「気づいたら同じツール呼び出しを何十回も繰り返していた」「セッションが終わらずAPI課金だけ伸びていた」という経験がある人向けの記事。
前提環境:
- Claude Code(2026年7月時点の最新版)
- hooks は
.claude/settings.json(またはsettings.local.json)のhooksフィールドで設定 - フック本体は Python 3.13 で記述(Node.js でも同様の実装が可能)
hooks の基本的な書き方や PreToolUse / PostToolUse を触ったことがある人を想定しているので、hooks そのものの入門部分は省略する。
TL;DR
-
Stophook は「エージェントが応答を終えようとした瞬間」にしか発火しない。ツール呼び出しを畳みかけているループの最中を止める用途には向かない - 本当に暴走(同一ツール呼び出しの無限連発)を検知したいなら、
PreToolUse側で直近の呼び出しを記録し、同一呼び出しが閾値を超えたら exit code 2 でブロックするのが正解 -
Stophook を「タスクが残っていれば継続させる」用途で使うときは、payload のstop_hook_activeを見ないと、hook 自身が無限に発火し続ける
手順 / 動かし方
1. hooks 設定
{
"hooks": {
"PreToolUse": [
{
"matcher": "*",
"hooks": [
{ "type": "command", "command": "python3 .claude/hooks/loop_guard.py" }
]
}
],
"Stop": [
{
"hooks": [
{ "type": "command", "command": "python3 .claude/hooks/force_continue.py" }
]
}
]
}
}
2. ループ検知(PreToolUse)
同一ツール・同一引数の呼び出しが連続した回数を /tmp に記録し、閾値を超えたら exit code 2 でブロックする。
# .claude/hooks/loop_guard.py
import json, sys, hashlib, pathlib
STATE = pathlib.Path("/tmp/claude_loop_guard.json")
THRESHOLD = 5
payload = json.load(sys.stdin)
tool_name = payload.get("tool_name", "")
tool_input = payload.get("tool_input", {})
key = hashlib.sha256(
f"{tool_name}:{json.dumps(tool_input, sort_keys=True)}".encode()
).hexdigest()
prev = json.loads(STATE.read_text()) if STATE.exists() else {}
count = prev.get("count", 0) + 1 if prev.get("key") == key else 1
STATE.write_text(json.dumps({"key": key, "count": count}))
if count >= THRESHOLD:
print(f"同一ツール呼び出しが{THRESHOLD}回連続。暴走とみなして停止します", file=sys.stderr)
sys.exit(2)
exit code 2 を返すと stderr の内容が Claude 自身に見える形でフィードバックされ、同じ手を繰り返さず別のアプローチを検討させられる。
3. Stop hook で継続させる場合の安全弁
タスクが残っていると判断したら decision: block を返して継続させる、という実装はよくあるが、stop_hook_active のチェックを外すと危険。
# .claude/hooks/force_continue.py
import json, sys
payload = json.load(sys.stdin)
if payload.get("stop_hook_active"):
# 既にこのhook経由で継続中 → ここで必ず抜ける
sys.exit(0)
if task_not_finished(): # 自前の完了判定
print(json.dumps({"decision": "block", "reason": "残タスクがあるので継続してください"}))
else:
sys.exit(0)
4. 動作確認
わざと同じファイルを5回 grep させるだけのプロンプトを投げてテストした。1〜4回目は通常どおり実行され、5回目で loop_guard.py が exit code 2 を返し、stderr のメッセージが会話に差し込まれて Claude が別の方法(直接ファイルを読む、など)に切り替えるのを確認できた。force_continue.py 側も、stop_hook_active を外した状態で動かすと同じ応答をひたすら繰り返すのを確認してから、チェックを戻した。
ハマりどころ
① Stop hook はループの外側でしか発火しない
Stop hook は「メインループが応答を終えて良いか」を判断するタイミングでのみ呼ばれる。ツール呼び出しを連発している最中には発火しないため、「暴走を止める」目的で Stop hook だけを実装すると、一生発火しないフックが出来上がる。ループそのものを検知したいなら PreToolUse 側に判定ロジックを置く必要がある。
② stop_hook_active を見ないと自己再帰する
Stop hook で decision: block を返すと、Claude はもう一度考えて応答しようとする。だがその応答の最後で再び Stop hook が発火する。payload に含まれる stop_hook_active(bool)を無視すると、「継続させる → また止まろうとする → また継続させる」のループが物理的に終わらなくなる。原因が分かるまで少し時間がかかった箇所。
③ headless モード(-p)ではブロックの意味が変わる
インタラクティブセッションだと decision: block はユーザーに見える形で会話が続くが、-p の headless 実行では会話相手がいないため、単に次のターンが自動生成されるだけになる。ループガードを付けないまま headless で動かすと、気づかないうちに API 呼び出し課金だけが積み上がる。長時間の自律タスクを headless で回すなら、PreToolUse 側の暴走検知は実質必須と考えたほうがいい。
背景・補足
Claude Code のフックは、シグナルではなく JSON / exit code でフレームワークに指示を返す設計になっている。exit code 0 は「そのまま続行」、exit code 2 は「ブロックして stderr を Claude に見せる」、それ以外の exit code は「エラーとして記録するが実行は止めない」という違いがある。この3値の意味を混同すると、本来止めたい処理が素通りしたり、逆に止めたくない処理まで止めてしまったりする。
まとめ
-
Stophook は「止まろうとした瞬間」専用。ループそのものの検知はPreToolUse側でやる -
stop_hook_activeのチェックを忘れると、継続と停止判断が無限ループになる - headless モードでは「継続」の意味がインタラクティブ時と変わるので、暴走ガードは必須
- 自律的にタスクを回すエージェント運用をするなら、まずどの hook がどのタイミングで発火するかを正しく理解してから設計するのが結局近道