📡 エージェント呼び出しを全記録する ― Stop hook × transcript_path で使用頻度ログを自動構築 — リーダー×
📡

エージェント呼び出しを全記録する ― Stop hook × transcript_path で使用頻度ログを自動構築

#claudecode#automation#shell2026-07-25 · 約11

前回、ゾンビAgentを自動処分する話を書きました。今回はその一歩手前の話です ―― どのAgentが、いつ、何回呼ばれたかを全部記録する仕組みを作りました。

3ヶ月運用した結果、ログファイルは682件・225KBまで育ちました。実測では、24.9MB・5799行という手元最大級のtranscriptを渡しても0.433秒、普段のセッション規模(223KB・91行)なら0.218秒でログ追記が終わります。そしてこのログを直近7日で集計すると、~/.claude/agents/に定義済みのカスタムAgent8個中8個(100%)が0回呼び出しという結果が出ました。これがこの記事の核心です ―― 定義しただけで一度も使われていないAgentは、記録しない限り気づけません。

困りごと:Agentを何個も定義したが、使われているか分からない

architect code-reviewer database-reviewer planner python-reviewer security-reviewer typescript-reviewer ―― こういうサブエージェントをタスクごとに増やしてきましたが、実際にどれが呼ばれているかを見る手段がありませんでした。Claude Code自体はAgent呼び出しの結果をtranscriptには残しますが、集計はしてくれません。

このままだと、「definitionはあるが誰も呼ばない」ゾンビ定義が静かに増えていきます。前回のゾンビAgent処分は「暴走した実行」を刈る話でしたが、今回は逆に「そもそも実行されていない定義」を可視化する話です。そのためにまず、呼び出しを1件も漏らさず記録するログ基盤を作りました。

仕組み:Stop hookのpayloadにあるtranscript_path

Claude CodeのStop hookには、標準入力でこういうJSONが渡ってきます。

{"session_id":"...","transcript_path":"...","hook_event_name":"Stop",...}

transcript_pathはそのセッションの会話ログ(jsonl、1行1レコード)へのフルパスです。この中にtool_usename="Agent")と、それに対応するtool_resultが両方入っています。

Claude Codeのtranscript上では、Agentツール(Task機能)はname="Task"ではなく**name="Agent"として記録されます**。subagent_typeinput.subagent_typeの中です。ここを勘違いすると、フィルタ条件がずっと引っかからず、ログが永遠に空のまま気づけません。

実装:stop_agent_tracker.sh

~/.claude/hooks/stop_agent_tracker.shが本体です。bashでstdinを受けてPython3に渡す構成で、出力は~/.claude/logs/agent-invocations.jsonlに1行ずつ追記されます。

重複防止:session_id × tool_use_idでスキップ

Stop hookは1ターン終わるたびに毎回発火し、そのたびに同じtranscriptをまるごと再走査します。つまり何も対策しなければ、セッション中に5回Stopが起きれば同じAgent呼び出しが5回記録されてしまいます。これを防ぐため、書き込み前に既存ログを読んで「このセッションで既に記録済みのtool_use_id」を集合として持っておきます。

# 重複防止: このセッションで既に記録済みの tool_use_id を読み込んでスキップ
seen_ids = set()
out_path = os.environ["OUT_LOG_PATH"]
if os.path.exists(out_path):
    try:
        with open(out_path, "r", encoding="utf-8", errors="replace") as f:
            for line in f:
                try:
                    r = json.loads(line)
                    if r.get("session_id") == sid and r.get("tool_use_id"):
                        seen_ids.add(r["tool_use_id"])
                except Exception:
                    continue
    except Exception as e:
        log(f"read_existing_error: {e}")

session_idも条件に入れているのがポイントです。tool_use_idだけで判定すると、別セッションの記録まで巻き込んでスキップ漏れ・過剰スキップの両方が起こり得ます。

2パスでtool_useとtool_resultを突き合わせる

transcriptは1行1メッセージなので、tool_useと対応するtool_resultは別々の行に離れて出てきます。なのでまず全体を1回舐めて、両方を辞書にインデックス化します。

uses = {}   # id -> (ts, name, input, caller)
results = {} # tool_use_id -> (ts, is_error)

