Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions packages/llm-cost-attribution/bin/llm-cost.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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];
Expand Down
28 changes: 18 additions & 10 deletions packages/llm-cost-attribution/src/index.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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 = [];

Expand All @@ -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);
Expand All @@ -98,23 +102,27 @@ 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 = [];

// Claude: the project directory name is the absolute cwd with every `/` and
// `.` 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);
Expand Down
Loading