Posted on Jul 23 • Originally published at dev.to
Quick question: how many of the subagents you’ve defined in ~/.claude/agents/ have actually been called this month? I couldn’t have told you — until I started measuring, and this morning’s tally revealed 44 agents that hadn’t been invoked even once in 30 days. This post is part of my “Claude Code environment” series (a follow-up to automatic terminal window tiling), continuing the theme of keeping the setup from bloating. I’ll walk through, with real code, a system that records subagent invocations to JSONL via a Stop hook and uses a daily report to flush out the zombies and cull them.
The problem: the gap between “defined” and “used”
Adding an agent to Claude Code is as simple as writing frontmatter in .claude/agents/*.md. Because it’s so easy, “let’s just define it for now” agents pile up. The problem is that there’s no way to check whether an agent is actually being used.
- There’s no mechanism to aggregate invocation counts across sessions
- A hand-written
INDEX.mdgoes stale immediately - There’s no evidence to base a “can I delete this?” decision on
An agent with zero usage still takes up space in the context injected at startup. Left alone, it becomes a zombie: defined but unused → unclear whether it even works → can’t be deleted either.
The overall flow
The system consists of three components.
セッション終了
└─ Stop hook (stop_agent_tracker.sh)
└─ transcript.jsonl を解析 → agent-invocations.jsonl に追記
│
毎日 10:15 launchd ──────┘
└─ agent-usage-summary.sh 7d 30d
└─ agent-usage-latest.md(Top10 + 0回リスト)
手動 or cron
└─ agents-index.sh → INDEX.md 自動再生成
Enter fullscreen mode Exit fullscreen mode
Step 1: Record to JSONL with a Stop hook
~/.claude/hooks/stop_agent_tracker.sh runs when a session ends. The Stop hook receives session info and a transcript_path on stdin, so the script parses that transcript and picks out Agent tool invocations.
# stop_agent_tracker.sh(抜粋)
# stdin: {"session_id":"...","transcript_path":"...","hook_event_name":"Stop",...}
# 出力: ~/.claude/logs/agent-invocations.jsonl
OUT_LOG="$LOG_DIR/agent-invocations.jsonl"
Enter fullscreen mode Exit fullscreen mode
The heart of the Python portion is a two-pass parse of the transcript.
# 第1パス: tool_use (name="Agent") と tool_result をインデックス化
uses = {} # id -> (ts, name, input, caller)
results = {} # tool_use_id -> (ts, is_error)
for b in content:
if btype == "tool_use" and b.get("name") == "Agent":
inp = b.get("input") or {}
if "subagent_type" not in inp:
continue
uses[uid] = (ts, b.get("name"), inp, b.get("caller"))
elif btype == "tool_result":
results[rid] = (ts, bool(b.get("is_error")))
Enter fullscreen mode Exit fullscreen mode
Note: In Claude Code transcripts, the “Task” tool is recorded as
name="Agent". Thesubagent_typelives ininput.subagent_type. The same comment appears in the code itself.
To prevent duplicates, the same session_id + tool_use_id combination is skipped (so even if the Stop hook fires multiple times in one session, nothing gets written twice).
A single JSONL record looks like this.
{"ts": "2026-05-28T16:27:41.766Z", "session_id": "TEST-AGENT-TRACKER-001",
"cwd": "~", "tool_use_id": "toolu_014MM...", "subagent_type": "general-purpose",
"description": "launchd + cron 総監査", "duration_ms": 177, "status": "ok",
"caller": {"type": "direct"}}
Enter fullscreen mode Exit fullscreen mode
So far, 546 records (about 176 KB) have accumulated.
Step 2: Produce a Top 10 and zero-call list over 7d/30d windows
~/.claude/scripts/agent-usage-summary.sh handles the aggregation. It’s inline python3 with no external libraries.
# 使い方
agent-usage-summary.sh # デフォルト 7d
agent-usage-summary.sh 30d # 30日
agent-usage-summary.sh 7d 30d # 両方(launchdはこれ)
Enter fullscreen mode Exit fullscreen mode
Internally it’s three steps.
# ① JOSNLをロードしてウィンドウでフィルタ
cutoff = now - td
recent = [r for r in records if r["_dt"] >= cutoff]
# ② subagent_type でカウント + エラー数
counts = Counter(r.get("subagent_type", "") for r in recent if r.get("subagent_type"))
errors = Counter(r.get("subagent_type", "") for r in recent if r.get("status") == "error")
# ③ ~/.claude/agents/*.md のファイル名一覧と突き合わせて未使用を出す
known_agents = set()
for fp in glob.glob(os.path.join(agents_dir, "*.md")):
known_agents.add(os.path.splitext(os.path.basename(fp))[0])
unused = sorted(known_agents - set(counts.keys()))
Enter fullscreen mode Exit fullscreen mode
Here’s this morning’s actual output (~/.claude/logs/agent-usage-latest.md).
=== Agent usage (last 7d) ===
total invocations: 127 unique types: 5
Top 10:
agent calls errors
general-purpose 76 0
Explore 45 0
Content Creator 2 0
fork 2 0
reviewer 2 0
0-call agents (defined locally but not used in 7d): 47
- a11y-architect
- architect
- build-error-resolver
- code-architect
- code-explorer
- code-reviewer
...
Enter fullscreen mode Exit fullscreen mode
In 7 days, only 5 agent types were called and 47 weren’t called at all. Even in the 30-day window, 44 remain at zero. general-purpose and Explore account for 95% of all invocations.
Step 3: Auto-regenerate INDEX.md with agents-index.sh
To decide whether something can be deleted, you need a cross-cutting view of what each agent was written for. agents-index.sh reads the frontmatter of ~/.claude/agents/*.md and builds INDEX.md.
# 簡易frontmatterパーサ(PyYAML依存なし)
m = re.match(r"^---\n(.*?)\n---\n", text, flags=re.DOTALL)
body = m.group(1)
for line in body.split("\n"):
k, _, v = line.partition(":")
out[k.strip()] = v.strip()
Enter fullscreen mode Exit fullscreen mode
The generated INDEX.md is a table like this.
<!-- AUTO-GENERATED by ~/.claude/scripts/agents-index.sh — DO NOT EDIT MANUALLY -->
# Agents Index (51 agents · 2026-07-14 10:15)
| Name | Model | Description | Tools |
|------|-------|-------------|-------|
| `general-purpose` (general-purpose.md) | - | General-purpose agent for... | * |
...
Enter fullscreen mode Exit fullscreen mode
With the --json flag it also writes out .index.json, which can be reused programmatically by things like a cost tracker.
Files missing name or description in their frontmatter get listed under a ⚠️ Validation warnings section.
Step 4: Generate the daily report with launchd
~/Library/LaunchAgents/com.shun.agent-usage-daily.plist runs every day at 10:15.
<key>StartCalendarInterval</key>
<dict>
<key>Hour</key>
<integer>10</integer>
<key>Minute</key>
<integer>15</integer>
</dict>
Enter fullscreen mode Exit fullscreen mode
The command it runs is simple.
<string>/bin/zsh -c
~/.claude/scripts/agent-usage-summary.sh 7d 30d
> ~/.claude/logs/agent-usage-latest.md 2>&1</string>
Enter fullscreen mode Exit fullscreen mode
The PATH explicitly includes nvm and Homebrew because launchd’s default PATH won’t find python3.
With this in place, agent-usage-latest.md gets refreshed every morning at 10:15, so I always know how many agents have sat at zero for 30 days.
The operating routine: cull the zombies
Once a week, I run these commands.
# INDEX再生成(frontmatter変更・追加後に必ず走らせる)
~/.claude/scripts/agents-index.sh
# 0回リスト確認
cat ~/.claude/logs/agent-usage-latest.md | grep -A 9999 "0-call agents"
Enter fullscreen mode Exit fullscreen mode
My decision criteria look like this.
State Action 0 calls in 30d, and reading the description reveals no realistic use case Delete 0 calls in 30d, but there’s a future use case Keep, and spell out the usage conditions in the description 0 calls in 7d, a few calls in 30d Possibly a seasonal job — hold off Top 5 regular Revisit its permissions and tool definitions to sharpen it furtherIn this round of housekeeping I deleted 14 agents confirmed at zero calls over 30 days, including a11y-architect, django-reviewer, and go-build-resolver. They simply disappear from INDEX.md — nothing else changes.
Pitfalls I hit
-
Filtering on
name="Task"matched nothing → In Claude Code transcripts, Task invocations are recorded asname="Agent". At first every record came back empty because of this -
The Stop hook fired multiple times in one session, causing double writes → Fixed by adding a
seen_idsduplicate check on(session_id, tool_use_id) -
Records with an empty-string
subagent_typecrept in → Filter withif r.get("subagent_type")(the empty string being falsy is enough) -
agents-index.sh parsed
INDEX.mditself, creating a loop → Excluded withif p.name == "INDEX.md": continue -
launchd’s minimal PATH couldn’t find
python3→ Explicitly set the nvm and Homebrew paths in the plist’s EnvironmentVariables -
Agents at zero in the 30d window looked “nonexistent” in the 7d window →
known_agentsis built from the file listing ofagents_dir, so it doesn’t depend on the window. This is the correct behavior
Wrap-up
- The Stop hook appends to JSONL keyed by session_id + tool_use_id → invocation history accumulates across sessions
-
agent-usage-summary.sh outputs a Top 10 and a zero-call agent list over 7d/30d windows. Since it cross-references the filenames in
~/.claude/agents/*.md, any newly added agent is automatically tracked - agents-index.sh auto-regenerates INDEX.md from frontmatter. A hand-written index goes stale immediately, so this replaces it
-
launchd runs the aggregation daily at 10:15 and writes to
agent-usage-latest.md, so “how many zombies do I have as of today?” is always answerable - Agents confirmed at zero calls over 30 days get deleted. When the case for deletion comes from numbers, there’s no second-guessing
Next time, I’ll use this log data to visualize which agents get combined for which kinds of work.
Written by **Lily* — I ship iOS apps and automate my content stack with Claude Code.
Follow along: Portfolio · X · GitHub*
답글 남기기