From 2707ef2eb20ddf492fa04cacd727e6d8a1ab9b2e Mon Sep 17 00:00:00 2001 From: Levi-Evan Date: Thu, 14 May 2026 16:35:12 -0400 Subject: [PATCH] fix(hooks): cost-tracker reads usage from transcript instead of stdin MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Stop hook's stdin payload does not include `usage` or `model` — those fields exist only in the session transcript JSONL (per-assistant-turn `message.usage` blocks). The previous implementation looked for `input.usage.input_tokens` on stdin and silently produced zero-filled rows (observed: 2,340 rows captured with 0.0% non-zero token rate over 52 days). Fix: read `transcript_path` from Stop stdin, scan the JSONL, sum usage across all assistant turns. Use `message.model` to pick a rate tier (haiku/sonnet/opus). Track cache tokens separately (cache_creation and cache_read have distinct billing rates). Behavior note: Stop fires per assistant response, so each row in costs.jsonl represents the cumulative session total at that point. To get per-session cost: take the last row per session_id. Verified by reading a real Stop hook payload from .claude/projects/*/.jsonl — payload contains {session_id, transcript_path, cwd, hook_event_name, ...} only, matching the published Claude Code hook spec. --- scripts/hooks/cost-tracker.js | 136 ++++++++++++++++++++++++++++------ 1 file changed, 115 insertions(+), 21 deletions(-) diff --git a/scripts/hooks/cost-tracker.js b/scripts/hooks/cost-tracker.js index a3f2f896..291345cc 100755 --- a/scripts/hooks/cost-tracker.js +++ b/scripts/hooks/cost-tracker.js @@ -1,63 +1,157 @@ #!/usr/bin/env node /** - * Cost Tracker Hook + * Cost Tracker Hook (v2) * - * Appends lightweight session usage metrics to ~/.claude/metrics/costs.jsonl. + * Reads transcript_path from Stop hook stdin, sums usage across all + * assistant turns in the session JSONL, and appends one row to + * ~/.claude/metrics/costs.jsonl. + * + * Stop hook stdin payload: { session_id, transcript_path, cwd, hook_event_name, ... } + * The Stop payload does NOT include `usage` or `model` directly. The previous + * version of this hook expected those fields and silently produced zero-filled + * rows (verified: 2,340 rows captured with 0.0% non-zero token rate over 52 + * days). The fix is to read the transcript file Claude Code already passes us. + * + * JSONL assistant entry shape (per Claude Code): + * { type: "assistant", message: { model, usage: { input_tokens, output_tokens, + * cache_creation_input_tokens, cache_read_input_tokens } } } + * + * 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. To get per-day + * spend, aggregate. */ 'use strict'; +const fs = require('fs'); const path = require('path'); const { ensureDir, appendFile, getClaudeDir } = require('../lib/utils'); -const { estimateCost } = require('../lib/cost-estimate'); const { sanitizeSessionId } = require('../lib/session-bridge'); -const MAX_STDIN = 1024 * 1024; -let raw = ''; +// 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 } +}; -function toNumber(value) { - const n = Number(value); +function getRates(model) { + const m = String(model || '').toLowerCase(); + if (m.includes('haiku')) return RATE_TABLE.haiku; + if (m.includes('opus')) return RATE_TABLE.opus; + return RATE_TABLE.sonnet; +} + +function toNumber(v) { + const n = Number(v); return Number.isFinite(n) ? n : 0; } +/** + * Scan the session JSONL and sum token usage across all assistant turns. + * Returns { inputTokens, outputTokens, cacheWriteTokens, cacheReadTokens, model } + * or null on read failure. + */ +function sumUsageFromTranscript(transcriptPath) { + let content; + try { + content = fs.readFileSync(transcriptPath, 'utf8'); + } catch { + return null; + } + + let inputTokens = 0; + let outputTokens = 0; + let cacheWriteTokens = 0; + let cacheReadTokens = 0; + let model = 'unknown'; + + for (const line of content.split('\n')) { + if (!line.trim()) continue; + let entry; + try { entry = JSON.parse(line); } catch { continue; } + + if (entry.type !== 'assistant') continue; + const msg = entry.message; + if (!msg || !msg.usage) continue; + + const u = msg.usage; + inputTokens += toNumber(u.input_tokens); + outputTokens += toNumber(u.output_tokens); + cacheWriteTokens += toNumber(u.cache_creation_input_tokens); + cacheReadTokens += toNumber(u.cache_read_input_tokens); + + if (msg.model && msg.model !== 'unknown') model = msg.model; + } + + return { inputTokens, outputTokens, cacheWriteTokens, cacheReadTokens, model }; +} + +const MAX_STDIN = 64 * 1024; +let raw = ''; + process.stdin.setEncoding('utf8'); process.stdin.on('data', chunk => { - if (raw.length < MAX_STDIN) { - const remaining = MAX_STDIN - raw.length; - raw += chunk.substring(0, remaining); - } + 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 usage = input.usage || input.token_usage || {}; - const inputTokens = toNumber(usage.input_tokens || usage.prompt_tokens || 0); - const outputTokens = toNumber(usage.output_tokens || usage.completion_tokens || 0); - const model = String(input.model || input._cursor?.model || process.env.CLAUDE_MODEL || 'unknown'); + const transcriptPath = (typeof input.transcript_path === 'string' && input.transcript_path) + ? input.transcript_path + : process.env.CLAUDE_TRANSCRIPT_PATH || null; + const sessionId = sanitizeSessionId(input.session_id) || sanitizeSessionId(process.env.ECC_SESSION_ID) || sanitizeSessionId(process.env.CLAUDE_SESSION_ID) || 'default'; + let usageTotals = null; + if (transcriptPath && fs.existsSync(transcriptPath)) { + usageTotals = sumUsageFromTranscript(transcriptPath); + } + + const { + inputTokens = 0, + outputTokens = 0, + cacheWriteTokens = 0, + cacheReadTokens = 0, + model = 'unknown' + } = usageTotals || {}; + + const rates = getRates(model); + const estimatedCostUsd = Math.round(( + (inputTokens / 1e6) * rates.in + + (outputTokens / 1e6) * rates.out + + (cacheWriteTokens / 1e6) * rates.cacheWrite + + (cacheReadTokens / 1e6) * rates.cacheRead + ) * 1e6) / 1e6; + const metricsDir = path.join(getClaudeDir(), 'metrics'); ensureDir(metricsDir); const row = { - timestamp: new Date().toISOString(), - session_id: sessionId, + timestamp: new Date().toISOString(), + session_id: sessionId, + transcript_path: transcriptPath || '', model, - input_tokens: inputTokens, - output_tokens: outputTokens, - estimated_cost_usd: estimateCost(model, inputTokens, outputTokens) + input_tokens: inputTokens, + output_tokens: outputTokens, + cache_write_tokens: cacheWriteTokens, + cache_read_tokens: cacheReadTokens, + estimated_cost_usd: estimatedCostUsd }; appendFile(path.join(metricsDir, 'costs.jsonl'), `${JSON.stringify(row)}\n`); } catch { - // Keep hook non-blocking. + // Non-blocking — never fail the Stop hook. } + // Pass stdin through (required by ECC hook convention). process.stdout.write(raw); });