はじめに / 対象と前提
Claude Code でサブエージェント(Task ツールで起動する子エージェント)を使い始めると、「並列で調べ物をさせたサブエージェントが、いつ終わったのか検知して後処理をしたい」という場面が出てくる。ログを1箇所に集約したい、終わったら Slack に通知したい、成果物のバリデーションを自動で挟みたい、といったケースだ。
この記事は以下を前提にしている。
- Claude Code v2.0 系(hooks 機能・サブエージェント機能が安定して使える版)
-
.claude/settings.jsonでの hooks 設定の基本(PreToolUse/PostToolUseくらいは触ったことがある)を前提知識とする - OS は macOS、シェルは bash/zsh 想定(Windows は別途パス調整が必要)
対象読者は「hooks はいくつか使ったことがあるが SubagentStop は触っていない」エンジニア。
TL;DR
-
SubagentStophook は サブエージェント(Task ツールで起動した子セッション)が終了するたびに 発火する。親セッション終了時に発火するStopとは別物。 - matcher を持たないため、どのサブエージェントが終わったか は stdin の JSON から自分でパースするしかない。
- 同期処理を hook に書くと サブエージェントの完了通知自体が遅延する ため、重い後処理は非同期に逃がすのが定石。
手順 / 動かし方
1. hooks を設定する
.claude/settings.json に以下を追加する。
{
"hooks": {
"SubagentStop": [
{
"hooks": [
{
"type": "command",
"command": "$CLAUDE_PROJECT_DIR/.claude/hooks/on-subagent-stop.sh"
}
]
}
]
}
}
PreToolUse/PostToolUse と違って SubagentStop には matcher を書く欄が実質意味を持たない(全サブエージェント終了で無条件発火する)。フィルタは自分のスクリプト内で行う。
2. stdin の JSON を受け取る
hook スクリプトには stdin 経由で JSON が渡される。中身を確認するには一度素通しして丸ごとログに落とすのが早い。
#!/usr/bin/env bash
set -euo pipefail
INPUT=$(cat)
echo "$INPUT" >> "$CLAUDE_PROJECT_DIR/.claude/logs/subagent-stop-raw.jsonl"
SESSION_ID=$(echo "$INPUT" | python3 -c "import json,sys; print(json.load(sys.stdin).get('session_id',''))")
TRANSCRIPT_PATH=$(echo "$INPUT" | python3 -c "import json,sys; print(json.load(sys.stdin).get('transcript_path',''))")
echo "[subagent-stop] session=$SESSION_ID transcript=$TRANSCRIPT_PATH" >&2
transcript_path を辿るとサブエージェントの発言ログ(JSONL)が読めるので、そこから「どのタスクを何件処理したか」を後付けで抽出できる。ここが Stop hook と同じ構造になっている点は覚えておくと楽。
3. 後処理を非同期化する
集計・通知処理をここに直接書くと、サブエージェントの完了レスポンスがその分だけ止まる。実運用では & でバックグラウンドに逃がし、hook 自体は即座に exit 0 で返す。
(
python3 "$CLAUDE_PROJECT_DIR/.claude/hooks/aggregate_subagent_log.py" "$TRANSCRIPT_PATH" \
>> "$CLAUDE_PROJECT_DIR/.claude/logs/subagent-aggregate.log" 2>&1
) &
disown
exit 0
ハマりどころ
1. 「全サブエージェント終了」で発火するため、意図しない多重発火が起きる
並列で 5 つサブエージェントを起動すると SubagentStop は 5 回発火する。1 回だけ集約処理を回したいケースでは、session_id をキーにファイルロック(flock やロックファイル作成)を挟まないと処理が重複する。自分は最初これに気づかず、Slack 通知が同じ内容で 5 通飛んで気まずい思いをした。
2. 親セッションの Stop hook と混同する
Stop は親(ユーザーとやり取りするメインセッション)が応答を終えたときに発火し、SubagentStop は Task ツールで起動された子セッションが終わったときに発火する。両方登録していると、ネストしたサブエージェント構成では発火順が直感と違うことがある(子が先、親が後、が基本だが、孫サブエージェントがいるとさらに深くなる)。デバッグ時は session_id と transcript_path を必ずログに落として、どの階層のイベントかを可視化するのが結局一番早い。
3. hook 内で無限ループになる設定ミス
SubagentStop hook の中でさらに Task ツールを起動するような自動化(「終わったら次のサブエージェントを呼ぶ」)を組むと、そのサブエージェントの終了がまた SubagentStop を発火させ、条件分岐を誤ると終わらないループになる。exit code や環境変数でトリガー元を判定し、hook 経由で起動したサブエージェントには目印(例: 環境変数 SUBAGENT_STOP_TRIGGERED=1)を付けて自己参照を止めるガードを必ず入れる。
背景・補足
SubagentStop は PreToolUse/PostToolUse のようにツール呼び出し単位ではなく、セッションのライフサイクルに紐づくイベントである。stdin の JSON スキーマはバージョンによって微妙にフィールドが増減することがあるため、本番運用に組み込む前に cat >> raw.jsonl で一度そのまま記録し、実際に使うフィールドが安定しているか確認してから本処理を書き始めるのが安全策になる。
まとめ
-
SubagentStopは matcher なしで全サブエージェント終了時に発火する、Stopとは別イベント - stdin の
session_id/transcript_pathを手がかりにログを追跡できる - 重い後処理は非同期化しないとサブエージェントの応答が遅延する
- 多重発火・親子混同・自己参照ループの3点は事前にガードを入れておく