0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

エージェント呼び出しを全記録する ― Stop hook × transcript_path で使用頻度ログを自動構築

0
Posted at

「Claude Code環境」シリーズの前作 ゾンビエージェントを自動で刈る話 では定義されているのに一度も使われないエージェントを発見する話を書きました。今回はその数字の出どころ ―― Stop hook のペイロードに含まれる transcript_path を Python で読んで、エージェント呼び出しを自動的に JSONL に積み上げる仕組み の話です。

いま ~/.claude/logs/agent-invocations.jsonl には 553件 の呼び出し記録が溜まっています。直近7日の集計では general-purpose が58回・Explore が24回。そして 47個のエージェントが一度も呼ばれていない という事実も、この仕組みなしには見えませんでした。

困りごと:「このエージェント、本当に使ってる?」が分からない

~/.claude/agents/ 以下にエージェント定義が増えていくと、自分で書いておきながら「これ最後にいつ使った?」が分からなくなります。使われていない定義はコンテキスト注入を増やすだけで有害です。

Claude Code にはセッション終了時に Stop hook を呼び出す仕組みがあり、そのペイロードに transcript_path が含まれています。これが会話全体のログファイルへのパスです。ここを読めば「どのエージェントを、いつ、何秒かけて呼んだか」が全部取れます。

全体の流れ

セッション終了 → Stop hook 発火 → stop_hooks_combined.sh がペイロードを受け取る → stop_agent_tracker.sh へ渡す → Python が transcript を読んで JSONL に追記。

実際の ~/.local/bin/stop_hooks_combined.sh の配線はこうなっています。

# 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

Stop hook は stdin でペイロードを受け取ります。複数 hook に流すため tmpfile を経由し、各フックへ < "$PAYLOAD" でリダイレクトしています。

stop_agent_tracker.sh の実装

スクリプト全体は bash のラッパーと Python インライン実行の2層になっています。

#!/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

bash 側は stdin を受け取って環境変数にセットするだけ。処理は Python に任せます。

第1パス:transcript を全走査してインデックス化

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

ポイントは name == "Agent" で絞ること。Claude Code の transcript では Task ツールも name: "Agent" として記録されます。subagent_typeinput に入っているものだけが対象で、素の claude 呼び出しとは区別されます。

重複防止:session_id × tool_use_id で既記録をスキップ

Stop hook は同一セッションで複数回発火することがあります(/clear や長いセッション)。重複なしに記録するため、書き込み前に既存ログを舐めます。

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"])

session_idtool_use_id の組み合わせが一意性の鍵です。tool_use_id だけで弾くと、異なるセッションで偶然 ID が衝突した場合に記録漏れが起きます。

duration_ms の算出

tool_use レコードのタイムスタンプと、対応する tool_result のタイムスタンプの差分がエージェントの実行時間です。

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)

tool_result がまだ来ていない(セッション中断など)場合は status: "pending" として duration_ms: null で記録します。

出力レコードの形式

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

description は 300文字でクリップしています。transcript 上の description フィールドは自由記述なのでたまに長大になります。

agent-usage-summary.sh で集計する

溜まった JSONL を集計するのが ~/.claude/scripts/agent-usage-summary.sh です。

agent-usage-summary.sh           # デフォルト 7d
agent-usage-summary.sh 30d       # 30日
agent-usage-summary.sh 7d 30d    # 両ウィンドウ同時

実行するとこう出ます(今日の実測値)。

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

general-purpose が58回・Explore が24回。この2種で7日の呼び出しの95%を占めています。そして 47個のエージェントが一度も呼ばれていない。これがゾンビエージェント刈りの入力データです。

~/.claude/scripts/dashboard.sh はこの出力を毎日 dashboard.md に組み込んで常時可視化しています。

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"

~/.claude/agents/*.md として定義されているエージェントを「既知エージェント」として扱い、ログに出現しないものを「0-call」として列挙します。INDEX.md などの非エージェントファイルも混入するため、実運用では INDEX が毎回 0-call 欄の先頭に出ます。気になる場合は known_agents の取得前に .startswith("INDEX") などで除外してください。

dashboard.sh でのダッシュボード統合

dashboard.sh は health、auto-skills 数、launchd ジョブ一覧などと合わせて agent 集計を ~/.claude/dashboard.md に書き出します。cron で daily 更新することで「今週どのエージェントが重宝されているか」が毎朝確認できます。

踏んだ落とし穴

  • Stop hook の stdin は一度しか読めないstop_hooks_combined.sh が tmpfile を作ってから各フックへリダイレクトする設計にした。最初は各フックで cat しようとして2番目以降が空になった。
  • mktemp が TMPDIR 壊れで失敗するstop_hooks_combined.sh|| PAYLOAD="/tmp/stop-hook.$$.$RANDOM.json" にフォールバックした。空パスのままリダイレクトすると全 hook が黙って no-op になる。
  • tool_use_id だけで重複排除すると異セッション衝突でレコードが消えるsession_id × tool_use_id のペアで判定するように直した。
  • name == "Task" で検索して何も取れない → Claude Code の transcript では Task ツールが name: "Agent" として記録される。ドキュメントに記載がなく、実ファイルを grep して判明。
  • description が数千文字になることがある → 300文字でクリップしないと JSONL が膨れて後の集計で json.loads が遅くなった。
  • tool_result が来ていない状態で Stop hook が発火する → セッション強制終了時など。status: "pending" で記録して duration_ms: null にしておけば集計クエリで is not null フィルタで除外できる。

まとめ

  • Stop hook のペイロードには transcript_path があり、会話全体の tool 呼び出し履歴が読める
  • name == "Agent" かつ input.subagent_type ありの行がエージェント呼び出し
  • session_id × tool_use_id で重複排除し、同一セッションの多重発火に対応
  • duration_mstool_usetool_result のタイムスタンプ差分で算出
  • agent-usage-summary.sh0-call 欄が「定義したのに使われていないエージェント」の発見装置になる

次回は、この集計で浮き彫りになった「使われていないエージェント」を自動で退避する仕組み ―― ゾンビエージェントを自動で刈る の設計を書きます(すでに公開済み)。


Lily@bokuwalily)― 個人開発者。Claude Code で自動化基盤を組みながら、iOSアプリやWebサービスを量産しています

皆さんの ❤️ やシェアが励みになります!

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?