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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 26 additions & 4 deletions docs/providers/claude.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,15 @@ Anthropic Claude Code CLI and Claude Desktop's local agent mode.

- **Source:** `src/providers/claude.ts`
- **Loading:** eager (`src/providers/index.ts:1`)
- **Test:** none directly. Coverage comes from `tests/parser-claude-cwd.test.ts`, `tests/parser-filter.test.ts`, and `tests/parser-mcp-inventory.test.ts`, which exercise `src/parser.ts` end-to-end against fixture session files.
- **Test:** `tests/providers/claude-cowork-ledger.test.ts` and `tests/providers/claude-cowork-ledger-readonly.test.ts`, plus `tests/parser-claude-cwd.test.ts`, `tests/parser-filter.test.ts`, and `tests/parser-mcp-inventory.test.ts`.

## Where it reads from

| Source | Path |
|---|---|
| Claude Code CLI | `$CLAUDE_CONFIG_DIR` if set, otherwise `~/.claude/projects/` |
| Claude Desktop (macOS) | `~/Library/Application Support/Claude/local-agent-mode-sessions/` |
| Claude Desktop (macOS, classic) | `~/Library/Application Support/Claude/local-agent-mode-sessions/` |
| Claude Desktop (macOS, 3p/Cowork) | `~/Library/Application Support/Claude-3p/local-agent-mode-sessions/` |
| Claude Desktop (Windows, classic) | `%APPDATA%/Claude/local-agent-mode-sessions/` |
| Claude Desktop (Windows, MSIX) | `%LOCALAPPDATA%/Packages/<Claude package>/LocalCache/Roaming/Claude/local-agent-mode-sessions/` |
| Claude Desktop (Linux) | `~/.config/Claude/local-agent-mode-sessions/` |
Expand All @@ -22,7 +23,7 @@ Desktop session roots are resolved in this order:

1. A non-empty `CODEBURN_DESKTOP_SESSIONS_DIR` overrides discovery and is the
only returned root.
2. macOS uses the single path shown above.
2. macOS checks both the classic `Claude` path and the `Claude-3p` Cowork path.
3. Windows always includes the classic path first. It then scans
`%LOCALAPPDATA%/Packages` for package directories whose names start with
`Claude_` or contain `.Claude_`, sorted by package name, and includes only
Expand All @@ -35,10 +36,24 @@ unreadable Windows package directories are ignored.
## Storage format

JSONL, one event per line, per session file. Sessions live under `<project>/<sessionId>.jsonl`.
Claude Desktop 3p also writes `usage-ledger/*.ndjson` records under its
`local-agent-mode-sessions` root. Both `surface: "cowork"` and
`surface: "code"` records are included and labeled `Claude Cowork` and
`Claude Code`, respectively.

A ledger record and a JSONL transcript can describe the same API call. The
ledger is treated as the durable billing record, and a transcript call is
removed only when its model, token counts, and timestamp match a ledger call
within 30 seconds. This keeps the request counted once while retaining it
when the transcript is later deleted.

## Parser

`createSessionParser` returns an empty async generator (`claude.ts:101-105`). Claude is a special case: `src/parser.ts` reads Claude JSONL files directly with full turn grouping, dedup of streaming message IDs, and MCP tool inventory extraction. The provider object exists only so `discoverSessions` can return Claude session sources alongside the others.
`src/parser.ts` reads Claude JSONL files directly with full turn grouping,
dedup of streaming message IDs, and MCP tool inventory extraction.
`createSessionParser` reads the Desktop usage ledger, which is parsed through
the shared provider cache so its records can be served independently of the
transcript tree.

Claude Code can record a message sent while it is working as an
`attachment` entry with `attachment.type: "queued_command"` and
Expand All @@ -62,6 +77,13 @@ aggregate cache-write token total for reports, but prices the 1-hour portion at
If the split fields are missing, the parser falls back to the legacy behavior
and prices every cache write at the 5-minute rate.

