エージェント呼び出しを全記録する ― Stop hook × transcript_path で使用頻度ログを自動構築
前回、ゾンビ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_use(name="Agent")と、それに対応するtool_resultが両方入っています。
Claude Codeのtranscript上では、Agentツール(Task機能)は
name="Task"ではなく**name="Agent"として記録されます**。subagent_typeはinput.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-purposeとExploreだけで、自分で定義したカスタム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上の実名はAgent(input.subagent_typeで判別) - 重複防止は
tool_use_id単体では不十分 →session_idとのペアで判定しないと別セッションの記録を巻き込む - Stop hookは1ターンごとに毎回発火し、transcriptを毎回まるごと再走査する → 対策なしだと同一呼び出しが複数回記録される。既存ログとの突き合わせが前提の設計
known_agentsはagents/*.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サービスを量産しています
- AIで「寝てても回る仕組み」を作って月120万にした話は noteの有料記事 に💰
- OSS: github.com/bokuwalily 🐙
- 最新情報・お問い合わせは X @bokuwalily へ🌍
- AI導入・自動化の相談と実装テンプレ7本の配布は 公式LINE から💬
皆さんの ❤️ やシェアが励みになります!