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.
This commit is contained in:
Abdul Kapti 2026-10-08 00:00:50 +05:30 • committed by GitHub
parent ec2822a147
commit c87d299a74
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 777 additions and 26 deletions

View file

@ -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/` |
@ -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 `<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
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

View file

@ -298,7 +298,10 @@ import type { DateRange, ProjectSummary } from './types.js'
// v60: Mistral Vibe 2.26 Unified Harness sessions (`unified/<id>/`) 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

View file

@ -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<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 }> = []
@ -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<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]
@ -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<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) {
@ -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<string, ParsedApiCall[]>()
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<ParsedApiCall>()
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<string, SessionSource[]>()
@ -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

View file

@ -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<string[]> {
return results
}
async function findDesktopUsageLedgerFiles(base: string): Promise<string[]> {
const results: string[] = []
async function walk(dir: string, depth: number): Promise<void> {
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<string, unknown>
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<string>,
): 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
// <desktopSessionsDir>/<appId>/<workspaceId>/local_<sessionId>/
@ -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<string>): SessionParser {
return createCoworkLedgerParser(source, seenKeys)
},
}

View file

@ -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

View file

@ -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

View file

@ -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 })
}
}

View file

@ -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', () => {

View file

@ -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<ReturnType<typeof parseAllSessions>>) {
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')
})
})

View file

@ -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<string, unknown> = {},
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<string> {
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<string> {
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<string> {
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)
})
})