For Desktop ledger records, CodeBurn uses the shared CodeBurn pricing table
and configured price overrides for known model ids. The recorded ledger cost
is used only as a fallback when CodeBurn has no billable price for that model,
so changing Claude Desktop pricing does not replace a CodeBurn estimate.
The raw model id remains available to pricing; route variants such as
`us.anthropic.…` and `global.anthropic.…` are not collapsed for billing.

## Bedrock sessions

The JSONL has no provider field; the only trace of how a call was billed is
Expand Down
6 changes: 5 additions & 1 deletion src/daily-cache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -265,7 +265,11 @@ import type { DateRange, ProjectSummary } from './types.js'
// under-price them; the bump re-derives surviving days off the warm session
// cache. MIN_SUPPORTED_VERSION stays at 28 (#1478's convention: a version bump
// alone re-derives warm caches, so raising the floor buys nothing).
export const DAILY_CACHE_VERSION = 53
// v54: #1579 Claude Desktop usage-ledger spend; days finalized earlier miss it.
// Ledger-backed calls and their transcript de-duplication must be re-derived.
// v55: Claude Desktop ledger surfaces, CodeBurn repricing, and transcript
// de-duplication change settled Claude totals, so re-derive surviving days.
export const DAILY_CACHE_VERSION = 55
const MIN_SUPPORTED_VERSION = 28

