In the previous post in my "Claude Code environment" series, automatically pruning zombie agents, I wrote about finding agents that are defined but never used. But where did those numbers come from in the first place? This post covers exactly that: a mechanism that reads the transcript_path included in the Stop hook payload with Python and automatically accumulates agent invocations into a JSONL log.
Right now, ~/.claude/logs/agent-invocations.jsonl holds 553 recorded invocations. Over the last 7 days, general-purpose was called 58 times and Explore 24 times. And the fact that 47 agents were never called even once — that was invisible without this mechanism.
The problem: "Am I actually using this agent?"
As agent definitions pile up under ~/.claude/agents/, you lose track of "when did I last use this?" — even though you wrote them yourself. Unused definitions are actively harmful: they only add to context injection.
Claude Code has a mechanism that fires a Stop hook when a session ends, and its payload includes transcript_path. This is the path to the log file for the entire conversation. If you read it, you can extract everything: which agent was called, when, and how long it took.
The overall flow
Session ends → Stop hook fires → stop_hooks_combined.sh receives the payload → passes it to stop_agent_tracker.sh → Python reads the transcript and appends to the JSONL.
Here is how the actual ~/.local/bin/stop_hooks_combined.sh is wired up.
# 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
The Stop hook receives its payload on stdin. To fan it out to multiple hooks, the payload goes through a tmpfile, and each hook gets it via a < "$PAYLOAD" redirect.
Implementing stop_agent_tracker.sh
The script has two layers: a bash wrapper and inline Python.
#!/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
The bash side just reads stdin and sets environment variables. All the actual work is delegated to Python.
Pass 1: scan the entire transcript and build an index
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")))
The key point is filtering on name == "Agent". In Claude Code transcripts, the Task tool is also recorded as name: "Agent". Only entries with subagent_type in input are what we want — this distinguishes them from plain claude calls.
Deduplication: skip already-recorded entries by session_id × tool_use_id
A Stop hook can fire multiple times within the same session (/clear, long sessions). To record without duplicates, the script sweeps the existing log before writing.
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"])
The combination of session_id and tool_use_id is the uniqueness key. If you deduplicate on tool_use_id alone, records get silently dropped when IDs happen to collide across different sessions.
Computing duration_ms
The difference between the timestamp of the tool_use record and the timestamp of its matching tool_result is the agent's execution time.
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)
If the tool_result hasn't arrived yet (e.g. the session was interrupted), the record is written with status: "pending" and duration_ms: null.
Output record format
{
"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"}
}
The description field is clipped at 300 characters. In the transcript it's free-form text, so it occasionally gets enormous.
Aggregating with agent-usage-summary.sh
The accumulated JSONL is aggregated by ~/.claude/scripts/agent-usage-summary.sh.
agent-usage-summary.sh # デフォルト 7d
agent-usage-summary.sh 30d # 30日
agent-usage-summary.sh 7d 30d # 両ウィンドウ同時
Running it produces this (actual numbers from today).
=== 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 at 58 calls, Explore at 24. These two types account for 95% of all invocations over 7 days. And 47 agents were never called at all. This is the input data for zombie-agent pruning.
~/.claude/scripts/dashboard.sh folds this output into dashboard.md every day for always-on visibility.
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"
Note: Agents defined as
*.mdfiles under~/.claude/agents/are treated as "known agents," and any that never appear in the log are listed as "0-call." Non-agent files likeINDEX.mdslip in too, so in practiceINDEXshows up at the top of the 0-call list every time. If that bothers you, filter it out with something like.startswith("INDEX")before buildingknown_agents.
Dashboard integration in dashboard.sh
dashboard.sh writes the agent summary out to ~/.claude/dashboard.md alongside health status, auto-skills counts, and the launchd job list. Updated daily via cron, it lets you check every morning which agents are pulling their weight this week.
Pitfalls I hit
-
The Stop hook's stdin can only be read once →
stop_hooks_combined.shwas designed to write a tmpfile first and then redirect it into each hook. My first attempt had each hookcatstdin directly, and every hook after the first got nothing. -
mktempfails when TMPDIR is broken → added a fallback instop_hooks_combined.sh:|| PAYLOAD="/tmp/stop-hook.$$.$RANDOM.json". If you redirect to an empty path, every hook silently becomes a no-op. -
Deduplicating on tool_use_id alone loses records on cross-session collisions → fixed by keying on the
session_id × tool_use_idpair. -
Searching for
name == "Task"returns nothing → in Claude Code transcripts, the Task tool is recorded asname: "Agent". This isn't documented anywhere; I found it by grepping the actual files. -
description can grow to thousands of characters → without clipping at 300 characters, the JSONL bloated and
json.loadsgot slow in later aggregation. -
The Stop hook can fire before the
tool_resultarrives → e.g. when a session is force-killed. Recording it withstatus: "pending"andduration_ms: nulllets aggregation queries exclude it with anis not nullfilter.
Summary
- The Stop hook payload includes
transcript_path, which means the entire conversation's tool call history is readable - Lines with
name == "Agent"and aninput.subagent_typeare agent invocations -
Deduplicate on
session_id × tool_use_idto handle multiple firings within the same session duration_msis the timestamp delta betweentool_useandtool_result- The 0-call section of
agent-usage-summary.shis a detector for "agents you defined but never use"
Next up: the mechanism that automatically retires those unused agents surfaced by this aggregation — the design behind automatically pruning zombie agents (already published).
Written by **Lily* — I ship iOS apps and automate my content stack with Claude Code.
Follow along: Portfolio · X · GitHub*
Top comments (1)
I like that you treated agents like any other production system. Once you have usage data, decisions stop being guesses.
One metric I’d be curious to add is success rate by agent type, not just invocation count. Sometimes the most frequently used agent is also the one causing the most retries. That kind of data can completely change optimization priorities.