「Claude Code環境」シリーズの前作 ゾンビエージェントを自動で刈る話 では定義されているのに一度も使われないエージェントを発見する話を書きました。今回はその数字の出どころ ―― Stop hook のペイロードに含まれる transcript_path を Python で読んで、エージェント呼び出しを自動的に JSONL に積み上げる仕組み の話です。
いま ~/.claude/logs/agent-invocations.jsonl には 553件 の呼び出し記録が溜まっています。直近7日の集計では general-purpose が58回・Explore が24回。そして 47個のエージェントが一度も呼ばれていない という事実も、この仕組みなしには見えませんでした。
困りごと:「このエージェント、本当に使ってる?」が分からない
~/.claude/agents/ 以下にエージェント定義が増えていくと、自分で書いておきながら「これ最後にいつ使った?」が分からなくなります。使われていない定義はコンテキスト注入を増やすだけで有害です。
Claude Code にはセッション終了時に Stop hook を呼び出す仕組みがあり、そのペイロードに transcript_path が含まれています。これが会話全体のログファイルへのパスです。ここを読めば「どのエージェントを、いつ、何秒かけて呼んだか」が全部取れます。
全体の流れ
セッション終了 → Stop hook 発火 → stop_hooks_combined.sh がペイロードを受け取る → stop_agent_tracker.sh へ渡す → Python が transcript を読んで JSONL に追記。
実際の ~/.local/bin/stop_hooks_combined.sh の配線はこうなっています。
# stdin → tmpfile に保存して複数 hook へ順次渡す
cat > "$PAYLOAD"
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
Stop hook は stdin でペイロードを受け取ります。複数 hook に流すため tmpfile を経由し、各フックへ < "$PAYLOAD" でリダイレクトしています。
stop_agent_tracker.sh の実装
スクリプト全体は bash のラッパーと Python インライン実行の2層になっています。
#!/usr/bin/env bash
set -uo pipefail
LOG_DIR="$HOME/.claude/logs"
OUT_LOG="$LOG_DIR/agent-invocations.jsonl"
INPUT=$(cat)
export STOP_INPUT="$INPUT"
export OUT_LOG_PATH="$OUT_LOG"
python3 - <<'PY'
# ...
PY
bash 側は stdin を受け取って環境変数にセットするだけ。処理は Python に任せます。
第1パス:transcript を全走査してインデックス化
uses = {} # tool_use_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)
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")
uses[uid] = (rec.get("timestamp"), b.get("name"), inp, b.get("caller"))
elif btype == "tool_result":
rid = b.get("tool_use_id")
if rid:
results[rid] = (rec.get("timestamp"), bool(b.get("is_error")))
ポイントは name == "Agent" で絞ること。Claude Code の transcript では Task ツールも name: "Agent" として記録されます。subagent_type が input に入っているものだけが対象で、素の claude 呼び出しとは区別されます。
重複防止:session_id × tool_use_id で既記録をスキップ
Stop hook は同一セッションで複数回発火することがあります(/clear や長いセッション)。重複なしに記録するため、書き込み前に既存ログを舐めます。
seen_ids = set()
if os.path.exists(out_path):
with open(out_path, "r", encoding="utf-8", errors="replace") as f:
for line in f:
r = json.loads(line)
# 同一セッション内の同一 tool_use_id だけをスキップ
if r.get("session_id") == sid and r.get("tool_use_id"):
seen_ids.add(r["tool_use_id"])
session_id と tool_use_id の組み合わせが一意性の鍵です。tool_use_id だけで弾くと、異なるセッションで偶然 ID が衝突した場合に記録漏れが起きます。
duration_ms の算出
tool_use レコードのタイムスタンプと、対応する tool_result のタイムスタンプの差分がエージェントの実行時間です。
def parse_ts(s):
if not s:
return None
return datetime.datetime.fromisoformat(s.replace("Z", "+00:00"))
t0 = parse_ts(use_ts)
t1 = parse_ts(res_ts)
if t0 and t1:
duration_ms = int((t1 - t0).total_seconds() * 1000)
tool_result がまだ来ていない(セッション中断など)場合は status: "pending" として duration_ms: null で記録します。
出力レコードの形式
{
"ts": "2026-05-28T16:27:41.766Z",
"session_id": "sess_xxx",
"cwd": "~",
"tool_use_id": "toolu_014MMSdJubC215oLCxfcrjok",
"subagent_type": "general-purpose",
"description": "launchd + cron 総監査",
"duration_ms": 177,
"status": "ok",
"caller": {"type": "direct"}
}
description は 300文字でクリップしています。transcript 上の description フィールドは自由記述なのでたまに長大になります。
agent-usage-summary.sh で集計する
溜まった JSONL を集計するのが ~/.claude/scripts/agent-usage-summary.sh です。
agent-usage-summary.sh # デフォルト 7d
agent-usage-summary.sh 30d # 30日
agent-usage-summary.sh 7d 30d # 両ウィンドウ同時
実行するとこう出ます(今日の実測値)。
=== Agent usage (last 7d) ===
total invocations: 86 unique types: 4
Top 10:
agent calls errors
general-purpose 58 0
Explore 24 0
fork 2 0
reviewer 2 0
0-call agents (defined locally but not used in 7d): 47
- INDEX
- a11y-architect
- architect
- build-error-resolver
- code-architect
...
general-purpose が58回・Explore が24回。この2種で7日の呼び出しの95%を占めています。そして 47個のエージェントが一度も呼ばれていない。これがゾンビエージェント刈りの入力データです。
~/.claude/scripts/dashboard.sh はこの出力を毎日 dashboard.md に組み込んで常時可視化しています。
echo "## 🤖 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)
echo "$TOP_BLOCK"
~/.claude/agents/ に *.md として定義されているエージェントを「既知エージェント」として扱い、ログに出現しないものを「0-call」として列挙します。INDEX.md などの非エージェントファイルも混入するため、実運用では INDEX が毎回 0-call 欄の先頭に出ます。気になる場合は known_agents の取得前に .startswith("INDEX") などで除外してください。
dashboard.sh でのダッシュボード統合
dashboard.sh は health、auto-skills 数、launchd ジョブ一覧などと合わせて agent 集計を ~/.claude/dashboard.md に書き出します。cron で daily 更新することで「今週どのエージェントが重宝されているか」が毎朝確認できます。
踏んだ落とし穴
-
Stop hook の stdin は一度しか読めない →
stop_hooks_combined.shが tmpfile を作ってから各フックへリダイレクトする設計にした。最初は各フックでcatしようとして2番目以降が空になった。 -
mktempが TMPDIR 壊れで失敗する →stop_hooks_combined.shで|| PAYLOAD="/tmp/stop-hook.$$.$RANDOM.json"にフォールバックした。空パスのままリダイレクトすると全 hook が黙って no-op になる。 -
tool_use_id だけで重複排除すると異セッション衝突でレコードが消える →
session_id × tool_use_idのペアで判定するように直した。 -
name == "Task"で検索して何も取れない → Claude Code の transcript では Task ツールがname: "Agent"として記録される。ドキュメントに記載がなく、実ファイルを grep して判明。 -
description が数千文字になることがある → 300文字でクリップしないと JSONL が膨れて後の集計で
json.loadsが遅くなった。 -
tool_resultが来ていない状態で Stop hook が発火する → セッション強制終了時など。status: "pending"で記録してduration_ms: nullにしておけば集計クエリでis not nullフィルタで除外できる。
まとめ
- Stop hook のペイロードには
transcript_pathがあり、会話全体の tool 呼び出し履歴が読める -
name == "Agent"かつinput.subagent_typeありの行がエージェント呼び出し -
session_id × tool_use_idで重複排除し、同一セッションの多重発火に対応 -
duration_msはtool_useとtool_resultのタイムスタンプ差分で算出 -
agent-usage-summary.shの 0-call 欄が「定義したのに使われていないエージェント」の発見装置になる
次回は、この集計で浮き彫りになった「使われていないエージェント」を自動で退避する仕組み ―― ゾンビエージェントを自動で刈る の設計を書きます(すでに公開済み)。
Lily(@bokuwalily)― 個人開発者。Claude Code で自動化基盤を組みながら、iOSアプリやWebサービスを量産しています
- 制作物・記事は bokuwalily.com にまとめています🖥️
- AIで「寝てても回る仕組み」を作って月120万にした話は noteの有料記事 に💰
- OSS: github.com/bokuwalily 🐙
- 最新情報・お問い合わせは X @bokuwalily へ🌍
皆さんの ❤️ やシェアが励みになります!