/// Providers whose per-day CALL COUNT means something different at
Expand Down
147 changes: 137 additions & 10 deletions src/parser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { createHash } from 'crypto'
import { performance } from 'node:perf_hooks'
import { basename, dirname, join, resolve, sep } from 'path'
import { FS_SCAN_CONCURRENCY, mapWithConcurrency, readSessionLines } from './fs-utils.js'
import { billableOutputTokens, calculateCost, calculateLocalModelSavings, getShortModelName, modelRowKey, isProxiedPath, getProxyPathsConfigHash, getModelAliasesConfigHash, getPriceOverridesConfigHash, getLocalModelSavingsConfigHash, recordedCostFallback } from './models.js'
import { billableOutputTokens, calculateCost, calculateLocalModelSavings, getShortModelName, modelRowKey, isProxiedPath, getProxyPathsConfigHash, getModelAliasesConfigHash, getPriceOverridesConfigHash, getLocalModelSavingsConfigHash, recordedCostFallback, getModelRoute } from './models.js'
import { resolveSubagentAttribution, sessionIdentity } from './sessions-report.js'
import { normalizeContentBlocks, flatSlice, flatString } from './content-utils.js'
import { discoverAllSessions, discoverAllSessionsWithFailures, getProvider } from './providers/index.js'
Expand Down Expand Up @@ -2066,9 +2066,11 @@ async function scanProjectDirs(
// mid-scan then resumes from a warm cache instead of re-parsing from zero.
onFileParsed?: () => Promise<void>,
readOnly = false,
preservedSourcePaths: string[] = [],
): Promise<ProjectSummary[]> {
const section = getOrCreateProviderSection(diskCache, 'claude')
const allDiscoveredFiles = new Set<string>()
for (const path of preservedSourcePaths) allDiscoveredFiles.add(path)

type FileInfo = { dirName: string; fp: NonNullable<Awaited<ReturnType<typeof fingerprintFile>>>; source?: SessionSourceMetadata }
const unchangedFiles: Array<{ filePath: string; dirName: string; source?: SessionSourceMetadata; cached: CachedFile }> = []
Expand Down Expand Up @@ -3597,7 +3599,9 @@ export async function parseProviderSources(

const wslHomesForOrphans = refreshWslHomesForOrphans(Object.keys(section.files), allDiscoveredFiles)

if (readOnly) {
// scanProjectDirs serves Claude's orphans; the Cowork ledger pass shares
// the claude section and would serve every transcript a second time.
if (readOnly && providerName !== 'claude') {
for (const [path, cached] of cacheEntriesInLoadOrder(diskCache, providerName)) {
if (allDiscoveredFiles.has(path)) continue
if (isCacheStub(cached)) {
Expand Down Expand Up @@ -3910,7 +3914,11 @@ export async function parseProviderSources(
markCacheDirty(diskCache, providerName)
}

if (!readOnly && !provider.durableSources) {
// Claude's transcript tree and Cowork's usage ledger share one provider
// cache section but are reconciled by two different parsers. The transcript
// scanner owns ordinary Claude-source eviction; the ledger pass must not
// interpret the transcript paths as ledger orphans and delete them.
if (!readOnly && !provider.durableSources && providerName !== 'claude') {
for (const cachedPath of Object.keys(section.files)) {
if (allDiscoveredFiles.has(cachedPath)) continue
const wslStatus = classifyWslCachePath(cachedPath, wslHomesForOrphans)
Expand Down Expand Up @@ -4213,7 +4221,7 @@ export async function parseProviderSources(

// Query-time: derive SessionSummary from all cached turns.
// Uses seenKeys (shared across providers) for cross-provider dedup.
const sessionMap = new Map<string, { project: string; projectPath?: string; workingDirectory?: string; turns: ClassifiedTurn[]; prLinks?: Set<string>; title?: string; lineage?: SessionLineage; agentName?: string; agentStartedAt?: string }>()
const sessionMap = new Map<string, { project: string; projectPath?: string; workingDirectory?: string; turns: ClassifiedTurn[]; prLinks?: Set<string>; title?: string; lineage?: SessionLineage; agentName?: string; agentStartedAt?: string; source?: SessionSourceMetadata }>()

for (const source of servedSources) {
const cachedFile = section.files[source.path]
Expand All @@ -4225,6 +4233,18 @@ export async function parseProviderSources(
}
continue
}
const sourceMetadata: SessionSourceMetadata | undefined = providerName === 'claude' &&
source.sourceId &&
source.sourceLabel &&
source.sourcePath &&
source.sourceKind
? {
id: source.sourceId,
label: source.sourceLabel,
path: source.sourcePath,
kind: source.sourceKind,
}
: undefined

for (const rawTurn of cachedFile.turns) {
const turn = serveTurn(rawTurn)
Expand Down Expand Up @@ -4279,6 +4299,7 @@ export async function parseProviderSources(
if (!existing.lineage && cachedFile.lineage) existing.lineage = cachedFile.lineage
if (!existing.agentName && cachedFile.agentName) existing.agentName = cachedFile.agentName
if (!existing.agentStartedAt && cachedFile.agentStartedAt) existing.agentStartedAt = cachedFile.agentStartedAt
if (!existing.source && sourceMetadata) existing.source = sourceMetadata
} else {
sessionMap.set(key, {
project,
Expand All @@ -4290,6 +4311,7 @@ export async function parseProviderSources(
...(cachedFile.lineage ? { lineage: cachedFile.lineage } : {}),
...(cachedFile.agentName ? { agentName: cachedFile.agentName } : {}),
...(cachedFile.agentStartedAt ? { agentStartedAt: cachedFile.agentStartedAt } : {}),
...(sourceMetadata ? { source: sourceMetadata } : {}),
})
}
}
Expand All @@ -4300,7 +4322,7 @@ export async function parseProviderSources(
// counted here so the monthly total never drops.
// WSL orphans join them for every provider: their distro being stopped must
// not drop the spend from the totals for the length of a shutdown (#1059).
if (provider.durableSources || Object.keys(section.files).some(isWslUncPath)) {
if (providerName !== 'claude' && (provider.durableSources || Object.keys(section.files).some(isWslUncPath))) {
for (const [cachedPath, cachedFile] of Object.entries(section.files)) {
if (!provider.durableSources && !isWslUncPath(cachedPath)) continue
if (allDiscoveredFiles.has(cachedPath)) continue // already counted above
Expand Down Expand Up @@ -4497,12 +4519,12 @@ export async function parseProviderSources(
// (first projectPath wins) before mergeProjectsByCrossProviderKey could see
// distinct abs identities.
const projectMap = new Map<string, { project: string; projectPath?: string; sessions: SessionSummary[] }>()
for (const [key, { project, projectPath, workingDirectory, turns, prLinks, title, lineage, agentName, agentStartedAt }] of sessionMap) {
for (const [key, { project, projectPath, workingDirectory, turns, prLinks, title, lineage, agentName, agentStartedAt, source }] of sessionMap) {
const sessionId = key.split(':')[1] ?? key
const assembledTurns = providerName === 'copilot'
? foldCopilotSupplementaryTurns(sessionId, turns, copilotRecon?.supplementaryStoreKeys)
: turns
const session = buildSessionSummary(sessionId, project, assembledTurns)
const session = buildSessionSummary(sessionId, project, assembledTurns, undefined, source)
const explicitLinks = new Set(assembledTurns.flatMap(turn => turn.prRefs ?? []))
for (const link of prLinks ?? []) explicitLinks.add(link)
if (explicitLinks.size) {
Expand Down Expand Up @@ -5449,6 +5471,88 @@ export function filterProjectsByCall(projects: ProjectSummary[], keep: (call: Pa
return filtered.sort((a, b) => b.totalCostUSD - a.totalCostUSD)
}

const CLAUDE_LEDGER_MATCH_WINDOW_MS = 30 * 1000

function claudeLedgerModelIdentity(model: string): { base: string; route?: string } {
const routed = getModelRoute(model)
return {
base: (routed?.baseModel ?? model).toLowerCase(),
...(routed?.variant ? { route: routed.variant.toLowerCase() } : {}),
}
}

function claudeLedgerCallMatchesTranscript(ledgerCall: ParsedApiCall, transcriptCall: ParsedApiCall): boolean {
const ledgerModel = claudeLedgerModelIdentity(ledgerCall.model)
const transcriptModel = claudeLedgerModelIdentity(transcriptCall.model)
if (ledgerModel.base !== transcriptModel.base) return false
// Matching normalizes only the Bedrock wrapper. Pricing still receives each
// call's raw model id. A bare transcript model can be paired with the routed
// ledger id; when both sides carry an explicit route, keep different
// geographic SKUs separate.
if (ledgerModel.route && transcriptModel.route && ledgerModel.route !== transcriptModel.route) return false

const ledgerUsage = ledgerCall.usage
const transcriptUsage = transcriptCall.usage
if (
ledgerUsage.inputTokens !== transcriptUsage.inputTokens ||
ledgerUsage.outputTokens !== transcriptUsage.outputTokens ||
ledgerUsage.cacheCreationInputTokens !== transcriptUsage.cacheCreationInputTokens ||
ledgerUsage.cacheReadInputTokens !== transcriptUsage.cacheReadInputTokens ||
ledgerUsage.webSearchRequests !== transcriptUsage.webSearchRequests
) {
return false
}

const ledgerTimestamp = Date.parse(ledgerCall.timestamp)
const transcriptTimestamp = Date.parse(transcriptCall.timestamp)
if (!Number.isFinite(ledgerTimestamp) || !Number.isFinite(transcriptTimestamp)) return false
return Math.abs(ledgerTimestamp - transcriptTimestamp) <= CLAUDE_LEDGER_MATCH_WINDOW_MS
}

/// Claude Desktop 3p writes a durable usage-ledger record alongside a Claude
/// transcript. The two records have different keys, so the normal parser
/// deduplication cannot see that they are the same request. Keep the ledger
/// record as the durable billing source and remove only a transcript call with
/// a one-to-one token/model/timestamp match.
function deduplicateClaudeDesktopTranscripts(
transcriptProjects: ProjectSummary[],
ledgerProjects: ProjectSummary[],
): ProjectSummary[] {
const ledgerCalls = ledgerProjects.flatMap(project =>
project.sessions.flatMap(session =>
session.turns.flatMap(turn => turn.assistantCalls)
)
)
if (ledgerCalls.length === 0) return transcriptProjects

const matchedLedgerIndexes = new Set<number>()
return filterProjectsByCall(transcriptProjects, transcriptCall => {
let bestIndex = -1
let bestDistance = Number.POSITIVE_INFINITY
let tied = false

for (let i = 0; i < ledgerCalls.length; i++) {
if (matchedLedgerIndexes.has(i)) continue
const ledgerCall = ledgerCalls[i]!
if (!claudeLedgerCallMatchesTranscript(ledgerCall, transcriptCall)) continue
const distance = Math.abs(Date.parse(ledgerCall.timestamp) - Date.parse(transcriptCall.timestamp))
if (distance < bestDistance) {
bestIndex = i
bestDistance = distance
tied = false
} else if (distance === bestDistance) {
tied = true
}
}

// An ambiguous match is safer left in the transcript source than silently
// removing an unrelated request.
if (bestIndex < 0 || tied) return true
matchedLedgerIndexes.add(bestIndex)
return false
})
}

export function filterProjectsByDateRange(projects: ProjectSummary[], dateRange: DateRange): ProjectSummary[] {
const sliceStartMs = dateRange.start.getTime()
const filtered: ProjectSummary[] = []
Expand Down Expand Up @@ -5636,7 +5740,7 @@ export async function computeCorpusFingerprint(providerFilter?: string): Promise
envFingerprinted.add(source.provider)
entries.push(`env:${source.provider}|${computeEnvFingerprint(source.provider)}`)
}
if (source.provider === 'claude') {
if (source.provider === 'claude' && source.sourceKind !== 'claude-desktop-ledger') {
for (const filePath of await collectJsonlFiles(source.path)) await record(filePath)
continue
}
Expand Down Expand Up @@ -6152,6 +6256,8 @@ async function runParseInner(
traceTiming('discovery', ` sources=${allSources.length}`)

const claudeSources = allSources.filter(s => s.provider === 'claude')
const claudeLedgerSources = claudeSources.filter(s => s.sourceKind === 'claude-desktop-ledger')
const claudeProjectSources = claudeSources.filter(s => s.sourceKind !== 'claude-desktop-ledger')
const nonClaudeSources = allSources.filter(s => s.provider !== 'claude')

const providerGroups = new Map<string, SessionSource[]>()
Expand Down Expand Up @@ -6186,7 +6292,7 @@ async function runParseInner(
...providerGroups.keys(),
] })

const claudeDirs = claudeSources.map(s => ({
const claudeDirs = claudeProjectSources.map(s => ({
path: s.path,
name: s.project,
source: s.sourceId && s.sourceLabel && s.sourcePath && s.sourceKind
Expand All @@ -6206,7 +6312,28 @@ async function runParseInner(
let claudeProjects: ProjectSummary[] = []
if (claudeInScope) {
try {
claudeProjects = await scanProjectDirs(claudeDirs, seenMsgIds, diskCache, dateRange, saveProgress, readOnly)
claudeProjects = await scanProjectDirs(
claudeDirs,
seenMsgIds,
diskCache,
dateRange,
saveProgress,
readOnly,
claudeLedgerSources.map(source => source.path),
)
if (claudeLedgerSources.length > 0) {
const ledgerProjects = await parseProviderSources(
'claude',
claudeLedgerSources,
seenKeys,
diskCache,
dateRange,
saveProgress,
readOnly,
)
claudeProjects = deduplicateClaudeDesktopTranscripts(claudeProjects, ledgerProjects)
claudeProjects.push(...ledgerProjects)
}
if (claudeSources.length > 0) emitScanProgress({ kind: 'provider', provider: 'claude', state: 'done', files: claudeSources.length })
} catch (err) {
if (!isPermissionError(err)) throw err
Expand Down
Loading