refactor(usage): split AI-usage scanners and stores under the max-lines budget (#14668)

The three usage scanners and their stores, plus the renderer usage-overview
model, each carried a file-level `eslint-disable max-lines` and had grown to
338-769 counted lines against a 300-line budget. AGENTS.md calls for splitting
rather than suppressing, and config/max-lines-baseline.txt is a shrink-only
ratchet, so this removes all seven suppressions and prunes their entries
(341 -> 334).

Each file is cut along the seams it already had -- and that several of the
suppression comments named out loud: filesystem discovery / record parsing /
attribution / aggregation for the scanners, and pricing policy / scope filters /
rollups / session rows / automation attribution for the stores.

Pure move, no behavior change. Code is relocated verbatim; the only edits are
import plumbing and, where a private class method became a free function, the
mechanical `this.state` -> `state` parameter threading. Every converted call
site passes `this.state` at call time and the automation path takes a live
`getState: () => this.state` getter, so no state is snapshotted. No barrel
exports: each new module owns real logic and importers point at the owner.

Verified: oxlint clean, ratchet passes, typecheck clean, full unit suite green
(remaining failures are pre-existing load flakes in untouched files, each green
when re-run serially), no import cycles among the 64 affected modules, and a
statement-level diff of every split confirms the moves are verbatim.
This commit is contained in:
Neil
2026-08-15 18:33:33 -07:00
committed by GitHub
parent bc28107864
commit 97b71c2285
50 changed files with 3867 additions and 3602 deletions
-7
View File
@@ -15,14 +15,10 @@ inline src/main/browser/cdp-bridge.ts
inline src/main/browser/grab-guest-script.ts
inline src/main/claude-accounts/runtime-auth-service.ts
inline src/main/claude-accounts/service.ts
inline src/main/claude-usage/scanner.ts
inline src/main/claude-usage/store.ts
inline src/main/cli/cli-installer.ts
inline src/main/cli/wsl-cli-installer.ts
inline src/main/codex-accounts/runtime-home-service.ts
inline src/main/codex-accounts/service.ts
inline src/main/codex-usage/scanner.ts
inline src/main/codex-usage/store.ts
inline src/main/codex/config-toml-trust.ts
inline src/main/codex/hook-service.ts
inline src/main/daemon/client.ts
@@ -57,8 +53,6 @@ inline src/main/keybindings/keybinding-file.ts
inline src/main/linear/issues.ts
inline src/main/linear/projects.ts
inline src/main/memory/collector.ts
inline src/main/opencode-usage/scanner.ts
inline src/main/opencode-usage/store.ts
inline src/main/opencode/hook-service.ts
inline src/main/persistence.ts
inline src/main/ports/advertised-url-watcher.ts
@@ -154,7 +148,6 @@ inline src/renderer/src/components/sidebar/RemoteFileBrowser.tsx
inline src/renderer/src/components/sidebar/WorkspaceKanbanDrawer.tsx
inline src/renderer/src/components/sidebar/WorktreeContextMenu.tsx
inline src/renderer/src/components/sidebar/use-workspace-kanban-area-selection.ts
inline src/renderer/src/components/stats/usage-overview-model.ts
inline src/renderer/src/components/status-bar/ResourceUsageStatusSegment.tsx
inline src/renderer/src/components/status-bar/StatusBar.tsx
inline src/renderer/src/components/status-bar/WorkspaceSpaceManagerPanel.tsx
@@ -11,7 +11,7 @@ vi.mock('./session-scanner-opencode-sqlite-discovery', () => ({
discoverOpenCodeSessions: discoverOpenCodeSessionsMock
}))
vi.mock('../opencode-usage/scanner', () => ({
vi.mock('../opencode-usage/opencode-database-discovery', () => ({
listOpenCodeDatabases: listOpenCodeDatabasesMock
}))
@@ -3,7 +3,7 @@ import type { AiVaultScanIssue } from '../../shared/ai-vault-types'
import { wslGatedReaddir } from '../native-chat/wsl-transcript-fs-access'
import { WslTranscriptFsError } from '../native-chat/wsl-transcript-fs-gate'
import { resolveOpenCodeStorageDirectory } from '../opencode/opencode-data-directory'
import { listOpenCodeDatabases } from '../opencode-usage/scanner'
import { listOpenCodeDatabases } from '../opencode-usage/opencode-database-discovery'
import { recordSessionScanIssue } from './session-scan-issues'
import { discoverOpenCodeSessions } from './session-scanner-opencode-sqlite-discovery'
import type { AiVaultScanOptions, SessionFileDiscovery } from './session-scanner-types'
@@ -0,0 +1,221 @@
type ClaudeModelPricing = {
input: number
output: number
cacheRead: number
cacheWrite: number
thresholdTokens?: number
inputAboveThreshold?: number
outputAboveThreshold?: number
cacheReadAboveThreshold?: number
cacheWriteAboveThreshold?: number
}
const LONG_CONTEXT_THRESHOLD_TOKENS = 200_000
const SONNET_LONG_CONTEXT_PRICING = {
thresholdTokens: LONG_CONTEXT_THRESHOLD_TOKENS,
inputAboveThreshold: 6,
outputAboveThreshold: 22.5,
cacheReadAboveThreshold: 0.6,
cacheWriteAboveThreshold: 7.5
} satisfies Partial<ClaudeModelPricing>
const MODEL_PRICING: Record<string, ClaudeModelPricing> = {
'claude-fable-5': { input: 10, output: 50, cacheRead: 1, cacheWrite: 12.5 },
'claude-opus-5': { input: 5, output: 25, cacheRead: 0.5, cacheWrite: 6.25 },
// Why: Sonnet 5 bills its full 1M window at flat rates, so no long-context tier here.
// Why: standard rates, not the $2/$10 introductory rate ending 2026-08-31 — no date dimension.
'claude-sonnet-5': { input: 3, output: 15, cacheRead: 0.3, cacheWrite: 3.75 },
'claude-opus-4-8': { input: 5, output: 25, cacheRead: 0.5, cacheWrite: 6.25 },
'claude-opus-4-7': { input: 5, output: 25, cacheRead: 0.5, cacheWrite: 6.25 },
'claude-opus-4-6': { input: 5, output: 25, cacheRead: 0.5, cacheWrite: 6.25 },
'claude-opus-4-5': { input: 5, output: 25, cacheRead: 0.5, cacheWrite: 6.25 },
'claude-opus-4-1': { input: 15, output: 75, cacheRead: 1.5, cacheWrite: 18.75 },
'claude-opus-4': { input: 15, output: 75, cacheRead: 1.5, cacheWrite: 18.75 },
'claude-sonnet-4-6': {
input: 3,
output: 15,
cacheRead: 0.3,
cacheWrite: 3.75,
...SONNET_LONG_CONTEXT_PRICING
},
'claude-sonnet-4-5': {
input: 3,
output: 15,
cacheRead: 0.3,
cacheWrite: 3.75,
...SONNET_LONG_CONTEXT_PRICING
},
'claude-sonnet-4': {
input: 3,
output: 15,
cacheRead: 0.3,
cacheWrite: 3.75,
...SONNET_LONG_CONTEXT_PRICING
},
'claude-sonnet-3-7': { input: 3, output: 15, cacheRead: 0.3, cacheWrite: 3.75 },
'claude-sonnet-3-5': { input: 3, output: 15, cacheRead: 0.3, cacheWrite: 3.75 },
'claude-haiku-4-5': { input: 1, output: 5, cacheRead: 0.1, cacheWrite: 1.25 },
'claude-haiku-3-5': { input: 0.8, output: 4, cacheRead: 0.08, cacheWrite: 1 },
'claude-haiku-3': { input: 0.25, output: 1.25, cacheRead: 0.03, cacheWrite: 0.3 }
}
const MODEL_ALIASES: Record<string, string> = {
model_placeholder_m26: 'claude-opus-4-6',
model_placeholder_m35: 'claude-sonnet-4-6',
'claude-opus-4.8': 'claude-opus-4-8',
'claude-opus-4.6': 'claude-opus-4-6',
'claude-sonnet-4.6': 'claude-sonnet-4-6',
'claude-opus-4.8-thinking': 'claude-opus-4-8',
'claude-opus-4.6-thinking': 'claude-opus-4-6',
'claude-sonnet-4.6-thinking': 'claude-sonnet-4-6',
'claude-opus-4-8-thinking': 'claude-opus-4-8',
'claude-opus-4-6-thinking': 'claude-opus-4-6',
'claude-sonnet-4-6-thinking': 'claude-sonnet-4-6'
}
function hasClaudeModelVersion(model: string, family: string, version: string): boolean {
const normalized = model.replace(/\./g, '-')
return new RegExp(`${family}-${version}(?:$|[^0-9])`).test(normalized)
}
function isLegacyBaseOpus4Model(model: string): boolean {
const normalized = model.replace(/\./g, '-')
return /opus-4(?:$|-thinking$|-20\d{6}(?:-thinking)?$|@20\d{6}$)/.test(normalized)
}
function normalizeModelForPricing(model: string | null): string | null {
if (!model) {
return null
}
const lower = model
.toLowerCase()
.trim()
.replace(/^anthropic[/:]/, '')
const alias = MODEL_ALIASES[lower]
if (alias) {
return alias
}
if (hasClaudeModelVersion(lower, 'fable', '5')) {
return 'claude-fable-5'
}
if (hasClaudeModelVersion(lower, 'opus', '5')) {
return 'claude-opus-5'
}
if (hasClaudeModelVersion(lower, 'opus', '4-8')) {
return 'claude-opus-4-8'
}
if (hasClaudeModelVersion(lower, 'opus', '4-7')) {
return 'claude-opus-4-7'
}
if (hasClaudeModelVersion(lower, 'opus', '4-6')) {
return 'claude-opus-4-6'
}
if (hasClaudeModelVersion(lower, 'opus', '4-5')) {
return 'claude-opus-4-5'
}
if (hasClaudeModelVersion(lower, 'opus', '4-1')) {
return 'claude-opus-4-1'
}
if (isLegacyBaseOpus4Model(lower)) {
return 'claude-opus-4'
}
if (lower.includes('opus-4')) {
// Why: new Opus 4 point releases now share the current low Opus pricing;
// avoid overbilling unknown future Claude Code model IDs as legacy Opus 4.
return 'claude-opus-4-8'
}
if (hasClaudeModelVersion(lower, 'sonnet', '5')) {
return 'claude-sonnet-5'
}
if (hasClaudeModelVersion(lower, 'sonnet', '4-6')) {
return 'claude-sonnet-4-6'
}
if (hasClaudeModelVersion(lower, 'sonnet', '4-5')) {
return 'claude-sonnet-4-5'
}
if (lower.includes('sonnet-4')) {
return 'claude-sonnet-4-6'
}
if (lower.includes('sonnet-3-7') || lower.includes('sonnet-3.7')) {
return 'claude-sonnet-3-7'
}
// Why: legacy version-first IDs like `claude-3-5-sonnet-20241022` are still
// present in historical Claude Code/SDK logs read off disk. Match them so
// their cost is not silently dropped from the breakdown.
if (
lower.includes('sonnet-3-5') ||
lower.includes('sonnet-3.5') ||
lower.includes('3-5-sonnet') ||
lower.includes('3.5-sonnet')
) {
return 'claude-sonnet-3-5'
}
if (lower.includes('haiku-4-5')) {
return 'claude-haiku-4-5'
}
if (lower.includes('haiku-3-5') || lower.includes('haiku-3.5')) {
return 'claude-haiku-3-5'
}
if (lower.includes('3-5-haiku') || lower.includes('3.5-haiku')) {
return 'claude-haiku-3-5'
}
if (lower.includes('haiku-3')) {
return 'claude-haiku-3'
}
return null
}
function calculateTieredCost(
tokens: number,
basePrice: number,
abovePrice?: number,
threshold?: number
): number {
if (threshold === undefined || abovePrice === undefined) {
return tokens * basePrice
}
const belowTokens = Math.min(tokens, threshold)
const aboveTokens = Math.max(tokens - threshold, 0)
return belowTokens * basePrice + aboveTokens * abovePrice
}
export function estimateCostUsd(
model: string | null,
inputTokens: number,
outputTokens: number,
cacheReadTokens: number,
cacheWriteTokens: number
): number | null {
const normalized = normalizeModelForPricing(model)
if (!normalized) {
return null
}
const pricing = MODEL_PRICING[normalized]
return (
(calculateTieredCost(
inputTokens,
pricing.input,
pricing.inputAboveThreshold,
pricing.thresholdTokens
) +
calculateTieredCost(
outputTokens,
pricing.output,
pricing.outputAboveThreshold,
pricing.thresholdTokens
) +
calculateTieredCost(
cacheReadTokens,
pricing.cacheRead,
pricing.cacheReadAboveThreshold,
pricing.thresholdTokens
) +
calculateTieredCost(
cacheWriteTokens,
pricing.cacheWrite,
pricing.cacheWriteAboveThreshold,
pricing.thresholdTokens
)) /
1_000_000
)
}
@@ -0,0 +1,149 @@
import type { AutomationRunUsage } from '../../shared/automations-types'
import type { ClaudeUsagePersistedState } from './types'
import { estimateCostUsd } from './claude-model-pricing'
const AUTOMATION_ATTRIBUTION_WINDOW_MS = 5 * 60_000
export type AutomationUsageLookupInput = {
worktreeId: string | null
terminalSessionId: string | null
startedAt: number | null
completedAt: number | null
}
type ClaudeUsageStateAccess = {
getState: () => ClaudeUsagePersistedState
refresh: (force: boolean) => Promise<{ lastScanError: string | null }>
}
function shouldForceAutomationUsageScan(
state: ClaudeUsagePersistedState,
completedAt: number
): boolean {
const { lastScanCompletedAt, lastScanError } = state.scanState
// Why: attribution needs a scan after the run finishes, but repeated
// lookups after that point should not rescan all Claude transcript history.
return Boolean(lastScanError) || lastScanCompletedAt === null || lastScanCompletedAt < completedAt
}
export async function resolveAutomationRunUsage(
input: AutomationUsageLookupInput,
access: ClaudeUsageStateAccess
): Promise<AutomationRunUsage> {
const collectedAt = Date.now()
const unavailable = (
unavailableReason: AutomationRunUsage['unavailableReason'],
unavailableMessage: string
): AutomationRunUsage => ({
status: 'unavailable',
provider: 'claude',
model: null,
inputTokens: null,
outputTokens: null,
cacheReadTokens: null,
cacheWriteTokens: null,
reasoningOutputTokens: null,
totalTokens: null,
estimatedCostUsd: null,
estimatedCostSource: null,
providerSessionId: null,
attribution: null,
collectedAt,
unavailableReason,
unavailableMessage
})
if (!access.getState().scanState.enabled) {
return unavailable('usage_not_enabled', 'Claude usage tracking is not enabled.')
}
if (!input.worktreeId || !input.startedAt || !input.completedAt) {
return unavailable('no_matching_session', 'Run session metadata is incomplete.')
}
const scanState = await access.refresh(
shouldForceAutomationUsageScan(access.getState(), input.completedAt)
)
if (scanState.lastScanError) {
return unavailable('scan_failed', scanState.lastScanError)
}
const windowStart = input.startedAt - AUTOMATION_ATTRIBUTION_WINDOW_MS
const windowEnd = input.completedAt + AUTOMATION_ATTRIBUTION_WINDOW_MS
const candidates = access.getState().sessions.filter((session) => {
const first = new Date(session.firstTimestamp).getTime()
const last = new Date(session.lastTimestamp).getTime()
if (!Number.isFinite(first) || !Number.isFinite(last)) {
return false
}
if (session.sessionId === input.terminalSessionId) {
return true
}
if (first < windowStart || first > windowEnd || last > windowEnd) {
return false
}
return session.locationBreakdown.some((entry) => entry.worktreeId === input.worktreeId)
})
if (candidates.length === 0) {
return unavailable('no_matching_session', 'No Claude usage session matched this run.')
}
if (candidates.length > 1) {
return unavailable(
'ambiguous_session',
'Multiple Claude usage sessions matched this run window.'
)
}
const session = candidates[0]
const scopedLocations = session.locationBreakdown.filter(
(entry) => entry.worktreeId === input.worktreeId
)
const locations = scopedLocations.length > 0 ? scopedLocations : session.locationBreakdown
const totals = locations.reduce(
(acc, entry) => {
acc.turns += entry.turnCount
acc.inputTokens += entry.inputTokens
acc.outputTokens += entry.outputTokens
acc.cacheReadTokens += entry.cacheReadTokens
acc.cacheWriteTokens += entry.cacheWriteTokens
return acc
},
{
turns: 0,
inputTokens: 0,
outputTokens: 0,
cacheReadTokens: 0,
cacheWriteTokens: 0
}
)
const estimatedCostUsd = estimateCostUsd(
session.model,
totals.inputTokens,
totals.outputTokens,
totals.cacheReadTokens,
totals.cacheWriteTokens
)
return {
status: 'known',
provider: 'claude',
model: session.model,
inputTokens: totals.inputTokens,
outputTokens: totals.outputTokens,
cacheReadTokens: totals.cacheReadTokens,
cacheWriteTokens: totals.cacheWriteTokens,
reasoningOutputTokens: null,
totalTokens:
totals.inputTokens + totals.outputTokens + totals.cacheReadTokens + totals.cacheWriteTokens,
estimatedCostUsd,
estimatedCostSource: estimatedCostUsd === null ? null : 'api_equivalent',
providerSessionId: session.sessionId,
// Why: Orca terminal tab ids and Claude usage session ids are different
// systems today, so attribution is intentionally limited to one local
// provider session in the run's worktree/time window.
attribution: 'provider_session_time_window',
collectedAt,
unavailableReason: null,
unavailableMessage: null
}
}
@@ -0,0 +1,181 @@
import type {
ClaudeUsageBreakdownKind,
ClaudeUsageBreakdownRow,
ClaudeUsageDailyPoint,
ClaudeUsageRange,
ClaudeUsageScope,
ClaudeUsageSummary
} from '../../shared/claude-usage-types'
import type { ClaudeUsagePersistedState } from './types'
import { estimateCostUsd } from './claude-model-pricing'
import { getFilteredDaily, getFilteredSessions } from './claude-usage-scope-filters'
export function buildSummary(
state: ClaudeUsagePersistedState,
scope: ClaudeUsageScope,
range: ClaudeUsageRange
): ClaudeUsageSummary {
const filteredDaily = getFilteredDaily(state, scope, range)
const filteredSessions = getFilteredSessions(state, scope, range)
let inputTokens = 0
let outputTokens = 0
let cacheReadTokens = 0
let cacheWriteTokens = 0
let turns = 0
let zeroCacheReadTurns = 0
const byModel = new Map<string, number>()
const byProject = new Map<string, number>()
let estimatedCostUsd = 0
let hasAnyBillableCost = false
for (const row of filteredDaily) {
inputTokens += row.inputTokens
outputTokens += row.outputTokens
cacheReadTokens += row.cacheReadTokens
cacheWriteTokens += row.cacheWriteTokens
turns += row.turnCount
zeroCacheReadTurns += row.zeroCacheReadTurnCount
const modelKey = row.model ?? 'Unknown model'
byModel.set(modelKey, (byModel.get(modelKey) ?? 0) + row.inputTokens + row.outputTokens)
byProject.set(
row.projectLabel,
(byProject.get(row.projectLabel) ?? 0) + row.inputTokens + row.outputTokens
)
const cost = estimateCostUsd(
row.model,
row.inputTokens,
row.outputTokens,
row.cacheReadTokens,
row.cacheWriteTokens
)
if (cost !== null) {
hasAnyBillableCost = true
estimatedCostUsd += cost
}
}
const topModel = [...byModel.entries()].sort((left, right) => right[1] - left[1])[0]?.[0] ?? null
const topProject =
[...byProject.entries()].sort((left, right) => right[1] - left[1])[0]?.[0] ?? null
return {
scope,
range,
sessions: filteredSessions.length,
turns,
zeroCacheReadTurns,
inputTokens,
outputTokens,
cacheReadTokens,
cacheWriteTokens,
cacheReuseRate:
inputTokens + cacheReadTokens > 0 ? cacheReadTokens / (inputTokens + cacheReadTokens) : null,
estimatedCostUsd: hasAnyBillableCost ? estimatedCostUsd : null,
topModel,
topProject,
// Why: the empty-state UX is scope/range specific. Using global persisted
// data here makes the Orca-only view render empty charts instead of the
// intended "no usage for this scope" message when only off-Orca logs exist.
hasAnyClaudeData: filteredSessions.length > 0 || filteredDaily.length > 0
}
}
export function buildDaily(
state: ClaudeUsagePersistedState,
scope: ClaudeUsageScope,
range: ClaudeUsageRange
): ClaudeUsageDailyPoint[] {
const byDay = new Map<string, ClaudeUsageDailyPoint>()
for (const row of getFilteredDaily(state, scope, range)) {
const existing = byDay.get(row.day) ?? {
day: row.day,
inputTokens: 0,
outputTokens: 0,
cacheReadTokens: 0,
cacheWriteTokens: 0
}
existing.inputTokens += row.inputTokens
existing.outputTokens += row.outputTokens
existing.cacheReadTokens += row.cacheReadTokens
existing.cacheWriteTokens += row.cacheWriteTokens
byDay.set(row.day, existing)
}
return [...byDay.values()].sort((left, right) => left.day.localeCompare(right.day))
}
export function buildBreakdown(
state: ClaudeUsagePersistedState,
scope: ClaudeUsageScope,
range: ClaudeUsageRange,
kind: ClaudeUsageBreakdownKind
): ClaudeUsageBreakdownRow[] {
const rows = new Map<string, ClaudeUsageBreakdownRow>()
const filteredDaily = getFilteredDaily(state, scope, range)
const filteredSessions = getFilteredSessions(state, scope, range)
for (const daily of filteredDaily) {
const key = kind === 'model' ? (daily.model ?? 'unknown') : daily.projectKey
const label = kind === 'model' ? (daily.model ?? 'Unknown model') : daily.projectLabel
const existing = rows.get(key) ?? {
key,
label,
sessions: 0,
turns: 0,
inputTokens: 0,
outputTokens: 0,
cacheReadTokens: 0,
cacheWriteTokens: 0,
estimatedCostUsd: null
}
existing.turns += daily.turnCount
existing.inputTokens += daily.inputTokens
existing.outputTokens += daily.outputTokens
existing.cacheReadTokens += daily.cacheReadTokens
existing.cacheWriteTokens += daily.cacheWriteTokens
rows.set(key, existing)
}
for (const session of filteredSessions) {
if (kind === 'model') {
const key = session.model ?? 'unknown'
const row = rows.get(key)
if (row) {
row.sessions++
}
continue
}
const matchingLocations = session.locationBreakdown.filter((entry) =>
scope === 'all' ? true : entry.worktreeId !== null
)
const seen = new Set<string>()
for (const location of matchingLocations) {
if (seen.has(location.locationKey)) {
continue
}
seen.add(location.locationKey)
const row = rows.get(location.locationKey)
if (row) {
row.sessions++
}
}
}
for (const row of rows.values()) {
if (kind === 'model') {
row.estimatedCostUsd = estimateCostUsd(
row.key,
row.inputTokens,
row.outputTokens,
row.cacheReadTokens,
row.cacheWriteTokens
)
}
}
return [...rows.values()].sort((left, right) => {
const leftTotal = left.inputTokens + left.outputTokens
const rightTotal = right.inputTokens + right.outputTokens
return rightTotal - leftTotal
})
}
@@ -0,0 +1,44 @@
import type { ClaudeUsageRange, ClaudeUsageScope } from '../../shared/claude-usage-types'
import type { ClaudeUsagePersistedState } from './types'
import { getLocalUsageDay, getUsageRangeCutoff } from '../usage/usage-calendar-range'
export function getFilteredDaily(
state: ClaudeUsagePersistedState,
scope: ClaudeUsageScope,
range: ClaudeUsageRange
) {
const cutoff = getUsageRangeCutoff(range)
return state.dailyAggregates.filter((entry) => {
if (cutoff && entry.day < cutoff) {
return false
}
if (scope === 'orca' && entry.worktreeId === null) {
return false
}
return true
})
}
export function getFilteredSessions(
state: ClaudeUsagePersistedState,
scope: ClaudeUsageScope,
range: ClaudeUsageRange
) {
const cutoff = getUsageRangeCutoff(range)
return state.sessions.filter((session) => {
// Why: daily aggregates use local calendar days, so session filtering has
// to use the same conversion or the sessions table/counts can disagree
// with the chart around UTC day boundaries.
const day = getLocalUsageDay(session.lastTimestamp)
if (!day) {
return false
}
if (cutoff && day < cutoff) {
return false
}
if (scope === 'orca') {
return session.locationBreakdown.some((entry) => entry.worktreeId !== null)
}
return true
})
}
@@ -0,0 +1,62 @@
import type {
ClaudeUsageRange,
ClaudeUsageScope,
ClaudeUsageSessionRow
} from '../../shared/claude-usage-types'
import type { ClaudeUsagePersistedState } from './types'
import { getSessionProjectLabel } from './usage-aggregation'
import { getFilteredSessions } from './claude-usage-scope-filters'
export function buildRecentSessions(
state: ClaudeUsagePersistedState,
scope: ClaudeUsageScope,
range: ClaudeUsageRange,
limit = 12
): ClaudeUsageSessionRow[] {
return getFilteredSessions(state, scope, range)
.slice(0, limit)
.map((session) => {
const matchingLocations = session.locationBreakdown.filter((entry) =>
scope === 'all' ? true : entry.worktreeId !== null
)
const scopedLocations =
matchingLocations.length > 0 ? matchingLocations : session.locationBreakdown
const totals = scopedLocations.reduce(
(acc, entry) => {
acc.turns += entry.turnCount
acc.inputTokens += entry.inputTokens
acc.outputTokens += entry.outputTokens
acc.cacheReadTokens += entry.cacheReadTokens
acc.cacheWriteTokens += entry.cacheWriteTokens
return acc
},
{
turns: 0,
inputTokens: 0,
outputTokens: 0,
cacheReadTokens: 0,
cacheWriteTokens: 0
}
)
const durationMinutes = Math.max(
0,
Math.round(
(new Date(session.lastTimestamp).getTime() - new Date(session.firstTimestamp).getTime()) /
60_000
)
)
return {
sessionId: session.sessionId,
lastActiveAt: session.lastTimestamp,
durationMinutes,
projectLabel: getSessionProjectLabel(scopedLocations),
branch: session.lastGitBranch,
model: session.model,
turns: totals.turns,
inputTokens: totals.inputTokens,
outputTokens: totals.outputTokens,
cacheReadTokens: totals.cacheReadTokens,
cacheWriteTokens: totals.cacheWriteTokens
}
})
}
@@ -59,7 +59,7 @@ describe('listClaudeTranscriptFiles large directories', () => {
throw new Error(`Unexpected readdir path: ${dirPath}`)
})
const { listClaudeTranscriptFiles } = await import('./scanner')
const { listClaudeTranscriptFiles } = await import('./transcript-file-discovery')
await expect(listClaudeTranscriptFiles()).resolves.toHaveLength(FILE_COUNT)
})
+3 -6
View File
@@ -1,10 +1,7 @@
import { describe, expect, it } from 'vitest'
import {
aggregateClaudeUsage,
attributeClaudeUsageTurns,
parseClaudeUsageFile,
parseClaudeUsageRecord
} from './scanner'
import { parseClaudeUsageFile, parseClaudeUsageRecord } from './transcript-record-parser'
import { attributeClaudeUsageTurns } from './worktree-attribution'
import { aggregateClaudeUsage } from './usage-aggregation'
import { mkdtemp, rm, writeFile } from 'node:fs/promises'
import { join } from 'node:path'
import { tmpdir } from 'node:os'
+14 -586
View File
@@ -1,174 +1,33 @@
/* eslint-disable max-lines -- Why: transcript discovery, parsing, attribution, and aggregation share one data shape pipeline. Keeping them co-located makes it easier to audit correctness when Claude usage numbers look surprising. */
import { homedir } from 'node:os'
import { join, basename } from 'node:path'
import { realpath, readdir, stat } from 'node:fs/promises'
import { createReadStream } from 'node:fs'
import { createInterface } from 'node:readline'
import { stat } from 'node:fs/promises'
import type {
ClaudeUsageAttributedTurn,
ClaudeUsageDailyAggregate,
ClaudeUsageLocationBreakdown,
ClaudeUsageParsedTurn,
ClaudeUsagePersistedFile,
ClaudeUsageProcessedFile,
ClaudeUsageSession
} from './types'
import { listClaudeTranscriptFiles } from './transcript-file-discovery'
import { readClaudeUsageScanFile, stripClaudeSourceMetadata } from './transcript-record-parser'
import {
attributeClaudeUsageTurns,
buildWorktreeLookup,
type ClaudeUsageWorktreeRef
} from './worktree-attribution'
import {
aggregateClaudeUsage,
finalizeClaudeSessions,
mergeClaudeDailyAggregates,
mergeClaudeSessions
} from './usage-aggregation'
export type ClaudeUsageWorktreeRef = {
repoId: string
worktreeId: string
path: string
displayName: string
}
type ClaudeUsageSourceRecord = {
type?: string
sessionId?: string
timestamp?: string
cwd?: string
gitBranch?: string
requestId?: string
/** Stable row id when present; preserved across fork-copied history. */
uuid?: string
isSidechain?: boolean
agentId?: string
message?: {
id?: string
model?: string
usage?: {
input_tokens?: number
output_tokens?: number
cache_read_input_tokens?: number
cache_creation_input_tokens?: number
}
}
}
const CLAUDE_PROJECTS_DIR = join(homedir(), '.claude', 'projects')
const CLAUDE_TRANSCRIPTS_DIR = join(homedir(), '.claude', 'transcripts')
const FILE_SCAN_BATCH_SIZE = 4
type ClaudeUsageParsedSourceTurn = ClaudeUsageParsedTurn & {
dedupeKey: string | null
}
type ClaudeUsageWorktreeEntry = [string, ClaudeUsageWorktreeRef]
const sortedWorktreeEntriesByLookup = new WeakMap<
Map<string, ClaudeUsageWorktreeRef>,
ClaudeUsageWorktreeEntry[]
>()
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
}
async function canonicalizePath(pathValue: string): Promise<string> {
try {
const resolved = await realpath(pathValue)
return normalizeComparablePath(resolved)
} catch {
return normalizeComparablePath(pathValue)
}
}
function normalizeComparablePath(pathValue: string): string {
const normalized = pathValue.replace(/\\/g, '/')
return process.platform === 'win32' ? normalized.toLowerCase() : normalized
}
function isContainedPath(parentPath: string, childPath: string): boolean {
const parent = normalizeComparablePath(parentPath).replace(/\/+$/, '')
const child = normalizeComparablePath(childPath).replace(/\/+$/, '')
return child === parent || child.startsWith(`${parent}/`)
}
function findContainingWorktree(
cwd: string,
worktreeLookup: Map<string, ClaudeUsageWorktreeRef>
): ClaudeUsageWorktreeRef | null {
const normalizedCwd = normalizeComparablePath(cwd)
const exact = worktreeLookup.get(normalizedCwd)
if (exact) {
return exact
}
for (const [worktreePath, worktree] of getSortedWorktreeEntries(worktreeLookup)) {
if (isContainedPath(worktreePath, normalizedCwd)) {
return worktree
}
}
return null
}
function getSortedWorktreeEntries(
worktreeLookup: Map<string, ClaudeUsageWorktreeRef>
): ClaudeUsageWorktreeEntry[] {
const cached = sortedWorktreeEntriesByLookup.get(worktreeLookup)
if (cached) {
return cached
}
const sorted = [...worktreeLookup.entries()].sort(
([leftPath], [rightPath]) => rightPath.length - leftPath.length
)
sortedWorktreeEntriesByLookup.set(worktreeLookup, sorted)
return sorted
}
// Why setImmediate: setTimeout(0) is clamped to ~1ms, and this yields once per
// 4-file batch, so a 7.5k-transcript scan spent ~2s parked on timers.
async function yieldToEventLoop(): Promise<void> {
await new Promise((resolve) => setImmediate(resolve))
}
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: long-lived transcript directories can exceed V8's argument limit if
// child file arrays are spread into push().
for (const filePath of source) {
target.push(filePath)
}
}
export async function listClaudeTranscriptFiles(): Promise<string[]> {
const roots = [CLAUDE_PROJECTS_DIR, CLAUDE_TRANSCRIPTS_DIR]
const files = await Promise.all(
roots.map(async (root) => {
try {
return await walkJsonlFiles(root)
} catch {
return []
}
})
)
return [...new Set(files.flat())].sort()
}
async function getProcessedFileStat(
filePath: string
): Promise<Omit<ClaudeUsageProcessedFile, 'lineCount'>> {
@@ -180,427 +39,6 @@ async function getProcessedFileStat(
}
}
function stripClaudeSourceMetadata(turn: ClaudeUsageParsedSourceTurn): ClaudeUsageParsedTurn {
return {
sessionId: turn.sessionId,
timestamp: turn.timestamp,
model: turn.model,
cwd: turn.cwd,
gitBranch: turn.gitBranch,
inputTokens: turn.inputTokens,
outputTokens: turn.outputTokens,
cacheReadTokens: turn.cacheReadTokens,
cacheWriteTokens: turn.cacheWriteTokens
}
}
function dedupeClaudeUsageTurns(
turns: ClaudeUsageParsedSourceTurn[]
): ClaudeUsageParsedSourceTurn[] {
const dedupeIndexByKey = new Map<string, number>()
const deduped: ClaudeUsageParsedSourceTurn[] = []
for (const turn of turns) {
if (turn.dedupeKey) {
const existingIndex = dedupeIndexByKey.get(turn.dedupeKey)
if (existingIndex !== undefined) {
const existing = deduped[existingIndex]
// Why: Claude Code streams repeated assistant rows with the same
// message/request IDs; later rows can contain more complete usage.
existing.inputTokens = Math.max(existing.inputTokens, turn.inputTokens)
existing.outputTokens = Math.max(existing.outputTokens, turn.outputTokens)
existing.cacheReadTokens = Math.max(existing.cacheReadTokens, turn.cacheReadTokens)
existing.cacheWriteTokens = Math.max(existing.cacheWriteTokens, turn.cacheWriteTokens)
continue
}
}
deduped.push({ ...turn })
if (turn.dedupeKey) {
dedupeIndexByKey.set(turn.dedupeKey, deduped.length - 1)
}
}
return deduped
}
function parseClaudeUsageSourceRecord(
line: string,
fallbackSessionId: string | null = null
): ClaudeUsageParsedSourceTurn | null {
let parsed: ClaudeUsageSourceRecord
try {
parsed = JSON.parse(line) as ClaudeUsageSourceRecord
} catch {
return null
}
if (parsed.type !== 'assistant') {
return null
}
const sessionId = parsed.sessionId ?? fallbackSessionId
if (!sessionId || !parsed.timestamp) {
return null
}
const usage = parsed.message?.usage
const inputTokens = usage?.input_tokens ?? 0
const outputTokens = usage?.output_tokens ?? 0
const cacheReadTokens = usage?.cache_read_input_tokens ?? 0
const cacheWriteTokens = usage?.cache_creation_input_tokens ?? 0
if (inputTokens + outputTokens + cacheReadTokens + cacheWriteTokens <= 0) {
return null
}
return {
sessionId,
timestamp: parsed.timestamp,
model: parsed.message?.model ?? null,
cwd: parsed.cwd ?? null,
gitBranch: parsed.gitBranch ?? null,
// Why: forks rewrite sessionId but keep message/request ids (and usually
// uuid). Prefer the strongest stable identity available so ownership still
// works when requestId is missing on older or partial rows.
dedupeKey: buildClaudeUsageDedupeKey(parsed),
inputTokens,
outputTokens,
cacheReadTokens,
cacheWriteTokens
}
}
function buildClaudeUsageDedupeKey(parsed: ClaudeUsageSourceRecord): string | null {
const messageId = parsed.message?.id?.trim()
const requestId = parsed.requestId?.trim()
if (messageId && requestId) {
return `${messageId}:${requestId}`
}
if (messageId) {
return `msg:${messageId}`
}
const uuid = parsed.uuid?.trim()
if (uuid) {
return `uuid:${uuid}`
}
return null
}
export function parseClaudeUsageRecord(line: string): ClaudeUsageParsedTurn | null {
const parsed = parseClaudeUsageSourceRecord(line)
return parsed ? stripClaudeSourceMetadata(parsed) : null
}
export async function parseClaudeUsageFile(filePath: string): Promise<ClaudeUsageParsedTurn[]> {
const turns: ClaudeUsageParsedSourceTurn[] = []
const fallbackSessionId = basename(filePath, '.jsonl')
const lines = createInterface({
input: createReadStream(filePath, { encoding: 'utf-8' }),
crlfDelay: Infinity
})
for await (const line of lines) {
const parsed = parseClaudeUsageSourceRecord(line, fallbackSessionId)
if (parsed) {
turns.push(parsed)
}
}
return dedupeClaudeUsageTurns(turns).map(stripClaudeSourceMetadata)
}
async function readClaudeUsageScanFile(filePath: string): Promise<{
processedFile: ClaudeUsageProcessedFile
turns: ClaudeUsageParsedSourceTurn[]
}> {
const fileStat = await stat(filePath)
let lineCount = 0
const turns: ClaudeUsageParsedSourceTurn[] = []
const fallbackSessionId = basename(filePath, '.jsonl')
const lines = createInterface({
input: createReadStream(filePath, { encoding: 'utf-8' }),
crlfDelay: Infinity
})
for await (const line of lines) {
lineCount++
const parsed = parseClaudeUsageSourceRecord(line, fallbackSessionId)
if (parsed) {
turns.push(parsed)
}
}
return {
processedFile: {
path: filePath,
mtimeMs: fileStat.mtimeMs,
size: fileStat.size,
lineCount
},
turns: dedupeClaudeUsageTurns(turns)
}
}
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}`
}
export async function buildWorktreeLookup(
worktrees: ClaudeUsageWorktreeRef[]
): Promise<Map<string, ClaudeUsageWorktreeRef>> {
const lookup = new Map<string, ClaudeUsageWorktreeRef>()
for (const worktree of worktrees) {
lookup.set(await canonicalizePath(worktree.path), worktree)
}
return lookup
}
export async function attributeClaudeUsageTurns(
turns: ClaudeUsageParsedTurn[],
worktreeLookup: Map<string, ClaudeUsageWorktreeRef>
): Promise<ClaudeUsageAttributedTurn[]> {
const attributed: ClaudeUsageAttributedTurn[] = []
const canonicalCwdByPath = new Map<string, string>()
for (const turn of turns) {
const day = localDayFromTimestamp(turn.timestamp)
if (!day) {
continue
}
let repoId: string | null = null
let worktreeId: string | null = null
let projectKey = 'unscoped'
let projectLabel = getDefaultProjectLabel(turn.cwd)
if (turn.cwd) {
let canonicalCwd = canonicalCwdByPath.get(turn.cwd)
if (canonicalCwd === undefined) {
// Why: Claude transcripts repeat the same cwd for many consecutive
// turns. Cache realpath work so attribution scales with unique paths.
canonicalCwd = await canonicalizePath(turn.cwd)
canonicalCwdByPath.set(turn.cwd, canonicalCwd)
}
const worktree = findContainingWorktree(canonicalCwd, worktreeLookup)
if (worktree) {
repoId = worktree.repoId
worktreeId = worktree.worktreeId
projectKey = `worktree:${worktreeId}`
projectLabel = worktree.displayName
} else {
projectKey = `cwd:${normalizeComparablePath(turn.cwd)}`
}
}
attributed.push({
...turn,
day,
projectKey,
projectLabel,
repoId,
worktreeId
})
}
return attributed
}
function mergeClaudeSessions(
target: Map<string, ClaudeUsageSession>,
sessions: ClaudeUsageSession[]
): void {
for (const session of sessions) {
const existing = target.get(session.sessionId)
if (!existing) {
target.set(session.sessionId, structuredClone(session))
continue
}
if (session.firstTimestamp < existing.firstTimestamp) {
existing.firstTimestamp = session.firstTimestamp
}
if (session.lastTimestamp > existing.lastTimestamp) {
existing.lastTimestamp = session.lastTimestamp
existing.lastCwd = session.lastCwd
existing.lastGitBranch = session.lastGitBranch
}
existing.model = session.model ?? existing.model
existing.turnCount += session.turnCount
existing.totalInputTokens += session.totalInputTokens
existing.totalOutputTokens += session.totalOutputTokens
existing.totalCacheReadTokens += session.totalCacheReadTokens
existing.totalCacheWriteTokens += session.totalCacheWriteTokens
for (const location of session.locationBreakdown) {
const existingLocation =
existing.locationBreakdown.find((entry) => entry.locationKey === location.locationKey) ??
null
if (existingLocation) {
existingLocation.turnCount += location.turnCount
existingLocation.inputTokens += location.inputTokens
existingLocation.outputTokens += location.outputTokens
existingLocation.cacheReadTokens += location.cacheReadTokens
existingLocation.cacheWriteTokens += location.cacheWriteTokens
} else {
existing.locationBreakdown.push({ ...location })
}
}
}
}
function mergeClaudeDailyAggregates(
target: Map<string, ClaudeUsageDailyAggregate>,
dailyAggregates: ClaudeUsageDailyAggregate[]
): 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.turnCount += aggregate.turnCount
existing.zeroCacheReadTurnCount += aggregate.zeroCacheReadTurnCount
existing.inputTokens += aggregate.inputTokens
existing.outputTokens += aggregate.outputTokens
existing.cacheReadTokens += aggregate.cacheReadTokens
existing.cacheWriteTokens += aggregate.cacheWriteTokens
}
}
function finalizeClaudeSessions(
sessionsById: Map<string, ClaudeUsageSession>
): ClaudeUsageSession[] {
for (const session of sessionsById.values()) {
session.locationBreakdown.sort((left, right) => {
const leftTotal = left.inputTokens + left.outputTokens
const rightTotal = right.inputTokens + right.outputTokens
return rightTotal - leftTotal
})
const primaryLocation = session.locationBreakdown[0] ?? null
if (primaryLocation) {
session.primaryRepoId = primaryLocation.repoId
session.primaryWorktreeId = primaryLocation.worktreeId
}
}
return [...sessionsById.values()].sort((left, right) =>
right.lastTimestamp.localeCompare(left.lastTimestamp)
)
}
export function aggregateClaudeUsage(turns: ClaudeUsageAttributedTurn[]): {
sessions: ClaudeUsageSession[]
dailyAggregates: ClaudeUsageDailyAggregate[]
} {
const sessionsById = new Map<string, ClaudeUsageSession>()
const dailyByKey = new Map<string, ClaudeUsageDailyAggregate>()
for (const turn of turns) {
const existingSession = sessionsById.get(turn.sessionId)
if (!existingSession) {
sessionsById.set(turn.sessionId, {
sessionId: turn.sessionId,
firstTimestamp: turn.timestamp,
lastTimestamp: turn.timestamp,
model: turn.model,
lastCwd: turn.cwd,
lastGitBranch: turn.gitBranch,
primaryWorktreeId: turn.worktreeId,
primaryRepoId: turn.repoId,
turnCount: 0,
totalInputTokens: 0,
totalOutputTokens: 0,
totalCacheReadTokens: 0,
totalCacheWriteTokens: 0,
locationBreakdown: []
})
}
const session = sessionsById.get(turn.sessionId)!
if (turn.timestamp < session.firstTimestamp) {
session.firstTimestamp = turn.timestamp
}
if (turn.timestamp > session.lastTimestamp) {
session.lastTimestamp = turn.timestamp
session.lastCwd = turn.cwd
session.lastGitBranch = turn.gitBranch
}
session.model = turn.model ?? session.model
session.turnCount++
session.totalInputTokens += turn.inputTokens
session.totalOutputTokens += turn.outputTokens
session.totalCacheReadTokens += turn.cacheReadTokens
session.totalCacheWriteTokens += turn.cacheWriteTokens
const location =
session.locationBreakdown.find((entry) => entry.locationKey === turn.projectKey) ?? null
if (location) {
location.turnCount++
location.inputTokens += turn.inputTokens
location.outputTokens += turn.outputTokens
location.cacheReadTokens += turn.cacheReadTokens
location.cacheWriteTokens += turn.cacheWriteTokens
} else {
session.locationBreakdown.push({
locationKey: turn.projectKey,
projectLabel: turn.projectLabel,
repoId: turn.repoId,
worktreeId: turn.worktreeId,
turnCount: 1,
inputTokens: turn.inputTokens,
outputTokens: turn.outputTokens,
cacheReadTokens: turn.cacheReadTokens,
cacheWriteTokens: turn.cacheWriteTokens
})
}
const dailyKey = [turn.day, turn.model ?? 'unknown', turn.projectKey].join('::')
const existingDaily = dailyByKey.get(dailyKey)
if (existingDaily) {
existingDaily.turnCount++
if (turn.cacheReadTokens === 0) {
existingDaily.zeroCacheReadTurnCount++
}
existingDaily.inputTokens += turn.inputTokens
existingDaily.outputTokens += turn.outputTokens
existingDaily.cacheReadTokens += turn.cacheReadTokens
existingDaily.cacheWriteTokens += turn.cacheWriteTokens
} else {
dailyByKey.set(dailyKey, {
day: turn.day,
model: turn.model,
projectKey: turn.projectKey,
projectLabel: turn.projectLabel,
repoId: turn.repoId,
worktreeId: turn.worktreeId,
turnCount: 1,
zeroCacheReadTurnCount: turn.cacheReadTokens === 0 ? 1 : 0,
inputTokens: turn.inputTokens,
outputTokens: turn.outputTokens,
cacheReadTokens: turn.cacheReadTokens,
cacheWriteTokens: turn.cacheWriteTokens
})
}
}
return {
sessions: finalizeClaudeSessions(sessionsById),
dailyAggregates: [...dailyByKey.values()].sort((left, right) =>
left.day === right.day
? left.projectLabel.localeCompare(right.projectLabel)
: left.day.localeCompare(right.day)
)
}
}
export async function scanClaudeUsageFiles(
worktrees: ClaudeUsageWorktreeRef[],
previousProcessedFiles: ClaudeUsagePersistedFile[] = []
@@ -737,13 +175,3 @@ export async function scanClaudeUsageFiles(
)
}
}
export function getSessionProjectLabel(locationBreakdown: ClaudeUsageLocationBreakdown[]): string {
if (locationBreakdown.length === 0) {
return 'Unknown location'
}
if (locationBreakdown.length === 1) {
return locationBreakdown[0].projectLabel
}
return 'Multiple locations'
}
+17 -615
View File
@@ -1,4 +1,3 @@
/* eslint-disable max-lines -- Why: Claude pricing, range, scope, breakdown, and automation-attribution policies remain one cohesive store. */
import { app } from 'electron'
import { join } from 'node:path'
import type {
@@ -14,102 +13,22 @@ import type {
import type { AutomationRunUsage } from '../../shared/automations-types'
import type { Store } from '../persistence'
import type { ClaudeUsagePersistedState } from './types'
import { getSessionProjectLabel, scanClaudeUsageFiles } from './scanner'
import { getLocalUsageDay, getUsageRangeCutoff } from '../usage/usage-calendar-range'
import { scanClaudeUsageFiles } from './scanner'
import { UsageProviderStoreLifecycle } from '../usage/usage-provider-store-lifecycle'
import { buildBreakdown, buildDaily, buildSummary } from './claude-usage-report-aggregation'
import { buildRecentSessions } from './claude-usage-session-rows'
import type { AutomationUsageLookupInput } from './claude-usage-automation-attribution'
import { resolveAutomationRunUsage } from './claude-usage-automation-attribution'
// Why: v5 widens Claude ownership keys (message-id / uuid fallbacks). Older
// caches either lack ownership or used narrower keys and can under/over-count
// after fork reclaim (#8006).
const SCHEMA_VERSION = 5
const AUTOMATION_ATTRIBUTION_WINDOW_MS = 5 * 60_000
// Why: capture the path after configureDevUserDataPath() but before app.setName()
// mutates Electron's derived userData location, matching the persistence/store pattern.
let _claudeUsageFile: string | null = null
type ClaudeModelPricing = {
input: number
output: number
cacheRead: number
cacheWrite: number
thresholdTokens?: number
inputAboveThreshold?: number
outputAboveThreshold?: number
cacheReadAboveThreshold?: number
cacheWriteAboveThreshold?: number
}
type AutomationUsageLookupInput = {
worktreeId: string | null
terminalSessionId: string | null
startedAt: number | null
completedAt: number | null
}
const LONG_CONTEXT_THRESHOLD_TOKENS = 200_000
const SONNET_LONG_CONTEXT_PRICING = {
thresholdTokens: LONG_CONTEXT_THRESHOLD_TOKENS,
inputAboveThreshold: 6,
outputAboveThreshold: 22.5,
cacheReadAboveThreshold: 0.6,
cacheWriteAboveThreshold: 7.5
} satisfies Partial<ClaudeModelPricing>
const MODEL_PRICING: Record<string, ClaudeModelPricing> = {
'claude-fable-5': { input: 10, output: 50, cacheRead: 1, cacheWrite: 12.5 },
'claude-opus-5': { input: 5, output: 25, cacheRead: 0.5, cacheWrite: 6.25 },
// Why: Sonnet 5 bills its full 1M window at flat rates, so no long-context tier here.
// Why: standard rates, not the $2/$10 introductory rate ending 2026-08-31 — no date dimension.
'claude-sonnet-5': { input: 3, output: 15, cacheRead: 0.3, cacheWrite: 3.75 },
'claude-opus-4-8': { input: 5, output: 25, cacheRead: 0.5, cacheWrite: 6.25 },
'claude-opus-4-7': { input: 5, output: 25, cacheRead: 0.5, cacheWrite: 6.25 },
'claude-opus-4-6': { input: 5, output: 25, cacheRead: 0.5, cacheWrite: 6.25 },
'claude-opus-4-5': { input: 5, output: 25, cacheRead: 0.5, cacheWrite: 6.25 },
'claude-opus-4-1': { input: 15, output: 75, cacheRead: 1.5, cacheWrite: 18.75 },
'claude-opus-4': { input: 15, output: 75, cacheRead: 1.5, cacheWrite: 18.75 },
'claude-sonnet-4-6': {
input: 3,
output: 15,
cacheRead: 0.3,
cacheWrite: 3.75,
...SONNET_LONG_CONTEXT_PRICING
},
'claude-sonnet-4-5': {
input: 3,
output: 15,
cacheRead: 0.3,
cacheWrite: 3.75,
...SONNET_LONG_CONTEXT_PRICING
},
'claude-sonnet-4': {
input: 3,
output: 15,
cacheRead: 0.3,
cacheWrite: 3.75,
...SONNET_LONG_CONTEXT_PRICING
},
'claude-sonnet-3-7': { input: 3, output: 15, cacheRead: 0.3, cacheWrite: 3.75 },
'claude-sonnet-3-5': { input: 3, output: 15, cacheRead: 0.3, cacheWrite: 3.75 },
'claude-haiku-4-5': { input: 1, output: 5, cacheRead: 0.1, cacheWrite: 1.25 },
'claude-haiku-3-5': { input: 0.8, output: 4, cacheRead: 0.08, cacheWrite: 1 },
'claude-haiku-3': { input: 0.25, output: 1.25, cacheRead: 0.03, cacheWrite: 0.3 }
}
const MODEL_ALIASES: Record<string, string> = {
model_placeholder_m26: 'claude-opus-4-6',
model_placeholder_m35: 'claude-sonnet-4-6',
'claude-opus-4.8': 'claude-opus-4-8',
'claude-opus-4.6': 'claude-opus-4-6',
'claude-sonnet-4.6': 'claude-sonnet-4-6',
'claude-opus-4.8-thinking': 'claude-opus-4-8',
'claude-opus-4.6-thinking': 'claude-opus-4-6',
'claude-sonnet-4.6-thinking': 'claude-sonnet-4-6',
'claude-opus-4-8-thinking': 'claude-opus-4-8',
'claude-opus-4-6-thinking': 'claude-opus-4-6',
'claude-sonnet-4-6-thinking': 'claude-sonnet-4-6'
}
function getDefaultState(): ClaudeUsagePersistedState {
return {
schemaVersion: SCHEMA_VERSION,
@@ -152,153 +71,6 @@ function getClaudeUsageFile(): string {
return _claudeUsageFile
}
function hasClaudeModelVersion(model: string, family: string, version: string): boolean {
const normalized = model.replace(/\./g, '-')
return new RegExp(`${family}-${version}(?:$|[^0-9])`).test(normalized)
}
function isLegacyBaseOpus4Model(model: string): boolean {
const normalized = model.replace(/\./g, '-')
return /opus-4(?:$|-thinking$|-20\d{6}(?:-thinking)?$|@20\d{6}$)/.test(normalized)
}
function normalizeModelForPricing(model: string | null): string | null {
if (!model) {
return null
}
const lower = model
.toLowerCase()
.trim()
.replace(/^anthropic[/:]/, '')
const alias = MODEL_ALIASES[lower]
if (alias) {
return alias
}
if (hasClaudeModelVersion(lower, 'fable', '5')) {
return 'claude-fable-5'
}
if (hasClaudeModelVersion(lower, 'opus', '5')) {
return 'claude-opus-5'
}
if (hasClaudeModelVersion(lower, 'opus', '4-8')) {
return 'claude-opus-4-8'
}
if (hasClaudeModelVersion(lower, 'opus', '4-7')) {
return 'claude-opus-4-7'
}
if (hasClaudeModelVersion(lower, 'opus', '4-6')) {
return 'claude-opus-4-6'
}
if (hasClaudeModelVersion(lower, 'opus', '4-5')) {
return 'claude-opus-4-5'
}
if (hasClaudeModelVersion(lower, 'opus', '4-1')) {
return 'claude-opus-4-1'
}
if (isLegacyBaseOpus4Model(lower)) {
return 'claude-opus-4'
}
if (lower.includes('opus-4')) {
// Why: new Opus 4 point releases now share the current low Opus pricing;
// avoid overbilling unknown future Claude Code model IDs as legacy Opus 4.
return 'claude-opus-4-8'
}
if (hasClaudeModelVersion(lower, 'sonnet', '5')) {
return 'claude-sonnet-5'
}
if (hasClaudeModelVersion(lower, 'sonnet', '4-6')) {
return 'claude-sonnet-4-6'
}
if (hasClaudeModelVersion(lower, 'sonnet', '4-5')) {
return 'claude-sonnet-4-5'
}
if (lower.includes('sonnet-4')) {
return 'claude-sonnet-4-6'
}
if (lower.includes('sonnet-3-7') || lower.includes('sonnet-3.7')) {
return 'claude-sonnet-3-7'
}
// Why: legacy version-first IDs like `claude-3-5-sonnet-20241022` are still
// present in historical Claude Code/SDK logs read off disk. Match them so
// their cost is not silently dropped from the breakdown.
if (
lower.includes('sonnet-3-5') ||
lower.includes('sonnet-3.5') ||
lower.includes('3-5-sonnet') ||
lower.includes('3.5-sonnet')
) {
return 'claude-sonnet-3-5'
}
if (lower.includes('haiku-4-5')) {
return 'claude-haiku-4-5'
}
if (lower.includes('haiku-3-5') || lower.includes('haiku-3.5')) {
return 'claude-haiku-3-5'
}
if (lower.includes('3-5-haiku') || lower.includes('3.5-haiku')) {
return 'claude-haiku-3-5'
}
if (lower.includes('haiku-3')) {
return 'claude-haiku-3'
}
return null
}
function calculateTieredCost(
tokens: number,
basePrice: number,
abovePrice?: number,
threshold?: number
): number {
if (threshold === undefined || abovePrice === undefined) {
return tokens * basePrice
}
const belowTokens = Math.min(tokens, threshold)
const aboveTokens = Math.max(tokens - threshold, 0)
return belowTokens * basePrice + aboveTokens * abovePrice
}
function estimateCostUsd(
model: string | null,
inputTokens: number,
outputTokens: number,
cacheReadTokens: number,
cacheWriteTokens: number
): number | null {
const normalized = normalizeModelForPricing(model)
if (!normalized) {
return null
}
const pricing = MODEL_PRICING[normalized]
return (
(calculateTieredCost(
inputTokens,
pricing.input,
pricing.inputAboveThreshold,
pricing.thresholdTokens
) +
calculateTieredCost(
outputTokens,
pricing.output,
pricing.outputAboveThreshold,
pricing.thresholdTokens
) +
calculateTieredCost(
cacheReadTokens,
pricing.cacheRead,
pricing.cacheReadAboveThreshold,
pricing.thresholdTokens
) +
calculateTieredCost(
cacheWriteTokens,
pricing.cacheWrite,
pricing.cacheWriteAboveThreshold,
pricing.thresholdTokens
)) /
1_000_000
)
}
export class ClaudeUsageStore extends UsageProviderStoreLifecycle<
'processedFiles',
ClaudeUsagePersistedState,
@@ -324,87 +96,17 @@ export class ClaudeUsageStore extends UsageProviderStoreLifecycle<
): ClaudeUsageSnapshot {
return {
scanState: this.getScanState(),
summary: this.buildSummary(scope, range),
daily: this.buildDaily(scope, range),
modelBreakdown: this.buildBreakdown(scope, range, 'model'),
projectBreakdown: this.buildBreakdown(scope, range, 'project'),
recentSessions: this.buildRecentSessions(scope, range, recentSessionLimit)
summary: buildSummary(this.state, scope, range),
daily: buildDaily(this.state, scope, range),
modelBreakdown: buildBreakdown(this.state, scope, range, 'model'),
projectBreakdown: buildBreakdown(this.state, scope, range, 'project'),
recentSessions: buildRecentSessions(this.state, scope, range, recentSessionLimit)
}
}
async getSummary(scope: ClaudeUsageScope, range: ClaudeUsageRange): Promise<ClaudeUsageSummary> {
await this.refresh(false)
return this.buildSummary(scope, range)
}
private buildSummary(scope: ClaudeUsageScope, range: ClaudeUsageRange): ClaudeUsageSummary {
const filteredDaily = this.getFilteredDaily(scope, range)
const filteredSessions = this.getFilteredSessions(scope, range)
let inputTokens = 0
let outputTokens = 0
let cacheReadTokens = 0
let cacheWriteTokens = 0
let turns = 0
let zeroCacheReadTurns = 0
const byModel = new Map<string, number>()
const byProject = new Map<string, number>()
let estimatedCostUsd = 0
let hasAnyBillableCost = false
for (const row of filteredDaily) {
inputTokens += row.inputTokens
outputTokens += row.outputTokens
cacheReadTokens += row.cacheReadTokens
cacheWriteTokens += row.cacheWriteTokens
turns += row.turnCount
zeroCacheReadTurns += row.zeroCacheReadTurnCount
const modelKey = row.model ?? 'Unknown model'
byModel.set(modelKey, (byModel.get(modelKey) ?? 0) + row.inputTokens + row.outputTokens)
byProject.set(
row.projectLabel,
(byProject.get(row.projectLabel) ?? 0) + row.inputTokens + row.outputTokens
)
const cost = estimateCostUsd(
row.model,
row.inputTokens,
row.outputTokens,
row.cacheReadTokens,
row.cacheWriteTokens
)
if (cost !== null) {
hasAnyBillableCost = true
estimatedCostUsd += cost
}
}
const topModel =
[...byModel.entries()].sort((left, right) => right[1] - left[1])[0]?.[0] ?? null
const topProject =
[...byProject.entries()].sort((left, right) => right[1] - left[1])[0]?.[0] ?? null
return {
scope,
range,
sessions: filteredSessions.length,
turns,
zeroCacheReadTurns,
inputTokens,
outputTokens,
cacheReadTokens,
cacheWriteTokens,
cacheReuseRate:
inputTokens + cacheReadTokens > 0
? cacheReadTokens / (inputTokens + cacheReadTokens)
: null,
estimatedCostUsd: hasAnyBillableCost ? estimatedCostUsd : null,
topModel,
topProject,
// Why: the empty-state UX is scope/range specific. Using global persisted
// data here makes the Orca-only view render empty charts instead of the
// intended "no usage for this scope" message when only off-Orca logs exist.
hasAnyClaudeData: filteredSessions.length > 0 || filteredDaily.length > 0
}
return buildSummary(this.state, scope, range)
}
async getDaily(
@@ -412,26 +114,7 @@ export class ClaudeUsageStore extends UsageProviderStoreLifecycle<
range: ClaudeUsageRange
): Promise<ClaudeUsageDailyPoint[]> {
await this.refresh(false)
return this.buildDaily(scope, range)
}
private buildDaily(scope: ClaudeUsageScope, range: ClaudeUsageRange): ClaudeUsageDailyPoint[] {
const byDay = new Map<string, ClaudeUsageDailyPoint>()
for (const row of this.getFilteredDaily(scope, range)) {
const existing = byDay.get(row.day) ?? {
day: row.day,
inputTokens: 0,
outputTokens: 0,
cacheReadTokens: 0,
cacheWriteTokens: 0
}
existing.inputTokens += row.inputTokens
existing.outputTokens += row.outputTokens
existing.cacheReadTokens += row.cacheReadTokens
existing.cacheWriteTokens += row.cacheWriteTokens
byDay.set(row.day, existing)
}
return [...byDay.values()].sort((left, right) => left.day.localeCompare(right.day))
return buildDaily(this.state, scope, range)
}
async getBreakdown(
@@ -440,82 +123,7 @@ export class ClaudeUsageStore extends UsageProviderStoreLifecycle<
kind: ClaudeUsageBreakdownKind
): Promise<ClaudeUsageBreakdownRow[]> {
await this.refresh(false)
return this.buildBreakdown(scope, range, kind)
}
private buildBreakdown(
scope: ClaudeUsageScope,
range: ClaudeUsageRange,
kind: ClaudeUsageBreakdownKind
): ClaudeUsageBreakdownRow[] {
const rows = new Map<string, ClaudeUsageBreakdownRow>()
const filteredDaily = this.getFilteredDaily(scope, range)
const filteredSessions = this.getFilteredSessions(scope, range)
for (const daily of filteredDaily) {
const key = kind === 'model' ? (daily.model ?? 'unknown') : daily.projectKey
const label = kind === 'model' ? (daily.model ?? 'Unknown model') : daily.projectLabel
const existing = rows.get(key) ?? {
key,
label,
sessions: 0,
turns: 0,
inputTokens: 0,
outputTokens: 0,
cacheReadTokens: 0,
cacheWriteTokens: 0,
estimatedCostUsd: null
}
existing.turns += daily.turnCount
existing.inputTokens += daily.inputTokens
existing.outputTokens += daily.outputTokens
existing.cacheReadTokens += daily.cacheReadTokens
existing.cacheWriteTokens += daily.cacheWriteTokens
rows.set(key, existing)
}
for (const session of filteredSessions) {
if (kind === 'model') {
const key = session.model ?? 'unknown'
const row = rows.get(key)
if (row) {
row.sessions++
}
continue
}
const matchingLocations = session.locationBreakdown.filter((entry) =>
scope === 'all' ? true : entry.worktreeId !== null
)
const seen = new Set<string>()
for (const location of matchingLocations) {
if (seen.has(location.locationKey)) {
continue
}
seen.add(location.locationKey)
const row = rows.get(location.locationKey)
if (row) {
row.sessions++
}
}
}
for (const row of rows.values()) {
if (kind === 'model') {
row.estimatedCostUsd = estimateCostUsd(
row.key,
row.inputTokens,
row.outputTokens,
row.cacheReadTokens,
row.cacheWriteTokens
)
}
}
return [...rows.values()].sort((left, right) => {
const leftTotal = left.inputTokens + left.outputTokens
const rightTotal = right.inputTokens + right.outputTokens
return rightTotal - leftTotal
})
return buildBreakdown(this.state, scope, range, kind)
}
async getRecentSessions(
@@ -524,219 +132,13 @@ export class ClaudeUsageStore extends UsageProviderStoreLifecycle<
limit = 12
): Promise<ClaudeUsageSessionRow[]> {
await this.refresh(false)
return this.buildRecentSessions(scope, range, limit)
}
private buildRecentSessions(
scope: ClaudeUsageScope,
range: ClaudeUsageRange,
limit = 12
): ClaudeUsageSessionRow[] {
return this.getFilteredSessions(scope, range)
.slice(0, limit)
.map((session) => {
const matchingLocations = session.locationBreakdown.filter((entry) =>
scope === 'all' ? true : entry.worktreeId !== null
)
const scopedLocations =
matchingLocations.length > 0 ? matchingLocations : session.locationBreakdown
const totals = scopedLocations.reduce(
(acc, entry) => {
acc.turns += entry.turnCount
acc.inputTokens += entry.inputTokens
acc.outputTokens += entry.outputTokens
acc.cacheReadTokens += entry.cacheReadTokens
acc.cacheWriteTokens += entry.cacheWriteTokens
return acc
},
{
turns: 0,
inputTokens: 0,
outputTokens: 0,
cacheReadTokens: 0,
cacheWriteTokens: 0
}
)
const durationMinutes = Math.max(
0,
Math.round(
(new Date(session.lastTimestamp).getTime() -
new Date(session.firstTimestamp).getTime()) /
60_000
)
)
return {
sessionId: session.sessionId,
lastActiveAt: session.lastTimestamp,
durationMinutes,
projectLabel: getSessionProjectLabel(scopedLocations),
branch: session.lastGitBranch,
model: session.model,
turns: totals.turns,
inputTokens: totals.inputTokens,
outputTokens: totals.outputTokens,
cacheReadTokens: totals.cacheReadTokens,
cacheWriteTokens: totals.cacheWriteTokens
}
})
return buildRecentSessions(this.state, scope, range, limit)
}
async getAutomationRunUsage(input: AutomationUsageLookupInput): Promise<AutomationRunUsage> {
const collectedAt = Date.now()
const unavailable = (
unavailableReason: AutomationRunUsage['unavailableReason'],
unavailableMessage: string
): AutomationRunUsage => ({
status: 'unavailable',
provider: 'claude',
model: null,
inputTokens: null,
outputTokens: null,
cacheReadTokens: null,
cacheWriteTokens: null,
reasoningOutputTokens: null,
totalTokens: null,
estimatedCostUsd: null,
estimatedCostSource: null,
providerSessionId: null,
attribution: null,
collectedAt,
unavailableReason,
unavailableMessage
return resolveAutomationRunUsage(input, {
getState: () => this.state,
refresh: (force) => this.refresh(force)
})
if (!this.state.scanState.enabled) {
return unavailable('usage_not_enabled', 'Claude usage tracking is not enabled.')
}
if (!input.worktreeId || !input.startedAt || !input.completedAt) {
return unavailable('no_matching_session', 'Run session metadata is incomplete.')
}
const scanState = await this.refresh(this.shouldForceAutomationUsageScan(input.completedAt))
if (scanState.lastScanError) {
return unavailable('scan_failed', scanState.lastScanError)
}
const windowStart = input.startedAt - AUTOMATION_ATTRIBUTION_WINDOW_MS
const windowEnd = input.completedAt + AUTOMATION_ATTRIBUTION_WINDOW_MS
const candidates = this.state.sessions.filter((session) => {
const first = new Date(session.firstTimestamp).getTime()
const last = new Date(session.lastTimestamp).getTime()
if (!Number.isFinite(first) || !Number.isFinite(last)) {
return false
}
if (session.sessionId === input.terminalSessionId) {
return true
}
if (first < windowStart || first > windowEnd || last > windowEnd) {
return false
}
return session.locationBreakdown.some((entry) => entry.worktreeId === input.worktreeId)
})
if (candidates.length === 0) {
return unavailable('no_matching_session', 'No Claude usage session matched this run.')
}
if (candidates.length > 1) {
return unavailable(
'ambiguous_session',
'Multiple Claude usage sessions matched this run window.'
)
}
const session = candidates[0]
const scopedLocations = session.locationBreakdown.filter(
(entry) => entry.worktreeId === input.worktreeId
)
const locations = scopedLocations.length > 0 ? scopedLocations : session.locationBreakdown
const totals = locations.reduce(
(acc, entry) => {
acc.turns += entry.turnCount
acc.inputTokens += entry.inputTokens
acc.outputTokens += entry.outputTokens
acc.cacheReadTokens += entry.cacheReadTokens
acc.cacheWriteTokens += entry.cacheWriteTokens
return acc
},
{
turns: 0,
inputTokens: 0,
outputTokens: 0,
cacheReadTokens: 0,
cacheWriteTokens: 0
}
)
const estimatedCostUsd = estimateCostUsd(
session.model,
totals.inputTokens,
totals.outputTokens,
totals.cacheReadTokens,
totals.cacheWriteTokens
)
return {
status: 'known',
provider: 'claude',
model: session.model,
inputTokens: totals.inputTokens,
outputTokens: totals.outputTokens,
cacheReadTokens: totals.cacheReadTokens,
cacheWriteTokens: totals.cacheWriteTokens,
reasoningOutputTokens: null,
totalTokens:
totals.inputTokens + totals.outputTokens + totals.cacheReadTokens + totals.cacheWriteTokens,
estimatedCostUsd,
estimatedCostSource: estimatedCostUsd === null ? null : 'api_equivalent',
providerSessionId: session.sessionId,
// Why: Orca terminal tab ids and Claude usage session ids are different
// systems today, so attribution is intentionally limited to one local
// provider session in the run's worktree/time window.
attribution: 'provider_session_time_window',
collectedAt,
unavailableReason: null,
unavailableMessage: null
}
}
private getFilteredDaily(scope: ClaudeUsageScope, range: ClaudeUsageRange) {
const cutoff = getUsageRangeCutoff(range)
return this.state.dailyAggregates.filter((entry) => {
if (cutoff && entry.day < cutoff) {
return false
}
if (scope === 'orca' && entry.worktreeId === null) {
return false
}
return true
})
}
private getFilteredSessions(scope: ClaudeUsageScope, range: ClaudeUsageRange) {
const cutoff = getUsageRangeCutoff(range)
return this.state.sessions.filter((session) => {
// Why: daily aggregates use local calendar days, so session filtering has
// to use the same conversion or the sessions table/counts can disagree
// with the chart around UTC day boundaries.
const day = getLocalUsageDay(session.lastTimestamp)
if (!day) {
return false
}
if (cutoff && day < cutoff) {
return false
}
if (scope === 'orca') {
return session.locationBreakdown.some((entry) => entry.worktreeId !== null)
}
return true
})
}
private shouldForceAutomationUsageScan(completedAt: number): boolean {
const { lastScanCompletedAt, lastScanError } = this.state.scanState
// Why: attribution needs a scan after the run finishes, but repeated
// lookups after that point should not rescan all Claude transcript history.
return (
Boolean(lastScanError) || lastScanCompletedAt === null || lastScanCompletedAt < completedAt
)
}
}
@@ -0,0 +1,46 @@
import { homedir } from 'node:os'
import { join } from 'node:path'
import { readdir } from 'node:fs/promises'
const CLAUDE_PROJECTS_DIR = join(homedir(), '.claude', 'projects')
const CLAUDE_TRANSCRIPTS_DIR = join(homedir(), '.claude', 'transcripts')
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: long-lived transcript directories can exceed V8's argument limit if
// child file arrays are spread into push().
for (const filePath of source) {
target.push(filePath)
}
}
export async function listClaudeTranscriptFiles(): Promise<string[]> {
const roots = [CLAUDE_PROJECTS_DIR, CLAUDE_TRANSCRIPTS_DIR]
const files = await Promise.all(
roots.map(async (root) => {
try {
return await walkJsonlFiles(root)
} catch {
return []
}
})
)
return [...new Set(files.flat())].sort()
}
@@ -0,0 +1,195 @@
import { basename } from 'node:path'
import { stat } from 'node:fs/promises'
import { createReadStream } from 'node:fs'
import { createInterface } from 'node:readline'
import type { ClaudeUsageParsedTurn, ClaudeUsageProcessedFile } from './types'
type ClaudeUsageSourceRecord = {
type?: string
sessionId?: string
timestamp?: string
cwd?: string
gitBranch?: string
requestId?: string
/** Stable row id when present; preserved across fork-copied history. */
uuid?: string
isSidechain?: boolean
agentId?: string
message?: {
id?: string
model?: string
usage?: {
input_tokens?: number
output_tokens?: number
cache_read_input_tokens?: number
cache_creation_input_tokens?: number
}
}
}
export type ClaudeUsageParsedSourceTurn = ClaudeUsageParsedTurn & {
dedupeKey: string | null
}
export function stripClaudeSourceMetadata(
turn: ClaudeUsageParsedSourceTurn
): ClaudeUsageParsedTurn {
return {
sessionId: turn.sessionId,
timestamp: turn.timestamp,
model: turn.model,
cwd: turn.cwd,
gitBranch: turn.gitBranch,
inputTokens: turn.inputTokens,
outputTokens: turn.outputTokens,
cacheReadTokens: turn.cacheReadTokens,
cacheWriteTokens: turn.cacheWriteTokens
}
}
function dedupeClaudeUsageTurns(
turns: ClaudeUsageParsedSourceTurn[]
): ClaudeUsageParsedSourceTurn[] {
const dedupeIndexByKey = new Map<string, number>()
const deduped: ClaudeUsageParsedSourceTurn[] = []
for (const turn of turns) {
if (turn.dedupeKey) {
const existingIndex = dedupeIndexByKey.get(turn.dedupeKey)
if (existingIndex !== undefined) {
const existing = deduped[existingIndex]
// Why: Claude Code streams repeated assistant rows with the same
// message/request IDs; later rows can contain more complete usage.
existing.inputTokens = Math.max(existing.inputTokens, turn.inputTokens)
existing.outputTokens = Math.max(existing.outputTokens, turn.outputTokens)
existing.cacheReadTokens = Math.max(existing.cacheReadTokens, turn.cacheReadTokens)
existing.cacheWriteTokens = Math.max(existing.cacheWriteTokens, turn.cacheWriteTokens)
continue
}
}
deduped.push({ ...turn })
if (turn.dedupeKey) {
dedupeIndexByKey.set(turn.dedupeKey, deduped.length - 1)
}
}
return deduped
}
function parseClaudeUsageSourceRecord(
line: string,
fallbackSessionId: string | null = null
): ClaudeUsageParsedSourceTurn | null {
let parsed: ClaudeUsageSourceRecord
try {
parsed = JSON.parse(line) as ClaudeUsageSourceRecord
} catch {
return null
}
if (parsed.type !== 'assistant') {
return null
}
const sessionId = parsed.sessionId ?? fallbackSessionId
if (!sessionId || !parsed.timestamp) {
return null
}
const usage = parsed.message?.usage
const inputTokens = usage?.input_tokens ?? 0
const outputTokens = usage?.output_tokens ?? 0
const cacheReadTokens = usage?.cache_read_input_tokens ?? 0
const cacheWriteTokens = usage?.cache_creation_input_tokens ?? 0
if (inputTokens + outputTokens + cacheReadTokens + cacheWriteTokens <= 0) {
return null
}
return {
sessionId,
timestamp: parsed.timestamp,
model: parsed.message?.model ?? null,
cwd: parsed.cwd ?? null,
gitBranch: parsed.gitBranch ?? null,
// Why: forks rewrite sessionId but keep message/request ids (and usually
// uuid). Prefer the strongest stable identity available so ownership still
// works when requestId is missing on older or partial rows.
dedupeKey: buildClaudeUsageDedupeKey(parsed),
inputTokens,
outputTokens,
cacheReadTokens,
cacheWriteTokens
}
}
function buildClaudeUsageDedupeKey(parsed: ClaudeUsageSourceRecord): string | null {
const messageId = parsed.message?.id?.trim()
const requestId = parsed.requestId?.trim()
if (messageId && requestId) {
return `${messageId}:${requestId}`
}
if (messageId) {
return `msg:${messageId}`
}
const uuid = parsed.uuid?.trim()
if (uuid) {
return `uuid:${uuid}`
}
return null
}
export function parseClaudeUsageRecord(line: string): ClaudeUsageParsedTurn | null {
const parsed = parseClaudeUsageSourceRecord(line)
return parsed ? stripClaudeSourceMetadata(parsed) : null
}
export async function parseClaudeUsageFile(filePath: string): Promise<ClaudeUsageParsedTurn[]> {
const turns: ClaudeUsageParsedSourceTurn[] = []
const fallbackSessionId = basename(filePath, '.jsonl')
const lines = createInterface({
input: createReadStream(filePath, { encoding: 'utf-8' }),
crlfDelay: Infinity
})
for await (const line of lines) {
const parsed = parseClaudeUsageSourceRecord(line, fallbackSessionId)
if (parsed) {
turns.push(parsed)
}
}
return dedupeClaudeUsageTurns(turns).map(stripClaudeSourceMetadata)
}
export async function readClaudeUsageScanFile(filePath: string): Promise<{
processedFile: ClaudeUsageProcessedFile
turns: ClaudeUsageParsedSourceTurn[]
}> {
const fileStat = await stat(filePath)
let lineCount = 0
const turns: ClaudeUsageParsedSourceTurn[] = []
const fallbackSessionId = basename(filePath, '.jsonl')
const lines = createInterface({
input: createReadStream(filePath, { encoding: 'utf-8' }),
crlfDelay: Infinity
})
for await (const line of lines) {
lineCount++
const parsed = parseClaudeUsageSourceRecord(line, fallbackSessionId)
if (parsed) {
turns.push(parsed)
}
}
return {
processedFile: {
path: filePath,
mtimeMs: fileStat.mtimeMs,
size: fileStat.size,
lineCount
},
turns: dedupeClaudeUsageTurns(turns)
}
}
+205
View File
@@ -0,0 +1,205 @@
import type {
ClaudeUsageAttributedTurn,
ClaudeUsageDailyAggregate,
ClaudeUsageLocationBreakdown,
ClaudeUsageSession
} from './types'
export function mergeClaudeSessions(
target: Map<string, ClaudeUsageSession>,
sessions: ClaudeUsageSession[]
): void {
for (const session of sessions) {
const existing = target.get(session.sessionId)
if (!existing) {
target.set(session.sessionId, structuredClone(session))
continue
}
if (session.firstTimestamp < existing.firstTimestamp) {
existing.firstTimestamp = session.firstTimestamp
}
if (session.lastTimestamp > existing.lastTimestamp) {
existing.lastTimestamp = session.lastTimestamp
existing.lastCwd = session.lastCwd
existing.lastGitBranch = session.lastGitBranch
}
existing.model = session.model ?? existing.model
existing.turnCount += session.turnCount
existing.totalInputTokens += session.totalInputTokens
existing.totalOutputTokens += session.totalOutputTokens
existing.totalCacheReadTokens += session.totalCacheReadTokens
existing.totalCacheWriteTokens += session.totalCacheWriteTokens
for (const location of session.locationBreakdown) {
const existingLocation =
existing.locationBreakdown.find((entry) => entry.locationKey === location.locationKey) ??
null
if (existingLocation) {
existingLocation.turnCount += location.turnCount
existingLocation.inputTokens += location.inputTokens
existingLocation.outputTokens += location.outputTokens
existingLocation.cacheReadTokens += location.cacheReadTokens
existingLocation.cacheWriteTokens += location.cacheWriteTokens
} else {
existing.locationBreakdown.push({ ...location })
}
}
}
}
export function mergeClaudeDailyAggregates(
target: Map<string, ClaudeUsageDailyAggregate>,
dailyAggregates: ClaudeUsageDailyAggregate[]
): 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.turnCount += aggregate.turnCount
existing.zeroCacheReadTurnCount += aggregate.zeroCacheReadTurnCount
existing.inputTokens += aggregate.inputTokens
existing.outputTokens += aggregate.outputTokens
existing.cacheReadTokens += aggregate.cacheReadTokens
existing.cacheWriteTokens += aggregate.cacheWriteTokens
}
}
export function finalizeClaudeSessions(
sessionsById: Map<string, ClaudeUsageSession>
): ClaudeUsageSession[] {
for (const session of sessionsById.values()) {
session.locationBreakdown.sort((left, right) => {
const leftTotal = left.inputTokens + left.outputTokens
const rightTotal = right.inputTokens + right.outputTokens
return rightTotal - leftTotal
})
const primaryLocation = session.locationBreakdown[0] ?? null
if (primaryLocation) {
session.primaryRepoId = primaryLocation.repoId
session.primaryWorktreeId = primaryLocation.worktreeId
}
}
return [...sessionsById.values()].sort((left, right) =>
right.lastTimestamp.localeCompare(left.lastTimestamp)
)
}
export function aggregateClaudeUsage(turns: ClaudeUsageAttributedTurn[]): {
sessions: ClaudeUsageSession[]
dailyAggregates: ClaudeUsageDailyAggregate[]
} {
const sessionsById = new Map<string, ClaudeUsageSession>()
const dailyByKey = new Map<string, ClaudeUsageDailyAggregate>()
for (const turn of turns) {
const existingSession = sessionsById.get(turn.sessionId)
if (!existingSession) {
sessionsById.set(turn.sessionId, {
sessionId: turn.sessionId,
firstTimestamp: turn.timestamp,
lastTimestamp: turn.timestamp,
model: turn.model,
lastCwd: turn.cwd,
lastGitBranch: turn.gitBranch,
primaryWorktreeId: turn.worktreeId,
primaryRepoId: turn.repoId,
turnCount: 0,
totalInputTokens: 0,
totalOutputTokens: 0,
totalCacheReadTokens: 0,
totalCacheWriteTokens: 0,
locationBreakdown: []
})
}
const session = sessionsById.get(turn.sessionId)!
if (turn.timestamp < session.firstTimestamp) {
session.firstTimestamp = turn.timestamp
}
if (turn.timestamp > session.lastTimestamp) {
session.lastTimestamp = turn.timestamp
session.lastCwd = turn.cwd
session.lastGitBranch = turn.gitBranch
}
session.model = turn.model ?? session.model
session.turnCount++
session.totalInputTokens += turn.inputTokens
session.totalOutputTokens += turn.outputTokens
session.totalCacheReadTokens += turn.cacheReadTokens
session.totalCacheWriteTokens += turn.cacheWriteTokens
const location =
session.locationBreakdown.find((entry) => entry.locationKey === turn.projectKey) ?? null
if (location) {
location.turnCount++
location.inputTokens += turn.inputTokens
location.outputTokens += turn.outputTokens
location.cacheReadTokens += turn.cacheReadTokens
location.cacheWriteTokens += turn.cacheWriteTokens
} else {
session.locationBreakdown.push({
locationKey: turn.projectKey,
projectLabel: turn.projectLabel,
repoId: turn.repoId,
worktreeId: turn.worktreeId,
turnCount: 1,
inputTokens: turn.inputTokens,
outputTokens: turn.outputTokens,
cacheReadTokens: turn.cacheReadTokens,
cacheWriteTokens: turn.cacheWriteTokens
})
}
const dailyKey = [turn.day, turn.model ?? 'unknown', turn.projectKey].join('::')
const existingDaily = dailyByKey.get(dailyKey)
if (existingDaily) {
existingDaily.turnCount++
if (turn.cacheReadTokens === 0) {
existingDaily.zeroCacheReadTurnCount++
}
existingDaily.inputTokens += turn.inputTokens
existingDaily.outputTokens += turn.outputTokens
existingDaily.cacheReadTokens += turn.cacheReadTokens
existingDaily.cacheWriteTokens += turn.cacheWriteTokens
} else {
dailyByKey.set(dailyKey, {
day: turn.day,
model: turn.model,
projectKey: turn.projectKey,
projectLabel: turn.projectLabel,
repoId: turn.repoId,
worktreeId: turn.worktreeId,
turnCount: 1,
zeroCacheReadTurnCount: turn.cacheReadTokens === 0 ? 1 : 0,
inputTokens: turn.inputTokens,
outputTokens: turn.outputTokens,
cacheReadTokens: turn.cacheReadTokens,
cacheWriteTokens: turn.cacheWriteTokens
})
}
}
return {
sessions: finalizeClaudeSessions(sessionsById),
dailyAggregates: [...dailyByKey.values()].sort((left, right) =>
left.day === right.day
? left.projectLabel.localeCompare(right.projectLabel)
: left.day.localeCompare(right.day)
)
}
}
export function getSessionProjectLabel(locationBreakdown: ClaudeUsageLocationBreakdown[]): string {
if (locationBreakdown.length === 0) {
return 'Unknown location'
}
if (locationBreakdown.length === 1) {
return locationBreakdown[0].projectLabel
}
return 'Multiple locations'
}
@@ -0,0 +1,151 @@
import { realpath } from 'node:fs/promises'
import type { ClaudeUsageAttributedTurn, ClaudeUsageParsedTurn } from './types'
export type ClaudeUsageWorktreeRef = {
repoId: string
worktreeId: string
path: string
displayName: string
}
type ClaudeUsageWorktreeEntry = [string, ClaudeUsageWorktreeRef]
const sortedWorktreeEntriesByLookup = new WeakMap<
Map<string, ClaudeUsageWorktreeRef>,
ClaudeUsageWorktreeEntry[]
>()
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
}
async function canonicalizePath(pathValue: string): Promise<string> {
try {
const resolved = await realpath(pathValue)
return normalizeComparablePath(resolved)
} catch {
return normalizeComparablePath(pathValue)
}
}
function normalizeComparablePath(pathValue: string): string {
const normalized = pathValue.replace(/\\/g, '/')
return process.platform === 'win32' ? normalized.toLowerCase() : normalized
}
function isContainedPath(parentPath: string, childPath: string): boolean {
const parent = normalizeComparablePath(parentPath).replace(/\/+$/, '')
const child = normalizeComparablePath(childPath).replace(/\/+$/, '')
return child === parent || child.startsWith(`${parent}/`)
}
function findContainingWorktree(
cwd: string,
worktreeLookup: Map<string, ClaudeUsageWorktreeRef>
): ClaudeUsageWorktreeRef | null {
const normalizedCwd = normalizeComparablePath(cwd)
const exact = worktreeLookup.get(normalizedCwd)
if (exact) {
return exact
}
for (const [worktreePath, worktree] of getSortedWorktreeEntries(worktreeLookup)) {
if (isContainedPath(worktreePath, normalizedCwd)) {
return worktree
}
}
return null
}
function getSortedWorktreeEntries(
worktreeLookup: Map<string, ClaudeUsageWorktreeRef>
): ClaudeUsageWorktreeEntry[] {
const cached = sortedWorktreeEntriesByLookup.get(worktreeLookup)
if (cached) {
return cached
}
const sorted = [...worktreeLookup.entries()].sort(
([leftPath], [rightPath]) => rightPath.length - leftPath.length
)
sortedWorktreeEntriesByLookup.set(worktreeLookup, sorted)
return sorted
}
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}`
}
export async function buildWorktreeLookup(
worktrees: ClaudeUsageWorktreeRef[]
): Promise<Map<string, ClaudeUsageWorktreeRef>> {
const lookup = new Map<string, ClaudeUsageWorktreeRef>()
for (const worktree of worktrees) {
lookup.set(await canonicalizePath(worktree.path), worktree)
}
return lookup
}
export async function attributeClaudeUsageTurns(
turns: ClaudeUsageParsedTurn[],
worktreeLookup: Map<string, ClaudeUsageWorktreeRef>
): Promise<ClaudeUsageAttributedTurn[]> {
const attributed: ClaudeUsageAttributedTurn[] = []
const canonicalCwdByPath = new Map<string, string>()
for (const turn of turns) {
const day = localDayFromTimestamp(turn.timestamp)
if (!day) {
continue
}
let repoId: string | null = null
let worktreeId: string | null = null
let projectKey = 'unscoped'
let projectLabel = getDefaultProjectLabel(turn.cwd)
if (turn.cwd) {
let canonicalCwd = canonicalCwdByPath.get(turn.cwd)
if (canonicalCwd === undefined) {
// Why: Claude transcripts repeat the same cwd for many consecutive
// turns. Cache realpath work so attribution scales with unique paths.
canonicalCwd = await canonicalizePath(turn.cwd)
canonicalCwdByPath.set(turn.cwd, canonicalCwd)
}
const worktree = findContainingWorktree(canonicalCwd, worktreeLookup)
if (worktree) {
repoId = worktree.repoId
worktreeId = worktree.worktreeId
projectKey = `worktree:${worktreeId}`
projectLabel = worktree.displayName
} else {
projectKey = `cwd:${normalizeComparablePath(turn.cwd)}`
}
}
attributed.push({
...turn,
day,
projectKey,
projectLabel,
repoId,
worktreeId
})
}
return attributed
}
@@ -0,0 +1,181 @@
import type { AutomationRunUsage } from '../../shared/automations-types'
import type { CodexUsagePersistedState } from './types'
import { estimateCostUsd } from './codex-usage-cost-estimate'
const AUTOMATION_ATTRIBUTION_WINDOW_MS = 5 * 60_000
export type AutomationUsageLookupInput = {
worktreeId: string | null
terminalSessionId: string | null
startedAt: number | null
completedAt: number | null
}
type CodexAutomationAttributionDeps = {
/** Callback, not a snapshot: refresh mutates persisted state in place. */
getState: () => CodexUsagePersistedState
refresh: (force: boolean) => Promise<{ lastScanError: string | null }>
}
function shouldForceAutomationUsageScan(
scanState: CodexUsagePersistedState['scanState'],
completedAt: number
): boolean {
const { lastScanCompletedAt, lastScanError } = scanState
// Why: attribution needs a scan after the run finishes, but repeated
// lookups after that point should not rescan all Codex session history.
return Boolean(lastScanError) || lastScanCompletedAt === null || lastScanCompletedAt < completedAt
}
export async function resolveCodexAutomationRunUsage(
input: AutomationUsageLookupInput,
deps: CodexAutomationAttributionDeps
): Promise<AutomationRunUsage> {
const collectedAt = Date.now()
const unavailable = (
unavailableReason: AutomationRunUsage['unavailableReason'],
unavailableMessage: string
): AutomationRunUsage => ({
status: 'unavailable',
provider: 'codex',
model: null,
inputTokens: null,
outputTokens: null,
cacheReadTokens: null,
cacheWriteTokens: null,
reasoningOutputTokens: null,
totalTokens: null,
estimatedCostUsd: null,
estimatedCostSource: null,
providerSessionId: null,
attribution: null,
collectedAt,
unavailableReason,
unavailableMessage
})
if (!deps.getState().scanState.enabled) {
return unavailable('usage_not_enabled', 'Codex usage tracking is not enabled.')
}
if (!input.worktreeId || !input.startedAt || !input.completedAt) {
return unavailable('no_matching_session', 'Run session metadata is incomplete.')
}
const scanState = await deps.refresh(
shouldForceAutomationUsageScan(deps.getState().scanState, input.completedAt)
)
if (scanState.lastScanError) {
return unavailable('scan_failed', scanState.lastScanError)
}
const windowStart = input.startedAt - AUTOMATION_ATTRIBUTION_WINDOW_MS
const windowEnd = input.completedAt + AUTOMATION_ATTRIBUTION_WINDOW_MS
const candidates = deps.getState().sessions.filter((session) => {
const first = new Date(session.firstTimestamp).getTime()
const last = new Date(session.lastTimestamp).getTime()
if (!Number.isFinite(first) || !Number.isFinite(last)) {
return false
}
if (session.sessionId === input.terminalSessionId) {
return true
}
if (first < windowStart || first > windowEnd || last > windowEnd) {
return false
}
return session.locationBreakdown.some((entry) => entry.worktreeId === input.worktreeId)
})
if (candidates.length === 0) {
return unavailable('no_matching_session', 'No Codex usage session matched this run.')
}
if (candidates.length > 1) {
return unavailable(
'ambiguous_session',
'Multiple Codex usage sessions matched this run window.'
)
}
const session = candidates[0]
const scopedLocations = session.locationBreakdown.filter(
(entry) => entry.worktreeId === input.worktreeId
)
const locations = scopedLocations.length > 0 ? scopedLocations : session.locationBreakdown
const totals = locations.reduce(
(acc, entry) => {
acc.events += entry.eventCount
acc.inputTokens += entry.inputTokens
acc.cachedInputTokens += entry.cachedInputTokens
acc.outputTokens += entry.outputTokens
acc.reasoningOutputTokens += entry.reasoningOutputTokens
acc.totalTokens += entry.totalTokens
return acc
},
{
events: 0,
inputTokens: 0,
cachedInputTokens: 0,
outputTokens: 0,
reasoningOutputTokens: 0,
totalTokens: 0
}
)
const scopedModelRows = session.locationModelBreakdown.filter(
(entry) => entry.worktreeId === input.worktreeId
)
const modelRows = scopedModelRows.length > 0 ? scopedModelRows : session.modelBreakdown
const modelLabels = [...new Set(modelRows.map((entry) => entry.modelLabel))]
let estimatedCostUsd = 0
let hasKnownCost = false
if (scopedModelRows.length > 0) {
for (const modelRow of scopedModelRows) {
const cost = estimateCostUsd(
modelRow.modelKey,
modelRow.inputTokens,
modelRow.cachedInputTokens,
modelRow.outputTokens
)
if (cost !== null) {
hasKnownCost = true
estimatedCostUsd += cost
}
}
} else if (!session.hasMixedModels) {
const cost = estimateCostUsd(
session.primaryModel,
totals.inputTokens,
totals.cachedInputTokens,
totals.outputTokens
)
if (cost !== null) {
hasKnownCost = true
estimatedCostUsd += cost
}
}
return {
status: 'known',
provider: 'codex',
model:
modelLabels.length === 1
? modelLabels[0]
: session.hasMixedModels
? 'Mixed models'
: session.primaryModel,
inputTokens: totals.inputTokens,
outputTokens: totals.outputTokens,
cacheReadTokens: totals.cachedInputTokens,
cacheWriteTokens: null,
reasoningOutputTokens: totals.reasoningOutputTokens,
totalTokens: totals.totalTokens,
estimatedCostUsd: hasKnownCost ? estimatedCostUsd : null,
estimatedCostSource: hasKnownCost ? 'api_equivalent' : null,
providerSessionId: session.sessionId,
// Why: Orca terminal tab ids and Codex usage session ids are different
// systems today, so attribution is intentionally limited to one local
// provider session in the run's worktree/time window.
attribution: 'provider_session_time_window',
collectedAt,
unavailableReason: null,
unavailableMessage: null
}
}
+181
View File
@@ -0,0 +1,181 @@
export type TieredPrice = { threshold: number; price: number }
export type CodexModelPricing = {
input: number
cachedInput: number
output: number
inputTiers?: TieredPrice[]
cachedInputTiers?: TieredPrice[]
outputTiers?: TieredPrice[]
}
const LONG_CONTEXT_THRESHOLD_TOKENS = 272_000
export const MODEL_PRICING: Record<string, CodexModelPricing> = {
'gpt-5': { input: 1.25, cachedInput: 0.125, output: 10 },
'gpt-5.1': { input: 1.25, cachedInput: 0.125, output: 10 },
'gpt-5.1-codex': { input: 1.25, cachedInput: 0.125, output: 10 },
'gpt-5.1-codex-max': { input: 1.25, cachedInput: 0.125, output: 10 },
'gpt-5.2': { input: 1.75, cachedInput: 0.175, output: 14 },
'gpt-5.2-codex': { input: 1.75, cachedInput: 0.175, output: 14 },
'gpt-5.3': { input: 1.75, cachedInput: 0.175, output: 14 },
'gpt-5.3-codex': { input: 1.75, cachedInput: 0.175, output: 14 },
'gpt-5.3-codex-spark': { input: 1.75, cachedInput: 0.175, output: 14 },
'gpt-5.4-mini': { input: 0.75, cachedInput: 0.075, output: 4.5 },
'gpt-5.4-nano': { input: 0.2, cachedInput: 0.02, output: 1.25 },
'gpt-5.4-pro': {
input: 30,
cachedInput: 30,
output: 180,
inputTiers: [{ threshold: LONG_CONTEXT_THRESHOLD_TOKENS, price: 60 }],
cachedInputTiers: [{ threshold: LONG_CONTEXT_THRESHOLD_TOKENS, price: 60 }],
outputTiers: [{ threshold: LONG_CONTEXT_THRESHOLD_TOKENS, price: 270 }]
},
'gpt-5.4': {
input: 2.5,
cachedInput: 0.25,
output: 15,
inputTiers: [{ threshold: LONG_CONTEXT_THRESHOLD_TOKENS, price: 5 }],
cachedInputTiers: [{ threshold: LONG_CONTEXT_THRESHOLD_TOKENS, price: 0.5 }],
outputTiers: [{ threshold: LONG_CONTEXT_THRESHOLD_TOKENS, price: 22.5 }]
},
'gpt-5.5-pro': {
input: 30,
cachedInput: 30,
output: 180,
inputTiers: [{ threshold: LONG_CONTEXT_THRESHOLD_TOKENS, price: 60 }],
cachedInputTiers: [{ threshold: LONG_CONTEXT_THRESHOLD_TOKENS, price: 60 }],
outputTiers: [{ threshold: LONG_CONTEXT_THRESHOLD_TOKENS, price: 270 }]
},
'gpt-5.5': {
input: 5,
cachedInput: 0.5,
output: 30,
inputTiers: [{ threshold: LONG_CONTEXT_THRESHOLD_TOKENS, price: 10 }],
cachedInputTiers: [{ threshold: LONG_CONTEXT_THRESHOLD_TOKENS, price: 1 }],
outputTiers: [{ threshold: LONG_CONTEXT_THRESHOLD_TOKENS, price: 45 }]
},
'gpt-5.6-sol': {
input: 5,
cachedInput: 0.5,
output: 30,
inputTiers: [{ threshold: LONG_CONTEXT_THRESHOLD_TOKENS, price: 10 }],
cachedInputTiers: [{ threshold: LONG_CONTEXT_THRESHOLD_TOKENS, price: 1 }],
outputTiers: [{ threshold: LONG_CONTEXT_THRESHOLD_TOKENS, price: 45 }]
},
'gpt-5.6-terra': {
input: 2.5,
cachedInput: 0.25,
output: 15,
inputTiers: [{ threshold: LONG_CONTEXT_THRESHOLD_TOKENS, price: 5 }],
cachedInputTiers: [{ threshold: LONG_CONTEXT_THRESHOLD_TOKENS, price: 0.5 }],
outputTiers: [{ threshold: LONG_CONTEXT_THRESHOLD_TOKENS, price: 22.5 }]
},
'gpt-5.6-luna': {
input: 1,
cachedInput: 0.1,
output: 6,
inputTiers: [{ threshold: LONG_CONTEXT_THRESHOLD_TOKENS, price: 2 }],
cachedInputTiers: [{ threshold: LONG_CONTEXT_THRESHOLD_TOKENS, price: 0.2 }],
outputTiers: [{ threshold: LONG_CONTEXT_THRESHOLD_TOKENS, price: 9 }]
}
}
const REASONING_TIER_SUFFIXES = ['minimal', 'low', 'medium', 'high', 'xhigh', 'auto', 'none']
function stripParenthesizedReasoningTier(model: string): string | null {
const match = model.match(/^(.*)\(([^()]*)\)$/)
if (!match) {
return model
}
const tier = match[2].trim().toLowerCase()
if (!REASONING_TIER_SUFFIXES.includes(tier)) {
return null
}
return match[1]
}
function stripDashReasoningTiers(model: string): string {
let current = model
for (let index = 0; index < 4; index++) {
const suffix = REASONING_TIER_SUFFIXES.find((tier) => current.endsWith(`-${tier}`))
if (!suffix) {
return current
}
current = current.slice(0, -suffix.length - 1)
}
return current
}
export function normalizeModelForPricing(model: string | null): string | null {
if (!model) {
return null
}
const lower = stripParenthesizedReasoningTier(model.toLowerCase().trim())
if (!lower) {
return null
}
const normalized = stripDashReasoningTiers(lower)
if (normalized === 'gpt-5' || normalized === 'gpt-5-codex') {
return 'gpt-5'
}
if (normalized === 'gpt-5.1-codex-max' || normalized.startsWith('gpt-5.1-codex-max-')) {
return 'gpt-5.1-codex-max'
}
if (normalized === 'gpt-5.1-codex' || normalized.startsWith('gpt-5.1-codex-')) {
return 'gpt-5.1-codex'
}
if (normalized === 'gpt-5.1' || normalized.startsWith('gpt-5.1-')) {
return 'gpt-5.1'
}
if (normalized === 'gpt-5.2-codex' || normalized.startsWith('gpt-5.2-codex-')) {
return 'gpt-5.2-codex'
}
if (normalized === 'gpt-5.2' || normalized.startsWith('gpt-5.2-')) {
return 'gpt-5.2'
}
if (normalized === 'gpt-5.3-codex-spark' || normalized.startsWith('gpt-5.3-codex-spark-')) {
return 'gpt-5.3-codex-spark'
}
if (normalized === 'gpt-5.3-codex' || normalized.startsWith('gpt-5.3-codex-')) {
return 'gpt-5.3-codex'
}
if (normalized === 'gpt-5.3' || normalized.startsWith('gpt-5.3-')) {
return 'gpt-5.3'
}
if (normalized === 'gpt-5.4-mini' || normalized.startsWith('gpt-5.4-mini-')) {
return 'gpt-5.4-mini'
}
if (normalized === 'gpt-5.4-nano' || normalized.startsWith('gpt-5.4-nano-')) {
return 'gpt-5.4-nano'
}
if (normalized === 'gpt-5.4-pro' || normalized.startsWith('gpt-5.4-pro-')) {
return 'gpt-5.4-pro'
}
if (normalized === 'gpt-5.4' || normalized.startsWith('gpt-5.4-')) {
return 'gpt-5.4'
}
if (normalized === 'gpt-5.5-pro' || normalized.startsWith('gpt-5.5-pro-')) {
return 'gpt-5.5-pro'
}
if (normalized === 'gpt-5.5' || normalized.startsWith('gpt-5.5-')) {
return 'gpt-5.5'
}
if (normalized === 'gpt-5.6-sol' || normalized.startsWith('gpt-5.6-sol-')) {
return 'gpt-5.6-sol'
}
if (normalized === 'gpt-5.6-terra' || normalized.startsWith('gpt-5.6-terra-')) {
return 'gpt-5.6-terra'
}
if (normalized === 'gpt-5.6-luna' || normalized.startsWith('gpt-5.6-luna-')) {
return 'gpt-5.6-luna'
}
// Why: OpenAI routes the bare `gpt-5.6` alias to Sol. Match it exactly — a
// `gpt-5.6-` prefix match would swallow the tier IDs above and any future
// cheaper variant.
if (normalized === 'gpt-5.6') {
return 'gpt-5.6-sol'
}
return null
}
@@ -0,0 +1,169 @@
import { join } from 'node:path'
import { existsSync } from 'node:fs'
import { realpath, readdir, stat } from 'node:fs/promises'
import { getOrcaManagedCodexHomePath, getSystemCodexHomePath } from '../codex/codex-home-paths'
import { getCodexAccountHomeSessionDirectories } from '../codex/codex-account-home-discovery'
import { getLegacyCopiedCodexSessionBridgeScanPreference } from '../codex/codex-session-bridge'
import { normalizeFsPath } from '../usage/usage-path-comparison'
const YIELD_EVERY_DISCOVERY_ENTRIES = 100
export async function canonicalizePath(pathValue: string): Promise<string> {
try {
const resolved = await realpath(pathValue)
return normalizeFsPath(resolved)
} catch {
return normalizeFsPath(pathValue)
}
}
export async function yieldToEventLoop(): Promise<void> {
await new Promise((resolve) => setImmediate(resolve))
}
async function walkJsonlFiles(
dirPath: string,
progress: { entriesVisited: number } = { entriesVisited: 0 }
): Promise<string[]> {
const entries = await readdir(dirPath, { withFileTypes: true })
const files: string[] = []
for (const entry of entries) {
progress.entriesVisited += 1
if (progress.entriesVisited % YIELD_EVERY_DISCOVERY_ENTRIES === 0) {
await yieldToEventLoop()
}
const fullPath = join(dirPath, entry.name)
if (entry.isDirectory()) {
appendDiscoveredFiles(files, await walkJsonlFiles(fullPath, progress))
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: sessions now live in three lanes — the shared runtime mirror, the real
// ~/.codex, and per-account self-contained homes; missing any lane silently
// undercounts usage for multi-account users.
return [
getCodexSessionsDirectory(),
join(getSystemCodexHomePath(), 'sessions'),
...getCodexAccountHomeSessionDirectories()
].filter((dirPath, index, allDirPaths) => allDirPaths.indexOf(dirPath) === index)
}
function hasLegacyCopiedSessionBridgeMarkers(): boolean {
return existsSync(join(getOrcaManagedCodexHomePath(), '.orca-session-copies'))
}
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, hasLegacyCopiedSessionBridgeMarkers())
}
async function dedupeCodexSessionFileAliases(
files: string[],
hasLegacyBridgeMarkers: boolean
): Promise<string[]> {
const excludedAliases = new Set<string>()
if (hasLegacyBridgeMarkers) {
for (const [index, filePath] of files.entries()) {
const legacyCopyBridge = getLegacyCopiedCodexSessionBridgeScanPreference(filePath)
if ((index + 1) % YIELD_EVERY_DISCOVERY_ENTRIES === 0) {
await yieldToEventLoop()
}
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 [index, filePath] of [...new Set(files)].sort().entries()) {
const aliasKey = await getCodexSessionFileAliasKey(filePath)
if (excludedAliases.has(aliasKey)) {
continue
}
if (seenAliases.has(aliasKey)) {
continue
}
seenAliases.add(aliasKey)
uniqueFiles.push(filePath)
if ((index + 1) % YIELD_EVERY_DISCOVERY_ENTRIES === 0) {
await yieldToEventLoop()
}
}
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)}`
}
export function getLegacySourceSkipBytesByPath(
files: string[],
hasLegacyBridgeMarkers = hasLegacyCopiedSessionBridgeMarkers()
): Map<string, number> {
const sourceSkipBytesByPath = new Map<string, number>()
if (!hasLegacyBridgeMarkers) {
return sourceSkipBytesByPath
}
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
}
@@ -0,0 +1,41 @@
import type { TieredPrice } from './codex-model-pricing'
import { MODEL_PRICING, normalizeModelForPricing } from './codex-model-pricing'
function calculateTieredCost(tokens: number, basePrice: number, tiers: TieredPrice[] = []): number {
let cost = 0
let lowerBound = 0
let activePrice = basePrice
for (const tier of tiers) {
if (tokens <= tier.threshold) {
return cost + Math.max(tokens - lowerBound, 0) * activePrice
}
cost += (tier.threshold - lowerBound) * activePrice
lowerBound = tier.threshold
activePrice = tier.price
}
return cost + Math.max(tokens - lowerBound, 0) * activePrice
}
export function estimateCostUsd(
model: string | null,
inputTokens: number,
cachedInputTokens: number,
outputTokens: number
): number | null {
const normalized = normalizeModelForPricing(model)
if (!normalized) {
return null
}
const pricing = MODEL_PRICING[normalized]
const clampedCached = Math.min(cachedInputTokens, inputTokens)
// Why: Codex cached tokens are part of the input bucket. Charge uncached
// input on (input-cached) so cached tokens are not billed once at full input
// price and again at cache-read price.
const nonCachedInputTokens = Math.max(inputTokens - clampedCached, 0)
return (
(calculateTieredCost(nonCachedInputTokens, pricing.input, pricing.inputTiers) +
calculateTieredCost(clampedCached, pricing.cachedInput, pricing.cachedInputTiers) +
calculateTieredCost(outputTokens, pricing.output, pricing.outputTiers)) /
1_000_000
)
}
@@ -0,0 +1,113 @@
import { win32, posix } from 'node:path'
import { areWorktreePathsEqual } from '../ipc/worktree-logic'
import {
looksLikeWindowsPath,
normalizeComparablePath,
normalizeFsPath
} from '../usage/usage-path-comparison'
import type { UsageScanWorktreeRef } from '../usage/usage-provider-contract'
import type { CodexUsageAttributedEvent, CodexUsageParsedEvent } from './types'
export type CodexUsageWorktreeRef = UsageScanWorktreeRef
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}`
}
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
}
}
@@ -0,0 +1,161 @@
import { extractString } from '../usage/usage-record-coercion'
import {
buildCodexUsageEventKey,
normalizeRawUsage,
resolveCodexUsageDelta,
type CodexUsageRawUsage
} from './codex-usage-token-delta'
import type { CodexUsageParsedEvent } from './types'
type CodexUsageRawRecord = {
timestamp?: string
type?: string
payload?: Record<string, unknown>
}
export type CodexUsageParseContext = {
sessionId: string
sessionCwd: string | null
currentCwd: string | null
currentModel: string | null
previousTotals: CodexUsageRawUsage | null
totalOnlyBaselinePending?: boolean
}
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
}
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,
eventKey: buildCodexUsageEventKey(parsed.timestamp, totalUsage, lastUsage),
cwd: context.currentCwd ?? context.sessionCwd,
model: resolvedModel,
hasInferredPricing,
inputTokens: delta.inputTokens,
cachedInputTokens: delta.cachedInputTokens,
outputTokens: delta.outputTokens,
reasoningOutputTokens: delta.reasoningOutputTokens,
totalTokens: delta.totalTokens
}
}
@@ -0,0 +1,183 @@
import type {
CodexUsageBreakdownKind,
CodexUsageBreakdownRow,
CodexUsageDailyPoint,
CodexUsageRange,
CodexUsageScope,
CodexUsageSummary
} from '../../shared/codex-usage-types'
import type { CodexUsagePersistedState } from './types'
import { estimateCostUsd } from './codex-usage-cost-estimate'
import {
getFilteredDaily,
getFilteredSessions,
getScopedSessionModels
} from './codex-usage-scope-filters'
export function buildSummary(
state: CodexUsagePersistedState,
scope: CodexUsageScope,
range: CodexUsageRange
): CodexUsageSummary {
const filteredDaily = getFilteredDaily(state, scope, range)
const filteredSessions = getFilteredSessions(state, scope, range)
let inputTokens = 0
let cachedInputTokens = 0
let outputTokens = 0
let reasoningOutputTokens = 0
let totalTokens = 0
let events = 0
let estimatedCostUsd = 0
let hasAnyBillableCost = false
const byModel = new Map<string, number>()
const byProject = new Map<string, number>()
for (const row of filteredDaily) {
inputTokens += row.inputTokens
cachedInputTokens += row.cachedInputTokens
outputTokens += row.outputTokens
reasoningOutputTokens += row.reasoningOutputTokens
totalTokens += row.totalTokens
events += row.eventCount
byModel.set(
row.model ?? 'Unknown model',
(byModel.get(row.model ?? 'Unknown model') ?? 0) + row.totalTokens
)
byProject.set(row.projectLabel, (byProject.get(row.projectLabel) ?? 0) + row.totalTokens)
const cost = estimateCostUsd(
row.model,
row.inputTokens,
row.cachedInputTokens,
row.outputTokens
)
if (cost !== null) {
hasAnyBillableCost = true
estimatedCostUsd += cost
}
}
const topModel = [...byModel.entries()].sort((left, right) => right[1] - left[1])[0]?.[0] ?? null
const topProject =
[...byProject.entries()].sort((left, right) => right[1] - left[1])[0]?.[0] ?? null
return {
scope,
range,
sessions: filteredSessions.length,
events,
inputTokens,
cachedInputTokens,
outputTokens,
reasoningOutputTokens,
totalTokens,
estimatedCostUsd: hasAnyBillableCost ? estimatedCostUsd : null,
topModel,
topProject,
hasAnyCodexData: filteredSessions.length > 0 || filteredDaily.length > 0
}
}
export function buildDaily(
state: CodexUsagePersistedState,
scope: CodexUsageScope,
range: CodexUsageRange
): CodexUsageDailyPoint[] {
const byDay = new Map<string, CodexUsageDailyPoint>()
for (const row of getFilteredDaily(state, scope, range)) {
const existing = byDay.get(row.day) ?? {
day: row.day,
inputTokens: 0,
cachedInputTokens: 0,
outputTokens: 0,
reasoningOutputTokens: 0,
totalTokens: 0
}
existing.inputTokens += row.inputTokens
existing.cachedInputTokens += row.cachedInputTokens
existing.outputTokens += row.outputTokens
existing.reasoningOutputTokens += row.reasoningOutputTokens
existing.totalTokens += row.totalTokens
byDay.set(row.day, existing)
}
return [...byDay.values()].sort((left, right) => left.day.localeCompare(right.day))
}
export function buildBreakdown(
state: CodexUsagePersistedState,
scope: CodexUsageScope,
range: CodexUsageRange,
kind: CodexUsageBreakdownKind
): CodexUsageBreakdownRow[] {
const rows = new Map<string, CodexUsageBreakdownRow>()
const filteredDaily = getFilteredDaily(state, scope, range)
const filteredSessions = getFilteredSessions(state, scope, range)
for (const daily of filteredDaily) {
const key = kind === 'model' ? (daily.model ?? 'unknown') : daily.projectKey
const label = kind === 'model' ? (daily.model ?? 'Unknown model') : daily.projectLabel
const existing = rows.get(key) ?? {
key,
label,
sessions: 0,
events: 0,
inputTokens: 0,
cachedInputTokens: 0,
outputTokens: 0,
reasoningOutputTokens: 0,
totalTokens: 0,
estimatedCostUsd: null,
hasInferredPricing: false
}
existing.events += daily.eventCount
existing.inputTokens += daily.inputTokens
existing.cachedInputTokens += daily.cachedInputTokens
existing.outputTokens += daily.outputTokens
existing.reasoningOutputTokens += daily.reasoningOutputTokens
existing.totalTokens += daily.totalTokens
existing.hasInferredPricing ||= daily.hasInferredPricing
rows.set(key, existing)
}
for (const session of filteredSessions) {
if (kind === 'model') {
const seen = new Set<string>()
for (const model of getScopedSessionModels(session, scope)) {
if (seen.has(model.modelKey)) {
continue
}
seen.add(model.modelKey)
const row = rows.get(model.modelKey)
if (row) {
row.sessions++
}
}
continue
}
const matchingLocations = session.locationBreakdown.filter((entry) =>
scope === 'all' ? true : entry.worktreeId !== null
)
const seen = new Set<string>()
for (const location of matchingLocations) {
if (seen.has(location.locationKey)) {
continue
}
seen.add(location.locationKey)
const row = rows.get(location.locationKey)
if (row) {
row.sessions++
}
}
}
for (const row of rows.values()) {
row.estimatedCostUsd = estimateCostUsd(
kind === 'model' ? row.key : null,
row.inputTokens,
row.cachedInputTokens,
row.outputTokens
)
}
return [...rows.values()].sort((left, right) => right.totalTokens - left.totalTokens)
}
@@ -0,0 +1,103 @@
import type { CodexUsageRange, CodexUsageScope } from '../../shared/codex-usage-types'
import type { CodexUsagePersistedState } from './types'
import { getLocalUsageDay, getUsageRangeCutoff } from '../usage/usage-calendar-range'
export type ScopedCodexUsageModelRow = {
modelKey: string
modelLabel: string
hasInferredPricing: boolean
eventCount: number
inputTokens: number
cachedInputTokens: number
outputTokens: number
reasoningOutputTokens: number
totalTokens: number
}
export function getFilteredDaily(
state: CodexUsagePersistedState,
scope: CodexUsageScope,
range: CodexUsageRange
) {
const cutoff = getUsageRangeCutoff(range)
return state.dailyAggregates.filter((entry) => {
if (cutoff && entry.day < cutoff) {
return false
}
if (scope === 'orca' && entry.worktreeId === null) {
return false
}
return true
})
}
export function getFilteredSessions(
state: CodexUsagePersistedState,
scope: CodexUsageScope,
range: CodexUsageRange
) {
const cutoff = getUsageRangeCutoff(range)
return state.sessions.filter((session) => {
const day = getLocalUsageDay(session.lastTimestamp)
if (!day) {
return false
}
if (cutoff && day < cutoff) {
return false
}
if (scope === 'orca') {
return session.locationBreakdown.some((entry) => entry.worktreeId !== null)
}
return true
})
}
export function getScopedSessionModels(
session: CodexUsagePersistedState['sessions'][number],
scope: CodexUsageScope
): ScopedCodexUsageModelRow[] {
if (scope === 'all' || session.locationModelBreakdown.length === 0) {
return session.modelBreakdown
}
const rows = new Map<string, ScopedCodexUsageModelRow>()
for (const entry of session.locationModelBreakdown) {
if (entry.worktreeId === null) {
continue
}
const existing = rows.get(entry.modelKey) ?? {
modelKey: entry.modelKey,
modelLabel: entry.modelLabel,
hasInferredPricing: false,
eventCount: 0,
inputTokens: 0,
cachedInputTokens: 0,
outputTokens: 0,
reasoningOutputTokens: 0,
totalTokens: 0
}
existing.hasInferredPricing ||= entry.hasInferredPricing
existing.eventCount += entry.eventCount
existing.inputTokens += entry.inputTokens
existing.cachedInputTokens += entry.cachedInputTokens
existing.outputTokens += entry.outputTokens
existing.reasoningOutputTokens += entry.reasoningOutputTokens
existing.totalTokens += entry.totalTokens
rows.set(entry.modelKey, existing)
}
return [...rows.values()].sort((left, right) => right.totalTokens - left.totalTokens)
}
export function getScopedSessionPrimaryModel(
session: CodexUsagePersistedState['sessions'][number],
scope: CodexUsageScope
): string | null {
const scopedModels = getScopedSessionModels(session, scope)
if (scopedModels.length === 0) {
return session.primaryModel
}
if (scopedModels.length === 1) {
return scopedModels[0]?.modelLabel ?? null
}
return 'Mixed models'
}
@@ -0,0 +1,69 @@
import type {
CodexUsageRange,
CodexUsageScope,
CodexUsageSessionRow
} from '../../shared/codex-usage-types'
import type { CodexUsagePersistedState } from './types'
import { getFilteredSessions, getScopedSessionPrimaryModel } from './codex-usage-scope-filters'
export function buildRecentSessions(
state: CodexUsagePersistedState,
scope: CodexUsageScope,
range: CodexUsageRange,
limit = 12
): CodexUsageSessionRow[] {
return getFilteredSessions(state, scope, range)
.slice(0, limit)
.map((session) => {
const matchingLocations = session.locationBreakdown.filter((entry) =>
scope === 'all' ? true : entry.worktreeId !== null
)
const scopedLocations =
matchingLocations.length > 0 ? matchingLocations : session.locationBreakdown
const totals = scopedLocations.reduce(
(acc, entry) => {
acc.events += entry.eventCount
acc.inputTokens += entry.inputTokens
acc.cachedInputTokens += entry.cachedInputTokens
acc.outputTokens += entry.outputTokens
acc.reasoningOutputTokens += entry.reasoningOutputTokens
acc.totalTokens += entry.totalTokens
acc.hasInferredPricing ||= entry.hasInferredPricing
return acc
},
{
events: 0,
inputTokens: 0,
cachedInputTokens: 0,
outputTokens: 0,
reasoningOutputTokens: 0,
totalTokens: 0,
hasInferredPricing: false
}
)
const durationMinutes = Math.max(
0,
Math.round(
(new Date(session.lastTimestamp).getTime() - new Date(session.firstTimestamp).getTime()) /
60_000
)
)
return {
sessionId: session.sessionId,
lastActiveAt: session.lastTimestamp,
durationMinutes,
projectLabel:
scopedLocations.length > 1
? 'Multiple locations'
: (scopedLocations[0]?.projectLabel ?? session.primaryProjectLabel),
model: getScopedSessionPrimaryModel(session, scope),
events: totals.events,
inputTokens: totals.inputTokens,
cachedInputTokens: totals.cachedInputTokens,
outputTokens: totals.outputTokens,
reasoningOutputTokens: totals.reasoningOutputTokens,
totalTokens: totals.totalTokens,
hasInferredPricing: session.hasInferredPricing || totals.hasInferredPricing
}
})
}
@@ -0,0 +1,178 @@
import { ensureNumber } from '../usage/usage-record-coercion'
export type CodexUsageRawUsage = {
inputTokens: number
cachedInputTokens: number
outputTokens: number
reasoningOutputTokens: number
totalTokens: number
}
type CodexUsageDeltaResolution =
| { kind: 'event'; delta: CodexUsageRawUsage; nextTotals: CodexUsageRawUsage | null }
| { kind: 'baseline'; nextTotals: CodexUsageRawUsage }
export 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
}
export 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
}
export function buildCodexUsageEventKey(
timestamp: string,
totalUsage: CodexUsageRawUsage | null,
lastUsage: CodexUsageRawUsage | null
): string {
// Why: fork/resume copies token_count records byte-for-byte into a new
// rollout file, but session_meta.id is often rewritten to the new session.
// Key only on the raw record fields (timestamp + usage tuples) so the copy
// matches the original regardless of surrounding parse context / session id.
const tupleOf = (usage: CodexUsageRawUsage | null): string =>
usage
? [
usage.inputTokens,
usage.cachedInputTokens,
usage.outputTokens,
usage.reasoningOutputTokens,
usage.totalTokens
].join(',')
: ''
return [timestamp, tupleOf(totalUsage), tupleOf(lastUsage)].join('|')
}
@@ -78,7 +78,7 @@ describe('listCodexSessionFiles large directories', () => {
} as Stats
})
const { listCodexSessionFiles } = await import('./scanner')
const { listCodexSessionFiles } = await import('./codex-session-file-discovery')
await expect(listCodexSessionFiles()).resolves.toHaveLength(FILE_COUNT)
expect(getLegacyCopiedCodexSessionBridgeScanPreferenceMock).not.toHaveBeenCalled()
+3 -3
View File
@@ -35,9 +35,9 @@ vi.mock('node:os', async () => {
import {
getCodexSessionDirectories,
getCodexSessionsDirectory,
listCodexSessionFiles,
scanCodexUsageFiles
} from './scanner'
listCodexSessionFiles
} from './codex-session-file-discovery'
import { scanCodexUsageFiles } from './scanner'
const originalCodexHome = process.env.CODEX_HOME
let fakeHomeDir: string
+2 -1
View File
@@ -10,7 +10,8 @@ vi.mock('electron', () => ({
}
}))
import { attributeCodexUsageEvent, parseCodexUsageRecord } from './scanner'
import { attributeCodexUsageEvent } from './codex-usage-event-attribution'
import { parseCodexUsageRecord } from './codex-usage-record-parser'
describe('parseCodexUsageRecord', () => {
it('uses token totals only as a duplicate baseline', () => {
+13 -610
View File
@@ -1,221 +1,29 @@
/* 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 'node:path'
import { createReadStream, existsSync } from 'node:fs'
import { realpath, readdir, stat } from 'node:fs/promises'
import { basename } from 'node:path'
import { createReadStream } from 'node:fs'
import { stat } from 'node:fs/promises'
import { createInterface } from 'node:readline'
import { areWorktreePathsEqual } from '../ipc/worktree-logic'
import { getOrcaManagedCodexHomePath, getSystemCodexHomePath } from '../codex/codex-home-paths'
import { getCodexAccountHomeSessionDirectories } from '../codex/codex-account-home-discovery'
import { getLegacyCopiedCodexSessionBridgeScanPreference } from '../codex/codex-session-bridge'
import { canonicalizeUsageWorktreePaths } from '../usage-worktree-canonicalizer'
import { createUsageEventAggregation } from '../usage/usage-event-aggregation'
import {
looksLikeWindowsPath,
normalizeComparablePath,
normalizeFsPath
} from '../usage/usage-path-comparison'
import { ensureNumber, extractString } from '../usage/usage-record-coercion'
import type { UsageScanWorktreeRef } from '../usage/usage-provider-contract'
canonicalizePath,
getLegacySourceSkipBytesByPath,
listCodexSessionFiles,
yieldToEventLoop
} from './codex-session-file-discovery'
import {
attributeCodexUsageEvent,
type CodexUsageWorktreeRef
} from './codex-usage-event-attribution'
import { parseCodexUsageRecord, type CodexUsageParseContext } from './codex-usage-record-parser'
import type {
CodexUsageAttributedEvent,
CodexUsageDailyAggregate,
CodexUsageParsedEvent,
CodexUsagePersistedFile,
CodexUsageProcessedFile,
CodexUsageSession
} from './types'
export type CodexUsageWorktreeRef = UsageScanWorktreeRef
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
const YIELD_EVERY_DISCOVERY_ENTRIES = 100
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) => setImmediate(resolve))
}
async function walkJsonlFiles(
dirPath: string,
progress: { entriesVisited: number } = { entriesVisited: 0 }
): Promise<string[]> {
const entries = await readdir(dirPath, { withFileTypes: true })
const files: string[] = []
for (const entry of entries) {
progress.entriesVisited += 1
if (progress.entriesVisited % YIELD_EVERY_DISCOVERY_ENTRIES === 0) {
await yieldToEventLoop()
}
const fullPath = join(dirPath, entry.name)
if (entry.isDirectory()) {
appendDiscoveredFiles(files, await walkJsonlFiles(fullPath, progress))
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: sessions now live in three lanes — the shared runtime mirror, the real
// ~/.codex, and per-account self-contained homes; missing any lane silently
// undercounts usage for multi-account users.
return [
getCodexSessionsDirectory(),
join(getSystemCodexHomePath(), 'sessions'),
...getCodexAccountHomeSessionDirectories()
].filter((dirPath, index, allDirPaths) => allDirPaths.indexOf(dirPath) === index)
}
function hasLegacyCopiedSessionBridgeMarkers(): boolean {
return existsSync(join(getOrcaManagedCodexHomePath(), '.orca-session-copies'))
}
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, hasLegacyCopiedSessionBridgeMarkers())
}
async function dedupeCodexSessionFileAliases(
files: string[],
hasLegacyBridgeMarkers: boolean
): Promise<string[]> {
const excludedAliases = new Set<string>()
if (hasLegacyBridgeMarkers) {
for (const [index, filePath] of files.entries()) {
const legacyCopyBridge = getLegacyCopiedCodexSessionBridgeScanPreference(filePath)
if ((index + 1) % YIELD_EVERY_DISCOVERY_ENTRIES === 0) {
await yieldToEventLoop()
}
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 [index, filePath] of [...new Set(files)].sort().entries()) {
const aliasKey = await getCodexSessionFileAliasKey(filePath)
if (excludedAliases.has(aliasKey)) {
continue
}
if (seenAliases.has(aliasKey)) {
continue
}
seenAliases.add(aliasKey)
uniqueFiles.push(filePath)
if ((index + 1) % YIELD_EVERY_DISCOVERY_ENTRIES === 0) {
await yieldToEventLoop()
}
}
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[],
hasLegacyBridgeMarkers = hasLegacyCopiedSessionBridgeMarkers()
): Map<string, number> {
const sourceSkipBytesByPath = new Map<string, number>()
if (!hasLegacyBridgeMarkers) {
return sourceSkipBytesByPath
}
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)
@@ -226,317 +34,12 @@ export async function getProcessedFileInfo(filePath: string): Promise<CodexUsage
}
}
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 buildCodexUsageEventKey(
timestamp: string,
totalUsage: CodexUsageRawUsage | null,
lastUsage: CodexUsageRawUsage | null
): string {
// Why: fork/resume copies token_count records byte-for-byte into a new
// rollout file, but session_meta.id is often rewritten to the new session.
// Key only on the raw record fields (timestamp + usage tuples) so the copy
// matches the original regardless of surrounding parse context / session id.
const tupleOf = (usage: CodexUsageRawUsage | null): string =>
usage
? [
usage.inputTokens,
usage.cachedInputTokens,
usage.outputTokens,
usage.reasoningOutputTokens,
usage.totalTokens
].join(',')
: ''
return [timestamp, tupleOf(totalUsage), tupleOf(lastUsage)].join('|')
}
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
}
}
type CodexUsageMetric = { hasInferredPricing: boolean }
const codexUsageAggregation = createUsageEventAggregation<
@@ -561,106 +64,6 @@ const codexUsageAggregation = createUsageEventAggregation<
const { finalizeSessions, mergeSessions, mergeDailyAggregates, sortDailyAggregates } =
codexUsageAggregation
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,
eventKey: buildCodexUsageEventKey(parsed.timestamp, totalUsage, lastUsage),
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 })[],
+16 -707
View File
@@ -1,4 +1,3 @@
/* eslint-disable max-lines -- Why: Codex pricing, range, scope, breakdown, and automation-attribution policies remain one cohesive store. */
import { app } from 'electron'
import { join } from 'node:path'
import type {
@@ -14,106 +13,17 @@ import type {
import type { AutomationRunUsage } from '../../shared/automations-types'
import type { Store } from '../persistence'
import type { CodexUsagePersistedState } from './types'
import type { AutomationUsageLookupInput } from './codex-automation-run-attribution'
import { CODEX_USAGE_SCHEMA_VERSION, codexUsageProvider } from './codex-usage-provider'
import { getLocalUsageDay, getUsageRangeCutoff } from '../usage/usage-calendar-range'
import { resolveCodexAutomationRunUsage } from './codex-automation-run-attribution'
import { buildRecentSessions } from './codex-usage-session-rows'
import { buildBreakdown, buildDaily, buildSummary } from './codex-usage-rollup-projections'
import { UsageProviderStoreLifecycle } from '../usage/usage-provider-store-lifecycle'
const SCHEMA_VERSION = CODEX_USAGE_SCHEMA_VERSION
const AUTOMATION_ATTRIBUTION_WINDOW_MS = 5 * 60_000
let _codexUsageFile: string | null = null
type TieredPrice = { threshold: number; price: number }
type CodexModelPricing = {
input: number
cachedInput: number
output: number
inputTiers?: TieredPrice[]
cachedInputTiers?: TieredPrice[]
outputTiers?: TieredPrice[]
}
type AutomationUsageLookupInput = {
worktreeId: string | null
terminalSessionId: string | null
startedAt: number | null
completedAt: number | null
}
const LONG_CONTEXT_THRESHOLD_TOKENS = 272_000
const MODEL_PRICING: Record<string, CodexModelPricing> = {
'gpt-5': { input: 1.25, cachedInput: 0.125, output: 10 },
'gpt-5.1': { input: 1.25, cachedInput: 0.125, output: 10 },
'gpt-5.1-codex': { input: 1.25, cachedInput: 0.125, output: 10 },
'gpt-5.1-codex-max': { input: 1.25, cachedInput: 0.125, output: 10 },
'gpt-5.2': { input: 1.75, cachedInput: 0.175, output: 14 },
'gpt-5.2-codex': { input: 1.75, cachedInput: 0.175, output: 14 },
'gpt-5.3': { input: 1.75, cachedInput: 0.175, output: 14 },
'gpt-5.3-codex': { input: 1.75, cachedInput: 0.175, output: 14 },
'gpt-5.3-codex-spark': { input: 1.75, cachedInput: 0.175, output: 14 },
'gpt-5.4-mini': { input: 0.75, cachedInput: 0.075, output: 4.5 },
'gpt-5.4-nano': { input: 0.2, cachedInput: 0.02, output: 1.25 },
'gpt-5.4-pro': {
input: 30,
cachedInput: 30,
output: 180,
inputTiers: [{ threshold: LONG_CONTEXT_THRESHOLD_TOKENS, price: 60 }],
cachedInputTiers: [{ threshold: LONG_CONTEXT_THRESHOLD_TOKENS, price: 60 }],
outputTiers: [{ threshold: LONG_CONTEXT_THRESHOLD_TOKENS, price: 270 }]
},
'gpt-5.4': {
input: 2.5,
cachedInput: 0.25,
output: 15,
inputTiers: [{ threshold: LONG_CONTEXT_THRESHOLD_TOKENS, price: 5 }],
cachedInputTiers: [{ threshold: LONG_CONTEXT_THRESHOLD_TOKENS, price: 0.5 }],
outputTiers: [{ threshold: LONG_CONTEXT_THRESHOLD_TOKENS, price: 22.5 }]
},
'gpt-5.5-pro': {
input: 30,
cachedInput: 30,
output: 180,
inputTiers: [{ threshold: LONG_CONTEXT_THRESHOLD_TOKENS, price: 60 }],
cachedInputTiers: [{ threshold: LONG_CONTEXT_THRESHOLD_TOKENS, price: 60 }],
outputTiers: [{ threshold: LONG_CONTEXT_THRESHOLD_TOKENS, price: 270 }]
},
'gpt-5.5': {
input: 5,
cachedInput: 0.5,
output: 30,
inputTiers: [{ threshold: LONG_CONTEXT_THRESHOLD_TOKENS, price: 10 }],
cachedInputTiers: [{ threshold: LONG_CONTEXT_THRESHOLD_TOKENS, price: 1 }],
outputTiers: [{ threshold: LONG_CONTEXT_THRESHOLD_TOKENS, price: 45 }]
},
'gpt-5.6-sol': {
input: 5,
cachedInput: 0.5,
output: 30,
inputTiers: [{ threshold: LONG_CONTEXT_THRESHOLD_TOKENS, price: 10 }],
cachedInputTiers: [{ threshold: LONG_CONTEXT_THRESHOLD_TOKENS, price: 1 }],
outputTiers: [{ threshold: LONG_CONTEXT_THRESHOLD_TOKENS, price: 45 }]
},
'gpt-5.6-terra': {
input: 2.5,
cachedInput: 0.25,
output: 15,
inputTiers: [{ threshold: LONG_CONTEXT_THRESHOLD_TOKENS, price: 5 }],
cachedInputTiers: [{ threshold: LONG_CONTEXT_THRESHOLD_TOKENS, price: 0.5 }],
outputTiers: [{ threshold: LONG_CONTEXT_THRESHOLD_TOKENS, price: 22.5 }]
},
'gpt-5.6-luna': {
input: 1,
cachedInput: 0.1,
output: 6,
inputTiers: [{ threshold: LONG_CONTEXT_THRESHOLD_TOKENS, price: 2 }],
cachedInputTiers: [{ threshold: LONG_CONTEXT_THRESHOLD_TOKENS, price: 0.2 }],
outputTiers: [{ threshold: LONG_CONTEXT_THRESHOLD_TOKENS, price: 9 }]
}
}
const REASONING_TIER_SUFFIXES = ['minimal', 'low', 'medium', 'high', 'xhigh', 'auto', 'none']
function getDefaultState(): CodexUsagePersistedState {
return {
schemaVersion: SCHEMA_VERSION,
@@ -167,155 +77,6 @@ function getCodexUsageFile(): string {
return _codexUsageFile
}
function stripParenthesizedReasoningTier(model: string): string | null {
const match = model.match(/^(.*)\(([^()]*)\)$/)
if (!match) {
return model
}
const tier = match[2].trim().toLowerCase()
if (!REASONING_TIER_SUFFIXES.includes(tier)) {
return null
}
return match[1]
}
function stripDashReasoningTiers(model: string): string {
let current = model
for (let index = 0; index < 4; index++) {
const suffix = REASONING_TIER_SUFFIXES.find((tier) => current.endsWith(`-${tier}`))
if (!suffix) {
return current
}
current = current.slice(0, -suffix.length - 1)
}
return current
}
function normalizeModelForPricing(model: string | null): string | null {
if (!model) {
return null
}
const lower = stripParenthesizedReasoningTier(model.toLowerCase().trim())
if (!lower) {
return null
}
const normalized = stripDashReasoningTiers(lower)
if (normalized === 'gpt-5' || normalized === 'gpt-5-codex') {
return 'gpt-5'
}
if (normalized === 'gpt-5.1-codex-max' || normalized.startsWith('gpt-5.1-codex-max-')) {
return 'gpt-5.1-codex-max'
}
if (normalized === 'gpt-5.1-codex' || normalized.startsWith('gpt-5.1-codex-')) {
return 'gpt-5.1-codex'
}
if (normalized === 'gpt-5.1' || normalized.startsWith('gpt-5.1-')) {
return 'gpt-5.1'
}
if (normalized === 'gpt-5.2-codex' || normalized.startsWith('gpt-5.2-codex-')) {
return 'gpt-5.2-codex'
}
if (normalized === 'gpt-5.2' || normalized.startsWith('gpt-5.2-')) {
return 'gpt-5.2'
}
if (normalized === 'gpt-5.3-codex-spark' || normalized.startsWith('gpt-5.3-codex-spark-')) {
return 'gpt-5.3-codex-spark'
}
if (normalized === 'gpt-5.3-codex' || normalized.startsWith('gpt-5.3-codex-')) {
return 'gpt-5.3-codex'
}
if (normalized === 'gpt-5.3' || normalized.startsWith('gpt-5.3-')) {
return 'gpt-5.3'
}
if (normalized === 'gpt-5.4-mini' || normalized.startsWith('gpt-5.4-mini-')) {
return 'gpt-5.4-mini'
}
if (normalized === 'gpt-5.4-nano' || normalized.startsWith('gpt-5.4-nano-')) {
return 'gpt-5.4-nano'
}
if (normalized === 'gpt-5.4-pro' || normalized.startsWith('gpt-5.4-pro-')) {
return 'gpt-5.4-pro'
}
if (normalized === 'gpt-5.4' || normalized.startsWith('gpt-5.4-')) {
return 'gpt-5.4'
}
if (normalized === 'gpt-5.5-pro' || normalized.startsWith('gpt-5.5-pro-')) {
return 'gpt-5.5-pro'
}
if (normalized === 'gpt-5.5' || normalized.startsWith('gpt-5.5-')) {
return 'gpt-5.5'
}
if (normalized === 'gpt-5.6-sol' || normalized.startsWith('gpt-5.6-sol-')) {
return 'gpt-5.6-sol'
}
if (normalized === 'gpt-5.6-terra' || normalized.startsWith('gpt-5.6-terra-')) {
return 'gpt-5.6-terra'
}
if (normalized === 'gpt-5.6-luna' || normalized.startsWith('gpt-5.6-luna-')) {
return 'gpt-5.6-luna'
}
// Why: OpenAI routes the bare `gpt-5.6` alias to Sol. Match it exactly — a
// `gpt-5.6-` prefix match would swallow the tier IDs above and any future
// cheaper variant.
if (normalized === 'gpt-5.6') {
return 'gpt-5.6-sol'
}
return null
}
function calculateTieredCost(tokens: number, basePrice: number, tiers: TieredPrice[] = []): number {
let cost = 0
let lowerBound = 0
let activePrice = basePrice
for (const tier of tiers) {
if (tokens <= tier.threshold) {
return cost + Math.max(tokens - lowerBound, 0) * activePrice
}
cost += (tier.threshold - lowerBound) * activePrice
lowerBound = tier.threshold
activePrice = tier.price
}
return cost + Math.max(tokens - lowerBound, 0) * activePrice
}
function estimateCostUsd(
model: string | null,
inputTokens: number,
cachedInputTokens: number,
outputTokens: number
): number | null {
const normalized = normalizeModelForPricing(model)
if (!normalized) {
return null
}
const pricing = MODEL_PRICING[normalized]
const clampedCached = Math.min(cachedInputTokens, inputTokens)
// Why: Codex cached tokens are part of the input bucket. Charge uncached
// input on (input-cached) so cached tokens are not billed once at full input
// price and again at cache-read price.
const nonCachedInputTokens = Math.max(inputTokens - clampedCached, 0)
return (
(calculateTieredCost(nonCachedInputTokens, pricing.input, pricing.inputTiers) +
calculateTieredCost(clampedCached, pricing.cachedInput, pricing.cachedInputTiers) +
calculateTieredCost(outputTokens, pricing.output, pricing.outputTiers)) /
1_000_000
)
}
type ScopedCodexUsageModelRow = {
modelKey: string
modelLabel: string
hasInferredPricing: boolean
eventCount: number
inputTokens: number
cachedInputTokens: number
outputTokens: number
reasoningOutputTokens: number
totalTokens: number
}
export class CodexUsageStore extends UsageProviderStoreLifecycle<
'processedFiles',
CodexUsagePersistedState,
@@ -340,104 +101,22 @@ export class CodexUsageStore extends UsageProviderStoreLifecycle<
): CodexUsageSnapshot {
return {
scanState: this.getScanState(),
summary: this.buildSummary(scope, range),
daily: this.buildDaily(scope, range),
modelBreakdown: this.buildBreakdown(scope, range, 'model'),
projectBreakdown: this.buildBreakdown(scope, range, 'project'),
recentSessions: this.buildRecentSessions(scope, range, recentSessionLimit)
summary: buildSummary(this.state, scope, range),
daily: buildDaily(this.state, scope, range),
modelBreakdown: buildBreakdown(this.state, scope, range, 'model'),
projectBreakdown: buildBreakdown(this.state, scope, range, 'project'),
recentSessions: buildRecentSessions(this.state, scope, range, recentSessionLimit)
}
}
async getSummary(scope: CodexUsageScope, range: CodexUsageRange): Promise<CodexUsageSummary> {
await this.refresh(false)
return this.buildSummary(scope, range)
}
private buildSummary(scope: CodexUsageScope, range: CodexUsageRange): CodexUsageSummary {
const filteredDaily = this.getFilteredDaily(scope, range)
const filteredSessions = this.getFilteredSessions(scope, range)
let inputTokens = 0
let cachedInputTokens = 0
let outputTokens = 0
let reasoningOutputTokens = 0
let totalTokens = 0
let events = 0
let estimatedCostUsd = 0
let hasAnyBillableCost = false
const byModel = new Map<string, number>()
const byProject = new Map<string, number>()
for (const row of filteredDaily) {
inputTokens += row.inputTokens
cachedInputTokens += row.cachedInputTokens
outputTokens += row.outputTokens
reasoningOutputTokens += row.reasoningOutputTokens
totalTokens += row.totalTokens
events += row.eventCount
byModel.set(
row.model ?? 'Unknown model',
(byModel.get(row.model ?? 'Unknown model') ?? 0) + row.totalTokens
)
byProject.set(row.projectLabel, (byProject.get(row.projectLabel) ?? 0) + row.totalTokens)
const cost = estimateCostUsd(
row.model,
row.inputTokens,
row.cachedInputTokens,
row.outputTokens
)
if (cost !== null) {
hasAnyBillableCost = true
estimatedCostUsd += cost
}
}
const topModel =
[...byModel.entries()].sort((left, right) => right[1] - left[1])[0]?.[0] ?? null
const topProject =
[...byProject.entries()].sort((left, right) => right[1] - left[1])[0]?.[0] ?? null
return {
scope,
range,
sessions: filteredSessions.length,
events,
inputTokens,
cachedInputTokens,
outputTokens,
reasoningOutputTokens,
totalTokens,
estimatedCostUsd: hasAnyBillableCost ? estimatedCostUsd : null,
topModel,
topProject,
hasAnyCodexData: filteredSessions.length > 0 || filteredDaily.length > 0
}
return buildSummary(this.state, scope, range)
}
async getDaily(scope: CodexUsageScope, range: CodexUsageRange): Promise<CodexUsageDailyPoint[]> {
await this.refresh(false)
return this.buildDaily(scope, range)
}
private buildDaily(scope: CodexUsageScope, range: CodexUsageRange): CodexUsageDailyPoint[] {
const byDay = new Map<string, CodexUsageDailyPoint>()
for (const row of this.getFilteredDaily(scope, range)) {
const existing = byDay.get(row.day) ?? {
day: row.day,
inputTokens: 0,
cachedInputTokens: 0,
outputTokens: 0,
reasoningOutputTokens: 0,
totalTokens: 0
}
existing.inputTokens += row.inputTokens
existing.cachedInputTokens += row.cachedInputTokens
existing.outputTokens += row.outputTokens
existing.reasoningOutputTokens += row.reasoningOutputTokens
existing.totalTokens += row.totalTokens
byDay.set(row.day, existing)
}
return [...byDay.values()].sort((left, right) => left.day.localeCompare(right.day))
return buildDaily(this.state, scope, range)
}
async getBreakdown(
@@ -446,85 +125,7 @@ export class CodexUsageStore extends UsageProviderStoreLifecycle<
kind: CodexUsageBreakdownKind
): Promise<CodexUsageBreakdownRow[]> {
await this.refresh(false)
return this.buildBreakdown(scope, range, kind)
}
private buildBreakdown(
scope: CodexUsageScope,
range: CodexUsageRange,
kind: CodexUsageBreakdownKind
): CodexUsageBreakdownRow[] {
const rows = new Map<string, CodexUsageBreakdownRow>()
const filteredDaily = this.getFilteredDaily(scope, range)
const filteredSessions = this.getFilteredSessions(scope, range)
for (const daily of filteredDaily) {
const key = kind === 'model' ? (daily.model ?? 'unknown') : daily.projectKey
const label = kind === 'model' ? (daily.model ?? 'Unknown model') : daily.projectLabel
const existing = rows.get(key) ?? {
key,
label,
sessions: 0,
events: 0,
inputTokens: 0,
cachedInputTokens: 0,
outputTokens: 0,
reasoningOutputTokens: 0,
totalTokens: 0,
estimatedCostUsd: null,
hasInferredPricing: false
}
existing.events += daily.eventCount
existing.inputTokens += daily.inputTokens
existing.cachedInputTokens += daily.cachedInputTokens
existing.outputTokens += daily.outputTokens
existing.reasoningOutputTokens += daily.reasoningOutputTokens
existing.totalTokens += daily.totalTokens
existing.hasInferredPricing ||= daily.hasInferredPricing
rows.set(key, existing)
}
for (const session of filteredSessions) {
if (kind === 'model') {
const seen = new Set<string>()
for (const model of this.getScopedSessionModels(session, scope)) {
if (seen.has(model.modelKey)) {
continue
}
seen.add(model.modelKey)
const row = rows.get(model.modelKey)
if (row) {
row.sessions++
}
}
continue
}
const matchingLocations = session.locationBreakdown.filter((entry) =>
scope === 'all' ? true : entry.worktreeId !== null
)
const seen = new Set<string>()
for (const location of matchingLocations) {
if (seen.has(location.locationKey)) {
continue
}
seen.add(location.locationKey)
const row = rows.get(location.locationKey)
if (row) {
row.sessions++
}
}
}
for (const row of rows.values()) {
row.estimatedCostUsd = estimateCostUsd(
kind === 'model' ? row.key : null,
row.inputTokens,
row.cachedInputTokens,
row.outputTokens
)
}
return [...rows.values()].sort((left, right) => right.totalTokens - left.totalTokens)
return buildBreakdown(this.state, scope, range, kind)
}
async getRecentSessions(
@@ -533,305 +134,13 @@ export class CodexUsageStore extends UsageProviderStoreLifecycle<
limit = 12
): Promise<CodexUsageSessionRow[]> {
await this.refresh(false)
return this.buildRecentSessions(scope, range, limit)
}
private buildRecentSessions(
scope: CodexUsageScope,
range: CodexUsageRange,
limit = 12
): CodexUsageSessionRow[] {
return this.getFilteredSessions(scope, range)
.slice(0, limit)
.map((session) => {
const matchingLocations = session.locationBreakdown.filter((entry) =>
scope === 'all' ? true : entry.worktreeId !== null
)
const scopedLocations =
matchingLocations.length > 0 ? matchingLocations : session.locationBreakdown
const totals = scopedLocations.reduce(
(acc, entry) => {
acc.events += entry.eventCount
acc.inputTokens += entry.inputTokens
acc.cachedInputTokens += entry.cachedInputTokens
acc.outputTokens += entry.outputTokens
acc.reasoningOutputTokens += entry.reasoningOutputTokens
acc.totalTokens += entry.totalTokens
acc.hasInferredPricing ||= entry.hasInferredPricing
return acc
},
{
events: 0,
inputTokens: 0,
cachedInputTokens: 0,
outputTokens: 0,
reasoningOutputTokens: 0,
totalTokens: 0,
hasInferredPricing: false
}
)
const durationMinutes = Math.max(
0,
Math.round(
(new Date(session.lastTimestamp).getTime() -
new Date(session.firstTimestamp).getTime()) /
60_000
)
)
return {
sessionId: session.sessionId,
lastActiveAt: session.lastTimestamp,
durationMinutes,
projectLabel:
scopedLocations.length > 1
? 'Multiple locations'
: (scopedLocations[0]?.projectLabel ?? session.primaryProjectLabel),
model: this.getScopedSessionPrimaryModel(session, scope),
events: totals.events,
inputTokens: totals.inputTokens,
cachedInputTokens: totals.cachedInputTokens,
outputTokens: totals.outputTokens,
reasoningOutputTokens: totals.reasoningOutputTokens,
totalTokens: totals.totalTokens,
hasInferredPricing: session.hasInferredPricing || totals.hasInferredPricing
}
})
return buildRecentSessions(this.state, scope, range, limit)
}
async getAutomationRunUsage(input: AutomationUsageLookupInput): Promise<AutomationRunUsage> {
const collectedAt = Date.now()
const unavailable = (
unavailableReason: AutomationRunUsage['unavailableReason'],
unavailableMessage: string
): AutomationRunUsage => ({
status: 'unavailable',
provider: 'codex',
model: null,
inputTokens: null,
outputTokens: null,
cacheReadTokens: null,
cacheWriteTokens: null,
reasoningOutputTokens: null,
totalTokens: null,
estimatedCostUsd: null,
estimatedCostSource: null,
providerSessionId: null,
attribution: null,
collectedAt,
unavailableReason,
unavailableMessage
return resolveCodexAutomationRunUsage(input, {
getState: () => this.state,
refresh: (force) => this.refresh(force)
})
if (!this.state.scanState.enabled) {
return unavailable('usage_not_enabled', 'Codex usage tracking is not enabled.')
}
if (!input.worktreeId || !input.startedAt || !input.completedAt) {
return unavailable('no_matching_session', 'Run session metadata is incomplete.')
}
const scanState = await this.refresh(this.shouldForceAutomationUsageScan(input.completedAt))
if (scanState.lastScanError) {
return unavailable('scan_failed', scanState.lastScanError)
}
const windowStart = input.startedAt - AUTOMATION_ATTRIBUTION_WINDOW_MS
const windowEnd = input.completedAt + AUTOMATION_ATTRIBUTION_WINDOW_MS
const candidates = this.state.sessions.filter((session) => {
const first = new Date(session.firstTimestamp).getTime()
const last = new Date(session.lastTimestamp).getTime()
if (!Number.isFinite(first) || !Number.isFinite(last)) {
return false
}
if (session.sessionId === input.terminalSessionId) {
return true
}
if (first < windowStart || first > windowEnd || last > windowEnd) {
return false
}
return session.locationBreakdown.some((entry) => entry.worktreeId === input.worktreeId)
})
if (candidates.length === 0) {
return unavailable('no_matching_session', 'No Codex usage session matched this run.')
}
if (candidates.length > 1) {
return unavailable(
'ambiguous_session',
'Multiple Codex usage sessions matched this run window.'
)
}
const session = candidates[0]
const scopedLocations = session.locationBreakdown.filter(
(entry) => entry.worktreeId === input.worktreeId
)
const locations = scopedLocations.length > 0 ? scopedLocations : session.locationBreakdown
const totals = locations.reduce(
(acc, entry) => {
acc.events += entry.eventCount
acc.inputTokens += entry.inputTokens
acc.cachedInputTokens += entry.cachedInputTokens
acc.outputTokens += entry.outputTokens
acc.reasoningOutputTokens += entry.reasoningOutputTokens
acc.totalTokens += entry.totalTokens
return acc
},
{
events: 0,
inputTokens: 0,
cachedInputTokens: 0,
outputTokens: 0,
reasoningOutputTokens: 0,
totalTokens: 0
}
)
const scopedModelRows = session.locationModelBreakdown.filter(
(entry) => entry.worktreeId === input.worktreeId
)
const modelRows = scopedModelRows.length > 0 ? scopedModelRows : session.modelBreakdown
const modelLabels = [...new Set(modelRows.map((entry) => entry.modelLabel))]
let estimatedCostUsd = 0
let hasKnownCost = false
if (scopedModelRows.length > 0) {
for (const modelRow of scopedModelRows) {
const cost = estimateCostUsd(
modelRow.modelKey,
modelRow.inputTokens,
modelRow.cachedInputTokens,
modelRow.outputTokens
)
if (cost !== null) {
hasKnownCost = true
estimatedCostUsd += cost
}
}
} else if (!session.hasMixedModels) {
const cost = estimateCostUsd(
session.primaryModel,
totals.inputTokens,
totals.cachedInputTokens,
totals.outputTokens
)
if (cost !== null) {
hasKnownCost = true
estimatedCostUsd += cost
}
}
return {
status: 'known',
provider: 'codex',
model:
modelLabels.length === 1
? modelLabels[0]
: session.hasMixedModels
? 'Mixed models'
: session.primaryModel,
inputTokens: totals.inputTokens,
outputTokens: totals.outputTokens,
cacheReadTokens: totals.cachedInputTokens,
cacheWriteTokens: null,
reasoningOutputTokens: totals.reasoningOutputTokens,
totalTokens: totals.totalTokens,
estimatedCostUsd: hasKnownCost ? estimatedCostUsd : null,
estimatedCostSource: hasKnownCost ? 'api_equivalent' : null,
providerSessionId: session.sessionId,
// Why: Orca terminal tab ids and Codex usage session ids are different
// systems today, so attribution is intentionally limited to one local
// provider session in the run's worktree/time window.
attribution: 'provider_session_time_window',
collectedAt,
unavailableReason: null,
unavailableMessage: null
}
}
private getFilteredDaily(scope: CodexUsageScope, range: CodexUsageRange) {
const cutoff = getUsageRangeCutoff(range)
return this.state.dailyAggregates.filter((entry) => {
if (cutoff && entry.day < cutoff) {
return false
}
if (scope === 'orca' && entry.worktreeId === null) {
return false
}
return true
})
}
private getFilteredSessions(scope: CodexUsageScope, range: CodexUsageRange) {
const cutoff = getUsageRangeCutoff(range)
return this.state.sessions.filter((session) => {
const day = getLocalUsageDay(session.lastTimestamp)
if (!day) {
return false
}
if (cutoff && day < cutoff) {
return false
}
if (scope === 'orca') {
return session.locationBreakdown.some((entry) => entry.worktreeId !== null)
}
return true
})
}
private getScopedSessionModels(
session: CodexUsagePersistedState['sessions'][number],
scope: CodexUsageScope
): ScopedCodexUsageModelRow[] {
if (scope === 'all' || session.locationModelBreakdown.length === 0) {
return session.modelBreakdown
}
const rows = new Map<string, ScopedCodexUsageModelRow>()
for (const entry of session.locationModelBreakdown) {
if (entry.worktreeId === null) {
continue
}
const existing = rows.get(entry.modelKey) ?? {
modelKey: entry.modelKey,
modelLabel: entry.modelLabel,
hasInferredPricing: false,
eventCount: 0,
inputTokens: 0,
cachedInputTokens: 0,
outputTokens: 0,
reasoningOutputTokens: 0,
totalTokens: 0
}
existing.hasInferredPricing ||= entry.hasInferredPricing
existing.eventCount += entry.eventCount
existing.inputTokens += entry.inputTokens
existing.cachedInputTokens += entry.cachedInputTokens
existing.outputTokens += entry.outputTokens
existing.reasoningOutputTokens += entry.reasoningOutputTokens
existing.totalTokens += entry.totalTokens
rows.set(entry.modelKey, existing)
}
return [...rows.values()].sort((left, right) => right.totalTokens - left.totalTokens)
}
private getScopedSessionPrimaryModel(
session: CodexUsagePersistedState['sessions'][number],
scope: CodexUsageScope
): string | null {
const scopedModels = this.getScopedSessionModels(session, scope)
if (scopedModels.length === 0) {
return session.primaryModel
}
if (scopedModels.length === 1) {
return scopedModels[0]?.modelLabel ?? null
}
return 'Mixed models'
}
private shouldForceAutomationUsageScan(completedAt: number): boolean {
const { lastScanCompletedAt, lastScanError } = this.state.scanState
// Why: attribution needs a scan after the run finishes, but repeated
// lookups after that point should not rescan all Codex session history.
return (
Boolean(lastScanError) || lastScanCompletedAt === null || lastScanCompletedAt < completedAt
)
}
}
@@ -0,0 +1,94 @@
import { stat } from 'node:fs/promises'
import { basename, isAbsolute, join } from 'node:path'
import { wslGatedReaddir, wslGatedStat } from '../native-chat/wsl-transcript-fs-access'
import { WslTranscriptFsError } from '../native-chat/wsl-transcript-fs-gate'
import { resolveOpenCodeDataDirectory } from '../opencode/opencode-data-directory'
import type { OpenCodeUsageProcessedDatabase } from './types'
type OpenCodeDatabaseOverride = {
isConfigured: boolean
path: string | null
}
function getOpenCodeDatabaseOverride(dataDirectory: string): OpenCodeDatabaseOverride {
const raw = process.env.OPENCODE_DB?.trim()
if (!raw) {
return { isConfigured: false, path: null }
}
if (raw === ':memory:') {
return { isConfigured: true, path: null }
}
return {
isConfigured: true,
path: isAbsolute(raw) ? raw : join(dataDirectory, raw)
}
}
// Why gated: the AI Vault's primary OpenCode source delegates here from inside
// its discovery fan-out, so a UNC data dir or a UNC OPENCODE_DB on a stalled
// distro would otherwise hang the whole scan on a raw syscall (STA-4049).
export async function listOpenCodeDatabases(
/** Lets a caller report the refusal; an empty list otherwise reads as
* "OpenCode not used" rather than "we could not look". */
onRefusal?: (path: string, error: WslTranscriptFsError) => void
): Promise<string[]> {
const dataDirectory = resolveOpenCodeDataDirectory()
const databaseOverride = getOpenCodeDatabaseOverride(dataDirectory)
if (databaseOverride.isConfigured) {
if (!databaseOverride.path) {
return []
}
try {
return (await wslGatedStat(databaseOverride.path, 'scan')).isFile()
? [databaseOverride.path]
: []
} catch (error) {
reportRefusal(databaseOverride.path, error, onRefusal)
return []
}
}
try {
const entries = await wslGatedReaddir(dataDirectory, 'scan')
return entries
.filter((entry) => entry.isFile() && /^opencode(?:-[A-Za-z0-9_.-]+)?\.db$/.test(entry.name))
.map((entry) => join(dataDirectory, entry.name))
.sort()
} catch (error) {
reportRefusal(dataDirectory, error, onRefusal)
return []
}
}
function reportRefusal(
path: string,
error: unknown,
onRefusal?: (path: string, error: WslTranscriptFsError) => void
): void {
if (error instanceof WslTranscriptFsError) {
onRefusal?.(path, error)
}
}
export function compareOpenCodeClaimPriority(left: string, right: string): number {
// Why: the canonical opencode.db is the live database; it must claim
// duplicated sessions ahead of stale sibling copies. Remaining ties use
// path order so ownership is deterministic across rescans.
const leftRank = basename(left).toLowerCase() === 'opencode.db' ? 0 : 1
const rightRank = basename(right).toLowerCase() === 'opencode.db' ? 0 : 1
if (leftRank !== rightRank) {
return leftRank - rightRank
}
return left < right ? -1 : left > right ? 1 : 0
}
export async function getProcessedDatabaseInfo(
dbPath: string
): Promise<OpenCodeUsageProcessedDatabase> {
const dbStat = await stat(dbPath)
return {
path: dbPath,
mtimeMs: dbStat.mtimeMs,
size: dbStat.size
}
}
@@ -0,0 +1,110 @@
import { ensureNumber, extractString } from '../usage/usage-record-coercion'
import type { OpenCodeUsageRow } from './opencode-usage-row-queries'
import type { OpenCodeUsageParsedEvent } from './types'
function parseJsonObject(value: unknown): Record<string, unknown> | null {
if (typeof value === 'object' && value !== null && !Array.isArray(value)) {
return value as Record<string, unknown>
}
if (typeof value !== 'string') {
return null
}
try {
const parsed = JSON.parse(value) as unknown
return typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed)
? (parsed as Record<string, unknown>)
: null
} catch {
return null
}
}
function extractModelLabel(data: Record<string, unknown>, sessionModel: unknown): string | null {
const directModel = extractString(data.modelID) ?? extractString(data.modelId)
const directProvider = extractString(data.providerID) ?? extractString(data.providerId)
if (directModel) {
return directProvider ? `${directProvider}/${directModel}` : directModel
}
const modelObject = parseJsonObject(data.model) ?? parseJsonObject(sessionModel)
if (!modelObject) {
return null
}
const modelID = extractString(modelObject.modelID) ?? extractString(modelObject.id)
const providerID = extractString(modelObject.providerID)
if (!modelID) {
return null
}
return providerID ? `${providerID}/${modelID}` : modelID
}
function extractCwd(data: Record<string, unknown>, row: OpenCodeUsageRow): string | null {
const pathData = parseJsonObject(data.path)
return (
extractString(pathData?.cwd) ??
extractString(row.directory) ??
extractString(row.worktree) ??
null
)
}
function normalizeMillis(value: unknown): number | null {
const numeric = ensureNumber(value)
if (numeric <= 0) {
return null
}
return numeric < 10_000_000_000 ? numeric * 1000 : numeric
}
function extractTimestamp(data: Record<string, unknown>, row: OpenCodeUsageRow): string | null {
const timeData = parseJsonObject(data.time)
const millis =
normalizeMillis(timeData?.completed) ??
normalizeMillis(timeData?.created) ??
normalizeMillis(row.time_updated) ??
normalizeMillis(row.time_created)
return millis ? new Date(millis).toISOString() : null
}
export function parseOpenCodeUsageRow(row: OpenCodeUsageRow): OpenCodeUsageParsedEvent | null {
const data = parseJsonObject(row.data)
if (!data) {
return null
}
const tokens = parseJsonObject(data.tokens)
if (!tokens) {
return null
}
const cache = parseJsonObject(tokens.cache)
const inputTokens = ensureNumber(tokens.input)
const outputTokens = ensureNumber(tokens.output)
const reasoningOutputTokens = ensureNumber(tokens.reasoning)
const cachedInputTokens = Math.min(ensureNumber(cache?.read), inputTokens)
const totalTokens =
ensureNumber(tokens.total) > 0
? ensureNumber(tokens.total)
: inputTokens + outputTokens + reasoningOutputTokens
if (inputTokens + outputTokens + reasoningOutputTokens + cachedInputTokens + totalTokens <= 0) {
return null
}
const timestamp = extractTimestamp(data, row)
if (!timestamp) {
return null
}
return {
sessionId: row.session_id,
timestamp,
cwd: extractCwd(data, row),
model: extractModelLabel(data, row.session_model),
estimatedCostUsd: ensureNumber(data.cost) > 0 ? ensureNumber(data.cost) : null,
inputTokens,
cachedInputTokens,
outputTokens,
reasoningOutputTokens,
totalTokens
}
}
@@ -0,0 +1,164 @@
import type Database from '../sqlite/sync-database'
import { columnExists, tableExists } from './schema-helpers'
export type OpenCodeUsageRow = {
id: string
session_id: string
time_created: number
time_updated: number | null
data: string
directory: string | null
title: string | null
worktree: string | null
session_model: string | null
}
type OpenCodeSessionUsageRow = {
id: string
session_id: string
time_created: number
time_updated: number | null
directory: string | null
title: string | null
worktree: string | null
session_model: string | null
cost: number
tokens_input: number
tokens_output: number
tokens_reasoning: number
tokens_cache_read: number
}
function getProjectJoin(db: Database.Database): string {
return tableExists(db, 'project') && columnExists(db, 'session', 'project_id')
? 'LEFT JOIN project p ON p.id = s.project_id'
: 'LEFT JOIN (SELECT NULL AS id, NULL AS worktree) p ON 1 = 0'
}
function getSessionModelSelect(db: Database.Database): string {
return columnExists(db, 'session', 'model') ? 's.model AS session_model' : 'NULL AS session_model'
}
function getAssistantSessionMessageCount(db: Database.Database): number {
if (!tableExists(db, 'session_message')) {
return 0
}
const assistantPredicate = columnExists(db, 'session_message', 'type')
? "type = 'assistant' AND json_extract(data, '$.tokens.input') IS NOT NULL"
: "json_extract(data, '$.tokens.input') IS NOT NULL"
const row = db
.prepare(`SELECT COUNT(*) AS count FROM session_message WHERE ${assistantPredicate}`)
.get() as { count?: number } | undefined
return row?.count ?? 0
}
function canReadSessionUsageRows(db: Database.Database): boolean {
if (!tableExists(db, 'session')) {
return false
}
return ['cost', 'tokens_input', 'tokens_output', 'tokens_reasoning', 'tokens_cache_read'].every(
(columnName) => columnExists(db, 'session', columnName)
)
}
function getSessionUsageRowCount(db: Database.Database): number {
if (!canReadSessionUsageRows(db)) {
return 0
}
const row = db
.prepare(
`SELECT COUNT(*) AS count
FROM session
WHERE tokens_input + tokens_output + tokens_reasoning + tokens_cache_read > 0`
)
.get() as { count?: number } | undefined
return row?.count ?? 0
}
function selectSessionUsageRows(db: Database.Database): OpenCodeUsageRow[] {
const projectJoin = getProjectJoin(db)
const sessionModelSelect = getSessionModelSelect(db)
const rows = db
.prepare(
`SELECT s.id, s.id AS session_id, s.time_created, s.time_updated,
s.directory, s.title, p.worktree, ${sessionModelSelect},
s.cost, s.tokens_input, s.tokens_output, s.tokens_reasoning, s.tokens_cache_read
FROM session s
${projectJoin}
WHERE s.tokens_input + s.tokens_output + s.tokens_reasoning + s.tokens_cache_read > 0
ORDER BY s.time_created, s.id`
)
.all() as OpenCodeSessionUsageRow[]
return rows.map((row) => ({
id: row.id,
session_id: row.session_id,
time_created: row.time_created,
time_updated: row.time_updated,
directory: row.directory,
title: row.title,
worktree: row.worktree,
session_model: row.session_model,
data: JSON.stringify({
cost: row.cost,
tokens: {
input: row.tokens_input,
output: row.tokens_output,
reasoning: row.tokens_reasoning,
total: row.tokens_input + row.tokens_output + row.tokens_reasoning,
cache: {
read: row.tokens_cache_read,
write: 0
}
}
})
}))
}
export function selectUsageRows(db: Database.Database): OpenCodeUsageRow[] {
if (!tableExists(db, 'session')) {
return []
}
// Why: newer OpenCode DBs maintain session-level token/cost totals. Reading
// one aggregate row per session is faster than parsing every message blob.
if (getSessionUsageRowCount(db) > 0) {
return selectSessionUsageRows(db)
}
const projectJoin = getProjectJoin(db)
const sessionModelSelect = getSessionModelSelect(db)
if (getAssistantSessionMessageCount(db) > 0) {
const assistantPredicate = columnExists(db, 'session_message', 'type')
? "sm.type = 'assistant'"
: "json_extract(sm.data, '$.tokens.input') IS NOT NULL"
return db
.prepare(
`SELECT sm.id, sm.session_id, sm.time_created, sm.time_updated, sm.data,
s.directory, s.title, p.worktree, ${sessionModelSelect}
FROM session_message sm
JOIN session s ON s.id = sm.session_id
${projectJoin}
WHERE ${assistantPredicate}
ORDER BY sm.time_created, sm.id`
)
.all() as OpenCodeUsageRow[]
}
if (!tableExists(db, 'message')) {
return []
}
return db
.prepare(
`SELECT m.id, m.session_id, m.time_created, m.time_updated, m.data,
s.directory, s.title, p.worktree, ${sessionModelSelect}
FROM message m
JOIN session s ON s.id = m.session_id
${projectJoin}
WHERE json_extract(m.data, '$.role') = 'assistant'
ORDER BY m.time_created, m.id`
)
.all() as OpenCodeUsageRow[]
}
@@ -0,0 +1,122 @@
import { realpath } from 'node:fs/promises'
import { posix, win32 } from 'node:path'
import { areWorktreePathsEqual } from '../ipc/worktree-logic'
import { canonicalizeUsageWorktreePaths } from '../usage-worktree-canonicalizer'
import {
looksLikeWindowsPath,
normalizeComparablePath,
normalizeFsPath
} from '../usage/usage-path-comparison'
import type { UsageScanWorktreeRef } from '../usage/usage-provider-contract'
import type { OpenCodeUsageAttributedEvent, OpenCodeUsageParsedEvent } from './types'
export type OpenCodeUsageWorktreeRef = UsageScanWorktreeRef
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}`
}
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
}
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 !== '.'
)
}
export async function buildWorktreesWithCanonicalPaths(
worktrees: OpenCodeUsageWorktreeRef[]
): Promise<(OpenCodeUsageWorktreeRef & { canonicalPath: string })[]> {
return canonicalizeUsageWorktreePaths(worktrees, canonicalizePath)
}
async function canonicalizePath(pathValue: string): Promise<string> {
try {
return normalizeFsPath(await realpath(pathValue))
} catch {
return normalizeFsPath(pathValue)
}
}
function findContainingWorktree(
cwd: string,
worktrees: (OpenCodeUsageWorktreeRef & { canonicalPath: string })[]
): OpenCodeUsageWorktreeRef | 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 attributeOpenCodeUsageEvent(
event: OpenCodeUsageParsedEvent,
worktrees: (OpenCodeUsageWorktreeRef & { canonicalPath: string })[]
): Promise<OpenCodeUsageAttributedEvent | 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 {
projectKey = `cwd:${normalizeComparablePath(event.cwd)}`
}
}
return {
...event,
day,
projectKey,
projectLabel,
repoId,
worktreeId
}
}
@@ -0,0 +1,68 @@
import type { OpenCodeUsageDailyAggregate, OpenCodeUsagePersistedState } from './types'
import { OPENCODE_USAGE_SCHEMA_VERSION } from './opencode-usage-provider'
const SCHEMA_VERSION = OPENCODE_USAGE_SCHEMA_VERSION
export function getDefaultState(): OpenCodeUsagePersistedState {
return {
schemaVersion: SCHEMA_VERSION,
worktreeFingerprint: null,
processedDatabases: [],
sessions: [],
dailyAggregates: [],
scanState: {
enabled: false,
lastScanStartedAt: null,
lastScanCompletedAt: null,
lastScanError: null
}
}
}
export function normalizePersistedState(
state: OpenCodeUsagePersistedState
): OpenCodeUsagePersistedState {
if (state.schemaVersion !== SCHEMA_VERSION) {
return getDefaultState()
}
return {
...state,
processedDatabases: (state.processedDatabases ?? []).map((database) => ({
...database,
sessions: (database.sessions ?? []).map(normalizeSessionCost),
dailyAggregates: (database.dailyAggregates ?? []).map(normalizeDailyAggregateCost)
})),
sessions: state.sessions.map(normalizeSessionCost),
dailyAggregates: state.dailyAggregates.map(normalizeDailyAggregateCost)
}
}
function normalizeDailyAggregateCost(
entry: OpenCodeUsageDailyAggregate
): OpenCodeUsageDailyAggregate {
return {
...entry,
estimatedCostUsd: entry.estimatedCostUsd ?? null
}
}
function normalizeSessionCost(
session: OpenCodeUsagePersistedState['sessions'][number]
): OpenCodeUsagePersistedState['sessions'][number] {
return {
...session,
estimatedCostUsd: session.estimatedCostUsd ?? null,
locationBreakdown: (session.locationBreakdown ?? []).map((entry) => ({
...entry,
estimatedCostUsd: entry.estimatedCostUsd ?? null
})),
modelBreakdown: (session.modelBreakdown ?? []).map((entry) => ({
...entry,
estimatedCostUsd: entry.estimatedCostUsd ?? null
})),
locationModelBreakdown: (session.locationModelBreakdown ?? []).map((entry) => ({
...entry,
estimatedCostUsd: entry.estimatedCostUsd ?? null
}))
}
}
@@ -20,7 +20,7 @@ vi.mock('node:fs/promises', async (importOriginal) => ({
stat: mocks.stat
}))
import { listOpenCodeDatabases } from './scanner'
import { listOpenCodeDatabases } from './opencode-database-discovery'
import {
WSL_TRANSCRIPT_FS_SCAN_TIMEOUT_MS,
WslTranscriptFsError
+4 -7
View File
@@ -3,13 +3,10 @@ import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import Database from '../sqlite/sync-database'
import {
attributeOpenCodeUsageEvent,
listOpenCodeDatabases,
parseOpenCodeUsageDatabase,
parseOpenCodeUsageRow,
scanOpenCodeUsageDatabases
} from './scanner'
import { listOpenCodeDatabases } from './opencode-database-discovery'
import { parseOpenCodeUsageRow } from './opencode-usage-row-parsing'
import { attributeOpenCodeUsageEvent } from './opencode-usage-worktree-attribution'
import { parseOpenCodeUsageDatabase, scanOpenCodeUsageDatabases } from './scanner'
const WORKTREE = '/workspace/repo'
+11 -485
View File
@@ -1,501 +1,27 @@
/* eslint-disable max-lines -- Why: OpenCode usage analytics need to normalize multiple local DB schema generations, attribute worktrees, and build persisted projections in one auditable pipeline. */
import { realpath, stat } from 'node:fs/promises'
import { basename, isAbsolute, join, posix, win32 } from 'node:path'
import { yieldToEventLoop } from '../../shared/event-loop-yield'
import { wslGatedReaddir, wslGatedStat } from '../native-chat/wsl-transcript-fs-access'
import { WslTranscriptFsError } from '../native-chat/wsl-transcript-fs-gate'
import { areWorktreePathsEqual } from '../ipc/worktree-logic'
import { resolveOpenCodeDataDirectory } from '../opencode/opencode-data-directory'
import Database from '../sqlite/sync-database'
import { columnExists, tableExists } from './schema-helpers'
import { canonicalizeUsageWorktreePaths } from '../usage-worktree-canonicalizer'
import { createUsageEventAggregation } from '../usage/usage-event-aggregation'
import {
looksLikeWindowsPath,
normalizeComparablePath,
normalizeFsPath
} from '../usage/usage-path-comparison'
import { ensureNumber, extractString } from '../usage/usage-record-coercion'
import type { UsageScanWorktreeRef } from '../usage/usage-provider-contract'
compareOpenCodeClaimPriority,
getProcessedDatabaseInfo,
listOpenCodeDatabases
} from './opencode-database-discovery'
import { parseOpenCodeUsageRow } from './opencode-usage-row-parsing'
import { selectUsageRows } from './opencode-usage-row-queries'
import {
attributeOpenCodeUsageEvent,
buildWorktreesWithCanonicalPaths,
type OpenCodeUsageWorktreeRef
} from './opencode-usage-worktree-attribution'
import type {
OpenCodeUsageAttributedEvent,
OpenCodeUsageDailyAggregate,
OpenCodeUsageParsedEvent,
OpenCodeUsagePersistedDatabase,
OpenCodeUsageProcessedDatabase,
OpenCodeUsageSession
} from './types'
export type OpenCodeUsageWorktreeRef = UsageScanWorktreeRef
type OpenCodeUsageRow = {
id: string
session_id: string
time_created: number
time_updated: number | null
data: string
directory: string | null
title: string | null
worktree: string | null
session_model: string | null
}
type OpenCodeSessionUsageRow = {
id: string
session_id: string
time_created: number
time_updated: number | null
directory: string | null
title: string | null
worktree: string | null
session_model: string | null
cost: number
tokens_input: number
tokens_output: number
tokens_reasoning: number
tokens_cache_read: number
}
const YIELD_EVERY_DATABASES = 2
type OpenCodeDatabaseOverride = {
isConfigured: boolean
path: string | null
}
function getOpenCodeDatabaseOverride(dataDirectory: string): OpenCodeDatabaseOverride {
const raw = process.env.OPENCODE_DB?.trim()
if (!raw) {
return { isConfigured: false, path: null }
}
if (raw === ':memory:') {
return { isConfigured: true, path: null }
}
return {
isConfigured: true,
path: isAbsolute(raw) ? raw : join(dataDirectory, raw)
}
}
// Why gated: the AI Vault's primary OpenCode source delegates here from inside
// its discovery fan-out, so a UNC data dir or a UNC OPENCODE_DB on a stalled
// distro would otherwise hang the whole scan on a raw syscall (STA-4049).
export async function listOpenCodeDatabases(
/** Lets a caller report the refusal; an empty list otherwise reads as
* "OpenCode not used" rather than "we could not look". */
onRefusal?: (path: string, error: WslTranscriptFsError) => void
): Promise<string[]> {
const dataDirectory = resolveOpenCodeDataDirectory()
const databaseOverride = getOpenCodeDatabaseOverride(dataDirectory)
if (databaseOverride.isConfigured) {
if (!databaseOverride.path) {
return []
}
try {
return (await wslGatedStat(databaseOverride.path, 'scan')).isFile()
? [databaseOverride.path]
: []
} catch (error) {
reportRefusal(databaseOverride.path, error, onRefusal)
return []
}
}
try {
const entries = await wslGatedReaddir(dataDirectory, 'scan')
return entries
.filter((entry) => entry.isFile() && /^opencode(?:-[A-Za-z0-9_.-]+)?\.db$/.test(entry.name))
.map((entry) => join(dataDirectory, entry.name))
.sort()
} catch (error) {
reportRefusal(dataDirectory, error, onRefusal)
return []
}
}
function reportRefusal(
path: string,
error: unknown,
onRefusal?: (path: string, error: WslTranscriptFsError) => void
): void {
if (error instanceof WslTranscriptFsError) {
onRefusal?.(path, error)
}
}
function compareOpenCodeClaimPriority(left: string, right: string): number {
// Why: the canonical opencode.db is the live database; it must claim
// duplicated sessions ahead of stale sibling copies. Remaining ties use
// path order so ownership is deterministic across rescans.
const leftRank = basename(left).toLowerCase() === 'opencode.db' ? 0 : 1
const rightRank = basename(right).toLowerCase() === 'opencode.db' ? 0 : 1
if (leftRank !== rightRank) {
return leftRank - rightRank
}
return left < right ? -1 : left > right ? 1 : 0
}
export async function getProcessedDatabaseInfo(
dbPath: string
): Promise<OpenCodeUsageProcessedDatabase> {
const dbStat = await stat(dbPath)
return {
path: dbPath,
mtimeMs: dbStat.mtimeMs,
size: dbStat.size
}
}
function getProjectJoin(db: Database.Database): string {
return tableExists(db, 'project') && columnExists(db, 'session', 'project_id')
? 'LEFT JOIN project p ON p.id = s.project_id'
: 'LEFT JOIN (SELECT NULL AS id, NULL AS worktree) p ON 1 = 0'
}
function getSessionModelSelect(db: Database.Database): string {
return columnExists(db, 'session', 'model') ? 's.model AS session_model' : 'NULL AS session_model'
}
function getAssistantSessionMessageCount(db: Database.Database): number {
if (!tableExists(db, 'session_message')) {
return 0
}
const assistantPredicate = columnExists(db, 'session_message', 'type')
? "type = 'assistant' AND json_extract(data, '$.tokens.input') IS NOT NULL"
: "json_extract(data, '$.tokens.input') IS NOT NULL"
const row = db
.prepare(`SELECT COUNT(*) AS count FROM session_message WHERE ${assistantPredicate}`)
.get() as { count?: number } | undefined
return row?.count ?? 0
}
function canReadSessionUsageRows(db: Database.Database): boolean {
if (!tableExists(db, 'session')) {
return false
}
return ['cost', 'tokens_input', 'tokens_output', 'tokens_reasoning', 'tokens_cache_read'].every(
(columnName) => columnExists(db, 'session', columnName)
)
}
function getSessionUsageRowCount(db: Database.Database): number {
if (!canReadSessionUsageRows(db)) {
return 0
}
const row = db
.prepare(
`SELECT COUNT(*) AS count
FROM session
WHERE tokens_input + tokens_output + tokens_reasoning + tokens_cache_read > 0`
)
.get() as { count?: number } | undefined
return row?.count ?? 0
}
function selectSessionUsageRows(db: Database.Database): OpenCodeUsageRow[] {
const projectJoin = getProjectJoin(db)
const sessionModelSelect = getSessionModelSelect(db)
const rows = db
.prepare(
`SELECT s.id, s.id AS session_id, s.time_created, s.time_updated,
s.directory, s.title, p.worktree, ${sessionModelSelect},
s.cost, s.tokens_input, s.tokens_output, s.tokens_reasoning, s.tokens_cache_read
FROM session s
${projectJoin}
WHERE s.tokens_input + s.tokens_output + s.tokens_reasoning + s.tokens_cache_read > 0
ORDER BY s.time_created, s.id`
)
.all() as OpenCodeSessionUsageRow[]
return rows.map((row) => ({
id: row.id,
session_id: row.session_id,
time_created: row.time_created,
time_updated: row.time_updated,
directory: row.directory,
title: row.title,
worktree: row.worktree,
session_model: row.session_model,
data: JSON.stringify({
cost: row.cost,
tokens: {
input: row.tokens_input,
output: row.tokens_output,
reasoning: row.tokens_reasoning,
total: row.tokens_input + row.tokens_output + row.tokens_reasoning,
cache: {
read: row.tokens_cache_read,
write: 0
}
}
})
}))
}
function selectUsageRows(db: Database.Database): OpenCodeUsageRow[] {
if (!tableExists(db, 'session')) {
return []
}
// Why: newer OpenCode DBs maintain session-level token/cost totals. Reading
// one aggregate row per session is faster than parsing every message blob.
if (getSessionUsageRowCount(db) > 0) {
return selectSessionUsageRows(db)
}
const projectJoin = getProjectJoin(db)
const sessionModelSelect = getSessionModelSelect(db)
if (getAssistantSessionMessageCount(db) > 0) {
const assistantPredicate = columnExists(db, 'session_message', 'type')
? "sm.type = 'assistant'"
: "json_extract(sm.data, '$.tokens.input') IS NOT NULL"
return db
.prepare(
`SELECT sm.id, sm.session_id, sm.time_created, sm.time_updated, sm.data,
s.directory, s.title, p.worktree, ${sessionModelSelect}
FROM session_message sm
JOIN session s ON s.id = sm.session_id
${projectJoin}
WHERE ${assistantPredicate}
ORDER BY sm.time_created, sm.id`
)
.all() as OpenCodeUsageRow[]
}
if (!tableExists(db, 'message')) {
return []
}
return db
.prepare(
`SELECT m.id, m.session_id, m.time_created, m.time_updated, m.data,
s.directory, s.title, p.worktree, ${sessionModelSelect}
FROM message m
JOIN session s ON s.id = m.session_id
${projectJoin}
WHERE json_extract(m.data, '$.role') = 'assistant'
ORDER BY m.time_created, m.id`
)
.all() as OpenCodeUsageRow[]
}
function parseJsonObject(value: unknown): Record<string, unknown> | null {
if (typeof value === 'object' && value !== null && !Array.isArray(value)) {
return value as Record<string, unknown>
}
if (typeof value !== 'string') {
return null
}
try {
const parsed = JSON.parse(value) as unknown
return typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed)
? (parsed as Record<string, unknown>)
: null
} catch {
return null
}
}
function extractModelLabel(data: Record<string, unknown>, sessionModel: unknown): string | null {
const directModel = extractString(data.modelID) ?? extractString(data.modelId)
const directProvider = extractString(data.providerID) ?? extractString(data.providerId)
if (directModel) {
return directProvider ? `${directProvider}/${directModel}` : directModel
}
const modelObject = parseJsonObject(data.model) ?? parseJsonObject(sessionModel)
if (!modelObject) {
return null
}
const modelID = extractString(modelObject.modelID) ?? extractString(modelObject.id)
const providerID = extractString(modelObject.providerID)
if (!modelID) {
return null
}
return providerID ? `${providerID}/${modelID}` : modelID
}
function extractCwd(data: Record<string, unknown>, row: OpenCodeUsageRow): string | null {
const pathData = parseJsonObject(data.path)
return (
extractString(pathData?.cwd) ??
extractString(row.directory) ??
extractString(row.worktree) ??
null
)
}
function normalizeMillis(value: unknown): number | null {
const numeric = ensureNumber(value)
if (numeric <= 0) {
return null
}
return numeric < 10_000_000_000 ? numeric * 1000 : numeric
}
function extractTimestamp(data: Record<string, unknown>, row: OpenCodeUsageRow): string | null {
const timeData = parseJsonObject(data.time)
const millis =
normalizeMillis(timeData?.completed) ??
normalizeMillis(timeData?.created) ??
normalizeMillis(row.time_updated) ??
normalizeMillis(row.time_created)
return millis ? new Date(millis).toISOString() : null
}
export function parseOpenCodeUsageRow(row: OpenCodeUsageRow): OpenCodeUsageParsedEvent | null {
const data = parseJsonObject(row.data)
if (!data) {
return null
}
const tokens = parseJsonObject(data.tokens)
if (!tokens) {
return null
}
const cache = parseJsonObject(tokens.cache)
const inputTokens = ensureNumber(tokens.input)
const outputTokens = ensureNumber(tokens.output)
const reasoningOutputTokens = ensureNumber(tokens.reasoning)
const cachedInputTokens = Math.min(ensureNumber(cache?.read), inputTokens)
const totalTokens =
ensureNumber(tokens.total) > 0
? ensureNumber(tokens.total)
: inputTokens + outputTokens + reasoningOutputTokens
if (inputTokens + outputTokens + reasoningOutputTokens + cachedInputTokens + totalTokens <= 0) {
return null
}
const timestamp = extractTimestamp(data, row)
if (!timestamp) {
return null
}
return {
sessionId: row.session_id,
timestamp,
cwd: extractCwd(data, row),
model: extractModelLabel(data, row.session_model),
estimatedCostUsd: ensureNumber(data.cost) > 0 ? ensureNumber(data.cost) : null,
inputTokens,
cachedInputTokens,
outputTokens,
reasoningOutputTokens,
totalTokens
}
}
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}`
}
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
}
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 !== '.'
)
}
async function buildWorktreesWithCanonicalPaths(
worktrees: OpenCodeUsageWorktreeRef[]
): Promise<(OpenCodeUsageWorktreeRef & { canonicalPath: string })[]> {
return canonicalizeUsageWorktreePaths(worktrees, canonicalizePath)
}
async function canonicalizePath(pathValue: string): Promise<string> {
try {
return normalizeFsPath(await realpath(pathValue))
} catch {
return normalizeFsPath(pathValue)
}
}
function findContainingWorktree(
cwd: string,
worktrees: (OpenCodeUsageWorktreeRef & { canonicalPath: string })[]
): OpenCodeUsageWorktreeRef | 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 attributeOpenCodeUsageEvent(
event: OpenCodeUsageParsedEvent,
worktrees: (OpenCodeUsageWorktreeRef & { canonicalPath: string })[]
): Promise<OpenCodeUsageAttributedEvent | 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 {
projectKey = `cwd:${normalizeComparablePath(event.cwd)}`
}
}
return {
...event,
day,
projectKey,
projectLabel,
repoId,
worktreeId
}
}
function addCost(left: number | null, right: number | null): number | null {
if (left === null && right === null) {
return null
@@ -0,0 +1,40 @@
import type { OpenCodeUsageRange, OpenCodeUsageScope } from '../../shared/opencode-usage-types'
import type { OpenCodeUsageDailyAggregate, OpenCodeUsageSession } from './types'
import { getLocalUsageDay, getUsageRangeCutoff } from '../usage/usage-calendar-range'
export function filterDailyAggregatesByScopeAndRange(
dailyAggregates: OpenCodeUsageDailyAggregate[],
scope: OpenCodeUsageScope,
range: OpenCodeUsageRange
): OpenCodeUsageDailyAggregate[] {
const cutoff = getUsageRangeCutoff(range)
return dailyAggregates.filter((row) => {
if (scope === 'orca' && !row.worktreeId) {
return false
}
if (cutoff && row.day < cutoff) {
return false
}
return true
})
}
export function filterSessionsByScopeAndRange(
sessions: OpenCodeUsageSession[],
scope: OpenCodeUsageScope,
range: OpenCodeUsageRange
): OpenCodeUsageSession[] {
const cutoff = getUsageRangeCutoff(range)
return sessions.filter((session) => {
if (scope === 'orca' && !session.primaryWorktreeId) {
return false
}
if (cutoff) {
const day = getLocalUsageDay(session.lastTimestamp)
if (!day || day < cutoff) {
return false
}
}
return true
})
}
+174
View File
@@ -0,0 +1,174 @@
import type {
OpenCodeUsageBreakdownKind,
OpenCodeUsageBreakdownRow,
OpenCodeUsageDailyPoint,
OpenCodeUsageRange,
OpenCodeUsageScope,
OpenCodeUsageSessionRow,
OpenCodeUsageSummary
} from '../../shared/opencode-usage-types'
import type { OpenCodeUsageDailyAggregate, OpenCodeUsageSession } from './types'
function addCost(left: number | null, right: number | null): number | null {
if (left === null && right === null) {
return null
}
return (left ?? 0) + (right ?? 0)
}
export function buildOpenCodeUsageSummary(
scope: OpenCodeUsageScope,
range: OpenCodeUsageRange,
filteredDaily: OpenCodeUsageDailyAggregate[],
filteredSessions: OpenCodeUsageSession[]
): OpenCodeUsageSummary {
let inputTokens = 0
let cachedInputTokens = 0
let outputTokens = 0
let reasoningOutputTokens = 0
let totalTokens = 0
let events = 0
let estimatedCostUsd: number | null = null
const byModel = new Map<string, number>()
const byProject = new Map<string, number>()
for (const row of filteredDaily) {
inputTokens += row.inputTokens
cachedInputTokens += row.cachedInputTokens
outputTokens += row.outputTokens
reasoningOutputTokens += row.reasoningOutputTokens
totalTokens += row.totalTokens
events += row.eventCount
estimatedCostUsd = addCost(estimatedCostUsd, row.estimatedCostUsd)
byModel.set(
row.model ?? 'Unknown model',
(byModel.get(row.model ?? 'Unknown model') ?? 0) + row.totalTokens
)
byProject.set(row.projectLabel, (byProject.get(row.projectLabel) ?? 0) + row.totalTokens)
}
const topModel = [...byModel.entries()].sort((left, right) => right[1] - left[1])[0]?.[0] ?? null
const topProject =
[...byProject.entries()].sort((left, right) => right[1] - left[1])[0]?.[0] ?? null
return {
scope,
range,
sessions: filteredSessions.length,
events,
inputTokens,
cachedInputTokens,
outputTokens,
reasoningOutputTokens,
totalTokens,
estimatedCostUsd,
topModel,
topProject,
hasAnyOpenCodeData: filteredSessions.length > 0 || filteredDaily.length > 0
}
}
export function buildOpenCodeUsageDailyPoints(
filteredDaily: OpenCodeUsageDailyAggregate[]
): OpenCodeUsageDailyPoint[] {
const byDay = new Map<string, OpenCodeUsageDailyPoint>()
for (const row of filteredDaily) {
const existing = byDay.get(row.day) ?? {
day: row.day,
inputTokens: 0,
cachedInputTokens: 0,
outputTokens: 0,
reasoningOutputTokens: 0,
totalTokens: 0
}
existing.inputTokens += row.inputTokens
existing.cachedInputTokens += row.cachedInputTokens
existing.outputTokens += row.outputTokens
existing.reasoningOutputTokens += row.reasoningOutputTokens
existing.totalTokens += row.totalTokens
byDay.set(row.day, existing)
}
return [...byDay.values()].sort((left, right) => left.day.localeCompare(right.day))
}
export function buildOpenCodeUsageBreakdownRows(
kind: OpenCodeUsageBreakdownKind,
filteredDaily: OpenCodeUsageDailyAggregate[],
filteredSessions: OpenCodeUsageSession[]
): OpenCodeUsageBreakdownRow[] {
const rows = new Map<string, OpenCodeUsageBreakdownRow>()
for (const daily of filteredDaily) {
const key = kind === 'model' ? (daily.model ?? 'unknown') : daily.projectKey
const label = kind === 'model' ? (daily.model ?? 'Unknown model') : daily.projectLabel
const existing = rows.get(key) ?? {
key,
label,
sessions: 0,
events: 0,
inputTokens: 0,
cachedInputTokens: 0,
outputTokens: 0,
reasoningOutputTokens: 0,
totalTokens: 0,
estimatedCostUsd: null
}
existing.events += daily.eventCount
existing.inputTokens += daily.inputTokens
existing.cachedInputTokens += daily.cachedInputTokens
existing.outputTokens += daily.outputTokens
existing.reasoningOutputTokens += daily.reasoningOutputTokens
existing.totalTokens += daily.totalTokens
existing.estimatedCostUsd = addCost(existing.estimatedCostUsd, daily.estimatedCostUsd)
rows.set(key, existing)
}
if (kind === 'model') {
for (const session of filteredSessions) {
for (const entry of session.modelBreakdown) {
const row = rows.get(entry.modelKey)
if (row) {
row.sessions++
}
}
}
} else {
for (const session of filteredSessions) {
for (const entry of session.locationBreakdown) {
const row = rows.get(entry.locationKey)
if (row) {
row.sessions++
}
}
}
}
return [...rows.values()].sort((left, right) => right.totalTokens - left.totalTokens)
}
export function buildOpenCodeUsageRecentSessions(
filteredSessions: OpenCodeUsageSession[],
limit = 10
): OpenCodeUsageSessionRow[] {
return filteredSessions.slice(0, limit).map(
(session): OpenCodeUsageSessionRow => ({
sessionId: session.sessionId,
lastActiveAt: session.lastTimestamp,
durationMinutes: Math.max(
0,
Math.round(
(new Date(session.lastTimestamp).getTime() - new Date(session.firstTimestamp).getTime()) /
60_000
)
),
projectLabel: session.primaryProjectLabel,
model: session.primaryModel,
events: session.eventCount,
inputTokens: session.totalInputTokens,
cachedInputTokens: session.totalCachedInputTokens,
outputTokens: session.totalOutputTokens,
reasoningOutputTokens: session.totalReasoningOutputTokens,
totalTokens: session.totalTokens
})
)
}
+2 -1
View File
@@ -22,7 +22,8 @@ vi.mock('./scanner', () => ({
scanOpenCodeUsageDatabases: vi.fn()
}))
import { OpenCodeUsageStore, initOpenCodeUsagePath, normalizePersistedState } from './store'
import { OpenCodeUsageStore, initOpenCodeUsagePath } from './store'
import { normalizePersistedState } from './persisted-state-normalization'
import { scanOpenCodeUsageDatabases } from './scanner'
function createEmptyScanResult() {
+34 -239
View File
@@ -1,4 +1,3 @@
/* eslint-disable max-lines -- Why: OpenCode cost normalization and range, scope, and breakdown policies remain one cohesive store. */
import { app } from 'electron'
import { join } from 'node:path'
import type {
@@ -12,49 +11,27 @@ import type {
OpenCodeUsageSummary
} from '../../shared/opencode-usage-types'
import type { Store } from '../persistence'
import type { OpenCodeUsageDailyAggregate, OpenCodeUsagePersistedState } from './types'
import { OPENCODE_USAGE_SCHEMA_VERSION, openCodeUsageProvider } from './opencode-usage-provider'
import { getLocalUsageDay, getUsageRangeCutoff } from '../usage/usage-calendar-range'
import type {
OpenCodeUsageDailyAggregate,
OpenCodeUsagePersistedState,
OpenCodeUsageSession
} from './types'
import { openCodeUsageProvider } from './opencode-usage-provider'
import { getDefaultState, normalizePersistedState } from './persisted-state-normalization'
import {
filterDailyAggregatesByScopeAndRange,
filterSessionsByScopeAndRange
} from './scope-range-filter'
import {
buildOpenCodeUsageBreakdownRows,
buildOpenCodeUsageDailyPoints,
buildOpenCodeUsageRecentSessions,
buildOpenCodeUsageSummary
} from './snapshot-rollups'
import { UsageProviderStoreLifecycle } from '../usage/usage-provider-store-lifecycle'
const SCHEMA_VERSION = OPENCODE_USAGE_SCHEMA_VERSION
let _openCodeUsageFile: string | null = null
function getDefaultState(): OpenCodeUsagePersistedState {
return {
schemaVersion: SCHEMA_VERSION,
worktreeFingerprint: null,
processedDatabases: [],
sessions: [],
dailyAggregates: [],
scanState: {
enabled: false,
lastScanStartedAt: null,
lastScanCompletedAt: null,
lastScanError: null
}
}
}
export function normalizePersistedState(
state: OpenCodeUsagePersistedState
): OpenCodeUsagePersistedState {
if (state.schemaVersion !== SCHEMA_VERSION) {
return getDefaultState()
}
return {
...state,
processedDatabases: (state.processedDatabases ?? []).map((database) => ({
...database,
sessions: (database.sessions ?? []).map(normalizeSessionCost),
dailyAggregates: (database.dailyAggregates ?? []).map(normalizeDailyAggregateCost)
})),
sessions: state.sessions.map(normalizeSessionCost),
dailyAggregates: state.dailyAggregates.map(normalizeDailyAggregateCost)
}
}
export function initOpenCodeUsagePath(): void {
_openCodeUsageFile = join(app.getPath('userData'), 'orca-opencode-usage.json')
}
@@ -66,43 +43,6 @@ function getOpenCodeUsageFile(): string {
return _openCodeUsageFile
}
function addCost(left: number | null, right: number | null): number | null {
if (left === null && right === null) {
return null
}
return (left ?? 0) + (right ?? 0)
}
function normalizeDailyAggregateCost(
entry: OpenCodeUsageDailyAggregate
): OpenCodeUsageDailyAggregate {
return {
...entry,
estimatedCostUsd: entry.estimatedCostUsd ?? null
}
}
function normalizeSessionCost(
session: OpenCodeUsagePersistedState['sessions'][number]
): OpenCodeUsagePersistedState['sessions'][number] {
return {
...session,
estimatedCostUsd: session.estimatedCostUsd ?? null,
locationBreakdown: (session.locationBreakdown ?? []).map((entry) => ({
...entry,
estimatedCostUsd: entry.estimatedCostUsd ?? null
})),
modelBreakdown: (session.modelBreakdown ?? []).map((entry) => ({
...entry,
estimatedCostUsd: entry.estimatedCostUsd ?? null
})),
locationModelBreakdown: (session.locationModelBreakdown ?? []).map((entry) => ({
...entry,
estimatedCostUsd: entry.estimatedCostUsd ?? null
}))
}
}
export class OpenCodeUsageStore extends UsageProviderStoreLifecycle<
'processedDatabases',
OpenCodeUsagePersistedState,
@@ -145,54 +85,12 @@ export class OpenCodeUsageStore extends UsageProviderStoreLifecycle<
}
private buildSummary(scope: OpenCodeUsageScope, range: OpenCodeUsageRange): OpenCodeUsageSummary {
const filteredDaily = this.getFilteredDaily(scope, range)
const filteredSessions = this.getFilteredSessions(scope, range)
let inputTokens = 0
let cachedInputTokens = 0
let outputTokens = 0
let reasoningOutputTokens = 0
let totalTokens = 0
let events = 0
let estimatedCostUsd: number | null = null
const byModel = new Map<string, number>()
const byProject = new Map<string, number>()
for (const row of filteredDaily) {
inputTokens += row.inputTokens
cachedInputTokens += row.cachedInputTokens
outputTokens += row.outputTokens
reasoningOutputTokens += row.reasoningOutputTokens
totalTokens += row.totalTokens
events += row.eventCount
estimatedCostUsd = addCost(estimatedCostUsd, row.estimatedCostUsd)
byModel.set(
row.model ?? 'Unknown model',
(byModel.get(row.model ?? 'Unknown model') ?? 0) + row.totalTokens
)
byProject.set(row.projectLabel, (byProject.get(row.projectLabel) ?? 0) + row.totalTokens)
}
const topModel =
[...byModel.entries()].sort((left, right) => right[1] - left[1])[0]?.[0] ?? null
const topProject =
[...byProject.entries()].sort((left, right) => right[1] - left[1])[0]?.[0] ?? null
return {
return buildOpenCodeUsageSummary(
scope,
range,
sessions: filteredSessions.length,
events,
inputTokens,
cachedInputTokens,
outputTokens,
reasoningOutputTokens,
totalTokens,
estimatedCostUsd,
topModel,
topProject,
hasAnyOpenCodeData: filteredSessions.length > 0 || filteredDaily.length > 0
}
this.getFilteredDaily(scope, range),
this.getFilteredSessions(scope, range)
)
}
async getDaily(
@@ -207,24 +105,7 @@ export class OpenCodeUsageStore extends UsageProviderStoreLifecycle<
scope: OpenCodeUsageScope,
range: OpenCodeUsageRange
): OpenCodeUsageDailyPoint[] {
const byDay = new Map<string, OpenCodeUsageDailyPoint>()
for (const row of this.getFilteredDaily(scope, range)) {
const existing = byDay.get(row.day) ?? {
day: row.day,
inputTokens: 0,
cachedInputTokens: 0,
outputTokens: 0,
reasoningOutputTokens: 0,
totalTokens: 0
}
existing.inputTokens += row.inputTokens
existing.cachedInputTokens += row.cachedInputTokens
existing.outputTokens += row.outputTokens
existing.reasoningOutputTokens += row.reasoningOutputTokens
existing.totalTokens += row.totalTokens
byDay.set(row.day, existing)
}
return [...byDay.values()].sort((left, right) => left.day.localeCompare(right.day))
return buildOpenCodeUsageDailyPoints(this.getFilteredDaily(scope, range))
}
async getBreakdown(
@@ -241,56 +122,11 @@ export class OpenCodeUsageStore extends UsageProviderStoreLifecycle<
range: OpenCodeUsageRange,
kind: OpenCodeUsageBreakdownKind
): OpenCodeUsageBreakdownRow[] {
const rows = new Map<string, OpenCodeUsageBreakdownRow>()
const filteredDaily = this.getFilteredDaily(scope, range)
const filteredSessions = this.getFilteredSessions(scope, range)
for (const daily of filteredDaily) {
const key = kind === 'model' ? (daily.model ?? 'unknown') : daily.projectKey
const label = kind === 'model' ? (daily.model ?? 'Unknown model') : daily.projectLabel
const existing = rows.get(key) ?? {
key,
label,
sessions: 0,
events: 0,
inputTokens: 0,
cachedInputTokens: 0,
outputTokens: 0,
reasoningOutputTokens: 0,
totalTokens: 0,
estimatedCostUsd: null
}
existing.events += daily.eventCount
existing.inputTokens += daily.inputTokens
existing.cachedInputTokens += daily.cachedInputTokens
existing.outputTokens += daily.outputTokens
existing.reasoningOutputTokens += daily.reasoningOutputTokens
existing.totalTokens += daily.totalTokens
existing.estimatedCostUsd = addCost(existing.estimatedCostUsd, daily.estimatedCostUsd)
rows.set(key, existing)
}
if (kind === 'model') {
for (const session of filteredSessions) {
for (const entry of session.modelBreakdown) {
const row = rows.get(entry.modelKey)
if (row) {
row.sessions++
}
}
}
} else {
for (const session of filteredSessions) {
for (const entry of session.locationBreakdown) {
const row = rows.get(entry.locationKey)
if (row) {
row.sessions++
}
}
}
}
return [...rows.values()].sort((left, right) => right.totalTokens - left.totalTokens)
return buildOpenCodeUsageBreakdownRows(
kind,
this.getFilteredDaily(scope, range),
this.getFilteredSessions(scope, range)
)
}
async getRecentSessions(
@@ -307,61 +143,20 @@ export class OpenCodeUsageStore extends UsageProviderStoreLifecycle<
range: OpenCodeUsageRange,
limit = 10
): OpenCodeUsageSessionRow[] {
return this.getFilteredSessions(scope, range)
.slice(0, limit)
.map(
(session): OpenCodeUsageSessionRow => ({
sessionId: session.sessionId,
lastActiveAt: session.lastTimestamp,
durationMinutes: Math.max(
0,
Math.round(
(new Date(session.lastTimestamp).getTime() -
new Date(session.firstTimestamp).getTime()) /
60_000
)
),
projectLabel: session.primaryProjectLabel,
model: session.primaryModel,
events: session.eventCount,
inputTokens: session.totalInputTokens,
cachedInputTokens: session.totalCachedInputTokens,
outputTokens: session.totalOutputTokens,
reasoningOutputTokens: session.totalReasoningOutputTokens,
totalTokens: session.totalTokens
})
)
return buildOpenCodeUsageRecentSessions(this.getFilteredSessions(scope, range), limit)
}
private getFilteredDaily(
scope: OpenCodeUsageScope,
range: OpenCodeUsageRange
): OpenCodeUsageDailyAggregate[] {
const cutoff = getUsageRangeCutoff(range)
return this.state.dailyAggregates.filter((row) => {
if (scope === 'orca' && !row.worktreeId) {
return false
}
if (cutoff && row.day < cutoff) {
return false
}
return true
})
return filterDailyAggregatesByScopeAndRange(this.state.dailyAggregates, scope, range)
}
private getFilteredSessions(scope: OpenCodeUsageScope, range: OpenCodeUsageRange) {
const cutoff = getUsageRangeCutoff(range)
return this.state.sessions.filter((session) => {
if (scope === 'orca' && !session.primaryWorktreeId) {
return false
}
if (cutoff) {
const day = getLocalUsageDay(session.lastTimestamp)
if (!day || day < cutoff) {
return false
}
}
return true
})
private getFilteredSessions(
scope: OpenCodeUsageScope,
range: OpenCodeUsageRange
): OpenCodeUsageSession[] {
return filterSessionsByScopeAndRange(this.state.sessions, scope, range)
}
}
@@ -5,12 +5,8 @@ import { Badge } from '../ui/badge'
import { Button } from '../ui/button'
import { Tooltip, TooltipContent, TooltipTrigger } from '../ui/tooltip'
import { StatCard } from './StatCard'
import {
buildUsageOverview,
formatUsageCost,
formatUsageTokens,
getRecentUsageDays
} from './usage-overview-model'
import { getRecentUsageDays } from './usage-overview-daily-series'
import { buildUsageOverview, formatUsageCost, formatUsageTokens } from './usage-overview-model'
import { DailyIntensityGrid, ProviderUsageRow, TokenMixBar } from './usage-overview-sections'
import { translate } from '@/i18n/i18n'
@@ -0,0 +1,120 @@
import type { ClaudeUsageDailyPoint } from '../../../../shared/claude-usage-types'
import type { UsageOverviewDailyPoint, UsageOverviewInput } from './usage-overview-types'
export function getClaudeDailyTotal(entry: ClaudeUsageDailyPoint): number {
return entry.inputTokens + entry.outputTokens + entry.cacheReadTokens + entry.cacheWriteTokens
}
function getIntensity(totalTokens: number, maxTokens: number): 0 | 1 | 2 | 3 | 4 {
if (totalTokens <= 0 || maxTokens <= 0) {
return 0
}
const ratio = totalTokens / maxTokens
if (ratio <= 0.25) {
return 1
}
if (ratio <= 0.5) {
return 2
}
if (ratio <= 0.75) {
return 3
}
return 4
}
export function countActiveDays(days: string[]): number {
return new Set(days).size
}
export function buildDailyOverview(input: UsageOverviewInput): UsageOverviewDailyPoint[] {
const byDay = new Map<string, Omit<UsageOverviewDailyPoint, 'intensity'>>()
for (const entry of input.claude.daily) {
const current = byDay.get(entry.day) ?? {
day: entry.day,
totalTokens: 0,
claudeTokens: 0,
codexTokens: 0,
openCodeTokens: 0
}
const total = getClaudeDailyTotal(entry)
current.totalTokens += total
current.claudeTokens += total
byDay.set(entry.day, current)
}
for (const entry of input.codex.daily) {
const current = byDay.get(entry.day) ?? {
day: entry.day,
totalTokens: 0,
claudeTokens: 0,
codexTokens: 0,
openCodeTokens: 0
}
current.totalTokens += entry.totalTokens
current.codexTokens += entry.totalTokens
byDay.set(entry.day, current)
}
for (const entry of input.opencode.daily) {
const current = byDay.get(entry.day) ?? {
day: entry.day,
totalTokens: 0,
claudeTokens: 0,
codexTokens: 0,
openCodeTokens: 0
}
current.totalTokens += entry.totalTokens
current.openCodeTokens += entry.totalTokens
byDay.set(entry.day, current)
}
let maxTokens = 0
// Why: usage history can be large enough to exceed V8's argument limit if
// every day is spread into Math.max.
for (const entry of byDay.values()) {
maxTokens = Math.max(maxTokens, entry.totalTokens)
}
return [...byDay.values()]
.sort((left, right) => left.day.localeCompare(right.day))
.map((entry) => ({
...entry,
intensity: getIntensity(entry.totalTokens, maxTokens)
}))
}
function formatLocalDay(date: Date): string {
const year = date.getFullYear()
const month = String(date.getMonth() + 1).padStart(2, '0')
const day = String(date.getDate()).padStart(2, '0')
return `${year}-${month}-${day}`
}
export function getRecentUsageDays(
daily: UsageOverviewDailyPoint[],
dayCount: number,
anchorDate = new Date()
): UsageOverviewDailyPoint[] {
const byDay = new Map(daily.map((entry) => [entry.day, entry]))
const count = Math.max(1, Math.floor(dayCount))
const end = new Date(anchorDate)
end.setHours(0, 0, 0, 0)
const result: UsageOverviewDailyPoint[] = []
for (let offset = count - 1; offset >= 0; offset--) {
const date = new Date(end)
date.setDate(end.getDate() - offset)
const day = formatLocalDay(date)
result.push(
byDay.get(day) ?? {
day,
totalTokens: 0,
claudeTokens: 0,
codexTokens: 0,
openCodeTokens: 0,
intensity: 0
}
)
}
return result
}
@@ -14,12 +14,8 @@ import type {
OpenCodeUsageScanState,
OpenCodeUsageSummary
} from '../../../../shared/opencode-usage-types'
import {
buildUsageOverview,
formatUsageCost,
formatUsageTokens,
getRecentUsageDays
} from './usage-overview-model'
import { getRecentUsageDays } from './usage-overview-daily-series'
import { buildUsageOverview, formatUsageCost, formatUsageTokens } from './usage-overview-model'
function enabledClaudeScanState(): ClaudeUsageScanState {
return {
@@ -1,315 +1,14 @@
/* eslint-disable max-lines -- Why: provider normalization, totals, and heatmap aggregation share
one tested model so the overview UI cannot drift from the math. */
import { buildDailyOverview, countActiveDays } from './usage-overview-daily-series'
import type {
ClaudeUsageDailyPoint,
ClaudeUsageScanState,
ClaudeUsageSummary
} from '../../../../shared/claude-usage-types'
import type {
CodexUsageDailyPoint,
CodexUsageScanState,
CodexUsageSummary
} from '../../../../shared/codex-usage-types'
import type {
OpenCodeUsageDailyPoint,
OpenCodeUsageScanState,
OpenCodeUsageSummary
} from '../../../../shared/opencode-usage-types'
import { translate } from '@/i18n/i18n'
export type UsageProviderId = 'claude' | 'codex' | 'opencode'
export type UsageProviderOverview = {
id: UsageProviderId
label: string
enabled: boolean
isScanning: boolean
hasData: boolean
lastScanCompletedAt: number | null
lastScanError: string | null
sessions: number
activityLabel: 'turns' | 'events'
activityCount: number
totalTokens: number
newInputTokens: number
outputTokens: number
cacheTokens: number
reasoningTokens: number
estimatedCostUsd: number | null
topModel: string | null
topProject: string | null
activeDays: number
}
export type UsageOverviewDailyPoint = {
day: string
totalTokens: number
claudeTokens: number
codexTokens: number
openCodeTokens: number
intensity: 0 | 1 | 2 | 3 | 4
}
export type UsageOverviewModel = {
providers: UsageProviderOverview[]
enabledProviderCount: number
dataProviderCount: number
hasAnyEnabledProvider: boolean
hasAnyData: boolean
totalTokens: number
newInputTokens: number
outputTokens: number
cacheTokens: number
reasoningTokens: number
sessions: number
activityCount: number
activeDays: number
estimatedCostUsd: number | null
hasPartialCost: boolean
cacheShare: number | null
daily: UsageOverviewDailyPoint[]
bestDay: UsageOverviewDailyPoint | null
lastUpdatedAt: number | null
}
export type UsageOverviewInput = {
claude: {
scanState: ClaudeUsageScanState | null
summary: ClaudeUsageSummary | null
daily: ClaudeUsageDailyPoint[]
}
codex: {
scanState: CodexUsageScanState | null
summary: CodexUsageSummary | null
daily: CodexUsageDailyPoint[]
}
opencode: {
scanState: OpenCodeUsageScanState | null
summary: OpenCodeUsageSummary | null
daily: OpenCodeUsageDailyPoint[]
}
}
function getClaudeDailyTotal(entry: ClaudeUsageDailyPoint): number {
return entry.inputTokens + entry.outputTokens + entry.cacheReadTokens + entry.cacheWriteTokens
}
function getCodexNewInputTokens(summary: CodexUsageSummary | null): number {
if (!summary) {
return 0
}
return Math.max(summary.inputTokens - summary.cachedInputTokens, 0)
}
function getOpenCodeNewInputTokens(summary: OpenCodeUsageSummary | null): number {
if (!summary) {
return 0
}
return Math.max(summary.inputTokens - summary.cachedInputTokens, 0)
}
function getIntensity(totalTokens: number, maxTokens: number): 0 | 1 | 2 | 3 | 4 {
if (totalTokens <= 0 || maxTokens <= 0) {
return 0
}
const ratio = totalTokens / maxTokens
if (ratio <= 0.25) {
return 1
}
if (ratio <= 0.5) {
return 2
}
if (ratio <= 0.75) {
return 3
}
return 4
}
function countActiveDays(days: string[]): number {
return new Set(days).size
}
function createClaudeProvider(input: UsageOverviewInput['claude']): UsageProviderOverview {
const summary = input.summary
const dailyActiveDays = input.daily
.filter((entry) => getClaudeDailyTotal(entry) > 0)
.map((entry) => entry.day)
return {
id: 'claude',
label: translate('auto.components.stats.usage.overview.model.544d6d4c16', 'Claude'),
enabled: input.scanState?.enabled ?? false,
isScanning: input.scanState?.isScanning ?? false,
hasData: summary?.hasAnyClaudeData ?? input.scanState?.hasAnyClaudeData ?? false,
lastScanCompletedAt: input.scanState?.lastScanCompletedAt ?? null,
lastScanError: input.scanState?.lastScanError ?? null,
sessions: summary?.sessions ?? 0,
activityLabel: 'turns',
activityCount: summary?.turns ?? 0,
totalTokens: summary
? summary.inputTokens +
summary.outputTokens +
summary.cacheReadTokens +
summary.cacheWriteTokens
: 0,
newInputTokens: summary?.inputTokens ?? 0,
outputTokens: summary?.outputTokens ?? 0,
cacheTokens: summary ? summary.cacheReadTokens + summary.cacheWriteTokens : 0,
reasoningTokens: 0,
estimatedCostUsd: summary?.estimatedCostUsd ?? null,
topModel: summary?.topModel ?? null,
topProject: summary?.topProject ?? null,
activeDays: countActiveDays(dailyActiveDays)
}
}
function createCodexProvider(input: UsageOverviewInput['codex']): UsageProviderOverview {
const summary = input.summary
const dailyActiveDays = input.daily
.filter((entry) => entry.totalTokens > 0)
.map((entry) => entry.day)
return {
id: 'codex',
label: translate('auto.components.stats.usage.overview.model.eb220d193b', 'Codex'),
enabled: input.scanState?.enabled ?? false,
isScanning: input.scanState?.isScanning ?? false,
hasData: summary?.hasAnyCodexData ?? input.scanState?.hasAnyCodexData ?? false,
lastScanCompletedAt: input.scanState?.lastScanCompletedAt ?? null,
lastScanError: input.scanState?.lastScanError ?? null,
sessions: summary?.sessions ?? 0,
activityLabel: 'events',
activityCount: summary?.events ?? 0,
totalTokens: summary?.totalTokens ?? 0,
newInputTokens: getCodexNewInputTokens(summary),
outputTokens: summary?.outputTokens ?? 0,
cacheTokens: summary?.cachedInputTokens ?? 0,
reasoningTokens: summary?.reasoningOutputTokens ?? 0,
estimatedCostUsd: summary?.estimatedCostUsd ?? null,
topModel: summary?.topModel ?? null,
topProject: summary?.topProject ?? null,
activeDays: countActiveDays(dailyActiveDays)
}
}
function createOpenCodeProvider(input: UsageOverviewInput['opencode']): UsageProviderOverview {
const summary = input.summary
const dailyActiveDays = input.daily
.filter((entry) => entry.totalTokens > 0)
.map((entry) => entry.day)
return {
id: 'opencode',
label: translate('auto.components.stats.usage.overview.model.bc474051e5', 'OpenCode'),
enabled: input.scanState?.enabled ?? false,
isScanning: input.scanState?.isScanning ?? false,
hasData: summary?.hasAnyOpenCodeData ?? input.scanState?.hasAnyOpenCodeData ?? false,
lastScanCompletedAt: input.scanState?.lastScanCompletedAt ?? null,
lastScanError: input.scanState?.lastScanError ?? null,
sessions: summary?.sessions ?? 0,
activityLabel: 'events',
activityCount: summary?.events ?? 0,
totalTokens: summary?.totalTokens ?? 0,
newInputTokens: getOpenCodeNewInputTokens(summary),
outputTokens: summary?.outputTokens ?? 0,
cacheTokens: summary?.cachedInputTokens ?? 0,
reasoningTokens: summary?.reasoningOutputTokens ?? 0,
estimatedCostUsd: summary?.estimatedCostUsd ?? null,
topModel: summary?.topModel ?? null,
topProject: summary?.topProject ?? null,
activeDays: countActiveDays(dailyActiveDays)
}
}
function buildDailyOverview(input: UsageOverviewInput): UsageOverviewDailyPoint[] {
const byDay = new Map<string, Omit<UsageOverviewDailyPoint, 'intensity'>>()
for (const entry of input.claude.daily) {
const current = byDay.get(entry.day) ?? {
day: entry.day,
totalTokens: 0,
claudeTokens: 0,
codexTokens: 0,
openCodeTokens: 0
}
const total = getClaudeDailyTotal(entry)
current.totalTokens += total
current.claudeTokens += total
byDay.set(entry.day, current)
}
for (const entry of input.codex.daily) {
const current = byDay.get(entry.day) ?? {
day: entry.day,
totalTokens: 0,
claudeTokens: 0,
codexTokens: 0,
openCodeTokens: 0
}
current.totalTokens += entry.totalTokens
current.codexTokens += entry.totalTokens
byDay.set(entry.day, current)
}
for (const entry of input.opencode.daily) {
const current = byDay.get(entry.day) ?? {
day: entry.day,
totalTokens: 0,
claudeTokens: 0,
codexTokens: 0,
openCodeTokens: 0
}
current.totalTokens += entry.totalTokens
current.openCodeTokens += entry.totalTokens
byDay.set(entry.day, current)
}
let maxTokens = 0
// Why: usage history can be large enough to exceed V8's argument limit if
// every day is spread into Math.max.
for (const entry of byDay.values()) {
maxTokens = Math.max(maxTokens, entry.totalTokens)
}
return [...byDay.values()]
.sort((left, right) => left.day.localeCompare(right.day))
.map((entry) => ({
...entry,
intensity: getIntensity(entry.totalTokens, maxTokens)
}))
}
function formatLocalDay(date: Date): string {
const year = date.getFullYear()
const month = String(date.getMonth() + 1).padStart(2, '0')
const day = String(date.getDate()).padStart(2, '0')
return `${year}-${month}-${day}`
}
export function getRecentUsageDays(
daily: UsageOverviewDailyPoint[],
dayCount: number,
anchorDate = new Date()
): UsageOverviewDailyPoint[] {
const byDay = new Map(daily.map((entry) => [entry.day, entry]))
const count = Math.max(1, Math.floor(dayCount))
const end = new Date(anchorDate)
end.setHours(0, 0, 0, 0)
const result: UsageOverviewDailyPoint[] = []
for (let offset = count - 1; offset >= 0; offset--) {
const date = new Date(end)
date.setDate(end.getDate() - offset)
const day = formatLocalDay(date)
result.push(
byDay.get(day) ?? {
day,
totalTokens: 0,
claudeTokens: 0,
codexTokens: 0,
openCodeTokens: 0,
intensity: 0
}
)
}
return result
}
UsageOverviewDailyPoint,
UsageOverviewInput,
UsageOverviewModel
} from './usage-overview-types'
import {
createClaudeProvider,
createCodexProvider,
createOpenCodeProvider
} from './usage-provider-normalization'
export function buildUsageOverview(input: UsageOverviewInput): UsageOverviewModel {
const providers = [
@@ -1,13 +1,12 @@
import { AlertCircle } from 'lucide-react'
import { Badge } from '../ui/badge'
import { Button } from '../ui/button'
import {
formatUsageCost,
formatUsageTokens,
type UsageOverviewDailyPoint,
type UsageOverviewModel,
type UsageProviderOverview
} from './usage-overview-model'
import { formatUsageCost, formatUsageTokens } from './usage-overview-model'
import type {
UsageOverviewDailyPoint,
UsageOverviewModel,
UsageProviderOverview
} from './usage-overview-types'
import { translate } from '@/i18n/i18n'
const INTENSITY_CLASS: Record<UsageOverviewDailyPoint['intensity'], string> = {
@@ -0,0 +1,88 @@
import type {
ClaudeUsageDailyPoint,
ClaudeUsageScanState,
ClaudeUsageSummary
} from '../../../../shared/claude-usage-types'
import type {
CodexUsageDailyPoint,
CodexUsageScanState,
CodexUsageSummary
} from '../../../../shared/codex-usage-types'
import type {
OpenCodeUsageDailyPoint,
OpenCodeUsageScanState,
OpenCodeUsageSummary
} from '../../../../shared/opencode-usage-types'
export type UsageProviderId = 'claude' | 'codex' | 'opencode'
export type UsageProviderOverview = {
id: UsageProviderId
label: string
enabled: boolean
isScanning: boolean
hasData: boolean
lastScanCompletedAt: number | null
lastScanError: string | null
sessions: number
activityLabel: 'turns' | 'events'
activityCount: number
totalTokens: number
newInputTokens: number
outputTokens: number
cacheTokens: number
reasoningTokens: number
estimatedCostUsd: number | null
topModel: string | null
topProject: string | null
activeDays: number
}
export type UsageOverviewDailyPoint = {
day: string
totalTokens: number
claudeTokens: number
codexTokens: number
openCodeTokens: number
intensity: 0 | 1 | 2 | 3 | 4
}
export type UsageOverviewModel = {
providers: UsageProviderOverview[]
enabledProviderCount: number
dataProviderCount: number
hasAnyEnabledProvider: boolean
hasAnyData: boolean
totalTokens: number
newInputTokens: number
outputTokens: number
cacheTokens: number
reasoningTokens: number
sessions: number
activityCount: number
activeDays: number
estimatedCostUsd: number | null
hasPartialCost: boolean
cacheShare: number | null
daily: UsageOverviewDailyPoint[]
bestDay: UsageOverviewDailyPoint | null
lastUpdatedAt: number | null
}
export type UsageOverviewInput = {
claude: {
scanState: ClaudeUsageScanState | null
summary: ClaudeUsageSummary | null
daily: ClaudeUsageDailyPoint[]
}
codex: {
scanState: CodexUsageScanState | null
summary: CodexUsageSummary | null
daily: CodexUsageDailyPoint[]
}
opencode: {
scanState: OpenCodeUsageScanState | null
summary: OpenCodeUsageSummary | null
daily: OpenCodeUsageDailyPoint[]
}
}
@@ -0,0 +1,110 @@
import type { CodexUsageSummary } from '../../../../shared/codex-usage-types'
import type { OpenCodeUsageSummary } from '../../../../shared/opencode-usage-types'
import { countActiveDays, getClaudeDailyTotal } from './usage-overview-daily-series'
import type { UsageOverviewInput, UsageProviderOverview } from './usage-overview-types'
import { translate } from '@/i18n/i18n'
function getCodexNewInputTokens(summary: CodexUsageSummary | null): number {
if (!summary) {
return 0
}
return Math.max(summary.inputTokens - summary.cachedInputTokens, 0)
}
function getOpenCodeNewInputTokens(summary: OpenCodeUsageSummary | null): number {
if (!summary) {
return 0
}
return Math.max(summary.inputTokens - summary.cachedInputTokens, 0)
}
export function createClaudeProvider(input: UsageOverviewInput['claude']): UsageProviderOverview {
const summary = input.summary
const dailyActiveDays = input.daily
.filter((entry) => getClaudeDailyTotal(entry) > 0)
.map((entry) => entry.day)
return {
id: 'claude',
label: translate('auto.components.stats.usage.overview.model.544d6d4c16', 'Claude'),
enabled: input.scanState?.enabled ?? false,
isScanning: input.scanState?.isScanning ?? false,
hasData: summary?.hasAnyClaudeData ?? input.scanState?.hasAnyClaudeData ?? false,
lastScanCompletedAt: input.scanState?.lastScanCompletedAt ?? null,
lastScanError: input.scanState?.lastScanError ?? null,
sessions: summary?.sessions ?? 0,
activityLabel: 'turns',
activityCount: summary?.turns ?? 0,
totalTokens: summary
? summary.inputTokens +
summary.outputTokens +
summary.cacheReadTokens +
summary.cacheWriteTokens
: 0,
newInputTokens: summary?.inputTokens ?? 0,
outputTokens: summary?.outputTokens ?? 0,
cacheTokens: summary ? summary.cacheReadTokens + summary.cacheWriteTokens : 0,
reasoningTokens: 0,
estimatedCostUsd: summary?.estimatedCostUsd ?? null,
topModel: summary?.topModel ?? null,
topProject: summary?.topProject ?? null,
activeDays: countActiveDays(dailyActiveDays)
}
}
export function createCodexProvider(input: UsageOverviewInput['codex']): UsageProviderOverview {
const summary = input.summary
const dailyActiveDays = input.daily
.filter((entry) => entry.totalTokens > 0)
.map((entry) => entry.day)
return {
id: 'codex',
label: translate('auto.components.stats.usage.overview.model.eb220d193b', 'Codex'),
enabled: input.scanState?.enabled ?? false,
isScanning: input.scanState?.isScanning ?? false,
hasData: summary?.hasAnyCodexData ?? input.scanState?.hasAnyCodexData ?? false,
lastScanCompletedAt: input.scanState?.lastScanCompletedAt ?? null,
lastScanError: input.scanState?.lastScanError ?? null,
sessions: summary?.sessions ?? 0,
activityLabel: 'events',
activityCount: summary?.events ?? 0,
totalTokens: summary?.totalTokens ?? 0,
newInputTokens: getCodexNewInputTokens(summary),
outputTokens: summary?.outputTokens ?? 0,
cacheTokens: summary?.cachedInputTokens ?? 0,
reasoningTokens: summary?.reasoningOutputTokens ?? 0,
estimatedCostUsd: summary?.estimatedCostUsd ?? null,
topModel: summary?.topModel ?? null,
topProject: summary?.topProject ?? null,
activeDays: countActiveDays(dailyActiveDays)
}
}
export function createOpenCodeProvider(
input: UsageOverviewInput['opencode']
): UsageProviderOverview {
const summary = input.summary
const dailyActiveDays = input.daily
.filter((entry) => entry.totalTokens > 0)
.map((entry) => entry.day)
return {
id: 'opencode',
label: translate('auto.components.stats.usage.overview.model.bc474051e5', 'OpenCode'),
enabled: input.scanState?.enabled ?? false,
isScanning: input.scanState?.isScanning ?? false,
hasData: summary?.hasAnyOpenCodeData ?? input.scanState?.hasAnyOpenCodeData ?? false,
lastScanCompletedAt: input.scanState?.lastScanCompletedAt ?? null,
lastScanError: input.scanState?.lastScanError ?? null,
sessions: summary?.sessions ?? 0,
activityLabel: 'events',
activityCount: summary?.events ?? 0,
totalTokens: summary?.totalTokens ?? 0,
newInputTokens: getOpenCodeNewInputTokens(summary),
outputTokens: summary?.outputTokens ?? 0,
cacheTokens: summary?.cachedInputTokens ?? 0,
reasoningTokens: summary?.reasoningOutputTokens ?? 0,
estimatedCostUsd: summary?.estimatedCostUsd ?? null,
topModel: summary?.topModel ?? null,
topProject: summary?.topProject ?? null,
activeDays: countActiveDays(dailyActiveDays)
}
}