最新资讯
dev.to 在信任 AI 生成的测试用例前,如何逐条复核 unite.ai Franklin Templeton 构建 AI 研究工作流以揭示风险 techxplore.com AI 助力寻找更优的钛合金 3D 打印工艺参数 engadget.com Perplexity 推出“teammates”日常工作任务 AI agent computerworld.com 别提数据中心:OpenAI 改称“compute campuses” theguardian.com 俄美试图阻止就自主武器制定具有约束力的规则 bloomberg.com Nvidia 加强东南亚芯片追踪以打击走私 thestack.technology 模型路由需求升温,Agent Router 推出开源替代方案 www.youtube.com Google DeepMind 的 Philipp Schmid:AI 同事需要具备的七个要素 unite.ai Cashfree 为 AI Agent 的托管结账加入 WebMCP 工具 dev.to 我们在 DolphinBench 上测试记忆检索:纯关键词搜索暴露了自身回归 dev.to 别再把 Agent 写成 while 循环,把它建模为状态机
多元动态
登录 注册
AI编码 dev.to · @Lily

用 Stop Hook 自动记录 Claude Code 子代理:12 个坑与每周省 15–20% 成本

原文 我的收藏

作者分享用 Stop hook 做会话后日志采集的 Claude Code 自动化方案,实测各子代理耗时(code-reviewer 约 37 秒最慢),据此调整使用习惯后每周 Claude Code 支出下降约 15–20%,并列出 12 个实现坑点与常用 jq 配方。

AI推荐理由 Claude Code 重度使用者的实操复盘:通过 Stop hook 自动记录子代理会话日志,量化各子代理耗时(general-purpose 约 18 秒、Explore 约 22 秒、code-reviewer 约 37 秒),并据此调整习惯使每周成本下降约 15–20%。提供了具体实现、12 个踩坑点和 jq 用法,对 Agent 成本治理有直接参考价值;但成本节省为个人经验数据,未独立验证。

68

正文

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 - > ~/.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*

来源报道 · 1 篇 · 以下链接均前往原始媒体

← 返回精选动态 原文

更多内容