fix(claude): attribute each call to the project of its own cwd (#1689)

* fix(claude): attribute each call to the project of its own cwd

* fix(claude): keep subfolder cwds in their project, count and show a split session once

* fix(claude): a deleted subfolder stays its own project
This commit is contained in:
Resham Joshi 2026-10-08 06:28:32 +02:00 • committed by GitHub
parent d3fecffa9a
commit d49a6a4eba
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
32 changed files with 242050 additions and 69 deletions

24
dist-b81e58e/cli.js Executable file
View file

@ -0,0 +1,24 @@
#!/usr/bin/env node
// This launcher must stay parseable by Node 18. Do NOT add static imports.
const [major, minor] = process.versions.node.split('.').map(Number)
if (major < 22 || (major === 22 && minor < 13)) {
process.stderr.write(
`codeburn requires Node.js >= 22.13.0 (current: ${process.version})\n` +
'Upgrade at https://nodejs.org/\n',
)
process.exit(1)
}
// React (loaded by Ink) picks its development build unless NODE_ENV is exactly
// "production". That build records a performance.measure() entry on every
// render into Node's user-timing buffer, which nothing ever trims, so an
// interactive dashboard left open for days grows until V8 aborts with
// "JavaScript heap out of memory". Default to the production build; an
// explicit NODE_ENV in the shell still wins. This must run before main.js is
// imported because React reads NODE_ENV at module load.
if (!process.env.NODE_ENV) process.env.NODE_ENV = 'production'
import('./main.js').catch((err) => {
process.stderr.write(String(err?.message ?? err) + '\n')
process.exit(1)
})

62384
dist-b81e58e/main.js Normal file

File diff suppressed because one or more lines are too long

1
dist-b81e58e/main.js.map Normal file

File diff suppressed because one or more lines are too long

18077
dist-b81e58e/parse-worker.js Normal file

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

24
dist-main/cli.js Executable file
View file

@ -0,0 +1,24 @@
#!/usr/bin/env node
// This launcher must stay parseable by Node 18. Do NOT add static imports.
const [major, minor] = process.versions.node.split('.').map(Number)
if (major < 22 || (major === 22 && minor < 13)) {
process.stderr.write(
`codeburn requires Node.js >= 22.13.0 (current: ${process.version})\n` +
'Upgrade at https://nodejs.org/\n',
)
process.exit(1)
}
// React (loaded by Ink) picks its development build unless NODE_ENV is exactly
// "production". That build records a performance.measure() entry on every
// render into Node's user-timing buffer, which nothing ever trims, so an
// interactive dashboard left open for days grows until V8 aborts with
// "JavaScript heap out of memory". Default to the production build; an
// explicit NODE_ENV in the shell still wins. This must run before main.js is
// imported because React reads NODE_ENV at module load.
if (!process.env.NODE_ENV) process.env.NODE_ENV = 'production'
import('./main.js').catch((err) => {
process.stderr.write(String(err?.message ?? err) + '\n')
process.exit(1)
})

62202
dist-main/main.js Normal file

File diff suppressed because one or more lines are too long

1
dist-main/main.js.map Normal file

File diff suppressed because one or more lines are too long

18066
dist-main/parse-worker.js Normal file

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

24
dist-trial/cli.js Executable file
View file

@ -0,0 +1,24 @@
#!/usr/bin/env node
// This launcher must stay parseable by Node 18. Do NOT add static imports.
const [major, minor] = process.versions.node.split('.').map(Number)
if (major < 22 || (major === 22 && minor < 13)) {
process.stderr.write(
`codeburn requires Node.js >= 22.13.0 (current: ${process.version})\n` +
'Upgrade at https://nodejs.org/\n',
)
process.exit(1)
}
// React (loaded by Ink) picks its development build unless NODE_ENV is exactly
// "production". That build records a performance.measure() entry on every
// render into Node's user-timing buffer, which nothing ever trims, so an
// interactive dashboard left open for days grows until V8 aborts with
// "JavaScript heap out of memory". Default to the production build; an
// explicit NODE_ENV in the shell still wins. This must run before main.js is
// imported because React reads NODE_ENV at module load.
if (!process.env.NODE_ENV) process.env.NODE_ENV = 'production'
import('./main.js').catch((err) => {
process.stderr.write(String(err?.message ?? err) + '\n')
process.exit(1)
})

62649
dist-trial/main.js Normal file

File diff suppressed because one or more lines are too long

1
dist-trial/main.js.map Normal file

File diff suppressed because one or more lines are too long

18082
dist-trial/parse-worker.js Normal file

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -9,7 +9,7 @@
// supplementary accounting must never be filtered out, only weightless.
type WeightedCall = { supplementaryAccounting?: boolean; requestCount?: number }
type WeightedTurn = { assistantCalls: readonly WeightedCall[] }
type WeightedTurn = { assistantCalls: readonly WeightedCall[]; projectContinuation?: boolean }
/** True when the call is a real request; false for supplementary accounting (weight 0). */
export function isBehavioralCall(call: WeightedCall): boolean {
@ -37,6 +37,7 @@ export function behavioralCallCount(calls: readonly WeightedCall[]): number {
/** True when the turn holds at least one behavioral call — supplementary-only turns add no turn/edit weight. */
export function isBehavioralTurn(turn: WeightedTurn): boolean {
if (turn.projectContinuation) return false
return turn.assistantCalls.some(call => !call.supplementaryAccounting)
}

View file

@ -331,7 +331,12 @@ import type { DateRange, ProjectSummary } from './types.js'
// v68: Kimi Code sessions whose state.json carries `cwd` but no `workDir` land
// on that folder. Only the per-project split moves; cost, tokens and calls are
// unchanged, so the bump re-derives surviving days.
export const DAILY_CACHE_VERSION = 68
// v69: Claude calls go to the project of their own cwd, so a session that
// moved folders splits across projects per call. Settled days re-derive their
// project rows; provider call counts and cost are unchanged, so no
// PENDING_REDERIVE_PROVIDER_VERSIONS entry is needed. Days whose transcripts
// are gone keep their old single-project attribution.
export const DAILY_CACHE_VERSION = 69
const MIN_SUPPORTED_VERSION = 28
/// Providers whose per-day CALL COUNT means something different at

View file

@ -27,6 +27,7 @@ import { planDisplayName } from './plans.js'
import { formatDayRangeLabel, getDateRange, parseDayFlag, PERIODS, PERIOD_LABELS, shiftDay, type Period } from './cli-date.js'
import { BSU, patchStdoutForWindows } from './ink-win.js'
import { startUserTimingGuard } from './user-timing-guard.js'
import { countSessions } from './session-output.js'
type View = 'dashboard' | 'optimize' | 'compare'
@ -604,7 +605,7 @@ function Overview({ projects, label, width, planUsages, durable }: { projects: P
const totalCost = durable ? durable.cost : projects.reduce((s, p) => s + p.totalCostUSD, 0)
const totalSavings = durable ? durable.savingsUSD : projects.reduce((s, p) => s + p.totalSavingsUSD, 0)
const totalCalls = durable ? durable.calls : projects.reduce((s, p) => s + p.totalApiCalls, 0)
const totalSessions = durable ? durable.sessions : projects.reduce((s, p) => s + p.sessions.length, 0)
const totalSessions = durable ? durable.sessions : countSessions(projects)
const allSessions = projects.flatMap(p => p.sessions)
const totalInput = durable ? durable.inputTokens : allSessions.reduce((s, sess) => s + sess.totalInputTokens, 0)
const totalOutput = durable ? durable.outputTokens : allSessions.reduce((s, sess) => s + sess.totalOutputTokens, 0)

View file

@ -1,6 +1,6 @@
import { projectDayKey, type DailyEntry, type ProjectDayStats, type ProviderDaySlice } from './daily-cache.js'
import type { PeriodData } from './menubar-json.js'
import { CATEGORY_LABELS, type ProjectSummary, type TaskCategory } from './types.js'
import { CATEGORY_LABELS, type ProjectSummary, type SessionSummary, type TaskCategory } from './types.js'
import { behavioralCallWeight, isBehavioralTurn } from './behavioral-weight.js'
import { billableOutputTokens, modelRowKey } from './models.js'
@ -78,17 +78,31 @@ export function aggregateProjectsIntoDays(projects: ProjectSummary[], dateKeyFn:
return p
}
// A session split across projects counts once per day and provider, on its
// earliest slice; each project it touched still counts it.
const splitHeads = new Map<string, SessionSummary>()
for (const project of projects) {
for (const session of project.sessions) {
if (!session.projectSplit) continue
const key = `${session.projectSplit.primaryProjectPath}\0${session.sessionId}`
const head = splitHeads.get(key)
if (!head || session.firstTimestamp < head.firstTimestamp) splitHeads.set(key, session)
}
}
for (const project of projects) {
for (const session of project.sessions) {
const sessionDate = dateKeyFn(session.firstTimestamp)
const sessionDay = ensure(sessionDate)
sessionDay.sessions += 1
const countsOnce = !session.projectSplit
|| splitHeads.get(`${session.projectSplit.primaryProjectPath}\0${session.sessionId}`) === session
if (countsOnce) sessionDay.sessions += 1
ensureProject(sessionDay, session.project, project.projectPath).sessions += 1
// A session belongs to exactly one provider; its calls all carry it.
const sessionProvider = session.turns.flatMap(t => t.assistantCalls)[0]?.provider
if (sessionProvider) {
const slice = ensureSlice(sessionDay, sessionProvider)
slice.sessions! += 1
if (countsOnce) slice.sessions! += 1
ensureProject(slice, session.project, project.projectPath).sessions += 1
}

View file

@ -6,7 +6,7 @@ import { getCurrency, convertCost, roundForActiveCurrency } from './currency.js'
import { dateKey } from './day-aggregator.js'
import { behavioralCallWeight, behavioralTurnCount } from './behavioral-weight.js'
import { aggregateModelEfficiency } from './model-efficiency.js'
import { callBillableOutputTokens, sessionModelBillableOutputTokens } from './session-output.js'
import { callBillableOutputTokens, countSessions, sessionModelBillableOutputTokens } from './session-output.js'
import { findUnpricedModels } from './models.js'
function escCsv(s: string): string {
@ -330,7 +330,7 @@ function buildSummaryRows(periods: PeriodExport[]): Row[] {
const cost = p.projects.reduce((s, proj) => s + proj.totalCostUSD, 0)
const savings = p.projects.reduce((s, proj) => s + proj.totalSavingsUSD, 0)
const calls = p.projects.reduce((s, proj) => s + proj.totalApiCalls, 0)
const sessions = p.projects.reduce((s, proj) => s + proj.sessions.length, 0)
const sessions = countSessions(p.projects)
const projectCount = p.projects.filter(proj => proj.totalCostUSD > 0 || proj.totalSavingsUSD > 0).length
return {
Period: p.label,

View file

@ -7,7 +7,7 @@ import { cachedProjectIdentitiesForRange } from './daily-cache.js'
import { reportUnmatchedProjectPatterns } from './project-filter-warnings.js'
import { getVercelGatewayApiKey } from './providers/vercel-gateway.js'
import { BILLING_FILTER_VALUES, ROUTE_FILTER_VALUES, filterProjectsByBillingRoute } from './billing-filter.js'
import { AGGREGATE_ONLY_PROVIDER, aggregateOnlyCostUSD, excludesAggregateOnlyProviders, parseAllSessions, filterProjectsByName, filterProjectsByDateRange, clearSessionCache, setInteractiveScanUI, computeCorpusFingerprint, isSessionHydrationComplete, startProgressKeepalive, stopProgressKeepalive, withLoadWindow } from './parser.js'
import { AGGREGATE_ONLY_PROVIDER, aggregateOnlyCostUSD, excludesAggregateOnlyProviders, parseAllSessions, filterProjectsByName, filterProjectsByDateRange, mergeProjectSplits, clearSessionCache, setInteractiveScanUI, computeCorpusFingerprint, isSessionHydrationComplete, startProgressKeepalive, stopProgressKeepalive, withLoadWindow } from './parser.js'
import { allProviderNames, getAllProviders, safeDiscoverSessions } from './providers/index.js'
import { getProvider } from './providers/index.js'
import { getClaudeConfigDirs, getDesktopSessionsDirs } from './providers/claude.js'
@ -770,7 +770,7 @@ function buildJsonReport(projects: ProjectSummary[], period: string, periodKey:
const sortedMap = (m: Record<string, number>) =>
Object.entries(m).sort(([, a], [, b]) => b - a).map(([name, calls]) => ({ name, calls }))
const topSessions = projects
const topSessions = mergeProjectSplits(projects)
.flatMap(p => p.sessions.map(s => ({
project: p.project,
sessionId: s.sessionId,
@ -2973,10 +2973,10 @@ program
const parsed = await parseAllSessions(range, opts.provider)
await reportUnmatchedProjectPatterns(parsed, opts.project, opts.exclude, () => cachedProjectIdentitiesForRange(range))
await reportExcludedGatewayCost(range, opts.provider)
const projects = filterProjectsByBillingRoute(
const projects = mergeProjectSplits(filterProjectsByBillingRoute(
filterProjectsByName(parsed, opts.project, opts.exclude),
{ route: opts.route, billing: opts.billing },
)
))
if (opts.byPr) {
const { rows: prRows, totals } = buildPrAttribution(projects)
if (opts.format === 'json') {

View file

@ -16,7 +16,7 @@ import { formatTokens } from './format.js'
import { recommendModelDefault, type ModelDefaultRecommendation } from './act/model-defaults.js'
import { appliedFixGlyph, formatAppliedFix, type AppliedFix } from './act/types.js'
import { isUserStartedSession, userStartedProjects } from './session-population.js'
import { inferSessionProvider, sessionBillableOutputTokens } from './session-output.js'
import { countSessions, inferSessionProvider, sessionBillableOutputTokens } from './session-output.js'
import { aggregateFileChurn, buildCoachingNotes, scanUserCorrections, medianTimeToFirstEditMs, worstOneShotCategory, type ReworkedFile } from './workflow-insights.js'
// ============================================================================
@ -2513,7 +2513,7 @@ export function detectUnusedMcp(
if (unused.length === 0) return null
const totalSessions = projects.reduce((s, p) => s + p.sessions.length, 0)
const totalSessions = countSessions(projects)
const schemaTokensPerSession = unused.length * TOOLS_PER_MCP_SERVER * TOKENS_PER_MCP_TOOL
const tokensSaved = schemaTokensPerSession * Math.max(totalSessions, 1)
@ -2866,7 +2866,7 @@ export function detectMcpAlwaysLoadHygiene(
const versions = apiCalls.map(c => c.version).filter(v => v.length > 0)
if (versions.length > 0 && versions.every(v => versionPredates(v, ALWAYSLOAD_MIN_VERSION))) return null
const totalSessions = projects.reduce((s, p) => s + p.sessions.length, 0)
const totalSessions = countSessions(projects)
if (totalSessions === 0) return null
const coverageByServer = new Map(mcpCoverage.map(c => [c.server, c]))
@ -4006,14 +4006,14 @@ export function cacheKey(projects: ProjectSummary[], dateRange: DateRange | unde
// stale findings when cost/tokens moved (e.g. a re-price) while call count
// held - reachable in the long-lived menubar process within the 60s TTL.
// Cost is scaled to whole micro-dollars so float jitter cannot thrash the key.
let calls = 0, cost = 0, savings = 0, proxied = 0, sessions = 0, sidechains = 0
let calls = 0, cost = 0, savings = 0, proxied = 0, sidechains = 0
const sessions = countSessions(projects)
const sidechainIdentities: string[] = []
for (const p of projects) {
calls += p.totalApiCalls
cost += p.totalCostUSD
savings += p.totalSavingsUSD
proxied += p.totalProxiedCostUSD
sessions += p.sessions.length
for (const session of p.sessions) {
if (session.isSidechain !== true) continue
sidechains++

View file

@ -5,7 +5,7 @@ import { homedir } from 'os'
import { CATEGORY_LABELS, type ProjectSummary, type TaskCategory } from './types.js'
import { formatCost as baseCost, getCurrency } from './currency.js'
import { findUnpricedModels, modelRowKey, unpricedModelHint } from './models.js'
import { callBillableOutputTokens, sessionBillableOutputTokens, sessionModelBillableOutputTokens } from './session-output.js'
import { callBillableOutputTokens, countSessions, sessionBillableOutputTokens, sessionModelBillableOutputTokens } from './session-output.js'
import { markEstimated, excludedGatewayNote, isEstimatedCost, ESTIMATED_COST_LEGEND } from './format.js'
import { AGGREGATE_ONLY_PROVIDER } from './parser.js'
import { maxOf } from './math-utils.js'
@ -163,11 +163,11 @@ export function renderOverview(
const byDay = new Map<string, { cost: number; tokens: number; providers: Set<string> }>()
const byProject = new Map<string, { cost: number; sessions: number; sample: ProjectSummary }>()
sessions = countSessions(projects)
for (const p of projects) {
cost += p.totalCostUSD
savings += p.totalSavingsUSD
calls += p.totalApiCalls
sessions += p.sessions.length
const pkey = projectAggKey(p)
const pe = byProject.get(pkey) ?? { cost: 0, sessions: 0, sample: p }
pe.cost += p.totalCostUSD

View file

@ -130,6 +130,60 @@ function projectNameFromPath(projectPath: string, fallback: string): string {
return normalized.split('/').filter(Boolean).pop() ?? fallback
}
type CallProject = { key: string; path: string; name: string }
async function resolveClaudeCallProject(cwd: string, filePath: string): Promise<CallProject | null> {
if (isCoworkSession(cwd, filePath)) return null
const canonical = await resolveCanonicalProjectPath(cwd)
return {
key: normalizeProjectPathKey(canonical.path),
path: canonical.path,
// Claude Code names a project directory by replacing every non-alphanumeric
// character of its cwd with '-', so a slice lands under the same label.
name: canonical.isWorktree ? projectNameFromPath(canonical.path, cwd) : canonical.path.replace(/[^a-zA-Z0-9]/g, '-'),
}
}
// True when cwd lies below projectPath without crossing into another repository
// (a .git entry between the two): `cd app` inside a repo stays in the repo,
// while `cd Projects/x` out of a home folder reaches another project. A folder
// that is gone cannot show a boundary (a deleted worktree), so it stays apart.
async function insideProjectTree(projectPath: string, cwd: string): Promise<boolean> {
const root = normalizeProjectPathKey(projectPath)
if (!normalizeProjectPathKey(cwd).startsWith(root + '/') || !existsSync(cwd)) return false
for (let dir = cwd.trim(); normalizeProjectPathKey(dir) !== root; dir = dirname(dir)) {
if (await lstat(join(dir, '.git')).catch(() => null)) return false
if (dirname(dir) === dir) return false
}
return true
}
// Partition turns by each call's project. A turn whose calls span projects is
// cut per call; only the piece holding its first call keeps the turn weight
// (turn, edit and retry counts), so those stay whole. Every piece keeps the
// turn's timestamp and category, so per-day and per-category cost do too.
function splitTurnsByProject(turns: ClassifiedTurn[], projectOf: (call: ParsedApiCall) => CallProject): Array<CallProject & { turns: ClassifiedTurn[] }> {
const groups = new Map<string, CallProject & { turns: ClassifiedTurn[] }>()
for (const turn of turns) {
const byKey = new Map<string, ParsedApiCall[]>()
for (const call of turn.assistantCalls) {
const project = projectOf(call)
if (!groups.has(project.key)) groups.set(project.key, { ...project, turns: [] })
const calls = byKey.get(project.key)
if (calls) calls.push(call)
else byKey.set(project.key, [call])
}
let first = true
for (const [key, calls] of byKey) {
groups.get(key)!.turns.push(calls.length === turn.assistantCalls.length
? turn
: { ...turn, assistantCalls: calls, ...(first ? {} : { projectContinuation: true, hasEdits: false, retries: 0 }) })
first = false
}
}
return [...groups.values()]
}
// Returns true for sessions whose canonical project key must NOT be derived
// from the cwd. Cowork sessions come in two flavours:
@ -1684,12 +1738,17 @@ export function groupIntoTurns(entries: JournalEntry[], seenMsgIds: Set<string>,
const msgId = getMessageId(entry)
if (msgId && seenMsgIds.has(msgId)) continue
if (msgId) seenMsgIds.add(msgId)
const cwd = typeof entry.cwd === 'string' && entry.cwd.trim() ? entry.cwd.trim() : undefined
const call = parseApiCall(entry, toolResultMeta)
if (call) {
if (cwd) call.cwd = cwd
currentCalls.push(call)
if (call.spawnToolUseIds) for (const id of call.spawnToolUseIds) if (!currentSpawnIds.includes(id)) currentSpawnIds.push(id)
}
for (const advisorCall of parseAdvisorCalls(entry)) currentCalls.push(advisorCall)
for (const advisorCall of parseAdvisorCalls(entry)) {
if (cwd) advisorCall.cwd = cwd
currentCalls.push(advisorCall)
}
} else if (entry.type === 'pr-link') {
const url = (entry as Record<string, unknown>)['prUrl']
if (typeof url === 'string' && url && !currentPrRefs.includes(url)) currentPrRefs.push(url)
@ -2504,8 +2563,13 @@ async function scanProjectDirs(
// it is pure waste (on a week view that is nearly all of history). The
// carries above still run over the FULL ordered turn list.
const classifiedTurns: ClassifiedTurn[] = []
// Each call's own cwd, carried forward over the full turn list like the
// branch (the cache stores it only when it changes).
let carriedCwd = cachedFile.workingDirectory
const callCwds = new Map<ParsedApiCall, string>()
for (const turn of cachedFile.turns) {
if (turn.gitBranch) carriedBranch = turn.gitBranch
const turnCwds = turn.calls.map(call => (carriedCwd = call.cwd ?? carriedCwd))
if (dateRange && !frozePrRefs) {
const firstTs = turn.calls[0]?.timestamp
if (firstTs && new Date(firstTs) >= dateRange.start) {
@ -2516,6 +2580,12 @@ async function scanProjectDirs(
if (turn.prRefs?.length) carriedPrRefs = turn.prRefs
if (dateRange && !callsInRange(turn.calls, dateRange)) continue
const classified = cachedTurnToClassified(turn, carriedBranch)
if (cachedFile.canonicalCwd) {
classified.assistantCalls.forEach((call, i) => {
const cwd = turnCwds[i]
if (cwd) callCwds.set(call, cwd)
})
}
// Slice rather than drop: a turn spanning local midnight would otherwise
// lose every call that lands in the requested day (issue #852). Only
// `assistantCalls`/`timestamp` are touched — see classifiedTurnSlicedToRange.
@ -2546,51 +2616,99 @@ async function scanProjectDirs(
const sessionId = basename(filePath, '.jsonl')
const projectPath = cachedFile.canonicalCwd ?? claudeSlugFallbackPath(dirName)
const projectName = cachedFile.canonicalProjectName ?? dirName
const projectKey = cachedFile.canonicalCwd
? normalizeProjectPathKey(cachedFile.canonicalCwd)
: `slug:${dirName}`
const fileProject: CallProject = { key: projectKey, path: projectPath, name: projectName }
const mcpInv = cachedFile.mcpInventory.length > 0 ? cachedFile.mcpInventory : undefined
const session = buildSessionSummary(sessionId, projectName, classifiedTurns, mcpInv, source)
if (cachedFile.workingDirectory && !isCoworkSession(cachedFile.workingDirectory, filePath)) {
session.workingDirectory = cachedFile.workingDirectory
const decorate = (session: SessionSummary): SessionSummary => {
if (cachedFile.workingDirectory && !isCoworkSession(cachedFile.workingDirectory, filePath)) {
session.workingDirectory = cachedFile.workingDirectory
}
session.agentType = cachedFile.agentType
if (everHadBranch) session.everHadBranch = true
const observedPrLinks = new Set(classifiedTurns.flatMap(turn => turn.prRefs ?? []))
for (const link of cachedFile.prLinks ?? []) observedPrLinks.add(link)
if (observedPrLinks.size) {
session.prLinks = [...observedPrLinks].sort()
session.prAttributionSource = cachedFile.prLinks?.length ? 'transcript' : 'explicit-reference'
}
if (prRefsAtRangeStart?.length) session.prRefsAtRangeStart = prRefsAtRangeStart
if (cachedFile.title) session.title = cachedFile.title
// Sidechain linkage: carry the parent id (the transcript's internal
// `sessionId`, authoritative even when it disagrees with the owning directory
// on a resumed session) and derive the agent id from the `agent-<agentId>`
// filename. A sidechain whose parent id was never captured stays standalone.
if (cachedFile.isSidechain) {
session.isSidechain = true
if (cachedFile.parentSessionId) session.parentSessionId = cachedFile.parentSessionId
session.agentId = sessionId.startsWith('agent-') ? sessionId.slice('agent-'.length) : sessionId
}
// Parent linkage maps (only present on sessions that spawned subagents).
if (cachedFile.agentSpawnLinks && Object.keys(cachedFile.agentSpawnLinks).length > 0) {
session.agentSpawnLinks = cachedFile.agentSpawnLinks
}
if (cachedFile.ambiguousSpawnAgentIds?.length) session.ambiguousSpawnAgentIds = cachedFile.ambiguousSpawnAgentIds
if (Object.keys(spawnPrSets).length > 0) session.spawnPrSets = spawnPrSets
// Provider-recorded parent/child lineage (CB-1, slice 1). Mirrors whatever
// the install path stored on the cached file; absent when no evidence.
if (cachedFile.lineage) session.lineage = cachedFile.lineage
return session
}
session.agentType = cachedFile.agentType
if (everHadBranch) session.everHadBranch = true
const observedPrLinks = new Set(classifiedTurns.flatMap(turn => turn.prRefs ?? []))
for (const link of cachedFile.prLinks ?? []) observedPrLinks.add(link)
if (observedPrLinks.size) {
session.prLinks = [...observedPrLinks].sort()
session.prAttributionSource = cachedFile.prLinks?.length ? 'transcript' : 'explicit-reference'
}
if (prRefsAtRangeStart?.length) session.prRefsAtRangeStart = prRefsAtRangeStart
if (cachedFile.title) session.title = cachedFile.title
// Sidechain linkage: carry the parent id (the transcript's internal
// `sessionId`, authoritative even when it disagrees with the owning directory
// on a resumed session) and derive the agent id from the `agent-<agentId>`
// filename. A sidechain whose parent id was never captured stays standalone.
if (cachedFile.isSidechain) {
session.isSidechain = true
if (cachedFile.parentSessionId) session.parentSessionId = cachedFile.parentSessionId
session.agentId = sessionId.startsWith('agent-') ? sessionId.slice('agent-'.length) : sessionId
}
// Parent linkage maps (only present on sessions that spawned subagents).
if (cachedFile.agentSpawnLinks && Object.keys(cachedFile.agentSpawnLinks).length > 0) {
session.agentSpawnLinks = cachedFile.agentSpawnLinks
}
if (cachedFile.ambiguousSpawnAgentIds?.length) session.ambiguousSpawnAgentIds = cachedFile.ambiguousSpawnAgentIds
if (Object.keys(spawnPrSets).length > 0) session.spawnPrSets = spawnPrSets
// Provider-recorded parent/child lineage (CB-1, slice 1). Mirrors whatever
// the install path stored on the cached file; absent when no evidence.
if (cachedFile.lineage) session.lineage = cachedFile.lineage
if (session.apiCalls > 0 || anchorOnly) {
const projectKey = cachedFile.canonicalCwd
? normalizeProjectPathKey(cachedFile.canonicalCwd)
: `slug:${dirName}`
const existing = projectMap.get(projectKey)
const addToProject = (project: CallProject, session: SessionSummary, anchor: boolean): void => {
const existing = projectMap.get(project.key)
// An anchor (no in-range spend) goes into a separate bucket, never `sessions`.
const target = existing ?? { project: projectName, projectPath, sessions: [], anchors: [], dirNames: new Set([dirName]) }
if (anchorOnly) target.anchors.push(session)
const target = existing ?? { project: project.name, projectPath: project.path, sessions: [], anchors: [], dirNames: new Set<string>() }
if (anchor) target.anchors.push(session)
else target.sessions.push(session)
target.dirNames.add(dirName)
if (!existing) projectMap.set(projectKey, target)
// Only the transcript's own directory may later absorb its slug-keyed rows.
if (project.key === projectKey) target.dirNames.add(dirName)
if (!existing) projectMap.set(project.key, target)
}
// A session that moved folders splits per call: each call goes to the
// project of its own cwd, and every slice keeps the session's metadata.
const callProjects = new Map<string, CallProject>()
for (const cwd of new Set(callCwds.values())) {
const resolved = await resolveClaudeCallProject(cwd, filePath)
callProjects.set(cwd, resolved && resolved.key !== projectKey ? resolved : fileProject)
}
// A subfolder of a folder this session worked in joins that folder's
// project (the outermost one), so `cd app` inside a repo is not a move.
const roots = [...new Map([fileProject, ...callProjects.values()].map(p => [p.key, p])).values()]
.sort((a, b) => a.path.length - b.path.length)
for (const [cwd, project] of callProjects) {
if (project === fileProject) continue
for (const root of roots) {
if (await insideProjectTree(root.path, cwd)) {
callProjects.set(cwd, root)
break
}
}
}
const groups = [...callProjects.values()].some(p => p !== fileProject)
? splitTurnsByProject(classifiedTurns, call => {
const cwd = callCwds.get(call)
return (cwd && callProjects.get(cwd)) || fileProject
})
: [{ ...fileProject, turns: classifiedTurns }]
if (groups.length === 1) {
const only = groups[0]!
const name = only === fileProject || only.key === projectKey ? projectName : projectMap.get(only.key)?.project ?? only.name
const session = decorate(buildSessionSummary(sessionId, name, classifiedTurns, mcpInv, source))
if (session.apiCalls > 0 || anchorOnly) addToProject({ ...only, name }, session, anchorOnly)
continue
}
const slices = groups.map(group => {
const name = group.key === projectKey ? projectName : projectMap.get(group.key)?.project ?? group.name
return { project: { ...group, name }, session: decorate(buildSessionSummary(sessionId, name, group.turns, mcpInv, source)) }
})
// Primary = the slice holding the most cost, so the session row sits where
// the money went and a late `cd` cannot move it. Ties keep the earlier slice.
const primary = slices.reduce((best, slice) => slice.session.totalCostUSD > best.session.totalCostUSD ? slice : best)
for (const { project, session } of slices) {
session.projectSplit = { primaryProject: primary.project.name, primaryProjectPath: primary.project.path, primary: session === primary.session }
if (session.apiCalls > 0) addToProject(project, session, false)
}
}
@ -2785,17 +2903,23 @@ function parsedTurnToCachedTurn(turn: ParsedTurn): CachedTurn {
}
// Convert a batch of parsed turns to cached turns, storing each turn's gitBranch
// only when it differs from the previous turn's branch in this batch. A report
// (and each call's cwd) only when it differs from the previous one in this batch. A report
// reconstructs a turn's branch by carrying the last stored value forward. The
// dedup is per-batch, so the first turn of an appended region always restates
// its branch (harmless: a redundant restatement, never a wrong value).
export function parsedTurnsToCachedTurns(turns: ParsedTurn[]): CachedTurn[] {
const out: CachedTurn[] = []
let prevBranch: string | undefined
let prevCwd: string | undefined
for (const turn of turns) {
const cached = parsedTurnToCachedTurn(turn)
if (turn.gitBranch && turn.gitBranch !== prevBranch) cached.gitBranch = turn.gitBranch
if (turn.gitBranch) prevBranch = turn.gitBranch
turn.assistantCalls.forEach((call, i) => {
if (!call.cwd || call.cwd === prevCwd) return
cached.calls[i]!.cwd = call.cwd
prevCwd = call.cwd
})
out.push(cached)
}
return out
@ -3206,6 +3330,13 @@ function getOrCreateProviderSection(cache: SessionCache, provider: string): Prov
section.files[path] = { ...rest, fingerprint: { dev: 0, ino: 0, mtimeMs: 0, sizeBytes: -1 } }
}
}
// A Claude transcript that is gone can never re-parse, and its cached entry
// (kept only when PR-bearing) is the last record of it: a parse bump keeps it.
if (existing && provider === 'claude') {
for (const [path, file] of Object.entries(existing.files)) {
if (!existsSync(path)) section.files[path] = file
}
}
cache.providers[provider] = section
markCacheDirty(cache, provider)
return section
@ -4999,6 +5130,57 @@ function carryLinkageFields(rebuilt: SessionSummary, original: SessionSummary):
if (original.agentName) rebuilt.agentName = original.agentName
if (original.agentStartedAt) rebuilt.agentStartedAt = original.agentStartedAt
if (original.lineage) rebuilt.lineage = original.lineage
if (original.projectSplit) rebuilt.projectSplit = original.projectSplit
}
/// Session-level views (session rows, PR attribution, subagent folds) need one
/// record per session. Rejoin the per-project slices of a split session into the
/// project holding its primary slice, or the first slice present under a filter.
export function mergeProjectSplits(projects: ProjectSummary[]): ProjectSummary[] {
const groups = new Map<string, { home: ProjectSummary; slices: SessionSummary[] }>()
for (const project of projects) {
for (const session of project.sessions) {
const split = session.projectSplit
if (!split) continue
const key = `${split.primaryProjectPath}\0${session.sessionId}`
const group = groups.get(key)
if (!group) groups.set(key, { home: project, slices: [session] })
else {
group.slices.push(session)
if (split.primary) group.home = project
}
}
}
if (groups.size === 0) return projects
const added = new Map<ProjectSummary, SessionSummary[]>()
const turnStart = (turn: ClassifiedTurn): number => Date.parse(turn.timestamp || turn.assistantCalls[0]?.timestamp || '') || 0
for (const { home, slices } of groups.values()) {
const base = slices.find(s => s.projectSplit!.primary) ?? slices[0]!
const turns = slices.flatMap(s => s.turns)
.sort((a, b) => turnStart(a) - turnStart(b) || Number(!!a.projectContinuation) - Number(!!b.projectContinuation))
const session = buildSessionSummary(base.sessionId, base.projectSplit!.primaryProject, turns, base.mcpInventory, base.source)
carryLinkageFields(session, base)
delete session.projectSplit
if (base.prRefsAtRangeStart?.length) session.prRefsAtRangeStart = base.prRefsAtRangeStart
const links = new Set(slices.flatMap(s => s.prLinks ?? []))
if (links.size) session.prLinks = [...links].sort()
const list = added.get(home)
if (list) list.push(session)
else added.set(home, [session])
}
const out: ProjectSummary[] = []
for (const project of projects) {
const sessions = project.sessions.filter(s => !s.projectSplit)
const extra = added.get(project)
if (!extra && sessions.length === project.sessions.length) {
out.push(project)
continue
}
if (extra) sessions.push(...extra)
if (sessions.length === 0 && !project.subagentAnchors?.length) continue
out.push({ ...project, ...summarizeProject(project.project, project.projectPath, sessions, project.subagentAnchors) })
}
return out
}
// The "PR active entering this slice", recomputed by replaying the ORIGINAL full
@ -5327,7 +5509,7 @@ export function correlateCrossProviderPrSessions(projects: ProjectSummary[]): vo
if (bucket) bucket.push(s)
else unlinkedByAgentId.set(s.agentId, [s])
}
for (const resolved of resolveSubagentAttribution(projects).values()) {
for (const resolved of resolveSubagentAttribution(mergeProjectSplits(projects)).values()) {
for (const child of resolved) {
// A multi-PR spawn set is valid for folding the child's own cost, but is
// too broad to identify which PR an independently saved nested review was

View file

@ -512,6 +512,14 @@ export function diffSessions(
): SessionDiffRow[] {
type Acc = { provider: string; sessionId: string; project: string; title?: string; costA: number; costB: number; callsA: number; callsB: number }
const accs = new Map<string, Acc>()
// A session split across projects names its primary slice per range, so the
// canonical key can differ between A and B: join those sessions on the id.
const splitIds = new Set<string>()
for (const project of [...projectsA, ...projectsB]) {
for (const session of project.sessions) {
if (session.projectSplit) splitIds.add(`${inferSessionProvider(session)}\0${session.sessionId}`)
}
}
const fold = (projects: ProjectSummary[], side: 'A' | 'B') => {
for (const project of projects) {
@ -524,7 +532,8 @@ export function diffSessions(
if (dimension === 'project' && spendProjectIdentity(project).id !== key) continue
const cost = dimension === 'model' ? modelEntry!.costUSD : session.totalCostUSD
const calls = dimension === 'model' ? modelEntry!.calls : session.apiCalls
const identity = canonicalSessionCountKey(session, project.projectPath)
const splitId = `${inferSessionProvider(session)}\0${session.sessionId}`
const identity = splitIds.has(splitId) ? `${splitId}\0split` : canonicalSessionCountKey(session, project.projectPath)
const acc = accs.get(identity) ?? {
provider: inferSessionProvider(session),
sessionId: session.sessionId,

View file

@ -45,6 +45,9 @@ export type CachedCall = {
/** Present only when workingDirectory came from a dedicated provider field. */
workingDirectoryProvenance?: 'provider-field'
workingDirectory?: string
/// Claude: the call's raw cwd, stored only when it differs from the previous
/// call's in the same parse batch (carried forward like gitBranch).
cwd?: string
toolSequence?: ToolCall[][]
// Rich-session-capture (capture-only; no report consumes these yet). All
// optional and omitted at zero/false to keep the per-call cache cost minimal.
@ -372,7 +375,9 @@ export const PROVIDER_PARSE_VERSIONS: Record<string, string> = {
// byte-identical to a build that omits it (see parser-lineage-capture test).
// queued-human-prompts-v1: cached turns need to be regrouped around Claude's
// queued_command prompt attachments, including classification and PR links.
claude: 'advisor-usage-v1-skills-rich-capture-v1-cross-provider-pr-v1-session-lineage-capture-v1-queued-human-prompts-v1',
// per-call-cwd-v1: cached calls carry their cwd so a session that moved
// folders splits across projects per call.
claude: 'advisor-usage-v1-skills-rich-capture-v1-cross-provider-pr-v1-session-lineage-capture-v1-queued-human-prompts-v1-per-call-cwd-v1',
cline: 'worktree-project-grouping-v1',
// reported-cost-v1: the CLI reports its own per-message cost, so entries
// cached before cline-cli joined the reported-cost allowlist in parser.ts
@ -1187,6 +1192,7 @@ function validateCall(c: unknown): c is CachedCall {
&& isOptionalString(o['projectPath'])
&& (o['workingDirectoryProvenance'] === undefined || o['workingDirectoryProvenance'] === 'provider-field')
&& isOptionalString(o['workingDirectory'])
&& isOptionalString(o['cwd'])
&& (o['toolSequence'] === undefined || (Array.isArray(o['toolSequence']) && (o['toolSequence'] as unknown[]).every(s => isToolCallArray(s))))
&& isOptionalNum(o['locAdded'])
&& isOptionalNum(o['locRemoved'])

View file

@ -116,3 +116,16 @@ export function callBillableOutputTokens(call: CallLike): number {
export function sessionBillableOutputTokens(session: SessionSummary): number {
return sessionBillableOutput(session).total
}
/** Sessions across projects, counting a session split across projects once. */
export function countSessions(projects: ReadonlyArray<{ sessions: SessionSummary[] }>): number {
const split = new Set<string>()
let count = 0
for (const project of projects) {
for (const session of project.sessions) {
if (session.projectSplit) split.add(`${session.projectSplit.primaryProjectPath}\0${session.sessionId}`)
else count++
}
}
return count + split.size
}

View file

@ -129,6 +129,9 @@ export type ParsedApiCall = {
/// this call's assistant message. Transient (built at parse time, aggregated
/// into the turn's `spawnToolUseIds`); never cached per-call.
spawnToolUseIds?: string[]
/// Claude Code: the `cwd` recorded on this call's assistant line. Transient
/// (parse time only); the cache stores it per call only when it changes.
cwd?: string
/// When set, `costUSD` is the actual local call (forced to 0) and
/// `savingsUSD` is the counterfactual cost the same tokens would have
/// incurred against `savingsBaselineModel`. Set by the savings
@ -209,6 +212,10 @@ export type ClassifiedTurn = ParsedTurn & {
subCategory?: string
retries: number
hasEdits: boolean
/// The calls of a turn that moved to another project mid-turn. The piece that
/// holds the turn's first call carries the turn's weight; this one only cost
/// (it reads as no edits and no retries).
projectContinuation?: boolean
}
export type SessionSourceMetadata = {
@ -333,6 +340,10 @@ export type SessionSummary = {
/// from a provider that never captures branches (→ contributes nothing).
/// Claude only; absent otherwise.
everHadBranch?: boolean
/// Claude Code: set on each per-project slice of a session whose calls ran
/// in more than one project (per-call `cwd`). Session rows merge the slices
/// back into one row labeled with the primary (highest-cost) project.
projectSplit?: { primaryProject: string; primaryProjectPath: string; primary: boolean }
modelBreakdown: Record<string, { calls: number; costUSD: number; tokens: TokenUsage; savingsUSD: number; estimatedCostUSD?: number; activeDurationMs?: number; activeGeneratedTokens?: number; toolWaitMs?: number }>
toolBreakdown: Record<string, { calls: number }>
mcpBreakdown: Record<string, { calls: number }>

View file

@ -3,7 +3,7 @@ import { CATEGORY_LABELS, type ProjectSummary, type SessionSummary, type TaskCat
import { behavioralCallWeight } from './behavioral-weight.js'
import { type PeriodData, type ProviderCost, type BreakdownArrays, type MenubarPayload, type ClaudeConfigSelector, type HydrationState, buildMenubarPayload } from './menubar-json.js'
import { type SessionCountBasis } from './session-count-label.js'
import { parseAllSessions, filterProjectsByName, filterProjectsByDays, filterProjectsByClaudeConfigSource, filterProjectsByDateRange, isSessionHydrationComplete, makeProjectFilter, type ProjectFilterTarget, sessionHydrationSnapshot } from './parser.js'
import { parseAllSessions, filterProjectsByName, mergeProjectSplits, filterProjectsByDays, filterProjectsByClaudeConfigSource, filterProjectsByDateRange, isSessionHydrationComplete, makeProjectFilter, type ProjectFilterTarget, sessionHydrationSnapshot } from './parser.js'
type ProjectFilter = (entry: ProjectFilterTarget) => boolean
import { findUnpricedModels, getFlatRateModelsConfigHash, getLocalModelSavingsConfigHash, getPriceOverridesConfigHash, getShortModelName, isExpectedFreeModel, billableOutputTokens, modelRowKey } from './models.js'
@ -1098,7 +1098,7 @@ type PayloadSessionDetail = NonNullable<PayloadProject['sessionDetails']>[number
/// Filename-colliding `sess.jsonl` in two folders stay distinct. Same file
/// spanning days stays one. Not fingerprint/cost/calls.
export function canonicalSessionCountKey(session: SessionSummary, projectPath?: string): string {
const loc = projectPath || session.workingDirectory || session.project
const loc = session.projectSplit?.primaryProjectPath || projectPath || session.workingDirectory || session.project
return `${inferSessionProvider(session)}\0${loc}\0${session.sessionId}`
}
@ -1836,7 +1836,7 @@ export async function buildMenubarPayloadForRange(periodInfo: PeriodInfo, opts:
const retryTax = buildRetryTax(effMap.values())
currentData.topSessions = scanProjects.flatMap(p =>
currentData.topSessions = mergeProjectSplits(scanProjects).flatMap(p =>
p.sessions.map(s => ({
project: friendlyProject(p),
cost: s.totalCostUSD,
@ -1864,7 +1864,7 @@ export async function buildMenubarPayloadForRange(periodInfo: PeriodInfo, opts:
// sessions) so this stays the genuine unscoped all-provider aggregation.
if (isAllProviders && !effectivelyScoped) {
// One pass yields both rows and totals, so they never disagree.
const { rows: prRows, totals: prTotals } = buildPrAttribution(scanProjects)
const { rows: prRows, totals: prTotals } = buildPrAttribution(mergeProjectSplits(scanProjects))
if (prRows.length > 0) {
currentData.pullRequests = {
// PRs are user-auditable spend records, so never collapse the tail into

View file

@ -221,6 +221,22 @@ describe('detectUnusedMcp', () => {
expect(finding!.explanation).toContain('ghost')
})
it('counts a session split across projects once', () => {
const root = makeFixtureRoot()
const projectDir = join(root, 'myapp')
mkdirSync(projectDir, { recursive: true })
writeFile(join(projectDir, '.mcp.json'), JSON.stringify({ mcpServers: { ghost: { command: 'x' } } }))
touchOld(join(projectDir, '.mcp.json'), 30)
const slice = (primary: boolean) => ({ sessionId: 's1', mcpBreakdown: {}, projectSplit: { primaryProject: 'a', primaryProjectPath: '/a', primary } })
const projects = [
{ project: 'a', projectPath: '/a', sessions: [slice(true)] },
{ project: 'b', projectPath: '/b', sessions: [slice(false)] },
] as unknown as ProjectSummary[]
const split = detectUnusedMcp([], projects, new Set([projectDir]), [])
const single = detectUnusedMcp([], projects.slice(0, 1), new Set([projectDir]), [])
expect(split!.tokensSaved).toBe(single!.tokensSaved)
})
it('does not flag servers configured within 24 hours', () => {
const root = makeFixtureRoot()
const projectDir = join(root, 'myapp')

View file

@ -0,0 +1,174 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
import { appendFile, mkdtemp, mkdir, writeFile, rm } from 'fs/promises'
import { join } from 'path'
import { tmpdir } from 'os'
import { clearSessionCache, filterProjectsByName, mergeProjectSplits, parseAllSessions } from '../src/parser.js'
import { aggregateSessions } from '../src/sessions-report.js'
import { aggregateProjectsIntoDays } from '../src/day-aggregator.js'
import { countSessions } from '../src/session-output.js'
import { buildMenubarPayloadForRange, uniqueCanonicalSessionCountFromProjects } from '../src/usage-aggregator.js'
import type { DateRange, ProjectSummary } from '../src/types.js'
let tmpDir: string
beforeEach(async () => {
tmpDir = await mkdtemp(join(tmpdir(), 'claude-per-call-cwd-'))
process.env['CLAUDE_CONFIG_DIR'] = tmpDir
process.env['CODEBURN_DESKTOP_SESSIONS_DIR'] = join(tmpDir, 'desktop-sessions')
process.env['CODEBURN_CACHE_DIR'] = join(tmpDir, 'cache')
clearSessionCache()
})
afterEach(async () => {
clearSessionCache()
await rm(tmpDir, { recursive: true, force: true })
})
const RANGE: DateRange = { start: new Date('2099-06-01T00:00:00.000Z'), end: new Date('2099-06-01T23:59:59.999Z') }
const HOME = '/work/home'
const APP = '/work/app'
const user = (text: string, cwd: string, ts: string) => JSON.stringify({
type: 'user', sessionId: 's1', timestamp: ts, cwd, message: { role: 'user', content: text },
})
const assistant = (id: string, cwd: string, ts: string, input: number) => JSON.stringify({
type: 'assistant', sessionId: 's1', timestamp: ts, cwd,
message: { id, type: 'message', role: 'assistant', model: 'claude-sonnet-4-5', content: [], usage: { input_tokens: input, output_tokens: 0 } },
})
async function writeSession(lines: string[]): Promise<string> {
const dir = join(tmpDir, 'projects', '-work-home')
await mkdir(dir, { recursive: true })
const file = join(dir, 's1.jsonl')
await writeFile(file, lines.join('\n') + '\n')
return file
}
const byPath = (projects: ProjectSummary[]) => Object.fromEntries(projects.map(p => [p.projectPath, p]))
describe('Claude per-call cwd', () => {
it('splits a session that moved folders by call, keeping totals, turns and the session count whole', async () => {
await writeSession([
user('start', HOME, '2099-06-01T10:00:00.000Z'),
assistant('m1', HOME, '2099-06-01T10:00:01.000Z', 1_000_000),
// The working directory changes mid-turn, then a new turn starts there.
assistant('m2', APP, '2099-06-01T10:00:02.000Z', 2_000_000),
user('next', APP, '2099-06-01T10:05:00.000Z'),
assistant('m3', APP, '2099-06-01T10:05:01.000Z', 3_000_000),
])
const projects = await parseAllSessions(RANGE, 'claude')
const paths = byPath(projects)
expect(Object.keys(paths).sort()).toEqual([APP, HOME])
expect(paths[HOME]!.totalApiCalls).toBe(1)
expect(paths[APP]!.totalApiCalls).toBe(2)
expect(paths[APP]!.totalCostUSD).toBeCloseTo(paths[HOME]!.totalCostUSD * 5, 10)
const allTurns = projects.flatMap(p => p.sessions).reduce((n, s) => n + Object.values(s.categoryBreakdown).reduce((m, c) => m + c.turns, 0), 0)
expect(allTurns).toBe(2)
expect(countSessions(projects)).toBe(1)
expect(uniqueCanonicalSessionCountFromProjects(projects)).toBe(1)
const [day] = aggregateProjectsIntoDays(projects)
expect(day!.sessions).toBe(1)
expect(day!.calls).toBe(3)
expect(Object.values(day!.projects!).map(p => p.calls).sort()).toEqual([1, 2])
const rows = aggregateSessions(mergeProjectSplits(projects))
expect(rows).toHaveLength(1)
expect(rows[0]!.project).toBe('-work-app')
expect(rows[0]!.calls).toBe(3)
expect(rows[0]!.turns).toBe(2)
expect(rows[0]!.cost).toBeCloseTo(paths[HOME]!.totalCostUSD + paths[APP]!.totalCostUSD, 10)
const payload = await buildMenubarPayloadForRange({ range: RANGE, label: 'p' }, { provider: 'claude', optimize: false, timeline: false })
expect(payload.current.topSessions.map(s => [s.sessionId, s.calls])).toEqual([['s1', 3]])
const onlyApp = filterProjectsByName(projects, ['app'], [])
expect(onlyApp.map(p => p.projectPath)).toEqual([APP])
expect(countSessions(onlyApp)).toBe(1)
})
it('keeps a cd into a subfolder of the same repository on the transcript project', async () => {
const repo = join(tmpDir, 'repo')
await mkdir(join(repo, '.git'), { recursive: true })
await mkdir(join(repo, 'app', 'src'), { recursive: true })
await writeSession([
user('start', repo, '2099-06-01T10:00:00.000Z'),
assistant('m1', repo, '2099-06-01T10:00:01.000Z', 1_000_000),
assistant('m2', join(repo, 'app'), '2099-06-01T10:00:02.000Z', 2_000_000),
assistant('m3', join(repo, 'app', 'src'), '2099-06-01T10:00:03.000Z', 3_000_000),
])
const projects = await parseAllSessions(RANGE, 'claude')
expect(projects.map(p => p.projectPath)).toEqual([repo])
expect(projects[0]!.totalApiCalls).toBe(3)
expect(projects[0]!.sessions[0]!.projectSplit).toBeUndefined()
})
it('splits a cd from a plain folder into a repository below it, and keeps the repository whole', async () => {
const home = join(tmpDir, 'home')
const repo = join(home, 'Projects', 'crew')
await mkdir(join(repo, '.git'), { recursive: true })
await mkdir(join(repo, 'src'), { recursive: true })
await mkdir(join(home, 'notes'), { recursive: true })
await writeSession([
user('start', home, '2099-06-01T10:00:00.000Z'),
assistant('m1', home, '2099-06-01T10:00:01.000Z', 1_000_000),
assistant('m2', join(home, 'notes'), '2099-06-01T10:00:02.000Z', 1_000_000),
assistant('m3', repo, '2099-06-01T10:00:03.000Z', 2_000_000),
assistant('m4', join(repo, 'src'), '2099-06-01T10:00:04.000Z', 2_000_000),
// A deleted worktree cannot prove it belonged to home, so it stays apart.
assistant('m5', join(home, 'gone-wt'), '2099-06-01T10:00:05.000Z', 1_000_000),
])
const paths = byPath(await parseAllSessions(RANGE, 'claude'))
expect(Object.keys(paths).sort()).toEqual([home, repo, join(home, 'gone-wt')].sort())
expect(paths[home]!.totalApiCalls).toBe(2)
expect(paths[repo]!.totalApiCalls).toBe(2)
})
it('reads the same split from an appended transcript as from a fresh parse', async () => {
const file = await writeSession([
user('start', HOME, '2099-06-01T10:00:00.000Z'),
assistant('m1', HOME, '2099-06-01T10:00:01.000Z', 1_000_000),
])
await parseAllSessions(RANGE, 'claude')
await appendFile(file, [
assistant('m2', APP, '2099-06-01T10:00:02.000Z', 2_000_000),
assistant('m3', HOME, '2099-06-01T10:00:03.000Z', 4_000_000),
].join('\n') + '\n')
clearSessionCache()
const appended = byPath(await parseAllSessions(RANGE, 'claude'))
await rm(join(tmpDir, 'cache'), { recursive: true, force: true })
clearSessionCache()
const fresh = byPath(await parseAllSessions(RANGE, 'claude'))
for (const paths of [appended, fresh]) {
expect(paths[HOME]!.totalApiCalls).toBe(2)
expect(paths[APP]!.totalApiCalls).toBe(1)
}
expect(appended[HOME]!.totalCostUSD).toBeCloseTo(fresh[HOME]!.totalCostUSD, 10)
expect(appended[APP]!.totalCostUSD).toBeCloseTo(fresh[APP]!.totalCostUSD, 10)
})
it('keeps a PR-bearing session whose transcript is gone across a parse bump', async () => {
const file = await writeSession([
user('start', HOME, '2099-06-01T10:00:00.000Z'),
assistant('m1', HOME, '2099-06-01T10:00:01.000Z', 1_000_000),
JSON.stringify({ type: 'pr-link', sessionId: 's1', timestamp: '2099-06-01T10:00:02.000Z', prUrl: 'https://github.com/o/r/pull/1' }),
])
await parseAllSessions(RANGE, 'claude')
await rm(file)
// Any change to the provider's env fingerprint rebuilds its cache section,
// exactly like a parse-version bump.
process.env['APPDATA'] = join(tmpDir, 'appdata')
clearSessionCache()
try {
const projects = await parseAllSessions(RANGE, 'claude')
expect(projects.flatMap(p => p.sessions).map(s => s.sessionId)).toEqual(['s1'])
} finally {
delete process.env['APPDATA']
}
})
})

View file

@ -311,6 +311,17 @@ describe('sessions spanning the A/B boundary', () => {
expect(rows[0]!.identity).toContain('straddle-1')
})
it('joins a split session whose primary project differs between the ranges', () => {
const slice = (cost: number, primaryProjectPath: string, primary: boolean): SessionSummary =>
({ ...makeSession({ sessionId: 'moved-1', model: 'model-x', costUSD: cost, calls: 1 }), projectSplit: { primaryProject: primaryProjectPath, primaryProjectPath, primary } })
const projectsA = [makeProject('/home', [slice(30, '/home', true)]), makeProject('/app', [slice(10, '/home', false)])]
const projectsB = [makeProject('/home', [slice(5, '/app', false)]), makeProject('/app', [slice(50, '/app', true)])]
expect(diffSessions(projectsA, projectsB, 'model', 'model-x')).toMatchObject([{ sessionId: 'moved-1', costA: 40, costB: 55 }])
expect(diffSessions(projectsA, projectsB, 'project', '/home')).toMatchObject([{ sessionId: 'moved-1', costA: 30, costB: 5 }])
const unsplitA = [makeProject('/home', [makeSession({ sessionId: 'moved-1', model: 'model-x', costUSD: 30, calls: 1 })])]
expect(diffSessions(unsplitA, projectsB, 'project', '/home')).toMatchObject([{ sessionId: 'moved-1', costA: 30, costB: 5 }])
})
it('drills by model across projects and by project across models', () => {
const a = [makeProject('/w1', [makeSession({ sessionId: 's1', modelCosts: { 'm-a': { costUSD: 10, calls: 1 }, 'm-b': { costUSD: 5, calls: 1 } } })])]
const b = [makeProject('/w2', [makeSession({ sessionId: 's2', modelCosts: { 'm-a': { costUSD: 25, calls: 2 } } })])]