DEV Community

Lily
Lily

Posted on Originally published at dev.to

12 Pitfalls I Hit Auto-Logging Claude Code Subagents with a Stop Hook (and How the Numbers Cut My Weekly Cost 15–20%)

I had a gut feeling that my general-purpose subagent was the slow one. Then I measured it: 18 seconds on average, faster than Explore at 22. The real drag was code-reviewer at 37 seconds. Changing one habit based on that number dropped my weekly Claude Code spend by roughly 15–20%.

I made a couple hundred thousand yen a month as a student juggling side gigs, got laid off and went back to zero, and spent six months building an autonomous Claude Code setup that now runs at about ¥1.2M in monthly revenue. The foundation of that setup is post-session log collection driven by a Stop hook. This post covers the implementation, the 12 pitfalls I hit along the way, and the jq recipes I use every week.

Why this works

After using Claude Code for a while, a nagging feeling sets in: "I have no idea where the cost is going." You mix Explore agents, code-reviewer agents, general-purpose agents, and before you know it a session has been running for tens of minutes. But which agent is slow? Which one errors most often? I had never seen a number.

Saying "that agent feels heavy" is not data. Without data, you can't tell what to fix. For someone shipping systems solo at volume, that's a fatal blind spot. Optimizing a workflow starts with measuring it.

Claude Code ships with a mechanism that lets you automate that measurement: the Stop hook.

A Stop hook is a shell script that runs every time a Claude Code session ends. The hook receives a transcript_path — the path to the raw JSONL conversation log for the entire session, including tool calls, agent launches, and response timestamps. In other words, every time a session ends, you automatically get a chance to read "everything that happened in this session."

Once I noticed that, my thinking changed. There's no need to keep records by hand. Trigger a script on session end, parse transcript_path, and write a list of subagent invocations out to JSONL. That alone accumulates "which agent took how many seconds, and did it succeed or fail."

Then aggregate the ledger with jq, and you move from gut feelings to a conversation about numbers.

One more important point: this approach touches nothing in Claude Code itself. A Stop hook is enabled by adding a single line to ~/.claude/settings.json. It doesn't change the original behavior; it just piggybacks on an existing event, session end. Near-zero side effects is a big advantage when you're maintaining a high-volume environment.

What's inside transcript.jsonl

Of the JSON the Stop hook receives, two fields matter.

{
  "session_id": "...",
  "transcript_path": "/path/to/transcript.jsonl"
}
Enter fullscreen mode Exit fullscreen mode

The file at transcript_path contains every message exchanged in the session in JSONL format (one JSON object per line). Inside each line's message.content, you'll find "type": "tool_use" blocks representing tool calls.

Subagent launches are recorded here as "name": "Agent". Note that even though Claude Code's UI displays "Task", the tool name in the transcript is "Agent" (the script's comment spells this out: "In Claude Code transcripts, the 'Task' tool is recorded as name="Agent""). The subagent_type lives in input.subagent_type.

The result of an agent call appears on a separate line as "type": "tool_result", linked to the call via tool_use_id. If is_error: true is present, it ended in error; otherwise, it succeeded.

Once you understand this structure, a two-pass Python script is all it takes to extract subagent execution records.

Overall flow

Here's the whole system as a diagram.

 Claude Codeセッション
 ┌─────────────────────────────────────────────────┐
 │ tool_use (name="Agent", subagent_type="Explore") │
 │     ...処理中...                                  │
 │ tool_result (tool_use_id=xxx, is_error=false)    │
 └─────────────────────────────────────────────────┘
              ↓ セッション終了 (Stop イベント)
 ┌─────────────────────────────────────────────────┐
 │ Stop hook: stop_agent_tracker.sh                 │
 │   stdin: {"session_id", "transcript_path", ...}  │
 └─────────────────────────────────────────────────┘
              ↓ transcript_path を読み込む
 ┌─────────────────────────────────────────────────┐
 │ Python: 2パス解析                                 │
 │   Pass1: uses{} / results{} を構築               │
 │   Pass2: tool_use_id で突き合わせ・duration算出   │
 └─────────────────────────────────────────────────┘
              ↓ 追記
 ~/.claude/logs/agent-invocations.jsonl
