import { readdir, readFile, stat } from "node:fs/promises"; import { existsSync, createReadStream } from "node:fs"; import { join } from "node:path"; import { homedir } from "node:os"; import { createInterface } from "node:readline"; import { debug } from "./logger"; export interface ClaudeHookData { hook_event_name: string; session_id: string; transcript_path: string; cwd: string; model: { id: string; display_name: string; }; workspace: { current_dir: string; project_dir: string; }; version?: string; output_style?: { name: string; }; cost?: { total_cost_usd: number; total_duration_ms: number; total_api_duration_ms: number; total_lines_added: number; total_lines_removed: number; }; context_window?: { total_input_tokens: number; total_output_tokens: number; context_window_size: number; used_percentage?: number | null; remaining_percentage?: number | null; current_usage?: { input_tokens: number; output_tokens: number; cache_creation_input_tokens: number; cache_read_input_tokens: number; } | null; }; exceeds_200k_tokens?: boolean; rate_limits?: { five_hour?: { used_percentage: number; resets_at: number; }; seven_day?: { used_percentage: number; resets_at: number; }; }; worktree?: { name: string; path: string; branch?: string; original_cwd: string; original_branch?: string; }; agent?: { name: string; }; effort?: { level?: string; }; thinking?: { enabled?: boolean; }; } export function getEffortLevel(hookData: ClaudeHookData): string | null { const level = hookData.effort?.level; if (typeof level !== "string") return null; const trimmed = level.trim(); return trimmed ? trimmed : null; } export function getThinkingEnabled(hookData: ClaudeHookData): boolean | null { const enabled = hookData.thinking?.enabled; if (typeof enabled !== "boolean") return null; return enabled; } export function getOutputStyleName(hookData: ClaudeHookData): string | null { const name = hookData.output_style?.name; if (typeof name !== "string") return null; const trimmed = name.trim(); return trimmed ? trimmed : null; } export function getClaudePaths(): string[] { const paths: string[] = []; const envPath = process.env.CLAUDE_CONFIG_DIR; if (envPath) { envPath.split(",").forEach((path) => { const trimmedPath = path.trim(); if (existsSync(trimmedPath)) { paths.push(trimmedPath); } }); } if (paths.length === 0) { const homeDir = homedir(); const configPath = join(homeDir, ".config", "claude"); const claudePath = join(homeDir, ".claude"); if (existsSync(configPath)) { paths.push(configPath); } if (existsSync(claudePath)) { paths.push(claudePath); } } return paths; } export async function findProjectPaths( claudePaths: string[], ): Promise { const projectPaths: string[] = []; for (const claudePath of claudePaths) { const projectsDir = join(claudePath, "projects"); if (existsSync(projectsDir)) { try { const entries = await readdir(projectsDir, { withFileTypes: true }); for (const entry of entries) { if (entry.isDirectory()) { const projectPath = join(projectsDir, entry.name); projectPaths.push(projectPath); } } } catch (error) { debug(`Failed to read projects directory ${projectsDir}:`, error); } } } return projectPaths; } export async function findTranscriptFile( sessionId: string, ): Promise { const claudePaths = getClaudePaths(); const projectPaths = await findProjectPaths(claudePaths); for (const projectPath of projectPaths) { const transcriptPath = join(projectPath, `${sessionId}.jsonl`); if (existsSync(transcriptPath)) { return transcriptPath; } } return null; } /** * Lists every agent transcript belonging to a session. Agents write to * /subagents/agent-*.jsonl, and workflow agents nest further under * subagents/workflows//, so the walk has to recurse. * * This is the single definition of where agent usage lives. Session cost and * daily cost both go through it, so they cannot disagree about which files * count. */ export async function findAgentTranscriptPaths( sessionDir: string, ): Promise { const paths: string[] = []; const walk = async (dir: string): Promise => { let entries; try { entries = await readdir(dir, { withFileTypes: true }); } catch { // Sessions without agents have no subagents directory at all. return; } for (const entry of entries) { const entryPath = join(dir, entry.name); if (entry.isDirectory()) { await walk(entryPath); } else if ( entry.name.startsWith("agent-") && entry.name.endsWith(".jsonl") ) { paths.push(entryPath); } } }; await walk(join(sessionDir, "subagents")); return paths; } /** * The session's own directory is what ties an agent transcript to it. Their * first line is no test: a forked agent's is a `fork-context-ref` record, * which names the session as `parentSessionId` and has no `sessionId`. */ export async function findAgentTranscripts( sessionId: string, projectPath: string, ): Promise { return findAgentTranscriptPaths(join(projectPath, sessionId)); } export async function getEarliestTimestamp( filePath: string, ): Promise { try { const content = await readFile(filePath, "utf-8"); const lines = content.trim().split("\n"); let earliestDate: Date | null = null; for (const line of lines) { if (!line.trim()) continue; try { const json = JSON.parse(line); if (json.timestamp && typeof json.timestamp === "string") { const date = new Date(json.timestamp); if (!isNaN(date.getTime())) { if (earliestDate === null || date < earliestDate) { earliestDate = date; } } } } catch { continue; } } return earliestDate; } catch (error) { debug(`Failed to get earliest timestamp for ${filePath}:`, error); return null; } } export async function sortFilesByTimestamp( files: string[], oldestFirst = true, ): Promise { const filesWithTimestamps = await Promise.all( files.map(async (file) => ({ file, timestamp: await getEarliestTimestamp(file), })), ); return filesWithTimestamps .sort((a, b) => { if (a.timestamp === null && b.timestamp === null) return 0; if (a.timestamp === null) return 1; if (b.timestamp === null) return -1; const sortOrder = oldestFirst ? 1 : -1; return sortOrder * (a.timestamp.getTime() - b.timestamp.getTime()); }) .map((item) => item.file); } export async function getFileModificationDate( filePath: string, ): Promise { try { const stats = await stat(filePath); return stats.mtime; } catch { return null; } } export interface ParsedEntryMessage { id?: string; usage?: { input_tokens?: number; output_tokens?: number; cache_creation_input_tokens?: number; cache_read_input_tokens?: number; cache_creation?: { ephemeral_1h_input_tokens?: number; ephemeral_5m_input_tokens?: number; }; }; // Transcripts carry this both as a string and as an object with an `id`; // PricingService.extractModelId handles both, so keep it unnarrowed. model?: unknown; } /** @info The fields of a transcript line that cost attribution reads. */ export interface RawEntryFields { requestId?: string; model?: unknown; model_id?: unknown; message?: ParsedEntryMessage; } export interface ParsedEntry { timestamp: Date; message?: ParsedEntryMessage; costUSD?: number; isSidechain?: boolean; raw: RawEntryFields; } export function createUniqueHash(entry: ParsedEntry): string | null { const messageId = entry.message?.id; const requestId = entry.raw.requestId; if (!messageId || !requestId) { return null; } return `${messageId}:${requestId}`; } export function deduplicateEntries(entries: ParsedEntry[]): ParsedEntry[] { const processedHashes = new Set(); const deduplicated: ParsedEntry[] = []; for (const entry of entries) { const uniqueHash = createUniqueHash(entry); if (uniqueHash && processedHashes.has(uniqueHash)) { continue; } if (uniqueHash) { processedHashes.add(uniqueHash); } deduplicated.push(entry); } return deduplicated; } const STREAMING_THRESHOLD_BYTES = 1024 * 1024; /** * @info Transcripts are append-only, so path + size + mtime identifies their * contents exactly. Segments resolve transcripts independently — context and * the month's scan of uncached days can both parse the session transcript in * the same render — and a status line process is short-lived, so memoising * within the render removes the duplicate parse without letting a stale result * outlive the file it came from. */ // Holds the in-flight parse, not its result: segments are started together, so // they all reach the cache before the first parse resolves and would otherwise // every one of them miss. const parsedFileCache = new Map>(); /** * @info The array and its entries are shared between callers. The readonly * array makes appending to it a type error; the entries are read-only by * convention. */ export async function parseJsonlFile( filePath: string, ): Promise { try { const stats = await stat(filePath); const fileSizeBytes = stats.size; const cacheKey = `${filePath}:${fileSizeBytes}:${stats.mtimeMs}`; const cached = parsedFileCache.get(cacheKey); if (cached) { debug(`Reusing parsed entries for ${filePath}`); // Awaited rather than returned, so a cached failure lands in the catch // below like a fresh one instead of rejecting out of here. return await cached; } const useStreaming = fileSizeBytes > STREAMING_THRESHOLD_BYTES; if (useStreaming) { debug( `Using streaming parser for large file ${filePath} (${Math.round(fileSizeBytes / 1024)}KB)`, ); } const parsing = useStreaming ? parseJsonlFileStreaming(filePath) : parseJsonlFileInMemory(filePath); parsedFileCache.set(cacheKey, parsing); const entries = await parsing; debug(`Parsed ${entries.length} entries from ${filePath}`); return entries; } catch (error) { debug(`Failed to read file ${filePath}:`, error); return []; } } interface RawTranscriptLine extends RawEntryFields { timestamp?: string; costUSD?: unknown; isSidechain?: unknown; } /** * @info Keeps references only to the fields consumers read. A transcript line * also carries tool results and message content that nothing here touches, and * holding the whole parsed line kept those alive for every entry — peak memory * grew with transcript size rather than with the usage data actually needed. * Every parser builds entries through here so they cannot drift apart. */ function toParsedEntry(raw: RawTranscriptLine): ParsedEntry | null { if (!raw.timestamp) return null; // `raw.message` and `entry.message` expose the same fields, so one narrowed // object serves both. const message: ParsedEntryMessage | undefined = raw.message ? { id: raw.message.id, model: raw.message.model, usage: raw.message.usage, } : undefined; return { timestamp: new Date(raw.timestamp), message, costUSD: typeof raw.costUSD === "number" ? raw.costUSD : undefined, isSidechain: raw.isSidechain === true, raw: { requestId: raw.requestId, model: raw.model, model_id: raw.model_id, message, }, }; } function parseTranscriptLine(line: string): ParsedEntry | null { const trimmed = line.trim(); if (!trimmed) return null; try { return toParsedEntry(JSON.parse(trimmed)); } catch (parseError) { debug(`Failed to parse JSONL line: ${parseError}`); return null; } } async function parseJsonlFileInMemory( filePath: string, ): Promise { const content = await readFile(filePath, "utf-8"); const entries: ParsedEntry[] = []; for (const line of content.split("\n")) { const entry = parseTranscriptLine(line); if (entry) entries.push(entry); } return entries; } async function parseJsonlFileStreaming( filePath: string, ): Promise { return new Promise((resolve, reject) => { const entries: ParsedEntry[] = []; const fileStream = createReadStream(filePath, { encoding: "utf8" }); const rl = createInterface({ input: fileStream, crlfDelay: Infinity, }); rl.on("line", (line) => { const entry = parseTranscriptLine(line); if (entry) entries.push(entry); }); rl.on("close", () => { resolve(entries); }); rl.on("error", (error) => { debug(`Streaming parser error for ${filePath}:`, error); reject(error); }); fileStream.on("error", (error) => { debug(`File stream error for ${filePath}:`, error); reject(error); }); }); } export interface TranscriptRead { /** Entries on the complete lines read, in file order. */ entries: ParsedEntry[]; /** Offset just past the last newline read, where the next read resumes. */ end: number; /** * The entry on a last line not yet ended by a newline, when it parses. * Claude Code may still be writing that line, so `end` stops before it. */ tail: ParsedEntry | null; } /** * Parses a transcript from byte offset `start` to its end. Streams, like * parseJsonlFileStreaming, so the first read of a large transcript does not * hold the whole file in memory. */ export async function readTranscriptFrom( filePath: string, start: number, ): Promise { const entries: ParsedEntry[] = []; let end = start; // The unterminated start of the next line, as chunks: a tool result line can // span many of them, and concatenating once per line keeps that linear. let partial: Buffer[] = []; for await (const chunk of createReadStream(filePath, { start })) { const buffer = chunk as Buffer; let lineStart = 0; let newline: number; while ((newline = buffer.indexOf(0x0a, lineStart)) !== -1) { partial.push(buffer.subarray(lineStart, newline)); const line = Buffer.concat(partial); partial = []; end += line.length + 1; lineStart = newline + 1; const entry = parseTranscriptLine(line.toString("utf-8")); if (entry) entries.push(entry); } if (lineStart < buffer.length) partial.push(buffer.subarray(lineStart)); } const tail = partial.length > 0 ? parseTranscriptLine(Buffer.concat(partial).toString("utf-8")) : null; return { entries, end, tail }; } interface FileStat { filePath: string; mtime: Date; } async function statFile(filePath: string): Promise { try { const mtime = await getFileModificationDate(filePath); return mtime ? { filePath, mtime } : null; } catch { return null; } } export async function collectProjectFiles( projectPath: string, fileFilter?: (filePath: string, modTime: Date) => boolean, ): Promise { try { const entries = await readdir(projectPath, { withFileTypes: true }); const sessionFiles = entries .filter((e) => !e.isDirectory() && e.name.endsWith(".jsonl")) .map((e) => statFile(join(projectPath, e.name))); const subagentFiles = entries .filter((e) => e.isDirectory()) .map(async (e) => { const paths = await findAgentTranscriptPaths(join(projectPath, e.name)); return paths.map((p) => statFile(p)); }); const [sessionResults, subagentResults] = await Promise.all([ Promise.all(sessionFiles), Promise.all(subagentFiles).then((nested) => Promise.all(nested.flat())), ]); return [...sessionResults, ...subagentResults].filter( (s): s is FileStat => s !== null && (!fileFilter || fileFilter(s.filePath, s.mtime)), ); } catch (dirError) { debug(`Failed to read project directory ${projectPath}:`, dirError); return []; } } /** Every transcript of every project, agents included. */ export async function collectAllProjectFiles( fileFilter?: (filePath: string, modTime: Date) => boolean, ): Promise { const projectPaths = await findProjectPaths(getClaudePaths()); const fileGroups = await Promise.all( projectPaths.map((projectPath) => collectProjectFiles(projectPath, fileFilter), ), ); return fileGroups.flat(); } /** * Loads entries from Claude projects with deterministic deduplication. * @param timeFilter Optional filter to apply based on timestamp * @param fileFilter Optional filter to apply based on file path and modification time * @param sortFiles Whether to sort files by modification time * @returns Deduplicated entries sorted by timestamp * @note Sorts entries by timestamp before deduplication to ensure consistent * duplicate selection. Otherwise, parallel file loading causes race conditions * where different duplicates are kept on each run, leading to flickering values. */ export async function loadEntriesFromProjects( timeFilter?: (entry: ParsedEntry) => boolean, fileFilter?: (filePath: string, modTime: Date) => boolean, sortFiles = false, ): Promise { const allFilesWithMtime = await collectAllProjectFiles(fileFilter); if (sortFiles) { allFilesWithMtime.sort((a, b) => b.mtime.getTime() - a.mtime.getTime()); } const allFiles = allFilesWithMtime.map((file) => file.filePath); const entries: ParsedEntry[] = []; const filePromises = allFiles.map(async (filePath) => { const fileEntries = await parseJsonlFile(filePath); return fileEntries.filter((entry) => !timeFilter || timeFilter(entry)); }); const fileResults = await Promise.all(filePromises); for (const fileEntries of fileResults) { entries.push(...fileEntries); } entries.sort((a, b) => a.timestamp.getTime() - b.timestamp.getTime()); return deduplicateEntries(entries); }