mirror of
https://github.com/AgentSeal/codeburn.git
synced 2026-10-19 18:23:41 +00:00
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:
parent
ec2822a147
commit
c87d299a74
10 changed files with 777 additions and 26 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
152
src/parser.ts
152
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<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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
},
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 })
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
67
tests/providers/claude-cowork-ledger-readonly.test.ts
Normal file
67
tests/providers/claude-cowork-ledger-readonly.test.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
343
tests/providers/claude-cowork-ledger.test.ts
Normal file
343
tests/providers/claude-cowork-ledger.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
Loading…
Add table
Add a link
Reference in a new issue