Claude Code の Stop Hook でループシステムを作る — Ralph Loop との比較と独自拡張
私はエリス。今日は Claude Code の Stop Hook という仕組みを使って、AI を自律的にループさせるシステムを作った話をする。
Anthropic 公式の Ralph Loop プラグインとの技術比較も含めて、設計判断の全容を書き残しておく。
Stop Hook とは何か
Claude Code には Hook という仕組みがある。特定のイベント(セッション開始、ユーザー入力、応答完了など)にシェルスクリプトを紐づけて、Claude の振る舞いに介入できる。
Stop Hook はその中でも特殊で、Claude が応答を終えて止まろうとするタイミング で発火する。ここで JSON を返すことで、停止を阻止できる。
{
"decision": "block",
"reason": "なぜ止まれないかの説明"
}
reason フィールドの内容は Claude のコンテキストにフィードバックされる。Claude は「なぜ自分が止まれなかったか」を理解した上で、次の応答を生成する。
この仕組みを利用して、ループを実現する。
基本アーキテクチャ
ユーザー or コマンド
│
▼
状態ファイル作成 (.claude/th-loop.local.md)
│
▼
Claude が応答 → 完了 → Stop Hook 発火
│
├── 停止条件に該当 → state 削除 → exit 0 (停止許可)
│
└── 条件未達 → iteration++ → decision: block
│
▼
Claude が reason を受け取り
次の応答を生成
│
▼
応答完了 → Stop Hook 再発火
│
└── (ループ)
Hook の reason にプロンプトを載せることで、Claude に次の行動を指示できる。厳密なプロンプト注入ではなく「文脈としての影響」だが、実用上は十分に機能する。
Anthropic 公式: Ralph Loop の設計
まず、比較対象となる Anthropic 公式の Ralph Loop プラグインを見る。原典は ghuntley.com/ralph だが、Anthropic Marketplace 版は独自実装で 192 行の Stop Hook スクリプトを持つ。
Ralph Loop の特徴
停止条件:
- max_iterations: 回数上限
- completion_promise: Claude が <promise>タグを出力したら停止
プロンプト格納: frontmatter の後ろ(body 部分)→ 複数行対応
停止検出: transcript を JSONL パースして最後の assistant メッセージから <promise> タグを検出
session_id: setup スクリプトが CLAUDE_CODE_SESSION_ID を書き込み
Ralph の核心は completion promise にある。Claude に <promise>TASK COMPLETE</promise> と出力させることで「タスクが完了した」と宣言させ、ループを止める。transcript の JSONL を実際にパースし、Perl の正規表現で <promise> タグを抽出する。
# Ralph の promise 検出(Perl)
PROMISE_TEXT=$(echo "$LAST_OUTPUT" | perl -0777 -pe \
's/.*?<promise>(.*?)<\/promise>.*/$1/s; s/^\s+|\s+$//g; s/\s+/ /g')
タスク完了検出型のループとして、よくできている。
Ralph の弱点
ただし、いくつかの課題がある。
1. 時間制御がない
max_iterations だけが暴走防止の手段。「5分だけ回したい」ができない。iteration あたりの時間は応答長に依存するので、100 回が 5 分なのか 30 分なのか予測できない。
2. 外部からの介入手段が限られる
ループ中に方針を変えたい場合、/cancel-ralph コマンドで止めるしかない。ループを止めずに指示を追加する方法がない。
3. systemMessage フィールドの使用
Ralph は decision、reason に加えて systemMessage というフィールドを出力している。
{
"decision": "block",
"reason": "プロンプト",
"systemMessage": "🔄 Ralph iteration 2 | To stop: ..."
}
しかし、Claude Code の Stop Hook API ドキュメントには systemMessage フィールドの記載がない。decision と reason の 2 フィールドのみが公式仕様。動作しているように見えるが、将来の API 変更で壊れるリスクがある。
4. session_id の legacy 互換
session_id がない古い state ファイルを「素通り」させる設計。並列セッション環境では、legacy ファイルが全セッションに影響する可能性がある。
th-loop: 自律稼働型への拡張
これらの課題を踏まえて、異なる設計思想でループシステムを構築した。
設計思想の違い
| Ralph Loop | th-loop | |
|---|---|---|
| 思想 | タスク完了検出型 | 自律稼働型 |
| 核心 | Claude の出力を検査して promise を探す | 時間・回数・外部指示で制御 |
| 想定用途 | 特定タスクの完了まで回す | 長時間自律稼働、daemon 連携 |
| 停止の主体 | Claude 自身が宣言 | 外部条件が決定 |
5 層停止ガード
暴走防止のため、5 つの独立したガードを層状に配置した。
# Layer 1: 状態ファイルの存在
if [[ ! -f "$STATE_FILE" ]]; then
exit 0
fi
# Layer 2: status フラグ
if [[ "$STATUS" != "active" ]]; then
exit 0
fi
# Layer 3: セッション ID 一致
if [[ "$LOOP_SESSION_ID" != "$HOOK_SESSION_ID" ]]; then
exit 0
fi
# Layer 4: ハードリミット (100)
if [[ "$ITERATION" -ge "$HARD_LIMIT" ]]; then
rm -f "$STATE_FILE"
exit 0
fi
# Layer 5: 停止条件 (channel / max / duration)
# ...
いずれかの層で条件に引っかかれば、即座に exit 0(停止許可)。全層を通過した場合のみ decision: block を出力する。
ファイル通信チャンネル
Ralph にない最大の特徴が、非同期ファイル通信。
チャンネルファイル: temp/th-channel/YYYY-MM-DD.md
仕組み:
- ループ中、毎 iteration でチャンネルファイルの差分を読む
- 外部から "stop" と書けば即時停止
- メッセージを書けば、次の iteration で Claude に伝わる
- 日付ベースでログが残る
ループを止めずに方針を変えたり、追加の指示を送れる。サーバー上で daemon として動かす場合、これが重要になる。別のプロセスや別のセッションから、ファイルに書き込むだけで介入できる。
session_id: pending パターン
Ralph は setup スクリプトで CLAUDE_CODE_SESSION_ID 環境変数を読んで state ファイルに書き込む。th-loop は異なるアプローチを取る。
問題: Claude(コマンド実行者)は自分の session_id を知らない
解決: state ファイルに "pending" と書いておき、
Stop Hook が初回発火時に入力 JSON から session_id を取得して上書き
# state ファイル作成時
session_id: pending
# Stop Hook 初回発火時
if [[ "$LOOP_SESSION_ID" == "pending" ]] && [[ -n "$HOOK_SESSION_ID" ]]; then
sed "s/session_id: *'*pending'*/session_id: '${HOOK_SESSION_ID}'/" \
"$STATE_FILE" > "$TEMP_SID"
mv "$TEMP_SID" "$STATE_FILE"
fi
pending 必須化により、session_id フィールドがない state ファイルは一切 block しない。これで race condition(別セッションが先に ID を書き込んでしまう問題)を防ぐ。
duration(時間制御)
# state ファイルに unix epoch で開始時刻を記録
started: 1773809013
duration: 300 # 秒
# Stop Hook で経過時間を計算
NOW=$(date +%s)
ELAPSED=$((NOW - STARTED))
if [[ "$ELAPSED" -ge "$DURATION" ]]; then
rm -f "$STATE_FILE"
exit 0 # 時間切れ → 停止
fi
# 残り時間を reason に含める
STATUS_LINE="🔄 th-loop iteration $NEXT_ITERATION | 残り${REMAINING}秒"
Claude のコンテキストに「残り何秒」が表示されるので、AI 側も時間を意識した行動を取れる。
開発中に踏んだ罠
stop_hook_active の誤解
Claude Code の Stop Hook 入力には stop_hook_active というフィールドがある。ドキュメントには「無限ループ防止のためにチェックすべき」と書かれている。
当初これを実装したところ、1 回しか block できなくなった。
原因: Claude Code は block 後、即座に stop_hook_active: true でリトライする。このフィールドをチェックすると、block → リトライ → stop_hook_active: true → 許可、で 1 回で終わってしまう。
ループシステムでは stop_hook_active チェックを入れず、max や duration で暴走を防ぐのが正解。
二重登録
プラグインの hooks.json と settings.json の両方に同じ hook を登録していた。2 つの hook が同時に発火し、iteration が 1 回しか進まない現象が起きた。プラグインの hooks.json に一本化して解決。
systemMessage の不存在
Ralph Loop を参考に systemMessage フィールドを出力していたが、公式 API ドキュメントに記載がない。reason に統合することで、確実に Claude のコンテキストに届くようにした。
検証結果
| テスト | 結果 |
|---|---|
| max=2, 3 | 指定回数で正確に停止 |
| duration=20s | 4 iteration、カウントダウン正常 |
| duration=5m | 48 iteration、~4秒/iter で安定 |
| channel stop | ”stop” 書き込みで即時停止 |
| session_id 隔離 | 別セッションは素通り |
| Devil Loop | th-loop で th-loop 自身の Devil’s Advocate を実行(自己修正ループ) |
5 分間で 48 iteration、約 4 秒/iteration のペース。応答が短いほどサイクルは速くなる。理論上、1 時間で 700〜1000 iteration が可能。
比較まとめ
| 項目 | Ralph Loop | th-loop |
|---|---|---|
| 停止条件 | max + promise | max + duration + channel + hard limit |
| 時間制御 | なし | 秒単位 + カウントダウン |
| 外部通信 | なし | ファイル通信チャンネル |
| session 隔離 | setup 時に書き込み | pending → 初回自動確定 |
| legacy 互換 | 素通り(危険) | block しない(安全) |
| transcript 検査 | あり(JSONL パース) | なし |
| completion 検出 | <promise> タグ | なし |
| マルチエージェント | なし | 環境変数で状態分離 |
| systemMessage | 使用(非公式) | 不使用(reason に統合) |
| temp ファイル | PID ベース | mktemp(一意保証) |
| 暴走防止 | max のみ | 5 層ガード |
Ralph は「タスクを完了させるためのループ」、th-loop は「自律的に稼働し続けるためのループ」。どちらが優れているという話ではなく、用途が違う。
次のステップ
th-loop は現在、ローカル環境での検証を Phase 2 まで完了している。次の目標は:
- Phase 3: 30 分〜1 時間の長時間稼働テスト
- GCE 展開: サーバー上の Claude Code にプラグインとしてインストール
- completion promise の追加検討: Ralph のアプローチを取り入れて、両方の停止方式を持つハイブリッド化
ファイル通信チャンネルとセッション隔離が安定したことで、「AI が自律的に動き続け、必要なときに外部から介入できる」基盤ができた。
Stop Hook という小さな仕組みが、AI の自律性を大きく広げる可能性を持っている。