From e2ff40381607457c73a501ac2bd4c94a5e4a14f6 Mon Sep 17 00:00:00 2001 From: Sunny Purewal Date: Fri, 29 May 2026 14:22:15 -0400 Subject: [PATCH] Add live scan progress counter to llm-cost Shows a live stderr counter while scanning Codex and Claude sessions so the command doesn't appear frozen on large transcript directories. Clears automatically before the results table. Silent when stderr is not a TTY (--json piped output unaffected). --- .../llm-cost-attribution/bin/llm-cost.mjs | 18 ++++++++++++ packages/llm-cost-attribution/src/index.mjs | 28 ++++++++++++------- 2 files changed, 36 insertions(+), 10 deletions(-) diff --git a/packages/llm-cost-attribution/bin/llm-cost.mjs b/packages/llm-cost-attribution/bin/llm-cost.mjs index 93a4c89..49f229f 100755 --- a/packages/llm-cost-attribution/bin/llm-cost.mjs +++ b/packages/llm-cost-attribution/bin/llm-cost.mjs @@ -63,6 +63,7 @@ async function main() { const options = { cwdPattern }; if (values['claude-dir'] !== undefined) options.claudeProjectsDir = values['claude-dir']; if (values['codex-dir'] !== undefined) options.codexSessionsDir = values['codex-dir']; + if (process.stderr.isTTY) options.onProgress = makeProgressReporter(); const withPricing = values['no-pricing'] !== true; @@ -160,6 +161,23 @@ async function main() { printMultiIssueRollup(multi, fromUsage !== undefined, withPricing); } +/** + * Returns an onProgress callback that writes a live scan counter to stderr, + * overwriting the same line each tick. Clears the line when the Codex phase + * completes so the output table starts on a clean line. + * Only wired up when stderr is a TTY (not when piping --json output). + */ +function makeProgressReporter() { + return ({ phase, processed, total }) => { + const pct = total === 0 ? 100 : Math.round((processed / total) * 100); + process.stderr.write( + ` scanning ${phase} sessions: ${processed.toLocaleString()} / ${total.toLocaleString()} (${pct}%)\r`, + ); + // Clear the line once each phase finishes so the results table is uncluttered. + if (processed === total) process.stderr.write(' '.repeat(60) + '\r'); + }; +} + function attachPricingToRollup(rollup) { for (const provider of ['claude', 'codex']) { const totals = rollup.providerTotals[provider]; diff --git a/packages/llm-cost-attribution/src/index.mjs b/packages/llm-cost-attribution/src/index.mjs index ad5a99b..26eba94 100644 --- a/packages/llm-cost-attribution/src/index.mjs +++ b/packages/llm-cost-attribution/src/index.mjs @@ -59,6 +59,7 @@ export async function computeIssueCost(issueIdentifier, options = {}) { const cwdPattern = options.cwdPattern ?? DEFAULT_CWD_PATTERN; const claudeRootDir = options.claudeProjectsDir ?? join(homedir(), '.claude', 'projects'); const codexRootDir = options.codexSessionsDir ?? join(homedir(), '.codex', 'sessions'); + const onProgress = options.onProgress ?? (() => undefined); const sessions = []; @@ -75,10 +76,13 @@ export async function computeIssueCost(issueIdentifier, options = {}) { } // Codex: session_meta.cwd match, scanned across all rollouts. - for (const file of await listCodexRollouts(codexRootDir)) { - const session = await parseCodexSession(file); - if (session === null) continue; - if (issueFromCwd(session.cwd, cwdPattern) === issueIdentifier) sessions.push(session); + const codexFiles = await listCodexRollouts(codexRootDir); + for (let i = 0; i < codexFiles.length; i++) { + const session = await parseCodexSession(codexFiles[i]); + if (session !== null && issueFromCwd(session.cwd, cwdPattern) === issueIdentifier) { + sessions.push(session); + } + onProgress({ phase: 'codex', processed: i + 1, total: codexFiles.length }); } return rollupSessions(issueIdentifier, sessions); @@ -98,6 +102,7 @@ export async function computeIssueCost(issueIdentifier, options = {}) { export async function computeWorktreeCost(worktreePath, options = {}) { const claudeRootDir = options.claudeProjectsDir ?? join(homedir(), '.claude', 'projects'); const codexRootDir = options.codexSessionsDir ?? join(homedir(), '.codex', 'sessions'); + const onProgress = options.onProgress ?? (() => undefined); const sessions = []; @@ -105,16 +110,19 @@ export async function computeWorktreeCost(worktreePath, options = {}) { // `.` replaced by `-`. Look it up directly — no regex needed. const encodedPath = worktreePath.replace(/[/.]/g, '-'); const claudeProjectDir = join(claudeRootDir, encodedPath); - for (const file of await listJsonlsRecursively(claudeProjectDir)) { - const session = await parseClaudeSession(file); + const claudeFiles = await listJsonlsRecursively(claudeProjectDir); + for (let i = 0; i < claudeFiles.length; i++) { + const session = await parseClaudeSession(claudeFiles[i]); if (session !== null) sessions.push(session); + onProgress({ phase: 'claude', processed: i + 1, total: claudeFiles.length }); } // Codex: scan all rollouts, keep those whose session_meta.cwd matches exactly. - for (const file of await listCodexRollouts(codexRootDir)) { - const session = await parseCodexSession(file); - if (session === null) continue; - if (session.cwd === worktreePath) sessions.push(session); + const codexFiles = await listCodexRollouts(codexRootDir); + for (let i = 0; i < codexFiles.length; i++) { + const session = await parseCodexSession(codexFiles[i]); + if (session !== null && session.cwd === worktreePath) sessions.push(session); + onProgress({ phase: 'codex', processed: i + 1, total: codexFiles.length }); } return rollupSessions(basename(worktreePath), sessions);