{"slug": "hunting-down-zombie-agents-logging-subagent-usage-with-a-claude-code-stop-hook", "title": "Hunting Down Zombie Agents: Logging Subagent Usage with a Claude Code Stop Hook", "summary": "A developer built a system using a Claude Code Stop hook to log subagent invocations to JSONL, revealing 44 of 44 defined agents had zero usage in 30 days. The setup parses session transcripts to track agent calls and generates daily reports to identify unused 'zombie' agents for cleanup.", "body_md": "Quick question: how many of the subagents you've defined in `~/.claude/agents/`\n\nhave 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.\n\nAdding an agent to Claude Code is as simple as writing frontmatter in `.claude/agents/*.md`\n\n. 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.\n\n`INDEX.md`\n\ngoes 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.\n\nThe system consists of three components.\n\n```\nセッション終了\n  └─ Stop hook (stop_agent_tracker.sh)\n       └─ transcript.jsonl を解析 → agent-invocations.jsonl に追記\n                                              │\n                     毎日 10:15 launchd ──────┘\n                       └─ agent-usage-summary.sh 7d 30d\n                            └─ agent-usage-latest.md（Top10 + 0回リスト）\n\n  手動 or cron\n   └─ agents-index.sh → INDEX.md 自動再生成\n```\n\n`~/.claude/hooks/stop_agent_tracker.sh`\n\nruns when a session ends. The Stop hook receives session info and a `transcript_path`\n\non stdin, so the script parses that transcript and picks out Agent tool invocations.\n\n```\n# stop_agent_tracker.sh（抜粋）\n# stdin: {\"session_id\":\"...\",\"transcript_path\":\"...\",\"hook_event_name\":\"Stop\",...}\n# 出力:  ~/.claude/logs/agent-invocations.jsonl\n\nOUT_LOG=\"$LOG_DIR/agent-invocations.jsonl\"\n```\n\nThe heart of the Python portion is a two-pass parse of the transcript.\n\n``` php\n# 第1パス: tool_use (name=\"Agent\") と tool_result をインデックス化\nuses = {}    # id -> (ts, name, input, caller)\nresults = {} # tool_use_id -> (ts, is_error)\n\nfor b in content:\n    if btype == \"tool_use\" and b.get(\"name\") == \"Agent\":\n        inp = b.get(\"input\") or {}\n        if \"subagent_type\" not in inp:\n            continue\n        uses[uid] = (ts, b.get(\"name\"), inp, b.get(\"caller\"))\n    elif btype == \"tool_result\":\n        results[rid] = (ts, bool(b.get(\"is_error\")))\n```\n\nNote:In Claude Code transcripts, the \"Task\" tool is recorded as`name=\"Agent\"`\n\n. The`subagent_type`\n\nlives in`input.subagent_type`\n\n. The same comment appears in the code itself.\n\nTo prevent duplicates, the same `session_id`\n\n+ `tool_use_id`\n\ncombination is skipped (so even if the Stop hook fires multiple times in one session, nothing gets written twice).\n\nA single JSONL record looks like this.\n\n```\n{\"ts\": \"2026-05-28T16:27:41.766Z\", \"session_id\": \"TEST-AGENT-TRACKER-001\",\n \"cwd\": \"~\", \"tool_use_id\": \"toolu_014MM...\", \"subagent_type\": \"general-purpose\",\n \"description\": \"launchd + cron 総監査\", \"duration_ms\": 177, \"status\": \"ok\",\n \"caller\": {\"type\": \"direct\"}}\n```\n\nSo far, 546 records (about 176 KB) have accumulated.\n\n`~/.claude/scripts/agent-usage-summary.sh`\n\nhandles the aggregation. It's inline python3 with no external libraries.\n\n```\n# 使い方\nagent-usage-summary.sh           # デフォルト 7d\nagent-usage-summary.sh 30d       # 30日\nagent-usage-summary.sh 7d 30d    # 両方（launchdはこれ）\n```\n\nInternally it's three steps.\n\n```\n# ① JOSNLをロードしてウィンドウでフィルタ\ncutoff = now - td\nrecent = [r for r in records if r[\"_dt\"] >= cutoff]\n\n# ② subagent_type でカウント + エラー数\ncounts = Counter(r.get(\"subagent_type\", \"\") for r in recent if r.get(\"subagent_type\"))\nerrors = Counter(r.get(\"subagent_type\", \"\") for r in recent if r.get(\"status\") == \"error\")\n\n# ③ ~/.claude/agents/*.md のファイル名一覧と突き合わせて未使用を出す\nknown_agents = set()\nfor fp in glob.glob(os.path.join(agents_dir, \"*.md\")):\n    known_agents.add(os.path.splitext(os.path.basename(fp))[0])\n\nunused = sorted(known_agents - set(counts.keys()))\n```\n\nHere's this morning's actual output (`~/.claude/logs/agent-usage-latest.md`\n\n).\n\n```\n=== Agent usage (last 7d) ===\ntotal invocations: 127  unique types: 5\n\nTop 10:\n  agent                                     calls  errors\n  general-purpose                              76       0\n  Explore                                      45       0\n  Content Creator                               2       0\n  fork                                          2       0\n  reviewer                                      2       0\n\n0-call agents (defined locally but not used in 7d): 47\n  - a11y-architect\n  - architect\n  - build-error-resolver\n  - code-architect\n  - code-explorer\n  - code-reviewer\n  ...\n```\n\nIn 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`\n\nand `Explore`\n\naccount for 95% of all invocations.\n\nTo decide whether something can be deleted, you need a cross-cutting view of what each agent was written for. `agents-index.sh`\n\nreads the frontmatter of `~/.claude/agents/*.md`\n\nand builds `INDEX.md`\n\n.\n\n```\n# 簡易frontmatterパーサ（PyYAML依存なし）\nm = re.match(r\"^---\\n(.*?)\\n---\\n\", text, flags=re.DOTALL)\nbody = m.group(1)\nfor line in body.split(\"\\n\"):\n    k, _, v = line.partition(\":\")\n    out[k.strip()] = v.strip()\n```\n\nThe generated `INDEX.md`\n\nis a table like this.\n\n```\n<!-- AUTO-GENERATED by ~/.claude/scripts/agents-index.sh — DO NOT EDIT MANUALLY -->\n# Agents Index (51 agents · 2026-07-14 10:15)\n\n| Name | Model | Description | Tools |\n|------|-------|-------------|-------|\n| `general-purpose` (general-purpose.md) | - | General-purpose agent for... | * |\n...\n```\n\nWith the `--json`\n\nflag it also writes out `.index.json`\n\n, which can be reused programmatically by things like a cost tracker.\n\nFiles missing `name`\n\nor `description`\n\nin their frontmatter get listed under a `⚠️ Validation warnings`\n\nsection.\n\n`~/Library/LaunchAgents/com.shun.agent-usage-daily.plist`\n\nruns every day at 10:15.\n\n```\n<key>StartCalendarInterval</key>\n<dict>\n    <key>Hour</key>\n    <integer>10</integer>\n    <key>Minute</key>\n    <integer>15</integer>\n</dict>\n```\n\nThe command it runs is simple.\n\n```\n<string>/bin/zsh -c\n  ~/.claude/scripts/agent-usage-summary.sh 7d 30d\n  > ~/.claude/logs/agent-usage-latest.md 2>&1</string>\n```\n\nThe PATH explicitly includes nvm and Homebrew because launchd's default PATH won't find `python3`\n\n.\n\nWith this in place, `agent-usage-latest.md`\n\ngets refreshed every morning at 10:15, so I always know how many agents have sat at zero for 30 days.\n\nOnce a week, I run these commands.\n\n```\n# INDEX再生成（frontmatter変更・追加後に必ず走らせる）\n~/.claude/scripts/agents-index.sh\n\n# 0回リスト確認\ncat ~/.claude/logs/agent-usage-latest.md | grep -A 9999 \"0-call agents\"\n```\n\nMy decision criteria look like this.\n\n| State | Action |\n|---|---|\n| 0 calls in 30d, and reading the description reveals no realistic use case | Delete |\n| 0 calls in 30d, but there's a future use case | Keep, and spell out the usage conditions in the description |\n| 0 calls in 7d, a few calls in 30d | Possibly a seasonal job — hold off |\n| Top 5 regular | Revisit its permissions and tool definitions to sharpen it further |\n\nIn this round of housekeeping I deleted 14 agents confirmed at zero calls over 30 days, including `a11y-architect`\n\n, `django-reviewer`\n\n, and `go-build-resolver`\n\n. They simply disappear from INDEX.md — nothing else changes.\n\n`name=\"Task\"`\n\nmatched nothing`name=\"Agent\"`\n\n. At first every record came back empty because of this`seen_ids`\n\nduplicate check on `(session_id, tool_use_id)`\n\n`subagent_type`\n\ncrept in`if r.get(\"subagent_type\")`\n\n(the empty string being falsy is enough)`INDEX.md`\n\nitself, creating a loop`if p.name == \"INDEX.md\": continue`\n\n`python3`\n\n`known_agents`\n\nis built from the file listing of `agents_dir`\n\n, so it doesn't depend on the window. This is the correct behavior`~/.claude/agents/*.md`\n\n, any newly added agent is automatically tracked`agent-usage-latest.md`\n\n, 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.\n\n*Written by **Lily** — I ship iOS apps and automate my content stack with Claude Code.\n\nFollow along: [Portfolio](https://bokuwalily.com) · [X](https://x.com/bokuwalily) · [GitHub](https://github.com/bokuwalily)*", "url": "https://wpnews.pro/news/hunting-down-zombie-agents-logging-subagent-usage-with-a-claude-code-stop-hook", "canonical_source": "https://dev.to/bokuwalily/hunting-down-zombie-agents-logging-subagent-usage-with-a-claude-code-stop-hook-332g", "published_at": "2026-07-23 00:00:06+00:00", "updated_at": "2026-07-23 00:29:09.122172+00:00", "lang": "en", "topics": ["developer-tools", "ai-agents", "large-language-models"], "entities": ["Claude Code", "launchd"], "alternates": {"html": "https://wpnews.pro/news/hunting-down-zombie-agents-logging-subagent-usage-with-a-claude-code-stop-hook", "markdown": "https://wpnews.pro/news/hunting-down-zombie-agents-logging-subagent-usage-with-a-claude-code-stop-hook.md", "text": "https://wpnews.pro/news/hunting-down-zombie-agents-logging-subagent-usage-with-a-claude-code-stop-hook.txt", "jsonld": "https://wpnews.pro/news/hunting-down-zombie-agents-logging-subagent-usage-with-a-claude-code-stop-hook.jsonld"}}