Claude Media
SubagentStart/SubagentStop hookでサブエージェントの起動と終了を監視する

SubagentStart/SubagentStop hookでサブエージェントの起動と終了を監視する

SubagentStart/SubagentStopはサブエージェントの起動・終了ごとに発火するフックです。matcherの絞り込み方と受け取るJSON、実装例をまとめます。

SubagentStartSubagentStopは、Claude Codeがサブエージェントを起動したときと終わらせたときに発火するフックイベントです。matcherにエージェント種別の名前を書くだけで、特定のサブエージェントだけをロギングしたり、終了前に出力を検証したりできます。フックイベント全体の一覧はHooks完全ガイドにまとめてあるので、この記事は両イベントが受け取るJSONの中身、matcherの書き方、settings.jsonとサブエージェントfrontmatterのどちらに定義するかの判断材料に絞ります。

SubagentStart/SubagentStopフックとは

サブエージェントそのものの仕組み(定義方法・並列実行・コンテキストの持ち方)はSub-agents完全ガイドに譲り、ここではフックとしての挙動だけを扱います。SubagentStartはClaudeがAgentツールでサブエージェントを生成したとき、停止したサブエージェントを再開したとき、そしてAgent Teamsのteammateが新しいメッセージを処理するたびに発火します。SubagentStopはサブエージェントが応答を終えたタイミングで発火し、値はSubagentStartと共通です。

両方ともmatcherでエージェント種別の名前を絞り込めます。組み込みエージェントならgeneral-purposeExplorePlan、カスタムサブエージェントならfrontmatterのnameフィールドの値がそのままagent_typeとして渡ってきます。プラグインが同梱するサブエージェントだけは例外で、my-plugin:reviewerのようにプラグイン名を含むスコープ付き識別子になります。

SubagentStartはサブエージェントの生成そのものを止められません。フックが用意されているのは生成をブロックするためではなく、生成された直後に文脈を注入するためです。一方SubagentStopStopフックと同じ決定モデルを使うため、終了を止めて処理を継続させられます。この違いはexit 2を返したときの挙動にそのまま表れます。

イベントブロック可否exit 2の効果stderrの届き先
SubagentStartブロック可否不可exit 2の効果生成は止まらず処理が続くstderrの届き先サブエージェント自身のトランスクリプトのみ(親会話には出ない)
SubagentStopブロック可否exit 2の効果終了を止め、reasonを次の指示としてサブエージェントに渡すstderrの届き先サブエージェントへの指示として届く

PreToolUse/PostToolUseとの役割分担

settings.jsonや管理ポリシー設定、プラグインで定義したPreToolUsePostToolUseは、メインの会話だけでなくサブエージェントの内側でも自動的に発火します。サブエージェントがツールを呼ぶたびに同じフックが動き、入力にはagent_idagent_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_activeagent_idagent_typeagent_transcript_pathlast_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_taskssession_cronsは親セッション側のスコープで渡され、サブエージェント自身が持つバックグラウンドタスクではありません。

effort(現在のeffortレベルを持つオブジェクト)もSubagentStopには渡りますが、SubagentStartのJSONには含まれません。ツール実行の文脈で発火するイベントだけに付く共通フィールドで、SubagentStartはサブエージェントがまだ何も実行していない生成直後のタイミングなので対象外です。SubagentStopのスクリプトで.effort.leveljqで読もうとして、SubagentStart側にも同じ処理を流用すると空値になります。

SubagentStopexit 2、またはdecision: "block"reasonを返すことでサブエージェントの終了を止められます。ブロックした場合、reason(またはstderr)がサブエージェントへの次の指示として渡り、処理が続きます。次の例は、サブエージェントの最終応答にテスト結果への言及がないときだけ終了を止めるスクリプトです。

#!/bin/bash
input=$(cat)
message=$(echo "$input" | jq -r '.last_assistant_message')
 
if [[ "$message" != *"tests"* && "$message" != *"テスト"* ]]; then
  echo "テスト結果への言及がありません。実行結果を含めて報告し直してください" >&2
  exit 2
fi
 
exit 0

サブエージェントが返した内容を親の会話側に持ち込みたい場合、SubagentStopでは実現できません。親会話へ渡すには、代わりにAgentツールに対するPostToolUseフックを使います。

settings.jsonとfrontmatterのどちらに書くか

SubagentStartSubagentStopsettings.json(プロジェクト単位・ユーザー単位)からも、サブエージェント自身のfrontmatterからも定義できますが、書ける内容と適用範囲が違います。

定義場所適用範囲書けるイベント向くケース
settings.json適用範囲セッション中に動く全サブエージェント書けるイベントSubagentStart / SubagentStopをそのまま定義向くケース特定のエージェント種別だけをmatcherで狙った共通ロギングや監査
サブエージェントfrontmatterのhooks適用範囲そのサブエージェントが動いている間だけ書けるイベントStop(実行時に自動でSubagentStopへ変換)向くケースそのサブエージェント固有の後処理(DB接続のクリーンアップなど)

frontmatterでStopと書くのは、そのファイルがサブエージェントとしても--agentでメインセッションとしても使われうるためです。サブエージェントとして呼ばれたときだけ、Claude Codeが実行時に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_idagent_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セッションでの実行は承認済み扱いになりません。設定自体は正しいのにhookが発火しない場合、まずこのtrust状態を疑います。

組み込みのExplorePlanはone-shotで、終了時にagent IDを返しません。agent_idを使って後から特定のサブエージェントにSendMessageで再開を送ったり、実行結果を紐付けたりする用途には使えないため、そうした運用が必要な場面ではgeneral-purposeかカスタムサブエージェントを選びます。

まとめ

SubagentStartはサブエージェントの生成に文脈を注入するためのフックで、生成自体はブロックできません。SubagentStopStopフックと同じ決定モデルを持ち、exit 2またはdecision: "block"で終了を止められます。どちらもagent_typematcherを絞り込めますが、ハイフンやコロンを含む名前はバージョンや評価経路によって挙動が変わるため、確実に狙ったサブエージェントだけに適用したいなら^$で固定しておくのが無難です。特定のサブエージェント固有の後処理はfrontmatterのhooksに、複数のサブエージェントを横断する監査や準備処理はsettings.jsonに、と定義場所を使い分けると設定が追いやすくなります。

この記事を共有:XはてブLinkedIn