Enter fullscreen mode Exit fullscreen mode

When the Stop hook fires, it receives JSON on stdin, and a Python script reads the transcript using the transcript_path inside it. The output destination is fixed: ~/.claude/logs/agent-invocations.jsonl.

Inside stop_agent_tracker.sh

The script is almost entirely Python. The shell portion only handles passing environment variables.

INPUT=$(cat)           # stdin から Stop イベント JSON を受け取る
export STOP_INPUT="$INPUT"
export OUT_LOG_PATH="$OUT_LOG"    # ~/.claude/logs/agent-invocations.jsonl
Enter fullscreen mode Exit fullscreen mode

The Python portion is split into three main blocks.

Deduplication block

If the Stop hook runs multiple times for the same session (which can happen by Claude Code's design), we don't want to write the same entry twice. So we read the existing log and load session_id + tool_use_id combinations into a seen_ids set.

seen_ids = set()
if os.path.exists(out_path):
    with open(out_path, "r", ...) as f:
        for line in f:
            r = json.loads(line)
            if r.get("session_id") == sid and r.get("tool_use_id"):
                seen_ids.add(r["tool_use_id"])
Enter fullscreen mode Exit fullscreen mode

Pass 1: build the index

Read the transcript line by line. tool_use blocks with "name": "Agent" and a subagent_type go into the uses dict; tool_result blocks go into the results dict.

uses = {}    # tool_use_id -> (ts, name, input, caller)
results = {} # tool_use_id -> (ts, is_error)

for b in content:
    if b.get("type") == "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 b.get("type") == "tool_result":
        results[rid] = (ts, bool(b.get("is_error")))
Enter fullscreen mode Exit fullscreen mode

Agent calls without a subagent_type are skipped. This check exists to exclude invocations of the main Claude agent itself.

Pass 2: match and write

Iterate over uses, and if a matching results entry exists, compute duration_ms and write it out.

t0 = parse_ts(use_ts)    # tool_use のタイムスタンプ
t1 = parse_ts(res_ts)    # tool_result のタイムスタンプ
if t0 and t1:
    duration_ms = int((t1 - t0).total_seconds() * 1000)
Enter fullscreen mode Exit fullscreen mode

Entries whose result hasn't come back yet are recorded with "status": "pending".

The description field is truncated at 300 characters. This keeps the log file size under control even when the instructions given to an agent are long.

if len(description) > 300:
    description = description[:300] + ""
Enter fullscreen mode Exit fullscreen mode

A single line of the resulting JSONL looks like this.

{
  "ts": "2026-09-10T08:30:00Z",
  "session_id": "abc123",
  "cwd": "~/dev/my-project",
  "tool_use_id": "toolu_01Xyz...",
  "subagent_type": "Explore",
  "description": "Find all TypeScript files that reference the Auth module…",
  "duration_ms": 18420,
  "status": "ok",
  "caller": null
}
Enter fullscreen mode Exit fullscreen mode

To summarize the fields: subagent_type is the agent kind, duration_ms is the execution time in milliseconds, and status is one of "ok" / "error" / "pending". caller holds information about the caller, but it's null most of the time.

Why two passes

When you read a tool_use in a single pass, the matching tool_result may not have appeared yet. JSONL is in chronological order, but during a long agent run, other messages can be interleaved. Reading every line first to build an index and matching afterward — the two-pass structure — guarantees you get the pairs.

Also, when the parse_ts function converts ISO 8601 timestamps to datetime objects, it replaces the trailing Z with +00:00 before passing to fromisoformat.

def parse_ts(s):
    try:
        return datetime.datetime.fromisoformat(s.replace("Z", "+00:00"))
    except Exception:
        return None
Enter fullscreen mode Exit fullscreen mode

Python's fromisoformat can't parse the Z suffix directly before 3.11, so this conversion is required.

How the log accumulates

The script opens the file in "a" (append) mode. Each session adds new records to the end. If the file doesn't exist, it's created automatically (guaranteed by mkdir -p on LOG_DIR).

Because data accumulates across sessions, after days or weeks of use, patterns emerge: "the Explore agent always takes over 20 seconds," "the code-reviewer agent errors out occasionally."

Dissecting the cost log script

Running alongside stop_agent_tracker.sh is stop_cost_log.sh. This one records the tokens consumed in a session and the estimated cost to ~/.claude/logs/cost-log.jsonl. Alongside agent slowness, knowing "when, in which session, and how much it cost" is essential for keeping a high-volume environment running.

The PRICING table and prefix matching

The rate table is implemented like this.

PRICING = {
    "claude-opus-4-7":   {"input": 15.0, "output": 75.0, "cache_read": 1.5,  "cache_create_5m": 18.75, "cache_create_1h": 30.0},
    "claude-sonnet-4-6": {"input": 3.0,  "output": 15.0, "cache_read": 0.3,  "cache_create_5m": 3.75,  "cache_create_1h": 6.0},
    "claude-haiku-4-5":  {"input": 1.0,  "output": 5.0,  "cache_read": 0.1,  "cache_create_5m": 1.25,  "cache_create_1h": 2.0},
}
DEFAULT_RATE = PRICING["claude-sonnet-4-6"]

def rate_for(model: str):
    for k, v in PRICING.items():
        if model.startswith(k):
            return v
    return DEFAULT_RATE
Enter fullscreen mode Exit fullscreen mode

The key point is that dictionary keys are matched by prefix (startswith) rather than exact match. Model names Claude Code writes to the transcript can carry a release-date suffix, like claude-sonnet-4-6-20250620. With exact matching, you'd have to rewrite PRICING every time a minor version updates. Prefix matching absorbs suffix changes. Unknown models fall back to DEFAULT_RATE (Sonnet-equivalent), so aggregation doesn't stop when a model not in the table shows up.

5-minute vs. 1-hour cache tokens

The Anthropic API has two kinds of cache: a 5-minute ephemeral cache and a 1-hour cache, priced differently (roughly a 1:1.6 ratio). The script tallies them separately.

cc_5m = (usage.get("cache_creation", {}) or {}).get("ephemeral_5m_input_tokens", 0) or 0
cc_1h = (usage.get("cache_creation", {}) or {}).get("ephemeral_1h_input_tokens", 0) or 0
if cc_5m + cc_1h == 0 and cc_total > 0:
    cc_5m = cc_total
Enter fullscreen mode Exit fullscreen mode

The last three lines are a consistency check. In older Claude Code versions or under certain conditions, the nested cache_creation object doesn't exist and only a flat cache_creation_input_tokens is returned. That yields cc_5m + cc_1h == 0 with cc_total > 0, so the full amount is treated as the 5-minute tier. Since the 5-minute tier is cheaper, this biases toward underestimating cost. I decided that's easier to reason about than overestimating.

The cost formula

cost_usd += (
    inp / 1_000_000 * r["input"]
    + out / 1_000_000 * r["output"]
    + cr / 1_000_000 * r["cache_read"]
    + cc_5m / 1_000_000 * r["cache_create_5m"]
    + cc_1h / 1_000_000 * r["cache_create_1h"]
)
Enter fullscreen mode Exit fullscreen mode

The unit is "$/MTok" (dollars per million tokens), so the token count is divided by 1_000_000 before multiplying. The _-separated numeric literals are for readability and work on Python 3.6+. The script walks every message in the session, accumulates cost_usd, and finally rounds to four decimal places with round(cost_usd, 4).

Aggregating the ledger with jq

Once the log builds up, you can ask it questions with jq. All of the following are real queries against ~/.claude/logs/agent-invocations.jsonl.

Average and max execution time per agent type

jq -s '
  group_by(.subagent_type) |
  map({
    type: .[0].subagent_type,
    count: length,
    avg_ms: (map(select(.duration_ms != null) | .duration_ms) | add / length | round),
    max_ms: (map(select(.duration_ms != null) | .duration_ms) | max)
  }) | sort_by(-.avg_ms)
' ~/.claude/logs/agent-invocations.jsonl
Enter fullscreen mode Exit fullscreen mode

group_by buckets by type, then avg_ms and max_ms are computed and sorted descending. When I actually ran this, my environment showed the Explore agent averaging around 22 seconds, code-reviewer averaging 37 seconds, and general-purpose averaging 18 seconds. code-reviewer is slow because it reads multiple files — and once that was confirmed by numbers, my hunch that "using code-reviewer for multi-file checks is heavy" finally had backing.

Identify agents with high error rates

jq -s '
  group_by(.subagent_type) |
  map({
    type: .[0].subagent_type,
    total: length,
    errors: map(select(.status == "error")) | length,
    error_rate: ((map(select(.status == "error")) | length) / length * 100 | round)
  }) | sort_by(-.error_rate)
' ~/.claude/logs/agent-invocations.jsonl
Enter fullscreen mode Exit fullscreen mode

Sum cost for a specific project (cwd)

jq -s '
  map(select(.cwd | contains("my-project"))) |
  { total_cost: (map(.cost_usd) | add) }
' ~/.claude/logs/cost-log.jsonl
Enter fullscreen mode Exit fullscreen mode

The cost log also has a cwd field, so you can filter by project path and get a total. Once you can see how much you've spent on which project at a glance, your sense of profitability changes.


Where I got stuck

I hit four concrete snags before the implementation was done. All of them were the "why doesn't this work?" kind of bug, where the symptom alone hides the cause.

Pitfall ①: stdin comes up empty and Python processes nothing

In the first version, I wired shell and Python together like this.

cat | python3 - <<'PY'
data = json.load(sys.stdin)  # ← ここで空が返ってくる
PY
Enter fullscreen mode Exit fullscreen mode

The misconception: "the heredoc Python script itself is passed via stdin, so I can read input from sys.stdin." In reality, <<'PY' occupies stdin as far as the shell is concerned, so by the time the script runs, sys.stdin is already at EOF. Even if cat receives the Stop hook JSON, there's no route for it to reach Python.

The fix is simple: "store it in a shell variable first, then pass it as an environment variable."

INPUT=$(cat)
export STOP_INPUT="$INPUT"

python3 - <<'PY'
data = json.loads(os.environ.get("STOP_INPUT", ""))
PY
Enter fullscreen mode Exit fullscreen mode

INPUT=$(cat) reads the Stop hook JSON, and export turns it into an environment variable. Inside the heredoc Python, read it from os.environ. This detour avoids the stdin conflict. The real script takes this shape precisely because of this history.

Pitfall ②: fromisoformat chokes on Z

Transcript timestamps come in the form "2026-09-10T08:30:00Z". At first I naively wrote datetime.fromisoformat(ts).

# Python 3.10以前ではこれが ValueError になる
datetime.datetime.fromisoformat("2026-09-10T08:30:00Z")
Enter fullscreen mode Exit fullscreen mode

macOS's system Python was 3.10, so this one line threw ValueError and the script died. I hadn't written a try/except, so the entire Stop hook exited with an error and no logs were written for several days. I had no idea why logs weren't being generated, and only found the cause after enabling debug logging with CC_AGENT_TRACKER_DEBUG=1.

The fix lives inside the parse_ts function.

def parse_ts(s):
    if not s:
        return None
    try:
        return datetime.datetime.fromisoformat(s.replace("Z", "+00:00"))
    except Exception:
        return None
Enter fullscreen mode Exit fullscreen mode

Replace Z with +00:00 before passing it in, and it works on Python 3.7+. It's also wrapped in try/except, so an unexpected format returns None instead of killing the script. The duration_ms computation only runs "when both t0 and t1 are not None," so entries that returned None are recorded with duration_ms: null. Give up on the calculation, but keep the record.

Pitfall ③: the Stop hook fires multiple times per session and writes duplicates

A few days after enabling the Stop hook, I found duplicate entries with the same tool_use_id in the log. For example, after a session that ran for three hours, agent-invocations.jsonl had two lines with the same ID.

Claude Code can emit the session end event more than once under certain conditions (such as when a subprocess's exit overlaps with the main session's exit). So the Stop hook ran twice for the same session, and the second run read the same transcript and wrote the same records.

The countermeasure was to add a dedup block at the top: "collect the tool_use_ids already written for this session into a set, and skip them next time."

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:
            try:
                r = json.loads(line)
                if r.get("session_id") == sid and r.get("tool_use_id"):
                    seen_ids.add(r["tool_use_id"])
            except Exception:
                continue
Enter fullscreen mode Exit fullscreen mode

At write time, the check looks like this.

for uid, (use_ts, _, inp, caller) in uses.items():
    if uid in seen_ids:
        continue  # 既に記録済みならスキップ
Enter fullscreen mode Exit fullscreen mode

Identity is determined by the session_id + tool_use_id combination because the probability of the same tool_use_id colliding across sessions isn't zero (they're UUIDs, so it's extremely low, but just in case). Reading only records with the same session_id, rather than the entire log, is a performance consideration.

Pitfall ④: filtering on name="Task" returns nothing

When I first investigated the transcript structure, the Claude Code interface showed "Task", so I wrote the script the same way.

if btype == "tool_use" and b.get("name") == "Task":
Enter fullscreen mode Exit fullscreen mode

Running this logged nothing. When I removed the filter for debugging and dumped every tool_use, I discovered the actual transcript records it as "name": "Agent".

The UI display name and the tool name in the transcript don't match. It's an internal naming asymmetry in Claude Code that isn't documented. The comment in the real script — "In Claude Code transcripts, the 'Task' tool is recorded as name="Agent"" — exists because of this experience. I left it in the first place anyone would look so nobody falls into the same trap.

The correct filter is a combination of two conditions.

if btype == "tool_use" and b.get("name") == "Agent":
    inp = b.get("input") or {}
    if "subagent_type" not in inp:
        continue
Enter fullscreen mode Exit fullscreen mode

Only lines where name == "Agent" and input contains subagent_type are targeted. Agent calls without a subagent_type can be entries for the main Claude session itself, and the two-stage filter exists to exclude those.

Pitfall ⑤: set -e dies silently and no logs remain at all

The initial shell header was set -euo pipefail. -e makes the entire shell exit immediately when a command returns non-zero.

The problem arose when running in a "session with no transcript." Claude Code's lightweight sessions (ask a short question and exit) sometimes don't generate a transcript. In that case os.path.exists(tp) returns False and Python exits with sys.exit(0), but there's also a path where the earlier [ -z "$INPUT" ] && exit 0 exits the shell normally. With -e enabled, depending on how the && evaluates, the script could stop unintentionally.

Also, calling sys.exit(0) inside the Python script is a normal exit (code 0) and fine, but in some environments the python3 command itself can't be found (not on PATH), returning exit code 127, and -e would stop the shell and skip all subsequent processing.

The fix is to drop -e and use -uo pipefail.

set -uo pipefail
Enter fullscreen mode Exit fullscreen mode

-u makes access to undefined variables an immediate error. pipefail makes the whole pipeline fail if any stage errors. -e is removed. The principle for a hook script is "even if I fall over, don't affect Claude Code's behavior," so bailing out proactively with exit 0 is safer. Errors are written to DEBUG_LOG (~/.claude/logs/stop_agent_tracker.log), so I prioritized having the cause visible in the debug log over dying silently under -e.


That covers the implementation details and the record of failures. The next part deals with how to fold these logs into daily operations, concrete examples of numbers changing, and a practical collection of jq recipes.

Pitfalls (extended, comprehensive edition)

The previous part covered five snags, but once you're in real operation, there are more. Listed in order of frequency.

  • python3 isn't found and the hook exits silently

    When Claude Code is launched from the GUI, it doesn't load the login shell and only has a minimal PATH, roughly /usr/bin:/bin. ~/.pyenv/shims/python3 and ~/.nvm/versions/.../bin/python3 aren't on it. The symptom: "the hook is registered but no log ever appears." With set -uo pipefail (no -e), the subsequent script keeps running after a 127, but the output file stays empty. Two countermeasures: write export PATH="/usr/local/bin:/usr/bin:$HOME/.pyenv/shims:$PATH" at the top of the script, or pin the full path /usr/bin/python3 instead of /usr/bin/env python3. On macOS Ventura and later, Xcode Command Line Tools provide /usr/bin/python3, so if a system Python exists, the latter is the safest.

  • ⑦ Some messages have content as a string rather than a list

    Most messages in transcript.jsonl have content as a list, but a handful of system messages have content as a string. Trying for b in content on those iterates character by character. The real script has a guard, if not isinstance(content, list): continue (lines 94–96), but if you modify the script yourself and delete that line, it breaks. Checking whether content is a list on every line is an ironclad rule.

  • jq -s on tens of thousands of JSONL lines runs out of memory

    jq -s reads the entire file into memory before processing. Once agent-invocations.jsonl exceeds tens of thousands of lines, a query eats several hundred MB of your Mac's memory. At a few hundred sessions a month, you get there in 3–4 months. The solution is the --stream option, or periodic archiving. I move logs monthly into ~/logs/archive/agent-YYYY-MM.jsonl and keep only the last 30 days in agent-invocations.jsonl.

  # 月次ローテーション(launchdで自動実行)
  MONTH=$(date -v-1m +%Y-%m)
  jq -c 'select(.ts | startswith("'"$MONTH"'"))' \
      ~/.claude/logs/agent-invocations.jsonl \
      >> ~/.claude/logs/archive/agent-${MONTH}.jsonl
Enter fullscreen mode Exit fullscreen mode
  • status: "pending" entries pile up

    If Claude Code is force-quit while a subagent is running (⌘Q, OS shutdown, OOM kill), the transcript ends without a tool_result being written. When the Stop hook runs in this state, no matching results exist, so the entry is recorded with status: "pending". That's correct behavior in itself, but once dozens of pending entries accumulate per month, average execution time aggregates get skewed (you need to filter duration_ms: null entries with select(.duration_ms != null)). Always adding select(.status == "ok") to monthly aggregate queries is the safe move.

  • ⑩ The dedup block gets slow on a huge log

    The dedup check at session start reads every line of the existing log looking for records whose session_id matches (lines 58–69). Once the log exceeds 100,000 lines, the hook takes several seconds per session. Two practical countermeasures. First, partition the JSONL by session date. Second, limit the seen_ids read to "only the last N lines" (entries from the same session aren't guaranteed to be contiguous, so it's not a complete fix, but it's a realistic compromise).

  • ⑪ Real paths land in cwd and you can't publish the script

    The JSONL stop_agent_tracker.sh outputs contains "cwd": "/Users/realname/dev/my-project". Forget to add this log to .gitignore, commit it to a repository, and your home directory's absolute path leaks. ~/.claude/logs/ sits outside any repo, so that's fine, but when handing logs to an analysis script, people sometimes push the whole log file to a gist or a shared folder. Either process it with jq 'del(.cwd)' before sharing, or add a preprocessing step that reduces the cwd field to just the directory name (basename).

  • ⑫ Burning days without knowing how to enable debug logging

    stop_agent_tracker.sh implements an environment variable switch, CC_AGENT_TRACKER_DEBUG=1 (line 31). Set it to 1 and detailed logs are written to ~/.claude/logs/stop_agent_tracker.log. stop_cost_log.sh does the same with CC_COST_DEBUG=1, writing to ~/.claude/logs/stop_cost_log.log. The first step to confirming whether a hook is running is tail -f ~/.claude/logs/stop_agent_tracker.log. I personally burned days in a "can't figure out why no logs appear" state without knowing this. After setting up hooks in a new environment, running one session in debug mode to confirm it works is a mandatory step.


Best practices

Here are the rules that solidified through implementation and operation.

1. Hook scripts always end with exit 0

If a Stop hook exits non-zero, Claude Code may show a warning at the next session start. Even if something goes wrong inside the script, the ironclad design is: write it to the log and bail out with exit 0. Removing set -e is for the same reason. Keep hook failures from spilling into Claude Code's own behavior.

2. Receive stdin on the first line with INPUT=$(cat) and repack it into an environment variable

There's no way to pass stdin to a heredoc Python script. Always use the INPUT=$(cat) → export STOP_INPUT → os.environ route. This is the direct lesson from pitfall ①. As long as you keep this shape, input will arrive no matter how much you modify the Python code.

3. Wrap the Python code in one big try/except, and write exceptions to the debug log instead of swallowing them

try:
    # メイン処理
    ...
except Exception as e:
    log(f"unexpected_error: {e}")
sys.exit(0)
Enter fullscreen mode Exit fullscreen mode

Even if an exception occurs, it exits normally via sys.exit(0). It's in the log. Claude Code is unaffected. The structure above achieves all three at once.

4. Confine timestamp handling to a parse_ts function that returns None

As the fromisoformat Z problem (pitfall ②) illustrates, timestamp processing is full of environment-dependent edge cases. Always isolate conversion in a dedicated function and return None on failure. If the duration_ms computation is conditioned on "only when both t0 and t1 are not None," None never causes a TypeError.

5. Add deduplication from the start. Add it later and you can't clean the existing log

Pitfall ③ (multiple Stop hook invocations) shows up days later as "why are there two lines of the same entry?" By then duplicates are buried in the existing log, and sort -u won't work as-is because JSONL key order isn't deterministic. The rule is to include dedup logic from the initial implementation. The cost is just "one pass reading the existing log."

6. Always include select(.duration_ms != null) and select(.status == "ok") in jq queries

Averages that include pending entries (duration_ms: null) get pulled toward zero. Add those two conditions as the default in aggregate queries, and you get accurate numbers based only on valid data.

jq -s '
  map(select(.duration_ms != null and .status == "ok")) |
  group_by(.subagent_type) |
  map({type: .[0].subagent_type, avg_ms: (map(.duration_ms) | add / length | round)})
' ~/.claude/logs/agent-invocations.jsonl
Enter fullscreen mode Exit fullscreen mode

7. Archive log files monthly and keep the live file light

When agent-invocations.jsonl grows, the dedup pass slows down (pitfall ⑩) and jq -s eats memory (pitfall ⑧). Set up a monthly launchd archive job from the start and these problems never occur. In practice, most people archive once they notice "the log got heavy," but putting it in from the beginning makes operation easier.

8. Look up the PRICING table by prefix, not exact match

The rate_for implementation in stop_cost_log.sh is the best practice as-is. Looking up keys with model.startswith(k) absorbs variation in model names with release-date suffixes, like claude-sonnet-4-6-20250620. Unknown models fall back to Sonnet-equivalent, so aggregation doesn't stop when a model not in the table appears.

9. After configuring a hook, always run one full session in debug mode to confirm

# ~/.claude/settings.json に追加(既存のhooksブロックに追記)
CC_AGENT_TRACKER_DEBUG=1 claude  # ← デバッグモードで起動
tail -f ~/.claude/logs/stop_agent_tracker.log  # 別ターミナルで監視
Enter fullscreen mode Exit fullscreen mode

"Configured" and "working" are different things. Actually run a session and confirm the log appears before turning debug mode off.

10. Don't filter on subagent_type alone; check for the existence of input.subagent_type

This is the counterpart to pitfall ④ (the name="Task" problem). name == "Agent" alone may match entries other than subagent calls. Always include the second-stage filter "subagent_type" in inp. That's how the real script is designed.

11. Use the cwd field to aggregate per project

Both cost-log.jsonl and agent-invocations.jsonl contain cwd. To get time and cost per project, this jq is enough.

# プロジェクト別コスト
jq -rsc '
  group_by(.cwd) |
  map({cwd: .[0].cwd, cost_usd: (map(.cost_usd) | add | . * 10000 | round / 10000)}) |
  sort_by(-.cost_usd)[] |
  "\(.cost_usd) USD  \(.cwd)"
' ~/.claude/logs/cost-log.jsonl
Enter fullscreen mode Exit fullscreen mode

Once you can see how much Claude Code you're using on which project, your sense of profitability changes.

12. Multiple Stop hooks can be registered and run independently

hooks.Stop in ~/.claude/settings.json is an array and accepts multiple entries. That's why stop_agent_tracker.sh and stop_cost_log.sh are registered separately. If one fails, the other still runs. Splitting scripts by role lets you fix and swap them independently.

13. Build the habit of looking at the numbers weekly

Accumulated logs mean nothing if you don't look at them. I run the ccstats alias every Monday morning.

alias ccstats='
  echo "=== エージェント平均時間(直近7日) ===";
  jq -sc "map(select(.status==\"ok\" and .duration_ms != null)) |
    group_by(.subagent_type) |
    map({type:.[0].subagent_type, avg_ms:(map(.duration_ms)|add/length|round)}) |
    sort_by(-.avg_ms)[]" \
    ~/.claude/logs/agent-invocations.jsonl;
  echo "=== 週間コスト ===";
  jq -sc "map(.cost_usd) | add | . * 100 | round / 100" \
    ~/.claude/logs/cost-log.jsonl
'
Enter fullscreen mode Exit fullscreen mode

After looking at these numbers, I can decide things like "cut back on code-reviewer calls this week" or "switch this project from Explore to Grep." Moving from gut feeling to number-based decisions is the real purpose of this system.


Summary

Claude Code's Stop hook lets you automate log collection just by piggybacking on an existing event: session end. Receive transcript_path, process it in two passes with Python, and append to agent-invocations.jsonl. The heart of this design is "change nothing in Claude Code itself." Even if the hook fails, it bails out with exit 0, so nothing spills into the main behavior. Because the side effects are near zero, you can leave it in a high-volume environment with confidence.

What the numbers actually showed me is that the agent I felt was heavy and the agent the data said was heavy weren't necessarily the same. In my environment, general-purpose felt heavy, but measured at an average of 18 seconds, it was faster than Explore (22 seconds). After seeing code-reviewer's 37 seconds, I developed the habit of "narrow down with Grep before reaching for code-reviewer on multi-file checks," and my weekly cost dropped by a felt 15–20%.

That change would not have happened without measurement.

A hook script quietly running at every session end keeps nudging up the precision of the autonomous environment behind ¥1.2M a month.

Which of your subagents do you think is the slow one — and have you ever actually measured it?


The full picture of the setup, the breakdown of the ¥1.2M/month, and a 30-day walkthrough are collected in a paid note (Japanese).

📕 How I actually earn with an autonomous Claude Code environment — the setup, real examples, getting started, and support


Written by **Lily* — I ship iOS apps and automate my content stack with Claude Code.
Follow along: Portfolio · X · GitHub*

Top comments (0)