52 Days of Silent Zeros: The Stop Hook Payload Has No usage Field
52 Days of Silent Zeros: The Stop Hook Payload Has No usage Field
52天的静默零值:Stop Hook 负载中并不包含 usage 字段
I used to be a college student scraping by on ¥100k a month. Then I was laid off. Six months later, after building an autonomous Claude Code environment, I’m clearing ¥1.2M a month in revenue. What closed that gap wasn’t talent or capital — it was continuously growing an environment that thinks and works in my place. 我曾经是一名月入仅 10 万日元的大学生,后来被裁员了。六个月后,在构建了一个自动化的 Claude Code 环境后,我的月收入达到了 120 万日元。填补这一差距的并非天赋或资本,而是不断优化一个能代替我思考和工作的环境。
Why this setup works: The first thing I noticed when trying to grow income through solo development: raising the quality of the environment has far better ROI than raising the amount of work. Spending a week building a system that runs autonomously and stacks up deliverables while I’m away beats hammering Claude Code for 8 hours a day — at least when you measure revenue three months out. This series is a record of mass-producing that kind of environment. 为什么这种模式有效:在尝试通过独立开发增加收入时,我注意到的第一件事是:提升环境质量的投资回报率远高于增加工作量。花一周时间构建一个能在我离开时自动运行并堆叠交付成果的系统,远胜于每天对着 Claude Code 敲击 8 小时——至少从三个月的收入周期来看是这样。本系列文章就是关于如何批量生产这种环境的记录。
Today’s topic is cost visibility. When you live on Claude Code, token consumption happens as naturally as breathing. The problem is that you can’t optimize a cost you can’t see. “How much did I spend this month?” “Which session was heavy?” “How many dollars a month would I save by shifting a bit more Sonnet work to Haiku?” — without answers to these, gross margin doesn’t improve even as revenue grows. 今天的主题是成本可视化。当你依赖 Claude Code 工作时,Token 的消耗就像呼吸一样自然。问题在于,你无法优化看不见的成本。“我这个月花了多少钱?”“哪次会话消耗最大?”“如果把更多 Sonnet 的任务转移到 Haiku,每月能省多少钱?”——如果没有这些问题的答案,即使收入增长,毛利率也不会改善。
Claude Code has a hook system. Shell scripts or Node.js scripts configured in ~/.claude/settings.json run automatically when tied to specific events. The Stop hook is the most important of them, and it fires every time the assistant completes a turn. Not just at session end — once per completed turn (see the comment on line 19 of cost-tracker.js: “Stop fires per assistant response, not per session”).
Claude Code 拥有一个 Hook 系统。配置在 ~/.claude/settings.json 中的 Shell 脚本或 Node.js 脚本可以在特定事件触发时自动运行。其中最重要的是 Stop Hook,它在助手完成每一轮对话时都会触发。不仅仅是在会话结束时,而是每一轮回复完成时都会触发(参见 cost-tracker.js 第 19 行的注释:“Stop 在每次助手回复时触发,而非每次会话”)。
The natural idea here is: “If I record token counts in the Stop hook, I get automatic cost tracking.” The implementation looks simple. The hook receives a JSON payload on stdin. Read usage.input_tokens and usage.output_tokens from that payload, append to a JSONL file, done — and building it with that assumption is exactly the mistake the first version made.
这里很自然的想法是:“如果我在 Stop Hook 中记录 Token 数量,就能实现自动成本追踪。”实现看起来很简单:Hook 通过标准输入(stdin)接收一个 JSON 负载,从中读取 usage.input_tokens 和 usage.output_tokens,追加到 JSONL 文件中,搞定——但基于这种假设去构建,正是第一版代码犯下的错误。
The Stop hook payload has no usage field. Here’s what the payload actually looks like:
{ "session_id": "...", "transcript_path": "/path/to/session.jsonl", "cwd": "/path/to/workdir", "hook_event_name": "Stop" }
Stop Hook 的负载中根本没有 usage 字段。实际的负载长这样:
{ "session_id": "...", "transcript_path": "/path/to/session.jsonl", "cwd": "/path/to/workdir", "hook_event_name": "Stop" }
session_id, transcript_path, cwd, hook_event_name — that’s it. No model name, no token counts, no cost. Because this isn’t spelled out in the docs, if you write code assuming usage exists, you read a nonexistent field and get undefined, Number(undefined) becomes NaN, you keep adding NaN, and 0 gets recorded. No errors. Just silent zeros, piling up every turn.
只有 session_id、transcript_path、cwd 和 hook_event_name。没有模型名称,没有 Token 计数,没有成本信息。由于文档中没有明确说明,如果你编写代码时假设 usage 存在,你读取的将是一个不存在的字段并得到 undefined,Number(undefined) 会变成 NaN,你不断累加 NaN,最终记录下的全是 0。没有报错,只有静默的零值,在每一轮对话中不断堆积。
The comment in my cost-tracker.js preserves the evidence verbatim (lines 12–13):
* The Stop payload does NOT include usage or model directly. The previous
* version of this hook expected those fields and silently produced zero-filled
* rows (verified: 2,340 rows captured with 0.0% non-zero token rate over 52
* days).
我在 cost-tracker.js 中的注释保留了这一证据(第 12-13 行):
* Stop 负载不直接包含 usage 或 model。该 Hook 的前一个版本
* 预设了这些字段,并静默产生了全零的行(已验证:52 天内捕获了
* 2,340 行数据,非零 Token 率为 0.0%)。
52 days, 2,340 rows, 0.0% non-zero rate. As a tracker, a total failure. And the whole time, the script kept running without complaint, the log file grew steadily, and running cost-summary.sh returned “$0.00 / 0 sess”. It looked like it was working while recording nothing. This isn’t merely an implementation bug — it’s an architectural mistake stemming from a misunderstanding of what Claude Code’s Stop hook is.
52 天,2,340 行数据,非零率 0.0%。作为一个追踪器,这完全失败了。而在这期间,脚本一直默默运行,日志文件稳步增长,运行 cost-summary.sh 返回的结果却是 “$0.00 / 0 sess”。它看起来在工作,实际上什么都没记录。这不仅仅是一个实现上的 Bug,更是一个架构上的错误,源于对 Claude Code Stop Hook 本质的误解。
To fix it, you have to give up on a field that doesn’t exist in the payload and go read the place the payload points to — transcript_path. That switch is the core of this article.
要修复它,你必须放弃那个在负载中根本不存在的字段,转而去读取负载指向的位置——transcript_path。这种思路的转变是本文的核心。
The overall flow / 整体流程
Here’s the corrected architecture at a glance: 以下是修正后的架构概览:
┌─────────────────────────────────────────────────────┐
│ Claude Code Session │
│ │
│ Assistant turn complete │
│ │
│ ▼ │
│ Stop hook fires │
│ │
│ ▼ stdin (JSON) │
│ { session_id, transcript_path, cwd, ... } │
│ │
│ ▼ │
│ cost-tracker.js │
│ ┌──────────────────────────────────────────────┐ │
│ │ 1. Get transcript_path │ │
│ │ 2. Read JSONL │ │
│ │ 3. Filter lines where type="assistant" │ │
│ │ 4. Sum message.usage │ │
│ │ 5. Calculate cost based on model rate │ │
│ │ 6. Append to ~/.claude/metrics/costs.jsonl │ │
│ └──────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────┘
The key is the shift in understanding: “the Stop hook is not a courier delivering cost information.” The hook is strictly an event notifier, and all the notification contains is an address (transcript_path) telling you which session transcript to read. The cost information lives inside the transcript.
关键在于理解的转变:“Stop Hook 不是运送成本信息的快递员。”该 Hook 严格来说只是一个事件通知器,通知中包含的唯一信息就是一个地址(transcript_path),告诉你去读取哪个会话记录。成本信息实际上存在于记录文件内部。
The structure of the JSONL that transcript_path points to / transcript_path 指向的 JSONL 结构
Claude Code writes every turn of a session into a single JSONL file. Each line corresponds to one message, and the type field distinguishes the kind. What you need for cost calculation are the type: "assistant" lines. Their structure is as follows:
Claude Code 将会话的每一轮都写入同一个 JSONL 文件。每一行对应一条消息,type 字段用于区分类型。进行成本计算时,你需要的是 type: "assistant" 的行。其结构如下:
{
"type": "assistant",
"message": {
"model": "claude-sonnet-4-6",
"usage": {
"input_tokens": 12483,
"output_tokens": 847,
"cache_creation_input_tokens": 8192,
"cache_read_input_tokens": 3200
}
}
}
Summing the four kinds — input_tokens, output_tokens, cache_creation_input_tokens, cache_read_input_tokens — across all assistant turns gives you the session’s total token consumption. Multiply by the billing rates and you have cost.
将四种 Token 类型——input_tokens、output_tokens、cache_creation_input_tokens 和 cache_read_input_tokens——在所有助手回复轮次中相加,即可得到该会话的总 Token 消耗量。乘以计费费率,就能得出成本。
Implementing the sumUsageFromTranscript function / 实现 sumUsageFromTranscript 函数
Lines 57–90 of cost-tracker.js implement this JSONL accumulation logic.
cost-tracker.js 的第 57-90 行实现了这一 JSONL 累加逻辑。
function sumUsageFromTranscript(transcriptPath) {
let content;
try {
content = fs.readFileSync(transcriptPath, 'utf8');
} catch {
return null;
}
let inputTokens = 0;
let outputTokens = 0;
let cacheWriteTokens = 0;
let cacheReadTokens = 0;
let model = 'unknown';
for (const line of content.split('\n')) {
if (!line.trim()) continue;
let entry;
try {
entry = JSON.parse(line);
} catch {
continue;
}
if (entry.type !== 'assistant') continue;
const msg = entry.message;
if (!msg || !msg.usage) continue;
const u = msg.usage;
inputTokens += toNumber(u.input_tokens);
outputTokens += toNumber(u.output_tokens);
cacheWriteTokens += toNumber(u.cache_creation_input_tokens);
cacheReadTokens += toNumber(u.cache_read_input_tokens);
if (msg.model && msg.model !== '