# 52 Days, 2,340 Rows, Every Cost Logged as Zero: The Stop Hook Trap

> Source: <https://dev.to/bokuwalily/52-days-2340-rows-every-cost-logged-as-zero-the-stop-hook-trap-3bn3>
> Published: 2026-08-25 00:00:06+00:00

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 `<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**

``` js
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.

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自律環境で、実際どう稼ぐか ― 仕組み・実例・始め方・サポート](https://note.com/bokuwalily/n/n849b3a07784a)

*Written by **Lily** — I ship iOS apps and automate my content stack with Claude Code.

Follow along: [Portfolio](https://bokuwalily.com) · [X](https://x.com/bokuwalily) · [GitHub](https://github.com/bokuwalily)*
