Posted on Jul 23 • Originally published at dev.to
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
Enter fullscreen mode Exit fullscreen mode
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
Enter fullscreen mode Exit fullscreen mode
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")))
Enter fullscreen mode Exit fullscreen mode
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"])
Enter fullscreen mode Exit fullscreen mode
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)
Enter fullscreen mode Exit fullscreen mode
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"}
}
Enter fullscreen mode Exit fullscreen mode
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 # 両ウィンドウ同時
Enter fullscreen mode Exit fullscreen mode
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
...
Enter fullscreen mode Exit fullscreen mode
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"
Enter fullscreen mode Exit fullscreen mode
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*
답글 남기기