Going from a $700/month student side hustle to a real business in six months came down to one thing: I stopped instructing Claude and started letting it run the whole environment autonomously. That environment then spent 52 days writing 2,340 log rows where every single cost was zero — and it never once complained.
Why This Setup Works
Most people who start with Claude Code use it as a convenient chat AI. But once monthly revenue crosses a certain threshold, your thinking shifts. Instead of "issuing instructions and getting output," you move to "letting the whole environment run itself."
Here's the concrete difference. In the first mode, you type a prompt every time and get a result back. In the second, hooks fire while you sleep, scripts execute, and logs accumulate. In my case, there are a dozen-odd jobs running on a schedule via launchd, and a Claude Code Stop hook that fires at the end of every session. I wake up to yesterday's brief sitting on my Desktop, and a record in ~/.claude/metrics/costs.jsonl of how many tokens each session consumed — that was the ideal, anyway.
Why track cost at all? Claude Code's MAX plan is a flat monthly fee, but there's an intuitive ceiling where "using too much effectively chokes next month's capacity." Without visibility into which session used which model and how much, you're running autonomous agents with zero cost awareness. The more convenient an autonomous environment gets, the more it silently eats. That's why measurement comes first.
The Stop hook is the mechanism that handles this measurement. When a Claude Code session ends (when the user runs /exit, or on timeout), it runs the commands registered in the Stop section of settings.json. Put a cost-aggregation script there and you get a "session ends = automatically recorded" pipeline. No more hand-typing costs into a spreadsheet.
"It's running" and "it's running correctly" are different things — any engineer knows the feeling. Logs streaming out with all-zero contents is worse than an error. Errors are easy to notice; a log full of zeros keeps up the appearance of "recording normally" while being completely hollow inside. I missed it for 52 days and 2,340 rows.
The root cause of cost aggregation "quietly lying" was one simple fact: the Stop hook's stdin does not contain usage or model fields. The first version was written on the assumption that "the Stop hook's stdin must contain token counts."
The Overall Flow
Getting a picture of the pipeline the current cost-tracker.js (v2) runs makes the rest of this easier to follow.
Claude Code セッション
│
│ (セッション終了イベント)
▼
Stop hook 起動
│
│ stdin に流れてくるペイロード
│ { session_id, transcript_path, cwd, hook_event_name, ... }
│ ↑ usage / model は存在しない ← ここが旧バージョンの穴
▼
cost-tracker.js
│
│ transcript_path を取り出す
▼
セッション JSONL ファイル(Claude Codeが書き続けているログ)
│
│ 行ごとにパース
│ { type: "assistant", message: { model, usage: { ... } } }
▼
sumUsageFromTranscript()
│
│ 全アシスタントターンを走査して
│ input_tokens / output_tokens / cache_* を累積加算
▼
RATE_TABLE でモデル別レートを掛ける
│
│ estimatedCostUsd = Σ tokens × rates / 1e6
▼
~/.claude/metrics/costs.jsonl に1行追記
The key point is the design principle: "the only trustworthy data source in a Stop hook is the transcript file." Rather than expecting usage to arrive directly in the stdin payload, you read the JSONL file Claude Code keeps writing throughout the session yourself. That is the core of the switch to v2.
Per-Model Rate Table
The actual rates are defined on lines 34–38 of the file.
// Approximate per-1M-token billing rates (USD).
// Cache creation: 1.25x input rate. Cache read: 0.1x input rate.
const RATE_TABLE = {
haiku: { in: 0.80, out: 4.0, cacheWrite: 1.00, cacheRead: 0.08 },
sonnet: { in: 3.00, out: 15.0, cacheWrite: 3.75, cacheRead: 0.30 },
opus: { in: 15.00, out: 75.0, cacheWrite: 18.75, cacheRead: 1.50 }
};
Three tiers: Haiku, Sonnet, and Opus. getRates() dispatches based on whether the model name string contains 'haiku' or 'opus', and treats anything else as Sonnet. A simple string match is plenty here; this doesn't warrant a regex.
Caching matters more in cost calculation than you'd expect. cache_creation_input_tokens (the initial cache write) is 1.25x normal input, and cache_read_input_tokens (reads from the second time on) is 0.1x. If you use caching heavily with Opus, the read rate works out to $1.50 per million tokens — which can be cheaper than Haiku's normal input.
The Function That Scans the Transcript
Lines 57–90 are the heart of this fix.
function sumUsageFromTranscript(transcriptPath) {
let content;
try {
content = fs.readFileSync(transcriptPath, 'utf8');
} catch {
return null; // 読めなければ 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; // user/tool行はスキップ
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 !== 'unknown') model = msg.model;
}
return { inputTokens, outputTokens, cacheWriteTokens, cacheReadTokens, model };
}
It parses JSON line by line, picks out only entries where type === 'assistant', and accumulates message.usage. For the model name it uses the last one found (the assumption being that even if the model switches mid-session, the final assistant turn carries the correct model).
Error handling is deliberately loose. If the file can't be read, null; if a line can't be parsed, continue. In both cases the goal is to avoid failing the Stop hook as a whole. A missing cost record is less of a problem than the Stop hook throwing an error and affecting Claude Code's session termination.
Reading stdin and the Full Flow
Let's also look at the stdin handling starting at line 100.
const MAX_STDIN = 64 * 1024;
let raw = '';
process.stdin.setEncoding('utf8');
process.stdin.on('data', chunk => {
if (raw.length < MAX_STDIN) raw += chunk.substring(0, MAX_STDIN - raw.length);
});
process.stdin.on('end', () => {
try {
const input = raw.trim() ? JSON.parse(raw) : {};
const transcriptPath = (typeof input.transcript_path === 'string' && input.transcript_path)
? input.transcript_path
: process.env.CLAUDE_TRANSCRIPT_PATH || null;
// ...
let usageTotals = null;
if (transcriptPath && fs.existsSync(transcriptPath)) {
usageTotals = sumUsageFromTranscript(transcriptPath);
}
} catch {
// Non-blocking — never fail the Stop hook.
}
process.stdout.write(raw); // ECC hook convention: stdin をそのまま stdout に流す
});
It accepts at most 64KB from stdin. Claude Code's Stop hook payload itself is only a few hundred bytes, so this is more than enough, but setting a cap means the design won't choke even if a huge input flows in.
Extracting transcript_path is two-tiered: use it if it's in the stdin JSON, otherwise fall back to the CLAUDE_TRANSCRIPT_PATH environment variable. If neither exists it becomes null, and since usageTotals stays null, all token counts get recorded as 0 — the same result as the old version, but an intentional zero. Because the fallback exists, the transcript is always read in the normal case where a file path is available.
The final process.stdout.write(raw) follows the ECC hook convention. A Stop hook can pass data to the next hook by piping stdin to stdout, and this script performs cost aggregation as a side effect while passing the payload straight through.
The One-Row-Per-Turn Cumulative Design
The behavior documented in the comment on lines 20–22 is quietly important.
// Cumulative behavior: Stop fires per assistant response, not per session.
// Each row therefore represents the cumulative session total up to that point.
// To get per-session cost, take the last row per session_id.
The Stop hook fires per assistant turn. That means if the assistant responds 10 times in one session, 10 rows get appended to costs.jsonl. Each row is the session's cumulative token count up to that point, so if you want "the cost of one session," you take the last row among the rows sharing the same session_id. For daily aggregation, you group by session_id, take the final row of each, and then group those by date.
The design is simple, but it has the fault tolerance that even if things crash midway, you can still use up to the last recorded row. Even if the session doesn't terminate cleanly, the cumulative total up to that point survives.
Implementation Details
toNumber() — The Function That Silently Absorbs Type Mismatches
Inside sumUsageFromTranscript() from earlier, every addition goes through toNumber(v) (lines 47–50).
function toNumber(v) {
const n = Number(v);
return Number.isFinite(n) ? n : 0;
}
Only three lines, but without it you get accidents. Claude Code's JSONL contains a mix of lines with missing fields, lines with null, and occasionally lines where cache-related fields come in as strings. Number(null) becomes 0, but Number(undefined) and Number(null) alike lead to NaN + a valid value = NaN. One NaN anywhere makes the entire cumulative sum NaN, and the final cost estimate gets written to the JSONL as NaN. When you write an aggregation script later, rows containing NaN break the total.
Number.isFinite() is used because it's stricter than isNaN(). isNaN(Infinity) returns false. If Infinity gets into cost calculation the total breaks, so isFinite rejects both cases at once.
You might feel this is "too defensive," but the Stop hook runs unattended at the end of every session. Nobody checks it each time, so when it receives unexpected input, quietly treating it as zero and not corrupting the whole log matters more.
Triple Fallback for session_id
Look at how sessionId is extracted on lines 108–112.
const sessionId =
sanitizeSessionId(input.session_id) ||
sanitizeSessionId(process.env.ECC_SESSION_ID) ||
sanitizeSessionId(process.env.CLAUDE_SESSION_ID) ||
'default';
Three stages of fallback. The reason is that the Stop hook's execution environment differs slightly depending on the session's context.
For sessions launched directly by Claude Code, input.session_id arrives normally. But when a session is started via a wrapper script called from launchd, or when the hook is run standalone for testing, the stdin payload may be omitted or arrive in a different format. ECC_SESSION_ID and CLAUDE_SESSION_ID exist so the value can be passed in as an environment variable for those cases.
sanitizeSessionId() is a function imported from session-bridge.js; it strips anything other than alphanumerics, hyphens, and underscores so the value is safe to use in paths and the like. Since session IDs written to files via the Stop hook go straight into rows of costs.jsonl, a stray newline or slash would break downstream aggregation scripts.
The final 'default' fallback is the worst case: "the session ID couldn't be obtained from anywhere." In that case multiple sessions all get recorded as session_id: "default", and per-session aggregation becomes meaningless. Actually hitting this path is an abnormal situation, so during debugging (covered later), "rows with the ID default are piling up" can be a useful clue.
Cost Calculation Precision and the Intent Behind Math.round()
The way cost is computed on lines 128–133 is a bit distinctive.
const estimatedCostUsd = Math.round((
(inputTokens / 1e6) * rates.in +
(outputTokens / 1e6) * rates.out +
(cacheWriteTokens / 1e6) * rates.cacheWrite +
(cacheReadTokens / 1e6) * rates.cacheRead
) * 1e6) / 1e6;
It multiplies by 1e6, calls Math.round(), then divides back by 1e6. This is to contain floating-point rounding error at six decimal places.
JavaScript's Number is IEEE 754 double-precision, so for the same reason 0.1 + 0.2 === 0.3 is false, rounding error rides along in an accumulation of multiple rate multiplications. A value like 0.000003000000000000001 in the JSONL is hard for humans to read, and the error is distracting when aggregating later.
Rounding to six decimal places reflects the judgment that "precision below one microdollar in USD isn't needed." Haiku's input rate is $0.80 per million tokens, so processing 10,000 tokens is $0.008, and 1,000 tokens is $0.0008. Six digits is plenty to develop a feel for cost.
appendFile and the JSONL Format Design Decision
The write on line 150 is simple.
appendFile(path.join(metricsDir, 'costs.jsonl'), `${JSON.stringify(row)}\n`);
The reason for choosing JSONL (JSON Lines) is that appending is safe. To append to a JSON array file, you have to rewrite the trailing ], so if the process dies mid-write the whole file is corrupted. JSONL is a "one line = one object" format, so appending just adds a line at the end. Even if the process dies partway, existing lines are intact.
Since the Stop hook runs inside Claude Code's shutdown sequence, the risk of being force-killed mid-write isn't zero. This is a format chosen so that even in that situation the data loss is minimal.
ensureDir(metricsDir) is a utility that creates ~/.claude/metrics/ if it doesn't exist. If the directory doesn't exist the first time the Stop hook runs, appendFile fails, so it's checked every time. It's a wrapper around fs.mkdirSync(path, { recursive: true }) and does nothing if the directory already exists.
process.stdout.write(raw) — The Convention That Keeps the Pipeline Intact
I touched on the meaning of the last line (line 156) earlier, but let's dig in a bit more.
process.stdout.write(raw);
Under ECC (Enhanced Claude Code) hook conventions, a Stop hook is required to pipe stdin straight to stdout. This is so multiple Stop hooks can be chained in series.
In Claude Code's settings.json, you can list multiple commands in the Stop section. In that case each command receives stdin and pipes it to stdout so the next command can receive that data. If the cost-tracking hook swallowed the data midway, downstream hooks (a self-audit hook, for instance) wouldn't receive the payload.
cost-tracker.js performs cost aggregation as a side effect and behaves as a transparent relay point in the pipeline. That's why, even when an error occurs, it skips via catch {} and always reaches process.stdout.write(raw) at the end. A missing cost record has less impact than a broken pipeline.
Where I Got Stuck
Why It Took 52 Days to Notice
I noticed the zero records in ~/.claude/metrics/costs.jsonl when I tried to generate a weekly report and ran an aggregation script. Aggregating 52 days of JSONL, all 2,340 rows had estimated_cost_usd of 0.
At first I thought, "well, of course — it's running on a plan with no per-token billing." Claude Code's MAX contract is a flat monthly fee rather than token billing, so you could argue a cost of 0 is correct. But all rows having 0 input_tokens too is wrong. The assistant had responded thousands of times; there's no way token counts weren't being recorded.
The problem was that the log was "running without errors." Hook execution completed normally every session. Rows kept getting appended to costs.jsonl just fine. The timestamps were correct. It's just that every numeric field was zero.
"Looks like it's running, but hollow inside" — this is the failure pattern that delays discovery the most. With an error log you notice immediately. A silent malfunction doesn't exist until you consciously check the data.
What Was Wrong with v1
I don't have the old version's code on hand, but working backwards from the behavior, the structure is obvious. It took the Stop hook's stdin payload and tried to pull usage and model directly out of it.
// v1 の想定(実際は存在しないフィールド)
const { usage, model, session_id } = JSON.parse(raw);
const inputTokens = usage?.input_tokens ?? 0;
The actual payload that arrives on the Stop hook's stdin is exactly what's written in the settings.json comment (lines 9–10), and it contains neither usage nor model.
{ session_id, transcript_path, cwd, hook_event_name, ... }
Since usage?.input_tokens is optional chaining, it returns undefined, and ?? 0 makes it zero. No error occurs at all — it just records zero every time.
The assumption I'd overlooked was the belief that "the Stop hook's stdin contains the aggregated cost information for the whole session." It doesn't. The Stop hook payload carries only the minimum information needed for session management; actual token usage can only be obtained by reading the transcript file. Claude Code's official documentation has no explicit statement about this, and I didn't understand the behavior until I actually read a transcript to confirm it.
The Swamp of transcript_path Not Being Available
After rewriting to v2, I hit another snag. It worked fine locally, but sessions launched via launchd would sporadically mix in rows recorded as zero.
Digging in, there were cases where the condition transcriptPath && fs.existsSync(transcriptPath) was evaluating false. There were two causes.
First, a PATH problem. launchd's PATH environment variable is basically /usr/bin:/bin:/usr/sbin:/sbin, which doesn't include /opt/homebrew/bin where Node.js lives, or anything under nvm. Since cost-tracker.js is a Node.js script, an incorrect PATH means the hook itself never starts, and Claude Code falls back to passing the payload straight through. In that case nothing gets written to costs.jsonl at all, so you can detect it as "a day with few rows = a day the hook wasn't running." Setting PATH explicitly in the plist resolved it.
Second, an absolute-path problem with transcript_path. The path where Claude Code writes session JSONL files is a fixed location under ~/.claude/projects/, but in some environments the string arriving in the transcript_path field was a relative path. fs.existsSync resolves relative paths against the current directory, so when expanded against the CWD of a launchd-started process (usually /), the file naturally isn't found.
The fix is to absolutize with path.resolve(transcript_path) as soon as you receive transcript_path. That said, looking at the current v2 code, path.resolve() isn't in there. Relative paths only arrived in the environment where the problem actually reproduced, and normal Claude Code sessions deliver absolute paths, so I judged the risk to be limited and deferred the fix. If you see the symptom "zero records appear sporadically only via launchd," this is the fastest place to start suspecting.
The Problem of null Returns Becoming "Invisible Zeros"
sumUsageFromTranscript() returns null when the read fails (the return null on line 62). On the calling side, line 125 destructures it like this.
const {
inputTokens = 0,
outputTokens = 0,
// ...
} = usageTotals || {};
When null arrives it expands to {}, and all the default values of 0 are used. As a result, "a row where the transcript couldn't be read" and "a row where the transcript was read and the tokens genuinely were zero (essentially impossible)" are indistinguishable in costs.jsonl.
I found this out when I manually opened costs.jsonl to check after noticing "a given day's records look oddly sparse." There were rows with transcript_path: "" mixed in. When transcript_path is an empty string, fs.existsSync("") returns false, so sumUsageFromTranscript() is never called, usageTotals stays null, and the result is a zero record.
The root cause is that there are cases where session_id is available but transcript_path is not. Line 141 of the v2 code has transcript_path: transcriptPath || '', so an empty string is recorded when the path isn't available. I deal with it by either excluding rows with an empty transcript_path in the aggregation script later, or treating zero records as "no data" using the condition estimated_cost_usd === 0 && input_tokens === 0 && transcript_path === "".
Calculating Costs 7x Too High by Not Knowing the "Cumulative Design"
After v2 started working correctly, I got stuck again when writing a daily cost aggregation script. Trying to produce a day's cost by naively summing all rows gave numbers many times larger than my intuition said they should be.
Rereading the comment on lines 19–21 (quoted earlier), it hit me: "ah, it's a cumulative design." Because the Stop hook fires per assistant turn, 10 responses in a session means 10 appended rows, each holding a cumulative token count for "up to turn 1," "up to turn 2," and so on. Summing all rows counts turn 1's tokens 10 times, turn 2's tokens 9 times, and so on — double and triple counting.
The correct way to aggregate is to group rows by the same session_id and take only the last row of each group.
const sessions = {};
for (const line of lines) {
const row = JSON.parse(line);
sessions[row.session_id] = row; // 後から来る行で上書きされる
}
const dailyCost = Object.values(sessions)
.filter(r => r.timestamp.startsWith('2026-07-08'))
.reduce((sum, r) => sum + r.estimated_cost_usd, 0);
Calculating "daily cost" with this bug in place inflates the number the more responses a session contains. A session with 10 responses shows roughly 10x the "apparent cost" of a session with 1 response — and I noticed it because the numbers varied wildly even though the same model should have been in use.
"The data is recorded correctly but the report is lying" — this too is one of those swamps where everything looks like it's running without errors. When writing aggregation logic, checking sample data by hand before implementing is the reliable approach. In my case, I didn't grasp the design intent until I grepped out rows with the same session_id and eyeballed them.
One-Liners Useful for Debugging
Here are the commands I reached for most often when I was stuck.
Check how many rows are zero records:
node -e "
const fs = require('fs');
const lines = fs.readFileSync(process.env.HOME + '/.claude/metrics/costs.jsonl','utf8')
.split('\n').filter(Boolean).map(JSON.parse);
const zeros = lines.filter(r => r.input_tokens === 0);
console.log('total:', lines.length, 'zeros:', zeros.length);
"
Find rows where transcript_path is empty:
node -e "
const fs = require('fs');
const lines = fs.readFileSync(process.env.HOME + '/.claude/metrics/costs.jsonl','utf8')
.split('\n').filter(Boolean).map(JSON.parse);
lines.filter(r => !r.transcript_path).forEach(r => console.log(r.timestamp, r.session_id));
"
When I ran these after switching to v2, the first command printing zeros: 0 means every record has token counts in it. If zeros remain, either there's a problem obtaining transcript_path or it's falling through to the fallback path.
Having to throw away 52 days of zero records hurt, but since switching to v2 accurate token counts accumulate every session. Only once I had a real sense of my autonomous environment's cost could I start having the conversation of "which work consumes the most, and where should I optimize." A log full of zeros isn't "proof it's running" — it's proof nothing is being recorded. It took me 2,340 rows to learn that.
Pitfalls
Beyond the "52 days of zero records," "cumulative design misaggregation," and "missing launchd PATH" covered above, there are several other places you can get stuck when running Stop hooks in production. Here's an exhaustive list.
① Model names will stop matching in the future
getRates() dispatches on whether the string contains 'haiku' or 'opus', treating everything else as Sonnet. That's sufficient under the current model naming convention (claude-haiku-4-5-20251001, etc.), but if Anthropic changes family names in the future (say, the haiku line gets a different label), the match falls through and everything gets Sonnet rates. No error appears — the rates just quietly drift. Safer options: an operational habit of periodically reconciling the code against the latest model names, or a branch that logs when the match fails.
② Cases where the Stop hook doesn't fire
If the Claude Code process is force-killed with SIGKILL (system OOM, battery death, etc.), the Stop hook doesn't run. It fires on a normal /exit and on timeout, but a force kill skips the session shutdown sequence, so that session's records are missing entirely. If you care about error in monthly cost aggregation, your aggregation script needs to interpret "no record = data loss, not zero." Concretely, one approach is to monitor the ratio of session count to row count and alert on "days with significantly fewer rows than expected."
③ Blocking synchronous reads of huge transcripts
sumUsageFromTranscript() uses fs.readFileSync(). In long Claude Code sessions (complex implementation tasks running dozens of turns or more), the transcript JSONL can reach tens of MB. readFileSync is a synchronous call, so Node.js's event loop stalls for the duration. The Stop hook itself is a one-shot execution, so a stalled event loop isn't a problem in itself, but if the read takes 2–3 seconds, you feel it as a delay in Claude Code's session-exit response. If it becomes a measured problem, switching to a stream-based line-by-line read from the start of the file avoids loading the entire file into memory at once.
④ stdin contention in the hook chain
When multiple hooks are registered in the Stop section, each runs in series, with the previous hook's stdout becoming the next hook's stdin. The final process.stdout.write(raw) in cost-tracker.js is there to honor this convention. If a hook that calls process.exit(0) cuts in mid-chain, or a hook exists that writes data other than raw to stdout, downstream hooks won't receive the payload correctly. When writing each hook in the chain, you have to be rigorous about the "receive stdin, pipe it straight to stdout" shape. That discipline applies to Stop hooks in general, not just cost-tracker.
⑤ costs.jsonl bloat
The Stop hook fires per assistant turn and appends one row. At 10 sessions a day averaging 20 turns each, that's 200 rows a day. Keep it up for a year and you're past 70,000 rows. costs.jsonl itself isn't much of a storage concern since each row is only a few hundred bytes, but if the aggregation script you write later is structured to read every row, scan time grows as time passes. Either an operational habit of periodically archiving old rows into annual files, or shaping aggregation queries to "scan only the last N days," makes long-term operation easier.
⑥ Timezone issues
The timestamp written by new Date().toISOString() is UTC. If you want to do daily aggregation on a JST basis, a row like 2026-07-09T00:30:00.000Z is July 9 in JST but counts as July 8 in UTC. It shows up as aggregation dates drifting for late-night sessions that cross midnight. The fix is to add +9h when extracting the timestamp and convert to a JST date string — but keeping the value in the JSONL as UTC and converting at aggregation time preserves consistency better. If you rewrite the JSONL values in JST, you lose the original data when you later want to go back to a UTC basis.
⑦ Read timing against a transcript being written
There's an edge case where the Stop hook runs while Claude Code is in the middle of writing an assistant turn (after a very short turn's response, for example). At that moment, the last line of the transcript JSONL may end with incomplete JSON. The parsing part of sumUsageFromTranscript() handles things line by line with try { entry = JSON.parse(line); } catch { continue; }, so a broken trailing line is skipped via continue. The result is that line's tokens aren't counted (aggregation stops at the last valid line). This almost never becomes a problem in practice, but it can be one contributor to "cost recorded slightly lower than reality."
⑧ session_id collisions and the 'default' fallback
As mentioned earlier, when session_id can't be obtained from anywhere it becomes the fixed value 'default'. If multiple Claude Code instances are running simultaneously (launchd jobs running in parallel, for instance) and each records as 'default', the cumulative design's "group by session_id and use the last row" aggregation falls apart. Only the last row among the rows grouped under 'default' is used, and that might be the smallest session's or the largest session's — the result is indeterminate. I recommend periodically grepping for session_id: "default" rows to keep track of how often it happens.
⑨ require() and Node.js version mismatches
cost-tracker.js is written in CommonJS ('use strict'; const fs = require('fs');). Right after bumping the Node.js version with nvm, or if a dependency migrates to ESM-only, require() will try to load an ESM module and throw ERR_REQUIRE_ESM. Node.js spits this error to stderr at hook execution time, but it doesn't show up in Claude Code's normal output. The hook fails silently and nothing gets written to costs.jsonl, which is indistinguishable from "zero records." You have to check node --version and your dependencies' ESM status whenever you upgrade Node.js.
Best Practices
After 52 days of failure, the rewrite to v2, and the smaller snags that followed, here's what I can distill into best practices for cost tracking with a Stop hook.
① The only trustworthy data source in a Stop hook is the transcript file
The most important lesson. The stdin payload does not contain token usage. Take transcript_path and read the session JSONL yourself. The 52 days it took me to realize this is the fact behind this entire article. If you're going to measure something, first confirm where that data actually lives. Write code on the assumption that "it must be there" and you build a tool that lies silently.
② Never let the Stop hook fail
A missing cost record is less of a problem than the Stop hook throwing an error and affecting Claude Code's session shutdown sequence. Wrap the entire main flow in try { ... } catch { } and design it so that even on error it always reaches process.stdout.write(raw). Lines 151–156 of cost-tracker.js are that pattern. It's a priority judgment: "it's fine to lose a day of logs; it's not fine to break the session."
③ Honor the stdin → stdout pass-through pattern
If every hook in the chain honors "receive stdin, pipe to stdout," you can chain any number of hooks in series. Break that convention and downstream hooks quietly stop working. When writing a hook, keep the functionality confined to a side effect and minimize the impact on the pipeline.
④ Centralize type conversion defensively in toNumber()
Don't feed values read from external data (transcript JSONL) directly into arithmetic. One NaN anywhere in a cumulative sum makes the whole aggregation NaN. Keep one function around that uses Number.isFinite() to reject both NaN and Infinity. Three lines to write, and it saves you weeks later.
⑤ Write append-only files as JSONL
The reason for choosing JSON Lines (one object per line) over a JSON array is simple. Appending to a JSON array rewrites the end of the file, so if the process dies partway the whole file is corrupted. With JSONL, appending just adds a line at the end. Since the Stop hook runs during Claude Code's shutdown sequence, the risk of being force-killed mid-write isn't zero. JSONL is the format that keeps loss minimal in that situation.
⑥ Aggregate using the last row per session_id
Because the Stop hook fires per turn, one session spans multiple rows. Naively summing all rows inflates cost several-fold. Group by session_id and use only the last row of each group. The design is spelled out in a comment (lines 19–21 of cost-tracker.js), but if you write an aggregation script without reading the code, you will fall into this hole.
const sessions = {};
for (const line of lines) {
const row = JSON.parse(line);
sessions[row.session_id] = row; // 後の行で上書き → 自動的に最終行が残る
}
⑦ Set PATH explicitly in the launchd plist
launchd's execution environment has a base PATH of /usr/bin:/bin:/usr/sbin:/sbin, and whether Node.js is at /opt/homebrew/bin or under nvm, the hook won't start if PATH doesn't reach it. It fails silently and nothing gets written to costs.jsonl. Set PATH explicitly in the plist under <key>EnvironmentVariables</key> and include Node.js's real path. This fix is basic and common to every script that runs under launchd.
⑧ Create the metrics directory with ensureDir every time
Calling appendFile while ~/.claude/metrics/ doesn't exist raises an error. It happens right after first setting up the hook, or if you accidentally delete the directory. Call a wrapper around fs.mkdirSync(path, { recursive: true }) every time. It does nothing when the directory exists, so overhead is essentially zero. Code that assumes a directory is "obviously there" breaks silently when the environment changes.
⑨ Put a cap on stdin
const MAX_STDIN = 64 * 1024;
if (raw.length < MAX_STDIN) raw += chunk.substring(0, MAX_STDIN - raw.length);
The Stop hook payload itself is a few hundred bytes, but reading without a cap means some anomaly sending in a huge input pressures Node.js's heap. Capping at 64KB gives you a design that doesn't affect the normal case and doesn't choke in the abnormal one.
⑩ Sanitize session_id before using it
Writing a session ID to a file as-is creates injection risk if you later build filenames from that field or embed it in a path. Put a sanitization layer in front that strips anything other than alphanumerics, hyphens, and underscores. sanitizeSessionId() is factored out into session-bridge.js because the same logic is used by multiple hooks. Put shared logic in lib/ and import it. Not lazily copy-pasting duplicates is what lowers your later maintenance cost.
⑪ Absolutize relative paths with path.resolve()
fs.existsSync(transcriptPath) resolves relative paths against the current directory. Since the CWD for a launchd-started process is usually /, a relative path points somewhere unintended. Adding a single line to absolutize with path.resolve(transcriptPath) right after receiving transcript_path makes the behavior independent of how it was launched. The current v2 omits this; it's not a problem in normal Claude Code sessions, but it's the first place to suspect if sporadic zero records via launchd bother you.
⑫ Record in UTC and convert at aggregation time
Record the JSONL timestamp with new Date().toISOString() (UTC) and do the JST conversion in the aggregation script. Rewriting the source data in a specific timezone creates a situation you can't undo. Store in UTC and you can convert to any timezone later. If you want daily aggregation on a JST basis, one aggregation function converting via new Date(timestamp).toLocaleDateString('ja-JP', { timeZone: 'Asia/Tokyo' }) is all you need.
⑬ Distinguish "zero record" from "no data"
In the current JSONL, a row with an empty transcript_path and a row where the transcript was readable and tokens genuinely were zero are indistinguishable. Excluding the combination estimated_cost_usd === 0 && input_tokens === 0 && transcript_path === '' as "no data" in your aggregation script improves accuracy on days with zeros mixed in. Rather than trusting silent zeros, a structure where you can explicitly label "this row has no data" keeps your instincts accurate over long-term operation.
Summary
Code written on the assumption that usage and model arrive in the Stop hook spat out all-zero rows for 52 days and 2,340 rows — an experience that taught me firsthand how frightening the state of "looks like it's running" is. If an error had appeared, I'd have caught it the next day. A bug that lies silently doesn't visibly exist until you consciously check the data.
The core of the v2 fix is one line. I stopped "reading usage from stdin" and changed it to "read transcript_path from stdin and read that file myself." That alone filled 52 days of hollowness.
The only trustworthy data source in a Stop hook is the transcript file — and this lesson isn't limited to Claude Code. It applies everywhere you write automation on the assumption that "the data I want must be in the hook or event payload." Start writing without checking the payload spec and you build code that breaks silently in exactly the cases where you needed to go read the data yourself.
The precision of an autonomous environment depends on the precision of its measurement. Until cost is recorded correctly, the conversation of "which sessions are heavy and where can I cut" can't even begin. Cost tracking isn't the last step of building your environment — it's the foundation to verify first.
仕組みの全体像・月120万の内訳・30日手順は有料noteにまとめています。
📕 Claude Code自律環境で、実際どう稼ぐか ― 仕組み・実例・始め方・サポート
Written by **Lily* — I ship iOS apps and automate my content stack with Claude Code.
Follow along: Portfolio · X · GitHub*
Top comments (0)