Read the script
#!/usr/bin/env node
// ponytail — imports past Claude Code sessions into the band's task ledger, so the board can
// compare them: cost, agent time, tokens, turns, name and model. Started in the background by
// `/ponytail band import`; also runs by hand. Sessions already in the ledger are left alone.
const fs = require('fs');
const os = require('os');
const path = require('path');
const { spawnSync } = require('child_process');
const { StringDecoder } = require('string_decoder');
const { getClaudeDir } = require('./ponytail-config');
// The band's columns (CSV_HEADER in hooks/band/metrics.ts; a test keeps the two equal).
const CSV_HEADER = 'key,started,project,name,cost_usd,turns,agent_minutes,tokens_total,tokens_output,reuse_pct,reused,new,callers_open,tests,level,model';
// USD per million tokens from https://platform.claude.com/docs/en/about-claude/pricing, checked
// 2026-10-17: [input, 5-minute cache write, 1-hour cache write, cache read, output]. Opus 5.5 and
// Fable 5.1 also match Claude Code's own totals in real transcripts. A model missing here, or a
// fast-mode reply, gets no estimate: an empty cost beats a guessed one.
const OPUS = [5, 6.25, 10, 0.5, 25];
const PRICES = {
'claude-fable-5-1': [10, 12.5, 20, 0.25, 50],
'claude-fable-5': [10, 12.5, 20, 1, 50],
'claude-opus-5-5': [4, 5, 8, 0.2, 20],
'claude-opus-5': OPUS,
'claude-opus-4-8': OPUS,
'claude-opus-4-7': OPUS,
'claude-opus-4-6': OPUS,
'claude-opus-4-5': OPUS,
'claude-sonnet-5-5': [2, 2.5, 4, 0.1, 10],
'claude-sonnet-5': [2, 2.5, 4, 0.2, 10],
'claude-sonnet-4-6': [3, 3.75, 6, 0.3, 15],
'claude-sonnet-4-5': [3, 3.75, 6, 0.3, 15],
'claude-haiku-5-5': [0.1, 0.125, 0.2, 0.01, 0.5],
'claude-haiku-4-5': [1, 1.25, 2, 0.1, 5],
};
// Haiku 5.5 bills a request whose prompt (input, cache reads and writes) is over 100,000 tokens higher.
const HAIKU_55_LONG = [0.5, 0.625, 1, 0.05, 2.5];
const WEB_SEARCH = 10 / 1000;
const IDLE_MS = 30 * 60000;
const modelId = m => m.replace(/\[.*\]$/, '').replace(/-\d{8}$/, '');
// One reply's price from its usage, or null when the price is not known.
function replyCost(model, u) {
let p = PRICES[modelId(model)];
if (!p || u.speed === 'fast') return null;
const cc = u.cache_creation || {};
const split = cc.ephemeral_5m_input_tokens !== undefined || cc.ephemeral_1h_input_tokens !== undefined;
const w5 = split ? cc.ephemeral_5m_input_tokens || 0 : u.cache_creation_input_tokens || 0;
const w1 = split ? cc.ephemeral_1h_input_tokens || 0 : 0;
const input = u.input_tokens || 0;
const read = u.cache_read_input_tokens || 0;
if (modelId(model) === 'claude-haiku-5-5' && input + read + w5 + w1 > 100000) p = HAIKU_55_LONG;
const searches = (u.server_tool_use && u.server_tool_use.web_search_requests) || 0;
return (input * p[0] + w5 * p[1] + w1 * p[2] + read * p[3] + (u.output_tokens || 0) * p[4]) / 1e6 + searches * WEB_SEARCH;
}
const human = r => r.type === 'user' && (r.turnOrigin === 'human' || (r.origin && r.origin.kind === 'human'));
const promptText = r => {
const c = r.message && r.message.content;
const t = typeof c === 'string' ? c : Array.isArray(c) ? (c.find(b => b && b.type === 'text') || {}).text || '' : '';
return t.trim();
};
const ms = r => (r.timestamp ? Date.parse(r.timestamp) : NaN);
const toolResult = r => (r.toolResult !== undefined ? r.toolResult
: Array.isArray(r.message && r.message.content) && r.message.content.some(b => b && b.type === 'tool_result'));
// Only what the numbers need from one record: a long session's transcript can pass 500 MB.
function slim(r) {
const m = r.message || {};
const c = human(r) ? m.content : undefined;
const text = typeof c === 'string' ? c.slice(0, 500)
: Array.isArray(c) ? c.filter(b => b && b.type === 'text').slice(0, 1).map(b => ({ type: 'text', text: String(b.text).slice(0, 500) })) : undefined;
return {
type: r.type, timestamp: r.timestamp, turnOrigin: r.turnOrigin, origin: r.origin && { kind: r.origin.kind },
isMeta: r.isMeta, toolResult: toolResult(r),
sessionId: r.sessionId, customTitle: r.customTitle, totalCostUSD: r.totalCostUSD,
message: { id: m.id, model: m.model, usage: m.usage, content: text },
};
}
// A JSONL file line by line, so no transcript is too big to read (one string stops at 512 MB).
function readRecords(file) {
const out = [];
const take = l => { if (l.startsWith('{')) try { out.push(slim(JSON.parse(l))); } catch (e) {} };
const fd = fs.openSync(file, 'r');
const buf = Buffer.alloc(1 << 20);
const dec = new StringDecoder('utf8');
let rest = '';
try {
for (let n; (n = fs.readSync(fd, buf, 0, buf.length, null)) > 0;) {
const parts = (rest + dec.write(buf.subarray(0, n))).split('\n');
rest = parts.pop();
parts.forEach(take);
}
take(rest + dec.end());
} finally {
fs.closeSync(fd);
}
return out;
}
// One session's numbers from its transcript records and its subagents' records. Null for a session
// nobody typed into (benchmarks, `claude -p`, scripts) or one without a model reply.
function sessionStats(records, subagentRecords = [], sessionId = '') {
const prompts = records.filter(human);
if (prompts.length === 0) return null;
// Usage repeats on every record of one message: count each message id once, subagents included.
const seen = new Set();
const replies = {};
let tokens = 0;
let output = 0;
let estimate = 0;
const reply = r => r.type === 'assistant' && r.message && r.message.model && r.message.model !== '<synthetic>';
for (const r of records) if (reply(r)) replies[r.message.model] = (replies[r.message.model] || 0) + 1;
for (const r of [...records, ...subagentRecords]) {
if (!reply(r)) continue;
const { id, model, usage } = r.message;
if (!usage || !id || seen.has(id)) continue;
seen.add(id);
tokens += (usage.input_tokens || 0) + (usage.output_tokens || 0) + (usage.cache_read_input_tokens || 0) + (usage.cache_creation_input_tokens || 0);
o