SubagentStart/SubagentStop hookでサブエージェントの起動と終了を監視する
SubagentStart/SubagentStopはサブエージェントの起動・終了ごとに発火するフックです。matcherの絞り込み方と受け取るJSON、実装例をまとめます。
SubagentStartとSubagentStopは、Claude Codeがサブエージェントを起動したときと終わらせたときに発火するフックイベントです。matcherにエージェント種別の名前を書くだけで、特定のサブエージェントだけをロギングしたり、終了前に出力を検証したりできます。フックイベント全体の一覧はHooks完全ガイドにあります。ここでは両イベントが受け取るJSON、matcherの書き方、settings.jsonとサブエージェントfrontmatterのどちらに定義するかを扱います。
SubagentStart/SubagentStopフックとは
サブエージェントそのものの仕組み(定義方法・並列実行・コンテキストの持ち方)はSub-agents完全ガイドに譲り、ここではフックとしての挙動だけを扱います。SubagentStartはClaudeがAgentツールでサブエージェントを生成したとき、停止したサブエージェントを再開したとき、そしてAgent Teamsのteammateがメッセージを処理するたびに発火します。SubagentStopはサブエージェントが応答を終えたタイミングで発火し、値はSubagentStartと共通です。
両方ともmatcherでエージェント種別の名前を絞り込めます。組み込みエージェントならgeneral-purpose・Explore・Plan、カスタムサブエージェントならfrontmatterのnameフィールドの値がそのままagent_typeとして渡ってきます。プラグインが同梱するサブエージェントだけは例外で、my-plugin:reviewerのようにプラグイン名を含むスコープ付き識別子になります。
SubagentStartはサブエージェントの生成そのものを止められません。フックが用意されているのは生成をブロックするためではなく、生成された直後に文脈を注入するためです。一方SubagentStopはStopフックと同じ決定モデルを使うため、終了を止めて処理を継続させられます。この違いはexit 2を返したときの挙動にそのまま表れます。
`exit 2`を返したときの違い
SubagentStart
生成は止まらず、処理が続きます。stderrはサブエージェント自身のトランスクリプトにだけ残り、親の会話には出ません。
SubagentStop
終了が止まり、reason(またはstderr)が次の指示としてサブエージェントに渡ります。
PreToolUse/PostToolUseとの役割分担
settings.jsonや管理ポリシー設定、プラグインで定義したPreToolUse・PostToolUseは、メインの会話だけでなくサブエージェントの内側でも自動的に発火します。サブエージェントがツールを呼ぶたびに同じフックが動き、入力にはagent_idとagent_typeが含まれるため、どのサブエージェントの呼び出しかを見分けられます。
つまり、サブエージェントの活動を追う手段はすでにPreToolUse/PostToolUseにもあります。違いは発火の粒度です。PreToolUse/PostToolUseはサブエージェントがツールを呼ぶたびに何度も発火するのに対し、SubagentStart/SubagentStopは1回の起動につき開始と終了で1回ずつしか発火しません。ツール呼び出しの中身までは要らず、単に「いつ始まっていつ終わったか」という境界だけが欲しい場合はSubagentStart/SubagentStopのほうが軽く済みます。
SubagentStartが受け取るJSONとmatcher
SubagentStartは共通フィールドに加えて、サブエージェントを一意に識別するagent_idと、matcherが照合するagent_typeを受け取ります。
{
"session_id": "abc123",
"transcript_path": "/Users/.../.claude/projects/.../00893aaf-19fa-41d2-8238-13269b9b3ca0.jsonl",
"cwd": "/Users/...",
"hook_event_name": "SubagentStart",
"agent_id": "agent-abc123",
"agent_type": "Explore"
}複数のエージェント種別をまとめて狙いたいときは、"general-purpose|Explore"や"general-purpose, Explore"のように|または,で列挙できます(カンマ区切りはClaude Code v2.1.191以降)。
matcherにコロンを含むプラグインスコープの名前を書くときは注意が必要です。コロンが含まれる文字列は正規表現として評価されるため、my-plugin:reviewerとだけ書くと部分一致してしまいます。特定のサブエージェントだけに絞るなら^my-plugin:reviewer$のように前後を固定します。
SubagentStartの主な用途はadditionalContextによるコンテキスト注入です。返した文字列はサブエージェントの最初のプロンプトより前、会話の冒頭に挿入されます。
{
"hookSpecificOutput": {
"hookEventName": "SubagentStart",
"additionalContext": "Follow security guidelines for this task"
}
}同じサブエージェントに対してフックが再び実行された場合、Claude Codeはすでに同じ内容が注入済みかどうかを確認し、重複しているときは挿入しません。起動時に注入した分はそのまま残り続けるため、サブエージェントのプロンプトキャッシュが壊れません。オートコンパクションでそのコピーが会話から消えた場合だけ、次の実行で再び注入されます。
SubagentStopが受け取るJSONとmatcher
SubagentStopは共通フィールドに加えてstop_hook_active・agent_id・agent_type・agent_transcript_path・last_assistant_messageを受け取ります。transcript_pathは親セッションのトランスクリプトを指し、サブエージェント自身の記録はsubagents/フォルダ配下のagent_transcript_pathに別ファイルとして存在します。
{
"session_id": "abc123",
"transcript_path": "~/.claude/projects/.../abc123.jsonl",
"cwd": "/Users/...",
"permission_mode": "default",
"hook_event_name": "SubagentStop",
"stop_hook_active": false,
"agent_id": "def456",
"agent_type": "Explore",
"agent_transcript_path": "~/.claude/projects/.../abc123/subagents/agent-def456.jsonl",
"last_assistant_message": "Analysis complete. Found 3 potential issues...",
"background_tasks": [],
"session_crons": []
}transcript_pathは非同期に書き込まれるため、フック発火時点で直近のやり取りをまだ含んでいないことがあります。サブエージェントの最終応答を確実に読みたいときは、トランスクリプトをパースせずlast_assistant_messageをそのまま使います。background_tasksとsession_cronsは親セッション側のスコープで渡され、サブエージェント自身が持つバックグラウンドタスクではありません。
共通フィールドのeffort(現在のeffortレベルを持つオブジェクト)は、ツール使用の文脈で発火するイベントに付きます。公式の一覧にはPreToolUse・PostToolUse・Stop・SubagentStopが挙がっており、SubagentStartの入力例には含まれていません。SubagentStopのスクリプトで.effort.levelをjqで読む処理をSubagentStartに流用すると、空値になる可能性があります。
SubagentStopは、Claudeが生成したサブエージェント以外でも発火します。プロンプトの候補表示や/btwの脇道の質問といった、Claude Code内部のエージェントが終わったときにも届きます。このときagent_typeには、そのセッション自身が動いているエージェント名(--agentやagent設定で指定したもの)が入り、指定が無ければ空文字列です。種別名でログを集計するなら、空のagent_typeを別枠で扱う前提で組むと、内部エージェントの分が混ざっても崩れません。
SubagentStopのmatcherにも同じ注意が要ります。特定の名前を指定したフックは、空のagent_typeには一致しません。matcherを省略するか""・"*"にすると、空の場合も含めて発火します。
v2.1.271以降、SubagentHandbackツールを使うサブエージェントでは、last_assistant_messageが報告本文ではなく締めの文になります。報告本文はtool_input.messageに入るため、このツールを使う構成でログを取るなら、そちらを読む処理が要ります。
SubagentStopはexit 2、またはdecision: "block"とreasonを返すことでサブエージェントの終了を止められます。
次の例は、サブエージェントの最終応答にテスト結果への言及がないときだけ終了を止めるスクリプトです。
#!/bin/bash
# ./scripts/check-tests-mentioned.sh
input=$(cat)
message=$(echo "$input" | jq -r '.last_assistant_message')
if [[ "$message" != *"tests"* && "$message" != *"テスト"* ]]; then
echo "テスト結果への言及がありません。実行結果を含めて報告し直してください" >&2
exit 2
fi
exit 0最終応答にtestsもテストも含まない例と、含む例をサンプルJSONで流すと、jqが使えるシェルでは終了コードが次のようになります。
$ echo '{"last_assistant_message":"Done editing."}' | bash ./scripts/check-tests-mentioned.sh; echo "exit=$?"
テスト結果への言及がありません。実行結果を含めて報告し直してください
exit=2
$ echo '{"last_assistant_message":"All tests pass."}' | bash ./scripts/check-tests-mentioned.sh; echo "exit=$?"
exit=0サブエージェントが返した内容を親の会話側に持ち込みたい場合、SubagentStopでは実現できません。親会話へ渡すには、代わりにAgentツールに対するPostToolUseフックを使います。
decision: "block"のほかに、hookSpecificOutputのadditionalContextでも継続させられます(hookEventNameは"SubagentStop")。こちらはエラーではないフィードバックとして返せます。ハンドラの種類も違い、SubagentStartがcommand・http・mcp_toolだけなのに対し、SubagentStopではprompt型やagent型のフックも使えます。
settings.jsonとfrontmatterのどちらに書くか
SubagentStartとSubagentStopはsettings.json(プロジェクト単位・ユーザー単位)からも、サブエージェント自身のfrontmatterからも定義できますが、よく使う内容と適用範囲が違います。
定義場所で変わる適用範囲
settings.json
セッション中に動く全サブエージェントが対象で、SubagentStartとSubagentStopをそのまま書けます。特定の種別だけをmatcherで狙った共通ロギングや監査に向きます。
frontmatterのhooks
そのサブエージェントが動いている間だけ有効で、PreToolUse・PostToolUse・Stopなどを書けます。このうちStopは実行時にSubagentStopへ変換されます。DB接続のクリーンアップなど、固有の後処理に向きます。
frontmatterのフックは、Agentツールや@メンションでサブエージェントとして呼ばれたときだけでなく、--agentやagent設定でメインセッションとして動くときも発火します。このときStopは、サブエージェントとして動く場合にだけSubagentStopへ読み替えられます。
settings.json側では、特定のサブエージェントが起動したときだけ準備処理を、どのサブエージェントが終了しても後片付けを走らせる、という組み合わせも書けます。
{
"hooks": {
"SubagentStart": [
{
"matcher": "db-agent",
"hooks": [
{ "type": "command", "command": "./scripts/setup-db-connection.sh" }
]
}
],
"SubagentStop": [
{
"hooks": [
{ "type": "command", "command": "./scripts/cleanup-db-connection.sh" }
]
}
]
}
}実践例 — 起動と終了を1つのログに残す
エージェント種別を問わず全サブエージェントの稼働時間を追いたい場合は、matcherを省略(または"*")して両方のイベントに同じログ用スクリプトを紐付けます。SubagentStart側はagent_idとagent_typeを、SubagentStop側は同じagent_idに加えて終了理由の手がかりになるlast_assistant_messageの冒頭部分を1行で追記します。
{
"hooks": {
"SubagentStart": [
{
"hooks": [
{ "type": "command", "command": "./scripts/log-subagent-start.sh" }
]
}
],
"SubagentStop": [
{
"hooks": [
{ "type": "command", "command": "./scripts/log-subagent-stop.sh" }
]
}
]
}
}#!/bin/bash
# ./scripts/log-subagent-stop.sh
input=$(cat)
agent_id=$(echo "$input" | jq -r '.agent_id')
agent_type=$(echo "$input" | jq -r '.agent_type')
summary=$(echo "$input" | jq -r '.last_assistant_message' | head -c 80)
echo "$(date -Iseconds) STOP $agent_type $agent_id ${summary}..." >> ~/.claude/subagent-activity.log
exit 0コマンドフックの代わりにtype: "http"を使うと、JSONをPOSTリクエストのボディとして送信し、結果もコマンドフックと同じJSON形式でレスポンスボディから受け取れます。空のボディで2xxのステータスコードを返せば成功として扱われます。ハンドラにonce: trueを付けて初回実行後に自動で外したくなる場面もありますが、この設定が効くのはskillのfrontmatterに書いたフックだけです。settings.jsonやサブエージェントのfrontmatterに書いたonceは無視され、毎回発火し続けます。
よくあるつまずき
db-agentのようにハイフンを含むmatcherは、Claude Code v2.1.195以降でだけ完全一致として評価されます。それより前のバージョンでは正規表現として扱われるため、prod-db-agentのような無関係なエージェント名にも誤ってマッチします。バージョンをまたいで使う設定は^db-agent$のように前後を固定しておくと安全です。
プロジェクトのサブエージェントfrontmatterに書いたhooksは、そのエージェントファイルが置かれたフォルダのworkspace trustダイアログを承認するまで動きません。親フォルダを信頼しても足りず、-pセッションも承認済み扱いになりません。~/.claude/agents/のユーザー単位の定義や--agentsで渡した定義は、この手順なしで動きます。v2.1.218より前は信頼前でも動いていたため、バージョンをまたぐと挙動が変わります。
組み込みのExploreとPlanはone-shotで、終了時にagent IDを返しません。agent_idを使って後から特定のサブエージェントにSendMessageで再開を送ったり、実行結果を紐付けたりする用途には使えないため、そうした運用が必要な場面ではgeneral-purposeかカスタムサブエージェントを選びます。
フックそのものが動いているかを切り分けるときは、claude --helpにある2つのフラグが手がかりになります。--include-hook-eventsは、フックのライフサイクルイベントを出力ストリームに含めます(--output-format=stream-jsonのときのみ有効)。逆に--bareは最小モードで、settingsやインストール済みプラグインで定義したフックが自動では読み込まれません。
まとめ
ログだけならどちらのイベントでも足りますが、サブエージェントの出力に条件を付けて差し戻したいならSubagentStopの出番です。種別を絞るmatcherは、ハイフンやコロンを含む名前を^と$で固定しておくと、バージョンが変わっても狙いがずれません。
参照した一次ソース
この記事の記述は、次の一次ソースと照合しています。更新時に参照先が変わった場合は一覧も更新します。