From c87d299a74e553e87fcdabad6ec1f35fac623951 Mon Sep 17 00:00:00 2001 From: Abdul Kapti Date: Thu, 8 Oct 2026 00:00:50 +0530 Subject: [PATCH] fix(claude): discover macOS Claude-3p Cowork sessions (#1579) Read Claude Desktop 3p usage-ledger records for the Cowork and Code surfaces, drop a ledger call that matches a transcript call, daily cache v61. --- docs/providers/claude.md | 31 +- src/daily-cache.ts | 5 +- src/parser.ts | 152 +++++++- src/providers/claude.ts | 189 +++++++++- src/providers/types.ts | 2 +- src/types.ts | 2 +- src/usage-aggregator.ts | 9 +- tests/providers/claude-config-dirs.test.ts | 3 +- .../claude-cowork-ledger-readonly.test.ts | 67 ++++ tests/providers/claude-cowork-ledger.test.ts | 343 ++++++++++++++++++ 10 files changed, 777 insertions(+), 26 deletions(-) create mode 100644 tests/providers/claude-cowork-ledger-readonly.test.ts create mode 100644 tests/providers/claude-cowork-ledger.test.ts diff --git a/docs/providers/claude.md b/docs/providers/claude.md index 3edd405d..1840563b 100644 --- a/docs/providers/claude.md +++ b/docs/providers/claude.md @@ -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//LocalCache/Roaming/Claude/local-agent-mode-sessions/` | | Claude Desktop (Linux) | `~/.config/Claude/local-agent-mode-sessions/` | @@ -24,7 +25,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 @@ -37,10 +38,25 @@ unreadable Windows package directories are ignored. ## Storage format JSONL, one event per line, per session file. Sessions live under `/.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 +transcript call is kept, since it carries the project, tools, and turn +classification, and a ledger call is dropped only when its model, token +counts, and timestamp match a transcript 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 @@ -64,6 +80,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 diff --git a/src/daily-cache.ts b/src/daily-cache.ts index 83727cad..65a5821b 100644 --- a/src/daily-cache.ts +++ b/src/daily-cache.ts @@ -298,7 +298,10 @@ import type { DateRange, ProjectSummary } from './types.js' // v60: Mistral Vibe 2.26 Unified Harness sessions (`unified//`) are read; // days finalized while they were skipped re-derive. Calls only rise, so no // PENDING_REDERIVE_PROVIDER_VERSIONS entry is needed. -export const DAILY_CACHE_VERSION = 60 +// v61: #1579 Claude Desktop usage-ledger records (Claude-3p Cowork and Code) +// are read and de-duplicated against matching transcript calls. Calls only +// rise, so no PENDING_REDERIVE_PROVIDER_VERSIONS entry is needed. +export const DAILY_CACHE_VERSION = 61 const MIN_SUPPORTED_VERSION = 28 /// Providers whose per-day CALL COUNT means something different at diff --git a/src/parser.ts b/src/parser.ts index 864ef1ea..e76a3942 100644 --- a/src/parser.ts +++ b/src/parser.ts @@ -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, pricingModelAt, isProxiedPath, getProxyPathsConfigHash, getModelAliasesConfigHash, getPriceOverridesConfigHash, getLocalModelSavingsConfigHash, recordedCostFallback } from './models.js' +import { billableOutputTokens, calculateCost, calculateLocalModelSavings, getShortModelName, modelRowKey, pricingModelAt, 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' @@ -2066,9 +2066,11 @@ async function scanProjectDirs( // mid-scan then resumes from a warm cache instead of re-parsing from zero. onFileParsed?: () => Promise, readOnly = false, + preservedSourcePaths: string[] = [], ): Promise { const section = getOrCreateProviderSection(diskCache, 'claude') const allDiscoveredFiles = new Set() + for (const path of preservedSourcePaths) allDiscoveredFiles.add(path) type FileInfo = { dirName: string; fp: NonNullable>>; source?: SessionSourceMetadata } const unchangedFiles: Array<{ filePath: string; dirName: string; source?: SessionSourceMetadata; cached: CachedFile }> = [] @@ -3598,7 +3600,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)) { @@ -3911,7 +3915,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) @@ -4235,7 +4243,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; title?: string; lineage?: SessionLineage; agentName?: string; agentStartedAt?: string }>() + const sessionMap = new Map; title?: string; lineage?: SessionLineage; agentName?: string; agentStartedAt?: string; source?: SessionSourceMetadata }>() for (const source of servedSources) { const cachedFile = section.files[source.path] @@ -4247,6 +4255,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) @@ -4301,6 +4321,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, @@ -4312,6 +4333,7 @@ export async function parseProviderSources( ...(cachedFile.lineage ? { lineage: cachedFile.lineage } : {}), ...(cachedFile.agentName ? { agentName: cachedFile.agentName } : {}), ...(cachedFile.agentStartedAt ? { agentStartedAt: cachedFile.agentStartedAt } : {}), + ...(sourceMetadata ? { source: sourceMetadata } : {}), }) } } @@ -4322,7 +4344,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 @@ -4522,12 +4544,12 @@ export async function parseProviderSources( // (first projectPath wins) before mergeProjectsByCrossProviderKey could see // distinct abs identities. const projectMap = new Map() - 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) { @@ -5474,6 +5496,94 @@ 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 +} + +function claudeLedgerUsageKey(call: ParsedApiCall): string { + const u = call.usage + return `${claudeLedgerModelIdentity(call.model).base}|${u.inputTokens}|${u.outputTokens}|${u.cacheCreationInputTokens}|${u.cacheReadInputTokens}|${u.webSearchRequests}` +} + +/// Claude Desktop 3p writes a 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 +/// transcript call, which carries the project, tools and turn classification, +/// and drop a ledger call only on a one-to-one token/model/timestamp match. +/// A ledger call whose transcript was deleted stays counted. +function deduplicateClaudeDesktopLedger( + ledgerProjects: ProjectSummary[], + transcriptProjects: ProjectSummary[], +): ProjectSummary[] { + if (ledgerProjects.length === 0) return ledgerProjects + const transcriptCalls = new Map() + for (const project of transcriptProjects) { + for (const session of project.sessions) { + for (const turn of session.turns) { + for (const call of turn.assistantCalls) { + const key = claudeLedgerUsageKey(call) + const bucket = transcriptCalls.get(key) + if (bucket) bucket.push(call) + else transcriptCalls.set(key, [call]) + } + } + } + } + if (transcriptCalls.size === 0) return ledgerProjects + + const matched = new Set() + return filterProjectsByCall(ledgerProjects, ledgerCall => { + let best: ParsedApiCall | undefined + let bestDistance = Number.POSITIVE_INFINITY + for (const transcriptCall of transcriptCalls.get(claudeLedgerUsageKey(ledgerCall)) ?? []) { + if (matched.has(transcriptCall)) continue + if (!claudeLedgerCallMatchesTranscript(ledgerCall, transcriptCall)) continue + const distance = Math.abs(Date.parse(ledgerCall.timestamp) - Date.parse(transcriptCall.timestamp)) + if (distance < bestDistance) { + best = transcriptCall + bestDistance = distance + } + } + if (!best) return true + matched.add(best) + return false + }) +} + export function filterProjectsByDateRange(projects: ProjectSummary[], dateRange: DateRange): ProjectSummary[] { const sliceStartMs = dateRange.start.getTime() const filtered: ProjectSummary[] = [] @@ -5661,7 +5771,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 } @@ -6177,6 +6287,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() @@ -6211,7 +6323,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 @@ -6231,7 +6343,27 @@ 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.push(...deduplicateClaudeDesktopLedger(ledgerProjects, claudeProjects)) + } if (claudeSources.length > 0) emitScanProgress({ kind: 'provider', provider: 'claude', state: 'done', files: claudeSources.length }) } catch (err) { if (!isPermissionError(err)) throw err diff --git a/src/providers/claude.ts b/src/providers/claude.ts index 05b84592..500fd44f 100644 --- a/src/providers/claude.ts +++ b/src/providers/claude.ts @@ -5,9 +5,9 @@ import { homedir } from 'os' import { createHash } from 'crypto' import type { Provider, ProbeRoot, SessionSource, SessionParser } from './types.js' -import { getShortModelName } from '../models.js' +import { calculateCost, getShortModelName } from '../models.js' import { readConfig } from '../config.js' -import { FS_SCAN_CONCURRENCY, mapWithConcurrency } from '../fs-utils.js' +import { FS_SCAN_CONCURRENCY, mapWithConcurrency, readSessionLines } from '../fs-utils.js' import { wslHomes } from '../wsl.js' export type ClaudeConfigSource = { @@ -161,9 +161,16 @@ export function getDesktopSessionsDirs(): string[] { if (override) return cacheDesktopSessionsDirs(cacheKey, [override]) if (platform === 'darwin') { + const appSupport = join(homedir(), 'Library', 'Application Support') return cacheDesktopSessionsDirs( cacheKey, - [join(homedir(), 'Library', 'Application Support', 'Claude', 'local-agent-mode-sessions')], + [ + join(appSupport, 'Claude', 'local-agent-mode-sessions'), + // Current Claude Desktop 3p builds use a distinct Electron user-data + // directory for Cowork, while the Code surface continues to use the + // traditional ~/.claude project store. + join(appSupport, 'Claude-3p', 'local-agent-mode-sessions'), + ], ) } if (platform === 'win32') { @@ -235,6 +242,159 @@ async function findDesktopProjectDirs(base: string): Promise { return results } +async function findDesktopUsageLedgerFiles(base: string): Promise { + const results: string[] = [] + + async function walk(dir: string, depth: number): Promise { + if (depth > 8) return + const entries = await readdir(dir, { withFileTypes: true }).catch(() => []) + for (const entry of entries) { + if (!entry.isDirectory()) continue + if (entry.name === 'node_modules' || entry.name === '.git') continue + + const full = join(dir, entry.name) + if (entry.name === 'usage-ledger') { + const ledgerEntries = await readdir(full, { withFileTypes: true }).catch(() => []) + for (const ledgerEntry of ledgerEntries) { + if (ledgerEntry.isFile() && ledgerEntry.name.endsWith('.ndjson')) { + results.push(join(full, ledgerEntry.name)) + } + } + continue + } + await walk(full, depth + 1) + } + } + + await walk(base, 0) + return results +} + +type CoworkLedgerObject = Record + +function ledgerObject(value: unknown): CoworkLedgerObject | null { + return value !== null && typeof value === 'object' && !Array.isArray(value) + ? value as CoworkLedgerObject + : null +} + +function ledgerNumber(value: unknown): number | undefined { + if (typeof value === 'number' && Number.isFinite(value)) return value + if (typeof value === 'string' && value.trim() !== '') { + const parsed = Number(value) + if (Number.isFinite(parsed)) return parsed + } + return undefined +} + +function ledgerTimestamp(value: unknown): string | undefined { + const raw = ledgerNumber(value) + if (raw === undefined) return undefined + const milliseconds = raw >= 1_000_000_000_000 ? raw : raw * 1000 + const date = new Date(milliseconds) + return Number.isNaN(date.getTime()) ? undefined : date.toISOString() +} + +function createCoworkLedgerParser( + source: SessionSource, + seenKeys: Set, +): SessionParser { + return { + async *parse() { + if (source.sourceKind !== 'claude-desktop-ledger') return + + let lineNumber = 0 + for await (const rawLine of readSessionLines(source.path)) { + lineNumber++ + let parsed: unknown + try { + parsed = JSON.parse(rawLine) + } catch { + // Cowork appends records while a turn is in flight. Ignore a torn + // final line; valid records elsewhere in the ledger remain usable. + continue + } + + const record = ledgerObject(parsed) + const surface = record?.['surface'] + if (!record || (surface !== 'cowork' && surface !== 'code')) continue + + const sessionId = typeof record['sessionId'] === 'string' ? record['sessionId'] : '' + const timestamp = ledgerTimestamp(record['ts']) + if (!sessionId || !timestamp) continue + + const models = ledgerObject(record['models']) + if (!models) continue + + for (const [model, rawUsage] of Object.entries(models)) { + const usage = ledgerObject(rawUsage) + if (!usage) continue + + const inputTokens = Math.max(0, ledgerNumber(usage['inputTokens']) ?? 0) + const outputTokens = Math.max(0, ledgerNumber(usage['outputTokens']) ?? 0) + const cacheReadInputTokens = Math.max(0, ledgerNumber(usage['cacheReadTokens']) ?? 0) + const cacheCreationInputTokens = Math.max(0, ledgerNumber(usage['cacheWriteTokens']) ?? 0) + const webSearchRequests = Math.max(0, ledgerNumber(usage['webSearchRequests']) ?? 0) + const recordedCost = ledgerNumber(ledgerObject(usage['cost'])?.['usd']) + const estimatedCost = calculateCost( + model, + inputTokens, + outputTokens, + cacheCreationInputTokens, + cacheReadInputTokens, + webSearchRequests, + 'standard', + 0, + 'claude', + ) + + if ( + inputTokens === 0 && + outputTokens === 0 && + cacheReadInputTokens === 0 && + cacheCreationInputTokens === 0 && + recordedCost === undefined + ) { + continue + } + + const deduplicationKey = `claude-cowork-ledger:${source.path}:${sessionId}:${timestamp}:${model}` + if (seenKeys.has(deduplicationKey)) continue + seenKeys.add(deduplicationKey) + + yield { + provider: 'claude', + model, + inputTokens, + outputTokens, + cacheCreationInputTokens, + cacheReadInputTokens, + cachedInputTokens: cacheReadInputTokens, + reasoningTokens: 0, + webSearchRequests, + // Keep the ledger's dollar amount only as a fallback for a model + // CodeBurn cannot price. Known models must use CodeBurn's pricing + // and overrides so cached usage can be repriced later. + costUSD: estimatedCost, + ...(recordedCost !== undefined ? { fallbackCostUSD: recordedCost } : {}), + tools: [], + bashCommands: [], + skills: [], + subagentTypes: [], + timestamp, + speed: 'standard', + deduplicationKey, + userMessage: '', + sessionId, + project: surface === 'code' ? 'Claude Code' : 'Claude Cowork', + turnId: `${timestamp}:${lineNumber}`, + } + } + } + }, + } +} + // ── Cowork space resolution ──────────────────────────────────────────── // Claude Desktop's local-agent-mode creates one directory per session under // ///local_/ @@ -430,14 +590,29 @@ export const claude: Provider = { sourceKind: 'claude-desktop', }) } + + // Current Claude Desktop Cowork builds also keep metered usage in a + // workspace-level usage-ledger/*.ndjson file. These records are separate + // from the Claude Code JSONL transcripts above and must be exposed as + // leaf sources so the generic provider cache can fingerprint and parse + // them incrementally. + for (const ledgerPath of await findDesktopUsageLedgerFiles(desktopBase)) { + sources.push({ + path: ledgerPath, + project: 'Claude Cowork', + provider: 'claude', + sourceId: desktopSourceId, + sourceLabel: 'Claude Desktop', + sourcePath: desktopBase, + sourceKind: 'claude-desktop-ledger', + }) + } } return sources }, - createSessionParser(): SessionParser { - return { - async *parse() {}, - } + createSessionParser(source: SessionSource, seenKeys: Set): SessionParser { + return createCoworkLedgerParser(source, seenKeys) }, } diff --git a/src/providers/types.ts b/src/providers/types.ts index 51ce9c67..879ad094 100644 --- a/src/providers/types.ts +++ b/src/providers/types.ts @@ -8,7 +8,7 @@ export type SessionSource = { sourceId?: string sourceLabel?: string sourcePath?: string - sourceKind?: 'claude-config' | 'claude-desktop' + sourceKind?: 'claude-config' | 'claude-desktop' | 'claude-desktop-ledger' // OMP stores each crewmate transcript under its parent-session directory. // These fields retain that per-agent identity through the shared cache path. agentName?: string diff --git a/src/types.ts b/src/types.ts index 09d7ad26..aa76b287 100644 --- a/src/types.ts +++ b/src/types.ts @@ -215,7 +215,7 @@ export type SessionSourceMetadata = { id: string label: string path: string - kind: 'claude-config' | 'claude-desktop' + kind: 'claude-config' | 'claude-desktop' | 'claude-desktop-ledger' } /// Provider-recorded parent-child session lineage (CB-1, slice 1). The diff --git a/src/usage-aggregator.ts b/src/usage-aggregator.ts index 25c00db9..0f0d3e86 100644 --- a/src/usage-aggregator.ts +++ b/src/usage-aggregator.ts @@ -335,7 +335,14 @@ async function claudeConfigSelector(projects: ProjectSummary[], selectedId?: str ).some(Boolean) if ((await getClaudeConfigDirs()).length > 1 || desktopExists) { for (const source of await claude.discoverSessions()) { - if ((source.sourceKind !== 'claude-config' && source.sourceKind !== 'claude-desktop') || !source.sourceId || !source.sourceLabel || !source.sourcePath) continue + if ( + (source.sourceKind !== 'claude-config' && + source.sourceKind !== 'claude-desktop' && + source.sourceKind !== 'claude-desktop-ledger') || + !source.sourceId || + !source.sourceLabel || + !source.sourcePath + ) continue if (!byId.has(source.sourceId)) byId.set(source.sourceId, { id: source.sourceId, label: source.sourceLabel, path: source.sourcePath }) } } diff --git a/tests/providers/claude-config-dirs.test.ts b/tests/providers/claude-config-dirs.test.ts index 567bca34..2567d477 100644 --- a/tests/providers/claude-config-dirs.test.ts +++ b/tests/providers/claude-config-dirs.test.ts @@ -326,7 +326,7 @@ describe('claude provider — Desktop sessions dir', () => { }) }) - it('returns one platform root on darwin and linux without including MSIX packages', async () => { + it('returns both macOS Desktop roots and one Linux root without including MSIX packages', async () => { const localAppData = join(tmpRoot, 'local-profile') await makeMsixSessionsDir(localAppData, 'Claude_crossplatform3b7j') process.env['LOCALAPPDATA'] = localAppData @@ -334,6 +334,7 @@ describe('claude provider — Desktop sessions dir', () => { withPlatform('darwin', () => { expect(getDesktopSessionsDirs()).toEqual([ resolve(join(homedir(), 'Library', 'Application Support', 'Claude', 'local-agent-mode-sessions')), + resolve(join(homedir(), 'Library', 'Application Support', 'Claude-3p', 'local-agent-mode-sessions')), ]) }) withPlatform('linux', () => { diff --git a/tests/providers/claude-cowork-ledger-readonly.test.ts b/tests/providers/claude-cowork-ledger-readonly.test.ts new file mode 100644 index 00000000..ce81da82 --- /dev/null +++ b/tests/providers/claude-cowork-ledger-readonly.test.ts @@ -0,0 +1,67 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { mkdir, mkdtemp, rm, writeFile } from 'fs/promises' +import { tmpdir } from 'os' +import { join } from 'path' + +vi.mock('../../src/cache-refresh-lock.js', () => ({ + acquireCacheRefreshLock: async () => ({ outcome: 'timed-out' as const }), +})) + +import { clearSessionCache, parseAllSessions } from '../../src/parser.js' + +let root: string + +beforeEach(async () => { + clearSessionCache() + root = await mkdtemp(join(tmpdir(), 'cb-cowork-ledger-readonly-')) + const home = join(root, 'home') + const project = join(home, 'projects', 'proj') + await mkdir(project, { recursive: true }) + await writeFile(join(project, 'sess.jsonl'), JSON.stringify({ + type: 'assistant', + sessionId: 'sess', + timestamp: '2026-05-15T10:00:00Z', + cwd: '/tmp/proj', + message: { + id: 'msg-1', type: 'message', role: 'assistant', model: 'claude-sonnet-4-5', + content: [], usage: { input_tokens: 100, output_tokens: 50 }, + }, + }) + '\n') + const ledgerDir = join(home, 'desktop-sessions', 'app', 'workspace', 'usage-ledger') + await mkdir(ledgerDir, { recursive: true }) + await writeFile(join(ledgerDir, '2026-05-15.ndjson'), JSON.stringify({ + ts: Date.parse('2026-05-15T11:00:00Z'), + surface: 'cowork', + sessionId: 'cowork-1', + models: { 'claude-sonnet-4-5': { inputTokens: 10, outputTokens: 20, cost: { usd: 0.25 } } }, + }) + '\n') + process.env['CLAUDE_CONFIG_DIR'] = home + process.env['CODEBURN_CACHE_DIR'] = join(root, 'cache') + process.env['CODEBURN_DESKTOP_SESSIONS_DIR'] = join(home, 'desktop-sessions') +}) + +afterEach(async () => { + clearSessionCache() + await rm(root, { recursive: true, force: true }) +}) + +function totals(projects: Awaited>) { + return { + cost: projects.reduce((sum, p) => sum + p.totalCostUSD, 0), + calls: projects.reduce((sum, p) => sum + p.totalApiCalls, 0), + names: projects.map(p => p.project).sort(), + } +} + +describe('Cowork ledger in a read-only refresh', () => { + it('does not re-serve Claude transcripts through the ledger pass', async () => { + const warm = totals(await parseAllSessions(undefined, 'claude')) + expect(warm.calls).toBe(2) + + clearSessionCache() + const readOnly = totals(await parseAllSessions(undefined, 'claude')) + + expect(readOnly).toEqual(warm) + expect(readOnly.names).not.toContain('claude') + }) +}) diff --git a/tests/providers/claude-cowork-ledger.test.ts b/tests/providers/claude-cowork-ledger.test.ts new file mode 100644 index 00000000..4cbe84ed --- /dev/null +++ b/tests/providers/claude-cowork-ledger.test.ts @@ -0,0 +1,343 @@ +import { mkdtemp, mkdir, rm, utimes, writeFile } from 'fs/promises' +import { join } from 'path' +import { tmpdir } from 'os' + +import { afterEach, beforeEach, describe, expect, it } from 'vitest' + +import { clearSessionCache, filterProjectsByClaudeConfigSource, parseAllSessions } from '../../src/parser.js' +import { calculateCost, setPriceOverrides } from '../../src/models.js' +import { claude } from '../../src/providers/claude.js' +import type { SessionSource } from '../../src/providers/types.js' +import type { DateRange } from '../../src/types.js' + +let root: string +const savedEnv = { + CLAUDE_CONFIG_DIR: process.env['CLAUDE_CONFIG_DIR'], + CLAUDE_CONFIG_DIRS: process.env['CLAUDE_CONFIG_DIRS'], + CODEBURN_CACHE_DIR: process.env['CODEBURN_CACHE_DIR'], + CODEBURN_DESKTOP_SESSIONS_DIR: process.env['CODEBURN_DESKTOP_SESSIONS_DIR'], +} + +beforeEach(async () => { + clearSessionCache() + setPriceOverrides({ + 'us.anthropic.claude-sonnet-5': { + input: 1, + output: 2, + cacheRead: 3, + cacheCreation: 4, + }, + }) + root = await mkdtemp(join(tmpdir(), 'codeburn-claude-cowork-ledger-')) + process.env['CLAUDE_CONFIG_DIR'] = join(root, 'claude-config') + process.env['CODEBURN_CACHE_DIR'] = join(root, 'cache') + process.env['CODEBURN_DESKTOP_SESSIONS_DIR'] = join(root, 'desktop') + delete process.env['CLAUDE_CONFIG_DIRS'] + await mkdir(process.env['CLAUDE_CONFIG_DIR'], { recursive: true }) +}) + +afterEach(async () => { + clearSessionCache() + setPriceOverrides({}) + for (const [key, value] of Object.entries(savedEnv)) { + if (value === undefined) delete process.env[key] + else process.env[key] = value + } + await rm(root, { recursive: true, force: true }) +}) + +function range(day: string): DateRange { + return { + start: new Date(`${day}T00:00:00.000Z`), + end: new Date(`${day}T23:59:59.999Z`), + } +} + +function ledgerLine( + ts: string, + sessionId: string, + surface: string, + costUSD: number, + overrides: Record = {}, + model = 'us.anthropic.claude-sonnet-5', +): string { + return JSON.stringify({ + ts: Date.parse(ts), + surface, + sessionId, + isError: false, + models: { + [model]: { + inputTokens: 10, + outputTokens: 20, + cacheReadTokens: 30, + cacheWriteTokens: 40, + webSearchRequests: 0, + cost: { usd: costUSD, basis: 'list' }, + ...overrides, + }, + }, + }) +} + +async function makeLedger(lines: string[], timestamp = '2099-05-10T12:00:00.000Z'): Promise { + const path = join( + process.env['CODEBURN_DESKTOP_SESSIONS_DIR']!, + 'app', + 'workspace', + 'usage-ledger', + '2099-05-10.ndjson', + ) + await mkdir(join(path, '..'), { recursive: true }) + await writeFile(path, `${lines.join('\n')}\n`) + const mtime = new Date(timestamp) + await utimes(path, mtime, mtime) + return path +} + +async function makeTranscript(timestamp = '2099-05-10T12:00:00.120Z', model = 'claude-sonnet-5'): Promise { + const projectDir = join(process.env['CLAUDE_CONFIG_DIR']!, 'projects', '-Users-test') + const path = join(projectDir, 'code-session.jsonl') + await mkdir(projectDir, { recursive: true }) + await writeFile(path, JSON.stringify({ + type: 'assistant', + sessionId: 'code-session', + timestamp, + cwd: '/Users/test', + message: { + id: 'message-code', + type: 'message', + role: 'assistant', + model, + content: [], + usage: { + input_tokens: 10, + output_tokens: 20, + cache_creation_input_tokens: 40, + cache_read_input_tokens: 30, + }, + }, + }) + '\n') + const mtime = new Date(timestamp) + await utimes(path, mtime, mtime) + return path +} + +async function makeCoworkTranscript(timestamp = '2099-05-10T12:02:00.120Z', model = 'claude-sonnet-5'): Promise { + const projectDir = join( + process.env['CODEBURN_DESKTOP_SESSIONS_DIR']!, + 'app', + 'workspace', + 'local_cowork-session', + '.claude', + 'projects', + '-Users-test', + ) + const path = join(projectDir, 'cowork-session.jsonl') + await mkdir(projectDir, { recursive: true }) + await writeFile(path, JSON.stringify({ + type: 'assistant', + sessionId: 'cowork-transcript-session', + timestamp, + message: { + id: 'message-cowork', + type: 'message', + role: 'assistant', + model, + content: [], + usage: { + input_tokens: 10, + output_tokens: 20, + cache_creation_input_tokens: 40, + cache_read_input_tokens: 30, + }, + }, + }) + '\n') + const mtime = new Date(timestamp) + await utimes(path, mtime, mtime) + return path +} + +function source(path: string): SessionSource { + return { + path, + project: 'Claude Cowork', + provider: 'claude', + sourceId: 'claude-desktop:test', + sourceLabel: 'Claude Desktop', + sourcePath: process.env['CODEBURN_DESKTOP_SESSIONS_DIR'], + sourceKind: 'claude-desktop-ledger', + } +} + +describe('Claude Cowork usage ledger', () => { + it('discovers usage-ledger NDJSON files as Claude Desktop sources', async () => { + const path = await makeLedger([ledgerLine('2099-05-10T12:00:00.000Z', 'session-1', 'cowork', 0.25)]) + + const sources = await claude.discoverSessions() + + expect(sources).toContainEqual(expect.objectContaining({ + path, + provider: 'claude', + sourceKind: 'claude-desktop-ledger', + sourceLabel: 'Claude Desktop', + })) + }) + + it('parses Cowork and Code records with surface labels and CodeBurn pricing', async () => { + const path = await makeLedger([ + ledgerLine('2099-05-10T12:00:00.000Z', 'session-1', 'cowork', 0.25), + ledgerLine('2099-05-10T12:01:00.000Z', 'session-2', 'code', 0.99), + 'not-json', + ]) + + const calls = [] + const parser = claude.createSessionParser(source(path), new Set(), range('2099-05-10')) + for await (const call of parser.parse()) calls.push(call) + + expect(calls).toHaveLength(2) + expect(calls[0]).toMatchObject({ + provider: 'claude', + model: 'us.anthropic.claude-sonnet-5', + sessionId: 'session-1', + project: 'Claude Cowork', + inputTokens: 10, + outputTokens: 20, + cacheReadInputTokens: 30, + cacheCreationInputTokens: 40, + costUSD: calculateCost('us.anthropic.claude-sonnet-5', 10, 20, 40, 30, 0, 'standard', 0, 'claude'), + }) + expect(calls[0]).not.toHaveProperty('costFromBilling') + expect(calls[1]).toMatchObject({ + sessionId: 'session-2', + project: 'Claude Code', + costUSD: calls[0]!.costUSD, + }) + expect(calls[1]).not.toHaveProperty('costFromBilling') + }) + + it('merges ledger usage into the normal Claude report', async () => { + await makeLedger([ledgerLine('2099-05-10T12:00:00.000Z', 'session-1', 'cowork', 0.25)]) + + const projects = await parseAllSessions(range('2099-05-10'), 'claude') + const cowork = projects.find(project => project.project === 'Claude Cowork') + + expect(cowork).toMatchObject({ + totalCostUSD: calculateCost('us.anthropic.claude-sonnet-5', 10, 20, 40, 30, 0, 'standard', 0, 'claude'), + totalApiCalls: 1, + sessions: [{ sessionId: 'session-1', totalCostUSD: calculateCost('us.anthropic.claude-sonnet-5', 10, 20, 40, 30, 0, 'standard', 0, 'claude') }], + }) + }) + + it('counts a matching transcript and ledger request once, then keeps it after transcript deletion', async () => { + const transcriptPath = await makeTranscript() + await makeLedger([ledgerLine('2099-05-10T12:00:00.000Z', 'session-code', 'code', 0.99)]) + + const expectedCost = calculateCost('us.anthropic.claude-sonnet-5', 10, 20, 40, 30, 0, 'standard', 0, 'claude') + const first = await parseAllSessions(range('2099-05-10'), 'claude') + expect(first.reduce((total, project) => total + project.totalApiCalls, 0)).toBe(1) + expect(first.flatMap(project => project.sessions.map(session => session.sessionId))).toEqual(['code-session']) + + await rm(transcriptPath) + clearSessionCache() + + const afterDeletion = await parseAllSessions(range('2099-05-10'), 'claude') + expect(afterDeletion.reduce((total, project) => total + project.totalApiCalls, 0)).toBe(1) + expect(afterDeletion).toContainEqual(expect.objectContaining({ + project: 'Claude Code', + totalApiCalls: 1, + totalCostUSD: expectedCost, + })) + }) + + it('keeps ledger usage in Claude Desktop source-filtered views', async () => { + await makeLedger([ledgerLine('2099-05-10T12:00:00.000Z', 'session-code', 'code', 0.99)]) + + const projects = await parseAllSessions(range('2099-05-10'), 'claude') + const ledgerSource = (await claude.discoverSessions()).find(source => + source.sourceKind === 'claude-desktop-ledger' + ) + expect(ledgerSource?.sourceId).toBeTruthy() + + const filtered = filterProjectsByClaudeConfigSource(projects, ledgerSource!.sourceId!) + const code = filtered.find(project => project.project === 'Claude Code') + expect(code).toMatchObject({ + totalApiCalls: 1, + sessions: [{ + source: { + id: ledgerSource!.sourceId, + kind: 'claude-desktop-ledger', + }, + }], + }) + }) + + it('deduplicates a matching Cowork transcript and ledger request', async () => { + await makeCoworkTranscript() + await makeLedger([ledgerLine('2099-05-10T12:02:00.000Z', 'session-cowork', 'cowork', 0.99)]) + + const projects = await parseAllSessions(range('2099-05-10'), 'claude') + expect(projects.reduce((total, project) => total + project.totalApiCalls, 0)).toBe(1) + expect(projects.flatMap(project => project.sessions.map(session => session.sessionId))).toEqual(['cowork-session']) + }) + + it('caches the whole ledger file even when the first parse is range-limited', async () => { + await makeLedger([ + ledgerLine('2099-05-10T12:00:00.000Z', 'session-1', 'cowork', 0.25), + ledgerLine('2099-05-11T12:00:00.000Z', 'session-2', 'cowork', 0.25), + ]) + + await parseAllSessions(range('2099-05-10'), 'claude') + clearSessionCache() + const nextDay = await parseAllSessions(range('2099-05-11'), 'claude') + + expect(nextDay.flatMap(project => project.sessions.map(session => session.sessionId))).toEqual(['session-2']) + }) + + it('does not deduplicate calls more than 30 seconds apart', async () => { + await makeTranscript('2099-05-10T12:00:30.001Z') + await makeLedger([ledgerLine('2099-05-10T12:00:00.000Z', 'session-code', 'code', 0.99)]) + + const projects = await parseAllSessions(range('2099-05-10'), 'claude') + expect(projects.reduce((total, project) => total + project.totalApiCalls, 0)).toBe(2) + }) + + it('keeps different explicit model routes separate during deduplication', async () => { + await makeTranscript('2099-05-10T12:00:00.120Z', 'us.anthropic.claude-sonnet-5') + await makeLedger([ + ledgerLine( + '2099-05-10T12:00:00.000Z', + 'session-code', + 'code', + 0.99, + {}, + 'global.anthropic.claude-sonnet-5', + ), + ]) + + const projects = await parseAllSessions(range('2099-05-10'), 'claude') + expect(projects.reduce((total, project) => total + project.totalApiCalls, 0)).toBe(2) + }) + + it('reprices cached ledger usage when CodeBurn overrides change', async () => { + await makeLedger([ledgerLine('2099-05-10T12:00:00.000Z', 'session-code', 'code', 0.99)]) + + const first = await parseAllSessions(range('2099-05-10'), 'claude') + const firstCost = first.find(project => project.project === 'Claude Code')!.totalCostUSD + + setPriceOverrides({ + 'us.anthropic.claude-sonnet-5': { + input: 10, + output: 20, + cacheRead: 30, + cacheCreation: 40, + }, + }) + clearSessionCache() + + const second = await parseAllSessions(range('2099-05-10'), 'claude') + const expectedCost = calculateCost('us.anthropic.claude-sonnet-5', 10, 20, 40, 30, 0, 'standard', 0, 'claude') + expect(second.find(project => project.project === 'Claude Code')!.totalCostUSD).toBe(expectedCost) + expect(second.find(project => project.project === 'Claude Code')!.totalCostUSD).toBeGreaterThan(firstCost) + }) +})