Lily

Lily

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.md goes 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". The subagent_type lives in input.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 further

In 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 as name="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_ids duplicate check on (session_id, tool_use_id)
  • Records with an empty-string subagent_type crept in → Filter with if r.get("subagent_type") (the empty string being falsy is enough)
  • agents-index.sh parsed INDEX.md itself, creating a loop → Excluded with if 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 windowknown_agents is built from the file listing of agents_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*