# Hunting Down Zombie Agents: Logging Subagent Usage with a Claude Code Stop Hook

> Source: <https://dev.to/bokuwalily/hunting-down-zombie-agents-logging-subagent-usage-with-a-claude-code-stop-hook-332g>
> Published: 2026-07-23 00:00:06+00:00

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](https://zenn.dev/bokuwalily/articles/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.

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.

`INDEX.md`

goes stale immediatelyAn 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 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 自動再生成
```

`~/.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"
```

The heart of the Python portion is a two-pass parse of the transcript.

``` php
# 第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")))
```

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"}}
```

So far, 546 records (about 176 KB) have accumulated.

`~/.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はこれ）
```

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()))
```

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
  ...
```

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.

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()
```

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... | * |
...
```

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.

`~/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>
```

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>
```

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.

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"
```

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.

`name="Task"`

matched nothing`name="Agent"`

. At first every record came back empty because of this`seen_ids`

duplicate check on `(session_id, tool_use_id)`

`subagent_type`

crept in`if r.get("subagent_type")`

(the empty string being falsy is enough)`INDEX.md`

itself, creating a loop`if p.name == "INDEX.md": continue`

`python3`

`known_agents`

is built from the file listing of `agents_dir`

, so it doesn't depend on the window. This is the correct behavior`~/.claude/agents/*.md`

, any newly added agent is automatically tracked`agent-usage-latest.md`

, so "how many zombies do I have as of today?" is always answerableNext 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](https://bokuwalily.com) · [X](https://x.com/bokuwalily) · [GitHub](https://github.com/bokuwalily)*
