52 Days, 2,340 Rows, Every Cost Logged as Zero: The Stop Hook Trap A developer who scaled a side hustle into a real business in six months discovered that their autonomous Claude Code environment had been logging costs as zero for 52 days and 2,340 rows. The root cause was that the Stop hook's stdin did not include usage or model fields, so the developer rewrote the cost tracker to parse the session transcript JSONL file directly. The fix ensures accurate cost tracking for autonomous AI sessions. 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. 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." 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. 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. Lines 57–90 are the heart of this fix. js 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. Let's also look at the stdin handling starting at line 100. js 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 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. Inside sumUsageFromTranscript from earlier, every addition goes through toNumber v lines 47–50 . js 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. Look at how sessionId is extracted on lines 108–112. js 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. The way cost is computed on lines 128–133 is a bit distinctive. js 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. 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. 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. 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. 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. 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. 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 === "" . 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. js 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. Here are the commands I reached for most often when I was stuck. Check how many rows are zero records: js 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: js 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. 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 grep ping 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. 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. js 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