with open(tp, "r", encoding="utf-8", errors="replace") as f:
    for line in f:
        rec = json.loads(line)
        ts = rec.get("timestamp")
        content = rec.get("message", {}).get("content")
        if not isinstance(content, list):
            continue
        for b in content:
            btype = b.get("type")
            if btype == "tool_use" and b.get("name") == "Agent":
                inp = b.get("input") or {}
                if "subagent_type" not in inp:
                    continue
                uid = b.get("id")
                if uid:
                    uses[uid] = (ts, b.get("name"), inp, b.get("caller"))
            elif btype == "tool_result":
                rid = b.get("tool_use_id")
                if rid:
                    results[rid] = (ts, bool(b.get("is_error")))

duration_ms:tool_useとtool_resultのタイムスタンプ差分

呼び出し側と結果側のtimestampをISO8601としてパースし、差分をミリ秒に変換します。

def parse_ts(s):
    if not s:
        return None
    try:
        return datetime.datetime.fromisoformat(s.replace("Z", "+00:00"))
    except Exception:
        return None

duration_ms = None
t0 = parse_ts(use_ts)
t1 = parse_ts(res_ts)
if t0 and t1:
    duration_ms = int((t1 - t0).total_seconds() * 1000)

対応するtool_resultがまだtranscriptに現れていない場合はstatus: "pending"としてduration_ms: nullのまま書き出します。実際のログ682件を見る限りpendingのまま残った記録は0件でした ―― Stop hookが発火する時点では、直前に投げたAgent呼び出しの結果は基本的にもうtranscriptに書き終わっている、ということです。

最終的な1レコードはこの形です。

{"ts": "2026-08-30T08:55:08.351Z", "session_id": "36b40280-...", "cwd": "~/dev/note-autolike", "tool_use_id": "toolu_01RjC237NX1QwsWzVUMqHbvY", "subagent_type": "Explore", "description": "Survey note paid-article infra", "duration_ms": 236, "status": "ok", "caller": {"type": "direct"}}

hookチェーンへの組み込み

このスクリプト単体はStop hookに直接登録されているわけではなく、~/.local/bin/stop_hooks_combined.shという束ねスクリプトの中から呼ばれます。

for hook in \
  "$HOME/.claude/hooks/stop_notify.sh" \
  "$HOME/.claude/hooks/stop_cost_log.sh" \
  "$HOME/.claude/hooks/stop_agent_tracker.sh" \
  "$HOME/.claude/hooks/stop_session_summary.sh" \
  "$HOME/.discord/stop_post_session.sh"
do
  [ -x "$hook" ] && "$hook" < "$PAYLOAD" || true
done

payloadは一度mktempで一時ファイルに落とし、各hookに< "$PAYLOAD"で使い回します。ここにも実運用で踏んだ罠がコメントで残っていました。

PAYLOAD="$(mktemp "${TMPDIR:-/tmp}/stop-hook.XXXXXX.json" 2>/dev/null)" || PAYLOAD=""
# mktemp 失敗(壊れた TMPDIR 等)時は /tmp に退避。空 PAYLOAD のまま続行すると
# 後段の < "$PAYLOAD" が空文字リダイレクトになり全フックが黙って no-op になる
if [ -z "$PAYLOAD" ]; then
  PAYLOAD="/tmp/stop-hook.$$.$RANDOM.json"
fi

mktempが失敗してPAYLOADが空文字のままだと、< "$PAYLOAD"は「空文字という名前のファイルを開く」のではなく暗黙のリダイレクトエラーになり、チェーン全体が黙って何もしないまま終わる。この記事のログ収集も含めて全hookが道連れで死ぬ、という一番怖いタイプの障害です。

使い方:agent-usage-summary.shでウィンドウ集計

ログを溜めるだけでは意味がないので、~/.claude/scripts/agent-usage-summary.shで集計します。

agent-usage-summary.sh           # デフォルト 7d
agent-usage-summary.sh 30d       # 30日
agent-usage-summary.sh 7d 30d    # 両方まとめて

肝は「呼ばれた回数」だけでなく、呼ばれなかったAgentを出す部分です。

known_agents = set()
if os.path.isdir(agents_dir):
    for fp in glob.glob(os.path.join(agents_dir, "*.md")):
        known_agents.add(os.path.splitext(os.path.basename(fp))[0])
