orca/src/main/codex-usage/scanner.ts

1060 lines
35 KiB
TypeScript

/* eslint-disable max-lines -- Why: Codex discovery, incremental parsing, attribution, and aggregation all depend on the same event-normalization rules. Keeping them together makes the duplicate-snapshot logic easier to audit when usage totals look wrong. */
import { basename, join, win32, posix } from 'path'
import { createReadStream } from 'fs'
import { realpath, readdir, stat } from 'fs/promises'
import { createInterface } from 'readline'
import type { Repo } from '../../shared/types'
import { areWorktreePathsEqual } from '../ipc/worktree-logic'
import { getOrcaManagedCodexHomePath, getSystemCodexHomePath } from '../codex/codex-home-paths'
import { getLegacyCopiedCodexSessionBridgeScanPreference } from '../codex/codex-session-bridge'
import { canonicalizeUsageWorktreePaths } from '../usage-worktree-canonicalizer'
import type {
CodexUsageAttributedEvent,
CodexUsageDailyAggregate,
CodexUsageLocationBreakdown,
CodexUsageLocationModelBreakdown,
CodexUsageModelBreakdown,
CodexUsageParsedEvent,
CodexUsagePersistedFile,
CodexUsageProcessedFile,
CodexUsageSession
} from './types'
export type CodexUsageWorktreeRef = {
repoId: string
worktreeId: string
path: string
displayName: string
}
type CodexUsageRawRecord = {
timestamp?: string
type?: string
payload?: Record<string, unknown>
}
type CodexUsageRawUsage = {
inputTokens: number
cachedInputTokens: number
outputTokens: number
reasoningOutputTokens: number
totalTokens: number
}
type CodexUsageParseContext = {
sessionId: string
sessionCwd: string | null
currentCwd: string | null
currentModel: string | null
previousTotals: CodexUsageRawUsage | null
totalOnlyBaselinePending?: boolean
}
type CodexUsageDeltaResolution =
| { kind: 'event'; delta: CodexUsageRawUsage; nextTotals: CodexUsageRawUsage | null }
| { kind: 'baseline'; nextTotals: CodexUsageRawUsage }
const YIELD_EVERY_FILES = 10
function ensureNumber(value: unknown): number {
return typeof value === 'number' && Number.isFinite(value) ? value : 0
}
function normalizeComparablePath(pathValue: string, platform = process.platform): string {
const normalized = pathValue.replace(/\\/g, '/')
return platform === 'win32' || looksLikeWindowsPath(pathValue)
? normalized.toLowerCase()
: normalized
}
function normalizeFsPath(pathValue: string, platform = process.platform): string {
if (platform === 'win32' || looksLikeWindowsPath(pathValue)) {
return win32.normalize(win32.resolve(pathValue))
}
return posix.normalize(posix.resolve(pathValue))
}
function looksLikeWindowsPath(pathValue: string): boolean {
return /^[A-Za-z]:[\\/]/.test(pathValue) || pathValue.startsWith('\\\\')
}
async function canonicalizePath(pathValue: string): Promise<string> {
try {
const resolved = await realpath(pathValue)
return normalizeFsPath(resolved)
} catch {
return normalizeFsPath(pathValue)
}
}
async function yieldToEventLoop(): Promise<void> {
await new Promise((resolve) => setTimeout(resolve, 0))
}
async function walkJsonlFiles(dirPath: string): Promise<string[]> {
const entries = await readdir(dirPath, { withFileTypes: true })
const files: string[] = []
for (const entry of entries) {
const fullPath = join(dirPath, entry.name)
if (entry.isDirectory()) {
appendDiscoveredFiles(files, await walkJsonlFiles(fullPath))
continue
}
if (entry.isFile() && entry.name.endsWith('.jsonl')) {
files.push(fullPath)
}
}
return files
}
function appendDiscoveredFiles(target: string[], source: readonly string[]): void {
// Why: large session directories can exceed V8's argument limit if child
// file arrays are spread into push().
for (const filePath of source) {
target.push(filePath)
}
}
export function getCodexSessionsDirectory(): string {
// Why: Orca-launched Codex processes receive an Orca-owned CODEX_HOME, so
// callers that need the primary runtime path should not consult ambient
// shell CODEX_HOME.
return join(getOrcaManagedCodexHomePath(), 'sessions')
}
export function getCodexSessionDirectories(): string[] {
// Why: upgraded users still have ordinary Codex history under ~/.codex, while
// new Orca-launched sessions are written under Orca's managed runtime home.
return [getCodexSessionsDirectory(), join(getSystemCodexHomePath(), 'sessions')].filter(
(dirPath, index, allDirPaths) => allDirPaths.indexOf(dirPath) === index
)
}
export async function listCodexSessionFiles(): Promise<string[]> {
const files: string[] = []
for (const dirPath of getCodexSessionDirectories()) {
try {
appendDiscoveredFiles(files, await walkJsonlFiles(dirPath))
} catch {
// Missing or unreadable history in one home should not hide the other.
}
}
return dedupeCodexSessionFileAliases(files)
}
async function dedupeCodexSessionFileAliases(files: string[]): Promise<string[]> {
const excludedAliases = new Set<string>()
for (const filePath of files) {
const legacyCopyBridge = getLegacyCopiedCodexSessionBridgeScanPreference(filePath)
if (!legacyCopyBridge) {
continue
}
if (legacyCopyBridge.sourceSkipBytes !== null) {
continue
}
excludedAliases.add(
await getPhysicalFileAliasKey(
legacyCopyBridge.preferManagedCopy ? legacyCopyBridge.sourcePath : filePath
)
)
}
const seenAliases = new Set<string>()
const uniqueFiles: string[] = []
for (const filePath of [...new Set(files)].sort()) {
const aliasKey = await getCodexSessionFileAliasKey(filePath)
if (excludedAliases.has(aliasKey)) {
continue
}
if (seenAliases.has(aliasKey)) {
continue
}
seenAliases.add(aliasKey)
uniqueFiles.push(filePath)
}
return uniqueFiles
}
async function getCodexSessionFileAliasKey(filePath: string): Promise<string> {
return getPhysicalFileAliasKey(filePath)
}
async function getPhysicalFileAliasKey(filePath: string): Promise<string> {
try {
const fileStat = await stat(filePath)
if (fileStat.ino !== 0) {
return `${fileStat.dev}:${fileStat.ino}`
}
} catch {}
return `path:${await canonicalizePath(filePath)}`
}
function getLegacySourceSkipBytesByPath(files: string[]): Map<string, number> {
const sourceSkipBytesByPath = new Map<string, number>()
for (const filePath of files) {
const legacyCopyBridge = getLegacyCopiedCodexSessionBridgeScanPreference(filePath)
if (!legacyCopyBridge || legacyCopyBridge.sourceSkipBytes === null) {
continue
}
const existing = sourceSkipBytesByPath.get(legacyCopyBridge.sourcePath) ?? 0
sourceSkipBytesByPath.set(
legacyCopyBridge.sourcePath,
Math.max(existing, legacyCopyBridge.sourceSkipBytes)
)
}
return sourceSkipBytesByPath
}
export async function getProcessedFileInfo(filePath: string): Promise<CodexUsageProcessedFile> {
const fileStat = await stat(filePath)
return {
path: filePath,
mtimeMs: fileStat.mtimeMs,
size: fileStat.size
}
}
function normalizeRawUsage(value: unknown): CodexUsageRawUsage | null {
if (value == null || typeof value !== 'object') {
return null
}
const record = value as Record<string, unknown>
const inputTokens = ensureNumber(record.input_tokens)
const cachedInputTokens = ensureNumber(
record.cached_input_tokens ?? record.cache_read_input_tokens
)
const outputTokens = ensureNumber(record.output_tokens)
const reasoningOutputTokens = ensureNumber(record.reasoning_output_tokens)
const totalTokens = ensureNumber(record.total_tokens)
return {
inputTokens,
cachedInputTokens,
outputTokens,
reasoningOutputTokens,
// Why: legacy Codex logs can omit total_tokens. Reasoning is already billed
// inside output, so synthesizing input+output matches Codex pricing instead
// of double-counting reasoning as another billable bucket.
totalTokens: totalTokens > 0 ? totalTokens : inputTokens + outputTokens
}
}
function subtractRawUsage(
current: CodexUsageRawUsage,
previous: CodexUsageRawUsage | null
): CodexUsageRawUsage {
return {
inputTokens: Math.max(current.inputTokens - (previous?.inputTokens ?? 0), 0),
cachedInputTokens: Math.max(current.cachedInputTokens - (previous?.cachedInputTokens ?? 0), 0),
outputTokens: Math.max(current.outputTokens - (previous?.outputTokens ?? 0), 0),
reasoningOutputTokens: Math.max(
current.reasoningOutputTokens - (previous?.reasoningOutputTokens ?? 0),
0
),
totalTokens: Math.max(current.totalTokens - (previous?.totalTokens ?? 0), 0)
}
}
function addRawUsage(left: CodexUsageRawUsage, right: CodexUsageRawUsage): CodexUsageRawUsage {
return {
inputTokens: left.inputTokens + right.inputTokens,
cachedInputTokens: left.cachedInputTokens + right.cachedInputTokens,
outputTokens: left.outputTokens + right.outputTokens,
reasoningOutputTokens: left.reasoningOutputTokens + right.reasoningOutputTokens,
totalTokens: left.totalTokens + right.totalTokens
}
}
function rawUsageEquals(left: CodexUsageRawUsage, right: CodexUsageRawUsage): boolean {
return (
left.inputTokens === right.inputTokens &&
left.cachedInputTokens === right.cachedInputTokens &&
left.outputTokens === right.outputTokens &&
left.reasoningOutputTokens === right.reasoningOutputTokens
)
}
function rawUsageIsMonotonic(current: CodexUsageRawUsage, previous: CodexUsageRawUsage): boolean {
return (
current.inputTokens >= previous.inputTokens &&
current.cachedInputTokens >= previous.cachedInputTokens &&
current.outputTokens >= previous.outputTokens &&
current.reasoningOutputTokens >= previous.reasoningOutputTokens
)
}
function rawUsageMagnitude(usage: CodexUsageRawUsage): number {
return (
usage.inputTokens + usage.cachedInputTokens + usage.outputTokens + usage.reasoningOutputTokens
)
}
function looksLikeStaleRegression(
current: CodexUsageRawUsage,
previous: CodexUsageRawUsage,
last: CodexUsageRawUsage
): boolean {
const previousTotal = rawUsageMagnitude(previous)
const currentTotal = rawUsageMagnitude(current)
const lastTotal = rawUsageMagnitude(last)
if (previousTotal <= 0 || currentTotal <= 0 || lastTotal <= 0) {
return false
}
return currentTotal * 100 >= previousTotal * 98 || currentTotal + lastTotal * 2 >= previousTotal
}
function resolveCodexUsageDelta(
totalUsage: CodexUsageRawUsage | null,
lastUsage: CodexUsageRawUsage | null,
previousTotals: CodexUsageRawUsage | null
): CodexUsageDeltaResolution | null {
if (totalUsage && lastUsage && previousTotals) {
if (rawUsageEquals(totalUsage, previousTotals)) {
return null
}
if (
!rawUsageIsMonotonic(totalUsage, previousTotals) &&
looksLikeStaleRegression(totalUsage, previousTotals, lastUsage)
) {
return null
}
// Why: Codex totals are mutable snapshots after compaction/resume. The
// last_token_usage payload is the billable increment; totals are the baseline.
return { kind: 'event', delta: lastUsage, nextTotals: totalUsage }
}
if (totalUsage && lastUsage) {
return { kind: 'event', delta: lastUsage, nextTotals: totalUsage }
}
if (totalUsage && previousTotals) {
if (rawUsageEquals(totalUsage, previousTotals)) {
return null
}
if (!rawUsageIsMonotonic(totalUsage, previousTotals)) {
return { kind: 'baseline', nextTotals: totalUsage }
}
return {
kind: 'event',
delta: subtractRawUsage(totalUsage, previousTotals),
nextTotals: totalUsage
}
}
if (totalUsage) {
return { kind: 'event', delta: totalUsage, nextTotals: totalUsage }
}
if (lastUsage && previousTotals) {
return { kind: 'event', delta: lastUsage, nextTotals: addRawUsage(previousTotals, lastUsage) }
}
if (lastUsage) {
return { kind: 'event', delta: lastUsage, nextTotals: null }
}
return null
}
function extractString(value: unknown): string | null {
if (typeof value !== 'string') {
return null
}
const trimmed = value.trim()
return trimmed.length > 0 ? trimmed : null
}
function extractModel(value: unknown): string | null {
if (value == null || typeof value !== 'object') {
return null
}
const record = value as Record<string, unknown>
const direct = [extractString(record.model), extractString(record.model_name)].find(
(candidate) => candidate !== null
)
if (direct) {
return direct
}
if (record.info && typeof record.info === 'object') {
const info = record.info as Record<string, unknown>
const infoDirect = [extractString(info.model), extractString(info.model_name)].find(
(candidate) => candidate !== null
)
if (infoDirect) {
return infoDirect
}
if (info.metadata && typeof info.metadata === 'object') {
const metadata = info.metadata as Record<string, unknown>
const metadataModel = extractString(metadata.model)
if (metadataModel) {
return metadataModel
}
}
}
if (record.metadata && typeof record.metadata === 'object') {
const metadata = record.metadata as Record<string, unknown>
return extractString(metadata.model)
}
return null
}
function getDefaultProjectLabel(cwd: string | null): string {
if (!cwd) {
return 'Unknown location'
}
const parts = cwd.replace(/\\/g, '/').split('/').filter(Boolean)
if (parts.length >= 2) {
return parts.slice(-2).join('/')
}
return parts.at(-1) ?? cwd
}
function localDayFromTimestamp(timestamp: string): string | null {
const parsed = new Date(timestamp)
if (Number.isNaN(parsed.getTime())) {
return null
}
const year = parsed.getFullYear()
const month = String(parsed.getMonth() + 1).padStart(2, '0')
const day = String(parsed.getDate()).padStart(2, '0')
return `${year}-${month}-${day}`
}
async function buildWorktreesWithCanonicalPaths(
worktrees: CodexUsageWorktreeRef[]
): Promise<(CodexUsageWorktreeRef & { canonicalPath: string })[]> {
return canonicalizeUsageWorktreePaths(worktrees, canonicalizePath)
}
function isContainingPath(candidatePath: string, targetPath: string): boolean {
const useWin32 = looksLikeWindowsPath(candidatePath) || looksLikeWindowsPath(targetPath)
const relativePath = useWin32
? win32.relative(candidatePath, targetPath)
: posix.relative(candidatePath, targetPath)
if (!relativePath) {
return true
}
// Why: on Windows, `path.relative('C:\\repo', 'D:\\other')` returns an
// absolute `D:\\other` path instead of a `..`-prefixed relative. Treating
// that as "contained" would attribute off-drive Codex usage to the wrong
// Orca worktree.
const isAbsoluteRelative = useWin32
? win32.isAbsolute(relativePath)
: posix.isAbsolute(relativePath)
const parentPrefix = useWin32 ? `..${win32.sep}` : `..${posix.sep}`
// Why: `..name` is a valid child path; only `..` and `../...` escape.
return (
!isAbsoluteRelative &&
relativePath !== '..' &&
!relativePath.startsWith(parentPrefix) &&
relativePath !== '.'
)
}
function findContainingWorktree(
cwd: string,
worktrees: (CodexUsageWorktreeRef & { canonicalPath: string })[]
): CodexUsageWorktreeRef | null {
const normalizedCwd = normalizeFsPath(cwd)
for (const worktree of worktrees) {
if (areWorktreePathsEqual(worktree.canonicalPath, normalizedCwd)) {
return worktree
}
if (isContainingPath(worktree.canonicalPath, normalizedCwd)) {
return worktree
}
}
return null
}
export async function attributeCodexUsageEvent(
event: CodexUsageParsedEvent,
worktrees: (CodexUsageWorktreeRef & { canonicalPath: string })[]
): Promise<CodexUsageAttributedEvent | null> {
const day = localDayFromTimestamp(event.timestamp)
if (!day) {
return null
}
let repoId: string | null = null
let worktreeId: string | null = null
let projectKey = 'unscoped'
let projectLabel = getDefaultProjectLabel(event.cwd)
if (event.cwd) {
const worktree = findContainingWorktree(event.cwd, worktrees)
if (worktree) {
repoId = worktree.repoId
worktreeId = worktree.worktreeId
projectKey = `worktree:${worktree.worktreeId}`
projectLabel = worktree.displayName
} else {
// Why: all-local mode should still collapse repeated off-Orca sessions by
// location, but those keys must normalize slash/case differences so the
// same folder does not fragment into multiple "projects" across platforms.
projectKey = `cwd:${normalizeComparablePath(event.cwd)}`
}
}
return {
...event,
day,
projectKey,
projectLabel,
repoId,
worktreeId
}
}
function createEmptySession(event: CodexUsageAttributedEvent): CodexUsageSession {
return {
sessionId: event.sessionId,
firstTimestamp: event.timestamp,
lastTimestamp: event.timestamp,
primaryModel: event.model,
hasMixedModels: false,
primaryProjectLabel: event.projectLabel,
hasMixedLocations: false,
primaryWorktreeId: event.worktreeId,
primaryRepoId: event.repoId,
eventCount: 0,
totalInputTokens: 0,
totalCachedInputTokens: 0,
totalOutputTokens: 0,
totalReasoningOutputTokens: 0,
totalTokens: 0,
hasInferredPricing: false,
locationBreakdown: [],
modelBreakdown: [],
locationModelBreakdown: []
}
}
function createEmptyDailyAggregate(event: CodexUsageAttributedEvent): CodexUsageDailyAggregate {
return {
day: event.day,
model: event.model,
projectKey: event.projectKey,
projectLabel: event.projectLabel,
repoId: event.repoId,
worktreeId: event.worktreeId,
eventCount: 0,
inputTokens: 0,
cachedInputTokens: 0,
outputTokens: 0,
reasoningOutputTokens: 0,
totalTokens: 0,
hasInferredPricing: false
}
}
function mergeLocationBreakdown(
target: CodexUsageLocationBreakdown[],
event: CodexUsageAttributedEvent
): void {
const existing = target.find((entry) => entry.locationKey === event.projectKey) ?? null
if (existing) {
existing.eventCount++
existing.inputTokens += event.inputTokens
existing.cachedInputTokens += event.cachedInputTokens
existing.outputTokens += event.outputTokens
existing.reasoningOutputTokens += event.reasoningOutputTokens
existing.totalTokens += event.totalTokens
existing.hasInferredPricing ||= event.hasInferredPricing
return
}
target.push({
locationKey: event.projectKey,
projectLabel: event.projectLabel,
repoId: event.repoId,
worktreeId: event.worktreeId,
eventCount: 1,
inputTokens: event.inputTokens,
cachedInputTokens: event.cachedInputTokens,
outputTokens: event.outputTokens,
reasoningOutputTokens: event.reasoningOutputTokens,
totalTokens: event.totalTokens,
hasInferredPricing: event.hasInferredPricing
})
}
function mergeModelBreakdown(
target: CodexUsageModelBreakdown[],
event: CodexUsageAttributedEvent
): void {
const key = event.model ?? 'unknown'
const existing = target.find((entry) => entry.modelKey === key) ?? null
if (existing) {
existing.eventCount++
existing.inputTokens += event.inputTokens
existing.cachedInputTokens += event.cachedInputTokens
existing.outputTokens += event.outputTokens
existing.reasoningOutputTokens += event.reasoningOutputTokens
existing.totalTokens += event.totalTokens
existing.hasInferredPricing ||= event.hasInferredPricing
return
}
target.push({
modelKey: key,
modelLabel: event.model ?? 'Unknown model',
eventCount: 1,
inputTokens: event.inputTokens,
cachedInputTokens: event.cachedInputTokens,
outputTokens: event.outputTokens,
reasoningOutputTokens: event.reasoningOutputTokens,
totalTokens: event.totalTokens,
hasInferredPricing: event.hasInferredPricing
})
}
function mergeLocationModelBreakdown(
target: CodexUsageLocationModelBreakdown[],
event: CodexUsageAttributedEvent
): void {
const modelKey = event.model ?? 'unknown'
const existing =
target.find((entry) => entry.locationKey === event.projectKey && entry.modelKey === modelKey) ??
null
if (existing) {
existing.eventCount++
existing.inputTokens += event.inputTokens
existing.cachedInputTokens += event.cachedInputTokens
existing.outputTokens += event.outputTokens
existing.reasoningOutputTokens += event.reasoningOutputTokens
existing.totalTokens += event.totalTokens
existing.hasInferredPricing ||= event.hasInferredPricing
return
}
target.push({
locationKey: event.projectKey,
modelKey,
modelLabel: event.model ?? 'Unknown model',
repoId: event.repoId,
worktreeId: event.worktreeId,
eventCount: 1,
inputTokens: event.inputTokens,
cachedInputTokens: event.cachedInputTokens,
outputTokens: event.outputTokens,
reasoningOutputTokens: event.reasoningOutputTokens,
totalTokens: event.totalTokens,
hasInferredPricing: event.hasInferredPricing
})
}
function aggregateCodexUsage(events: CodexUsageAttributedEvent[]): {
sessions: CodexUsageSession[]
dailyAggregates: CodexUsageDailyAggregate[]
} {
const sessionsById = new Map<string, CodexUsageSession>()
const dailyByKey = new Map<string, CodexUsageDailyAggregate>()
for (const event of events) {
const session = sessionsById.get(event.sessionId) ?? createEmptySession(event)
if (!sessionsById.has(event.sessionId)) {
sessionsById.set(event.sessionId, session)
}
if (event.timestamp < session.firstTimestamp) {
session.firstTimestamp = event.timestamp
}
if (event.timestamp >= session.lastTimestamp) {
session.lastTimestamp = event.timestamp
}
session.eventCount++
session.totalInputTokens += event.inputTokens
session.totalCachedInputTokens += event.cachedInputTokens
session.totalOutputTokens += event.outputTokens
session.totalReasoningOutputTokens += event.reasoningOutputTokens
session.totalTokens += event.totalTokens
session.hasInferredPricing ||= event.hasInferredPricing
mergeLocationBreakdown(session.locationBreakdown, event)
mergeModelBreakdown(session.modelBreakdown, event)
mergeLocationModelBreakdown(session.locationModelBreakdown, event)
const dailyKey = [event.day, event.model ?? 'unknown', event.projectKey].join('::')
const daily = dailyByKey.get(dailyKey) ?? createEmptyDailyAggregate(event)
if (!dailyByKey.has(dailyKey)) {
dailyByKey.set(dailyKey, daily)
}
daily.eventCount++
daily.inputTokens += event.inputTokens
daily.cachedInputTokens += event.cachedInputTokens
daily.outputTokens += event.outputTokens
daily.reasoningOutputTokens += event.reasoningOutputTokens
daily.totalTokens += event.totalTokens
daily.hasInferredPricing ||= event.hasInferredPricing
}
return {
sessions: finalizeSessions(sessionsById),
dailyAggregates: [...dailyByKey.values()].sort((left, right) =>
left.day === right.day
? left.projectLabel.localeCompare(right.projectLabel)
: left.day.localeCompare(right.day)
)
}
}
function finalizeSessions(sessionsById: Map<string, CodexUsageSession>): CodexUsageSession[] {
for (const session of sessionsById.values()) {
session.locationBreakdown.sort((left, right) => right.totalTokens - left.totalTokens)
session.modelBreakdown.sort((left, right) => right.totalTokens - left.totalTokens)
const primaryLocation = session.locationBreakdown[0] ?? null
const primaryModel = session.modelBreakdown[0] ?? null
session.primaryProjectLabel =
session.locationBreakdown.length <= 1
? (primaryLocation?.projectLabel ?? 'Unknown location')
: 'Multiple locations'
session.hasMixedLocations = session.locationBreakdown.length > 1
session.primaryWorktreeId = primaryLocation?.worktreeId ?? null
session.primaryRepoId = primaryLocation?.repoId ?? null
session.primaryModel =
session.modelBreakdown.length <= 1 ? (primaryModel?.modelLabel ?? null) : 'Mixed models'
session.hasMixedModels = session.modelBreakdown.length > 1
}
return [...sessionsById.values()].sort((left, right) =>
right.lastTimestamp.localeCompare(left.lastTimestamp)
)
}
function mergeSessions(
target: Map<string, CodexUsageSession>,
sessions: CodexUsageSession[]
): void {
for (const session of sessions) {
const existing = target.get(session.sessionId)
if (!existing) {
target.set(session.sessionId, structuredClone(session))
continue
}
existing.firstTimestamp =
session.firstTimestamp < existing.firstTimestamp
? session.firstTimestamp
: existing.firstTimestamp
existing.lastTimestamp =
session.lastTimestamp > existing.lastTimestamp
? session.lastTimestamp
: existing.lastTimestamp
existing.eventCount += session.eventCount
existing.totalInputTokens += session.totalInputTokens
existing.totalCachedInputTokens += session.totalCachedInputTokens
existing.totalOutputTokens += session.totalOutputTokens
existing.totalReasoningOutputTokens += session.totalReasoningOutputTokens
existing.totalTokens += session.totalTokens
existing.hasInferredPricing ||= session.hasInferredPricing
for (const location of session.locationBreakdown) {
const existingLocation =
existing.locationBreakdown.find((entry) => entry.locationKey === location.locationKey) ??
null
if (existingLocation) {
existingLocation.eventCount += location.eventCount
existingLocation.inputTokens += location.inputTokens
existingLocation.cachedInputTokens += location.cachedInputTokens
existingLocation.outputTokens += location.outputTokens
existingLocation.reasoningOutputTokens += location.reasoningOutputTokens
existingLocation.totalTokens += location.totalTokens
existingLocation.hasInferredPricing ||= location.hasInferredPricing
} else {
existing.locationBreakdown.push({ ...location })
}
}
for (const model of session.modelBreakdown) {
const existingModel =
existing.modelBreakdown.find((entry) => entry.modelKey === model.modelKey) ?? null
if (existingModel) {
existingModel.eventCount += model.eventCount
existingModel.inputTokens += model.inputTokens
existingModel.cachedInputTokens += model.cachedInputTokens
existingModel.outputTokens += model.outputTokens
existingModel.reasoningOutputTokens += model.reasoningOutputTokens
existingModel.totalTokens += model.totalTokens
existingModel.hasInferredPricing ||= model.hasInferredPricing
} else {
existing.modelBreakdown.push({ ...model })
}
}
for (const locationModel of session.locationModelBreakdown) {
const existingLocationModel =
existing.locationModelBreakdown.find(
(entry) =>
entry.locationKey === locationModel.locationKey &&
entry.modelKey === locationModel.modelKey
) ?? null
if (existingLocationModel) {
existingLocationModel.eventCount += locationModel.eventCount
existingLocationModel.inputTokens += locationModel.inputTokens
existingLocationModel.cachedInputTokens += locationModel.cachedInputTokens
existingLocationModel.outputTokens += locationModel.outputTokens
existingLocationModel.reasoningOutputTokens += locationModel.reasoningOutputTokens
existingLocationModel.totalTokens += locationModel.totalTokens
existingLocationModel.hasInferredPricing ||= locationModel.hasInferredPricing
} else {
existing.locationModelBreakdown.push({ ...locationModel })
}
}
}
}
function mergeDailyAggregates(
target: Map<string, CodexUsageDailyAggregate>,
dailyAggregates: CodexUsageDailyAggregate[]
): void {
for (const aggregate of dailyAggregates) {
const key = [aggregate.day, aggregate.model ?? 'unknown', aggregate.projectKey].join('::')
const existing = target.get(key)
if (!existing) {
target.set(key, { ...aggregate })
continue
}
existing.eventCount += aggregate.eventCount
existing.inputTokens += aggregate.inputTokens
existing.cachedInputTokens += aggregate.cachedInputTokens
existing.outputTokens += aggregate.outputTokens
existing.reasoningOutputTokens += aggregate.reasoningOutputTokens
existing.totalTokens += aggregate.totalTokens
existing.hasInferredPricing ||= aggregate.hasInferredPricing
}
}
export function parseCodexUsageRecord(
line: string,
context: CodexUsageParseContext
): CodexUsageParsedEvent | null {
let parsed: CodexUsageRawRecord
try {
parsed = JSON.parse(line) as CodexUsageRawRecord
} catch {
return null
}
if (!parsed.type || !parsed.payload) {
return null
}
if (parsed.type === 'session_meta') {
context.sessionId = extractString(parsed.payload.id) ?? context.sessionId
context.sessionCwd = extractString(parsed.payload.cwd)
if (!context.currentCwd && context.sessionCwd) {
context.currentCwd = context.sessionCwd
}
return null
}
if (parsed.type === 'turn_context') {
context.currentCwd =
extractString(parsed.payload.cwd) ?? context.currentCwd ?? context.sessionCwd
context.currentModel = extractModel(parsed.payload) ?? context.currentModel
return null
}
if (parsed.type !== 'event_msg' || parsed.payload.type !== 'token_count' || !parsed.timestamp) {
return null
}
const info = parsed.payload.info
if (info == null || typeof info !== 'object') {
// Why: Codex emits token_count snapshots with null info for rate-limit
// updates. Treating them as malformed usage would make active sessions look
// flaky and create false scan errors for perfectly valid logs.
return null
}
const record = info as Record<string, unknown>
const totalUsage = normalizeRawUsage(record.total_token_usage)
const lastUsage = normalizeRawUsage(record.last_token_usage)
if (context.totalOnlyBaselinePending) {
context.totalOnlyBaselinePending = false
if (totalUsage && !lastUsage && !context.previousTotals) {
context.previousTotals = totalUsage
return null
}
}
const resolvedUsage = resolveCodexUsageDelta(totalUsage, lastUsage, context.previousTotals)
if (!resolvedUsage) {
return null
}
if (resolvedUsage.kind === 'baseline') {
context.previousTotals = resolvedUsage.nextTotals
return null
}
let delta = {
...resolvedUsage.delta,
cachedInputTokens: Math.min(
resolvedUsage.delta.cachedInputTokens,
resolvedUsage.delta.inputTokens
)
}
if (
delta.inputTokens === 0 &&
delta.cachedInputTokens === 0 &&
delta.outputTokens === 0 &&
delta.reasoningOutputTokens === 0 &&
delta.totalTokens === 0
) {
return null
}
context.previousTotals = resolvedUsage.nextTotals
const resolvedModel = extractModel(parsed.payload) ?? context.currentModel
const hasInferredPricing = resolvedModel === null
return {
sessionId: context.sessionId,
timestamp: parsed.timestamp,
cwd: context.currentCwd ?? context.sessionCwd,
model: resolvedModel,
hasInferredPricing,
inputTokens: delta.inputTokens,
cachedInputTokens: delta.cachedInputTokens,
outputTokens: delta.outputTokens,
reasoningOutputTokens: delta.reasoningOutputTokens,
totalTokens: delta.totalTokens
}
}
export async function parseCodexUsageFile(
filePath: string,
worktrees: (CodexUsageWorktreeRef & { canonicalPath: string })[],
options: { skipInitialBytes?: number } = {}
): Promise<CodexUsagePersistedFile> {
const processedFile = await getProcessedFileInfo(filePath)
const lines = createInterface({
input: createReadStream(filePath, {
encoding: 'utf-8',
start: options.skipInitialBytes ?? 0
}),
crlfDelay: Infinity
})
const events: CodexUsageAttributedEvent[] = []
const context: CodexUsageParseContext = {
sessionId: basename(filePath, '.jsonl'),
sessionCwd: null,
currentCwd: null,
currentModel: null,
previousTotals: null,
// Why: suffix-only legacy copy parsing lacks the copied prefix context. A
// leading total-only snapshot is a baseline, not the suffix's billable delta.
totalOnlyBaselinePending: (options.skipInitialBytes ?? 0) > 0
}
for await (const line of lines) {
const parsed = parseCodexUsageRecord(line, context)
if (!parsed) {
continue
}
const attributed = await attributeCodexUsageEvent(parsed, worktrees)
if (attributed) {
events.push(attributed)
}
}
return {
...processedFile,
...aggregateCodexUsage(events)
}
}
export async function scanCodexUsageFiles(
worktrees: CodexUsageWorktreeRef[],
previousProcessedFiles: CodexUsagePersistedFile[]
): Promise<{
processedFiles: CodexUsagePersistedFile[]
sessions: CodexUsageSession[]
dailyAggregates: CodexUsageDailyAggregate[]
}> {
const files = await listCodexSessionFiles()
const previousByPath = new Map(previousProcessedFiles.map((file) => [file.path, file]))
const processedFiles: CodexUsagePersistedFile[] = []
const worktreesWithCanonicalPaths = await buildWorktreesWithCanonicalPaths(worktrees)
const legacySourceSkipBytesByPath = getLegacySourceSkipBytesByPath(files)
const sessionsById = new Map<string, CodexUsageSession>()
const dailyByKey = new Map<string, CodexUsageDailyAggregate>()
for (const [index, filePath] of files.entries()) {
const legacySourceSkipBytes = legacySourceSkipBytesByPath.get(filePath) ?? 0
const fileInfo = await getProcessedFileInfo(filePath)
const previous = previousByPath.get(filePath)
const canReuse =
legacySourceSkipBytes === 0 &&
previous &&
previous.mtimeMs === fileInfo.mtimeMs &&
previous.size === fileInfo.size
const processed = canReuse
? previous
: await parseCodexUsageFile(filePath, worktreesWithCanonicalPaths, {
skipInitialBytes: legacySourceSkipBytes
})
processedFiles.push(processed)
mergeSessions(sessionsById, processed.sessions)
mergeDailyAggregates(dailyByKey, processed.dailyAggregates)
// Why: Codex session history can grow large, and scans run on the Electron
// main process. Yield regularly so opening Settings does not stall while
// a background refresh walks old JSONL files.
if ((index + 1) % YIELD_EVERY_FILES === 0) {
await yieldToEventLoop()
}
}
return {
processedFiles,
sessions: finalizeSessions(sessionsById),
dailyAggregates: [...dailyByKey.values()].sort((left, right) =>
left.day === right.day
? left.projectLabel.localeCompare(right.projectLabel)
: left.day.localeCompare(right.day)
)
}
}
export function createWorktreeRefs(
repos: Repo[],
worktreesByRepo: Map<string, { path: string; worktreeId: string; displayName: string }[]>
): CodexUsageWorktreeRef[] {
const refs: CodexUsageWorktreeRef[] = []
for (const repo of repos) {
for (const worktree of worktreesByRepo.get(repo.id) ?? []) {
refs.push({
repoId: repo.id,
worktreeId: worktree.worktreeId,
path: worktree.path,
displayName: worktree.displayName
})
}
}
return refs
}
export function getDefaultWorktreeLabel(pathValue: string): string {
return basename(pathValue)
}
export function getSessionProjectLabel(locationBreakdown: CodexUsageLocationBreakdown[]): string {
if (locationBreakdown.length === 0) {
return 'Unknown location'
}
if (locationBreakdown.length === 1) {
return locationBreakdown[0].projectLabel
}
return 'Multiple locations'
}