#!/usr/bin/env node /** * Cost Tracker Hook (v2) * * 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. * * Harness-cost contract (optional, opt-in by the statusline): * If the user's statusline (which receives `cost.total_cost_usd` directly * from Claude Code) writes `{ts, cost_usd}` to * `/harness-cost-.json` on each render, this hook * prefers that authoritative value over the transcript-sum estimate when * the cache is fresh (≤ 300s). The transcript-sum is kept as a safe * fallback because: * - the hard-coded rate table cannot represent Opus 4.7's >200K-token * 2x tier or the 1h-cache 2x tier (under-counts on long sessions); * - summing the full transcript double-counts work done across * `--resume` boundaries while `cost.total_cost_usd` is per-process. * Absent a writer, behavior is unchanged. */ 'use strict'; const fs = require('fs'); const os = require('os'); const path = require('path'); const { ensureDir, appendFile, getClaudeDir } = require('../lib/utils'); const { sanitizeSessionId } = require('../lib/session-bridge'); const HARNESS_COST_MAX_AGE_SECONDS = 300; /** * Read authoritative harness cost from the per-session cache file. * @param {string} sessionId * @param {number} maxAgeSeconds * @returns {number|null} cost in USD, or null on miss / stale / parse error */ function readHarnessCost(sessionId, maxAgeSeconds) { if (!sessionId) return null; try { const fp = path.join(os.tmpdir(), `harness-cost-${sessionId}.json`); if (!fs.existsSync(fp)) return null; const obj = JSON.parse(fs.readFileSync(fp, 'utf8')); const ts = Number(obj && obj.ts); const cost = Number(obj && obj.cost_usd); if (!Number.isFinite(ts) || !Number.isFinite(cost) || cost < 0) return null; const age = Math.floor(Date.now() / 1000) - ts; if (age < 0 || age > maxAgeSeconds) return null; return cost; } catch { return null; } } // 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 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) 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; 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 transcriptCostUsd = Math.round(( (inputTokens / 1e6) * rates.in + (outputTokens / 1e6) * rates.out + (cacheWriteTokens / 1e6) * rates.cacheWrite + (cacheReadTokens / 1e6) * rates.cacheRead ) * 1e6) / 1e6; // Prefer the harness's authoritative `cost.total_cost_usd` when the // statusline has written it to the per-session cache (see contract in // the file header). The harness number reflects API-billed truth // (correct rates, 1h-cache 2x, >200K tier 2x) and is per-process so it // does not drift across `--resume`. Cache miss → transcript-sum. const harnessCost = readHarnessCost(sessionId, HARNESS_COST_MAX_AGE_SECONDS); const estimatedCostUsd = harnessCost !== null ? Math.round(harnessCost * 1e6) / 1e6 : transcriptCostUsd; const metricsDir = path.join(getClaudeDir(), 'metrics'); ensureDir(metricsDir); const row = { timestamp: new Date().toISOString(), session_id: sessionId, transcript_path: transcriptPath || '', model, 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 { // Non-blocking — never fail the Stop hook. } // Pass stdin through (required by ECC hook convention). process.stdout.write(raw); });