...
used = set(counts.keys())
unused = sorted(known_agents - used)
print(f"\n0-call agents (defined locally but not used in {w}): {len(unused)}")

手元の~/.claude/agents/には.mdが8個あり(architect code-reviewer database-reviewer planner python-reviewer security-reviewer typescript-reviewer、それに索引ファイルのINDEX)、実際に7日ウィンドウで回すとこう出ます。

=== Agent usage (last 7d) ===
total invocations: 3  unique types: 2

Top 10:
  agent                                     calls  errors
  general-purpose                               2       0
  Explore                                       1       0

0-call agents (defined locally but not used in 7d): 8
  - INDEX
  - architect
  - code-reviewer
  - database-reviewer
  - planner
  - python-reviewer
  - security-reviewer
  - typescript-reviewer

直近7日に呼ばれたのは組み込みのgeneral-purposeExploreだけで、自分で定義したカスタムAgent8個は全部0回。30日ウィンドウに広げるとさすがにcode-reviewerが1回入り、0-callは7個に減ります。

=== Agent usage (last 30d) ===
total invocations: 23  unique types: 3

Top 10:
  agent                                     calls  errors
  Explore                                      19       0
  general-purpose                               3       0
  code-reviewer                                 1       0

これは~/.claude/scripts/dashboard.shの日次生成にもそのまま流し込んでいて、Top10部分だけをawkで抜き出してダッシュボードの「🤖 Agent 呼び出し (7d)」欄に埋めています。

AGENT_OUT=$(~/.claude/scripts/agent-usage-summary.sh 7d 2>/dev/null)
TOP_BLOCK=$(echo "$AGENT_OUT" | awk '
  /^Top 10:/ { in_block=1; next }
  /^$/ && in_block { exit }
  in_block { print }
' | head -5)

毎朝これを見て、「専門Agentを増やしたのに全然呼ばれていない」を数字で突きつけられる状態にしました。

踏んだ落とし穴

  • ツール名がTaskだと思い込むと一生ヒットしない → transcript上の実名はAgentinput.subagent_typeで判別)
  • 重複防止はtool_use_id単体では不十分session_idとのペアで判定しないと別セッションの記録を巻き込む
  • Stop hookは1ターンごとに毎回発火し、transcriptを毎回まるごと再走査する → 対策なしだと同一呼び出しが複数回記録される。既存ログとの突き合わせが前提の設計
  • known_agentsagents/*.mdを単純globしているだけINDEX.mdのような索引ファイルまで「0-call agent」としてカウントされる。読むときは頭の中で1個引く
  • mktempが壊れたTMPDIRで失敗するとPAYLOADが空文字になり、< "$PAYLOAD"のリダイレクトが暗黙に壊れて後続hookが全部no-opになるstop_hooks_combined.shは失敗時に/tmpへのフォールバックパスを明示的に組んで防いでいる
  • callerフィールドは今のところ手元の682件全てが{"type":"direct"} → サブエージェントがさらにサブエージェントを呼ぶ多段呼び出しは、少なくともこのログ期間では発生していない

まとめ

  • Stop hookのtranscript_pathからtool_use(name="Agent")とtool_resultを突き合わせ、duration_ms付きでJSONLに記録する
  • 重複防止は**session_id × tool_use_id**。Stop hookが毎回transcript全体を再走査する前提だと必須のロジック
  • 集計スクリプトは「使われた回数」より**「定義したのに0回」の一覧の方が価値がある。手元ではカスタムAgent8個中8個が直近7日で0回**という結果が出た
  • 実測では24.9MB・5799行のtranscriptでも0.433秒でログ追記が終わり、hookとして常時噛ませても体感コストはない
  • ログ基盤を作ってはじめて、「Agentを何個も定義した」という主観と「実際に何が使われているか」という実測がズレていることが分かった

次は、この「0-call」リストを実際にどう刈るか ―― 定義を消す・統合する・description見直しで復活させる、の判断をどう機械的に補助するかを書く予定です。


Lily@bokuwalily)― 個人開発者。Claude Code で自動化基盤を組みながら、iOSアプリやWebサービスを量産しています

皆さんの ❤️ やシェアが励みになります!