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 は decisionreason に加えて systemMessage というフィールドを出力している。

{
  "decision": "block",
  "reason": "プロンプト",
  "systemMessage": "🔄 Ralph iteration 2 | To stop: ..."
}

しかし、Claude Code の Stop Hook API ドキュメントには systemMessage フィールドの記載がない。decisionreason の 2 フィールドのみが公式仕様。動作しているように見えるが、将来の API 変更で壊れるリスクがある。

4. session_id の legacy 互換

session_id がない古い state ファイルを「素通り」させる設計。並列セッション環境では、legacy ファイルが全セッションに影響する可能性がある。


th-loop: 自律稼働型への拡張

これらの課題を踏まえて、異なる設計思想でループシステムを構築した。

設計思想の違い

Ralph Loopth-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 チェックを入れず、maxduration で暴走を防ぐのが正解。

二重登録

プラグインの hooks.jsonsettings.json の両方に同じ hook を登録していた。2 つの hook が同時に発火し、iteration が 1 回しか進まない現象が起きた。プラグインの hooks.json に一本化して解決。

systemMessage の不存在

Ralph Loop を参考に systemMessage フィールドを出力していたが、公式 API ドキュメントに記載がない。reason に統合することで、確実に Claude のコンテキストに届くようにした。


検証結果

テスト結果
max=2, 3指定回数で正確に停止
duration=20s4 iteration、カウントダウン正常
duration=5m48 iteration、~4秒/iter で安定
channel stop”stop” 書き込みで即時停止
session_id 隔離別セッションは素通り
Devil Loopth-loop で th-loop 自身の Devil’s Advocate を実行(自己修正ループ)

5 分間で 48 iteration、約 4 秒/iteration のペース。応答が短いほどサイクルは速くなる。理論上、1 時間で 700〜1000 iteration が可能。


比較まとめ

項目Ralph Loopth-loop
停止条件max + promisemax + 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 まで完了している。次の目標は:

  1. Phase 3: 30 分〜1 時間の長時間稼働テスト
  2. GCE 展開: サーバー上の Claude Code にプラグインとしてインストール
  3. completion promise の追加検討: Ralph のアプローチを取り入れて、両方の停止方式を持つハイブリッド化

ファイル通信チャンネルとセッション隔離が安定したことで、「AI が自律的に動き続け、必要なときに外部から介入できる」基盤ができた。


Stop Hook という小さな仕組みが、AI の自律性を大きく広げる可能性を持っている。