diff --git a/CHANGELOG.md b/CHANGELOG.md
index 98c3807..d846966 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,6 +2,17 @@
## Unreleased
+### Added
+- **Credit-metered ChatGPT workspaces (Business / Edu / Enterprise) now show their limit.** These plans report no rate-limit windows, so the admin-set monthly allowance from `spend_control.individual_limit` is shown as a "Monthly usage limit" bar in the desktop app and the menubar.
+
+### Added (CLI)
+- **Codex throughput tracking**: per-model Tok/s in the dashboard and report, active time excludes tool wait. (#805, thanks @ihearttokyo)
+- `codeburn sync push --attribution` (opt-in): sends git attribution spans — the session→commit correlation from `codeburn yield` (`codeburn.session.attribution` and `codeburn.commit` span types with normalized repo remote, commit SHAs, merged/reverted state, and PR links). Nothing new is sent without the flag; local-only repos and Windows filesystem paths are never emitted as repo identities, and sessions whose project path no longer resolves never inherit the push-time working directory's repo. See docs/sync/README.md "Git attribution".
+
+### Fixed (CLI)
+- **`--project` / `--exclude` now apply to the headline totals, not just the detail panels.** The durable headline unions the carry-forward daily cache with today's live parse, and the cached days were sliced to the requested provider but never to the requested project — so the Overview panel counted excluded projects while By Project / By Activity / By Model (built from the name-filtered parse) left them out, and the two could not be reconciled. Cost, calls, sessions and savings are now sliced out of the per-project day stats the cache has carried since v15. Tokens, models and categories have no per-project split in the cache, so under a project filter they come from the (project-filtered) live parse instead; cached days — or provider slices — carried from before v15 have no project split at all, so they cannot be attributed to a filtered project, and the terminal overview now states how much was set aside rather than folding it into the total. (#864)
+- **Codex parser corrections**: fork-replay no longer double-counts `patch_apply_end` and `mcp_tool_call_end`; `exec` is normalized to Bash; `custom_tool_call` events are handled; token_count lines larger than 32 KiB now parse exact token counts instead of estimating. Codex session cache bumps from v7 to v8 for a one-time re-parse. Only tool attribution changes for ordinary sessions, leaving their cost identical; sessions that logged an oversized token_count line are repriced from exact counts instead of an estimate. (#805)
+
### Fixed
- Claude Desktop and Cowork sessions are discovered for Windows Microsoft Store (MSIX) installs. (#611)
diff --git a/README.md b/README.md
index f6d04b6..d5a318c 100644
--- a/README.md
+++ b/README.md
@@ -1,5 +1,5 @@
-
+
diff --git a/app/electron/main.test.ts b/app/electron/main.test.ts
index 30dfe17..208bd86 100644
--- a/app/electron/main.test.ts
+++ b/app/electron/main.test.ts
@@ -89,6 +89,12 @@ const ARGV_CASES: Array<{ channel: string; args: unknown[]; argv: string[] }> =
{ channel: 'codeburn:getOverview', args: ['30days', 'all', { from: '2026-07-01', to: '2026-07-11' }], argv: ['status', '--format', 'menubar-json', '--period', '30days', '--no-timeline', '--from', '2026-07-01', '--to', '2026-07-11'] },
{ channel: 'codeburn:getOverview', args: ['30days', 'all', undefined, 'claude-config:91dda17e8cf35193'], argv: ['status', '--format', 'menubar-json', '--period', '30days', '--no-timeline', '--claude-config-source', 'claude-config:91dda17e8cf35193'] },
{ channel: 'codeburn:getOverview', args: ['month', 'claude', { from: '2026-07-01', to: '2026-07-11' }, 'claude-desktop:980e1e488a654830'], argv: ['status', '--format', 'menubar-json', '--period', 'month', '--no-timeline', '--provider', 'claude', '--from', '2026-07-01', '--to', '2026-07-11', '--claude-config-source', 'claude-desktop:980e1e488a654830'] },
+ // Combined scope emits --scope combined; an explicit local scope is identical
+ // to the default (no flag). The CLI rejects --scope with --provider, so a
+ // provider passed alongside combined is dropped (the renderer forces 'all').
+ { channel: 'codeburn:getOverview', args: ['30days', 'all', undefined, undefined, undefined, 'combined'], argv: ['status', '--format', 'menubar-json', '--period', '30days', '--no-timeline', '--scope', 'combined'] },
+ { channel: 'codeburn:getOverview', args: ['30days', 'claude', undefined, undefined, undefined, 'combined'], argv: ['status', '--format', 'menubar-json', '--period', '30days', '--no-timeline', '--scope', 'combined'] },
+ { channel: 'codeburn:getOverview', args: ['30days', 'claude', undefined, undefined, undefined, 'local'], argv: ['status', '--format', 'menubar-json', '--period', '30days', '--no-timeline', '--provider', 'claude'] },
{ channel: 'codeburn:getModels', args: ['week', 'claude', true, { from: '2026-07-01', to: '2026-07-11' }], argv: ['models', '--format', 'json', '--period', 'week', '--provider', 'claude', '--by-task', '--from', '2026-07-01', '--to', '2026-07-11'] },
{ channel: 'codeburn:getYield', args: ['today', 'all', { from: '2026-07-01', to: '2026-07-11' }], argv: ['yield', '--format', 'json', '--period', 'today', '--from', '2026-07-01', '--to', '2026-07-11'] },
{ channel: 'codeburn:getSpendFlow', args: ['month', 'all', { from: '2026-07-01', to: '2026-07-11' }], argv: ['spend', '--format', 'flow-json', '--period', 'month', '--from', '2026-07-01', '--to', '2026-07-11'] },
@@ -212,6 +218,7 @@ describe('createBridgeHandlers (IPC input validation)', () => {
{ name: 'remove price override model that looks like a flag', channel: 'codeburn:removePriceOverride', args: ['--all'] },
{ name: 'claude config source that looks like a flag', channel: 'codeburn:getOverview', args: ['30days', 'all', undefined, '-rf'] },
{ name: 'claude config source with shell metacharacters', channel: 'codeburn:getOverview', args: ['30days', 'all', undefined, 'id; rm -rf'] },
+ { name: 'unknown scope', channel: 'codeburn:getOverview', args: ['30days', 'all', undefined, undefined, undefined, 'everything'] },
]
it.each(REJECTIONS)('rejects $name with a bad-args envelope and never spawns', async ({ channel, args }) => {
diff --git a/app/electron/main.ts b/app/electron/main.ts
index 82a95c0..5eddbdc 100644
--- a/app/electron/main.ts
+++ b/app/electron/main.ts
@@ -165,6 +165,11 @@ function vConfigSource(source: string | null | undefined): string | null {
if (!/^[A-Za-z0-9][A-Za-z0-9:_-]*$/.test(source)) throw new CliError('bad-args', 'invalid claude config source')
return source
}
+function vScope(scope: string | undefined): 'local' | 'combined' {
+ if (scope === 'combined') return 'combined'
+ if (scope === undefined || scope === 'local') return 'local'
+ throw new CliError('bad-args', 'invalid scope')
+}
function vOutPath(outPath: string): string {
if (outPath.startsWith('-') || !path.isAbsolute(outPath)) throw new CliError('bad-args', 'export path must be absolute')
return outPath
@@ -269,19 +274,28 @@ export function createBridgeHandlers(deps: Deps = { spawnCli, spawnCliAction, re
// The desktop never renders the granular timeline, so it always passes
// --no-timeline (skips buildGranularHistory on every poll). The Swift menubar
// omits the flag and keeps the timeline unchanged.
- const buildOverviewArgs = (period: string, provider: string, range?: DateRange, configSource?: string | null): string[] => [
- 'status', '--format', 'menubar-json', '--period', vPeriod(period), '--no-timeline',
- ...providerArgs(vProvider(provider)), ...rangeArgs(vRange(range)), ...configSourceArgs(vConfigSource(configSource)),
- ]
+ //
+ // Combined scope aggregates paired-device usage: the CLI rejects --scope
+ // combined alongside --provider/--project/--exclude (paired devices report
+ // unfiltered usage), so the provider filter is dropped in that mode. The
+ // caller (renderer) forces provider='all' when combined, so nothing is lost.
+ const buildOverviewArgs = (period: string, provider: string, range?: DateRange, configSource?: string | null, scope?: string): string[] => {
+ const vScopeValue = vScope(scope)
+ return [
+ 'status', '--format', 'menubar-json', '--period', vPeriod(period), '--no-timeline',
+ ...(vScopeValue === 'combined' ? ['--scope', 'combined'] : providerArgs(vProvider(provider))),
+ ...rangeArgs(vRange(range)), ...configSourceArgs(vConfigSource(configSource)),
+ ]
+ }
// `background` (renderer prefetch only) drops this fetch to background priority
// so it yields the CLI's run slots to any interactive poll or click. Optional
// and defaulting to interactive, so an older preload that omits it is unchanged.
- const getOverview: Handler = async (period: string, provider: string, range?: DateRange, configSource?: string | null, background?: boolean) => {
+ const getOverview: Handler = async (period: string, provider: string, range?: DateRange, configSource?: string | null, background?: boolean, scope?: string) => {
coldStartBegan ??= Date.now()
const priority: SpawnPriority | undefined = background ? 'background' : undefined
try {
- const args = buildOverviewArgs(period, provider, range, configSource)
+ const args = buildOverviewArgs(period, provider, range, configSource, scope)
if (overviewWarmed) return { ok: true, value: await deps.spawnCli(args, priority ? { priority } : undefined) }
const value = await deps.spawnCli(args, {
timeoutMs: WARMUP_TIMEOUT_MS,
diff --git a/app/electron/preload.ts b/app/electron/preload.ts
index 39426eb..1c4bc3e 100644
--- a/app/electron/preload.ts
+++ b/app/electron/preload.ts
@@ -20,7 +20,7 @@ async function invoke(channel: string, ...args: unknown[]): Promise {
// renderer-side where `window.codeburn` is declared as CodeburnBridge.
const bridge = {
getQuota: (force?: boolean) => invoke('codeburn:getQuota', force),
- getOverview: (period: string, provider: string, range?: DateRange, configSource?: string | null, background?: boolean) => invoke('codeburn:getOverview', period, provider, range, configSource, background),
+ getOverview: (period: string, provider: string, range?: DateRange, configSource?: string | null, background?: boolean, scope?: string) => invoke('codeburn:getOverview', period, provider, range, configSource, background, scope),
getPlans: (period: string) => invoke('codeburn:getPlans', period),
getActReport: () => invoke('codeburn:getActReport'),
getModels: (period: string, provider: string, byTask: boolean, range?: DateRange) => invoke('codeburn:getModels', period, provider, byTask, range),
diff --git a/app/electron/quota/codex.test.ts b/app/electron/quota/codex.test.ts
index b8e2de4..4e429d6 100644
--- a/app/electron/quota/codex.test.ts
+++ b/app/electron/quota/codex.test.ts
@@ -38,6 +38,153 @@ describe('Codex quota', () => {
expect(quota.details).toHaveLength(1)
})
+ // Shape captured from a live ChatGPT Enterprise workspace.
+ const enterpriseBody = {
+ plan_type: 'business',
+ rate_limit: null,
+ additional_rate_limits: null,
+ credits: { has_credits: false, unlimited: false, balance: null },
+ spend_control: {
+ reached: false,
+ individual_limit: {
+ source: 'workspace_spend_controls',
+ limit: '10000',
+ used: '3028.9909675121307',
+ remaining: '6971.009032487869',
+ used_percent: 30,
+ remaining_percent: 70,
+ reset_after_seconds: 441_896,
+ reset_at: 1_785_542_400,
+ },
+ },
+ rate_limit_reset_credits: { available_count: 0 },
+ }
+
+ it('surfaces the spend-control credit limit when there are no rate windows', () => {
+ const quota = decodeCodexUsage(enterpriseBody)
+ expect(quota.primary).toEqual({
+ label: 'Monthly usage limit · 3,029 / 10,000 credits',
+ percent: 0.3,
+ resetsAt: new Date(1_785_542_400 * 1000).toISOString(),
+ })
+ expect(quota.details).toEqual([quota.primary])
+ expect(quota.planLabel).toBe('Business')
+ expect(quota.footerLines).toEqual([])
+ })
+
+ it('keeps rate windows primary and appends the credit limit alongside them', () => {
+ const quota = decodeCodexUsage({
+ ...enterpriseBody,
+ rate_limit: { primary_window: { used_percent: 20, reset_at: 1_800_000_000, limit_window_seconds: 18_000 } },
+ })
+ expect(quota.primary?.label).toBe('5-hour')
+ expect(quota.details.map(row => row.label)).toEqual([
+ '5-hour',
+ 'Monthly usage limit · 3,029 / 10,000 credits',
+ ])
+ })
+
+ it.each([
+ ['top level', (limit: unknown) => ({ individual_limit: limit })],
+ ['camelCase key', (limit: unknown) => ({ spend_control: { individualLimit: limit } })],
+ ['nested in rate_limit', (limit: unknown) => ({ rate_limit: { individual_limit: limit } })],
+ ])('reads the credit limit positioned at %s', (_name, wrap) => {
+ const quota = decodeCodexUsage(wrap({ limit: 10_000, used: 2500, used_percent: 25 }))
+ expect(quota.primary?.percent).toBe(0.25)
+ expect(quota.primary?.label).toBe('Monthly usage limit · 2,500 / 10,000 credits')
+ })
+
+ it('derives the percent from remaining_percent, then from used/limit', () => {
+ const fromRemaining = decodeCodexUsage({ spend_control: { individual_limit: { limit: 10_000, remaining_percent: 70 } } })
+ expect(fromRemaining.primary?.percent).toBeCloseTo(0.3)
+ expect(fromRemaining.primary?.label).toBe('Monthly usage limit · 3,000 / 10,000 credits')
+
+ const fromRatio = decodeCodexUsage({ spend_control: { individual_limit: { limit: 400, used: 100 } } })
+ expect(fromRatio.primary?.percent).toBeCloseTo(0.25)
+ })
+
+ it('ignores a spend control with no usable limit', () => {
+ for (const individual_limit of [{ limit: 0, used: 5 }, { limit: null }, { used_percent: 40 }, null]) {
+ const quota = decodeCodexUsage({ spend_control: { individual_limit } })
+ expect(quota.primary).toBeNull()
+ expect(quota.details).toEqual([])
+ }
+ })
+
+ it('renders no row when the allowance is known but the draw on it is not', () => {
+ const quota = decodeCodexUsage({ spend_control: { individual_limit: { limit: 10_000, reset_at: 1_785_542_400 } } })
+ expect(quota.primary).toBeNull()
+ expect(quota.details).toEqual([])
+ })
+
+ it('treats a blank numeric string as absent, not as zero', () => {
+ const quota = decodeCodexUsage({ spend_control: { individual_limit: { limit: '10000', used: ' ', used_percent: 30 } } })
+ expect(quota.primary?.label).toBe('Monthly usage limit · 3,000 / 10,000 credits')
+ expect(decodeCodexUsage({ spend_control: { individual_limit: { limit: '' } } }).primary).toBeNull()
+ })
+
+ it('marks a spent-out allowance as reached', () => {
+ const quota = decodeCodexUsage({
+ spend_control: { reached: true, individual_limit: { limit: 10_000, used: 10_000, used_percent: 100 } },
+ })
+ expect(quota.primary?.label).toBe('Monthly usage limit · 10,000 / 10,000 credits · limit reached')
+ expect(quota.primary?.percent).toBe(1)
+ })
+
+ it('keeps overage counts truthful while clamping the bar', () => {
+ const quota = decodeCodexUsage({ spend_control: { individual_limit: { limit: 10_000, used: 12_000, used_percent: 120 } } })
+ expect(quota.primary?.label).toBe('Monthly usage limit · 12,000 / 10,000 credits')
+ expect(quota.primary?.percent).toBe(1)
+ })
+
+ it('keeps the implied overage when only the percent is given', () => {
+ const quota = decodeCodexUsage({ spend_control: { individual_limit: { limit: 10_000, used_percent: 120 } } })
+ expect(quota.primary?.label).toBe('Monthly usage limit · 12,000 / 10,000 credits')
+ expect(quota.primary?.percent).toBe(1)
+ })
+
+ it('skips a garbage alias instead of letting it mask a valid one', () => {
+ const quota = decodeCodexUsage({
+ spend_control: { individual_limit: 'bad', individualLimit: { limit: 100, usedPercent: 25 } },
+ })
+ expect(quota.primary?.label).toBe('Monthly usage limit · 25 / 100 credits')
+ const perField = decodeCodexUsage({
+ spend_control: { individual_limit: { limit: 100, used_percent: 'bad', usedPercent: 25 } },
+ })
+ expect(perField.primary?.percent).toBe(0.25)
+ })
+
+ it('survives a reset timestamp beyond the Date range', () => {
+ const quota = decodeCodexUsage({ spend_control: { individual_limit: { limit: 100, used_percent: 10, reset_at: 9_000_000_000_000 } } })
+ expect(quota.primary?.resetsAt).toBeNull()
+ expect(quota.primary?.percent).toBe(0.1)
+ })
+
+ it('says so when the account is credit-metered but uncapped', () => {
+ const quota = decodeCodexUsage({ plan_type: 'business', credits: { has_credits: true, unlimited: true } })
+ expect(quota.footerLines).toEqual(['Credits · Unlimited'])
+ const capped = decodeCodexUsage({ credits: { unlimited: true }, spend_control: { individual_limit: { limit: 10_000, used_percent: 30 } } })
+ expect(capped.footerLines).toEqual([])
+ })
+
+ it('normalizes credit-based-pricing plan tiers', () => {
+ const label = (plan_type: string) => decodeCodexUsage({ plan_type }).planLabel
+ expect(label('enterprise_cbp_usage_based')).toBe('Enterprise')
+ expect(label('self_serve_business_usage_based')).toBe('Business')
+ expect(label('enterprise')).toBe('Enterprise')
+ expect(label('some_future_tier')).toBe('Some Future Tier')
+ })
+
+ it('labels a credit-settled balance in credits, not dollars', () => {
+ const inCredits = decodeCodexUsage({ credits: { has_credits: true, balance: 3410.4 } })
+ expect(inCredits.footerLines).toEqual(['Credits remaining · 3,410'])
+ const inDollars = decodeCodexUsage({ credits: { has_credits: false, balance: 3.5 } })
+ expect(inDollars.footerLines).toEqual(['Credits remaining · $3.50'])
+ // Thousands separators must match the menubar's en_US currency formatter.
+ const inDollarsLarge = decodeCodexUsage({ credits: { has_credits: false, balance: 12500 } })
+ expect(inDollarsLarge.footerLines).toEqual(['Credits remaining · $12,500.00'])
+ })
+
it('returns disconnected without credentials', async () => {
const fetchMock = vi.fn()
const result = await fetchCodexQuota({ fetch: fetchMock, readFile: vi.fn(async () => null) })
diff --git a/app/electron/quota/codex.ts b/app/electron/quota/codex.ts
index b339d50..42b32df 100644
--- a/app/electron/quota/codex.ts
+++ b/app/electron/quota/codex.ts
@@ -133,10 +133,27 @@ function windowOf(value: unknown, override?: string): QuotaWindow | null {
return { label: override ?? labelForSeconds(row.limit_window_seconds), percent, resetsAt: reset }
}
+// chatgpt.com mixes encodings inside one payload. `Number('')` is 0, not NaN,
+// so blank is rejected or an absent `used` decodes as a confident zero.
+function num(value: unknown): number | null {
+ if (typeof value === 'string' && !value.trim()) return null
+ const parsed = typeof value === 'number' ? value : typeof value === 'string' ? Number(value.trim()) : NaN
+ return Number.isFinite(parsed) ? parsed : null
+}
+
+// Credit-based-pricing tiers arrive composite (`enterprise_cbp_usage_based`).
+function normalizePlanType(value: string): string {
+ return value
+ .replace(/[_-]usage[_-]based$/, '')
+ .replace(/^self[_-]serve[_-]/, '')
+ .replace(/[_-]cbp$/, '')
+ .replace(/[_-]cbp[_-]/g, '_')
+}
+
function planLabel(value: unknown): string | null {
if (typeof value !== 'string' || !value.trim()) return null
const raw = value.trim()
- const lower = raw.toLowerCase()
+ const lower = normalizePlanType(raw.toLowerCase())
const known: Record = {
guest: 'Guest', free: 'Free', go: 'Go', plus: 'Plus', pro: 'Pro',
prolite: 'Pro Lite', pro_lite: 'Pro Lite', 'pro-lite': 'Pro Lite',
@@ -146,6 +163,44 @@ function planLabel(value: unknown): string | null {
return known[lower] ?? lower.replace(/(^|[_-])\w/g, match => match.replace(/[_-]/, ' ').toUpperCase())
}
+// The admin-set monthly allowance, the only limit a credit-metered workspace
+// has. `spend_control` is the live position, the others forward-compat. `any`
+// because this walks six optional-chained hops, all validated by `num()`.
+function spendControlWindow(data: Record): QuotaWindow | null {
+ // `find`/`num` per alias, not `??`: a non-null garbage value would stop `??`
+ // and mask a valid alias further down. Object-shaped garbage still wins the
+ // position, matching Swift, which likewise commits to the first that decodes.
+ const row = [
+ data.spend_control?.individual_limit,
+ data.spend_control?.individualLimit,
+ data.individual_limit,
+ data.individualLimit,
+ data.rate_limit?.individual_limit,
+ data.rate_limit?.individualLimit,
+ ].find(candidate => candidate && typeof candidate === 'object')
+ if (!row) return null
+ const limit = num(row.limit)
+ if (limit === null || limit <= 0) return null
+ const remainingPercent = num(row.remaining_percent) ?? num(row.remainingPercent)
+ const used = num(row.used)
+ const rawPercent = num(row.used_percent) ?? num(row.usedPercent)
+ ?? (remainingPercent === null ? null : 100 - remainingPercent)
+ ?? (used === null ? null : (used / limit) * 100)
+ if (rawPercent === null) return null
+ const percent = Math.min(1, Math.max(0, rawPercent / 100))
+ const resetRaw = num(row.reset_at) ?? num(row.resets_at) ?? num(row.resetsAt)
+ // Past 8.64e15 ms `toISOString()` throws RangeError.
+ const resetsAt = resetRaw !== null && resetRaw > 0 && resetRaw * 1000 <= 8.64e15
+ ? new Date(resetRaw * 1000).toISOString()
+ : null
+ // Unclamped percent, so a 120% draw still reports 12,000 of 10,000.
+ const spent = used ?? limit * Math.max(0, rawPercent) / 100
+ const round = (n: number) => Math.round(n).toLocaleString('en-US')
+ const reached = data.spend_control?.reached === true
+ const label = `Monthly usage limit · ${round(spent)} / ${round(limit)} credits`
+ return { label: reached ? `${label} · limit reached` : label, percent, resetsAt }
+}
+
export function decodeCodexUsage(body: unknown): QuotaProvider {
const data = body && typeof body === 'object' ? body as Record : {}
const primaryRaw = windowOf(data.rate_limit?.primary_window)
@@ -165,12 +220,21 @@ export function decodeCodexUsage(body: unknown): QuotaProvider {
}
}
}
- const rawBalance = data.credits?.balance
- const balance = typeof rawBalance === 'number' ? rawBalance : typeof rawBalance === 'string' ? Number(rawBalance) : NaN
+ const credits = spendControlWindow(data)
+ if (credits) details.push(credits)
+ const balance = num(data.credits?.balance)
+ // Credit-settled accounts denominate in credits, so no currency symbol.
+ const hasCredits = data.credits?.has_credits === true
+ const footerLines: string[] = []
+ if (balance !== null && balance > 0) {
+ footerLines.push(`Credits remaining · ${hasCredits ? Math.round(balance).toLocaleString('en-US') : balance.toLocaleString('en-US', { style: 'currency', currency: 'USD' })}`)
+ }
+ // Uncapped on purpose, so a bar-less card does not read as a failed fetch.
+ if (!credits && data.credits?.unlimited === true) footerLines.push('Credits · Unlimited')
return {
- provider: 'codex', connection: 'connected', primary, details,
+ provider: 'codex', connection: 'connected', primary: primary ?? credits, details,
planLabel: planLabel(data.plan_type),
- footerLines: Number.isFinite(balance) && balance > 0 ? [`Credits remaining · $${balance.toFixed(2)}`] : [],
+ footerLines,
}
}
diff --git a/app/renderer/App.test.tsx b/app/renderer/App.test.tsx
index 81c7e09..8548917 100644
--- a/app/renderer/App.test.tsx
+++ b/app/renderer/App.test.tsx
@@ -17,7 +17,7 @@ vi.stubGlobal('localStorage', {
})
const mocks = vi.hoisted(() => ({
- getOverview: vi.fn<(period: string, provider: string, range?: DateRange, configSource?: string | null, background?: boolean) => Promise>(),
+ getOverview: vi.fn<(period: string, provider: string, range?: DateRange, configSource?: string | null, background?: boolean, scope?: string) => Promise>(),
getSpendFlow: vi.fn<(period: string, provider: string, range?: DateRange) => Promise>(),
getOptimizeReport: vi.fn<(period: string, provider: string, range?: DateRange) => Promise>(),
getModels: vi.fn(),
@@ -273,6 +273,25 @@ describe('App shortcuts', () => {
})
})
+ it('drives combined-scope overview fetches and persists the Scope setting', async () => {
+ render( )
+ await waitFor(() => expect(mocks.getOverview).toHaveBeenCalledWith('30days', 'all'))
+
+ fireEvent.keyDown(document, { key: ',', metaKey: true })
+ fireEvent.click(await screen.findByLabelText('Scope'))
+ fireEvent.click(await screen.findByRole('option', { name: 'Combined' }))
+
+ // Combined scope forces provider='all' and passes --scope combined (6th arg).
+ await waitFor(() => expect(mocks.getOverview).toHaveBeenCalledWith('30days', 'all', undefined, undefined, undefined, 'combined'))
+ expect(localStorage.getItem('codeburn.scope')).toBe('combined')
+ })
+
+ it('boots in combined scope from the persisted Scope setting', async () => {
+ localStorage.setItem('codeburn.scope', 'combined')
+ render( )
+ await waitFor(() => expect(mocks.getOverview).toHaveBeenCalledWith('30days', 'all', undefined, undefined, undefined, 'combined'))
+ })
+
it('builds the provider picker from providerDetails so display-name providers round-trip their internal id', async () => {
// grok's display name is "Grok Build"; the picker must show the label but
// send the internal id `grok` as --provider (which assertProvider accepts).
diff --git a/app/renderer/App.tsx b/app/renderer/App.tsx
index 1540bd3..a3110a2 100644
--- a/app/renderer/App.tsx
+++ b/app/renderer/App.tsx
@@ -27,7 +27,7 @@ import { Compare } from './sections/Compare'
import { Plans } from './sections/Plans'
import { Settings, type SettingsPane } from './sections/Settings'
import { SpendContent } from './sections/Spend'
-import type { DateRange, MenubarPayload, ModelReportRow, Period, TelemetryStatus } from './lib/types'
+import type { DateRange, MenubarPayload, ModelReportRow, Period, Scope, TelemetryStatus } from './lib/types'
// Bucket raw dollar amounts before they leave the machine: telemetry carries
// coarse ranges, never exact spend.
@@ -130,8 +130,8 @@ const STANDARD_PERIODS: Period[] = ['today', 'week', '30days', 'month', 'all', '
// Instant-switch memo key for an overview result. Shared by the overview poll
// and the provider prefetcher so the two never drift out of sync. Exported so
// the prefetch-storm test can assert warmed keys survive between polls.
-export function overviewMemoKey(provider: string, period: Period, range: DateRange | null, configSource: string | null): string {
- return `overview|${provider}|${period}|${range?.from ?? ''}-${range?.to ?? ''}|${configSource ?? ''}`
+export function overviewMemoKey(provider: string, period: Period, range: DateRange | null, configSource: string | null, scope: Scope = 'local'): string {
+ return `overview|${provider}|${period}|${range?.from ?? ''}-${range?.to ?? ''}|${configSource ?? ''}|${scope}`
}
// Prefetch pacing: wait a short idle after the first paint, then warm one
@@ -172,6 +172,15 @@ function persistConfigSource(id: string | null): void {
} catch { /* storage can be unavailable */ }
}
+/** Boot scope = the persisted dashboard Scope setting, else local. */
+function initialScope(): Scope {
+ try { return globalThis.localStorage?.getItem('codeburn.scope') === 'combined' ? 'combined' : 'local' } catch { return 'local' }
+}
+
+function persistScope(scope: Scope): void {
+ try { globalThis.localStorage?.setItem('codeburn.scope', scope) } catch { /* storage can be unavailable */ }
+}
+
function providerName(provider: string): string {
if (provider === 'all') return 'All providers'
return provider
@@ -218,20 +227,27 @@ function AppMain() {
const [detectedProviders, setDetectedProviders] = useState>([])
const [customRange, setCustomRange] = useState(null)
const [claudeConfigSource, setClaudeConfigSource] = useState(initialConfigSource)
+ const [scope, setScopeState] = useState(initialScope)
const [refreshToken, setRefreshToken] = useState(0)
const [now, setNow] = useState(() => Date.now())
const [, setCurrencyTick] = useState(0)
// Preserve the 2/3-arg call shapes when no config is scoped so the CLI argv
// stays flag-free; only add --claude-config-source once a config is picked.
+ // Combined scope aggregates paired-device usage; the CLI rejects it alongside
+ // a provider/config filter, so onScopeChange forces provider='all' and clears
+ // the config scope before this poll runs. Passing scope='local' produces the
+ // same flag-free argv as before, so local users are unaffected.
const overview = usePolled(
- () => claudeConfigSource
+ () => scope === 'combined'
+ ? codeburn.getOverview(period, 'all', customRange ?? undefined, undefined, undefined, 'combined')
+ : claudeConfigSource
? codeburn.getOverview(period, provider, customRange ?? undefined, claudeConfigSource)
: customRange
? codeburn.getOverview(period, provider, customRange)
: codeburn.getOverview(period, provider),
- [period, provider, customRange?.from, customRange?.to, claudeConfigSource],
- { memoKey: overviewMemoKey(provider, period, customRange, claudeConfigSource) },
+ [period, provider, customRange?.from, customRange?.to, claudeConfigSource, scope],
+ { memoKey: overviewMemoKey(provider, period, customRange, claudeConfigSource, scope) },
)
const refreshOverview = overview.refresh
@@ -273,7 +289,7 @@ function AppMain() {
// fails we still emit the snapshot, just without the model x category cross.
const snapshotDayRef = useRef(null)
useEffect(() => {
- if (!overview.data || provider !== 'all' || customRange || claudeConfigSource) return
+ if (!overview.data || provider !== 'all' || customRange || claudeConfigSource || scope !== 'local') return
const today = localDateKey(new Date())
if (snapshotDayRef.current === today) return
snapshotDayRef.current = today
@@ -285,7 +301,7 @@ function AppMain() {
} catch { /* degrade: emit the snapshot without per-model topCategory */ }
trackEvent('usage_snapshot', usageSnapshotProps(payload, modelCategories))
})()
- }, [overview.data, provider, customRange, claudeConfigSource, period, trackEvent])
+ }, [overview.data, provider, customRange, claudeConfigSource, scope, period, trackEvent])
useEffect(() => {
let saved: string | null = null
@@ -360,7 +376,9 @@ function AppMain() {
overviewBusyRef.current = overview.loading
const warmedKeys = useRef>(new Set())
useEffect(() => {
- if (!ready || overview.data == null || customRange || claudeConfigSource) return
+ // Combined scope has no provider picker to warm — it always shows unfiltered
+ // all-device usage — so the per-provider prefetch is local-scope only.
+ if (!ready || overview.data == null || customRange || claudeConfigSource || scope !== 'local') return
const targets = detectedProviders.map(entry => entry.id).filter(id => id !== provider)
if (targets.length === 0) return
let cancelled = false
@@ -391,7 +409,7 @@ function AppMain() {
// `overview.data == null` (a boolean) gates on first-resolution without
// re-running every poll; the data content itself is intentionally not a dep.
// eslint-disable-next-line react-hooks/exhaustive-deps
- }, [ready, period, provider, customRange, claudeConfigSource, detectedProviders, overview.data == null])
+ }, [ready, period, provider, customRange, claudeConfigSource, scope, detectedProviders, overview.data == null])
useEffect(() => {
const id = window.setInterval(() => setNow(Date.now()), 1000)
@@ -451,17 +469,22 @@ function AppMain() {
// A Claude config scopes Claude usage only, so a non-Claude provider filter
// would make the CLI reject the flag: reset it to 'all' first (a 'claude'
- // filter is already compatible and is left alone).
+ // filter is already compatible and is left alone). Picking a config also
+ // implies a device-specific view, so drop combined scope back to local.
const onConfigSelect = (id: string) => {
const next = id || null
if (next && provider !== 'all' && provider !== 'claude') setProvider('all')
+ if (next && scope === 'combined') { setScopeState('local'); persistScope('local') }
setClaudeConfigSource(next)
persistConfigSource(next)
}
// Symmetric direction: picking a non-Claude provider while a config is
- // scoped would hit the same CLI rejection, so drop the config scope.
+ // scoped would hit the same CLI rejection, so drop the config scope. A
+ // specific provider filter is a device-specific view, so it also drops
+ // combined scope back to local (combined reports unfiltered usage).
const onProviderSelect = (value: string) => {
+ if (value !== 'all' && scope === 'combined') { setScopeState('local'); persistScope('local') }
if (claudeConfigSource && value !== 'all' && value !== 'claude') {
setClaudeConfigSource(null)
persistConfigSource(null)
@@ -469,6 +492,19 @@ function AppMain() {
setProvider(value)
}
+ // Combined scope reports unfiltered, all-provider usage across paired devices,
+ // so switching to it resets the provider filter and Claude-config scope (which
+ // the CLI would otherwise reject), mirroring the menubar's setMenubarScope.
+ const onScopeChange = (value: string) => {
+ const next: Scope = value === 'combined' ? 'combined' : 'local'
+ if (next === 'combined') {
+ if (provider !== 'all') setProvider('all')
+ if (claudeConfigSource) { setClaudeConfigSource(null); persistConfigSource(null) }
+ }
+ setScopeState(next)
+ persistScope(next)
+ }
+
const claudeConfigs = overview.data?.claudeConfigs
const providerOptions = [
{ value: 'all', label: 'All providers' },
@@ -478,7 +514,11 @@ function AppMain() {
const activeConfigLabel = claudeConfigSource
? claudeConfigs?.options.find(option => option.id === claudeConfigSource)?.label ?? null
: null
- const scope = `${customRange ? rangeLabel(customRange) : PERIOD_LABELS[period]} · ${providerLabel}${activeConfigLabel ? ` · ${activeConfigLabel}` : ''}`
+ // Combined scope reports unfiltered all-device usage, so the caption reads
+ // "Combined" in place of the (forced-'all') provider label.
+ const scopeCaption = scope === 'combined'
+ ? `${customRange ? rangeLabel(customRange) : PERIOD_LABELS[period]} · Combined`
+ : `${customRange ? rangeLabel(customRange) : PERIOD_LABELS[period]} · ${providerLabel}${activeConfigLabel ? ` · ${activeConfigLabel}` : ''}`
return (
@@ -494,12 +534,12 @@ function AppMain() {
{section === 'plans' ? (
) : section === 'settings' ? (
-
+
) : (
<>
{section === 'overview' ? (
-
+
) : section === 'sessions' ? (
) : section === 'pullRequests' ? (
diff --git a/app/renderer/lib/types.ts b/app/renderer/lib/types.ts
index 14e2d80..faf00a9 100644
--- a/app/renderer/lib/types.ts
+++ b/app/renderer/lib/types.ts
@@ -6,6 +6,10 @@
export type Period = 'today' | 'week' | '30days' | 'month' | 'all' | 'lifetime'
+// Dashboard usage scope: this device only ('local') or the aggregate across
+// every paired device ('combined'). Mirrors the macOS menubar's Scope setting.
+export type Scope = 'local' | 'combined'
+
export type DateRange = { from: string; to: string }
export type CliErrorKind = 'not-found' | 'nonzero' | 'bad-json' | 'timeout' | 'too-large' | 'bad-args'
@@ -637,7 +641,9 @@ export interface CodeburnBridge {
getQuota(force?: boolean): Promise
// `background` (prefetch only) requests background CLI-spawn priority; optional
// so an older preload that ignores it degrades to interactive priority.
- getOverview(period: Period, provider: string, range?: DateRange, configSource?: string | null, background?: boolean): Promise
+ // `scope` selects local-device usage ('local', default) or paired-device
+ // aggregate ('combined'); optional so an older preload degrades to local.
+ getOverview(period: Period, provider: string, range?: DateRange, configSource?: string | null, background?: boolean, scope?: string): Promise
getPlans(period: Period): Promise
getActReport(): Promise
readonly platform: string
diff --git a/app/renderer/sections/Overview.test.tsx b/app/renderer/sections/Overview.test.tsx
index 3138792..1093eeb 100644
--- a/app/renderer/sections/Overview.test.tsx
+++ b/app/renderer/sections/Overview.test.tsx
@@ -520,6 +520,49 @@ describe('Overview', () => {
expect(screen.queryByText('Saved to date')).not.toBeInTheDocument()
})
+ it('shows paired-device aggregate totals in the hero under combined scope', async () => {
+ const now = new Date()
+ const payload = makePayload(now)
+ // Local device: $312.40 / 4200 calls / 88 sessions (from makePayload).
+ // Combined swaps the hero to the cross-device aggregate and lists devices.
+ payload.combined = {
+ perDevice: [
+ { id: 'local', name: 'laptop', local: true, cost: 312.4, calls: 4200, sessions: 88, inputTokens: 0, outputTokens: 0, cacheCreateTokens: 0, cacheReadTokens: 0, totalTokens: 0 },
+ { id: 'fp-workstation', name: 'workstation', local: false, cost: 187.6, calls: 2100, sessions: 40, inputTokens: 0, outputTokens: 0, cacheCreateTokens: 0, cacheReadTokens: 0, totalTokens: 0 },
+ ],
+ combined: { cost: 500, calls: 6300, sessions: 128, inputTokens: 0, outputTokens: 0, cacheCreateTokens: 0, cacheReadTokens: 0, totalTokens: 0, deviceCount: 2, reachableCount: 2 },
+ }
+
+ const { container } = render( )
+
+ const kpis = container.querySelector('.ov-hero-main') as HTMLElement
+ // Hero cost is the combined $500, not the local $312.40.
+ expect(within(kpis).getByText('$500.00')).toBeInTheDocument()
+ expect(within(kpis).getByText(/6,300 calls · 128 sessions/)).toBeInTheDocument()
+ expect(within(kpis).getByText('Combined · Last 30 days')).toBeInTheDocument()
+ expect(within(kpis).getByText('2 of 2 devices')).toBeInTheDocument()
+ expect(within(kpis).getByText('workstation')).toBeInTheDocument()
+ expect(within(kpis).getByText('laptop · this device')).toBeInTheDocument()
+ // Combined mode hides the local savings lines (they are device-specific).
+ expect(within(kpis).queryByText('Saved via local models')).not.toBeInTheDocument()
+ })
+
+ it('keeps local hero totals when scope is local even if a combined payload is present', async () => {
+ const now = new Date()
+ const payload = makePayload(now)
+ payload.combined = {
+ perDevice: [],
+ combined: { cost: 999, calls: 1, sessions: 1, inputTokens: 0, outputTokens: 0, cacheCreateTokens: 0, cacheReadTokens: 0, totalTokens: 0, deviceCount: 2, reachableCount: 2 },
+ }
+
+ const { container } = render( )
+
+ const kpis = container.querySelector('.ov-hero-main') as HTMLElement
+ expect(within(kpis).getByText('$312.40')).toBeInTheDocument()
+ expect(within(kpis).queryByText('$999.00')).not.toBeInTheDocument()
+ expect(within(kpis).queryByText(/devices/)).not.toBeInTheDocument()
+ })
+
it('shows a stale banner when last-good data is present but the latest poll failed', async () => {
const now = new Date()
const overview: Polled = {
diff --git a/app/renderer/sections/Overview.tsx b/app/renderer/sections/Overview.tsx
index 06cb9c6..b2a9dd2 100644
--- a/app/renderer/sections/Overview.tsx
+++ b/app/renderer/sections/Overview.tsx
@@ -15,10 +15,12 @@ import { codeburn } from '../lib/ipc'
import { contiguousDailyWindow, dataStartKey, formatChartDate, localDateKey, sliceDailyToPeriod, sliceDailyToRange } from '../lib/period'
import type {
ActReportJson,
+ CombinedUsage,
DailyHistoryEntry,
DateRange,
MenubarPayload,
Period,
+ Scope,
YieldJsonReport,
} from '../lib/types'
@@ -650,6 +652,23 @@ export function Overview({ period, provider }: { period: Period; provider: strin
return
}
+/** Combined-scope hero footer: a per-device cost breakdown plus a reachable/
+ * total device count, mirroring the menubar's combined view. An unreachable
+ * device (powered off, off-network) shows its error in place of a cost. */
+function CombinedDevices({ usage }: { usage: CombinedUsage }) {
+ return (
+
+
{usage.combined.reachableCount} of {usage.combined.deviceCount} devices
+ {usage.perDevice.map(device => (
+
+ {device.local ? `${device.name} · this device` : device.name}
+ {device.error ?? formatUsd(device.cost)}
+
+ ))}
+
+ )
+}
+
export function OverviewContent({
period,
provider = 'all',
@@ -657,6 +676,7 @@ export function OverviewContent({
overview,
onNavigate,
ready = true,
+ scope = 'local',
}: {
period: Period
provider?: string
@@ -664,6 +684,7 @@ export function OverviewContent({
overview: Polled
onNavigate?: (section: 'optimize' | 'sessions') => void
ready?: boolean
+ scope?: Scope
}) {
// Gate secondary spawns on the app-level readiness (first overview resolved),
// so the cold hydration runs once (via overview) rather than 3 parses at once
@@ -680,7 +701,14 @@ export function OverviewContent({
const now = new Date()
const rangeActive = !!range
- const animateKey = `${period}|${provider}|${range?.from ?? ''}|${range?.to ?? ''}`
+ // Combined scope shows the paired-device aggregate in the hero KPIs, mirroring
+ // the menubar. Only the hero totals are aggregated; the detailed panels below
+ // (daily chart, models) stay local — the combined payload carries totals only.
+ const combined = scope === 'combined' ? data.combined : undefined
+ const heroCost = combined ? combined.combined.cost : data.current.cost
+ const heroCalls = combined ? combined.combined.calls : data.current.calls
+ const heroSessions = combined ? combined.combined.sessions : data.current.sessions
+ const animateKey = `${period}|${provider}|${range?.from ?? ''}|${range?.to ?? ''}|${scope}`
const stats = deriveStats(data, now)
const periodDaily = sliceDailyToPeriod(data.history.daily, period, now)
// Daily chart: contiguous zero-filled calendar window. A custom range spans
@@ -715,15 +743,21 @@ export function OverviewContent({
{error && }
-
{data.current.label} {streakDays(data.history.daily, now)} -day streak
-
-
{data.current.calls.toLocaleString('en-US')} calls · {data.current.sessions.toLocaleString('en-US')} sessions
- {saved > 0 && (
-
Saved by applied fixes {formatUsd(saved)} across {applied} {applied === 1 ? 'fix' : 'fixes'}
- )}
- {localSaved > 0 && (
-
Saved via local models {formatUsd(localSaved)} local-model routing
- )}
+
{combined ? `Combined · ${data.current.label}` : data.current.label} {streakDays(data.history.daily, now)} -day streak
+
+
{heroCalls.toLocaleString('en-US')} calls · {heroSessions.toLocaleString('en-US')} sessions
+ {combined
+ ?
+ : (
+ <>
+ {saved > 0 && (
+
Saved by applied fixes {formatUsd(saved)} across {applied} {applied === 1 ? 'fix' : 'fixes'}
+ )}
+ {localSaved > 0 && (
+
Saved via local models {formatUsd(localSaved)} local-model routing
+ )}
+ >
+ )}
diff --git a/app/renderer/sections/Settings.test.tsx b/app/renderer/sections/Settings.test.tsx
index c2d1282..58aa987 100644
--- a/app/renderer/sections/Settings.test.tsx
+++ b/app/renderer/sections/Settings.test.tsx
@@ -145,6 +145,17 @@ describe('Settings', () => {
expect(localStorage.getItem('codeburn.dailyBudget')).toBeFalsy()
})
+ it('reflects the current scope and reports a change through onScopeChange', async () => {
+ const user = userEvent.setup()
+ const onScopeChange = vi.fn()
+ render(
)
+ const scope = screen.getByLabelText('Scope')
+ expect(scope).toHaveTextContent('Local')
+ await user.click(scope)
+ await user.click(screen.getByRole('option', { name: 'Combined' }))
+ expect(onScopeChange).toHaveBeenCalledWith('combined')
+ })
+
it('lists providers from the real overview payload', async () => {
const user = userEvent.setup()
render(
)
diff --git a/app/renderer/sections/Settings.tsx b/app/renderer/sections/Settings.tsx
index 17666f2..f3af6fe 100644
--- a/app/renderer/sections/Settings.tsx
+++ b/app/renderer/sections/Settings.tsx
@@ -18,7 +18,7 @@ import { REFRESH_OPTIONS, useRefreshCadence } from '../lib/refreshCadence'
import { showToast } from '../lib/toast'
import { ToastHost } from '../components/ToastHost'
import { rateLimitedNote } from './Plans'
-import type { ActionResult, AliasRow, ClaudeConfigSelector, CliError, CombinedUsage, DeviceScanResult, Identity, JsonPlanSummary, MenubarPayload, Period, PlanId, PlanProvider, PriceOverrideList, PriceOverrideRow, PriceRates, QuotaProvider, ShareStatus, StatusJson, TelemetryStatus } from '../lib/types'
+import type { ActionResult, AliasRow, ClaudeConfigSelector, CliError, CombinedUsage, DeviceScanResult, Identity, JsonPlanSummary, MenubarPayload, Period, PlanId, PlanProvider, PriceOverrideList, PriceOverrideRow, PriceRates, QuotaProvider, Scope, ShareStatus, StatusJson, TelemetryStatus } from '../lib/types'
export type SettingsPane = 'general' | 'providers' | 'aliases' | 'pricing' | 'plans' | 'devices' | 'export' | 'privacy'
type Pane = SettingsPane
@@ -97,7 +97,7 @@ function ConfirmButton({ label, prompt, onConfirm }: { label: string; prompt: st
)
}
-export function Settings({ period, refreshToken = 0, onNavigate, initialPane, claudeConfigs, claudeConfigSource = null, onConfigMutated }: { period: Period; refreshToken?: number; onNavigate?: (section: Section) => void; initialPane?: SettingsPane; claudeConfigs?: ClaudeConfigSelector; claudeConfigSource?: string | null; onConfigMutated?: () => void }) {
+export function Settings({ period, refreshToken = 0, onNavigate, initialPane, claudeConfigs, claudeConfigSource = null, onConfigMutated, scope = 'local', onScopeChange }: { period: Period; refreshToken?: number; onNavigate?: (section: Section) => void; initialPane?: SettingsPane; claudeConfigs?: ClaudeConfigSelector; claudeConfigSource?: string | null; onConfigMutated?: () => void; scope?: Scope; onScopeChange?: (scope: string) => void }) {
const [pane, setPane] = useState
(initialPane ?? 'general')
return (
@@ -113,7 +113,7 @@ export function Settings({ period, refreshToken = 0, onNavigate, initialPane, cl
))}
- {pane === 'general' && }
+ {pane === 'general' && }
{pane === 'providers' && }
{pane === 'aliases' && }
{pane === 'pricing' && }
@@ -128,7 +128,7 @@ export function Settings({ period, refreshToken = 0, onNavigate, initialPane, cl
)
}
-function GeneralPane({ period, refreshToken, claudeConfigs, claudeConfigSource, onConfigMutated }: { period: Period; refreshToken: number; claudeConfigs?: ClaudeConfigSelector; claudeConfigSource: string | null; onConfigMutated?: () => void }) {
+function GeneralPane({ period, refreshToken, claudeConfigs, claudeConfigSource, onConfigMutated, scope = 'local', onScopeChange }: { period: Period; refreshToken: number; claudeConfigs?: ClaudeConfigSelector; claudeConfigSource: string | null; onConfigMutated?: () => void; scope?: Scope; onScopeChange?: (scope: string) => void }) {
const [currencyNonce, setCurrencyNonce] = useState(0)
const plans = usePolled(() => codeburn.getPlans(period), [period, refreshToken, currencyNonce])
const [theme, setTheme] = useState(() => {
@@ -202,6 +202,7 @@ function GeneralPane({ period, refreshToken, claudeConfigs, claudeConfigSource,
void codeburn.resetCurrency().then(finishCurrency)}>Reset to USD
Default periodApplied on next launch. { setDefaultPeriod(value); writeSetting('codeburn.defaultPeriod', value) }} width={92} />
+ ScopeCombined aggregates usage across every paired device, like the menubar. Local shows this device only. onScopeChange?.(value)} width={110} />
Refresh everyHow often data auto-refreshes. Manual updates only on ⌘R. ({ value: option.value, label: option.label }))} onChange={cadence.setValue} width={124} />
Daily budgetWarns at 80%, alerts at 100%. { const kind = value as 'off' | 'usd' | 'tokens'; setBudgetKind(kind); persistBudget(kind, budgetInput) }} width={120} />{budgetKind !== 'off' && { setBudgetInput(event.target.value); persistBudget(budgetKind, event.target.value) }} style={{ width: 90 }} />}
{budgetError && {budgetError}
}
diff --git a/app/renderer/styles/plain.css b/app/renderer/styles/plain.css
index 64b45c5..f82172b 100644
--- a/app/renderer/styles/plain.css
+++ b/app/renderer/styles/plain.css
@@ -512,6 +512,12 @@ td:first-child { font-size: var(--fs-body); font-weight: var(--fw-body); }
.ov-saved-line { display: flex; flex-wrap: wrap; align-items: baseline; gap: 3px 7px; margin-top: 5px; padding-top: 9px; border-top: 1px solid var(--line2); color: var(--mut2); font-size: 10.5px; }
.ov-saved-line strong { color: var(--ok); font-family: var(--mono); font-size: 13px; font-weight: 650; font-variant-numeric: tabular-nums; }
.ov-saved-line small { color: var(--mut2); font-size: 10px; }
+.ov-combined-devices { width: 100%; margin-top: 5px; padding-top: 9px; border-top: 1px solid var(--line2); display: flex; flex-direction: column; gap: 3px; }
+.ov-combined-head { color: var(--mut2); font-size: 10.5px; font-weight: 560; text-transform: uppercase; letter-spacing: 0.03em; margin-bottom: 2px; }
+.ov-combined-row { display: flex; align-items: baseline; justify-content: space-between; gap: 10px; font-size: 11.5px; color: var(--mut); }
+.ov-combined-row .ov-combined-name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+.ov-combined-row .ov-combined-val { font-family: var(--mono); font-variant-numeric: tabular-nums; color: var(--ink); }
+.ov-combined-row.err .ov-combined-val { color: var(--warn); font-family: inherit; }
.ov-hero-split .ov-heatmap-bare { display: flex; flex-direction: column; justify-content: space-between; gap: 8px; }
.ov-activity-head { display: flex; align-items: baseline; gap: 8px; }
.ov-hero-sub .neutral { color: var(--mut); font-weight: 560; }
diff --git a/assets/open-source-recipient.png b/assets/open-source-recipient.png
new file mode 100644
index 0000000..9c54891
Binary files /dev/null and b/assets/open-source-recipient.png differ
diff --git a/docs/providers/codex.md b/docs/providers/codex.md
index 505b308..b68847d 100644
--- a/docs/providers/codex.md
+++ b/docs/providers/codex.md
@@ -48,6 +48,110 @@ A session that yielded zero parseable lines does **not** write to the cache (`co
- `prev*` token counters are advanced on **every** event, including ones that used `last_token_usage`. Earlier code only updated them on the fallback branch, which double-counted any session that mixed modes.
- OpenAI counts cached tokens **inside** `input_tokens`. The parser subtracts them so the rest of the codebase can assume Anthropic semantics (cached are separate).
+## Live quota (ChatGPT subscription)
+
+Separate from the log parser above: the desktop app and the macOS menubar read
+live quota from `GET https://chatgpt.com/backend-api/wham/usage` using the Codex
+OAuth token. Two independent implementations of the same decoder, which must be
+kept in sync:
+
+- `app/electron/quota/codex.ts`: `decodeCodexUsage()` is the pure, exported decoder.
+- `mac/Sources/CodeBurnMenubar/Data/CodexSubscriptionService.swift`: `decodeUsage()`.
+
+### Seat-based plans (Plus, Pro, Team)
+
+`rate_limit.primary_window` / `secondary_window` carry `used_percent`,
+`reset_at` and `limit_window_seconds`. The window *label* is inferred from the
+duration (5-hour, Weekly, …), never from the plan, because window size is dynamic per
+account. `additional_rate_limits[]` holds per-model limits (Codex Spark, etc.)
+and is only surfaced when utilization is non-zero.
+
+### Credit-metered plans (Business, Edu, Enterprise on flexible pricing)
+
+These workspaces have **no rate-limit windows**: `rate_limit` comes back
+`null`. Usage scales with credits, and an admin sets a monthly per-user credit
+allowance. That allowance is the account's only limit and lives in
+`spend_control`:
+
+```jsonc
+"spend_control": {
+ "reached": false,
+ "individual_limit": {
+ "source": "workspace_spend_controls",
+ "limit": "10000", // string
+ "used": "3028.9909675121307", // string
+ "used_percent": 30, // number
+ "remaining_percent": 70,
+ "reset_after_seconds": 441896, // time *remaining*, not window length
+ "reset_at": 1785542400
+ }
+}
+```
+
+Notes that have bitten us:
+
+- **Number encodings are mixed within the same object**: `limit` and `used`
+ arrive as strings while `used_percent` arrives as a number. Every numeric
+ field is decoded flexibly (number | string) on both sides.
+- **`reset_after_seconds` is not the window length.** Pace projection needs the
+ whole-window duration, so it is derived as the calendar month preceding
+ `reset_at`, resolved in **UTC**: `reset_at` is a UTC boundary, and a local
+ calendar would make the month length depend on the viewer's timezone (a
+ 2026-03-01Z reset spans 28 days in UTC but 31 in Toronto).
+- Two other positions for this object have been observed in other clients
+ (top-level `individual_limit`, and nested under `rate_limit`), in both
+ snake_case and camelCase. All are accepted; `spend_control` wins.
+- `credits.has_credits` means the account settles in **credits, not dollars**, so
+ `credits.balance` must not be rendered with a currency symbol in that case.
+ `credits.unlimited` means credit-metered but deliberately uncapped.
+- **`has_credits` is not "is credit-metered".** The live Enterprise workspace
+ above is credit-metered (it has a `spend_control` allowance) yet reports
+ `has_credits: false` with a `null` balance, so the flag tracks whether the
+ account holds a *credit balance*, which is orthogonal to the allowance. Do not
+ derive one from the other. The `has_credits: true` rendering path has not been
+ observed against a real account; if a seat-based account ever reports it
+ alongside a dollar balance, the footer would drop the `$` and round to whole
+ units.
+
+### `plan_type` cannot distinguish Business from Enterprise
+
+A live ChatGPT **Enterprise** workspace reports `plan_type: "business"` on this
+endpoint, and the `id_token`'s `https://api.openai.com/auth → chatgpt_plan_type`
+claim says `"business"` too, even though ChatGPT's own workspace switcher
+displays "Enterprise". Neither source carries the distinction, so the label
+CodeBurn shows is faithfully what OpenAI returns. Do not try to infer a tier
+from the presence of a spend control.
+
+The switcher renders from the accounts endpoints, and **those are not reachable
+with a Codex token**, verified against a live Enterprise workspace:
+
+| Endpoint | Result |
+| --- | --- |
+| `/backend-api/accounts/check/v4-2023-04-27` | 403 |
+| `/backend-api/accounts/check` | 403 |
+| `/backend-api/me` | 403 |
+| `/backend-api/settings/account_user_setting` | 403 |
+
+Not an expiry or a missing-header problem: the same token returns 200 on
+`/wham/usage` (and on `/backend-api/gizmo_creator_profile`) in the same run. The
+Codex OAuth access token carries scopes `openid profile email offline_access
+api.connectors.read api.connectors.invoke` with audience
+`https://api.openai.com/v1`, with no ChatGPT web-app account scope, so the accounts
+surfaces reject it by design. Adding a `ChatGPT-Account-Id` header does not
+change this. **Business is therefore the correct label to display**; closing
+this gap would need a different credential, not a different endpoint.
+
+Composite tiers (`enterprise_cbp_usage_based`, `self_serve_business_usage_based`)
+*are* normalized down to their base tier before lookup.
+
+### Reset credits
+
+`rate_limit_reset_credits` is carried inline on the usage payload
+(`available_count`). The dedicated `GET /wham/rate-limit-reset-credits`
+endpoint is only called when the inline block is absent. It is the sole source
+of per-credit `expires_at` values, so the "next expires" caption is omitted on
+the inline path.
+
## When fixing a bug here
1. Reproduce against a real `rollout-*.jsonl` if you can. Drop a redacted copy under `tests/fixtures/codex/` and reference it from `tests/providers/codex.test.ts`.
diff --git a/docs/sync/README.md b/docs/sync/README.md
index 5f1343e..64540b8 100644
--- a/docs/sync/README.md
+++ b/docs/sync/README.md
@@ -48,6 +48,9 @@ codeburn sync push --since 30d
# Preview what would be sent
codeburn sync push --dry-run
+
+# Also push git attribution (opt-in — see "Git attribution" below)
+codeburn sync push --attribution
```
### `codeburn sync status`
@@ -95,6 +98,36 @@ Each AI interaction becomes one OTLP span with these attributes:
A pseudonymous `device_id` distinguishes your machines without revealing hostnames.
+### Git attribution (opt-in: `--attribution`)
+
+`codeburn sync push --attribution` additionally sends the session→commit correlation that `codeburn yield` computes locally, so the backend can join AI usage to git activity without git hooks. Two extra span types are emitted:
+
+**`codeburn.session.attribution`** — one per session with joinable evidence:
+
+| Field | Example | Description |
+|---|---|---|
+| `ai.session_id` | `abc123…` | Session (shares the usage spans' traceId) |
+| `ai.project` | `my-app` | Project name |
+| `git.repo` | `github.com/acme/widget` | Normalized `origin` remote (credentials and ports stripped) |
+| `git.pr_links` | `["…/pull/12"]` | PR URLs captured for the session |
+| `git.commit_count` | `2` | Number of attributed commits |
+
+**`codeburn.commit`** — one per commit attributed to a session:
+
+| Field | Example | Description |
+|---|---|---|
+| `git.sha` | `4f2a…` | Commit SHA |
+| `git.in_main` | `true` | Whether the commit landed in the main branch |
+| `git.was_reverted` | `false` | Whether a later commit reverted it |
+
+Attribution is **inferred** (timestamp-window correlation, the same heuristic as `codeburn yield`); the resource attribute `codeburn.attribution_methodology: timestamp-window` marks it as such. State transitions (a commit merging to main, or being reverted) are re-sent automatically on later pushes — receivers should upsert commits by `(git.repo, git.sha)` and session spans by `ai.session_id` (latest state wins). When a commit migrates to a later-parsed session with a tighter window, the losing session re-emits with `git.commit_count: 0` (a retraction), so summing `git.commit_count` across upserted session rows never double-counts. Retractions fire only when the commit was won by another session — commits that merely age out of the `--since` window are not retracted, so a previously-synced count stays correct. Session spans also re-emit when an ongoing session's window grows, keeping the span end time current.
+
+With `--attribution`, normalized repo remote URLs, commit SHAs, commit timestamps (span start times), PR URLs, and the merged/reverted booleans leave your machine — plus the same pseudonymous `codeburn.device_id` resource attribute the usage spans carry. PR links are rebuilt client-side from scheme + host + path only (userinfo, query strings, and fragments are dropped; https, `/org/repo/pull/N` path, bounded length, max 20 per session), and the repo identity itself passes a strict hostname/path allow-list before sending — malformed or transport-helper remotes (`ext::…`, `codecommit::…`) are rejected outright rather than parsed. Precisely what is and is not sent:
+
+- **Commits**: only from repos with a network `origin` remote, and only for sessions whose own project path resolved to that repo. Local-only repos, `file://` remotes, and Windows filesystem paths are never emitted as repo identities. A session whose project path no longer resolves never inherits the repo of the directory you happen to push from.
+- **PR links**: sent whenever a session captured them, even when the session's repo could not be identified — the PR URL itself names the repo, so this adds no information beyond the link the session already recorded.
+- Without the flag, none of this is sent.
+
### What is NOT sent
- **Prompts** — your actual messages to AI are never included
@@ -102,7 +135,7 @@ A pseudonymous `device_id` distinguishes your machines without revealing hostnam
- **Bash commands** — may contain secrets, never sent
- **Your name/email** — identity is derived server-side from your login token
-There is no flag to override this. Privacy is structural, not configurable.
+There is no flag to override this. Privacy is structural, not configurable. The only additive opt-in is `--attribution` (repo remotes, commit SHAs, and PR URLs — never code or prompts), described above.
## Authentication
diff --git a/mac/Sources/CodeBurnMenubar/AppStore.swift b/mac/Sources/CodeBurnMenubar/AppStore.swift
index 32c9cdd..ca8f026 100644
--- a/mac/Sources/CodeBurnMenubar/AppStore.swift
+++ b/mac/Sources/CodeBurnMenubar/AppStore.swift
@@ -271,6 +271,38 @@ final class AppStore {
cache[menubarStatusKey]?.payload
}
+ private var menubarCombinedKey: PayloadCacheKey {
+ PayloadCacheKey(scope: .combined, period: menubarPeriod, provider: .all, day: nil, claudeConfigSourceId: selectedClaudeConfigSourceId)
+ }
+
+ /// Cross-device totals for the menubar badge's period, used so the badge
+ /// figure matches the popover hero under combined scope. `nil` under local
+ /// scope, or when no combined payload for the badge period is cached yet
+ /// (cold start, or the peer is unreachable) — the badge then falls back to
+ /// the local figure, exactly like the popover.
+ var menubarBadgeCombined: CombinedUsageTotals? {
+ guard effectiveSelectedScope == .combined else { return nil }
+ return cache[menubarCombinedKey]?.payload.combined?.combined
+ }
+
+ /// Refresh the payloads the badge renders for `period`: always the local
+ /// figure, plus the combined cross-device total when combined scope is
+ /// active. Combined is best-effort — a slow or unreachable peer degrades to
+ /// the local figure — so the local fetch alone determines success.
+ @discardableResult
+ func refreshMenubarBadge(period: Period, force: Bool = false, qualityOfService: QualityOfService = .userInitiated) async -> Bool {
+ async let local = refreshQuietly(period: period, force: force, qualityOfService: qualityOfService)
+ guard effectiveSelectedScope == .combined else { return await local }
+ async let combined = refreshQuietly(
+ key: PayloadCacheKey(scope: .combined, period: period, provider: .all, day: nil, claudeConfigSourceId: selectedClaudeConfigSourceId),
+ includeOptimize: false,
+ force: force,
+ qualityOfService: qualityOfService
+ )
+ let (localSucceeded, _) = await (local, combined)
+ return localSucceeded
+ }
+
/// All-provider payload for the selected period. Used by the tab strip to show
/// per-provider costs that match the active period, not just today.
var periodAllPayload: MenubarPayload? {
@@ -1378,19 +1410,38 @@ final class AppStore {
details.append(.init(label: "\(extra.name) · \(s.windowLabel)", percent: s.usedPercent / 100, resetsAt: s.resetsAt))
}
}
+ // No rate windows here, so the allowance feeds the bar and badge.
+ if let credits = usage.creditLimit {
+ let row = QuotaSummary.Window(
+ label: credits.shortLabel,
+ percent: credits.usedPercent / 100,
+ resetsAt: credits.resetsAt
+ )
+ if primary == nil { primary = row }
+ details.append(row)
+ }
}
let plan = codexUsage?.plan.displayName
var footerLines: [String] = []
if let balance = codexUsage?.creditsBalance, balance > 0 {
- // Format as plain dollars; ChatGPT settles in USD regardless of
- // the user's display-currency preference.
+ // Credit-settled accounts denominate in credits, so no symbol.
+ let inCredits = codexUsage?.hasCredits == true
let formatter = NumberFormatter()
- formatter.numberStyle = .currency
- formatter.currencyCode = "USD"
- formatter.maximumFractionDigits = 2
- let formatted = formatter.string(from: NSNumber(value: balance)) ?? "$\(balance)"
+ formatter.numberStyle = inCredits ? .decimal : .currency
+ formatter.maximumFractionDigits = inCredits ? 0 : 2
+ // Half-up matches the desktop decoder's Math.round; the default is
+ // half-even, which disagrees on exact-half balances.
+ formatter.roundingMode = .halfUp
+ // `en_US`, not `en_US_POSIX`: the latter drops grouping entirely.
+ formatter.locale = Locale(identifier: "en_US")
+ if !inCredits { formatter.currencyCode = "USD" }
+ let fallback = inCredits ? "\(Int(balance.rounded()))" : "$\(balance)"
+ let formatted = formatter.string(from: NSNumber(value: balance)) ?? fallback
footerLines.append("Credits remaining · \(formatted)")
}
+ if codexUsage?.creditLimit == nil, codexUsage?.creditsUnlimited == true {
+ footerLines.append("Credits · Unlimited")
+ }
return QuotaSummary(providerFilter: filter, connection: connection, primary: primary, details: details, planLabel: plan, footerLines: footerLines)
}
diff --git a/mac/Sources/CodeBurnMenubar/CodeBurnApp.swift b/mac/Sources/CodeBurnMenubar/CodeBurnApp.swift
index 1188eea..f8b2958 100644
--- a/mac/Sources/CodeBurnMenubar/CodeBurnApp.swift
+++ b/mac/Sources/CodeBurnMenubar/CodeBurnApp.swift
@@ -467,7 +467,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate, NSM
// is refreshed by refreshPayloadForPopoverOpen the moment it opens,
// so a closed-popover tick never pays for it (#647).
if !(popover?.isShown ?? false) {
- async let menubar = store.refreshQuietly(
+ async let menubar = store.refreshMenubarBadge(
period: menubarPeriod,
force: force,
qualityOfService: qualityOfService
@@ -489,7 +489,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate, NSM
qualityOfService: qualityOfService
)
async let menubar = needsMenubarPayload
- ? store.refreshQuietly(period: menubarPeriod, force: force, qualityOfService: qualityOfService)
+ ? store.refreshMenubarBadge(period: menubarPeriod, force: force, qualityOfService: qualityOfService)
: true
async let today = needsTodayPayload
? store.refreshQuietly(period: .today, force: force, qualityOfService: qualityOfService)
@@ -870,6 +870,9 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate, NSM
_ = self.store.payload
_ = self.store.menubarPeriod
_ = self.store.menubarPayload
+ // Combined-scope badge total: re-render the badge when the cross-device
+ // aggregate for the menubar period lands (or a peer goes reachable).
+ _ = self.store.menubarBadgeCombined
// Track currency so the menubar title catches up immediately on
// currency switch instead of waiting for the next 30s payload tick.
_ = self.store.currency
@@ -1023,23 +1026,31 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate, NSM
if store.displayMetric != .iconOnly {
let suffix = menubarPeriod.menubarSuffix(compact: compact)
+ // Under combined scope the badge shows the cross-device aggregate, so
+ // it matches the popover hero instead of trailing it with the local
+ // figure. Falls back to local when no combined payload is available
+ // (local scope, cold cache, or an unreachable peer). Credits have no
+ // combined total, so that metric always reflects the local device.
+ let badgeCombined = store.menubarBadgeCombined
+ let cost: Double? = badgeCombined?.cost ?? menubarPayload?.current.cost
+ let outputTokens: Int? = badgeCombined?.outputTokens ?? menubarPayload?.current.outputTokens
+ let inputTokens: Int? = badgeCombined?.inputTokens ?? menubarPayload?.current.inputTokens
let valueText: String
- if store.displayMetric == .tokens, let p = menubarPayload?.current {
- let out = formatTokensMenubar(Double(p.outputTokens))
- let inp = formatTokensMenubar(Double(p.inputTokens))
- valueText = compact ? "↑\(out)↓\(inp)\(suffix)" : " ↑\(out) ↓\(inp)\(suffix)"
- } else if store.displayMetric == .totalTokens, let p = menubarPayload?.current {
- let total = formatTokensMenubar(Double(p.inputTokens + p.outputTokens))
+ if store.displayMetric == .tokens, let out = outputTokens, let inp = inputTokens {
+ let outText = formatTokensMenubar(Double(out))
+ let inpText = formatTokensMenubar(Double(inp))
+ valueText = compact ? "↑\(outText)↓\(inpText)\(suffix)" : " ↑\(outText) ↓\(inpText)\(suffix)"
+ } else if store.displayMetric == .totalTokens, let out = outputTokens, let inp = inputTokens {
+ let total = formatTokensMenubar(Double(inp + out))
valueText = compact ? "\(total)\(suffix)" : " \(total)\(suffix)"
} else if store.displayMetric == .credits, let p = menubarPayload?.current {
let credits = formatTokensMenubar((p.codexCredits ?? 0).rounded())
valueText = compact ? "\(credits)cr\(suffix)" : " \(credits) credits\(suffix)"
} else {
let fallback = compact ? "$-" : "$—"
- let formatted = menubarPayload?.current.cost
valueText = compact
- ? (formatted?.asCompactCurrencyWhole() ?? fallback) + suffix
- : " " + (formatted?.asCompactCurrency() ?? fallback) + suffix
+ ? (cost?.asCompactCurrencyWhole() ?? fallback) + suffix
+ : " " + (cost?.asCompactCurrency() ?? fallback) + suffix
}
var textAttrs: [NSAttributedString.Key: Any] = [.font: font, .baselineOffset: -1.0]
diff --git a/mac/Sources/CodeBurnMenubar/Data/CodexSubscriptionService.swift b/mac/Sources/CodeBurnMenubar/Data/CodexSubscriptionService.swift
index 0eea71f..d25637c 100644
--- a/mac/Sources/CodeBurnMenubar/Data/CodexSubscriptionService.swift
+++ b/mac/Sources/CodeBurnMenubar/Data/CodexSubscriptionService.swift
@@ -111,10 +111,12 @@ enum CodexSubscriptionService {
switch http.statusCode {
case 200:
clearUsageBlock()
- // Companion fetch, strictly best-effort: any failure yields nil and
- // the Plan view simply omits the row. This endpoint must never be
- // able to break the quota display.
- let resetCredits = await fetchResetCredits(token: token)
+ // Skip the companion request only when the inline block says zero.
+ // Best-effort either way: nil just omits the row.
+ var resetCredits = inlineResetCreditsShortcut(data: data)
+ if resetCredits == nil {
+ resetCredits = await fetchResetCredits(token: token)
+ }
do {
return try decodeUsage(data: data, resetCredits: resetCredits)
} catch {
@@ -144,15 +146,83 @@ enum CodexSubscriptionService {
}
}
+ /// chatgpt.com mixes encodings inside one payload: `"limit": "10000"` next
+ /// to `"used_percent": 30`. Every numeric field decodes through here.
+ private enum Flexible {
+ // `decode`, not `decodeIfPresent`: missing, null and wrong-typed all
+ // mean "not available", without the double-optional footgun.
+ // Int first keeps precision above 2^53. Infinity and NaN survive
+ // `Double(_ text:)`, so reject them here.
+ static func double(_ c: KeyedDecodingContainer, _ key: K) -> Double? {
+ if let v = try? c.decode(Int.self, forKey: key) { return Double(v) }
+ if let v = try? c.decode(Double.self, forKey: key) { return v.isFinite ? v : nil }
+ if let v = try? c.decode(String.self, forKey: key),
+ let d = Double(v.trimmingCharacters(in: .whitespacesAndNewlines)) {
+ return d.isFinite ? d : nil
+ }
+ return nil
+ }
+ // `Int(exactly:)`, never `Int(_:)`: the plain initializer traps on an
+ // out-of-range Double, and a trap is not a catchable DecodingError.
+ static func int(_ c: KeyedDecodingContainer, _ key: K) -> Int? {
+ double(c, key).flatMap { Int(exactly: $0.rounded()) }
+ }
+ static func bool(_ c: KeyedDecodingContainer, _ key: K) -> Bool {
+ (try? c.decode(Bool.self, forKey: key)) ?? false
+ }
+ }
+
+ /// Decoding `[T]` is atomic, so one bad entry would discard every sibling.
+ private struct Lossy: Decodable {
+ let value: T?
+ init(from decoder: Decoder) throws { value = try? T(from: decoder) }
+ }
+
private struct UsageDTO: Decodable {
let plan_type: String?
let rate_limit: RateLimit?
let additional_rate_limits: [AdditionalLimitDTO]?
let credits: Credits?
+ let spend_control: SpendControl?
+ /// Forward-compat: some variants hoist this to the top level.
+ let individual_limit: IndividualLimit?
+
+ enum CodingKeys: String, CodingKey {
+ case plan_type, rate_limit, additional_rate_limits, credits, spend_control
+ case individual_limit
+ case individualLimit
+ }
+
+ init(from decoder: Decoder) throws {
+ let c = try decoder.container(keyedBy: CodingKeys.self)
+ plan_type = try? c.decode(String.self, forKey: .plan_type)
+ rate_limit = try? c.decode(RateLimit.self, forKey: .rate_limit)
+ additional_rate_limits = (try? c.decode([Lossy].self, forKey: .additional_rate_limits))?
+ .compactMap(\.value)
+ credits = try? c.decode(Credits.self, forKey: .credits)
+ spend_control = try? c.decode(SpendControl.self, forKey: .spend_control)
+ individual_limit = (try? c.decode(IndividualLimit.self, forKey: .individual_limit))
+ ?? (try? c.decode(IndividualLimit.self, forKey: .individualLimit))
+ }
struct RateLimit: Decodable {
let primary_window: WindowDTO?
let secondary_window: WindowDTO?
+ /// Forward-compat: another observed position for the spend control.
+ let individual_limit: IndividualLimit?
+
+ enum CodingKeys: String, CodingKey {
+ case primary_window, secondary_window, individual_limit
+ case individualLimit
+ }
+
+ init(from decoder: Decoder) throws {
+ let c = try decoder.container(keyedBy: CodingKeys.self)
+ primary_window = try? c.decode(WindowDTO.self, forKey: .primary_window)
+ secondary_window = try? c.decode(WindowDTO.self, forKey: .secondary_window)
+ individual_limit = (try? c.decode(IndividualLimit.self, forKey: .individual_limit))
+ ?? (try? c.decode(IndividualLimit.self, forKey: .individualLimit))
+ }
}
struct AdditionalLimitDTO: Decodable {
let limit_name: String?
@@ -163,22 +233,72 @@ enum CodexSubscriptionService {
let reset_at: Int?
let limit_window_seconds: Int?
}
+ /// Credit-metered workspaces report `rate_limit: null` and carry their
+ /// real limit here: the monthly allowance an admin sets.
+ struct SpendControl: Decodable {
+ let reached: Bool
+ let individualLimit: IndividualLimit?
+
+ enum CodingKeys: String, CodingKey {
+ case reached
+ case individual_limit
+ case individualLimit
+ }
+
+ init(from decoder: Decoder) throws {
+ let c = try decoder.container(keyedBy: CodingKeys.self)
+ reached = Flexible.bool(c, .reached)
+ individualLimit = (try? c.decode(IndividualLimit.self, forKey: .individual_limit))
+ ?? (try? c.decode(IndividualLimit.self, forKey: .individualLimit))
+ }
+ }
+ struct IndividualLimit: Decodable {
+ let limit: Double?
+ let used: Double?
+ let usedPercent: Double?
+ let remainingPercent: Double?
+ let resetAt: Int?
+
+ enum CodingKeys: String, CodingKey {
+ case limit, used
+ case used_percent, usedPercent
+ case remaining_percent, remainingPercent
+ case reset_at, resets_at, resetsAt
+ }
+
+ init(from decoder: Decoder) throws {
+ let c = try decoder.container(keyedBy: CodingKeys.self)
+ limit = Flexible.double(c, .limit)
+ used = Flexible.double(c, .used)
+ usedPercent = Flexible.double(c, .used_percent) ?? Flexible.double(c, .usedPercent)
+ remainingPercent = Flexible.double(c, .remaining_percent)
+ ?? Flexible.double(c, .remainingPercent)
+ resetAt = Flexible.int(c, .reset_at)
+ ?? Flexible.int(c, .resets_at)
+ ?? Flexible.int(c, .resetsAt)
+ }
+ }
// chatgpt.com sometimes serializes balance as a Double ("balance": 0.0)
// and other times as a String ("balance": "0.00"). Mirror CodexBar's
// resilient decode so a schema drift on either shape doesn't blow up
// the whole quota fetch.
struct Credits: Decodable {
let balance: Double?
- enum CodingKeys: String, CodingKey { case balance }
+ /// Settles in credits, not dollars, which relabels `balance`.
+ let hasCredits: Bool
+ let unlimited: Bool
+
+ enum CodingKeys: String, CodingKey {
+ case balance
+ case has_credits
+ case unlimited
+ }
+
init(from decoder: Decoder) throws {
let c = try decoder.container(keyedBy: CodingKeys.self)
- if let n = try? c.decode(Double.self, forKey: .balance) {
- balance = n
- } else if let s = try? c.decode(String.self, forKey: .balance), let n = Double(s) {
- balance = n
- } else {
- balance = nil
- }
+ balance = Flexible.double(c, .balance)
+ hasCredits = Flexible.bool(c, .has_credits)
+ unlimited = Flexible.bool(c, .unlimited)
}
}
}
@@ -204,6 +324,28 @@ enum CodexSubscriptionService {
return parseResetCredits(data: data)
}
+ /// The inline block carries no per-credit expiry list, so it is only a safe
+ /// shortcut at zero, where there is no expiry to report. A non-zero count
+ /// still pays for the companion request rather than dropping the
+ /// "next expires" caption the popover would otherwise show.
+ static func inlineResetCreditsShortcut(data: Data) -> CodexUsage.ResetCredits? {
+ guard let inline = inlineResetCredits(data: data), inline.availableCount == 0 else { return nil }
+ return inline
+ }
+
+ /// Reset-credit inventory carried inline on the usage payload. Nil means
+ /// absent.
+ static func inlineResetCredits(data: Data) -> CodexUsage.ResetCredits? {
+ struct InlineDTO: Decodable {
+ struct Block: Decodable { let available_count: Int? }
+ let rate_limit_reset_credits: Block?
+ }
+ guard let count = (try? JSONDecoder().decode(InlineDTO.self, from: data))?
+ .rate_limit_reset_credits?.available_count, count >= 0
+ else { return nil }
+ return CodexUsage.ResetCredits(availableCount: count, nextExpiresAt: nil)
+ }
+
/// Internal (not private) so tests can drive it with fixture payloads.
/// Returns nil on any unexpected shape — the caller treats nil as
/// "feature unavailable", never as an error.
@@ -239,7 +381,8 @@ enum CodexSubscriptionService {
return plain.date(from: raw)
}
- private static func decodeUsage(data: Data, resetCredits: CodexUsage.ResetCredits? = nil) throws -> CodexUsage {
+ /// Internal (not private) so tests can drive it with fixture payloads.
+ static func decodeUsage(data: Data, resetCredits: CodexUsage.ResetCredits? = nil) throws -> CodexUsage {
let root = try JSONDecoder().decode(UsageDTO.self, from: data)
let additional: [CodexUsage.AdditionalLimit] = (root.additional_rate_limits ?? []).compactMap { dto in
guard let name = dto.limit_name, !name.isEmpty else { return nil }
@@ -249,17 +392,61 @@ enum CodexSubscriptionService {
secondary: makeWindow(dto.rate_limit?.secondary_window)
)
}
+ let limitDTO = root.spend_control?.individualLimit
+ ?? root.individual_limit
+ ?? root.rate_limit?.individual_limit
return CodexUsage(
plan: CodexUsage.planType(from: root.plan_type),
primary: makeWindow(root.rate_limit?.primary_window),
secondary: makeWindow(root.rate_limit?.secondary_window),
additionalLimits: additional,
creditsBalance: root.credits?.balance,
+ hasCredits: root.credits?.hasCredits ?? false,
+ creditsUnlimited: root.credits?.unlimited ?? false,
+ creditLimit: makeCreditLimit(limitDTO, reached: root.spend_control?.reached ?? false),
resetCredits: resetCredits,
fetchedAt: Date()
)
}
+ private static func makeCreditLimit(
+ _ dto: UsageDTO.IndividualLimit?,
+ reached: Bool
+ ) -> CodexUsage.CreditLimit? {
+ guard let dto, let limit = dto.limit, limit > 0 else { return nil }
+ // Server percentage, then remaining_percent, then the raw ratio. No
+ // signal at all means the draw is unknown; a 0% bar would claim otherwise.
+ guard let raw = dto.usedPercent
+ ?? dto.remainingPercent.map({ 100 - $0 })
+ ?? dto.used.map({ $0 / limit * 100 })
+ else { return nil }
+ let percent = min(max(raw, 0), 100)
+ let resetsAt = dto.resetAt.flatMap { $0 > 0 ? Date(timeIntervalSince1970: TimeInterval($0)) : nil }
+ return CodexUsage.CreditLimit(
+ // Unclamped percent, so a 120% draw still reports 12,000 of 10,000.
+ used: dto.used ?? limit * max(raw, 0) / 100,
+ limit: limit,
+ usedPercent: percent,
+ resetsAt: resetsAt,
+ windowSeconds: monthlyWindowSeconds(endingAt: resetsAt),
+ reached: reached
+ )
+ }
+
+ /// Spend controls reset on a calendar-month boundary, so the window is the
+ /// month preceding the reset. Not `reset_after_seconds`, which is remaining.
+ /// UTC, not `Calendar.current`: a 2026-03-01Z reset spans 28 days in UTC
+ /// but 31 in Toronto, so a local calendar makes pace timezone-dependent.
+ private static func monthlyWindowSeconds(endingAt resetsAt: Date?) -> Int? {
+ var calendar = Calendar(identifier: .gregorian)
+ calendar.timeZone = TimeZone(secondsFromGMT: 0) ?? .gmt
+ guard let resetsAt,
+ let start = calendar.date(byAdding: .month, value: -1, to: resetsAt)
+ else { return nil }
+ let seconds = Int(resetsAt.timeIntervalSince(start))
+ return seconds > 0 ? seconds : nil
+ }
+
private static func makeWindow(_ dto: UsageDTO.WindowDTO?) -> CodexUsage.Window? {
guard let dto, let used = dto.used_percent, let windowSeconds = dto.limit_window_seconds else {
return nil
diff --git a/mac/Sources/CodeBurnMenubar/Data/CodexUsage.swift b/mac/Sources/CodeBurnMenubar/Data/CodexUsage.swift
index 3a5d814..2fbbdbf 100644
--- a/mac/Sources/CodeBurnMenubar/Data/CodexUsage.swift
+++ b/mac/Sources/CodeBurnMenubar/Data/CodexUsage.swift
@@ -31,7 +31,12 @@ struct CodexUsage: Sendable, Equatable {
case .k12: "K-12"
case .enterprise: "Enterprise"
case .edu: "Edu"
- case let .unknown(raw): raw.isEmpty ? "Subscription" : raw.capitalized
+ case let .unknown(raw):
+ raw.isEmpty
+ ? "Subscription"
+ : raw.replacingOccurrences(of: "_", with: " ")
+ .replacingOccurrences(of: "-", with: " ")
+ .capitalized
}
}
}
@@ -76,16 +81,55 @@ struct CodexUsage: Sendable, Equatable {
let nextExpiresAt: Date?
}
+ /// The monthly allowance an admin sets. Credit-metered workspaces report
+ /// `rate_limit: null`, so this is their only limit.
+ struct CreditLimit: Sendable, Equatable {
+ let used: Double
+ let limit: Double
+ let usedPercent: Double // 0.0 ... 100.0
+ let resetsAt: Date?
+ /// Calendar month the allowance resets on, for pace projection. Not the
+ /// payload's `reset_after_seconds`, which is the time remaining.
+ let windowSeconds: Int?
+ /// Allowance already spent: a hard stop, not a near-limit warning.
+ let reached: Bool
+
+ /// `.halfUp` matches the desktop decoder's `Math.round`.
+ var displayLabel: String {
+ let formatter = NumberFormatter()
+ formatter.numberStyle = .decimal
+ formatter.maximumFractionDigits = 0
+ formatter.roundingMode = .halfUp
+ // `en_US`, not `en_US_POSIX`: the latter drops grouping entirely.
+ formatter.locale = Locale(identifier: "en_US")
+ func text(_ value: Double) -> String {
+ formatter.string(from: NSNumber(value: value)) ?? "\(Int(value.rounded()))"
+ }
+ let base = "Monthly usage limit · \(text(used)) / \(text(limit)) credits"
+ return reached ? "\(base) · limit reached" : base
+ }
+
+ var shortLabel: String {
+ reached ? "Monthly usage limit · limit reached" : "Monthly usage limit"
+ }
+ }
+
let plan: PlanType
let primary: Window?
let secondary: Window?
let additionalLimits: [AdditionalLimit]
let creditsBalance: Double?
+ /// Account settles in credits, not dollars, which changes `creditsBalance`.
+ let hasCredits: Bool
+ /// Uncapped on purpose, as distinct from a limit we failed to read.
+ let creditsUnlimited: Bool
+ let creditLimit: CreditLimit?
let resetCredits: ResetCredits?
let fetchedAt: Date
static func planType(from raw: String?) -> PlanType {
- guard let raw = raw?.lowercased() else { return .unknown("") }
+ guard let original = raw?.lowercased() else { return .unknown("") }
+ let raw = normalizePlanType(original)
switch raw {
case "guest": return .guest
case "free": return .free
@@ -101,7 +145,28 @@ struct CodexUsage: Sendable, Equatable {
case "k12": return .k12
case "enterprise": return .enterprise
case "edu": return .edu
+ // Normalized, so an unknown composite reads "Some Future Tier".
default: return .unknown(raw)
}
}
+
+ /// Credit-based-pricing tiers arrive composite (`enterprise_cbp_usage_based`).
+ private static func normalizePlanType(_ raw: String) -> String {
+ var value = raw.trimmingCharacters(in: .whitespacesAndNewlines)
+ for suffix in ["_usage_based", "-usage-based", "_usage-based", "-usage_based"]
+ where value.hasSuffix(suffix) {
+ value.removeLast(suffix.count)
+ }
+ for prefix in ["self_serve_", "self-serve-", "self_serve-", "self-serve_"]
+ where value.hasPrefix(prefix) {
+ value.removeFirst(prefix.count)
+ }
+ for suffix in ["_cbp", "-cbp"] where value.hasSuffix(suffix) {
+ value.removeLast(suffix.count)
+ }
+ for infix in ["_cbp_", "-cbp-", "_cbp-", "-cbp_"] {
+ value = value.replacingOccurrences(of: infix, with: "_")
+ }
+ return value
+ }
}
diff --git a/mac/Sources/CodeBurnMenubar/Views/HeatmapSection.swift b/mac/Sources/CodeBurnMenubar/Views/HeatmapSection.swift
index 25b128a..42ae294 100644
--- a/mac/Sources/CodeBurnMenubar/Views/HeatmapSection.swift
+++ b/mac/Sources/CodeBurnMenubar/Views/HeatmapSection.swift
@@ -2067,7 +2067,7 @@ private struct CodexPlanInsight: View {
.font(.system(size: 13, weight: .semibold))
.foregroundStyle(.primary)
Spacer()
- if let resetsAt = (usage.primary ?? usage.secondary)?.resetsAt {
+ if let resetsAt = (usage.primary ?? usage.secondary)?.resetsAt ?? usage.creditLimit?.resetsAt {
Text("Resets \(relativeReset(resetsAt))")
.font(.system(size: 10.5))
.foregroundStyle(.secondary)
@@ -2109,6 +2109,26 @@ private struct CodexPlanInsight: View {
)
}
}
+ // No rate windows here, so without this row the card is empty.
+ if let credits = usage.creditLimit {
+ UtilizationRow(
+ label: credits.displayLabel,
+ percent: credits.usedPercent,
+ resetsAt: credits.resetsAt,
+ projection: pace(for: credits)
+ )
+ } else if usage.creditsUnlimited {
+ // Uncapped on purpose, not a failed fetch.
+ HStack(alignment: .firstTextBaseline) {
+ Text("Credits")
+ .font(.system(size: 11, weight: .medium))
+ .foregroundStyle(.secondary)
+ Spacer()
+ Text("Unlimited")
+ .font(.system(size: 10.5))
+ .foregroundStyle(.secondary)
+ }
+ }
// Limit-reset credits the account is holding. Hidden at zero so
// plans that never receive these grants see no extra row.
if let resets = usage.resetCredits, resets.availableCount > 0 {
@@ -2147,6 +2167,25 @@ private struct CodexPlanInsight: View {
)
}
+ /// Rate-window pace math over the spend control's calendar month.
+ private func pace(for credits: CodexUsage.CreditLimit) -> WindowProjection? {
+ guard let windowSeconds = credits.windowSeconds,
+ let result = QuotaPace.evaluate(
+ usedPercent: credits.usedPercent,
+ resetsAt: credits.resetsAt,
+ windowSeconds: windowSeconds
+ )
+ else { return nil }
+ return WindowProjection(
+ percent: result.projectedPercent,
+ willOverflow: result.willOverflow,
+ hitsLimitAt: result.hitsLimitAt,
+ source: .linear,
+ deltaPercent: result.deltaPercent,
+ compact: TimeInterval(windowSeconds) <= QuotaPace.etaSuppressionMaxSeconds
+ )
+ }
+
private func resetCreditsLabel(_ resets: CodexUsage.ResetCredits) -> String {
let count = "\(resets.availableCount) available"
guard let next = resets.nextExpiresAt else { return count }
diff --git a/mac/Sources/CodeBurnMenubar/Views/SettingsView.swift b/mac/Sources/CodeBurnMenubar/Views/SettingsView.swift
index f220ba5..289ad58 100644
--- a/mac/Sources/CodeBurnMenubar/Views/SettingsView.swift
+++ b/mac/Sources/CodeBurnMenubar/Views/SettingsView.swift
@@ -487,7 +487,7 @@ private struct CodexSettingsTab: View {
CodexConnectionRow()
}
Section {
- Text("Codex live-quota tracking reads `~/.codex/auth.json` once on Connect, then keeps a local copy under Application Support so subsequent quota fetches don't re-read the original. Only ChatGPT-mode auth (Plus / Pro / Team / Business) is supported — API-key users are billed per request and have a different reporting surface.")
+ Text("Codex live-quota tracking reads `~/.codex/auth.json` once on Connect, then keeps a local copy under Application Support so subsequent quota fetches don't re-read the original. Only ChatGPT-mode auth (Plus / Pro / Team / Business / Edu / Enterprise) is supported. API-key users are billed per request and have a different reporting surface. Credit-metered workspaces report no rate-limit windows, so their monthly credit allowance is shown instead.")
.font(.system(size: 11))
.foregroundStyle(.secondary)
} header: {
diff --git a/mac/Tests/CodeBurnMenubarTests/AppStoreRefreshRecoveryTests.swift b/mac/Tests/CodeBurnMenubarTests/AppStoreRefreshRecoveryTests.swift
index 74d0ec8..d4e8b27 100644
--- a/mac/Tests/CodeBurnMenubarTests/AppStoreRefreshRecoveryTests.swift
+++ b/mac/Tests/CodeBurnMenubarTests/AppStoreRefreshRecoveryTests.swift
@@ -202,6 +202,56 @@ struct AppStoreRefreshRecoveryTests {
#expect(store.payload.combined == nil)
}
+ @Test("menubar badge shows the combined total under combined scope")
+ func menubarBadgeShowsCombinedTotal() {
+ let store = AppStore()
+ store.suppressRefreshesForTesting()
+ let period = store.menubarPeriod
+ // Local badge figure and a higher cross-device combined total, both for
+ // the badge's period.
+ store.setCachedPayloadForTesting(
+ menubarPayload(cost: 30),
+ scope: .local,
+ period: period,
+ provider: .all,
+ fetchedAt: Date()
+ )
+ store.setCachedPayloadForTesting(
+ menubarPayload(cost: 30, combined: combinedUsage(cost: 75)),
+ scope: .combined,
+ period: period,
+ provider: .all,
+ fetchedAt: Date()
+ )
+
+ // Local scope: no combined total, so the badge renders the local figure.
+ store.selectedScope = .local
+ #expect(store.menubarBadgeCombined == nil)
+
+ // Combined scope: the badge total is the cross-device aggregate ($75),
+ // not the local $30 — this is the fix for the badge trailing the popover.
+ store.selectedScope = .combined
+ #expect(store.menubarBadgeCombined?.cost == 75)
+ }
+
+ @Test("menubar badge falls back to local when no combined payload is cached")
+ func menubarBadgeFallsBackWhenCombinedMissing() {
+ let store = AppStore()
+ store.suppressRefreshesForTesting()
+ let period = store.menubarPeriod
+ store.setCachedPayloadForTesting(
+ menubarPayload(cost: 30),
+ scope: .local,
+ period: period,
+ provider: .all,
+ fetchedAt: Date()
+ )
+ // Combined scope selected but no combined payload cached yet (cold cache
+ // or an unreachable peer): the badge must fall back to the local figure.
+ store.selectedScope = .combined
+ #expect(store.menubarBadgeCombined == nil)
+ }
+
@Test("switching to combined resets selected provider to all")
func switchingToCombinedResetsSelectedProviderToAll() {
let store = AppStore()
diff --git a/mac/Tests/CodeBurnMenubarTests/CodexPlanParsingTests.swift b/mac/Tests/CodeBurnMenubarTests/CodexPlanParsingTests.swift
new file mode 100644
index 0000000..bd55447
--- /dev/null
+++ b/mac/Tests/CodeBurnMenubarTests/CodexPlanParsingTests.swift
@@ -0,0 +1,248 @@
+import Foundation
+import XCTest
+@testable import CodeBurnMenubar
+
+/// `plan_type` parsing and the credit-metered branch of the wham/usage decoder.
+final class CodexPlanParsingTests: XCTestCase {
+ private func decode(_ json: String) throws -> CodexUsage {
+ try CodexSubscriptionService.decodeUsage(data: Data(json.utf8))
+ }
+
+ func testKnownTiersMapToDisplayNames() {
+ let expected: [String: String] = [
+ "guest": "Guest", "free": "Free", "go": "Go", "plus": "Plus", "pro": "Pro",
+ "prolite": "Pro Lite", "pro_lite": "Pro Lite", "pro-lite": "Pro Lite",
+ "free_workspace": "Free Workspace", "team": "Team", "business": "Business",
+ "education": "Education", "quorum": "Quorum", "k12": "K-12",
+ "enterprise": "Enterprise", "edu": "Edu",
+ ]
+ for (raw, display) in expected {
+ XCTAssertEqual(CodexUsage.planType(from: raw).displayName, display, "plan_type: \(raw)")
+ }
+ }
+
+ func testTierMatchingIsCaseInsensitive() {
+ XCTAssertEqual(CodexUsage.planType(from: "pLuS"), .plus)
+ XCTAssertEqual(CodexUsage.planType(from: "ENTERPRISE"), .enterprise)
+ }
+
+ func testCreditBasedPricingCompositesNormalize() {
+ XCTAssertEqual(CodexUsage.planType(from: "enterprise_cbp_usage_based"), .enterprise)
+ XCTAssertEqual(CodexUsage.planType(from: "self_serve_business_usage_based"), .business)
+ XCTAssertEqual(CodexUsage.planType(from: "business_cbp"), .business)
+ }
+
+ func testHyphenSeparatedCompositesNormalizeToo() {
+ XCTAssertEqual(CodexUsage.planType(from: "enterprise-cbp-usage-based"), .enterprise)
+ XCTAssertEqual(CodexUsage.planType(from: "self-serve-business-usage-based"), .business)
+ XCTAssertEqual(CodexUsage.planType(from: "business-cbp"), .business)
+ }
+
+ func testUnknownTierNormalizesAndTitleCasesLikeTheDesktopDecoder() {
+ XCTAssertEqual(CodexUsage.planType(from: "some_future_tier_usage_based"),
+ .unknown("some_future_tier"))
+ XCTAssertEqual(CodexUsage.planType(from: "some_future_tier_usage_based").displayName,
+ "Some Future Tier")
+ XCTAssertEqual(CodexUsage.planType(from: nil), .unknown(""))
+ XCTAssertEqual(CodexUsage.planType(from: nil).displayName, "Subscription")
+ }
+
+ /// Captured from a live ChatGPT Enterprise workspace (identifiers replaced).
+ private let enterprisePayload = #"""
+ {
+ "plan_type": "business",
+ "rate_limit": null,
+ "code_review_rate_limit": null,
+ "additional_rate_limits": null,
+ "credits": {
+ "has_credits": false, "unlimited": false, "overage_limit_reached": false,
+ "balance": null, "approx_local_messages": null, "approx_cloud_messages": null
+ },
+ "spend_control": {
+ "reached": false,
+ "individual_limit": {
+ "source": "workspace_spend_controls",
+ "limit": "10000",
+ "used": "3028.9909675121307",
+ "remaining": "6971.009032487869",
+ "used_percent": 30,
+ "remaining_percent": 70,
+ "reset_after_seconds": 441896,
+ "reset_at": 1785542400
+ }
+ },
+ "rate_limit_reset_credits": {"available_count": 0, "applicable_available_count": 0}
+ }
+ """#
+
+ func testEnterprisePayloadDecodesTheSpendControlLimit() throws {
+ let usage = try decode(enterprisePayload)
+ XCTAssertNil(usage.primary)
+ XCTAssertNil(usage.secondary)
+ XCTAssertTrue(usage.additionalLimits.isEmpty)
+
+ let credits = try XCTUnwrap(usage.creditLimit)
+ XCTAssertEqual(credits.limit, 10_000)
+ XCTAssertEqual(credits.used, 3028.9909675121307, accuracy: 0.0001)
+ XCTAssertEqual(credits.usedPercent, 30)
+ XCTAssertEqual(credits.resetsAt, Date(timeIntervalSince1970: 1_785_542_400))
+ XCTAssertFalse(credits.reached)
+ // July 2026, not the payload's `reset_after_seconds`.
+ XCTAssertEqual(try XCTUnwrap(credits.windowSeconds), 31 * 86_400)
+
+ XCTAssertEqual(usage.plan, .business)
+ XCTAssertNil(usage.creditsBalance)
+ XCTAssertFalse(usage.hasCredits)
+ XCTAssertFalse(usage.creditsUnlimited)
+ }
+
+ func testSpendControlIsReadAtEveryObservedPosition() throws {
+ let bodies = [
+ #"{"spend_control": {"individual_limit": {"limit": 10000, "used_percent": 25}}}"#,
+ #"{"spend_control": {"individualLimit": {"limit": 10000, "usedPercent": 25}}}"#,
+ #"{"individual_limit": {"limit": 10000, "used_percent": 25}}"#,
+ #"{"rate_limit": {"individual_limit": {"limit": 10000, "used_percent": 25}}}"#,
+ ]
+ for body in bodies {
+ let credits = try XCTUnwrap(decode(body).creditLimit, body)
+ XCTAssertEqual(credits.usedPercent, 25, body)
+ XCTAssertEqual(credits.used, 2500, body)
+ }
+ }
+
+ func testPercentFallsBackThroughRemainingPercentThenRawRatio() throws {
+ let fromRemaining = try XCTUnwrap(
+ decode(#"{"spend_control": {"individual_limit": {"limit": 10000, "remaining_percent": 70}}}"#).creditLimit)
+ XCTAssertEqual(fromRemaining.usedPercent, 30, accuracy: 0.0001)
+
+ let fromRatio = try XCTUnwrap(
+ decode(#"{"spend_control": {"individual_limit": {"limit": 400, "used": 100}}}"#).creditLimit)
+ XCTAssertEqual(fromRatio.usedPercent, 25, accuracy: 0.0001)
+ }
+
+ func testUnusableSpendControlYieldsNoRow() throws {
+ let bodies = [
+ #"{"spend_control": {"individual_limit": {"limit": 0, "used": 5}}}"#,
+ #"{"spend_control": {"individual_limit": {"limit": null}}}"#,
+ #"{"spend_control": {"individual_limit": {"used_percent": 40}}}"#,
+ #"{"spend_control": {"individual_limit": null}}"#,
+ #"{"spend_control": null}"#,
+ "{}",
+ ]
+ for body in bodies {
+ XCTAssertNil(try decode(body).creditLimit, body)
+ }
+ }
+
+ func testAllowanceWithoutAnyUsageSignalYieldsNoRow() throws {
+ let body = #"{"spend_control": {"individual_limit": {"limit": 10000, "reset_at": 1785542400}}}"#
+ XCTAssertNil(try decode(body).creditLimit)
+ }
+
+ func testBlankNumericStringIsAbsentNotZero() throws {
+ let credits = try XCTUnwrap(decode(#"""
+ {"spend_control": {"individual_limit": {"limit": "10000", "used": " ", "used_percent": 30}}}
+ """#).creditLimit)
+ XCTAssertEqual(credits.used, 3000, accuracy: 0.001)
+ XCTAssertNil(try decode(#"{"spend_control": {"individual_limit": {"limit": ""}}}"#).creditLimit)
+ }
+
+ func testOverageKeepsCountsTruthfulWhileClampingPercent() throws {
+ let credits = try XCTUnwrap(decode(#"""
+ {"spend_control": {"individual_limit": {"limit": 10000, "used": 12000, "used_percent": 120}}}
+ """#).creditLimit)
+ XCTAssertEqual(credits.used, 12000, accuracy: 0.001)
+ XCTAssertEqual(credits.usedPercent, 100)
+ }
+
+ func testMonthWindowIsTimezoneIndependent() throws {
+ // 2026-03-01Z reads as 28 days in UTC but 31 through a local calendar.
+ let body = #"{"spend_control": {"individual_limit": {"limit": 100, "used_percent": 10, "reset_at": 1772323200}}}"#
+ XCTAssertEqual(try XCTUnwrap(decode(body).creditLimit?.windowSeconds), 28 * 86_400)
+ }
+
+ func testOutOfRangeNumbersDoNotTrap() throws {
+ // `Int(_:)` on 1e100 traps, and a trap is not a catchable error.
+ for body in [
+ #"{"spend_control": {"individual_limit": {"limit": 100, "used_percent": 10, "reset_at": 1e100}}}"#,
+ #"{"spend_control": {"individual_limit": {"limit": "Infinity", "used_percent": 10}}}"#,
+ #"{"spend_control": {"individual_limit": {"limit": 100, "used_percent": "NaN"}}}"#,
+ ] {
+ _ = try? decode(body)
+ }
+ let huge = #"{"spend_control": {"individual_limit": {"limit": 100, "used_percent": 10, "reset_at": "1e100"}}}"#
+ XCTAssertNil(try XCTUnwrap(decode(huge).creditLimit).resetsAt)
+ }
+
+ func testPercentOnlyOverageKeepsTheImpliedCount() throws {
+ let body = #"{"spend_control": {"individual_limit": {"limit": 10000, "used_percent": 120}}}"#
+ let credits = try XCTUnwrap(decode(body).creditLimit)
+ XCTAssertEqual(credits.used, 12000, accuracy: 0.001)
+ XCTAssertEqual(credits.usedPercent, 100)
+ XCTAssertEqual(credits.displayLabel, "Monthly usage limit · 12,000 / 10,000 credits")
+ }
+
+ func testOneMalformedAdditionalLimitDoesNotDiscardTheRest() throws {
+ let body = #"""
+ {"additional_rate_limits": [
+ null,
+ {"limit_name": "Spark", "rate_limit": {"primary_window": {"used_percent": 40, "limit_window_seconds": 18000}}}
+ ]}
+ """#
+ XCTAssertEqual(try decode(body).additionalLimits.map(\.name), ["Spark"])
+ }
+
+ func testReachedSpendControlIsCarriedThrough() throws {
+ let credits = try XCTUnwrap(decode(#"""
+ {"spend_control": {"reached": true, "individual_limit": {"limit": 10000, "used_percent": 100}}}
+ """#).creditLimit)
+ XCTAssertTrue(credits.reached)
+ XCTAssertEqual(credits.usedPercent, 100)
+ }
+
+ func testCreditFlagsAndMixedNumberEncodings() throws {
+ let usage = try decode(#"""
+ {"credits": {"has_credits": true, "unlimited": true, "balance": "3410.40"}}
+ """#)
+ XCTAssertTrue(usage.hasCredits)
+ XCTAssertTrue(usage.creditsUnlimited)
+ XCTAssertEqual(try XCTUnwrap(usage.creditsBalance), 3410.40, accuracy: 0.0001)
+ }
+
+ func testRateWindowsStillDecodeAlongsideASpendControl() throws {
+ let usage = try decode(#"""
+ {
+ "plan_type": "plus",
+ "rate_limit": {
+ "primary_window": {"used_percent": 20, "reset_at": 1800000000, "limit_window_seconds": 18000}
+ },
+ "spend_control": {"individual_limit": {"limit": 10000, "used_percent": 30}}
+ }
+ """#)
+ XCTAssertEqual(usage.primary?.usedPercent, 20)
+ XCTAssertEqual(usage.primary?.windowLabel, "5-hour")
+ XCTAssertEqual(usage.creditLimit?.usedPercent, 30)
+ }
+
+ func testOnlyAZeroCountSkipsTheCompanionRequest() {
+ let zero = #"{"rate_limit_reset_credits": {"available_count": 0}}"#
+ let some = #"{"rate_limit_reset_credits": {"available_count": 3}}"#
+ XCTAssertEqual(CodexSubscriptionService.inlineResetCreditsShortcut(data: Data(zero.utf8))?.availableCount, 0)
+ XCTAssertNil(CodexSubscriptionService.inlineResetCreditsShortcut(data: Data(some.utf8)))
+ XCTAssertNil(CodexSubscriptionService.inlineResetCreditsShortcut(data: Data("{}".utf8)))
+ XCTAssertEqual(CodexSubscriptionService.inlineResetCredits(data: Data(some.utf8))?.availableCount, 3)
+ }
+
+ func testInlineResetCreditsParseFromTheUsagePayload() {
+ let inline = CodexSubscriptionService.inlineResetCredits(data: Data(enterprisePayload.utf8))
+ XCTAssertEqual(inline?.availableCount, 0)
+ XCTAssertNil(inline?.nextExpiresAt)
+ }
+
+ func testInlineResetCreditsAbsentSignalsFallback() {
+ XCTAssertNil(CodexSubscriptionService.inlineResetCredits(data: Data("{}".utf8)))
+ XCTAssertNil(CodexSubscriptionService.inlineResetCredits(data: Data("not json".utf8)))
+ XCTAssertNil(CodexSubscriptionService.inlineResetCredits(
+ data: Data(#"{"rate_limit_reset_credits": {}}"#.utf8)))
+ }
+}
diff --git a/mac/Tests/CodeBurnMenubarTests/CodexQuotaSummaryTests.swift b/mac/Tests/CodeBurnMenubarTests/CodexQuotaSummaryTests.swift
new file mode 100644
index 0000000..07f0443
--- /dev/null
+++ b/mac/Tests/CodeBurnMenubarTests/CodexQuotaSummaryTests.swift
@@ -0,0 +1,85 @@
+import Foundation
+import Testing
+@testable import CodeBurnMenubar
+
+private func usage(
+ balance: Double? = nil,
+ hasCredits: Bool = false,
+ unlimited: Bool = false,
+ creditLimit: CodexUsage.CreditLimit? = nil
+) -> CodexUsage {
+ CodexUsage(
+ plan: .business,
+ primary: nil,
+ secondary: nil,
+ additionalLimits: [],
+ creditsBalance: balance,
+ hasCredits: hasCredits,
+ creditsUnlimited: unlimited,
+ creditLimit: creditLimit,
+ resetCredits: nil,
+ fetchedAt: Date()
+ )
+}
+
+private func limit(used: Double, of total: Double, reached: Bool = false) -> CodexUsage.CreditLimit {
+ CodexUsage.CreditLimit(
+ used: used,
+ limit: total,
+ usedPercent: used / total * 100,
+ resetsAt: Date(timeIntervalSince1970: 1_785_542_400),
+ windowSeconds: 31 * 86_400,
+ reached: reached
+ )
+}
+
+@MainActor
+private func store(_ usage: CodexUsage) -> AppStore {
+ let store = AppStore()
+ store.codexUsage = usage
+ // Pinned: the default depends on whether this machine has Codex connected.
+ store.codexLoadState = .loaded
+ return store
+}
+
+@MainActor
+struct CodexQuotaSummaryTests {
+ @Test("credit-settled balances group without a currency symbol")
+ func creditSettledBalanceIsGroupedAndUnprefixed() {
+ let store = store(usage(balance: 3410.4, hasCredits: true))
+ #expect(store.quotaSummary(for: .codex)?.footerLines == ["Credits remaining · 3,410"])
+ }
+
+ @Test("dollar balances keep the currency formatting")
+ func dollarBalanceKeepsCurrencyFormatting() {
+ let store = store(usage(balance: 3410.4, hasCredits: false))
+ #expect(store.quotaSummary(for: .codex)?.footerLines == ["Credits remaining · $3,410.40"])
+ }
+
+ @Test("an exact-half credit balance rounds up, matching the desktop decoder")
+ func creditBalanceRoundsHalfUp() {
+ let store = store(usage(balance: 3410.5, hasCredits: true))
+ #expect(store.quotaSummary(for: .codex)?.footerLines == ["Credits remaining · 3,411"])
+ }
+
+ @Test("an uncapped credit account says so instead of showing nothing")
+ func uncappedAccountSaysUnlimited() {
+ let store = store(usage(hasCredits: true, unlimited: true))
+ #expect(store.quotaSummary(for: .codex)?.footerLines == ["Credits · Unlimited"])
+ }
+
+ @Test("the allowance row drives the chip with the short label")
+ func allowanceRowUsesTheShortLabel() {
+ let store = store(usage(creditLimit: limit(used: 3033, of: 10_000)))
+ let summary = store.quotaSummary(for: .codex)
+ #expect(summary?.primary?.label == "Monthly usage limit")
+ #expect(summary?.primary?.percent == 0.3033)
+ #expect(summary?.footerLines.isEmpty == true)
+ }
+
+ @Test("a spent-out allowance is called out on the chip")
+ func reachedAllowanceIsCalledOut() {
+ let store = store(usage(creditLimit: limit(used: 10_000, of: 10_000, reached: true)))
+ #expect(store.quotaSummary(for: .codex)?.primary?.label == "Monthly usage limit · limit reached")
+ }
+}
diff --git a/src/cache-refresh-lock.ts b/src/cache-refresh-lock.ts
index a012009..58faf28 100644
--- a/src/cache-refresh-lock.ts
+++ b/src/cache-refresh-lock.ts
@@ -1,4 +1,4 @@
-import { randomBytes } from 'crypto'
+import { createHash, randomBytes } from 'crypto'
import { existsSync } from 'fs'
import { mkdir, open, readFile, stat, unlink, utimes, writeFile } from 'fs/promises'
import { homedir } from 'os'
@@ -81,6 +81,13 @@ async function retryWindowsMutation(operation: () => Promise, sleep: (ms:
return false
}
+// The directory entry becomes visible before the awaited body write, so the
+// file is briefly observable at zero bytes. Deliberately left as is: a corrupt
+// body is only ever recovered once its mtime is older than staleMs, and this
+// window is milliseconds wide on a file whose mtime is by definition now, so
+// no observer can reach the age gate through it. Closing it would mean
+// link()ing a temp file into place, which is not portable to filesystems
+// without hard links.
async function createExclusive(path: string, body: string): Promise<'created' | 'exists' | 'unavailable'> {
try {
const handle = await open(path, 'wx', 0o600)
@@ -92,7 +99,24 @@ async function createExclusive(path: string, body: string): Promise<'created' |
}
}
-type Observation = { record: LockRecord; mtimeMs: number }
+// A null record is a body whose stat bracket agreed across the read and that
+// still does not parse into a lock record: a corrupt leftover of 0 bytes, a
+// truncation, or a wrong shape. The bracket is a heuristic, not proof that the
+// read was whole — a same-size rewrite moves neither size nor (on a coarse
+// filesystem) mtime — which is why nothing here treats a single read as
+// authoritative. It owns nothing, but it is a real file with a
+// real mtime, not an infrastructure failure — classifying it 'unavailable'
+// routed every later refresh to the read-only path and froze ingestion. It
+// carries no authority: it is only ever recovered through the unmodified
+// staleness gate, exactly like an abandoned but well-formed lock.
+//
+// `digest` fingerprints the exact bytes. A corrupt body has no token, so
+// token equality between two corrupt observations degenerates to
+// `undefined === undefined`, and mtime granularity is coarse on some
+// filesystems (measured on macOS: a 2s grid on FAT32, 10ms on exFAT, sub-ms on
+// APFS — and on all three a same-size rewrite moves neither mtime nor size), so
+// mtime is not a reliable change signal on its own.
+type Observation = { record: LockRecord | null; mtimeMs: number; digest: string }
type ObservationResult = Observation | 'missing' | 'changing' | 'unavailable'
async function observe(path: string): Promise {
@@ -100,6 +124,7 @@ async function observe(path: string): Promise {
// written, and heartbeat rewrites briefly truncate it. Treat that bounded
// transition as contention, not broken infrastructure.
let sawChange = false
+ let corrupt: Observation | null = null
for (let attempt = 0; attempt < 3; attempt++) {
try {
const before = await stat(path)
@@ -110,10 +135,23 @@ async function observe(path: string): Promise {
await delay(1)
continue
}
- const parsed = JSON.parse(raw) as Partial
- if (typeof parsed.pid === 'number' && typeof parsed.token === 'string' && typeof parsed.at === 'number') {
- return { record: { pid: parsed.pid, token: parsed.token, at: parsed.at }, mtimeMs: after.mtimeMs }
+ const digest = createHash('sha1').update(raw).digest('hex')
+ // A body that is valid JSON of the wrong shape is corrupt like any other,
+ // including one written by a future version with a different record
+ // shape. That is safe precisely because staleness is never waived: a
+ // foreign version's LIVE lock keeps its mtime fresh through its own
+ // heartbeat, so it is never taken — both versions just degrade to the
+ // read-only path. Only an abandoned one is recovered, and a lock record
+ // is per-run state with nothing in it worth preserving.
+ let parsed: Partial | undefined
+ try { parsed = JSON.parse(raw) as Partial } catch { parsed = undefined }
+ if (parsed && typeof parsed.pid === 'number' && typeof parsed.token === 'string' && typeof parsed.at === 'number') {
+ return { record: { pid: parsed.pid, token: parsed.token, at: parsed.at }, mtimeMs: after.mtimeMs, digest }
}
+ // Keep the most recent corrupt read. It is not evidence of stability on
+ // its own: tryTakeover re-observes under the guard and compares with
+ // sameObservation before acting, so stability is proven there, not here.
+ corrupt = { record: null, mtimeMs: after.mtimeMs, digest }
} catch (err) {
if (isMissingError(err)) return 'missing'
const code = (err as NodeJS.ErrnoException | undefined)?.code
@@ -121,11 +159,19 @@ async function observe(path: string): Promise {
}
await delay(1)
}
- return sawChange ? 'changing' : 'unavailable'
+ // Contention outranks corruption: a body seen mid-rewrite is a live owner's,
+ // and the caller must poll rather than treat it as recoverable.
+ if (sawChange) return 'changing'
+ return corrupt ?? 'unavailable'
}
function sameObservation(a: Observation, b: Observation): boolean {
- return a.record.token === b.record.token && a.mtimeMs === b.mtimeMs
+ // A corrupt body and an owned one are never "the same observation", even
+ // though `a.record?.token === b.record?.token` cannot tell them apart once
+ // both sides are corrupt. Compare that boundary explicitly, then require the
+ // bytes themselves to match, so "unchanged" survives a coarse mtime.
+ if ((a.record === null) !== (b.record === null)) return false
+ return a.record?.token === b.record?.token && a.mtimeMs === b.mtimeMs && a.digest === b.digest
}
let singleFlightTail: Promise = Promise.resolve()
@@ -209,7 +255,7 @@ export async function acquireCacheRefreshLock(options: RefreshLockOptions = {}):
if (current === 'missing') return true
if (current === 'changing') return false
if (current === 'unavailable') return false
- if (current.record.token !== token) return true
+ if (current.record?.token !== token) return true
return retryWindowsMutation(() => unlink(lockPath), sleep)
} finally {
await retryWindowsMutation(() => unlink(takeoverPath), sleep)
@@ -221,7 +267,7 @@ export async function acquireCacheRefreshLock(options: RefreshLockOptions = {}):
if (guard !== 'created') return false
try {
const current = await observe(lockPath)
- return current !== 'missing' && current !== 'changing' && current !== 'unavailable' && current.record.token === token
+ return current !== 'missing' && current !== 'changing' && current !== 'unavailable' && current.record?.token === token
} finally {
await retryWindowsMutation(() => unlink(takeoverPath), sleep)
}
@@ -238,7 +284,23 @@ export async function acquireCacheRefreshLock(options: RefreshLockOptions = {}):
if (guard !== 'created') { heartbeatRunning = false; return }
try {
const current = await observe(lockPath)
- if (current === 'missing' || current === 'changing' || current === 'unavailable' || current.record.token !== token) return
+ if (current === 'missing' || current === 'changing' || current === 'unavailable') return
+ // A corrupt body is NOT ours to rewrite, even though no parseable
+ // token contradicts us. Holding the takeover guard excludes the other
+ // guard-takers, but NOT createExclusive, which publishes a directory
+ // entry before its body — so an unparseable body may be a successor's
+ // lock a millisecond from being written, or a foreign version's whose
+ // record shape we cannot read. Stamping our token over it made this
+ // process an owner again after it had been legitimately replaced:
+ // verifyStillOwner then answered true for a displaced writer, and
+ // release()'s removeIfOwned deleted the live successor's lock.
+ //
+ // So a body we cannot prove is ours ends our ownership. The mtime
+ // stops advancing, the fence refuses to publish (the parse is
+ // discarded, which is the fail-safe direction), and a successor
+ // recovers the lock one staleMs later through the age gate. Losing a
+ // parse is the correct price for never having two owners.
+ if (current.record === null || current.record.token !== token) return
await writeFile(lockPath, body(), { encoding: 'utf-8' })
const now = new Date(clock.wallNow())
await utimes(lockPath, now, now)
@@ -325,6 +387,12 @@ export async function acquireCacheRefreshLock(options: RefreshLockOptions = {}):
continue
}
+ // A corrupt observation takes this path unchanged. Staleness is never
+ // waived for it: an abandoned corrupt lock is older than staleMs and is
+ // recovered here, while a corrupt body younger than that is waited out
+ // and left alone, because it may belong to a live owner whose heartbeat
+ // will repair it. Worst case we time out and serve the prior snapshot
+ // read-only for one staleMs window instead of freezing forever.
const age = Math.max(0, clock.wallNow() - observation.mtimeMs)
if (age > staleMs) {
const takeover = await tryTakeover(observation)
diff --git a/src/codex-cache.ts b/src/codex-cache.ts
index 0eb59b4..6146e8e 100644
--- a/src/codex-cache.ts
+++ b/src/codex-cache.ts
@@ -11,9 +11,10 @@ import type { ParsedProviderCall } from './providers/types.js'
// v5: also attribute CLI-wrapped MCP calls (`mcp-cli call server tool`) that
// Codex logs as a plain exec_command (issue #478 follow-up). Force a re-parse
// so sessions cached under v4 pick up the CLI-MCP attribution.
-// v6: rich-session-capture — per-call locAdded/locRemoved/editFailed from
+// v6/v7: rich-session-capture — per-call locAdded/locRemoved/editFailed from
// patch_apply_end. Sessions cached under v5 lack these fields; re-parse to add.
-const CODEX_CACHE_VERSION = 7
+// v8: persist native MCP timing and compact invocation attribution.
+const CODEX_CACHE_VERSION = 8
const CACHE_FILE = 'codex-results.json'
type FileFingerprint = { mtimeMs: number; sizeBytes: number }
diff --git a/src/codex-throughput.ts b/src/codex-throughput.ts
new file mode 100644
index 0000000..4796206
--- /dev/null
+++ b/src/codex-throughput.ts
@@ -0,0 +1,521 @@
+import { open, stat } from 'node:fs/promises'
+import { StringDecoder } from 'node:string_decoder'
+
+export type CodexThroughputPoint = {
+ timestamp: string
+ model?: string
+ outputTokens: number
+ reasoningTokens: number
+ generatedTokens: number
+ taskGeneratedTokens?: number
+ elapsedSeconds?: number
+ generatedTokensPerSecond?: number
+ activeDurationSeconds?: number
+ activeGeneratedTokensPerSecond?: number
+ toolWaitSeconds?: number
+}
+
+type TokenUsage = {
+ output_tokens?: number
+ reasoning_output_tokens?: number
+ total_tokens?: number
+}
+
+type RolloutLine = {
+ type?: string
+ timestamp?: string
+ payload?: {
+ type?: string
+ turn_id?: string
+ call_id?: string
+ started_at?: number
+ duration_ms?: number
+ duration?: { secs?: number; nanos?: number } | string
+ model?: string
+ forked_from_id?: string
+ info?: {
+ last_token_usage?: TokenUsage
+ total_token_usage?: TokenUsage
+ }
+ }
+}
+
+const CHUNK_BYTES = 64 * 1024
+const MAX_PENDING_LINE_CHARS = 4 * 1024 * 1024
+const TRUNCATION_MARKER = '__CODEBURN_TRUNCATED_LINE__'
+
+function rawString(source: string, field: string): string | undefined {
+ const match = new RegExp(`"${field}"\\s*:\\s*"((?:\\\\.|[^"\\\\])*)"`).exec(source)
+ if (!match) return undefined
+ try { return JSON.parse(`"${match[1]}"`) as string } catch { return undefined }
+}
+
+function rawNumber(source: string, field: string): number | undefined {
+ const match = new RegExp(`"${field}"\\s*:\\s*(-?\\d+(?:\\.\\d+)?)`).exec(source)
+ if (!match) return undefined
+ const value = Number(match[1])
+ return Number.isFinite(value) ? value : undefined
+}
+
+function compactUsage(source: string, field: 'last_token_usage' | 'total_token_usage'): TokenUsage | undefined {
+ const index = source.indexOf(`"${field}"`)
+ if (index < 0) return undefined
+ const body = source.slice(index, index + 4096)
+ return {
+ output_tokens: rawNumber(body, 'output_tokens'),
+ reasoning_output_tokens: rawNumber(body, 'reasoning_output_tokens'),
+ total_tokens: rawNumber(body, 'total_tokens'),
+ }
+}
+
+function parseRawDurationValue(value: string): number | undefined {
+ const objectMatch = /^\s*\{\s*"secs"\s*:\s*(-?\d+(?:\.\d+)?)\s*,\s*"nanos"\s*:\s*(-?\d+(?:\.\d+)?)\s*\}/.exec(value)
+ if (objectMatch) {
+ const seconds = Number(objectMatch[1])
+ const nanos = Number(objectMatch[2])
+ if (Number.isFinite(seconds) && Number.isFinite(nanos)) return seconds * 1000 + nanos / 1e6
+ }
+ const stringMatch = /^\s*"(\d+(?:\.\d+)?)(ms|s)?"/.exec(value)
+ if (stringMatch) {
+ const parsed = Number(stringMatch[1])
+ if (Number.isFinite(parsed)) return parsed * (stringMatch[2] === 's' ? 1000 : 1)
+ }
+ const numberMatch = /^\s*(-?\d+(?:\.\d+)?)/.exec(value)
+ if (numberMatch) {
+ const parsed = Number(numberMatch[1])
+ if (Number.isFinite(parsed)) return parsed
+ }
+ return undefined
+}
+
+function durationMs(payload: RolloutLine['payload']): number | undefined {
+ if (!payload) return undefined
+ if (typeof payload.duration_ms === 'number' && Number.isFinite(payload.duration_ms)) return payload.duration_ms
+ if (typeof payload.duration === 'object' && payload.duration) {
+ const seconds = payload.duration.secs
+ const nanos = payload.duration.nanos
+ if (typeof seconds === 'number' && typeof nanos === 'number' && Number.isFinite(seconds) && Number.isFinite(nanos)) {
+ return seconds * 1000 + nanos / 1e6
+ }
+ }
+ if (typeof payload.duration === 'string') {
+ const match = /^(\d+(?:\.\d+)?)(ms|s)?$/.exec(payload.duration.trim())
+ if (match) return Number(match[1]) * (match[2] === 's' ? 1000 : 1)
+ }
+ return undefined
+}
+
+function mergeToolIntervals(intervals: Array<[number, number]>, durationMs: number, taskStartedAt?: number, taskCompletedAt?: number): number {
+ const windowStart = taskStartedAt ?? (taskCompletedAt !== undefined ? taskCompletedAt - durationMs : undefined)
+ const windowEnd = windowStart !== undefined ? windowStart + durationMs : undefined
+ const clipped = intervals.map(([start, end]) => [
+ windowStart !== undefined ? Math.max(start, windowStart) : start,
+ windowEnd !== undefined ? Math.min(end, windowEnd) : end,
+ ] as [number, number]).filter(([start, end]) => end > start)
+ const merged = clipped.sort((a, b) => a[0] - b[0]).reduce>((result, interval) => {
+ const previous = result.at(-1)
+ if (previous && interval[0] <= previous[1]) previous[1] = Math.max(previous[1], interval[1])
+ else result.push([...interval])
+ return result
+ }, [])
+ return Math.min(durationMs, merged.reduce((total, [start, end]) => total + end - start, 0))
+}
+
+function parseLine(line: string): RolloutLine | null {
+ const payloadStart = line.indexOf('"payload"')
+ const payloadHead = payloadStart >= 0 ? line.slice(payloadStart) : line
+ if (line.length > 256 * 1024 || line.startsWith(TRUNCATION_MARKER)) {
+ const payloadType = rawString(payloadHead, 'type')
+ const infoStart = payloadHead.indexOf('"info"')
+ const info = infoStart >= 0 ? payloadHead.slice(infoStart) : ''
+ return {
+ type: rawString(line, 'type'),
+ timestamp: rawString(line, 'timestamp'),
+ payload: {
+ type: payloadType,
+ turn_id: rawString(payloadHead, 'turn_id'),
+ call_id: rawString(payloadHead, 'call_id'),
+ started_at: rawNumber(payloadHead, 'started_at'),
+ duration_ms: rawNumber(payloadHead, 'duration_ms'),
+ duration: rawString(payloadHead, 'duration') ?? (rawNumber(payloadHead, 'secs') !== undefined
+ ? { secs: rawNumber(payloadHead, 'secs'), nanos: rawNumber(payloadHead, 'nanos') }
+ : undefined),
+ model: rawString(payloadHead, 'model'),
+ forked_from_id: rawString(payloadHead, 'forked_from_id'),
+ info: {
+ last_token_usage: compactUsage(info, 'last_token_usage'),
+ total_token_usage: compactUsage(info, 'total_token_usage'),
+ },
+ },
+ }
+ }
+ try {
+ return JSON.parse(line) as RolloutLine
+ } catch {
+ return null
+ }
+}
+
+/**
+ * Estimate generated tokens/sec from a Codex rollout's persisted checkpoints.
+ * Codex JSONL has no per-token timestamps, so this is deliberately a
+ * checkpoint-to-checkpoint estimate, not live decode speed.
+ */
+type ThroughputState = {
+ model?: string
+ previousTotal?: number
+ previousOutput: number
+ previousReasoning: number
+ previousTimestamp?: number
+ currentTaskGenerated: number
+ currentTaskToolIntervals: Array<[number, number]>
+ currentTaskStartedAt?: number
+ toolStarts: Map
+ latestPoint?: CodexThroughputPoint
+ points: CodexThroughputPoint[]
+ forkCutoffMs?: number
+}
+
+function newThroughputState(): ThroughputState {
+ return {
+ previousOutput: 0,
+ previousReasoning: 0,
+ currentTaskGenerated: 0,
+ currentTaskToolIntervals: [],
+ toolStarts: new Map(),
+ points: [],
+ }
+}
+
+/**
+ * Incrementally parses a rollout. Watch mode feeds only newly appended bytes
+ * to this reader, so a growing JSONL file is not reparsed from byte zero.
+ */
+export class CodexThroughputReader {
+ private offset = 0
+ private pending = ''
+ private decoder = new StringDecoder('utf8')
+ private pendingDurationMs: number | undefined
+ private scanDepth = 0
+ private scanPayloadDepth: number | undefined
+ private scanInString = false
+ private scanEscape = false
+ private scanString = ''
+ private scanLastString = ''
+ private scanAwaitingColon = false
+ private scanCurrentKey: string | undefined
+ private scanCapture: { mode: 'string' | 'object' | 'primitive'; text: string; depth: number } | undefined
+ private state = newThroughputState()
+
+ reset(): void {
+ this.offset = 0
+ this.pending = ''
+ this.decoder = new StringDecoder('utf8')
+ this.pendingDurationMs = undefined
+ this.scanDepth = 0
+ this.scanPayloadDepth = undefined
+ this.scanInString = false
+ this.scanEscape = false
+ this.scanString = ''
+ this.scanLastString = ''
+ this.scanAwaitingColon = false
+ this.scanCurrentKey = undefined
+ this.scanCapture = undefined
+ this.state = newThroughputState()
+ }
+
+ private finishDurationCapture(): void {
+ if (!this.scanCapture) return
+ const value = this.scanCapture.mode === 'string' ? `"${this.scanCapture.text}"` : this.scanCapture.text
+ const parsed = parseRawDurationValue(value)
+ if (parsed !== undefined && this.pendingDurationMs === undefined) this.pendingDurationMs = parsed
+ this.scanCapture = undefined
+ }
+
+ private scanDurationSegment(source: string): void {
+ for (let i = 0; i < source.length; i++) {
+ const char = source[i]!
+ if (this.scanInString) {
+ if (this.scanEscape) {
+ this.scanEscape = false
+ if (this.scanCapture?.mode === 'object') this.scanCapture.text += char
+ else if (this.scanCapture?.mode === 'string') this.scanCapture.text += char
+ else this.scanString += char
+ continue
+ }
+ if (char === '\\') {
+ this.scanEscape = true
+ if (this.scanCapture?.mode === 'object' || this.scanCapture?.mode === 'string') this.scanCapture.text += char
+ continue
+ }
+ if (char === '"') {
+ if (this.scanCapture?.mode === 'object') this.scanCapture.text += char
+ this.scanInString = false
+ if (this.scanCapture?.mode === 'string') this.finishDurationCapture()
+ else if (this.scanCapture?.mode === 'object') {
+ this.scanAwaitingColon = false
+ this.scanCurrentKey = undefined
+ } else {
+ this.scanLastString = this.scanString
+ this.scanAwaitingColon = true
+ }
+ continue
+ }
+ if (this.scanCapture?.mode === 'object' || this.scanCapture?.mode === 'string') this.scanCapture.text += char
+ else this.scanString += char
+ continue
+ }
+
+ if (this.scanCapture?.mode === 'primitive') {
+ if (char === ',' || char === '}' || char === ']') this.finishDurationCapture()
+ else { this.scanCapture.text += char; continue }
+ }
+ if (this.scanAwaitingColon) {
+ if (/\s/.test(char)) continue
+ if (char === ':') {
+ this.scanCurrentKey = this.scanLastString
+ this.scanAwaitingColon = false
+ continue
+ }
+ this.scanAwaitingColon = false
+ }
+ if (char === '"') {
+ this.scanString = ''
+ if (this.scanCapture?.mode === 'object') this.scanCapture.text += char
+ if (this.scanCurrentKey === 'duration' && this.scanPayloadDepth === this.scanDepth) {
+ this.scanCapture = { mode: 'string', text: '', depth: this.scanDepth }
+ this.scanCurrentKey = undefined
+ }
+ this.scanInString = true
+ continue
+ }
+ if (char === '{' || char === '[') {
+ if (this.scanCurrentKey === 'payload' && char === '{' && this.scanPayloadDepth === undefined) {
+ this.scanPayloadDepth = this.scanDepth + 1
+ }
+ if (this.scanCurrentKey === 'duration' && this.scanPayloadDepth === this.scanDepth) {
+ this.scanCapture = { mode: 'object', text: char, depth: this.scanDepth + 1 }
+ this.scanCurrentKey = undefined
+ } else if (this.scanCapture?.mode === 'object') {
+ this.scanCapture.text += char
+ }
+ this.scanDepth++
+ continue
+ }
+ if (char === '}' || char === ']') {
+ if (this.scanCapture?.mode === 'object') this.scanCapture.text += char
+ this.scanDepth = Math.max(0, this.scanDepth - 1)
+ if (this.scanCapture?.mode === 'object' && this.scanDepth < this.scanCapture.depth) this.finishDurationCapture()
+ continue
+ }
+ if (this.scanCurrentKey === 'duration' && this.scanPayloadDepth === this.scanDepth && !/\s/.test(char)) {
+ this.scanCapture = { mode: 'primitive', text: char, depth: this.scanDepth }
+ this.scanCurrentKey = undefined
+ continue
+ }
+ if (this.scanCapture?.mode === 'object') this.scanCapture.text += char
+ }
+ }
+
+ private processLine(line: string, durationOverride?: number): void {
+ const entry = parseLine(line)
+ if (!entry) return
+ if (durationOverride !== undefined && (line.startsWith(TRUNCATION_MARKER) || line.length > 256 * 1024) && entry.type === 'event_msg' && (entry.payload?.type === 'mcp_tool_call_end' || entry.payload?.type === 'task_complete')) {
+ entry.payload = { ...entry.payload, duration_ms: durationOverride }
+ }
+ const state = this.state
+ if (entry.type === 'session_meta') {
+ if (entry.payload?.model) state.model = entry.payload.model
+ if (entry.payload?.forked_from_id && entry.timestamp) {
+ const timestamp = Date.parse(entry.timestamp)
+ if (Number.isFinite(timestamp)) state.forkCutoffMs = timestamp + 5000
+ }
+ return
+ }
+ if (entry.type === 'turn_context' && entry.payload?.model) state.model = entry.payload.model
+ const entryTimestamp = entry.timestamp ? Date.parse(entry.timestamp) : NaN
+ const isForkReplay = state.forkCutoffMs !== undefined && Number.isFinite(entryTimestamp) && entryTimestamp < state.forkCutoffMs
+ if (isForkReplay && (
+ entry.payload?.type === 'task_started' ||
+ entry.payload?.type === 'task_complete' ||
+ entry.payload?.type === 'function_call' ||
+ entry.payload?.type === 'function_call_output' ||
+ entry.payload?.type === 'custom_tool_call' ||
+ entry.payload?.type === 'custom_tool_call_output' ||
+ entry.payload?.type === 'mcp_tool_call_end' ||
+ entry.payload?.type === 'patch_apply_end' ||
+ entry.payload?.type === 'token_count'
+ )) return
+ if (entry.type === 'event_msg' && entry.payload?.type === 'task_started') {
+ state.currentTaskGenerated = 0
+ state.currentTaskToolIntervals = []
+ const startedAt = entry.timestamp ? Date.parse(entry.timestamp) : NaN
+ state.currentTaskStartedAt = Number.isFinite(startedAt) ? startedAt : undefined
+ state.toolStarts.clear()
+ }
+ if (entry.type === 'response_item' && (entry.payload?.type === 'function_call' || entry.payload?.type === 'custom_tool_call') && entry.payload.call_id && entry.timestamp) {
+ const started = Date.parse(entry.timestamp)
+ if (Number.isFinite(started)) state.toolStarts.set(entry.payload.call_id, started)
+ }
+ if (entry.type === 'response_item' && (entry.payload?.type === 'function_call_output' || entry.payload?.type === 'custom_tool_call_output') && entry.payload.call_id && entry.timestamp) {
+ const ended = Date.parse(entry.timestamp)
+ const started = state.toolStarts.get(entry.payload.call_id)
+ if (started !== undefined && Number.isFinite(ended) && ended > started) state.currentTaskToolIntervals.push([started, ended])
+ state.toolStarts.delete(entry.payload.call_id)
+ }
+ if (entry.type === 'event_msg' && entry.payload?.type === 'mcp_tool_call_end' && entry.timestamp) {
+ const ended = Date.parse(entry.timestamp)
+ const elapsed = durationMs(entry.payload)
+ if (Number.isFinite(ended) && elapsed !== undefined && elapsed > 0) state.currentTaskToolIntervals.push([ended - elapsed, ended])
+ }
+ if (entry.type === 'event_msg' && entry.payload?.type === 'task_complete') {
+ const taskDurationMs = durationMs(entry.payload)
+ if (state.latestPoint && typeof taskDurationMs === 'number' && taskDurationMs > 0 && state.currentTaskGenerated > 0) {
+ state.latestPoint.taskGeneratedTokens = state.currentTaskGenerated
+ const completedAt = entry.timestamp ? Date.parse(entry.timestamp) : undefined
+ const toolWaitMs = mergeToolIntervals(state.currentTaskToolIntervals, taskDurationMs, state.currentTaskStartedAt, Number.isFinite(completedAt) ? completedAt : undefined)
+ const activeMs = taskDurationMs - toolWaitMs
+ if (activeMs > 0) {
+ state.latestPoint.activeDurationSeconds = activeMs / 1000
+ state.latestPoint.toolWaitSeconds = toolWaitMs / 1000
+ state.latestPoint.activeGeneratedTokensPerSecond = state.currentTaskGenerated / (activeMs / 1000)
+ }
+ }
+ }
+ if (entry.type !== 'event_msg' || entry.payload?.type !== 'token_count') return
+ const info = entry.payload.info
+ if (!info || !entry.timestamp) return
+ const last = info.last_token_usage
+ const total = info.total_token_usage
+ const cumulative = total?.total_tokens
+ if (cumulative !== undefined && cumulative === state.previousTotal) return
+ let outputTokens = last?.output_tokens ?? 0
+ let reasoningTokens = last?.reasoning_output_tokens ?? 0
+ if (!last && total && cumulative !== undefined && state.previousTotal !== undefined) {
+ outputTokens = Math.max(0, (total.output_tokens ?? 0) - state.previousOutput)
+ reasoningTokens = Math.max(0, (total.reasoning_output_tokens ?? 0) - state.previousReasoning)
+ }
+ if (cumulative !== undefined) {
+ state.previousTotal = cumulative
+ state.previousOutput = total?.output_tokens ?? state.previousOutput
+ state.previousReasoning = total?.reasoning_output_tokens ?? state.previousReasoning
+ }
+ const generatedTokens = outputTokens + reasoningTokens
+ if (generatedTokens <= 0) return
+ const timestampMs = Date.parse(entry.timestamp)
+ if (!Number.isFinite(timestampMs)) return
+ const point: CodexThroughputPoint = {
+ timestamp: entry.timestamp,
+ model: state.model,
+ outputTokens,
+ reasoningTokens,
+ generatedTokens,
+ }
+ state.currentTaskGenerated += generatedTokens
+ state.latestPoint = point
+ if (state.previousTimestamp !== undefined && timestampMs > state.previousTimestamp) {
+ const elapsedSeconds = (timestampMs - state.previousTimestamp) / 1000
+ point.elapsedSeconds = elapsedSeconds
+ point.generatedTokensPerSecond = generatedTokens / elapsedSeconds
+ }
+ state.previousTimestamp = timestampMs
+ state.points.push(point)
+ if (state.points.length > 10000) state.points.splice(0, state.points.length - 10000)
+ }
+
+ async update(filePath: string, limit = 10, finalize = false): Promise {
+ const info = await stat(filePath)
+ if (info.size < this.offset) this.reset()
+ const bytesToRead = info.size - this.offset
+ if (bytesToRead > 0) {
+ const file = await open(filePath, 'r')
+ try {
+ let position = this.offset
+ while (position < info.size) {
+ const buffer = Buffer.allocUnsafe(Math.min(CHUNK_BYTES, info.size - position))
+ const { bytesRead } = await file.read(buffer, 0, buffer.length, position)
+ if (bytesRead === 0) break
+ position += bytesRead
+ this.offset = position
+ let chunk = this.decoder.write(buffer.subarray(0, bytesRead))
+ while (chunk.length > 0) {
+ const newlineIndex = chunk.search(/\r?\n/)
+ const segment = newlineIndex >= 0 ? chunk.slice(0, newlineIndex) : chunk
+ this.pending += segment
+ this.scanDurationSegment(segment)
+ if (newlineIndex < 0) break
+ const newlineLength = chunk[newlineIndex] === '\r' ? 2 : 1
+ const line = this.pending
+ const durationOverride = this.pendingDurationMs
+ this.pending = ''
+ this.pendingDurationMs = undefined
+ this.scanDepth = 0
+ this.scanPayloadDepth = undefined
+ this.scanInString = false
+ this.scanEscape = false
+ this.scanString = ''
+ this.scanLastString = ''
+ this.scanAwaitingColon = false
+ this.scanCurrentKey = undefined
+ this.scanCapture = undefined
+ this.processLine(line, durationOverride)
+ chunk = chunk.slice(newlineIndex + newlineLength)
+ }
+ if (this.pending.length > MAX_PENDING_LINE_CHARS) {
+ const body = this.pending.startsWith(TRUNCATION_MARKER)
+ ? this.pending.slice(TRUNCATION_MARKER.length)
+ : this.pending
+ this.pending = TRUNCATION_MARKER + body.slice(0, 256 * 1024) + body.slice(-256 * 1024)
+ }
+ }
+ } finally {
+ await file.close()
+ }
+ }
+ if (finalize && this.pending) {
+ this.processLine(this.pending, this.pendingDurationMs)
+ this.pending = ''
+ this.pendingDurationMs = undefined
+ }
+ return limit > 0 ? this.state.points.slice(-limit) : this.state.points.slice()
+ }
+}
+
+export async function readCodexThroughput(filePath: string, limit = 10): Promise {
+ return new CodexThroughputReader().update(filePath, limit, true)
+}
+
+export async function newestCodexSession(sessions: Array<{ path: string }>): Promise {
+ let newest: { path: string; mtimeMs: number } | undefined
+ for (const session of sessions) {
+ try {
+ const info = await stat(session.path)
+ if (!newest || info.mtimeMs > newest.mtimeMs) newest = { path: session.path, mtimeMs: info.mtimeMs }
+ } catch {
+ // A session can disappear while Codex rotates or archives it.
+ }
+ }
+ return newest?.path
+}
+
+export function renderCodexThroughput(points: CodexThroughputPoint[], filePath: string): string {
+ const latest = points.at(-1)
+ if (!latest) return `No token_count checkpoints found in ${filePath}.`
+ const lines = [
+ 'CodeBurn Codex throughput estimate',
+ `Session: ${filePath}`,
+ `Latest checkpoint: ${latest.timestamp}`,
+ `Latest checkpoint tokens: ${latest.generatedTokens.toLocaleString()} (${latest.outputTokens.toLocaleString()} output + ${latest.reasoningTokens.toLocaleString()} reasoning)`,
+ ]
+ if (latest.taskGeneratedTokens !== undefined) lines.push(`Completed task total: ${latest.taskGeneratedTokens.toLocaleString()} generated tokens`)
+ if (latest.activeGeneratedTokensPerSecond !== undefined) {
+ lines.push(`Active throughput: ${latest.activeGeneratedTokensPerSecond.toFixed(1)} generated tokens/sec over ${latest.activeDurationSeconds!.toFixed(1)}s`)
+ lines.push(`Excluded tool wait: ${latest.toolWaitSeconds!.toFixed(1)}s`)
+ } else if (latest.generatedTokensPerSecond !== undefined) {
+ lines.push(`Checkpoint estimate: ${latest.generatedTokensPerSecond.toFixed(1)} generated tokens/sec over ${latest.elapsedSeconds!.toFixed(1)}s`)
+ } else {
+ lines.push('Throughput: unavailable (waiting for a completed turn)')
+ }
+ lines.push('Note: offline JSONL estimate; tool intervals are removed, but server/prompt latency may remain.')
+ return lines.join('\n')
+}
diff --git a/src/daily-cache.ts b/src/daily-cache.ts
index c5439c3..7a7486c 100644
--- a/src/daily-cache.ts
+++ b/src/daily-cache.ts
@@ -150,6 +150,14 @@ export type DailyCache = {
/// as incomplete and is fully re-backfilled. Absent on caches written before
/// this field existed → treated as incomplete (one self-healing re-backfill).
complete?: boolean
+ /// True once a COMPLETE parse finalized this watermark. The pull-back below
+ /// only distrusts caches WITHOUT this stamp: a degraded parse can no longer
+ /// set `complete`, so a stamped cache whose watermark sits past its newest
+ /// populated day is a legitimately idle tail (recent days had no activity),
+ /// not a frozen hole, and re-deriving it every launch is pure waste. Absent
+ /// on caches written before this field: distrusted once (one healing
+ /// pull-back), then stamped.
+ watermarkTrusted?: boolean
}
function getCacheDir(): string {
@@ -257,7 +265,13 @@ function sanitizeProjects(raw: unknown): { projects?: DailyEntry['projects'] } {
if (!isRecord(raw)) return {}
const out: NonNullable = {}
for (const [name, p] of Object.entries(raw)) {
- if (name in Object.prototype || !isRecord(p)) continue
+ // A project key is a directory basename, so it can legitimately be a
+ // prototype-member name ("constructor", "valueOf", ...). `setOwn` writes it
+ // as an own property via defineProperty, so keeping it is pollution-safe —
+ // and dropping it would silently subtract that project's cost from a
+ // --project/--exclude total (the day's split would no longer sum to its own
+ // cost, which the filtered headline relies on).
+ if (!isRecord(p)) continue
setOwn(out, name, {
cost: num(p.cost),
calls: num(p.calls),
@@ -294,7 +308,7 @@ function migrateDays(days: Record[]): DailyEntry[] {
}))
}
-function migratedFrom(parsed: { version: number; lastComputedDate: string | null; savingsConfigHash?: string; tzKey?: string; days: Record[]; complete?: boolean }): DailyCache {
+function migratedFrom(parsed: { version: number; lastComputedDate: string | null; savingsConfigHash?: string; tzKey?: string; days: Record[]; complete?: boolean; watermarkTrusted?: boolean }): DailyCache {
return {
version: DAILY_CACHE_VERSION,
savingsConfigHash: parsed.savingsConfigHash ?? '',
@@ -306,6 +320,9 @@ function migratedFrom(parsed: { version: number; lastComputedDate: string | null
// Only a cache explicitly marked complete stays trusted; one written before
// the marker existed reads false and is re-backfilled once.
complete: parsed.complete === true,
+ // Absent on a pre-fix cache: the watermark is distrusted once (healing
+ // pull-back), then re-stamped by the finalize that follows.
+ watermarkTrusted: parsed.watermarkTrusted === true,
}
}
@@ -409,6 +426,7 @@ async function adoptOlderDailyCaches(): Promise {
// accounting: leave complete unset so the next hydration re-derives every
// day whose sources survive (the merge keeps the rest).
complete: rest.length === candidates.length ? false : base.complete,
+ watermarkTrusted: rest.length === candidates.length ? false : base.watermarkTrusted,
}
await saveDailyCache(adopted).catch(() => {})
return adopted
@@ -451,6 +469,7 @@ export function addNewDays(cache: DailyCache, incoming: DailyEntry[], newestDate
lastComputedDate: nextLast,
days: applyRetention(merged, newestDate),
complete: cache.complete,
+ watermarkTrusted: cache.watermarkTrusted,
}
}
@@ -671,6 +690,27 @@ export async function ensureCacheHydrated(
c = { ...c, days: freshDays, lastComputedDate: latestFresh }
}
+ // A cache can claim `complete` while its watermark points PAST its newest
+ // populated day — what a run finalizing off a degraded (read-only) parse
+ // leaves behind: it advanced lastComputedDate over days the parse never
+ // covered. Since gapStart is lastComputedDate + 1, that hole is invisible
+ // to the gap logic forever. Trust the DATA over the marker: pull the
+ // watermark back to the newest day actually present so the ordinary gap
+ // parse re-derives the tail. Nothing is dropped — the cached days all stay.
+ //
+ // Only UNSTAMPED caches are distrusted here. A degraded parse can no longer
+ // set `complete` (that is this fix), so the corrupt state can only be
+ // written by pre-fix code: an unstamped cache. A stamped one whose watermark
+ // outruns its newest day is a legitimately idle tail (recent days had no
+ // activity), and re-deriving that empty tail on every launch is the
+ // regression this guard avoids. A cache with NO days is exempt: it has no
+ // newest day to trust, and a machine with no history at all must still be
+ // able to finalize (below) rather than re-backfill on every launch.
+ const newestCachedDate = c.days.reduce((max, d) => (max === null || d.date > max ? d.date : max), null)
+ if (c.watermarkTrusted !== true && newestCachedDate !== null && c.lastComputedDate !== null && c.lastComputedDate > newestCachedDate) {
+ c = { ...c, lastComputedDate: newestCachedDate }
+ }
+
// Three reasons to re-derive the whole retention window:
// 1. Savings config changed — cached `savingsUSD` totals are stale.
// 2. The cache was never finalized against a COMPLETE session parse (an old
@@ -691,6 +731,7 @@ export async function ensureCacheHydrated(
const tzChanged = c.tzKey !== undefined && c.tzKey !== tzKey
if (c.savingsConfigHash !== savingsConfigHash || c.complete !== true || tzChanged) {
const baseline = c.days
+ const priorWatermark = c.lastComputedDate
const backfillStart = new Date(now.getFullYear(), now.getMonth(), now.getDate() - BACKFILL_DAYS)
let freshDays: DailyEntry[] = []
if (backfillStart.getTime() <= yesterdayEnd.getTime()) {
@@ -708,9 +749,18 @@ export async function ensureCacheHydrated(
version: DAILY_CACHE_VERSION,
savingsConfigHash,
tzKey,
- lastComputedDate: yesterdayStr,
+ // The watermark records how far history has actually been derived, so
+ // only a COMPLETE parse may advance it. A partial one produced no data
+ // for whatever it could not read; moving the watermark to yesterday
+ // anyway would place those days behind the next run's gapStart and
+ // freeze the hole in (retention still anchors on yesterdayStr — the
+ // real calendar edge — so holding the watermark can't evict anything).
+ lastComputedDate: parseWasComplete ? yesterdayStr : priorWatermark,
days: applyRetention(merged, yesterdayStr),
complete: parseWasComplete,
+ // Stamp the watermark as trusted only when a COMPLETE parse produced it,
+ // so a later idle tail under this watermark is not distrusted above.
+ watermarkTrusted: parseWasComplete,
}
await saveDailyCache(c)
return c
@@ -733,18 +783,23 @@ export async function ensureCacheHydrated(
const gapRange: DateRange = { start: gapStart, end: yesterdayEnd }
const gapProjects = await parseSessions(gapRange)
const gapDays = aggregateDays(gapProjects)
+ const parseWasComplete = sessionComplete()
+ const priorWatermark = c.lastComputedDate
c = addNewDays(c, gapDays, yesterdayStr)
// Finalize as complete ONLY when the session parse that produced these days
// was itself complete. If it was partial, leave `complete: false` so the
// next launch (once the session cache is whole) re-backfills instead of
- // freezing the partial history.
- c = { ...c, complete: sessionComplete() }
+ // freezing the partial history — and hold the watermark where it was, for
+ // the same reason as the re-derive path above: a partial parse cannot
+ // vouch for the days it never read, and gapStart is the only thing that
+ // will ever bring them back.
+ c = { ...c, lastComputedDate: parseWasComplete ? c.lastComputedDate : priorWatermark, complete: parseWasComplete, watermarkTrusted: parseWasComplete }
await saveDailyCache(c)
} else if (c.complete !== true && sessionComplete()) {
// No gap to fill (already current through yesterday) but not yet marked —
// e.g. a brand-new machine whose only data is today. Finalize so future
// launches don't re-backfill the whole window every time.
- c = { ...c, complete: true }
+ c = { ...c, complete: true, watermarkTrusted: true }
await saveDailyCache(c)
}
return c
diff --git a/src/dashboard.tsx b/src/dashboard.tsx
index 1b68a22..c4c1dbb 100644
--- a/src/dashboard.tsx
+++ b/src/dashboard.tsx
@@ -46,6 +46,8 @@ export function showEmptyState(projectCount: number, scrollableHistory: boolean,
return historyProjectCount === 0 && !historyLoading
}
+// The By Model panel drops the Tok/s column when the panel is too narrow, so
+// the wider two-column layout can still activate at ordinary terminal widths.
const MIN_WIDE = 90
const ORANGE = '#FF8C42'
const DIM = '#555555'
@@ -196,9 +198,9 @@ function nextTick(): Promise {
return new Promise(resolve => setImmediate(resolve))
}
-type Layout = { dashWidth: number; wide: boolean; halfWidth: number; barWidth: number }
+export type Layout = { dashWidth: number; wide: boolean; halfWidth: number; barWidth: number }
-function getLayout(columns?: number): Layout {
+export function getLayout(columns?: number): Layout {
const termWidth = columns || parseInt(process.env['COLUMNS'] ?? '') || 80
const dashWidth = Math.min(160, termWidth)
const wide = dashWidth >= MIN_WIDE
@@ -440,6 +442,7 @@ const MODEL_COL_COST = 8
const MODEL_COL_CACHE = 7
const MODEL_COL_CALLS = 7
const MODEL_COL_ONESHOT = 7
+const MODEL_COL_TPS = 7
const MODEL_NAME_WIDTH = 14
const MIN_EDIT_TURNS_FOR_RATE = 5
@@ -449,6 +452,10 @@ function ModelBreakdown({ projects, pw, bw }: { projects: ProjectSummary[]; pw:
const modelTotals = aggregateModelTotals(projects)
const modelEfficiency = aggregateModelEfficiency(projects)
const anyEstimated = Object.values(modelTotals).some(d => d.estimatedCostUSD > 0)
+ const anyActiveTiming = Object.values(modelTotals).some(d => d.activeDurationMs > 0 && d.activeGeneratedTokens > 0)
+ // The Tok/s column needs 61 inner columns for the full row; hide it on narrower
+ // panels and when no model has timing data (non-Codex users get no dead column).
+ const showTps = pw - PANEL_CHROME >= 61 && anyActiveTiming
const sorted = Object.entries(modelTotals).sort(([, a], [, b]) => b.costUSD - a.costUSD)
const maxCost = sorted[0]?.[1]?.costUSD ?? 0
const unpriced = findUnpricedModels(Object.entries(modelTotals).map(([model, d]) => ({
@@ -460,7 +467,7 @@ function ModelBreakdown({ projects, pw, bw }: { projects: ProjectSummary[]; pw:
return (
- {''.padEnd(bw + 1 + MODEL_NAME_WIDTH)}{'cost'.padStart(MODEL_COL_COST)}{'cache'.padStart(MODEL_COL_CACHE)}{'calls'.padStart(MODEL_COL_CALLS)}{'1-shot'.padStart(MODEL_COL_ONESHOT)}
+ {''.padEnd(bw + 1 + MODEL_NAME_WIDTH)}{'cost'.padStart(MODEL_COL_COST)}{'cache'.padStart(MODEL_COL_CACHE)}{'calls'.padStart(MODEL_COL_CALLS)}{'1-shot'.padStart(MODEL_COL_ONESHOT)}{showTps ? 'Tok/s'.padStart(MODEL_COL_TPS) : ''}
{sorted.map(([model, data], i) => {
const totalInput = data.freshInput + data.cacheRead + data.cacheWrite
const cacheHit = totalInput > 0 ? (data.cacheRead / totalInput) * 100 : 0
@@ -469,6 +476,9 @@ function ModelBreakdown({ projects, pw, bw }: { projects: ProjectSummary[]; pw:
const oneShotLabel = efficiency && efficiency.editTurns >= MIN_EDIT_TURNS_FOR_RATE && efficiency.oneShotRate !== null
? `${efficiency.oneShotRate.toFixed(1)}%`
: '-'
+ const tpsLabel = data.activeDurationMs > 0 && data.activeGeneratedTokens > 0
+ ? (data.activeGeneratedTokens / (data.activeDurationMs / 1000)).toFixed(1)
+ : '-'
return (
@@ -477,6 +487,7 @@ function ModelBreakdown({ projects, pw, bw }: { projects: ProjectSummary[]; pw:
{cacheLabel.padStart(MODEL_COL_CACHE)}
{String(data.calls).padStart(MODEL_COL_CALLS)}
{oneShotLabel.padStart(MODEL_COL_ONESHOT)}
+ {showTps && {tpsLabel.padStart(MODEL_COL_TPS)} }
)
})}
@@ -488,6 +499,9 @@ function ModelBreakdown({ projects, pw, bw }: { projects: ProjectSummary[]; pw:
{anyEstimated && (
~ estimated cost (priced from estimated tokens)
)}
+ {showTps && (
+ ~ Tok/s: generated tokens / active time; tool wait excluded
+ )}
)
}
diff --git a/src/day-aggregator.ts b/src/day-aggregator.ts
index 5563de6..cdac6a2 100644
--- a/src/day-aggregator.ts
+++ b/src/day-aggregator.ts
@@ -75,14 +75,25 @@ export function aggregateProjectsIntoDays(projects: ProjectSummary[]): DailyEntr
for (const turn of session.turns) {
if (turn.assistantCalls.length === 0) continue
- // Turn-anchored bucketing: attribute the WHOLE turn — every one of its
- // calls — to the day of the turn's user-message timestamp, matching the
- // live headline/report rollup (main.ts daily). Falls back to the first
- // assistant-call timestamp when the user line is missing (continuation
- // sessions that begin mid-conversation). Previously the calls were
- // bucketed per-call by each call's own timestamp, so a midnight-
- // straddling turn split across two days and history.daily / the provider
- // breakdown never reconciled to current.cost (a constant offset).
+ // Two bucketing rules, deliberately different per level:
+ // - Turn-level judgments (category, editTurns, oneShotTurns) stay
+ // anchored to the turn's day (its timestamp — the user-message time,
+ // or the re-anchored first surviving call when the parser sliced
+ // the turn to a range, and falling back to the first assistant call
+ // when the user line is missing). They describe the whole exchange,
+ // not a per-call sum, so a sliced straddling turn reports them on
+ // each side's anchor day — summed across days they inflate, which
+ // is the accepted, documented semantics (see review on #852).
+ // - Call-derived values (cost/savings/calls/tokens and the model,
+ // project, and provider-slice rollups built from them) bucket under
+ // EACH CALL's own local day (the per-call loop below). The parser
+ // slices straddling turns per range (issue #852), so every parse
+ // only holds in-range calls and per-call bucketing keeps day-N +
+ // day-N+1 equal to the whole range — and history.daily reconciled
+ // to the headline built from the same days. (Before the parser
+ // sliced per call, per-call bucketing here was what caused the
+ // constant offset against the whole-turn headline; the slice is
+ // what makes it exact now.)
const turnDate = dateKey(turn.timestamp || turn.assistantCalls[0]!.timestamp)
const turnDay = ensure(turnDate)
@@ -140,21 +151,26 @@ export function aggregateProjectsIntoDays(projects: ProjectSummary[]): DailyEntr
for (const call of turn.assistantCalls) {
const callSavings = call.savingsUSD ?? 0
+ // Call-derived values bucket under the call's OWN day (see the
+ // two-rule comment above). An unparseable call timestamp falls back
+ // to the turn's anchor day rather than producing a garbage date key.
+ const callDate = Number.isNaN(new Date(call.timestamp).getTime()) ? turnDate : dateKey(call.timestamp)
+ const callDay = ensure(callDate)
- turnDay.cost += call.costUSD
- turnDay.savingsUSD += callSavings
- turnDay.calls += 1
- turnDay.inputTokens += call.usage.inputTokens
- turnDay.outputTokens += call.usage.outputTokens
- turnDay.cacheReadTokens += call.usage.cacheReadInputTokens
- turnDay.cacheWriteTokens += call.usage.cacheCreationInputTokens
+ callDay.cost += call.costUSD
+ callDay.savingsUSD += callSavings
+ callDay.calls += 1
+ callDay.inputTokens += call.usage.inputTokens
+ callDay.outputTokens += call.usage.outputTokens
+ callDay.cacheReadTokens += call.usage.cacheReadInputTokens
+ callDay.cacheWriteTokens += call.usage.cacheCreationInputTokens
- const dayProject = ensureProject(turnDay, session.project, project.projectPath)
+ const dayProject = ensureProject(callDay, session.project, project.projectPath)
dayProject.cost += call.costUSD
dayProject.calls += 1
dayProject.savingsUSD += callSavings
- const model = turnDay.models[call.model] ?? {
+ const model = callDay.models[call.model] ?? {
calls: 0, cost: 0, savingsUSD: 0,
inputTokens: 0, outputTokens: 0,
cacheReadTokens: 0, cacheWriteTokens: 0,
@@ -166,9 +182,9 @@ export function aggregateProjectsIntoDays(projects: ProjectSummary[]): DailyEntr
model.outputTokens += call.usage.outputTokens
model.cacheReadTokens += call.usage.cacheReadInputTokens
model.cacheWriteTokens += call.usage.cacheCreationInputTokens
- turnDay.models[call.model] = model
+ callDay.models[call.model] = model
- const slice = ensureSlice(turnDay, call.provider)
+ const slice = ensureSlice(callDay, call.provider)
slice.calls += 1
slice.cost += call.costUSD
slice.savingsUSD += callSavings
diff --git a/src/main.ts b/src/main.ts
index ae6867c..6ce62b3 100644
--- a/src/main.ts
+++ b/src/main.ts
@@ -5,6 +5,7 @@ import { exportCsv, exportJson, type PeriodExport } from './export.js'
import { findUnpricedModels, loadPricing, setModelAliases, setPriceOverrides, setLocalModelSavings, setProxyPaths, normalizeProxyPath } from './models.js'
import { parseAllSessions, filterProjectsByName, filterProjectsByDateRange, clearSessionCache, setInteractiveScanUI } from './parser.js'
import { allProviderNames, getAllProviders } from './providers/index.js'
+import { getProvider } from './providers/index.js'
import { convertCost, formatCost } from './currency.js'
import { renderStatusBar } from './format.js'
import { toDateString } from './daily-cache.js'
@@ -46,6 +47,7 @@ import { createRequire } from 'node:module'
const require = createRequire(import.meta.url)
const { version } = require('../package.json')
import { loadCurrency, getCurrency, isValidCurrencyCode } from './currency.js'
+import { CodexThroughputReader, newestCodexSession, renderCodexThroughput } from './codex-throughput.js'
// A downstream reader that closes the pipe early (`| head`, quitting `less`, or
// a missing command) makes stdout writes fail with EPIPE. Exit cleanly rather
@@ -68,6 +70,22 @@ function parseInteger(value: string): number {
return parseInt(value, 10)
}
+function parseCodexTpsLimit(value: string): number {
+ const parsed = Number(value)
+ if (!Number.isFinite(parsed) || !Number.isInteger(parsed) || parsed < 1 || parsed > 10000) {
+ throw new Error('limit must be an integer from 1 to 10000')
+ }
+ return parsed
+}
+
+function parseCodexTpsWatch(value: string): number {
+ const parsed = Number(value)
+ if (!Number.isFinite(parsed) || parsed < 0 || (parsed > 0 && parsed < 1) || parsed > 3600) {
+ throw new Error('watch must be 0 or at least 1 second (up to 3600 seconds)')
+ }
+ return parsed
+}
+
type PriceOverrideConfig = NonNullable[string]
type PriceOverrideOptions = {
@@ -497,9 +515,17 @@ function buildJsonReport(projects: ProjectSummary[], period: string, periodKey:
if (turn.retries === 0) dailyMap[day].oneShotTurns += 1
}
for (const call of turn.assistantCalls) {
- dailyMap[day].cost += call.costUSD
- dailyMap[day].savings += call.savingsUSD ?? 0
- dailyMap[day].calls += 1
+ // Cost/savings/calls bucket under each call's OWN day — the same
+ // per-call rule as the durable day set (day-aggregator.ts), so this
+ // fallback and durable.days never diverge on a midnight-straddling
+ // turn (issue #852). Turn counts/edit stats stay anchored on the
+ // turn's day above. An unparseable call timestamp falls back to the
+ // turn's day rather than producing a garbage date key.
+ const callDay = Number.isNaN(new Date(call.timestamp).getTime()) ? day : dateKey(call.timestamp)
+ if (!dailyMap[callDay]) { dailyMap[callDay] = { cost: 0, savings: 0, calls: 0, turns: 0, editTurns: 0, oneShotTurns: 0 } }
+ dailyMap[callDay].cost += call.costUSD
+ dailyMap[callDay].savings += call.savingsUSD ?? 0
+ dailyMap[callDay].calls += 1
}
}
}
@@ -966,6 +992,7 @@ program
cacheWriteTokens: durable.data.cacheWriteTokens,
days: durable.days,
carriedCostUSD: durable.carriedCostUSD,
+ unattributedCostUSD: durable.unattributedCostUSD,
},
}))
})
@@ -1843,6 +1870,94 @@ program
await runContextCommand(session, opts)
})
+program
+ .command('codex-tps [session]')
+ .description('Retrospective Codex generated-tokens/sec estimate from rollout checkpoints (not live decode speed)')
+ .option('--json', 'JSON output')
+ .option('--limit ', 'Number of recent checkpoints to scan', parseCodexTpsLimit, 10)
+ .option('--watch ', 'Refresh continuously while Codex writes checkpoints', parseCodexTpsWatch, 0)
+ .action(async (session: string | undefined, opts: { json?: boolean; limit: number; watch: number }) => {
+ const intervalMs = Math.max(0, opts.watch) * 1000
+ if (opts.json && intervalMs > 0) {
+ process.stderr.write('codeburn codex-tps: --json cannot be combined with --watch; use text watch output or one-shot JSON.\n')
+ process.exitCode = 2
+ return
+ }
+ const provider = await getProvider('codex')
+ if (!provider) {
+ process.stderr.write('codeburn codex-tps: Codex provider is unavailable.\n')
+ process.exitCode = 1
+ return
+ }
+ let cachedPath: string | undefined = session
+ let throughputReader: CodexThroughputReader | undefined
+ let lastFileState: { size: number; mtimeMs: number } | undefined
+ let lastDiscoveryMs = 0
+ let refreshInFlight = false
+ const render = async (): Promise => {
+ if (refreshInFlight) return
+ refreshInFlight = true
+ try {
+ let filePath = session ?? cachedPath
+ // Keep an idle watcher on its chosen rollout. A full active+archive
+ // discovery can be hundreds of milliseconds on large histories, so
+ // only re-scan slowly to notice rotation; disappearance still triggers
+ // an immediate discovery on the next tick.
+ if (!session && (!filePath || Date.now() - lastDiscoveryMs >= 60_000)) {
+ lastDiscoveryMs = Date.now()
+ filePath = await newestCodexSession(await provider.discoverSessions())
+ }
+ if (!filePath) {
+ process.stderr.write('codeburn codex-tps: no Codex rollout sessions found.\n')
+ if (intervalMs === 0) process.exitCode = 1
+ return
+ }
+ const previousPath = cachedPath
+ cachedPath = filePath
+ if (previousPath !== filePath || !throughputReader) throughputReader = new CodexThroughputReader()
+ const fileInfo = await import('node:fs/promises').then(fs => fs.stat(filePath)).catch(() => null)
+ if (!fileInfo) {
+ process.stderr.write(`codeburn codex-tps: session file not found: ${filePath}\n`)
+ if (intervalMs === 0) process.exitCode = 1
+ if (!session) cachedPath = undefined
+ return
+ }
+ if (intervalMs > 0 && lastFileState && fileInfo.size === lastFileState.size && fileInfo.mtimeMs === lastFileState.mtimeMs) return
+ lastFileState = { size: fileInfo.size, mtimeMs: fileInfo.mtimeMs }
+ const points = await throughputReader!.update(filePath, opts.limit, intervalMs === 0)
+ if (opts.json) {
+ process.stdout.write(JSON.stringify({ session: filePath, points, live: intervalMs > 0 }, null, 2) + '\n')
+ } else {
+ if (intervalMs > 0) process.stdout.write('\x1b[2J\x1b[H')
+ process.stdout.write(renderCodexThroughput(points, filePath) + (intervalMs > 0 ? '\nWatching for new Codex checkpoints... (Ctrl-C to stop)\n' : '\n'))
+ }
+ } finally {
+ refreshInFlight = false
+ }
+ }
+ try {
+ await render()
+ } catch (error) {
+ const message = error instanceof Error ? error.message : String(error)
+ process.stderr.write(`codeburn codex-tps: refresh failed: ${message}\n`)
+ if (intervalMs === 0) {
+ process.exitCode = 1
+ return
+ }
+ }
+ if (intervalMs > 0) {
+ await new Promise((resolve) => {
+ const timer = setInterval(() => {
+ void render().catch(error => {
+ const message = error instanceof Error ? error.message : String(error)
+ process.stderr.write(`codeburn codex-tps: refresh failed: ${message}\n`)
+ })
+ }, intervalMs)
+ process.once('SIGINT', () => { clearInterval(timer); resolve() })
+ })
+ }
+ })
+
program
.command('compare')
.description('Compare two AI models side-by-side')
diff --git a/src/model-breakdown.ts b/src/model-breakdown.ts
index 5801be8..799338e 100644
--- a/src/model-breakdown.ts
+++ b/src/model-breakdown.ts
@@ -8,6 +8,8 @@ export interface ModelTotals {
freshInput: number
cacheRead: number
cacheWrite: number
+ activeDurationMs: number
+ activeGeneratedTokens: number
}
/// Aggregate per-model usage across every session, keyed by the friendly display
@@ -24,6 +26,7 @@ export function aggregateModelTotals(projects: ProjectSummary[]): Record 0) {
+ out.push(c.dim(` excludes ${formatCost(durable.unattributedCostUSD!)} from days with no per-project history`))
+ }
+
return out.join('\n') + '\n'
}
diff --git a/src/parser.ts b/src/parser.ts
index ee165e3..4fd7e14 100644
--- a/src/parser.ts
+++ b/src/parser.ts
@@ -27,6 +27,7 @@ import {
saveCache,
} from './session-cache.js'
import { acquireCacheRefreshLock, type RefreshLockHandle } from './cache-refresh-lock.js'
+import { dateKey } from './day-aggregator.js'
import type { ParsedProviderCall, SessionSource } from './providers/types.js'
import type {
ApiUsageIteration,
@@ -1734,6 +1735,11 @@ function buildSessionSummary(
modelBreakdown[modelKey].tokens.cacheReadInputTokens += call.usage.cacheReadInputTokens
modelBreakdown[modelKey].tokens.cacheCreationInputTokens += call.usage.cacheCreationInputTokens
modelBreakdown[modelKey].tokens.reasoningTokens += call.usage.reasoningTokens
+ if (call.activeDurationMs !== undefined) {
+ modelBreakdown[modelKey].activeDurationMs = (modelBreakdown[modelKey].activeDurationMs ?? 0) + call.activeDurationMs
+ modelBreakdown[modelKey].activeGeneratedTokens = (modelBreakdown[modelKey].activeGeneratedTokens ?? 0) + (call.activeGeneratedTokens ?? call.usage.outputTokens + call.usage.reasoningTokens)
+ modelBreakdown[modelKey].toolWaitMs = (modelBreakdown[modelKey].toolWaitMs ?? 0) + (call.toolWaitMs ?? 0)
+ }
for (const tool of extractCoreTools(call.tools)) {
toolBreakdown[tool] = toolBreakdown[tool] ?? { calls: 0 }
@@ -1931,6 +1937,7 @@ async function scanProjectDirs(
const cached = section.files[filePath]
const action = reconcileFile(fp, cached)
if (cached && (readOnly || action.action === 'unchanged')) {
+ if (readOnly && action.action !== 'unchanged') readOnlyServedStale = true
unchangedFiles.push({ filePath, dirName, source, cached: section.files[filePath]! })
} else if (!readOnly) {
if (action.action === 'appended') {
@@ -1942,6 +1949,10 @@ async function scanProjectDirs(
continue
}
changedFiles.push({ filePath, info: { dirName, fp, source } })
+ } else {
+ // Read-only with no cache entry at all: this file is dropped from what
+ // we serve, so the snapshot under-reports whatever days it covers.
+ readOnlyServedStale = true
}
}
dirsDone++
@@ -2200,12 +2211,12 @@ async function scanProjectDirs(
const spawnPrSets = cachedFile.prLinks?.length ? buildSpawnPrSets(cachedFile.turns) : {}
if (dateRange) {
- classifiedTurns = classifiedTurns.filter(turn => {
- if (turn.assistantCalls.length === 0) return false
- const firstCallTs = turn.assistantCalls[0]!.timestamp
- if (!firstCallTs) return false
- const ts = new Date(firstCallTs)
- return ts >= dateRange.start && ts <= dateRange.end
+ // 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.
+ classifiedTurns = classifiedTurns.flatMap(turn => {
+ const sliced = classifiedTurnSlicedToRange(turn, dateRange)
+ return sliced ? [sliced] : []
})
}
@@ -2393,6 +2404,9 @@ function providerCallToCachedCall(call: ParsedProviderCall): CachedCall {
...(call.locAdded ? { locAdded: call.locAdded } : {}),
...(call.locRemoved ? { locRemoved: call.locRemoved } : {}),
...(call.editFailed ? { editFailed: call.editFailed } : {}),
+ activeDurationMs: call.activeDurationMs,
+ activeGeneratedTokens: call.activeGeneratedTokens,
+ toolWaitMs: call.toolWaitMs,
}
}
@@ -2429,6 +2443,9 @@ function apiCallToCachedCall(call: ParsedApiCall): CachedCall {
...(call.interrupted ? { interrupted: true } : {}),
...(call.userModified ? { userModified: true } : {}),
...(call.toolErrors ? { toolErrors: call.toolErrors } : {}),
+ activeDurationMs: call.activeDurationMs,
+ activeGeneratedTokens: call.activeGeneratedTokens,
+ toolWaitMs: call.toolWaitMs,
}
}
@@ -2541,6 +2558,9 @@ function cachedCallToApiCall(call: CachedCall): ParsedApiCall {
deduplicationKey: call.deduplicationKey,
cacheCreationOneHourTokens: u.cacheCreationOneHourTokens || undefined,
toolSequence: call.toolSequence,
+ activeDurationMs: call.activeDurationMs,
+ activeGeneratedTokens: call.activeGeneratedTokens,
+ toolWaitMs: call.toolWaitMs,
})
}
@@ -2786,6 +2806,59 @@ export function createScanProgress(label: string, total: number) {
}
}
+// Shared by the turn-range slicers below: which of a turn's calls actually
+// fall inside dateRange. Returns null when none do (the turn should be dropped
+// entirely, not kept with an empty call list).
+function callsInRange(calls: T[], dateRange: DateRange): T[] | null {
+ const inRange = calls.filter(c => {
+ const ts = new Date(c.timestamp)
+ return !Number.isNaN(ts.getTime()) && ts >= dateRange.start && ts <= dateRange.end
+ })
+ return inRange.length > 0 ? inRange : null
+}
+
+// A turn can span local midnight (e.g. a long-running autonomous Codex
+// session): dropping the whole turn because its FIRST call falls outside
+// dateRange discards every later call that lands in the requested day (issue
+// #852). Instead, keep only the calls actually inside the range. `timestamp`
+// is re-anchored to the first surviving call so downstream turn-anchored
+// bucketing (session day, report rollups) keys the slice under the day its
+// retained calls actually fall in, not the pre-slice turn's original
+// (possibly prior-day) start. Returns null when no call is in range.
+function turnSlicedToRange(turn: CachedTurn, dateRange: DateRange): CachedTurn | null {
+ const inRangeCalls = callsInRange(turn.calls, dateRange)
+ if (!inRangeCalls) return null
+ if (inRangeCalls.length === turn.calls.length) return turn
+ return { ...turn, calls: inRangeCalls, timestamp: inRangeCalls[0]!.timestamp }
+}
+
+// Same slice, applied post-classification (scanProjectDirs classifies every
+// turn from its FULL call list up front, before date filtering — see the
+// carriedBranch/carriedPrRefs comments in scanProjectDirs — so this only
+// trims `assistantCalls` and re-anchors `timestamp`; `category`/`subCategory`/
+// `retries`/`hasEdits` stay exactly as classified from the complete turn.
+// Those are turn-level judgments about the whole exchange, not a per-call
+// sum, so they aren't recomputed from the partial call list.
+function classifiedTurnSlicedToRange(turn: ClassifiedTurn, dateRange: DateRange): ClassifiedTurn | null {
+ const inRangeCalls = callsInRange(turn.assistantCalls, dateRange)
+ if (!inRangeCalls) return null
+ if (inRangeCalls.length === turn.assistantCalls.length) return turn
+ return { ...turn, assistantCalls: inRangeCalls, timestamp: inRangeCalls[0]!.timestamp }
+}
+
+// Day-set variant of classifiedTurnSlicedToRange for the menubar/history day
+// selection: keep only the calls whose own local day is selected and
+// re-anchor `timestamp` to the first survivor — the same split rule.
+function classifiedTurnSlicedToDays(turn: ClassifiedTurn, days: Set): ClassifiedTurn | null {
+ const inRangeCalls = turn.assistantCalls.filter(c => {
+ const ts = new Date(c.timestamp)
+ return !Number.isNaN(ts.getTime()) && days.has(dateKey(c.timestamp))
+ })
+ if (inRangeCalls.length === 0) return null
+ if (inRangeCalls.length === turn.assistantCalls.length) return turn
+ return { ...turn, assistantCalls: inRangeCalls, timestamp: inRangeCalls[0]!.timestamp }
+}
+
async function parseProviderSources(
providerName: string,
sources: SessionSource[],
@@ -2826,9 +2899,13 @@ async function parseProviderSources(
// re-read a file that already threw and hasn't changed. It re-parses only
// when the file changes (then `reconcileFile` reports non-'unchanged').
if (cached && (readOnly || (action.action === 'unchanged' && (cached.failed || !cachedFileNeedsProviderReparse(providerName, source.path, cached))))) {
+ if (readOnly && action.action !== 'unchanged') readOnlyServedStale = true
unchangedSources.push({ source, cached })
} else if (!readOnly) {
changedSources.push({ source, fp })
+ } else {
+ // Read-only with no cache entry at all — see scanProjectDirs.
+ readOnlyServedStale = true
}
}
@@ -2993,24 +3070,32 @@ async function parseProviderSources(
for (const c of turn.calls) seenKeys.add(c.deduplicationKey)
+ let slicedTurn = turn
if (dateRange) {
- const callTs = turn.calls[0]?.timestamp
- if (!callTs) continue
- const ts = new Date(callTs)
- if (ts < dateRange.start || ts > dateRange.end) continue
+ const sliced = turnSlicedToRange(turn, dateRange)
+ if (!sliced) continue
+ slicedTurn = sliced
}
- const classified = cachedTurnToClassified(turn)
- const project = turn.calls[0]?.project ?? source.project
+ // Classify the FULL turn, then keep only the in-range calls: category /
+ // hasEdits / retries are whole-exchange judgments, not per-call sums, so a
+ // midnight-straddling turn is classified identically to the Claude path
+ // (scanProjectDirs) rather than being re-derived from a partial slice.
+ // Cost/calls come from the retained calls, unchanged.
+ const classifiedFull = cachedTurnToClassified(turn)
+ const classified = dateRange
+ ? (classifiedTurnSlicedToRange(classifiedFull, dateRange) ?? classifiedFull)
+ : classifiedFull
+ const project = slicedTurn.calls[0]?.project ?? source.project
const key = `${providerName}:${turn.sessionId}:${project}`
const existing = sessionMap.get(key)
if (existing) {
existing.turns.push(classified)
- if (!existing.projectPath && turn.calls[0]?.projectPath) {
- existing.projectPath = turn.calls[0]!.projectPath
+ if (!existing.projectPath && slicedTurn.calls[0]?.projectPath) {
+ existing.projectPath = slicedTurn.calls[0]!.projectPath
}
- if (!existing.workingDirectory && turn.calls[0]?.workingDirectory) existing.workingDirectory = turn.calls[0].workingDirectory
+ if (!existing.workingDirectory && slicedTurn.calls[0]?.workingDirectory) existing.workingDirectory = slicedTurn.calls[0].workingDirectory
if (cachedFile.prLinks?.length) {
const links = (existing.prLinks ??= new Set())
for (const link of cachedFile.prLinks) links.add(link)
@@ -3019,8 +3104,8 @@ async function parseProviderSources(
} else {
sessionMap.set(key, {
project,
- projectPath: turn.calls[0]?.projectPath,
- workingDirectory: turn.calls[0]?.workingDirectory,
+ projectPath: slicedTurn.calls[0]?.projectPath,
+ workingDirectory: slicedTurn.calls[0]?.workingDirectory,
turns: [classified],
...(cachedFile.prLinks?.length ? { prLinks: new Set(cachedFile.prLinks) } : {}),
...(cachedFile.title ? { title: cachedFile.title } : {}),
@@ -3042,25 +3127,31 @@ async function parseProviderSources(
for (const c of turn.calls) seenKeys.add(c.deduplicationKey)
+ let slicedTurn = turn
if (dateRange) {
- const callTs = turn.calls[0]?.timestamp
- if (!callTs) continue
- const ts = new Date(callTs)
- if (ts < dateRange.start || ts > dateRange.end) continue
+ const sliced = turnSlicedToRange(turn, dateRange)
+ if (!sliced) continue
+ slicedTurn = sliced
}
- const classified = cachedTurnToClassified(turn)
- const project = turn.calls[0]?.project ?? providerName
+ // Classify the FULL turn, then keep only the in-range calls (same rule
+ // as the loop above and the Claude path): whole-exchange judgments stay
+ // whole-turn; cost/calls come from the retained calls.
+ const classifiedFull = cachedTurnToClassified(turn)
+ const classified = dateRange
+ ? (classifiedTurnSlicedToRange(classifiedFull, dateRange) ?? classifiedFull)
+ : classifiedFull
+ const project = slicedTurn.calls[0]?.project ?? providerName
const key = `${providerName}:${turn.sessionId}:${project}`
const existingEntry = sessionMap.get(key)
if (existingEntry) {
existingEntry.turns.push(classified)
- if (!existingEntry.projectPath && turn.calls[0]?.projectPath) {
- existingEntry.projectPath = turn.calls[0]!.projectPath
+ if (!existingEntry.projectPath && slicedTurn.calls[0]?.projectPath) {
+ existingEntry.projectPath = slicedTurn.calls[0]!.projectPath
}
} else {
- sessionMap.set(key, { project, projectPath: turn.calls[0]?.projectPath, workingDirectory: turn.calls[0]?.workingDirectory, turns: [classified] })
+ sessionMap.set(key, { project, projectPath: slicedTurn.calls[0]?.projectPath, workingDirectory: slicedTurn.calls[0]?.workingDirectory, turns: [classified] })
}
}
}
@@ -3153,14 +3244,6 @@ export function filterProjectsByName(
return result
}
-function turnIsInDateRange(turn: ClassifiedTurn, dateRange: DateRange): boolean {
- if (turn.assistantCalls.length === 0) return false
- const firstCallTs = turn.assistantCalls[0]!.timestamp
- if (!firstCallTs) return false
- const ts = new Date(firstCallTs)
- return ts >= dateRange.start && ts <= dateRange.end
-}
-
function turnDayString(turn: ClassifiedTurn): string | null {
if (turn.assistantCalls.length === 0) return null
const ts = turn.assistantCalls[0]!.timestamp
@@ -3289,9 +3372,13 @@ export function filterProjectsByDays(projects: ProjectSummary[], days: Set()
for (const session of project.sessions) {
- const turns = session.turns.filter(turn => {
- const ds = turnDayString(turn)
- return ds !== null && days.has(ds)
+ // Slice turns per call by the selected days (not whole-turn keep/drop):
+ // a midnight-straddling turn contributes the calls that actually
+ // happened on each selected day (issue #852, same split rule as the
+ // range slicers — see classifiedTurnSlicedToDays).
+ const turns = session.turns.flatMap(turn => {
+ const sliced = classifiedTurnSlicedToDays(turn, days)
+ return sliced ? [sliced] : []
})
if (turns.length === 0) {
if (isSpawnParent(session)) anchors.push(session)
@@ -3498,7 +3585,13 @@ export function filterProjectsByDateRange(projects: ProjectSummary[], dateRange:
const anchors: SessionSummary[] = [...(project.subagentAnchors ?? [])]
const survivingIdentities = new Set()
for (const session of project.sessions) {
- const turns = session.turns.filter(turn => turnIsInDateRange(turn, dateRange))
+ // Slice turns per call (not whole-turn keep/drop) so a midnight-
+ // straddling turn keeps the calls that landed inside the range — the
+ // same split rule as the parse-time slicers (issue #852).
+ const turns = session.turns.flatMap(turn => {
+ const sliced = classifiedTurnSlicedToRange(turn, dateRange)
+ return sliced ? [sliced] : []
+ })
if (turns.length === 0) {
if (isSpawnParent(session)) anchors.push(session)
continue
@@ -3525,6 +3618,15 @@ export function isSessionHydrationComplete(): boolean {
return sessionHydrationComplete
}
+// Set by the read-only serving paths when the snapshot they served did NOT
+// match what is on disk: in read-only mode a changed file is served at its
+// stale fingerprint and a file with no cache entry is skipped entirely. A
+// read-only run under which nothing changed is equivalent to a full parse and
+// stays trustworthy; one that skipped real data is a PARTIAL hydration, and
+// finalizing daily history off it freezes the days it never saw out of the
+// chart (gapStart = lastComputedDate + 1 never looks back at them).
+let readOnlyServedStale = false
+
export async function parseAllSessions(dateRange?: DateRange, providerFilter?: string): Promise {
const key = cacheKey(dateRange, providerFilter)
const cached = sessionCache.get(key)
@@ -3594,6 +3696,7 @@ async function runParse(
options: RunParseOptions = {},
): Promise {
const { isCold = false, readOnly = false, refreshLock } = options
+ readOnlyServedStale = false
const seenMsgIds = new Set()
const seenKeys = new Set()
const allSources = await discoverAllSessions(providerFilter)
@@ -3633,15 +3736,26 @@ async function runParse(
? { id: s.sourceId, label: s.sourceLabel, path: s.sourcePath, kind: s.sourceKind }
: undefined,
}))
+ // Claude is scanned through scanProjectDirs rather than parseProviderSources, so
+ // it needs the same provider-filter guard the durable-orphan loop below applies at
+ // its own level. Without it a --provider run still enters scanProjectDirs
+ // with an empty dirs list, and the orphan pass there (which reads the whole cached
+ // claude section) treats every cached file as "no longer discovered" and re-injects
+ // it into the result. Note this is deliberately NOT a `claudeDirs.length > 0` check:
+ // when claude IS in scope but every transcript has been pruned from disk, that
+ // orphan pass is exactly what keeps PR-attributed spend from vanishing.
+ const claudeInScope = !providerFilter || providerFilter === 'all' || providerFilter === 'claude'
if (claudeSources.length > 0) emitScanProgress({ kind: 'provider', provider: 'claude', state: 'start' })
let claudeProjects: ProjectSummary[] = []
- try {
- claudeProjects = await scanProjectDirs(claudeDirs, seenMsgIds, diskCache, dateRange, saveProgress, readOnly)
- if (claudeSources.length > 0) emitScanProgress({ kind: 'provider', provider: 'claude', state: 'done', files: claudeSources.length })
- } catch (err) {
- if (!isPermissionError(err)) throw err
- process.stderr.write(`codeburn: skipped claude data (permission denied; grant Full Disk Access to include it)\n`)
- emitScanProgress({ kind: 'provider', provider: 'claude', state: 'skipped' })
+ if (claudeInScope) {
+ try {
+ claudeProjects = await scanProjectDirs(claudeDirs, seenMsgIds, diskCache, dateRange, saveProgress, readOnly)
+ if (claudeSources.length > 0) emitScanProgress({ kind: 'provider', provider: 'claude', state: 'done', files: claudeSources.length })
+ } catch (err) {
+ if (!isPermissionError(err)) throw err
+ process.stderr.write(`codeburn: skipped claude data (permission denied; grant Full Disk Access to include it)\n`)
+ emitScanProgress({ kind: 'provider', provider: 'claude', state: 'skipped' })
+ }
}
const otherProjects: ProjectSummary[] = []
@@ -3698,7 +3812,10 @@ async function runParse(
if (refreshLock) throw new RefreshPublicationUnavailableError()
}
}
- sessionHydrationComplete = true
+ // Assigned, not forced true: a read-only run that had to skip or stale real
+ // files reached the end of the scan without hydrating everything, and the
+ // daily backfill must not finalize history off it.
+ sessionHydrationComplete = !readOnly || !readOnlyServedStale
// Merge across providers by normalised project path so the same repository
// is not double-counted when it was worked on with more than one tool
diff --git a/src/providers/codex.ts b/src/providers/codex.ts
index 5cdce82..faa8fcf 100644
--- a/src/providers/codex.ts
+++ b/src/providers/codex.ts
@@ -32,6 +32,9 @@ const modelDisplayEntries = Object.entries(modelDisplayNames).sort((a, b) => b[0
const toolNameMap: Record = {
exec_command: 'Bash',
+ // Codex Desktop's custom-tool transport uses the shorter `exec` name for
+ // the same shell tool that CLI rollouts record as `exec_command`.
+ exec: 'Bash',
read_file: 'Read',
write_file: 'Edit',
apply_diff: 'Edit',
@@ -96,6 +99,11 @@ type CodexEntry = {
timestamp?: string
payload?: {
type?: string
+ turn_id?: string
+ call_id?: string
+ started_at?: number
+ duration_ms?: number
+ duration?: { secs?: number; nanos?: number } | string
role?: string
cwd?: string
model_provider?: string
@@ -104,6 +112,7 @@ type CodexEntry = {
forked_from_id?: string
model?: string
name?: string
+ invocation?: { server?: string; tool?: string }
content?: Array<{ type?: string; text?: string }>
info?: {
model?: string
@@ -196,11 +205,128 @@ function getRawJsonStringField(head: string, field: string): string | undefined
}
}
+function getRawJsonNumberField(head: string, field: string): number | undefined {
+ const match = new RegExp(`"${field}"\\s*:\\s*(-?\\d+(?:\\.\\d+)?)`).exec(head)
+ if (!match) return undefined
+ const value = Number(match[1])
+ return Number.isFinite(value) ? value : undefined
+}
+
+function getRawPayloadFieldWindow(source: Buffer, field: string, windowBytes = 4096): string | undefined {
+ const payloadKey = Buffer.from('"payload"')
+ const payloadIndex = source.indexOf(payloadKey)
+ if (payloadIndex < 0) return undefined
+ let payloadStart = source.indexOf(0x7b, payloadIndex + payloadKey.length) // {
+ if (payloadStart < 0) return undefined
+
+ let depth = 0
+ let inString = false
+ let escaped = false
+ for (let i = payloadStart; i < source.length; i++) {
+ const byte = source[i]!
+ if (inString) {
+ if (escaped) escaped = false
+ else if (byte === 0x5c) escaped = true // \\
+ else if (byte === 0x22) inString = false // "
+ continue
+ }
+ if (byte === 0x22) {
+ const keyStart = i + 1
+ let keyEnd = keyStart
+ let keyEscaped = false
+ for (; keyEnd < source.length; keyEnd++) {
+ const keyByte = source[keyEnd]!
+ if (keyEscaped) { keyEscaped = false; continue }
+ if (keyByte === 0x5c) { keyEscaped = true; continue }
+ if (keyByte === 0x22) break
+ }
+ if (depth === 1 && keyEnd < source.length) {
+ const key = source.subarray(keyStart, keyEnd).toString('utf-8')
+ let valueStart = keyEnd + 1
+ while (valueStart < source.length && (source[valueStart] === 0x20 || source[valueStart] === 0x09 || source[valueStart] === 0x0a || source[valueStart] === 0x0d)) valueStart++
+ if (source[valueStart] === 0x3a && key === field) {
+ return source.subarray(i, Math.min(source.length, i + windowBytes)).toString('utf-8')
+ }
+ }
+ i = keyEnd
+ inString = false
+ continue
+ }
+ if (byte === 0x22) inString = true
+ else if (byte === 0x7b || byte === 0x5b) depth++ // { or [
+ else if (byte === 0x7d || byte === 0x5d) depth-- // } or ]
+ if (depth < 0) break
+ }
+ return undefined
+}
+
+function getRawDurationMs(head: string): number | undefined {
+ const objectMatch = /"duration"\s*:\s*\{\s*"secs"\s*:\s*(-?\d+(?:\.\d+)?)\s*,\s*"nanos"\s*:\s*(-?\d+(?:\.\d+)?)\s*\}/.exec(head)
+ if (objectMatch) {
+ const seconds = Number(objectMatch[1])
+ const nanos = Number(objectMatch[2])
+ if (Number.isFinite(seconds) && Number.isFinite(nanos)) return seconds * 1000 + nanos / 1e6
+ }
+ const text = getRawJsonStringField(head, 'duration')
+ if (text) {
+ const match = /^(\d+(?:\.\d+)?)(ms|s)?$/.exec(text.trim())
+ if (match) {
+ const value = Number(match[1])
+ if (Number.isFinite(value)) return value * (match[2] === 's' ? 1000 : 1)
+ }
+ }
+ return undefined
+}
+
+function durationValueMs(value: unknown): number | undefined {
+ if (typeof value === 'number' && Number.isFinite(value)) return value
+ if (typeof value === 'object' && value) {
+ const record = value as Record
+ const seconds = record['secs']
+ const nanos = record['nanos']
+ if (typeof seconds === 'number' && typeof nanos === 'number' && Number.isFinite(seconds) && Number.isFinite(nanos)) {
+ return seconds * 1000 + nanos / 1e6
+ }
+ }
+ if (typeof value === 'string') {
+ const match = /^(\d+(?:\.\d+)?)(ms|s)?$/.exec(value.trim())
+ if (match) {
+ const parsed = Number(match[1])
+ if (Number.isFinite(parsed)) return parsed * (match[2] === 's' ? 1000 : 1)
+ }
+ }
+ return undefined
+}
+
+function getRawTokenUsage(head: string, field: 'last_token_usage' | 'total_token_usage'): CodexTokenUsage | undefined {
+ const match = new RegExp(`"${field}"\\s*:\\s*\\{([^}]*)\\}`).exec(head)
+ if (!match) return undefined
+ const body = match[1]!
+ return {
+ input_tokens: getRawJsonNumberField(body, 'input_tokens'),
+ cached_input_tokens: getRawJsonNumberField(body, 'cached_input_tokens'),
+ output_tokens: getRawJsonNumberField(body, 'output_tokens'),
+ reasoning_output_tokens: getRawJsonNumberField(body, 'reasoning_output_tokens'),
+ total_tokens: getRawJsonNumberField(body, 'total_tokens'),
+ }
+}
+
function payloadHead(head: string): string {
const idx = head.indexOf('"payload"')
return idx === -1 ? head : head.slice(idx)
}
+function getRawInvocation(head: string): { server?: string; tool?: string } | undefined {
+ const idx = head.indexOf('"invocation"')
+ if (idx === -1) return undefined
+ // Server/tool are shallow fields and precede the potentially huge arguments
+ // object in Codex MCP records. Limit this scan to keep compact parsing cheap.
+ const invocationHead = head.slice(idx, idx + 8192)
+ const server = getRawJsonStringField(invocationHead, 'server')
+ const tool = getRawJsonStringField(invocationHead, 'tool')
+ return server || tool ? { server, tool } : undefined
+}
+
function countJsonStringBytes(source: Buffer, valueStart: number): number {
let count = 0
for (let i = valueStart; i < source.length; i++) {
@@ -270,6 +396,31 @@ function parseCodexLine(line: string | Buffer): CodexEntry | null {
const pHead = payloadHead(head)
const payloadType = getRawJsonStringField(pHead, 'type')
const role = getRawJsonStringField(pHead, 'role')
+ // task_complete appends the potentially huge final assistant message before
+ // its duration fields. Fall back to the full Buffer only for this event so
+ // timing metadata is not lost when the compact head stops early.
+ const needsTimingTail = type === 'event_msg' && (payloadType === 'task_complete' || payloadType === 'mcp_tool_call_end')
+ const timingTail = needsTimingTail && line.length > RAW_HEAD_BYTES
+ ? line.subarray(Math.max(0, line.length - 16 * 1024)).toString('utf-8')
+ : pHead
+ const timingNumber = (field: string): number | undefined =>
+ getRawJsonNumberField(pHead, field) ?? getRawJsonNumberField(timingTail, field)
+ // MCP records can place a large invocation.arguments object before duration
+ // and a large result after it. Searching a small window around the field
+ // avoids materializing the middle of the Buffer while still preserving wait
+ // timing for those records.
+ const payloadDuration = payloadType === 'mcp_tool_call_end'
+ ? getRawDurationMs(getRawPayloadFieldWindow(line, 'duration') ?? '')
+ : undefined
+ const timingDuration = payloadDuration ?? getRawDurationMs(pHead) ?? getRawDurationMs(timingTail)
+ const compactModel = getRawJsonStringField(pHead, 'model')
+ const compactModelName = getRawJsonStringField(pHead, 'model_name')
+ const compactLastUsage = getRawTokenUsage(pHead, 'last_token_usage')
+ const compactTotalUsage = getRawTokenUsage(pHead, 'total_token_usage')
+ const compactInfo = compactModel || compactModelName || compactLastUsage || compactTotalUsage
+ ? { model: compactModel, model_name: compactModelName, last_token_usage: compactLastUsage, total_token_usage: compactTotalUsage }
+ : undefined
+ const invocation = getRawInvocation(pHead) ?? getRawInvocation(timingTail)
const entry: CodexEntry = {
type,
@@ -284,6 +435,16 @@ function parseCodexLine(line: string | Buffer): CodexEntry | null {
forked_from_id: getRawJsonStringField(pHead, 'forked_from_id'),
model: getRawJsonStringField(pHead, 'model'),
name: getRawJsonStringField(pHead, 'name'),
+ invocation,
+ call_id: getRawJsonStringField(pHead, 'call_id'),
+ turn_id: getRawJsonStringField(pHead, 'turn_id'),
+ // On mcp_tool_call_end a coincidental `duration_ms` inside the large
+ // invocation.arguments object can shadow the payload-level duration, so the
+ // depth-aware value wins. The naive scan stays as the fallback for
+ // task_complete, which records duration_ms at the payload level directly.
+ duration_ms: timingDuration ?? timingNumber('duration_ms'),
+ started_at: timingNumber('started_at'),
+ info: compactInfo,
},
}
@@ -300,6 +461,8 @@ async function discoverSessionFile(filePath: string): Promise null)
if (!s?.isFile()) return null
+ // Fast path: cached results already know the project, so avoid opening the
+ // file. This keeps discovery cheap on large session directories.
const cachedProject = await getCachedCodexProject(filePath)
if (cachedProject) {
return { path: filePath, project: cachedProject, provider: 'codex' }
@@ -314,6 +477,12 @@ async function discoverSessionFile(filePath: string): Promise {
const sources: SessionSource[] = []
+ // Codex archives a session by moving it from sessions/YYYY/MM/DD/ to
+ // archived_sessions/, keeping the same basename. Deduplicate by basename so
+ // a session does not appear twice while it exists in both roots. This avoids
+ // reading every file to extract session_id and preserves the cheap cached
+ // fast path.
+ const seenBasenames = new Set()
const sessionsDir = join(codexDir, 'sessions')
const years = await readdir(sessionsDir).catch(() => [] as string[])
@@ -335,8 +504,9 @@ async function discoverSessionsInDir(codexDir: string): Promise
for (const file of files) {
if (!file.startsWith('rollout-') || !file.endsWith('.jsonl')) continue
- const filePath = join(dayDir, file)
- const source = await discoverSessionFile(filePath)
+ if (seenBasenames.has(file)) continue
+ seenBasenames.add(file)
+ const source = await discoverSessionFile(join(dayDir, file))
if (source) sources.push(source)
}
}
@@ -345,10 +515,14 @@ async function discoverSessionsInDir(codexDir: string): Promise
// Codex moves archived sessions into a flat directory. Keep them in usage
// reports so archiving a conversation does not erase its historical usage.
+ // Call-level deduplication (seenKeys) already collapses any remaining
+ // archived copies, while basename dedup above prevents double discovery.
const archivedDir = join(codexDir, 'archived_sessions')
const archivedFiles = await readdir(archivedDir).catch(() => [] as string[])
for (const file of archivedFiles) {
if (!file.startsWith('rollout-') || !file.endsWith('.jsonl')) continue
+ if (seenBasenames.has(file)) continue
+ seenBasenames.add(file)
const source = await discoverSessionFile(join(archivedDir, file))
if (source) sources.push(source)
}
@@ -410,6 +584,16 @@ function createParser(source: SessionSource, seenKeys: Set): SessionPars
let currentTurnId = `${sessionId}:t0`
let sawAnyLine = false
const results: ParsedProviderCall[] = []
+ // Calls decoded since the last task_started, held back so task_complete can
+ // stamp active/toolWait timing before they are appended to results. Emitting
+ // a task only once its timing is known keeps single-pass and split/resume
+ // decodes in agreement instead of back-patching already-emitted calls.
+ // Bounded by one task's calls; flushed at the next task_started and at EOF.
+ let pendingTaskCalls: ParsedProviderCall[] = []
+ let taskGeneratedTokens = 0
+ let taskToolIntervals: Array<[number, number]> = []
+ let taskStartedAt: number | undefined
+ const openToolStarts = new Map()
// Stream the session file line by line. Heavy Codex sessions can exceed
// 250 MB on disk; reading the entire file into a string would either hit
@@ -437,7 +621,32 @@ function createParser(source: SessionSource, seenKeys: Set): SessionPars
continue
}
- if (entry.type === 'response_item' && entry.payload?.type === 'function_call') {
+ const isForkReplay = Boolean(forkCutoff && entry.timestamp && entry.timestamp < forkCutoff)
+ if (isForkReplay && (
+ entry.payload?.type === 'task_started' ||
+ entry.payload?.type === 'task_complete' ||
+ entry.payload?.type === 'function_call' ||
+ entry.payload?.type === 'function_call_output' ||
+ entry.payload?.type === 'custom_tool_call' ||
+ entry.payload?.type === 'custom_tool_call_output' ||
+ entry.payload?.type === 'mcp_tool_call_end' ||
+ entry.payload?.type === 'patch_apply_end'
+ )) continue
+
+ if (entry.type === 'event_msg' && entry.payload?.type === 'task_started') {
+ // Emit the previous task. If it never reached task_complete its timing
+ // fields simply stay unset, matching the un-buffered behaviour.
+ results.push(...pendingTaskCalls)
+ pendingTaskCalls = []
+ taskGeneratedTokens = 0
+ taskToolIntervals = []
+ const startedAt = entry.timestamp ? Date.parse(entry.timestamp) : NaN
+ taskStartedAt = Number.isFinite(startedAt) ? startedAt : undefined
+ openToolStarts.clear()
+ continue
+ }
+
+ if (entry.type === 'response_item' && (entry.payload?.type === 'function_call' || entry.payload?.type === 'custom_tool_call')) {
const rawName = entry.payload.name ?? ''
const mapped = toolNameMap[rawName] ?? rawName
pendingTools.push(mapped)
@@ -459,10 +668,52 @@ function createParser(source: SessionSource, seenKeys: Set): SessionPars
pendingToolSequence.push([{ tool: mcpTool }])
}
}
+ const callId = entry.payload.call_id
+ const started = entry.timestamp ? Date.parse(entry.timestamp) : NaN
+ if (callId && Number.isFinite(started)) openToolStarts.set(callId, started)
pendingToolSequence.push([call])
continue
}
+ if (entry.type === 'response_item' && (entry.payload?.type === 'function_call_output' || entry.payload?.type === 'custom_tool_call_output')) {
+ const callId = entry.payload.call_id
+ const ended = entry.timestamp ? Date.parse(entry.timestamp) : NaN
+ const started = callId ? openToolStarts.get(callId) : undefined
+ if (started !== undefined && Number.isFinite(ended) && ended > started) taskToolIntervals.push([started, ended])
+ if (callId) openToolStarts.delete(callId)
+ continue
+ }
+
+ if (entry.type === 'event_msg' && entry.payload?.type === 'task_complete') {
+ const durationMs = entry.payload.duration_ms
+ if (typeof durationMs === 'number' && durationMs > 0 && taskGeneratedTokens > 0 && pendingTaskCalls.length > 0) {
+ const completedAt = entry.timestamp ? Date.parse(entry.timestamp) : NaN
+ const windowStart = taskStartedAt ?? (Number.isFinite(completedAt) ? completedAt - durationMs : undefined)
+ const windowEnd = windowStart !== undefined ? windowStart + durationMs : undefined
+ const clipped = taskToolIntervals.map(([start, end]) => [
+ windowStart !== undefined ? Math.max(start, windowStart) : start,
+ windowEnd !== undefined ? Math.min(end, windowEnd) : end,
+ ] as [number, number]).filter(([start, end]) => end > start)
+ const merged = clipped.sort((a, b) => a[0] - b[0]).reduce>((acc, interval) => {
+ const previous = acc.at(-1)
+ if (previous && interval[0] <= previous[1]) previous[1] = Math.max(previous[1], interval[1])
+ else acc.push([...interval])
+ return acc
+ }, [])
+ const toolWaitMs = Math.min(durationMs, merged.reduce((sum, interval) => sum + interval[1] - interval[0], 0))
+ const activeMs = durationMs - toolWaitMs
+ if (activeMs <= 0) continue
+ for (const call of pendingTaskCalls) {
+ const generated = call.outputTokens + call.reasoningTokens
+ if (generated <= 0) continue
+ call.activeGeneratedTokens = generated
+ call.activeDurationMs = activeMs * (generated / taskGeneratedTokens)
+ call.toolWaitMs = toolWaitMs * (generated / taskGeneratedTokens)
+ }
+ }
+ continue
+ }
+
if (entry.type === 'event_msg' && entry.payload?.type === 'patch_apply_end') {
pendingTools.push('Edit')
const p = entry.payload as Record
@@ -490,6 +741,11 @@ function createParser(source: SessionSource, seenKeys: Set): SessionPars
// attributed. Rebuild the canonical `mcp____` name the
// classifier recognizes.
if (entry.type === 'event_msg' && entry.payload?.type === 'mcp_tool_call_end') {
+ const endedAt = entry.timestamp ? Date.parse(entry.timestamp) : NaN
+ const durationMs = entry.payload.duration_ms ?? durationValueMs(entry.payload.duration)
+ if (typeof durationMs === 'number' && durationMs > 0 && Number.isFinite(endedAt)) {
+ taskToolIntervals.push([endedAt - durationMs, endedAt])
+ }
const inv = (entry.payload as Record)['invocation'] as Record | undefined
const server = typeof inv?.['server'] === 'string' ? inv['server'] as string : ''
const tool = typeof inv?.['tool'] === 'string' ? inv['tool'] as string : ''
@@ -542,7 +798,7 @@ function createParser(source: SessionSource, seenKeys: Set): SessionPars
const costUSD = calculateCost(model, estInput, estOutput, 0, 0, 0)
- results.push({
+ pendingTaskCalls.push({
provider: 'codex',
model,
inputTokens: estInput,
@@ -568,6 +824,7 @@ function createParser(source: SessionSource, seenKeys: Set): SessionPars
...(pendingLocRemoved ? { locRemoved: pendingLocRemoved } : {}),
...(pendingEditFailed ? { editFailed: pendingEditFailed } : {}),
})
+ taskGeneratedTokens += estOutput
pendingTools = []
pendingToolSequence = []
@@ -660,7 +917,7 @@ function createParser(source: SessionSource, seenKeys: Set): SessionPars
0,
)
- results.push({
+ pendingTaskCalls.push({
provider: 'codex',
model,
inputTokens: uncachedInputTokens,
@@ -685,6 +942,7 @@ function createParser(source: SessionSource, seenKeys: Set): SessionPars
...(pendingLocRemoved ? { locRemoved: pendingLocRemoved } : {}),
...(pendingEditFailed ? { editFailed: pendingEditFailed } : {}),
})
+ taskGeneratedTokens += outputTokens + reasoningTokens
pendingTools = []
pendingToolSequence = []
@@ -701,6 +959,9 @@ function createParser(source: SessionSource, seenKeys: Set): SessionPars
// result set against a fingerprint that would otherwise be re-parsed.
if (!sawAnyLine) return
+ // Flush the final task, which has no following task_started to trigger it.
+ results.push(...pendingTaskCalls)
+
await writeCachedCodexResults(source.path, source.project, results, fp)
for (const call of results) {
diff --git a/src/providers/types.ts b/src/providers/types.ts
index b5396a2..8f9e902 100644
--- a/src/providers/types.ts
+++ b/src/providers/types.ts
@@ -53,6 +53,9 @@ export type ParsedProviderCall = {
// Exact provider-recorded cwd, kept separately because projectPath may later
// canonicalize a linked worktree to its main repository.
workingDirectory?: string
+ activeDurationMs?: number
+ activeGeneratedTokens?: number
+ toolWaitMs?: number
}
// A directory or database file that a provider's discoverSessions() scans.
diff --git a/src/session-cache.ts b/src/session-cache.ts
index 461dd46..2682756 100644
--- a/src/session-cache.ts
+++ b/src/session-cache.ts
@@ -51,6 +51,9 @@ export type CachedCall = {
toolErrors?: number
// Codex: count of this call's patch applications with success === false.
editFailed?: number
+ activeDurationMs?: number
+ activeGeneratedTokens?: number
+ toolWaitMs?: number
}
export type CachedTurn = {
@@ -213,7 +216,7 @@ export const PROVIDER_PARSE_VERSIONS: Record = {
// rich-session-capture-v1: per-call LOC deltas + editFailed from
// patch_apply_end. (The codex-results.json CODEX_CACHE_VERSION is bumped in
// lockstep so the pre-session-cache layer re-parses too.)
- codex: 'mcp-attribution-v2-est-cost-rich-capture-v1-cross-provider-pr-v1',
+ codex: 'mcp-attribution-v5-est-cost-active-timing-mcp-wait-rich-capture-v1-cross-provider-pr-v1',
cursor: 'composer-anchored-crediting-v1-est-cost',
'cursor-agent': 'workspaceless-transcript-v1',
copilot: 'cli-shutdown-cost-v1-skills',
@@ -337,6 +340,9 @@ function validateCall(c: unknown): c is CachedCall {
&& (o['speed'] === 'standard' || o['speed'] === 'fast')
&& isOptionalNum(o['costUSD'])
&& isOptionalBool(o['isEstimated'])
+ && isOptionalNum(o['activeDurationMs'])
+ && isOptionalNum(o['activeGeneratedTokens'])
+ && isOptionalNum(o['toolWaitMs'])
&& isStringArray(o['tools'])
&& isStringArray(o['bashCommands'])
&& isStringArray(o['skills'])
diff --git a/src/sync/cli.ts b/src/sync/cli.ts
index 174b616..f6f28db 100644
--- a/src/sync/cli.ts
+++ b/src/sync/cli.ts
@@ -22,7 +22,8 @@ import {
} from './auth.js'
import { createCredentialStore } from './credentials.js'
import { readSyncConfig, writeSyncConfig, deleteSyncConfig, updateLastSync } from './config.js'
-import { collectUnsentCalls, sendBatches, batchCalls, MAX_PER_PUSH } from './push.js'
+import { collectUnsentCalls, collectUnsentAttribution, sendBatches, sendAttributionBatches, batchCalls, MAX_PER_PUSH, MAX_ATTRIBUTION_PER_PUSH, type PushResult } from './push.js'
+import { batchAttributionItems } from './otlp.js'
export function registerSyncCommands(program: Command): void {
const sync = program
@@ -226,7 +227,8 @@ export function registerSyncCommands(program: Command): void {
.description('Push unsent telemetry data to the configured endpoint')
.option('--since ', 'Time window: today, 7d, 30d, month, all (max 6 months)', '7d')
.option('--dry-run', 'Show what would be sent without sending')
- .action(async (opts: { since: string; dryRun?: boolean }) => {
+ .option('--attribution', 'Also push git attribution spans (session→commit correlation from `codeburn yield`, plus PR links). Sends normalized repo remotes and commit SHAs to the endpoint.')
+ .action(async (opts: { since: string; dryRun?: boolean; attribution?: boolean }) => {
const config = readSyncConfig()
if (!config) {
process.stderr.write('Sync not configured. Run `codeburn sync setup ` first.\n')
@@ -277,6 +279,18 @@ export function registerSyncCommands(program: Command): void {
// Flatten + filter against sent-ledger
const { allCalls, unsent } = collectUnsentCalls(projects)
+ // Attribution records (opt-in): session→commit correlation computed
+ // locally from the same parsed projects. Reuses the yield engine.
+ let attributionUnsent: Awaited>['unsent'] = []
+ let attributionTotal = 0
+ if (opts.attribution) {
+ const { computeAttributionRecords } = await import('../yield.js')
+ const records = computeAttributionRecords(projects, range, process.cwd())
+ const collected = collectUnsentAttribution(records)
+ attributionUnsent = collected.unsent
+ attributionTotal = collected.allItems.length
+ }
+
if (opts.dryRun) {
const toPushCount = Math.min(unsent.length, MAX_PER_PUSH)
const cost = unsent.slice(0, MAX_PER_PUSH).reduce((s, c) => s + c.call.costUSD, 0)
@@ -285,10 +299,19 @@ export function registerSyncCommands(program: Command): void {
if (unsent.length > MAX_PER_PUSH) {
process.stderr.write(`[dry-run] ${unsent.length - MAX_PER_PUSH} more calls exceed the ${MAX_PER_PUSH} safety limit — a second push would be needed\n`)
}
+ if (opts.attribution) {
+ const toPushAttr = attributionUnsent.slice(0, MAX_ATTRIBUTION_PER_PUSH)
+ const commits = toPushAttr.filter(i => i.kind === 'commit').length
+ const sessions = toPushAttr.filter(i => i.kind === 'session').length
+ process.stderr.write(`[dry-run] Attribution: ${attributionTotal} facts total, would push ${toPushAttr.length} (${sessions} sessions, ${commits} commits)\n`)
+ if (attributionUnsent.length > MAX_ATTRIBUTION_PER_PUSH) {
+ process.stderr.write(`[dry-run] ${attributionUnsent.length - MAX_ATTRIBUTION_PER_PUSH} more attribution facts exceed the ${MAX_ATTRIBUTION_PER_PUSH} safety limit — a second push would be needed\n`)
+ }
+ }
return
}
- if (unsent.length === 0) {
+ if (unsent.length === 0 && attributionUnsent.length === 0) {
process.stderr.write(`Nothing to push (${allCalls.length} calls already synced).\n`)
updateLastSync()
return
@@ -302,15 +325,18 @@ export function registerSyncCommands(program: Command): void {
// Batch and send (loops until done; waits out 429 rate limits)
const discoveryDoc = await fetchDiscoveryDoc(config.baseUrl)
- const batches = batchCalls(toPush, discoveryDoc.max_batch_size)
const endpoint = `${config.baseUrl}${config.tracesPath}`
- const result = await sendBatches({
- endpoint,
- accessToken: tokens.access_token,
- batches,
- log: msg => process.stderr.write(`${msg}\n`),
- })
+ let result: PushResult = { outcome: 'complete', totalSent: 0, totalRejected: 0, totalCostSent: 0 }
+ if (toPush.length > 0) {
+ const batches = batchCalls(toPush, discoveryDoc.max_batch_size)
+ result = await sendBatches({
+ endpoint,
+ accessToken: tokens.access_token,
+ batches,
+ log: msg => process.stderr.write(`${msg}\n`),
+ })
+ }
if (result.outcome === 'auth-rejected') {
process.stderr.write('Auth rejected by server. Run `codeburn sync setup` to re-authenticate.\n')
@@ -323,11 +349,50 @@ export function registerSyncCommands(program: Command): void {
process.stderr.write(`Server error (HTTP ${result.httpStatus}). Remaining calls will be sent on the next push.\n`)
}
+ // Attribution spans ride the same endpoint after the usage push
+ // completes. Skipped when the usage push hit rate limits or server
+ // errors — the endpoint is already unhappy; both retry on next push.
+ let attrResult: PushResult | null = null
+ if (opts.attribution && attributionUnsent.length > 0) {
+ if (result.outcome === 'complete') {
+ // Safety valve, mirroring the usage-call cap
+ const attrToPush = attributionUnsent.slice(0, MAX_ATTRIBUTION_PER_PUSH)
+ if (attributionUnsent.length > MAX_ATTRIBUTION_PER_PUSH) {
+ process.stderr.write(`${attributionUnsent.length} attribution facts exceed the ${MAX_ATTRIBUTION_PER_PUSH} safety limit. Pushing first ${MAX_ATTRIBUTION_PER_PUSH}; run again to continue.\n`)
+ }
+ const attrBatches = batchAttributionItems(attrToPush, discoveryDoc.max_batch_size)
+ attrResult = await sendAttributionBatches({
+ endpoint,
+ accessToken: tokens.access_token,
+ batches: attrBatches,
+ log: msg => process.stderr.write(`${msg}\n`),
+ })
+ if (attrResult.outcome === 'auth-rejected') {
+ process.stderr.write('Auth rejected by server during attribution push. Run `codeburn sync setup` to re-authenticate.\n')
+ process.exit(1)
+ }
+ if (attrResult.outcome === 'rate-limited') {
+ process.stderr.write(`Rate limited during attribution push — gave up after repeated retries. Remaining facts will be sent on the next push.\n`)
+ }
+ if (attrResult.outcome === 'server-error') {
+ process.stderr.write(`Server error (HTTP ${attrResult.httpStatus}) during attribution push. Remaining facts will be sent on the next push.\n`)
+ }
+ } else {
+ process.stderr.write(`Skipping attribution push (${attributionUnsent.length} facts) — will retry on next push.\n`)
+ }
+ }
+
// Update lastSync
updateLastSync()
// Summary
process.stderr.write(`\nSynced ${result.totalSent} calls ($${result.totalCostSent.toFixed(2)}) to ${config.baseUrl}\n`)
+ if (attrResult) {
+ const attrSuffix = attrResult.outcome !== 'complete'
+ ? ` (push incomplete — remainder retries next push)`
+ : attrResult.totalRejected > 0 ? `, ${attrResult.totalRejected} rejected (will retry)` : ''
+ process.stderr.write(` Attribution: ${attrResult.totalSent} facts synced${attrSuffix}\n`)
+ }
if (result.totalRejected > 0) {
process.stderr.write(` ${result.totalRejected} spans rejected (will retry on next push)\n`)
}
@@ -337,7 +402,7 @@ export function registerSyncCommands(program: Command): void {
// Non-zero exit when the push did not complete, so cron/scripts can
// detect it. Ledgered progress is kept; next push resumes.
- if (result.outcome !== 'complete') {
+ if (result.outcome !== 'complete' || (attrResult !== null && attrResult.outcome !== 'complete')) {
process.exitCode = 1
}
} catch (err) {
diff --git a/src/sync/otlp.ts b/src/sync/otlp.ts
index 8a12c7e..8df36c5 100644
--- a/src/sync/otlp.ts
+++ b/src/sync/otlp.ts
@@ -8,6 +8,7 @@
import { createHash } from 'crypto'
import { hostname, userInfo } from 'os'
import type { ParsedApiCall } from '../types.js'
+import type { SessionAttributionRecord } from '../yield.js'
export interface OtlpSpan {
traceId: string
@@ -141,3 +142,175 @@ export function batchCalls(calls: CallWithSession[], maxBatchSize: number): Call
}
return batches
}
+
+// --- Attribution spans (sync push --attribution) ---
+
+export const SESSION_ATTRIBUTION_SPAN_NAME = 'codeburn.session.attribution'
+export const COMMIT_ATTRIBUTION_SPAN_NAME = 'codeburn.commit'
+
+/**
+ * A single ledger-able attribution unit: either one session-level record
+ * (repo + PR links + commit count) or one attributed commit. The dedup key
+ * encodes the mutable state (inMain/wasReverted for commits; repo, PR links,
+ * and commit set for sessions), so a state TRANSITION mints a new key and the
+ * updated fact is re-sent on the next push — the receiver upserts by
+ * (repo, sha) / (session). Identical states dedupe via the sent-ledger.
+ */
+export type AttributionItem = {
+ kind: 'session' | 'commit'
+ dedupKey: string
+ /** Span start: commit author time for commits, session start for sessions. ISO 8601. */
+ timestamp: string
+ /** Span end for session items (session lastTimestamp). Absent for commits. */
+ endTimestamp?: string
+ sessionId: string
+ project: string
+ repo: string | null
+ // session kind
+ prLinks?: string[]
+ commitCount?: number
+ // commit kind
+ sha?: string
+ inMain?: boolean
+ wasReverted?: boolean
+}
+
+function stateHash(parts: string[]): string {
+ return createHash('sha256').update(parts.join('\u001e')).digest('hex').slice(0, 16)
+}
+
+/** Deterministic dedup key for a commit attribution fact (state included). */
+export function commitAttributionKey(sessionId: string, sha: string, inMain: boolean, wasReverted: boolean): string {
+ return `attr:c:${sessionId}:${sha}:${inMain ? 1 : 0}${wasReverted ? 1 : 0}`
+}
+
+/** Deterministic dedup key for a session attribution fact (state included). */
+export function sessionAttributionKey(record: SessionAttributionRecord): string {
+ const commitStates = record.commits
+ .map(c => `${c.sha}:${c.inMain ? 1 : 0}${c.wasReverted ? 1 : 0}`)
+ .sort()
+ // Project and both window timestamps are part of the state: an ongoing
+ // session whose window grew (or whose project resolution changed) re-emits
+ // with the corrected span times instead of freezing at first send.
+ return `attr:s:${record.sessionId}:${stateHash([
+ record.repo ?? '',
+ record.project,
+ record.firstTimestamp,
+ record.lastTimestamp,
+ ...record.prLinks,
+ ...commitStates,
+ ])}`
+}
+
+/** Ledger-key prefix for a session's attribution facts (any state). */
+export function sessionAttributionKeyPrefix(sessionId: string): string {
+ return `attr:s:${sessionId}:`
+}
+
+/** Flatten attribution records into ledger-able items (one session item + one per commit). */
+export function flattenAttributionRecords(records: SessionAttributionRecord[]): AttributionItem[] {
+ const items: AttributionItem[] = []
+ for (const record of records) {
+ items.push({
+ kind: 'session',
+ dedupKey: sessionAttributionKey(record),
+ timestamp: record.firstTimestamp,
+ endTimestamp: record.lastTimestamp,
+ sessionId: record.sessionId,
+ project: record.project,
+ repo: record.repo,
+ prLinks: record.prLinks,
+ commitCount: record.commits.length,
+ })
+ for (const commit of record.commits) {
+ items.push({
+ kind: 'commit',
+ dedupKey: commitAttributionKey(record.sessionId, commit.sha, commit.inMain, commit.wasReverted),
+ timestamp: commit.timestamp,
+ sessionId: record.sessionId,
+ project: record.project,
+ repo: record.repo,
+ sha: commit.sha,
+ inMain: commit.inMain,
+ wasReverted: commit.wasReverted,
+ })
+ }
+ }
+ return items
+}
+
+/**
+ * Build an OTLP payload from attribution items. Spans share the session's
+ * traceId with the usage spans (`deriveTraceId(sessionId)`), so a receiver
+ * can correlate cost and attribution without any extra key.
+ */
+export function buildAttributionOtlpPayload(items: AttributionItem[]): OtlpPayload {
+ const deviceId = getDeviceId()
+
+ const spans: OtlpSpan[] = items.map(item => {
+ const startNano = toUnixNano(item.timestamp)
+ // Clamp like the usage builder: end is never 0 (malformed timestamp) and
+ // never earlier than start + 1ms (out-of-order session timestamps).
+ const minEndNano = BigInt(startNano) + 1_000_000n
+ const rawEndNano = item.endTimestamp ? BigInt(toUnixNano(item.endTimestamp)) : 0n
+ const endNano = (rawEndNano > minEndNano ? rawEndNano : minEndNano).toString()
+
+ const attributes: OtlpAttribute[] = [
+ { key: 'ai.session_id', value: { stringValue: item.sessionId } },
+ { key: 'ai.project', value: { stringValue: item.project } },
+ ]
+ if (item.repo) {
+ attributes.push({ key: 'git.repo', value: { stringValue: item.repo } })
+ }
+
+ if (item.kind === 'commit') {
+ attributes.push(
+ { key: 'git.sha', value: { stringValue: item.sha ?? '' } },
+ { key: 'git.in_main', value: { boolValue: item.inMain ?? false } },
+ { key: 'git.was_reverted', value: { boolValue: item.wasReverted ?? false } },
+ )
+ } else {
+ attributes.push({ key: 'git.commit_count', value: { intValue: String(item.commitCount ?? 0) } })
+ if (item.prLinks && item.prLinks.length > 0) {
+ attributes.push({
+ key: 'git.pr_links',
+ value: { arrayValue: { values: item.prLinks.map(u => ({ stringValue: u })) } },
+ })
+ }
+ }
+
+ return {
+ traceId: deriveTraceId(item.sessionId),
+ spanId: deriveSpanId(item.dedupKey),
+ name: item.kind === 'commit' ? COMMIT_ATTRIBUTION_SPAN_NAME : SESSION_ATTRIBUTION_SPAN_NAME,
+ startTimeUnixNano: startNano,
+ endTimeUnixNano: endNano,
+ attributes,
+ }
+ })
+
+ return {
+ resourceSpans: [{
+ resource: {
+ attributes: [
+ { key: 'codeburn.device_id', value: { stringValue: deviceId } },
+ // Honesty marker: this attribution is inferred (timestamp-window
+ // correlation), not declared. Receivers should label it as such.
+ { key: 'codeburn.attribution_methodology', value: { stringValue: 'timestamp-window' } },
+ ],
+ },
+ scopeSpans: [{
+ spans,
+ }],
+ }],
+ }
+}
+
+/** Split attribution items into batches of maxBatchSize. */
+export function batchAttributionItems(items: AttributionItem[], maxBatchSize: number): AttributionItem[][] {
+ const batches: AttributionItem[][] = []
+ for (let i = 0; i < items.length; i += maxBatchSize) {
+ batches.push(items.slice(i, i + maxBatchSize))
+ }
+ return batches
+}
diff --git a/src/sync/push.ts b/src/sync/push.ts
index 0444c71..7a22420 100644
--- a/src/sync/push.ts
+++ b/src/sync/push.ts
@@ -8,7 +8,16 @@
import type { ProjectSummary } from '../types.js'
import { assertHttps } from './discovery.js'
import { ledgerKeySet, appendToLedger, type LedgerEntry } from './ledger.js'
-import { buildOtlpPayload, batchCalls, type CallWithSession } from './otlp.js'
+import {
+ buildOtlpPayload,
+ batchCalls,
+ buildAttributionOtlpPayload,
+ flattenAttributionRecords,
+ type CallWithSession,
+ type AttributionItem,
+ type OtlpPayload,
+} from './otlp.js'
+import type { SessionAttributionRecord } from '../yield.js'
/**
* Safety valve, not a routine cap — pushes now loop until all batches are
@@ -91,6 +100,23 @@ export function parseRetryAfterMs(value: string | null): number | null {
* retry on the next push.
*/
export async function sendBatches(opts: SendBatchesOptions): Promise {
+ return sendBatchesCore({
+ ...opts,
+ buildPayload: buildOtlpPayload,
+ toOutbound: c => ({ key: c.call.deduplicationKey, ts: c.call.timestamp, costUSD: c.call.costUSD }),
+ })
+}
+
+/** How sendBatchesCore ledgers and prices a batch item. */
+type OutboundItem = { key: string; ts: string; costUSD: number }
+
+type SendBatchesCoreOptions = Omit & {
+ batches: T[][]
+ buildPayload: (batch: T[]) => OtlpPayload
+ toOutbound: (item: T) => OutboundItem
+}
+
+async function sendBatchesCore(opts: SendBatchesCoreOptions): Promise {
assertHttps(opts.endpoint, 'Traces endpoint')
const log = opts.log ?? (() => {})
const sleep = opts.sleep ?? ((ms: number) => new Promise(r => setTimeout(r, ms)))
@@ -107,7 +133,7 @@ export async function sendBatches(opts: SendBatchesOptions): Promise
// Retry loop for the current batch (429 only)
for (;;) {
- const payload = buildOtlpPayload(batch)
+ const payload = opts.buildPayload(batch)
const response = await fetch(opts.endpoint, {
method: 'POST',
@@ -158,13 +184,11 @@ export async function sendBatches(opts: SendBatchesOptions): Promise
totalRejected += rejected
log(` Batch: ${rejected}/${batch.length} spans rejected — whole batch will retry on next push`)
} else {
- const entries: LedgerEntry[] = batch.map(c => ({
- key: c.call.deduplicationKey,
- ts: c.call.timestamp,
- }))
+ const outbound = batch.map(opts.toOutbound)
+ const entries: LedgerEntry[] = outbound.map(o => ({ key: o.key, ts: o.ts }))
appendToLedger(entries)
totalSent += batch.length
- totalCostSent += batch.reduce((s, c) => s + c.call.costUSD, 0)
+ totalCostSent += outbound.reduce((s, o) => s + o.costUSD, 0)
}
break // batch done (success or partial) — move to next batch
}
@@ -173,4 +197,56 @@ export async function sendBatches(opts: SendBatchesOptions): Promise
return { outcome: 'complete', totalSent, totalRejected, totalCostSent, totalWaitMs }
}
+/**
+ * Safety valve for attribution items, mirroring MAX_PER_PUSH: bounds a first
+ * `--since all --attribution` push over a long history. Remaining facts are
+ * sent on the next push (the ledger tracks progress).
+ */
+export const MAX_ATTRIBUTION_PER_PUSH = 10_000
+
+/** Flatten attribution records into items and filter out already-sent ones. */
+export function collectUnsentAttribution(records: SessionAttributionRecord[]): {
+ allItems: AttributionItem[]
+ unsent: AttributionItem[]
+} {
+ const sent = ledgerKeySet()
+
+ // Empty records (no commits, no PR links) exist only to RETRACT a session
+ // span whose commits migrated to another session. Send one only when a
+ // PRIOR state for that session was already ledgered — a session that was
+ // never sent has nothing to retract.
+ const sessionsWithPriorState = new Set()
+ for (const key of sent) {
+ if (key.startsWith('attr:s:')) {
+ const sessionId = key.slice('attr:s:'.length, key.lastIndexOf(':'))
+ sessionsWithPriorState.add(sessionId)
+ }
+ }
+ const sendable = records.filter(r =>
+ r.commits.length > 0 || r.prLinks.length > 0 || sessionsWithPriorState.has(r.sessionId),
+ )
+
+ const allItems = flattenAttributionRecords(sendable)
+ const unsent = allItems.filter(i => !sent.has(i.dedupKey))
+ return { allItems, unsent }
+}
+
+export interface SendAttributionBatchesOptions extends Omit {
+ batches: AttributionItem[][]
+}
+
+/**
+ * Send attribution batches through the same retry/ledger pipeline as usage
+ * batches. Items are ledgered by their state-encoding dedup keys, so an
+ * identical attribution fact is sent once and a state transition (commit
+ * merged to main, commit reverted) re-sends the updated fact.
+ */
+export async function sendAttributionBatches(opts: SendAttributionBatchesOptions): Promise {
+ return sendBatchesCore({
+ ...opts,
+ buildPayload: buildAttributionOtlpPayload,
+ toOutbound: item => ({ key: item.dedupKey, ts: item.timestamp, costUSD: 0 }),
+ })
+}
+
export { batchCalls }
diff --git a/src/types.ts b/src/types.ts
index d5624cf..a51ae67 100644
--- a/src/types.ts
+++ b/src/types.ts
@@ -153,6 +153,9 @@ export type ParsedApiCall = {
/// Count of this call's tool results flagged `is_error` (Claude tool_result
/// blocks). Bash stderr alone is NOT counted (warnings go there). Omitted at 0.
toolErrors?: number
+ activeDurationMs?: number
+ activeGeneratedTokens?: number
+ toolWaitMs?: number
}
export type ToolCall = {
@@ -268,7 +271,7 @@ export type SessionSummary = {
/// from a provider that never captures branches (→ contributes nothing).
/// Claude only; absent otherwise.
everHadBranch?: boolean
- modelBreakdown: Record
+ modelBreakdown: Record
toolBreakdown: Record
mcpBreakdown: Record
bashBreakdown: Record
diff --git a/src/usage-aggregator.ts b/src/usage-aggregator.ts
index baf4c31..86d0e2e 100644
--- a/src/usage-aggregator.ts
+++ b/src/usage-aggregator.ts
@@ -12,7 +12,7 @@ import { aggregateModels } from './models-report.js'
import { scanUserCorrections, medianTimeToFirstEditMs, aggregateFileChurn, computePricingCoverage } from './workflow-insights.js'
import { buildPrAttribution, aggregateByBranch } from './sessions-report.js'
import { scanAndDetect } from './optimize.js'
-import { getDaysInRange, ensureCacheHydrated, emptyCache, BACKFILL_DAYS, toDateString, type DailyCache, type DailyEntry } from './daily-cache.js'
+import { getDaysInRange, ensureCacheHydrated, emptyCache, BACKFILL_DAYS, toDateString, type DailyCache, type DailyEntry, type ProjectDayStats, type ProviderDaySlice } from './daily-cache.js'
import { buildGranularHistory } from './granular-history.js'
// Row caps for the by-PR / by-branch payload aggregations, ranked by cost.
@@ -226,25 +226,130 @@ function sliceDayToProvider(day: DailyEntry, provider: string): DailyEntry {
}
}
+/// Does a cached day's project entry pass the active name filters? Mirrors
+/// parser.filterProjectsByName exactly — case-insensitive substring match
+/// against the project name OR its filesystem path, include first then exclude —
+/// so a filter selects the same projects whether it is resolved against a fresh
+/// parse or against the day cache. Patterns arrive pre-lowercased. `path` is
+/// absent on entries whose sessions were gone before it could be recorded; the
+/// name is then all there is to match on, as it is for the display layers.
+function dayProjectMatches(name: string, path: string | undefined, include: string[], exclude: string[]): boolean {
+ const n = name.toLowerCase()
+ const p = (path ?? '').toLowerCase()
+ const hit = (pattern: string): boolean => n.includes(pattern) || (p !== '' && p.includes(pattern))
+ if (include.length > 0 && !include.some(hit)) return false
+ if (exclude.length > 0 && exclude.some(hit)) return false
+ return true
+}
+
+/// Sum the per-project day stats that pass the filters. `defineProperty` so a
+/// project directory named "__proto__" stays an own key instead of mutating the
+/// prototype link (same reason day-aggregator does it when writing them).
+function sumMatchingProjects(
+ projects: Record,
+ include: string[],
+ exclude: string[],
+): { cost: number; calls: number; savingsUSD: number; sessions: number; projects: Record; matched: number } {
+ const out = { cost: 0, calls: 0, savingsUSD: 0, sessions: 0, projects: {} as Record, matched: 0 }
+ for (const [name, p] of Object.entries(projects)) {
+ if (!dayProjectMatches(name, p.path, include, exclude)) continue
+ out.cost += p.cost
+ out.calls += p.calls
+ out.savingsUSD += p.savingsUSD ?? 0
+ out.sessions += p.sessions ?? 0
+ out.matched += 1
+ Object.defineProperty(out.projects, name, { value: p, enumerable: true, writable: true, configurable: true })
+ }
+ return out
+}
+
+/// Collapse a day to the slice matching the active --project/--exclude filters,
+/// the project-level counterpart of sliceDayToProvider. Without this a
+/// project-filtered headline counted every historical day WHOLE — excluded
+/// projects included — while every detail panel (By Project / By Activity / By
+/// Model, all built from the name-filtered live parse) left them out, so the two
+/// could not be reconciled.
+///
+/// `DailyEntry.projects` (cache v15+) carries cost/calls/savingsUSD/sessions per
+/// project, so those four are recomputed EXACTLY. The day's tokens, models and
+/// categories have no per-project split to slice, so they are dropped here
+/// rather than reported as if they belonged to the surviving projects;
+/// buildDurablePeriod refills them from the project-filtered live parse, which
+/// is exact for every session that still exists.
+///
+/// A day recorded before v15 has no `projects` at all and nothing can
+/// reconstruct one once the sources are gone. Such a day cannot be attributed to
+/// any project, so it contributes nothing to a project-filtered total and its
+/// cost is surfaced as `unattributedCostUSD` instead of being silently folded in
+/// (understating with a stated figure beats overstating with excluded spend).
+function sliceDayToProject(day: DailyEntry, include: string[], exclude: string[]): DailyEntry {
+ const zeroDay = (): DailyEntry => ({
+ date: day.date, cost: 0, savingsUSD: 0, calls: 0, sessions: 0,
+ inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0,
+ editTurns: 0, oneShotTurns: 0, models: {}, categories: {}, providers: {},
+ ...(day.carried ? { carried: true as const } : {}),
+ })
+ if (!day.projects) return zeroDay()
+ const totals = sumMatchingProjects(day.projects, include, exclude)
+ if (totals.matched === 0) return zeroDay()
+
+ // Provider slices carry their own per-project split, so `--provider X` on top
+ // of a project filter stays consistent with the day-level slice. A slice
+ // adopted from a pre-v15 cache has no split and is dropped for the same
+ // reason the day-level one is.
+ const providers: Record = {}
+ for (const [name, slice] of Object.entries(day.providers)) {
+ if (!slice.projects) continue
+ const sliced = sumMatchingProjects(slice.projects, include, exclude)
+ if (sliced.matched === 0) continue
+ Object.defineProperty(providers, name, {
+ value: { cost: sliced.cost, calls: sliced.calls, savingsUSD: sliced.savingsUSD, sessions: sliced.sessions, projects: sliced.projects },
+ enumerable: true, writable: true, configurable: true,
+ })
+ }
+
+ return {
+ date: day.date,
+ cost: totals.cost,
+ savingsUSD: totals.savingsUSD,
+ calls: totals.calls,
+ sessions: totals.sessions,
+ inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0,
+ editTurns: 0, oneShotTurns: 0, models: {}, categories: {},
+ providers,
+ projects: totals.projects,
+ ...(day.carried ? { carried: true as const } : {}),
+ }
+}
+
/// The durable day set behind a period's headline: historical days from the
/// carry-forward cache (up to yesterday, INCLUDING days whose session files have
/// expired) unioned with today parsed live, then narrowed to the requested range
/// and (when given) the heatmap day selection. Identical construction to the
/// menubar's all-provider headline — this IS that construction, extracted.
+///
+/// `sliceHistorical` narrows the cache-sourced days only. Today's days come from
+/// a parse the caller already name-filtered, so re-slicing them would be a no-op
+/// at best and could only lose data the filter meant to keep.
function unionDaysForPeriod(
cache: DailyCache,
todayAllDays: DailyEntry[],
periodInfo: PeriodInfo,
daysSelection: Set | null,
+ sliceHistorical?: (day: DailyEntry) => DailyEntry,
): DailyEntry[] {
const now = new Date()
const yesterdayStr = toDateString(new Date(now.getFullYear(), now.getMonth(), now.getDate() - 1))
const rangeStartStr = toDateString(periodInfo.range.start)
const rangeEndStr = toDateString(periodInfo.range.end)
const historicalRangeEndStr = rangeEndStr < yesterdayStr ? rangeEndStr : yesterdayStr
- const historicalDays = rangeStartStr <= historicalRangeEndStr
+ const cacheDays = rangeStartStr <= historicalRangeEndStr
? getDaysInRange(cache, rangeStartStr, historicalRangeEndStr)
: []
+ // Apply the day selection BEFORE slicing so a day the heatmap filtered out
+ // never reaches the slicer (which tallies what it could not attribute).
+ const selectedCacheDays = daysSelection ? cacheDays.filter(d => daysSelection.has(d.date)) : cacheDays
+ const historicalDays = sliceHistorical ? selectedCacheDays.map(d => sliceHistorical(d)) : selectedCacheDays
const todayInRange = todayAllDays.filter(d => d.date >= rangeStartStr && d.date <= rangeEndStr)
const unfiltered = [...historicalDays, ...todayInRange].sort((a, b) => a.date.localeCompare(b.date))
return daysSelection ? unfiltered.filter(d => daysSelection.has(d.date)) : unfiltered
@@ -266,6 +371,12 @@ export type DurablePeriod = {
days: DailyEntry[]
/// Sum of `cost` on `carried` days included in the period (footnote source).
carriedCostUSD: number
+ /// Cost the active --project/--exclude filter had to set aside: cached days
+ /// recorded before per-project day stats existed (v15) carry no project split,
+ /// so they cannot be attributed to the filtered projects. Always 0 when no
+ /// project filter is active. Reported so a filtered total that is short of the
+ /// unfiltered one says so instead of just looking wrong.
+ unattributedCostUSD: number
/// Fresh per-period parse (provider + name filtered) for detail views that
/// still need surviving session files.
liveProjects: ProjectSummary[]
@@ -325,7 +436,33 @@ export async function buildDurablePeriod(periodInfo: PeriodInfo, opts: Aggregate
scanRange = isTodayOnly ? todayRange : periodInfo.range
}
- const allDays = unionDaysForPeriod(cache, todayAllDays, periodInfo, daysSelection?.days ?? null)
+ // Name filters must reach the cache-sourced days too. Today's parse is already
+ // name-filtered above (`fp`), but the historical remainder comes straight out
+ // of the day cache, so without this slice a --project/--exclude headline
+ // counted every expired-source day whole while the detail panels did not.
+ const projectInclude = (opts.project ?? []).map(s => s.toLowerCase())
+ const projectExclude = (opts.exclude ?? []).map(s => s.toLowerCase())
+ const hasProjectFilter = projectInclude.length > 0 || projectExclude.length > 0
+ // What a filtered total cannot claim, and therefore has to leave out: a cached
+ // day with no project split at all, or — with a provider filter also active,
+ // since the headline then reads that provider's slice — a slice carried from a
+ // cache generation that predates per-project splits. Both are stated back to
+ // the caller (footnoted by the overview) instead of vanishing from the total.
+ const unattributableCost = (day: DailyEntry): number => {
+ if (pf === 'all') return day.projects ? 0 : day.cost
+ const slice = Object.hasOwn(day.providers, pf) ? day.providers[pf] : undefined
+ if (!slice) return 0
+ return !day.projects || !slice.projects ? slice.cost : 0
+ }
+ let unattributedCostUSD = 0
+ const sliceHistorical = hasProjectFilter
+ ? (day: DailyEntry): DailyEntry => {
+ unattributedCostUSD += unattributableCost(day)
+ return sliceDayToProject(day, projectInclude, projectExclude)
+ }
+ : undefined
+
+ const allDays = unionDaysForPeriod(cache, todayAllDays, periodInfo, daysSelection?.days ?? null, sliceHistorical)
const days = pf === 'all' ? allDays : allDays.map(d => sliceDayToProvider(d, pf))
const data = buildPeriodDataFromDays(days, periodInfo.label)
@@ -342,6 +479,21 @@ export async function buildDurablePeriod(periodInfo: PeriodInfo, opts: Aggregate
// Cache buckets a session on its START day, the scan on any ACTIVE day; both
// are lower bounds of distinct sessions, so max is the tightest safe bound.
data.sessions = Math.max(data.sessions, scanData.sessions)
+ // Tokens/models/categories have no per-project split in the day cache, so
+ // sliceDayToProject drops them (see there). Under a project filter they come
+ // from the live parse instead: exact for the filtered projects, bounded by
+ // source retention like every other scan-derived field above, and consistent
+ // with the By Model / By Activity panels that read the same parse. Cost, calls,
+ // sessions and savings stay durable — sliced out of the cache, expired days
+ // included.
+ if (hasProjectFilter) {
+ data.inputTokens = scanData.inputTokens
+ data.outputTokens = scanData.outputTokens
+ data.cacheReadTokens = scanData.cacheReadTokens
+ data.cacheWriteTokens = scanData.cacheWriteTokens
+ data.models = scanData.models
+ data.categories = scanData.categories
+ }
const estimatedByModel = new Map(
scanData.models.filter(m => m.estimatedCostUSD != null).map(m => [m.name, m.estimatedCostUSD!]),
)
@@ -352,7 +504,7 @@ export async function buildDurablePeriod(periodInfo: PeriodInfo, opts: Aggregate
}
const carriedCostUSD = days.reduce((s, d) => s + (d.carried ? d.cost : 0), 0)
- return { data, days, carriedCostUSD, liveProjects, cache, todayAllDays, scanRange }
+ return { data, days, carriedCostUSD, unattributedCostUSD, liveProjects, cache, todayAllDays, scanRange }
}
/**
diff --git a/src/yield.ts b/src/yield.ts
index ce3cbd0..79239b9 100644
--- a/src/yield.ts
+++ b/src/yield.ts
@@ -2,7 +2,7 @@ import { execFileSync } from 'child_process'
import { realpathSync } from 'fs'
import { resolve } from 'path'
import { parseAllSessions } from './parser.js'
-import type { DateRange, SessionSummary } from './types.js'
+import type { DateRange, ProjectSummary, SessionSummary } from './types.js'
export type YieldCategory = 'productive' | 'reverted' | 'abandoned' | 'ambiguous'
@@ -133,7 +133,98 @@ function getMainBranch(cwd: string): string {
return 'main'
}
-type CommitInfo = {
+/**
+ * Normalize a git remote URL to a host-scoped repo identity (`host/org/repo`)
+ * usable as a server-side join key. Handles the three common transports:
+ *
+ * git@github.com:org/repo.git -> github.com/org/repo
+ * ssh://git@github.com:22/org/repo.git -> github.com/org/repo
+ * https://user:tok@github.com/org/repo.git -> github.com/org/repo
+ *
+ * Credentials and ports are stripped (a token embedded in an https remote must
+ * never leave the machine), the host is lowercased (path case is preserved),
+ * and a trailing `.git` / `/` is removed. Local paths and `file://` remotes
+ * return null — a repo with no network remote has no server-side identity.
+ */
+/** Max length of an emitted repo identity (`host/org/repo`). */
+const MAX_REPO_IDENTITY_LENGTH = 200
+
+/**
+ * Positive validation (allow-list) of a composed repo identity — the final
+ * gate EVERY branch passes through before anything is returned. The host must
+ * look like a hostname and every path segment like a repo path segment, so no
+ * upstream parsing quirk (transport-helper remotes like `ext::…` or
+ * `codecommit::…`, credentials that survived a malformed URL, oversized
+ * strings) can reach the wire. Rejecting is always safe: an unrecognizable
+ * remote simply has no server-side identity.
+ */
+function isValidRepoIdentity(host: string, segments: string[]): boolean {
+ if (!/^[a-z0-9]([a-z0-9.-]*[a-z0-9])?$/.test(host)) return false
+ if (segments.length === 0) return false
+ return segments.every(s => /^[A-Za-z0-9._~-]+$/.test(s))
+}
+
+export function normalizeRemoteUrl(url: string): string | null {
+ const trimmed = url.trim()
+ if (!trimmed) return null
+
+ // Windows drive-letter paths (`C:\Users\...`, `C:/Users/...`) are local
+ // filesystem paths, not scp-like remotes — without this check the scp-like
+ // branch would parse `C:` as a host and emit the user's local path as a
+ // repo identity.
+ if (/^[a-zA-Z]:[\\/]/.test(trimmed)) return null
+
+ let host: string
+ let path: string
+
+ if (/^[a-zA-Z][a-zA-Z0-9+.-]*:\/\//.test(trimmed)) {
+ let parsed: URL
+ try {
+ parsed = new URL(trimmed)
+ } catch {
+ return null
+ }
+ if (parsed.protocol === 'file:') return null
+ if (!parsed.hostname) return null
+ host = parsed.hostname
+ path = parsed.pathname
+ } else {
+ // scp-like syntax: [user@]host:path. Credentials (userinfo) are split off
+ // at the FIRST `@` BEFORE any host matching — expressing userinfo as an
+ // optional regex group lets backtracking abandon the group and re-parse a
+ // credential prefix as `host:path`, dumping the token into the path
+ // (e.g. `x-access-token:ghp_…@github.com/org/repo`). Host must be at
+ // least 2 chars: a single-character "host" is a Windows drive-relative
+ // path (`C:repo`), never a real remote host.
+ const at = trimmed.indexOf('@')
+ const rest = at >= 0 ? trimmed.slice(at + 1) : trimmed
+ const scpLike = /^([^:/\\]{2,}):(?!\/\/)(.+)$/.exec(rest)
+ if (!scpLike) return null
+ host = scpLike[1]
+ path = scpLike[2]
+ }
+
+ const cleanPath = path
+ .replace(/\/+/g, '/') // collapse doubled slashes → one join key, not two
+ .replace(/^\/+/, '')
+ .replace(/\/+$/, '')
+ .replace(/\.git$/i, '') // case-insensitive: Repo.GIT joins with repo.git
+ if (!cleanPath) return null
+
+ const identity = `${host.toLowerCase()}/${cleanPath}`
+ if (identity.length > MAX_REPO_IDENTITY_LENGTH) return null
+ if (!isValidRepoIdentity(host.toLowerCase(), cleanPath.split('/'))) return null
+
+ return identity
+}
+
+/** `git remote get-url origin`, normalized. Null when absent or local-only. */
+function getRepoRemote(gitDir: string): string | null {
+ const url = runGit(['remote', 'get-url', 'origin'], gitDir)
+ return url ? normalizeRemoteUrl(url) : null
+}
+
+export type CommitInfo = {
sha: string
timestamp: Date
inMain: boolean
@@ -296,34 +387,39 @@ function categorizeSession(
return { category: 'abandoned', commitCount: commits.length }
}
-export async function computeYield(range: DateRange, cwd: string, provider: string = 'all'): Promise {
- const projects = await parseAllSessions(range, provider)
-
- const summary: YieldSummary = {
- productive: { cost: 0, sessions: 0 },
- reverted: { cost: 0, sessions: 0 },
- abandoned: { cost: 0, sessions: 0 },
- ambiguous: { cost: 0, sessions: 0 },
- total: { cost: 0, sessions: 0 },
- details: [],
- }
+type RepoGroup = {
+ commits: CommitInfo[]
+ sessions: SessionSummary[]
+ projectNames: string[]
+ /** Parallel to `sessions`: true when the session's identity came from its
+ * OWN project path; false when it inherited the cwd-fallback identity.
+ * The attribution (sync) path must never egress fallback-derived repos. */
+ ownIdentity: boolean[]
+ /** A directory to run further git queries in (remote lookup); null when the group has no git identity. */
+ gitDir: string | null
+}
+/**
+ * Group sessions by canonical repository identity and load each group's
+ * commits for the range. Shared by `computeYield` (categorization) and
+ * `computeAttributionRecords` (sync). Grouping semantics are unchanged from
+ * the original computeYield implementation: each commit is awarded at most
+ * once across the whole repo; monorepo subdirectories and worktrees collapse
+ * to one group; a project whose path is missing or not a git repo falls back
+ * to the cwd repo (or an empty commit list when cwd is not a repo either).
+ */
+function buildRepoGroups(
+ projects: ProjectSummary[],
+ range: DateRange,
+ cwd: string,
+): Map {
const repoIdentityCache = new Map()
- // Get all commits in the date range for correlation
const cwdIdentity = resolveRepoIdentity(cwd, repoIdentityCache)
const cwdCommits = cwdIdentity
? getCommitsInRange(cwd, range.start, range.end, getMainBranch(cwd))
: []
- // Group sessions by canonical repository identity before attributing so that
- // each commit is awarded at most once across the whole repo. Two monorepo
- // subdirectory sessions, or two worktrees of one repo, resolve to the same
- // git-common-dir and share ONE group; keying on the raw path would double
- // count. A project whose path is missing or not a git repo falls back to the
- // cwd repo (or, when cwd is not a repo either, an empty commit list) exactly
- // as before.
- type RepoGroup = { commits: CommitInfo[]; sessions: SessionSummary[]; projectNames: string[] }
const repoGroups = new Map()
for (const project of projects) {
const projectIdentity = project.projectPath
@@ -342,15 +438,35 @@ export async function computeYield(range: DateRange, cwd: string, provider: stri
: getCommitsInRange(identity.gitDir, range.start, range.end, getMainBranch(identity.gitDir)),
sessions: [],
projectNames: [],
+ ownIdentity: [],
+ gitDir: identity?.gitDir ?? null,
}
repoGroups.set(groupKey, group)
}
for (const session of project.sessions) {
group.sessions.push(session)
group.projectNames.push(project.project)
+ group.ownIdentity.push(projectIdentity !== null)
}
}
+ return repoGroups
+}
+
+export async function computeYield(range: DateRange, cwd: string, provider: string = 'all'): Promise {
+ const projects = await parseAllSessions(range, provider)
+
+ const summary: YieldSummary = {
+ productive: { cost: 0, sessions: 0 },
+ reverted: { cost: 0, sessions: 0 },
+ abandoned: { cost: 0, sessions: 0 },
+ ambiguous: { cost: 0, sessions: 0 },
+ total: { cost: 0, sessions: 0 },
+ details: [],
+ }
+
+ const repoGroups = buildRepoGroups(projects, range, cwd)
+
for (const group of repoGroups.values()) {
const attributions = attributeCommits(group.sessions, group.commits)
for (const [index, session] of group.sessions.entries()) {
@@ -446,3 +562,160 @@ export function buildYieldJsonReport(
})),
}
}
+
+// --- Sync attribution (codeburn sync push --attribution) ---
+
+export type CommitAttribution = {
+ sha: string
+ /** Commit author time, ISO 8601. */
+ timestamp: string
+ inMain: boolean
+ wasReverted: boolean
+}
+
+/**
+ * Per-session git attribution record — the sync-facing projection of the
+ * yield timestamp-window correlation. One record per session that produced
+ * server-joinable evidence: attributed commits (requires a normalized remote)
+ * and/or PR links.
+ */
+export type SessionAttributionRecord = {
+ sessionId: string
+ project: string
+ /** Normalized origin remote (`host/org/repo`), the server-side join key. Null when only prLinks are available. */
+ repo: string | null
+ /** GitHub PR URLs captured for the session (already normalized upstream). */
+ prLinks: string[]
+ /** Commits attributed to this session. Empty when repo is null (SHAs without a repo identity cannot be joined). */
+ commits: CommitAttribution[]
+ /** Session window, ISO 8601 — lets the receiver reason about attribution recency. */
+ firstTimestamp: string
+ lastTimestamp: string
+}
+
+/** Max PR links retained per session attribution record. */
+export const MAX_PR_LINKS_PER_SESSION = 20
+
+/**
+ * Shape-check PR links before they leave the machine. Upstream parsers only
+ * verify truthiness, so arbitrary strings can land in `session.prLinks`.
+ * Keep only https URLs shaped like a PR (`/org/repo/pull/N` — GitHub and
+ * GitHub Enterprise), bounded in length, capped per session, sorted.
+ *
+ * Links are REBUILT from `origin + pathname`, never passed through verbatim:
+ * userinfo (`https://alice:token@…`), query strings (copy-pasted GitHub
+ * links routinely carry `?notification_referrer_id=…`), and fragments are
+ * all dropped. Rebuilt links that collapse to the same URL dedupe.
+ */
+export function sanitizePrLinks(links: string[]): string[] {
+ const valid = new Set()
+ for (const link of links) {
+ if (typeof link !== 'string' || link.length === 0 || link.length > 512) continue
+ let url: URL
+ try {
+ url = new URL(link)
+ } catch {
+ continue
+ }
+ if (url.protocol !== 'https:') continue
+ if (!/^\/[^/]+\/[^/]+\/pull\/\d+$/.test(url.pathname)) continue
+ const rebuilt = `${url.origin}${url.pathname}`
+ if (rebuilt.length > 256) continue
+ valid.add(rebuilt)
+ }
+ return [...valid].sort().slice(0, MAX_PR_LINKS_PER_SESSION)
+}
+
+/**
+ * Compute per-session attribution records for sync. Reuses the exact yield
+ * repo grouping + tightest-window commit attribution (`methodology:
+ * timestamp-window`), then joins in each repo group's normalized origin
+ * remote and the session's sanitized PR links.
+ *
+ * Inclusion rules:
+ * - Only sessions whose OWN project path resolved to a repo participate in
+ * commit attribution; cwd-fallback sessions never carry a repo or commits
+ * (privacy gate — see below) but still emit a record when they have PR links.
+ * - Commits require a normalized remote; a SHA without a repo identity has
+ * no server-side meaning.
+ * - A session with no commits and no PR links is emitted ONLY when it lost a
+ * commit to a tighter-window session in THIS computation (`lostCandidacy`)
+ * — a retraction candidate. A session that merely aged its commits out of
+ * the range lost them to nobody, and emitting an empty record for it would
+ * permanently retract a still-correct server-side count.
+ *
+ * Takes already-parsed projects (sync push has them in hand) instead of
+ * re-parsing like computeYield does.
+ */
+export function computeAttributionRecords(
+ projects: ProjectSummary[],
+ range: DateRange,
+ cwd: string,
+): SessionAttributionRecord[] {
+ const repoGroups = buildRepoGroups(projects, range, cwd)
+ const records: SessionAttributionRecord[] = []
+
+ for (const group of repoGroups.values()) {
+ const remote = group.gitDir ? getRepoRemote(group.gitDir) : null
+
+ // Privacy gate: only sessions whose identity came from their OWN project
+ // path participate in commit attribution. A session whose project path no
+ // longer resolves (deleted/renamed dir, non-repo session) inherits the
+ // cwd-fallback identity in buildRepoGroups — attributing it here would
+ // egress whatever repo the user happens to be pushing from, with commits
+ // that session never touched. Fallback sessions get no repo and no
+ // commits; they still emit a record when they carry PR links (which are
+ // session-native and safe). Excluding them from the competition also
+ // prevents a fallback window from stealing a commit that belongs to a
+ // genuine session.
+ const ownSessions = group.sessions.filter((_, i) => group.ownIdentity[i])
+ const attributions = attributeCommits(ownSessions, group.commits)
+ // Keyed by object reference: session objects are unique per group entry,
+ // whereas sessionId strings could collide across projects.
+ const attributionBySession = new Map()
+ const lostCandidacyBySession = new Map()
+ for (const [i, session] of ownSessions.entries()) {
+ attributionBySession.set(session, attributions[i]?.commits ?? [])
+ lostCandidacyBySession.set(session, attributions[i]?.lostCandidacy ?? false)
+ }
+
+ for (const [index, session] of group.sessions.entries()) {
+ if (!session.firstTimestamp) continue
+
+ const isOwn = group.ownIdentity[index] === true
+ const sessionRemote = isOwn ? remote : null
+ const attributedCommits = sessionRemote
+ ? (attributionBySession.get(session) ?? [])
+ : []
+ const prLinks = sanitizePrLinks(session.prLinks ?? [])
+ // Empty sessions are retraction candidates ONLY when they lost a commit
+ // to a tighter-window session in THIS run: that commit's server-side
+ // attribution is migrating, so the loser must re-emit commit_count=0.
+ // An empty session whose commits merely aged out of the --since range
+ // (rolling window, or a narrower window than a previous push) lost them
+ // to NOBODY — emitting a retraction for it would permanently zero a
+ // still-correct server-side count, because the original state key stays
+ // ledgered and is never re-sent.
+ const lostToTighterSession = sessionRemote !== null &&
+ (lostCandidacyBySession.get(session) ?? false)
+ if (attributedCommits.length === 0 && prLinks.length === 0 && !lostToTighterSession) continue
+
+ records.push({
+ sessionId: session.sessionId,
+ project: group.projectNames[index] ?? session.project,
+ repo: sessionRemote,
+ prLinks,
+ commits: attributedCommits.map(c => ({
+ sha: c.sha,
+ timestamp: c.timestamp.toISOString(),
+ inMain: c.inMain,
+ wasReverted: c.wasReverted,
+ })),
+ firstTimestamp: session.firstTimestamp,
+ lastTimestamp: session.lastTimestamp ?? session.firstTimestamp,
+ })
+ }
+ }
+
+ return records
+}
diff --git a/tests/cache-refresh-lock-corrupt-body.test.ts b/tests/cache-refresh-lock-corrupt-body.test.ts
new file mode 100644
index 0000000..c4a7702
--- /dev/null
+++ b/tests/cache-refresh-lock-corrupt-body.test.ts
@@ -0,0 +1,121 @@
+import { spawn, type ChildProcess } from 'child_process'
+import { existsSync } from 'fs'
+import { mkdir, mkdtemp, readFile, rm, stat, writeFile } from 'fs/promises'
+import { tmpdir } from 'os'
+import { join } from 'path'
+import { afterEach, describe, expect, it } from 'vitest'
+
+import { acquireCacheRefreshLock } from '../src/cache-refresh-lock.js'
+
+// Recovering a corrupt session-refresh.lock must never cost the two design
+// commitments the lock exists for: it may not fail open into mutation, and it
+// may not be stolen from a live heartbeating owner. An earlier fix waived the
+// staleness gate for a corrupt body once the contender's wait expired, which
+// handed two processes the lock at the same time in both directions below.
+// Corruption is recovered only through the unmodified staleness gate.
+
+const roots: string[] = []
+afterEach(async () => {
+ while (roots.length) await rm(roots.pop()!, { recursive: true, force: true })
+})
+
+async function tempCase(prefix: string): Promise<{ cacheDir: string; barriers: string; lockPath: string }> {
+ const root = await mkdtemp(join(tmpdir(), prefix))
+ roots.push(root)
+ const cacheDir = join(root, 'cache')
+ const barriers = join(root, 'barriers')
+ await mkdir(cacheDir, { recursive: true })
+ await mkdir(barriers, { recursive: true })
+ return { cacheDir, barriers, lockPath: join(cacheDir, 'session-refresh.lock') }
+}
+
+function spawnFixture(fixture: string, args: string[], env: NodeJS.ProcessEnv = {}): ChildProcess {
+ return spawn(process.execPath, ['--import', 'tsx', join(process.cwd(), 'tests/fixtures', fixture), ...args], {
+ cwd: process.cwd(),
+ stdio: ['ignore', 'ignore', 'inherit'],
+ env: { ...process.env, ...env },
+ })
+}
+
+async function waitForFile(path: string, timeoutMs = 15_000): Promise {
+ const deadline = Date.now() + timeoutMs
+ while (!existsSync(path)) {
+ if (Date.now() > deadline) throw new Error(`timed out waiting for ${path}`)
+ await new Promise(resolve => { setTimeout(resolve, 5) })
+ }
+}
+
+const exited = (child: ChildProcess): Promise => new Promise(resolve => child.once('exit', resolve))
+
+describe('warm refresh lock: a corrupt body never displaces a live owner', () => {
+ it('leaves the zero-byte lock alone while its creator is still inside createExclusive', async () => {
+ const { cacheDir, barriers, lockPath } = await tempCase('cb-refresh-corrupt-race-')
+ // UV_THREADPOOL_SIZE=1 plus the fixture's pbkdf2 churn stretches the gap
+ // between open(path,'wx') and the awaited body write, so the lock is
+ // genuinely observable at zero bytes with no external corruption at all.
+ const owner = spawnFixture('cache-refresh-slow-owner.ts', [cacheDir, barriers], { UV_THREADPOOL_SIZE: '1' })
+ try {
+ const deadline = Date.now() + 20_000
+ let sawZeroByteLock = false
+ while (Date.now() < deadline) {
+ const info = await stat(lockPath).catch(() => null)
+ if (info) { sawZeroByteLock = info.size === 0; break }
+ await new Promise(resolve => { setTimeout(resolve, 1) })
+ }
+ expect(sawZeroByteLock, 'never caught the owner mid-createExclusive').toBe(true)
+
+ const contender = await acquireCacheRefreshLock({ cacheDir, waitMs: 40, pollMs: 5, staleMs: 90_000 })
+ if (contender.outcome === 'acquired') await contender.handle.release()
+ expect(contender.outcome).toBe('timed-out')
+ } finally {
+ await exited(owner)
+ }
+ // The owner kept the lock end to end, so its publication fence held.
+ expect(existsSync(join(barriers, 'owner.acquired'))).toBe(true)
+ expect(existsSync(join(barriers, 'owner.verify.true')), 'owner lost its own lock').toBe(true)
+ }, 60_000)
+
+ it('leaves a live owner alone after its body is truncated, however long the wait', async () => {
+ const { cacheDir, barriers, lockPath } = await tempCase('cb-refresh-corrupt-live-')
+ const owner = spawnFixture('cache-refresh-corrupt-owner.ts', [cacheDir, barriers, '4000', '100'])
+ try {
+ await waitForFile(join(barriers, 'owner.acquired'))
+ const ownerToken = await readFile(join(barriers, 'owner.acquired'), 'utf-8')
+
+ // The state a heartbeat leaves when writeFile() truncates and then fails
+ // (ENOSPC/EIO, swallowed by the heartbeat's own catch). No guard is held,
+ // and the body carries no token to compare against.
+ await writeFile(lockPath, '')
+ // This wait is shorter than one heartbeat period, so the body is still
+ // truncated throughout: the contender has nothing but the fresh mtime to
+ // go on, and that alone must keep it out.
+ const early = await acquireCacheRefreshLock({ cacheDir, waitMs: 80, pollMs: 5, staleMs: 90_000 })
+ if (early.outcome === 'acquired') await early.handle.release()
+ expect(early.outcome).toBe('timed-out')
+
+ // Past the age gate, the successor SHOULD win. Only the heartbeat
+ // advances mtime and it refuses to rewrite a body it cannot prove is
+ // ours, so a corrupt body freezes its own mtime and ages out. That is the
+ // intended end state, not a displacement to be prevented: an owner that
+ // cannot prove ownership must not publish, and the alternative — letting
+ // the heartbeat restamp its token over an unparseable body — resurrected
+ // legitimately-replaced writers and let release() delete a live
+ // successor's lock.
+ await writeFile(lockPath, '')
+ const late = await acquireCacheRefreshLock({ cacheDir, waitMs: 2_400, pollMs: 10, staleMs: 400 })
+ expect(late.outcome).toBe('acquired')
+ if (late.outcome === 'acquired') {
+ // The successor owns it outright: the body carries its token, not the
+ // original owner's.
+ expect(JSON.parse(await readFile(lockPath, 'utf-8')).token).not.toBe(ownerToken)
+ await late.handle.release()
+ }
+ } finally {
+ await exited(owner)
+ }
+ // The displaced owner's fence must refuse. Discarding its parse is the
+ // fail-safe direction; two writers believing they own the lock is not.
+ expect(existsSync(join(barriers, 'owner.verify.false')), 'displaced owner still passed its fence').toBe(true)
+ expect(existsSync(join(barriers, 'owner.verify.true'))).toBe(false)
+ }, 30_000)
+})
diff --git a/tests/cache-refresh-lock-process.test.ts b/tests/cache-refresh-lock-process.test.ts
index cf907c3..d837f81 100644
--- a/tests/cache-refresh-lock-process.test.ts
+++ b/tests/cache-refresh-lock-process.test.ts
@@ -85,6 +85,41 @@ describe('warm refresh child-process regression', () => {
await expect(stat(join(cacheDir, 'session-refresh.lock.takeover'))).rejects.toMatchObject({ code: 'ENOENT' })
})
+ it('gives exactly one contender ownership of a stale zero-byte lock', async () => {
+ const root = await mkdtemp(join(tmpdir(), 'cb-refresh-corrupt-'))
+ roots.push(root)
+ const cacheDir = join(root, 'cache')
+ const barriers = join(root, 'barriers')
+ await mkdir(cacheDir, { recursive: true })
+ await mkdir(barriers, { recursive: true })
+ process.env['CODEBURN_CACHE_DIR'] = cacheDir
+ const initial = emptyCache()
+ initial.complete = true
+ await saveCache(initial)
+ const corruptPath = join(cacheDir, 'session-refresh.lock')
+ await writeFile(corruptPath, '')
+ await utimes(corruptPath, new Date(1), new Date(1))
+ const source = join(root, 'changed.json')
+ await writeFile(source, JSON.stringify({ output: 404 }))
+
+ const a = worker(cacheDir, barriers, 'a', source)
+ const b = worker(cacheDir, barriers, 'b', source)
+ const winner = await Promise.race([
+ waitFor(join(barriers, 'a.parsed')).then(() => 'a'),
+ waitFor(join(barriers, 'b.parsed')).then(() => 'b'),
+ ])
+ const loser = winner === 'a' ? 'b' : 'a'
+ expect(Number(existsSync(join(barriers, 'a.parsed'))) + Number(existsSync(join(barriers, 'b.parsed')))).toBe(1)
+ const loserOutcome = await waitForAny(barriers, [
+ `${loser}.timed-out`, `${loser}.parsed`, `${loser}.completed-by-other`, `${loser}.unavailable`,
+ ])
+ expect(loserOutcome, (await readdir(barriers)).join(',')).toBe(`${loser}.timed-out`)
+ await writeFile(join(barriers, `${winner}.save`), '')
+ await Promise.all([waitForExit(a), waitForExit(b)])
+ await expect(stat(join(cacheDir, 'session-refresh.lock.takeover'))).rejects.toMatchObject({ code: 'ENOENT' })
+ expect(Object.keys((await loadCache()).providers['regression']?.files ?? {})).toEqual([source])
+ })
+
it('serializes disjoint parsed updates so the later publication cannot drop the first', async () => {
const root = await mkdtemp(join(tmpdir(), 'cb-refresh-process-'))
roots.push(root)
diff --git a/tests/cache-refresh-lock.test.ts b/tests/cache-refresh-lock.test.ts
index bb8e44b..4ba633d 100644
--- a/tests/cache-refresh-lock.test.ts
+++ b/tests/cache-refresh-lock.test.ts
@@ -1,5 +1,5 @@
import { afterEach, describe, expect, it } from 'vitest'
-import { mkdir, mkdtemp, readFile, rm, stat, unlink, utimes, writeFile } from 'fs/promises'
+import { chmod, mkdir, mkdtemp, readFile, rm, stat, unlink, utimes, writeFile } from 'fs/promises'
import { tmpdir } from 'os'
import { join } from 'path'
@@ -7,6 +7,7 @@ import {
acquireCacheRefreshLock,
type RefreshLockClock,
} from '../src/cache-refresh-lock.js'
+import { clearSessionCache, parseAllSessions } from '../src/parser.js'
import { emptyCache, loadCache, saveCache, sessionCachePath } from '../src/session-cache.js'
const dirs: string[] = []
@@ -223,3 +224,225 @@ describe('warm session-cache refresh lock', () => {
await rm(dir, { recursive: true, force: true })
})
})
+
+// A lock body that never parses into a record is a corrupt leftover, not an
+// unusable filesystem: classifying it as 'unavailable' routed every subsequent
+// refresh to the read-only path and froze ingestion permanently.
+describe('warm session-cache refresh lock: corrupt lock recovery', () => {
+ it('takes over a stale zero-byte lock', async () => {
+ const dir = await tempDir()
+ const clock = fakeClock(100_000)
+ const path = lockPath(dir)
+ await writeFile(path, '')
+ const old = new Date(1)
+ await utimes(path, old, old)
+
+ const result = await acquireCacheRefreshLock({ cacheDir: dir, clock, staleMs: 90, waitMs: 100, pollMs: 1 })
+ expect(result.outcome).toBe('acquired')
+ if (result.outcome !== 'acquired') return
+ expect(JSON.parse(await readFile(path, 'utf-8')).token).toBe(result.handle.token)
+ await result.handle.release()
+ await expect(stat(join(dir, 'session-refresh.lock.takeover'))).rejects.toMatchObject({ code: 'ENOENT' })
+ })
+
+ it('takes over a stale malformed-JSON lock', async () => {
+ const dir = await tempDir()
+ const clock = fakeClock(100_000)
+ const path = lockPath(dir)
+ await writeFile(path, '{"pid":1,"token":')
+ const old = new Date(1)
+ await utimes(path, old, old)
+
+ const result = await acquireCacheRefreshLock({ cacheDir: dir, clock, staleMs: 90, waitMs: 100, pollMs: 1 })
+ expect(result.outcome).toBe('acquired')
+ if (result.outcome !== 'acquired') return
+ expect(JSON.parse(await readFile(path, 'utf-8')).token).toBe(result.handle.token)
+ await result.handle.release()
+ })
+
+ it('takes over a stale lock whose body parses but has the wrong shape', async () => {
+ const dir = await tempDir()
+ const clock = fakeClock(100_000)
+ const path = lockPath(dir)
+ await writeFile(path, JSON.stringify({ pid: 'one', token: 7 }))
+ const old = new Date(1)
+ await utimes(path, old, old)
+
+ const result = await acquireCacheRefreshLock({ cacheDir: dir, clock, staleMs: 90, waitMs: 100, pollMs: 1 })
+ expect(result.outcome).toBe('acquired')
+ if (result.outcome === 'acquired') await result.handle.release()
+ })
+
+ // Staleness is never waived for a corrupt body, so a FRESH one is waited out
+ // and left alone: it may belong to a live owner whose heartbeat is about to
+ // repair it. The resulting freeze is bounded by staleMs rather than
+ // permanent, which is the whole of the reported defect.
+ it('waits out a fresh malformed lock, then recovers it once it ages past staleMs', async () => {
+ const dir = await tempDir()
+ const clock = fakeClock(100_000)
+ const path = lockPath(dir)
+ await writeFile(path, '')
+ const now = new Date(clock.wallNow())
+ await utimes(path, now, now)
+
+ let polls = 0
+ const fresh = await acquireCacheRefreshLock({
+ cacheDir: dir,
+ clock,
+ staleMs: 90_000,
+ waitMs: 50,
+ pollMs: 10,
+ sleep: async ms => { polls++; clock.advance(ms) },
+ })
+ expect(polls).toBeGreaterThan(0)
+ expect(fresh).toEqual({ outcome: 'timed-out' })
+ expect(await readFile(path, 'utf-8')).toBe('')
+
+ // Nothing rewrote the body, so its mtime is still frozen. One stale window
+ // later the very next run recovers it through the unmodified age gate.
+ clock.advance(90_001)
+ const later = await acquireCacheRefreshLock({ cacheDir: dir, clock, staleMs: 90_000, waitMs: 50, pollMs: 10 })
+ expect(later.outcome).toBe('acquired')
+ if (later.outcome !== 'acquired') return
+ expect(JSON.parse(await readFile(path, 'utf-8')).token).toBe(later.handle.token)
+ await later.handle.release()
+ })
+
+ it('never steals a fresh malformed lock from an owner that repairs it mid-wait', async () => {
+ const dir = await tempDir()
+ const clock = fakeClock(100_000)
+ const path = lockPath(dir)
+ await writeFile(path, '')
+ const now = new Date(clock.wallNow())
+ await utimes(path, now, now)
+
+ let polls = 0
+ const result = await acquireCacheRefreshLock({
+ cacheDir: dir,
+ clock,
+ staleMs: 90_000,
+ waitMs: 50,
+ pollMs: 10,
+ sleep: async ms => {
+ if (++polls === 1) {
+ await writeFile(path, JSON.stringify({ pid: 1, token: 'holder', at: clock.wallNow() }))
+ const t = new Date(clock.wallNow())
+ await utimes(path, t, t)
+ }
+ clock.advance(ms)
+ },
+ })
+ expect(result).toEqual({ outcome: 'timed-out' })
+ expect(JSON.parse(await readFile(path, 'utf-8')).token).toBe('holder')
+ })
+
+ it('treats a body truncated mid-heartbeat as contention, not corruption', async () => {
+ const dir = await tempDir()
+ const path = lockPath(dir)
+ const record = (): string => JSON.stringify({ pid: 1, token: 'holder', at: Date.now() })
+ await writeFile(path, record())
+ // A real heartbeat rewrite exposes a zero-length body for an instant. The
+ // waiter must keep polling and leave the live owner alone.
+ const heartbeat = setInterval(() => {
+ void (async () => {
+ try {
+ await writeFile(path, '')
+ await writeFile(path, record())
+ } catch { /* the winner may have replaced the file */ }
+ })()
+ }, 1)
+ let result
+ try {
+ result = await acquireCacheRefreshLock({ cacheDir: dir, staleMs: 30, waitMs: 120, pollMs: 5 })
+ } finally {
+ clearInterval(heartbeat)
+ }
+ await new Promise(resolve => { setTimeout(resolve, 20) })
+ expect(result).toEqual({ outcome: 'timed-out' })
+ expect(JSON.parse(await readFile(path, 'utf-8')).token).toBe('holder')
+ })
+
+ // The sidecar is created by the same createExclusive as the lock, so it can be
+ // left 0-byte by exactly the same crash. Before observe() separated corrupt
+ // from unreadable this returned 'unavailable' from acquireTakeoverGuard and
+ // froze recovery even when the primary lock was perfectly fine.
+ it('reclaims a stale zero-byte takeover sidecar', async () => {
+ const dir = await tempDir()
+ const path = lockPath(dir)
+ const sidecar = join(dir, 'session-refresh.lock.takeover')
+ await writeFile(path, '')
+ await writeFile(sidecar, '')
+ const old = new Date(1)
+ await utimes(path, old, old)
+ await utimes(sidecar, old, old)
+
+ const result = await acquireCacheRefreshLock({ cacheDir: dir, staleMs: 90, waitMs: 200, pollMs: 5 })
+ expect(result.outcome).toBe('acquired')
+ if (result.outcome !== 'acquired') return
+ expect(JSON.parse(await readFile(path, 'utf-8')).token).toBe(result.handle.token)
+ await result.handle.release()
+ await expect(stat(sidecar)).rejects.toMatchObject({ code: 'ENOENT' })
+ })
+
+ it('still reports unavailable when the lock body cannot be read', async () => {
+ if (process.getuid?.() === 0) return
+ const dir = await tempDir()
+ const path = lockPath(dir)
+ await writeFile(path, '')
+ await chmod(path, 0o000)
+ const old = new Date(1)
+ await utimes(path, old, old)
+ try {
+ const result = await acquireCacheRefreshLock({ cacheDir: dir, staleMs: 1, waitMs: 50, pollMs: 5 })
+ expect(result).toEqual({ outcome: 'unavailable' })
+ } finally {
+ await chmod(path, 0o600)
+ }
+ })
+
+ it('resumes ingesting changed sources after a corrupt lock froze the refresh', async () => {
+ const root = await tempDir()
+ const cacheDir = join(root, 'cache')
+ const config = join(root, 'claude')
+ const projectDir = join(config, 'projects', 'frozen-proj')
+ await mkdir(cacheDir, { recursive: true })
+ await mkdir(projectDir, { recursive: true })
+ process.env['CODEBURN_CACHE_DIR'] = cacheDir
+ process.env['CLAUDE_CONFIG_DIR'] = config
+ process.env['CODEBURN_DESKTOP_SESSIONS_DIR'] = join(root, 'desktop-sessions')
+
+ const session = (id: string, ts: string): string => [
+ JSON.stringify({ type: 'user', sessionId: id, timestamp: ts, cwd: '/tmp/frozen-proj', message: { role: 'user', content: 'hi' } }),
+ JSON.stringify({
+ type: 'assistant', sessionId: id, timestamp: ts, cwd: '/tmp/frozen-proj',
+ message: { id: `msg-${id}`, type: 'message', role: 'assistant', model: 'claude-sonnet-4-5', content: [], usage: { input_tokens: 100, output_tokens: 20 } },
+ }),
+ ].join('\n') + '\n'
+
+ await writeFile(join(projectDir, 'sess-1.jsonl'), session('sess-1', '2026-05-01T10:00:00.000Z'))
+ clearSessionCache()
+ const warm = await parseAllSessions()
+ expect(warm[0]?.sessions.map(s => s.sessionId)).toEqual(['sess-1'])
+
+ // The field state: a 0-byte lock AND the takeover sidecar of the dead owner
+ // that was mid-recovery when it died, both older than the stale window.
+ // Nothing else on the machine ever repairs either file, so on every later
+ // run the lock read as 'unavailable', the parser fell back to a read-only
+ // re-parse, and sess-2 was never ingested.
+ const old = new Date(1)
+ for (const name of ['session-refresh.lock', 'session-refresh.lock.takeover']) {
+ await writeFile(join(cacheDir, name), name.endsWith('.takeover') ? JSON.stringify({ pid: 999_999, token: 'dead-owner', at: 1 }) : '')
+ await utimes(join(cacheDir, name), old, old)
+ }
+
+ await writeFile(join(projectDir, 'sess-2.jsonl'), session('sess-2', '2026-05-01T11:00:00.000Z'))
+ clearSessionCache()
+ const after = await parseAllSessions()
+ expect(after[0]?.sessions.map(s => s.sessionId).sort()).toEqual(['sess-1', 'sess-2'])
+ expect(Object.keys((await loadCache()).providers['claude']?.files ?? {}).length).toBe(2)
+
+ clearSessionCache()
+ delete process.env['CLAUDE_CONFIG_DIR']
+ delete process.env['CODEBURN_DESKTOP_SESSIONS_DIR']
+ })
+})
diff --git a/tests/cli-codex-tps.test.ts b/tests/cli-codex-tps.test.ts
new file mode 100644
index 0000000..4b39027
--- /dev/null
+++ b/tests/cli-codex-tps.test.ts
@@ -0,0 +1,46 @@
+import { mkdtemp, rm } from 'node:fs/promises'
+import { tmpdir } from 'node:os'
+import { join } from 'node:path'
+import { spawnSync } from 'node:child_process'
+import { afterEach, describe, expect, it } from 'vitest'
+
+const homes: string[] = []
+
+afterEach(async () => {
+ while (homes.length) await rm(homes.pop()!, { recursive: true, force: true })
+})
+
+function runCli(args: string[], home: string) {
+ return spawnSync(process.execPath, ['--import', 'tsx', 'src/cli.ts', ...args], {
+ cwd: process.cwd(),
+ env: { ...process.env, HOME: home, CODEX_HOME: join(home, '.codex'), TZ: 'UTC' },
+ encoding: 'utf-8',
+ timeout: 30_000,
+ })
+}
+
+describe('codex-tps CLI validation', () => {
+ it('rejects sub-second watch intervals', async () => {
+ const home = await mkdtemp(join(tmpdir(), 'codeburn-tps-cli-'))
+ homes.push(home)
+ const result = runCli(['codex-tps', '--watch', '0.1'], home)
+ expect(result.status).toBe(1)
+ expect(result.stderr).toContain('watch must be 0 or at least 1 second')
+ })
+
+ it('rejects JSON watch output instead of concatenating invalid JSON documents', async () => {
+ const home = await mkdtemp(join(tmpdir(), 'codeburn-tps-cli-'))
+ homes.push(home)
+ const result = runCli(['codex-tps', '--json', '--watch', '1'], home)
+ expect(result.status).toBe(2)
+ expect(result.stderr).toContain('--json cannot be combined with --watch')
+ })
+
+ it('returns a nonzero status for a missing explicit rollout', async () => {
+ const home = await mkdtemp(join(tmpdir(), 'codeburn-tps-cli-'))
+ homes.push(home)
+ const result = runCli(['codex-tps', join(home, 'missing.jsonl')], home)
+ expect(result.status).toBe(1)
+ expect(result.stderr).toContain('session file not found')
+ })
+})
diff --git a/tests/cli-durable-totals.test.ts b/tests/cli-durable-totals.test.ts
index 82f1e44..8a19b65 100644
--- a/tests/cli-durable-totals.test.ts
+++ b/tests/cli-durable-totals.test.ts
@@ -1,4 +1,4 @@
-import { afterEach, beforeAll, beforeEach, describe, expect, it } from 'vitest'
+import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
import { mkdir, rm, writeFile } from 'fs/promises'
import { existsSync } from 'fs'
import { tmpdir } from 'os'
@@ -13,7 +13,7 @@ import {
buildPeriodData,
getDailyCacheConfigHash,
} from '../src/usage-aggregator.js'
-import { parseAllSessions, filterProjectsByName, clearSessionCache } from '../src/parser.js'
+import { parseAllSessions, filterProjectsByName, filterProjectsByDateRange, filterProjectsByDays, clearSessionCache } from '../src/parser.js'
import { renderOverview } from '../src/overview.js'
import type { DateRange } from '../src/types.js'
@@ -28,7 +28,7 @@ import type { DateRange } from '../src/types.js'
// queries, and in the plain live regime with no carried days at all.
const ROOT = join(tmpdir(), `codeburn-durable-totals-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`)
-const ENV_KEYS = ['HOME', 'CODEBURN_CACHE_DIR', 'CLAUDE_CONFIG_DIR', 'CLAUDE_CONFIG_DIRS', 'CODEX_HOME'] as const
+const ENV_KEYS = ['HOME', 'CODEBURN_CACHE_DIR', 'CLAUDE_CONFIG_DIR', 'CLAUDE_CONFIG_DIRS', 'CODEX_HOME', 'USERPROFILE', 'KIMI_CODE_HOME', 'CODEBURN_DESKTOP_SESSIONS_DIR'] as const
let savedEnv: Record
const CARRIED_COST = 100
@@ -120,11 +120,22 @@ beforeEach(async () => {
savedEnv = Object.fromEntries(ENV_KEYS.map(k => [k, process.env[k]]))
await mkdir(join(ROOT, 'home', '.claude'), { recursive: true })
await mkdir(join(ROOT, 'cache'), { recursive: true })
+ await mkdir(join(ROOT, 'no-desktop-sessions'), { recursive: true })
+ await mkdir(join(ROOT, 'no-kimi-home'), { recursive: true })
process.env['HOME'] = join(ROOT, 'home')
process.env['CODEBURN_CACHE_DIR'] = join(ROOT, 'cache')
process.env['CLAUDE_CONFIG_DIR'] = join(ROOT, 'home', '.claude')
delete process.env['CLAUDE_CONFIG_DIRS']
delete process.env['CODEX_HOME']
+ // Keep real provider data on the machine out of every parse: absolute-count
+ // assertions are meaningless when the host's own sessions leak in.
+ // USERPROFILE matters on Windows, where os.homedir() ignores HOME;
+ // KIMI_CODE_HOME / the desktop-sessions override redirect the two env-aware
+ // discovery roots. (The codex provider captures its home at import time, so
+ // it is redirected separately in vi.hoisted below.)
+ process.env['USERPROFILE'] = join(ROOT, 'home')
+ process.env['KIMI_CODE_HOME'] = join(ROOT, 'no-kimi-home')
+ process.env['CODEBURN_DESKTOP_SESSIONS_DIR'] = join(ROOT, 'no-desktop-sessions')
clearSessionCache()
})
@@ -259,3 +270,156 @@ describe('terminal overview carried-day footnote', () => {
expect(noCarried).not.toContain('preserved from expired session logs')
})
})
+
+// Issue #852 review: per-call slicing is only conservation-correct when day
+// bucketing attributes call-derived values to each call's own day. These
+// tests pin the straddling-turn case the review reproduced end-to-end through
+// buildDurablePeriod: a turn starting the previous day at 23:57 with one call
+// before and one after local midnight must keep BOTH calls across a multi-day
+// period (cache ≤ yesterday + live today union), each on its own day.
+//
+// The codex provider captures CODEX_HOME when its module is first imported,
+// so the redirect must happen before module evaluation (vi.hoisted) rather
+// than in beforeEach. The captured dir is per-test-process and empty except
+// for the fixture written below, which also shields the suite from any real
+// ~/.codex on the machine running it.
+const CODEX_ROOT = vi.hoisted(() => {
+ const root = `${process.env['TMPDIR'] || '/tmp'}/codeburn-straddle-codex-${process.pid}-${Date.now()}`
+ process.env['CODEX_HOME'] = `${root}/codex`
+ return root
+})
+
+describe('midnight-straddling turn conservation (issue #852)', () => {
+ // Day N = 2026-07-27, day N+1 ("today") = 2026-07-28 — LOCAL dates built
+ // from constructor args so the case is machine-TZ independent (dateKey /
+ // toDateString use the same local getters).
+ const NOW = new Date(2026, 6, 28, 12, 0, 0)
+ const DAY_N = '2026-07-27'
+ const DAY_N1 = '2026-07-28'
+
+ function fakeNow(): void {
+ // Date only: the daily-cache lock and retry helpers must keep real timers.
+ vi.useFakeTimers({ toFake: ['Date'] })
+ vi.setSystemTime(NOW)
+ }
+
+ beforeEach(async () => {
+ await rm(CODEX_ROOT, { recursive: true, force: true })
+ })
+
+ afterEach(async () => {
+ await rm(CODEX_ROOT, { recursive: true, force: true })
+ })
+
+ // One codex turn (the issue's provider) with a token_count call on each
+ // side of local midnight: 23:58 (input 1000/output 200), 00:10 (2000/400).
+ async function seedStraddlingCodexTurn(): Promise {
+ const sessionDir = join(CODEX_ROOT, 'codex', 'sessions', '2026', '07', '27')
+ await mkdir(sessionDir, { recursive: true })
+ const line = (obj: unknown): string => JSON.stringify(obj)
+ await writeFile(join(sessionDir, 'rollout-straddle.jsonl'), [
+ line({ type: 'session_meta', timestamp: new Date(2026, 6, 27, 23, 55, 0).toISOString(), payload: { session_id: 'sess-straddle', model: 'gpt-5.5', cwd: '/tmp/straddle-proj', originator: 'codex_cli_rs' } }),
+ line({ type: 'response_item', timestamp: new Date(2026, 6, 27, 23, 57, 0).toISOString(), payload: { type: 'message', role: 'user', content: [{ type: 'input_text', text: 'work through midnight' }] } }),
+ line({ type: 'event_msg', timestamp: new Date(2026, 6, 27, 23, 58, 0).toISOString(), payload: { type: 'token_count', info: { last_token_usage: { input_tokens: 1000, output_tokens: 200 }, total_token_usage: { total_tokens: 1200 } } } }),
+ line({ type: 'event_msg', timestamp: new Date(2026, 6, 28, 0, 10, 0).toISOString(), payload: { type: 'token_count', info: { last_token_usage: { input_tokens: 2000, output_tokens: 400 }, total_token_usage: { total_tokens: 3600 } } } }),
+ ].join('\n') + '\n', 'utf-8')
+ }
+
+ // The same straddle through the Claude Code path (scanProjectDirs).
+ async function seedStraddlingClaudeTurn(): Promise {
+ const projectDir = join(ROOT, 'home', '.claude', 'projects', 'straddle-proj')
+ await mkdir(projectDir, { recursive: true })
+ const line = (obj: unknown): string => JSON.stringify(obj)
+ await writeFile(join(projectDir, 's-straddle.jsonl'), [
+ line({ type: 'user', sessionId: 's-straddle', timestamp: new Date(2026, 6, 27, 23, 57, 0).toISOString(), cwd: '/tmp/straddle-proj', message: { role: 'user', content: 'work through midnight' } }),
+ line({ type: 'assistant', sessionId: 's-straddle', timestamp: new Date(2026, 6, 27, 23, 58, 0).toISOString(), cwd: '/tmp/straddle-proj', message: { id: 'm1', type: 'message', role: 'assistant', model: 'claude-3-5-sonnet-20241022', content: [], usage: { input_tokens: 1000, output_tokens: 200, cache_creation_input_tokens: 0, cache_read_input_tokens: 0 } } }),
+ line({ type: 'assistant', sessionId: 's-straddle', timestamp: new Date(2026, 6, 28, 0, 10, 0).toISOString(), cwd: '/tmp/straddle-proj', message: { id: 'm2', type: 'message', role: 'assistant', model: 'claude-3-5-sonnet-20241022', content: [], usage: { input_tokens: 2000, output_tokens: 400, cache_creation_input_tokens: 0, cache_read_input_tokens: 0 } } }),
+ ].join('\n') + '\n', 'utf-8')
+ }
+
+ // The two calls' exact costs from an unfiltered parse (pricing-agnostic truth).
+ async function truthCosts(provider: string): Promise<[number, number]> {
+ clearSessionCache()
+ const projects = await parseAllSessions(undefined, provider)
+ const calls = projects.flatMap(p => p.sessions).flatMap(s => s.turns).flatMap(t => t.assistantCalls)
+ expect(calls).toHaveLength(2)
+ return [calls[0]!.costUSD, calls[1]!.costUSD]
+ }
+
+ it('keeps day-N + day-N+1 equal to the whole-range totals through buildDurablePeriod', async () => {
+ fakeNow()
+ try {
+ await seedStraddlingCodexTurn()
+ const [costN, costN1] = await truthCosts('codex')
+ expect(costN + costN1).toBeGreaterThan(0)
+
+ const range: DateRange = { start: new Date(2026, 6, 27, 0, 0, 0), end: new Date() }
+ clearSessionCache()
+ const durable = await buildDurablePeriod({ range, label: '2d' }, { provider: 'all' })
+
+ const dayN = durable.days.find(d => d.date === DAY_N)
+ const dayN1 = durable.days.find(d => d.date === DAY_N1)
+ // Each side of the turn lands on its own day...
+ expect(dayN?.calls).toBe(1)
+ expect(dayN1?.calls).toBe(1)
+ expect(dayN!.cost).toBeCloseTo(costN, 8)
+ expect(dayN1!.cost).toBeCloseTo(costN1, 8)
+ // ...and the two sides conserve the whole-range totals (the review's
+ // week/month leak returned 1 call and only the pre-midnight cost).
+ expect(durable.data.calls).toBe(2)
+ expect(dayN!.calls + dayN1!.calls).toBe(durable.data.calls)
+ expect(durable.data.cost).toBeCloseTo(costN + costN1, 8)
+ expect(dayN!.cost + dayN1!.cost).toBeCloseTo(durable.data.cost, 8)
+ expect(durable.data.inputTokens).toBe(3000)
+ expect(durable.data.outputTokens).toBe(600)
+ expect(dayN!.inputTokens + dayN1!.inputTokens).toBe(durable.data.inputTokens)
+ expect(dayN!.outputTokens + dayN1!.outputTokens).toBe(durable.data.outputTokens)
+ } finally {
+ vi.useRealTimers()
+ }
+ }, 60_000)
+
+ it('shows the post-midnight call in the today-only view on the Claude Code path', async () => {
+ fakeNow()
+ try {
+ await seedStraddlingClaudeTurn()
+ const [, costN1] = await truthCosts('claude')
+
+ clearSessionCache()
+ const durable = await buildDurablePeriod({ range: getDateRange('today').range, label: 'today' }, { provider: 'all' })
+ expect(durable.data.calls).toBe(1)
+ expect(durable.data.cost).toBeCloseTo(costN1, 8)
+ expect(durable.data.inputTokens).toBe(2000)
+ expect(durable.data.outputTokens).toBe(400)
+ } finally {
+ vi.useRealTimers()
+ }
+ }, 60_000)
+
+ it('slices the straddling turn per call in the dashboard/menubar surface filters', async () => {
+ fakeNow()
+ try {
+ await seedStraddlingClaudeTurn()
+ clearSessionCache()
+ const all = await parseAllSessions(undefined, 'claude')
+ const callsOf = (ps: typeof all) => ps.flatMap(p => p.sessions).flatMap(s => s.turns).flatMap(t => t.assistantCalls)
+ const inputOf = (ps: typeof all) => ps.flatMap(p => p.sessions).reduce((s, sess) => s + sess.totalInputTokens, 0)
+
+ // Dashboard Today/7-Days narrowing over an unfiltered parse.
+ const dashToday = filterProjectsByDateRange(all, getDateRange('today').range)
+ expect(callsOf(dashToday)).toHaveLength(1)
+ expect(inputOf(dashToday)).toBe(2000)
+
+ // Menubar/history day selection, on each side of midnight.
+ const menubarToday = filterProjectsByDays(all, new Set([DAY_N1]))
+ expect(callsOf(menubarToday)).toHaveLength(1)
+ expect(inputOf(menubarToday)).toBe(2000)
+
+ const menubarYesterday = filterProjectsByDays(all, new Set([DAY_N]))
+ expect(callsOf(menubarYesterday)).toHaveLength(1)
+ expect(inputOf(menubarYesterday)).toBe(1000)
+ } finally {
+ vi.useRealTimers()
+ }
+ }, 60_000)
+})
diff --git a/tests/codex-throughput.test.ts b/tests/codex-throughput.test.ts
new file mode 100644
index 0000000..1e0fd4d
--- /dev/null
+++ b/tests/codex-throughput.test.ts
@@ -0,0 +1,124 @@
+import { appendFile, mkdtemp, writeFile } from 'node:fs/promises'
+import { tmpdir } from 'node:os'
+import { join } from 'node:path'
+import { describe, expect, it } from 'vitest'
+import { CodexThroughputReader, readCodexThroughput, renderCodexThroughput } from '../src/codex-throughput.js'
+
+describe('Codex throughput prototype', () => {
+ it('estimates generated tokens/sec between token_count checkpoints', async () => {
+ const dir = await mkdtemp(join(tmpdir(), 'codeburn-tps-'))
+ const path = join(dir, 'rollout.jsonl')
+ await writeFile(path, [
+ JSON.stringify({ type: 'session_meta', timestamp: '2026-07-25T00:00:00.000Z', payload: { model: 'gpt-5.6-sol' } }),
+ JSON.stringify({ type: 'event_msg', timestamp: '2026-07-25T00:00:00.000Z', payload: { type: 'task_started' } }),
+ JSON.stringify({ type: 'response_item', timestamp: '2026-07-25T00:00:02.000Z', payload: { type: 'function_call', call_id: 'tool-1' } }),
+ JSON.stringify({ type: 'response_item', timestamp: '2026-07-25T00:00:05.000Z', payload: { type: 'function_call_output', call_id: 'tool-1' } }),
+ JSON.stringify({ type: 'event_msg', timestamp: '2026-07-25T00:00:05.000Z', payload: { type: 'mcp_tool_call_end', duration: { secs: 3, nanos: 0 } } }),
+ JSON.stringify({ type: 'event_msg', timestamp: '2026-07-25T00:00:10.000Z', payload: { type: 'token_count', info: { last_token_usage: { output_tokens: 80, reasoning_output_tokens: 20 }, total_token_usage: { total_tokens: 100, output_tokens: 80, reasoning_output_tokens: 20 } } } }),
+ JSON.stringify({ type: 'event_msg', timestamp: '2026-07-25T00:00:15.000Z', payload: { type: 'token_count', info: { last_token_usage: { output_tokens: 40, reasoning_output_tokens: 10 }, total_token_usage: { total_tokens: 150, output_tokens: 120, reasoning_output_tokens: 30 } } } }),
+ JSON.stringify({ type: 'event_msg', timestamp: '2026-07-25T00:00:16.000Z', payload: { type: 'task_complete', duration_ms: 10000 } }),
+ ].join('\n'))
+
+ const points = await readCodexThroughput(path)
+ expect(points).toHaveLength(2)
+ expect(points[1]).toMatchObject({ generatedTokens: 50, elapsedSeconds: 5, generatedTokensPerSecond: 10, activeDurationSeconds: 7, activeGeneratedTokensPerSecond: 21.428571428571427, toolWaitSeconds: 3, model: 'gpt-5.6-sol' })
+ expect(renderCodexThroughput(points, path)).toContain('21.4 generated tokens/sec')
+ })
+
+ it('parses only appended complete lines while watching a growing rollout', async () => {
+ const dir = await mkdtemp(join(tmpdir(), 'codeburn-tps-watch-'))
+ const path = join(dir, 'rollout.jsonl')
+ const first = JSON.stringify({ type: 'event_msg', timestamp: '2026-07-25T00:00:00.000Z', payload: { type: 'token_count', info: { last_token_usage: { output_tokens: 8, reasoning_output_tokens: 2 }, total_token_usage: { total_tokens: 10, output_tokens: 8, reasoning_output_tokens: 2 } } } })
+ const second = JSON.stringify({ type: 'event_msg', timestamp: '2026-07-25T00:00:01.000Z', payload: { type: 'token_count', info: { last_token_usage: { output_tokens: 4, reasoning_output_tokens: 1 }, total_token_usage: { total_tokens: 15, output_tokens: 12, reasoning_output_tokens: 3 } } } })
+ await writeFile(path, first.slice(0, 40))
+ const reader = new CodexThroughputReader()
+ expect(await reader.update(path)).toEqual([])
+ await appendFile(path, first.slice(40) + '\n' + second + '\n')
+ const points = await reader.update(path)
+ expect(points).toHaveLength(2)
+ expect(points[1]).toMatchObject({ generatedTokens: 5, generatedTokensPerSecond: 5 })
+ })
+
+ it('ignores replayed pre-fork checkpoints before estimating new work', async () => {
+ const dir = await mkdtemp(join(tmpdir(), 'codeburn-tps-fork-'))
+ const path = join(dir, 'rollout.jsonl')
+ const line = (timestamp: string, payload: Record) => JSON.stringify({ type: 'event_msg', timestamp, payload })
+ await writeFile(path, [
+ JSON.stringify({ type: 'session_meta', timestamp: '2026-07-25T00:00:00.000Z', payload: { model: 'gpt-5.6-sol', forked_from_id: 'parent' } }),
+ line('2026-07-25T00:00:01.000Z', { type: 'task_started' }),
+ line('2026-07-25T00:00:02.000Z', { type: 'token_count', info: { last_token_usage: { output_tokens: 100 }, total_token_usage: { total_tokens: 100, output_tokens: 100 } } }),
+ line('2026-07-25T00:00:03.000Z', { type: 'task_complete', duration_ms: 1000 }),
+ line('2026-07-25T00:00:06.000Z', { type: 'task_started' }),
+ line('2026-07-25T00:00:08.000Z', { type: 'token_count', info: { last_token_usage: { output_tokens: 20 }, total_token_usage: { total_tokens: 20, output_tokens: 20 } } }),
+ line('2026-07-25T00:00:10.000Z', { type: 'task_complete', duration_ms: 4000 }),
+ ].join('\n'))
+
+ const points = await readCodexThroughput(path)
+ expect(points).toHaveLength(1)
+ expect(points[0]).toMatchObject({ generatedTokens: 20, activeGeneratedTokensPerSecond: 5 })
+ })
+
+ it('keeps oversized rollout lines bounded while extracting token usage', async () => {
+ const dir = await mkdtemp(join(tmpdir(), 'codeburn-tps-large-'))
+ const path = join(dir, 'rollout.jsonl')
+ const largeResult = JSON.stringify({
+ type: 'event_msg',
+ timestamp: '2026-07-25T00:00:01.000Z',
+ payload: {
+ type: 'token_count',
+ info: { last_token_usage: { output_tokens: 12 }, total_token_usage: { total_tokens: 12, output_tokens: 12 } },
+ result: 'x'.repeat(5 * 1024 * 1024),
+ },
+ })
+ await writeFile(path, largeResult)
+ const points = await readCodexThroughput(path)
+ expect(points).toHaveLength(1)
+ expect(points[0]?.generatedTokens).toBe(12)
+ })
+
+ it('keeps MCP duration when arguments and result surround the middle field', async () => {
+ const dir = await mkdtemp(join(tmpdir(), 'codeburn-tps-mcp-large-'))
+ const path = join(dir, 'rollout.jsonl')
+ const mcp = JSON.stringify({
+ type: 'event_msg', timestamp: '2026-07-25T00:00:05.000Z',
+ payload: {
+ type: 'mcp_tool_call_end',
+ invocation: { server: 'github', tool: 'get_issue', arguments: { body: 'x'.repeat(5 * 1024 * 1024) } },
+ duration: { secs: 3, nanos: 0 },
+ result: { duration: '9s', text: 'x'.repeat(5 * 1024 * 1024) },
+ },
+ })
+ await writeFile(path, [
+ JSON.stringify({ type: 'session_meta', timestamp: '2026-07-25T00:00:00.000Z', payload: { model: 'gpt-5.6-sol' } }),
+ JSON.stringify({ type: 'event_msg', timestamp: '2026-07-25T00:00:00.000Z', payload: { type: 'task_started' } }),
+ mcp,
+ JSON.stringify({ type: 'event_msg', timestamp: '2026-07-25T00:00:08.000Z', payload: { type: 'token_count', info: { last_token_usage: { output_tokens: 100 }, total_token_usage: { total_tokens: 100, output_tokens: 100 } } } }),
+ JSON.stringify({ type: 'event_msg', timestamp: '2026-07-25T00:00:10.000Z', payload: { type: 'task_complete', duration_ms: 10000 } }),
+ ].join('\n'))
+ const points = await readCodexThroughput(path)
+ expect(points[0]).toMatchObject({ toolWaitSeconds: 3, activeDurationSeconds: 7, activeGeneratedTokensPerSecond: 100 / 7 })
+ })
+
+ it('keeps a streamed string MCP duration when arguments and result surround the middle field', async () => {
+ const dir = await mkdtemp(join(tmpdir(), 'codeburn-tps-mcp-string-large-'))
+ const path = join(dir, 'rollout.jsonl')
+ const mcp = JSON.stringify({
+ type: 'event_msg', timestamp: '2026-07-25T00:00:05.000Z',
+ payload: {
+ type: 'mcp_tool_call_end',
+ invocation: { server: 'github', tool: 'get_issue', arguments: { body: 'x'.repeat(5 * 1024 * 1024) } },
+ duration: '3s',
+ result: { duration: '9s', text: 'x'.repeat(5 * 1024 * 1024) },
+ },
+ })
+ await writeFile(path, [
+ JSON.stringify({ type: 'session_meta', timestamp: '2026-07-25T00:00:00.000Z', payload: { model: 'gpt-5.6-sol' } }),
+ JSON.stringify({ type: 'event_msg', timestamp: '2026-07-25T00:00:00.000Z', payload: { type: 'task_started' } }),
+ mcp,
+ JSON.stringify({ type: 'event_msg', timestamp: '2026-07-25T00:00:08.000Z', payload: { type: 'token_count', info: { last_token_usage: { output_tokens: 100 }, total_token_usage: { total_tokens: 100, output_tokens: 100 } } } }),
+ JSON.stringify({ type: 'event_msg', timestamp: '2026-07-25T00:00:10.000Z', payload: { type: 'task_complete', duration_ms: 10000 } }),
+ ].join('\n'))
+ const points = await readCodexThroughput(path)
+ expect(points[0]).toMatchObject({ toolWaitSeconds: 3, activeDurationSeconds: 7, activeGeneratedTokensPerSecond: 100 / 7 })
+ })
+})
diff --git a/tests/daily-cache-carry-forward.test.ts b/tests/daily-cache-carry-forward.test.ts
index 62197fa..a3056b8 100644
--- a/tests/daily-cache-carry-forward.test.ts
+++ b/tests/daily-cache-carry-forward.test.ts
@@ -609,6 +609,7 @@ describe('adoption union across older cache files', () => {
lastComputedDate: daysAgoStr(1),
days: [rich],
complete: true,
+ watermarkTrusted: true,
}
await saveDailyCache(cache)
const loaded = await loadDailyCache()
diff --git a/tests/daily-cache-degraded-completeness.test.ts b/tests/daily-cache-degraded-completeness.test.ts
new file mode 100644
index 0000000..d3df6cc
--- /dev/null
+++ b/tests/daily-cache-degraded-completeness.test.ts
@@ -0,0 +1,187 @@
+import { afterEach, beforeEach, describe, expect, it } from 'vitest'
+import { mkdir, rm } from 'fs/promises'
+import { existsSync } from 'fs'
+import { tmpdir } from 'os'
+import { join } from 'path'
+
+import type { DateRange, ProjectSummary } from '../src/types.js'
+
+import {
+ DAILY_CACHE_VERSION,
+ type DailyCache,
+ type DailyEntry,
+ type ProviderDaySlice,
+ currentTzKey,
+ ensureCacheHydrated,
+ saveDailyCache,
+} from '../src/daily-cache.js'
+
+const TMP_CACHE_ROOT = join(tmpdir(), `codeburn-degraded-test-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`)
+
+beforeEach(async () => {
+ process.env['CODEBURN_CACHE_DIR'] = TMP_CACHE_ROOT
+ await mkdir(TMP_CACHE_ROOT, { recursive: true })
+})
+
+afterEach(async () => {
+ if (existsSync(TMP_CACHE_ROOT)) {
+ await rm(TMP_CACHE_ROOT, { recursive: true, force: true })
+ }
+})
+
+function slice(cost: number, calls: number, extra: Partial = {}): ProviderDaySlice {
+ return { cost, calls, savingsUSD: 0, ...extra }
+}
+
+function day(date: string, providers: Record, overrides: Partial = {}): DailyEntry {
+ const cost = Object.values(providers).reduce((s, p) => s + p.cost, 0)
+ const calls = Object.values(providers).reduce((s, p) => s + p.calls, 0)
+ return {
+ date,
+ cost,
+ savingsUSD: 0,
+ calls,
+ sessions: 0,
+ inputTokens: 0,
+ outputTokens: 0,
+ cacheReadTokens: 0,
+ cacheWriteTokens: 0,
+ editTurns: 0,
+ oneShotTurns: 0,
+ models: {},
+ categories: {},
+ providers,
+ ...overrides,
+ }
+}
+
+function daysAgoStr(n: number): string {
+ const d = new Date()
+ d.setDate(d.getDate() - n)
+ return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
+}
+
+const noSessions = async (): Promise => []
+
+/// The day whose session files are long gone: it exists in the daily cache and
+/// nowhere else, so every path below must still hand it back untouched.
+const VANISHED = day(daysAgoStr(40), { claude: slice(399.70, 1572) }, { carried: true })
+
+async function seed(overrides: Partial = {}): Promise {
+ await saveDailyCache({
+ version: DAILY_CACHE_VERSION,
+ savingsConfigHash: 'cfg-A',
+ tzKey: currentTzKey(),
+ lastComputedDate: daysAgoStr(4),
+ days: [VANISHED, day(daysAgoStr(4), { claude: slice(120, 900) })],
+ complete: true,
+ ...overrides,
+ })
+}
+
+/** The vanished-sources day is still there, with its original accounting. */
+function expectPreserved(cache: DailyCache): void {
+ const kept = cache.days.find(d => d.date === VANISHED.date)
+ expect(kept).toMatchObject({ cost: 399.70, calls: 1572 })
+ expect(kept!.providers['claude']!.cost).toBe(399.70)
+}
+
+describe('daily cache: a degraded session parse never finalizes history', () => {
+ it('does not publish complete, and does not advance the watermark past what it covered', async () => {
+ await seed()
+ const out = await ensureCacheHydrated(noSessions, () => [], 'cfg-A', () => false)
+ // The parse covered nothing it can vouch for, so the watermark stays put:
+ // advancing it to yesterday would put the missed days behind gapStart
+ // (lastComputedDate + 1) forever.
+ expect(out.lastComputedDate).toBe(daysAgoStr(4))
+ expect(out.complete).toBe(false)
+ expectPreserved(out)
+ })
+
+ it('does not advance the watermark on the full re-derive path either', async () => {
+ await seed({ complete: false })
+ const out = await ensureCacheHydrated(noSessions, () => [], 'cfg-A', () => false)
+ expect(out.lastComputedDate).toBe(daysAgoStr(4))
+ expect(out.complete).toBe(false)
+ expectPreserved(out)
+ })
+
+ it('a later healthy run rebuilds the days the degraded run missed', async () => {
+ await seed()
+ await ensureCacheHydrated(noSessions, () => [], 'cfg-A', () => false)
+ const missed = [1, 2, 3].map(n => day(daysAgoStr(n), { claude: slice(n * 10, n * 100) }))
+ const healed = await ensureCacheHydrated(noSessions, () => missed, 'cfg-A', () => true)
+ expect(healed.days.map(d => d.date)).toEqual([
+ daysAgoStr(40), daysAgoStr(4), daysAgoStr(3), daysAgoStr(2), daysAgoStr(1),
+ ])
+ expect(healed.lastComputedDate).toBe(daysAgoStr(1))
+ expect(healed.complete).toBe(true)
+ expectPreserved(healed)
+ })
+})
+
+describe('daily cache: a complete cache that outruns its own data is not trusted', () => {
+ it('re-derives the days between the newest entry and the watermark', async () => {
+ // The field artifact: complete: true, lastComputedDate yesterday, entries
+ // stopping four days earlier — written by a run that finalized off a parse
+ // which never covered those days.
+ await seed({ lastComputedDate: daysAgoStr(1) })
+ const ranges: DateRange[] = []
+ const missed = [1, 2, 3].map(n => day(daysAgoStr(n), { claude: slice(n * 10, n * 100) }))
+ const out = await ensureCacheHydrated(
+ async (range) => { ranges.push(range); return [] },
+ () => missed,
+ 'cfg-A',
+ () => true,
+ )
+ expect(ranges).toHaveLength(1)
+ expect(out.days.map(d => d.date)).toEqual([
+ daysAgoStr(40), daysAgoStr(4), daysAgoStr(3), daysAgoStr(2), daysAgoStr(1),
+ ])
+ expect(out.days.find(d => d.date === daysAgoStr(2))!.cost).toBe(20)
+ expect(out.complete).toBe(true)
+ expectPreserved(out)
+ })
+
+ it('trusts a stamped watermark over an idle tail — no re-derive treadmill', async () => {
+ // Same shape as the corrupt case above (watermark past the newest populated
+ // day), but stamped by a COMPLETE parse: the recent days are genuinely
+ // empty, not a frozen hole. A degraded parse can no longer produce this
+ // state, so the stamp means the watermark is trustworthy and re-deriving the
+ // empty tail on every launch (the perf regression) must not happen.
+ await seed({ lastComputedDate: daysAgoStr(1), watermarkTrusted: true })
+ let parses = 0
+ const out = await ensureCacheHydrated(
+ async () => { parses += 1; return [] },
+ () => [],
+ 'cfg-A',
+ () => true,
+ )
+ expect(parses).toBe(0)
+ expect(out.lastComputedDate).toBe(daysAgoStr(1))
+ expect(out.complete).toBe(true)
+ expectPreserved(out)
+ })
+
+ it('a degraded re-derivation of those days still keeps every carried day', async () => {
+ await seed({ lastComputedDate: daysAgoStr(1) })
+ const out = await ensureCacheHydrated(noSessions, () => [], 'cfg-A', () => false)
+ expect(out.complete).toBe(false)
+ expect(out.days.map(d => d.date)).toEqual([daysAgoStr(40), daysAgoStr(4)])
+ expectPreserved(out)
+ })
+
+ it('an empty cache still finalizes — no re-parse treadmill on a machine with no history', async () => {
+ await seed({ days: [], lastComputedDate: daysAgoStr(1) })
+ let parses = 0
+ const out = await ensureCacheHydrated(
+ async () => { parses += 1; return [] },
+ () => [],
+ 'cfg-A',
+ () => true,
+ )
+ expect(parses).toBe(0)
+ expect(out.lastComputedDate).toBe(daysAgoStr(1))
+ expect(out.complete).toBe(true)
+ })
+})
diff --git a/tests/daily-cache.test.ts b/tests/daily-cache.test.ts
index 6ae0707..d887d25 100644
--- a/tests/daily-cache.test.ts
+++ b/tests/daily-cache.test.ts
@@ -183,6 +183,7 @@ describe('loadDailyCache', () => {
lastComputedDate: '2026-04-10',
days: [emptyDay('2026-04-09', 12.5, 40), emptyDay('2026-04-10', 7.25, 28)],
complete: true,
+ watermarkTrusted: true,
}
await saveDailyCache(saved)
const loaded = await loadDailyCache()
@@ -318,6 +319,7 @@ describe('ensureCacheHydrated', () => {
lastComputedDate: '2026-06-11',
days: [emptyDay('2026-06-11', 5, 10)],
complete: true,
+ watermarkTrusted: true,
}
await saveDailyCache(saved)
diff --git a/tests/dashboard.test.ts b/tests/dashboard.test.ts
index 59ada8f..0abd36d 100644
--- a/tests/dashboard.test.ts
+++ b/tests/dashboard.test.ts
@@ -2,7 +2,7 @@ import { homedir } from 'os'
import { describe, it, expect } from 'vitest'
-import { getDailyActivityRows, getDashboardScanRange, pageHistoryCursor, scrollHistoryCursor, selectDashboardPeriodProjects, shortProject, showEmptyState } from '../src/dashboard.js'
+import { getDailyActivityRows, getDashboardScanRange, getLayout, pageHistoryCursor, scrollHistoryCursor, selectDashboardPeriodProjects, shortProject, showEmptyState } from '../src/dashboard.js'
import { getDateRange } from '../src/cli-date.js'
import { formatCost } from '../src/format.js'
import type { ProjectSummary, SessionSummary } from '../src/types.js'
@@ -248,3 +248,25 @@ describe('showEmptyState', () => {
expect(showEmptyState(2, false, 0, false)).toBe(false)
})
})
+
+describe('getLayout - dashboard width breakpoints', () => {
+ it('uses a single column at 89 columns or below', () => {
+ expect(getLayout(89)).toMatchObject({ dashWidth: 89, wide: false, halfWidth: 89 })
+ })
+
+ it('switches to two columns at 90 columns', () => {
+ expect(getLayout(90)).toMatchObject({ dashWidth: 90, wide: true, halfWidth: 45 })
+ })
+
+ it('keeps two columns at 120 columns but the By-Model panel is too narrow for Tok/s', () => {
+ // Inner panel width is halfWidth - PANEL_CHROME (4). At 120 cols halfWidth=60,
+ // inner=56, below the 61-col threshold where Tok/s renders.
+ expect(getLayout(120)).toMatchObject({ dashWidth: 120, wide: true, halfWidth: 60 })
+ expect(getLayout(120).halfWidth - 4).toBeLessThan(61)
+ })
+
+ it('keeps two columns and has enough room for Tok/s at 130 columns', () => {
+ expect(getLayout(130)).toMatchObject({ dashWidth: 130, wide: true, halfWidth: 65 })
+ expect(getLayout(130).halfWidth - 4).toBeGreaterThanOrEqual(61)
+ })
+})
diff --git a/tests/day-aggregator.test.ts b/tests/day-aggregator.test.ts
index 24b9c16..7d70d14 100644
--- a/tests/day-aggregator.test.ts
+++ b/tests/day-aggregator.test.ts
@@ -74,10 +74,12 @@ function makeSingleTurnProject(
}
describe('aggregateProjectsIntoDays', () => {
- it('buckets a whole turn (all its calls) on the turn user-message date', () => {
- // Turn-anchored bucketing: a turn whose calls straddle midnight lands wholly
- // on the day of its user-message timestamp — matching the live headline/
- // report rollup — instead of splitting per-call across two days.
+ it("buckets call-derived values under each call's own date when a turn straddles midnight", () => {
+ // Per-call bucketing (issue #852): a turn whose calls straddle midnight
+ // puts each call's cost/calls/tokens on the day the call happened, so
+ // day-N + day-N+1 reconcile with a range parse that sliced the turn at
+ // the same boundary. Turn-level judgments (editTurns, category turns)
+ // stay anchored on the turn's day.
const projects: ProjectSummary[] = [
makeProject({
sessions: [{
@@ -116,9 +118,17 @@ describe('aggregateProjectsIntoDays', () => {
]
const days = aggregateProjectsIntoDays(projects)
- expect(days.map(d => d.date)).toEqual(['2026-04-09'])
- expect(days[0]!.cost).toBe(10)
- expect(days[0]!.calls).toBe(2)
+ expect(days.map(d => d.date)).toEqual(['2026-04-09', '2026-04-10'])
+ expect(days[0]!.cost).toBe(4)
+ expect(days[0]!.calls).toBe(1)
+ expect(days[1]!.cost).toBe(6)
+ expect(days[1]!.calls).toBe(1)
+ // Turn-level stats anchor on the turn's day only — they describe the
+ // whole exchange, not a per-call sum.
+ expect(days[0]!.editTurns).toBe(1)
+ expect(days[1]!.editTurns).toBe(0)
+ expect(days[0]!.categories['coding']?.turns).toBe(1)
+ expect(days[1]!.categories['coding']).toBeUndefined()
})
it('attributes category turns + editTurns + oneShotTurns to the first call date of the turn', () => {
@@ -406,17 +416,17 @@ describe('buildPeriodDataFromDays', () => {
expect(pd.models).toEqual([])
})
- it('attributes a midnight-straddling turn to the user-message date, matching the live report', () => {
- // A turn whose user message sits on one side of midnight and whose assistant
- // response lands on the other must bucket by the USER-MESSAGE timestamp, so
- // the daily cache (history.daily + provider breakdown) reconciles exactly to
- // the live headline/report rollup (main.ts daily), which anchors on the same
- // turn timestamp. The prior per-call bucketing split such turns and left a
- // constant offset between the trend bars and current.cost.
+ it("attributes a midnight-straddling turn's cost to the call's own date", () => {
+ // A turn whose user message sits on one side of midnight and whose
+ // assistant response lands on the other buckets its cost under the CALL's
+ // day (issue #852's per-call rule), so the daily cache (history.daily +
+ // provider breakdown) reconciles exactly to a range parse that slices the
+ // same turn at the same boundary — and day-N + day-N+1 sum to the period
+ // total with nothing lost on either side.
const userTs = '2026-04-20T23:58:00Z'
const assistantTs = '2026-04-21T00:30:00Z'
- const userLocal = new Date(userTs)
- const expectedDate = `${userLocal.getFullYear()}-${String(userLocal.getMonth() + 1).padStart(2, '0')}-${String(userLocal.getDate()).padStart(2, '0')}`
+ const assistantLocal = new Date(assistantTs)
+ const expectedDate = `${assistantLocal.getFullYear()}-${String(assistantLocal.getMonth() + 1).padStart(2, '0')}-${String(assistantLocal.getDate()).padStart(2, '0')}`
const projects: ProjectSummary[] = [
makeProject({
@@ -457,21 +467,22 @@ describe('daily-cache ↔ report daily-bucket parity', () => {
// headline (main.ts daily rollup) must bucket days by the SAME rule, or their
// per-day totals drift and their period sums diverge from current.cost at
// window boundaries — the V1 audit's constant -$3.45/-81-calls finding. Both
- // are now TURN-anchored: this asserts per-day equality against a reference
- // that mirrors main.ts:486-499 (turn.timestamp anchor), plus the invariant
- // history.daily Σ == report.daily Σ == total call cost.
+ // are now PER-CALL for cost/savings/calls (issue #852) with turn-level stats
+ // still turn-anchored: this asserts per-day equality against a reference
+ // that mirrors main.ts buildJsonReport's dailyMap fallback (each call on its
+ // own date), plus the invariant history.daily Σ == report.daily Σ == total
+ // call cost.
- // Mirrors the live report/headline daily rollup in src/main.ts (bucket the
- // whole turn — all its calls — on the turn's user-message date).
+ // Mirrors the live report/headline daily rollup fallback in src/main.ts
+ // (cost/savings/calls bucket under each call's own date).
function reportDailyByDate(projects: ProjectSummary[]): Record {
const byDate: Record = {}
for (const p of projects) {
for (const sess of p.sessions) {
for (const turn of sess.turns) {
- if (turn.assistantCalls.length === 0) continue
- const ts = turn.timestamp || turn.assistantCalls[0]!.timestamp
- const day = dateKey(ts)
- for (const call of turn.assistantCalls) byDate[day] = (byDate[day] ?? 0) + call.costUSD
+ for (const call of turn.assistantCalls) {
+ byDate[dateKey(call.timestamp)] = (byDate[dateKey(call.timestamp)] ?? 0) + call.costUSD
+ }
}
}
}
@@ -492,8 +503,8 @@ describe('daily-cache ↔ report daily-bucket parity', () => {
expect(dayA).not.toBe(dayB) // sanity: the fixture really straddles local midnight
// A midnight-straddling turn (calls on both days) plus a same-day turn, so
- // per-CALL bucketing would produce DIFFERENT per-day totals than the turn-
- // anchored report — the case the old code got wrong.
+ // whole-TURN anchoring would produce DIFFERENT per-day totals than the
+ // per-call rule — the case the old code got wrong.
const projects: ProjectSummary[] = [
makeProject({
sessions: [{
@@ -541,8 +552,10 @@ describe('daily-cache ↔ report daily-bucket parity', () => {
const totalCallCost = 2 + 3 + 7
expect(historySum).toBeCloseTo(totalCallCost, 10)
expect(reportSum).toBeCloseTo(totalCallCost, 10)
- // Day A owns the WHOLE straddling turn (2+3=5), not just its first call (2).
- expect(historyByDate[dayA]).toBe(5)
- expect(historyByDate[dayB]).toBe(7)
+ // Day A owns only the straddling turn's pre-midnight call (2); day B owns
+ // the post-midnight call plus the same-day turn (3+7=10). Both paths agree
+ // per day and the period total is conserved.
+ expect(historyByDate[dayA]).toBe(2)
+ expect(historyByDate[dayB]).toBe(10)
})
})
diff --git a/tests/fixtures/cache-refresh-corrupt-owner.ts b/tests/fixtures/cache-refresh-corrupt-owner.ts
new file mode 100644
index 0000000..dfacd26
--- /dev/null
+++ b/tests/fixtures/cache-refresh-corrupt-owner.ts
@@ -0,0 +1,22 @@
+import { writeFile } from 'fs/promises'
+import { join } from 'path'
+
+import { acquireCacheRefreshLock } from '../../src/cache-refresh-lock.js'
+
+// A plain owner in its own process. It records its outcome, its token, and the
+// result of the publication fence into the barrier directory so the parent can
+// assert what the owner itself believed while a contender was racing it.
+const [cacheDir, barrierDir, holdMs, heartbeatMs = '200'] = process.argv.slice(2)
+if (!cacheDir || !barrierDir || !holdMs) throw new Error('missing owner argument')
+
+const refresh = await acquireCacheRefreshLock({ cacheDir, heartbeatMs: Number(heartbeatMs) })
+if (refresh.outcome !== 'acquired') {
+ await writeFile(join(barrierDir, `owner.${refresh.outcome}`), '')
+ process.exit(0)
+}
+await writeFile(join(barrierDir, 'owner.acquired'), refresh.handle.token)
+await new Promise(resolve => { setTimeout(resolve, Number(holdMs)) })
+// The publication fence, exactly as parser.ts uses it before saving.
+await writeFile(join(barrierDir, `owner.verify.${await refresh.handle.verifyStillOwner()}`), '')
+await refresh.handle.release()
+await writeFile(join(barrierDir, 'owner.done'), '')
diff --git a/tests/fixtures/cache-refresh-slow-owner.ts b/tests/fixtures/cache-refresh-slow-owner.ts
new file mode 100644
index 0000000..70b5755
--- /dev/null
+++ b/tests/fixtures/cache-refresh-slow-owner.ts
@@ -0,0 +1,25 @@
+import { pbkdf2 } from 'crypto'
+import { writeFile } from 'fs/promises'
+import { join } from 'path'
+
+import { acquireCacheRefreshLock } from '../../src/cache-refresh-lock.js'
+
+// Saturate the (size-1) libuv threadpool so every fs operation inside
+// createExclusive queues behind a pbkdf2 round. No test hook and no patched
+// module: this is what an ordinary process looks like mid cold parse, and it
+// widens the window in which the lock exists at zero bytes -- between
+// open(path,'wx') and the awaited body write -- to something observable.
+const [cacheDir, barrierDir] = process.argv.slice(2)
+if (!cacheDir || !barrierDir) throw new Error('missing owner argument')
+
+let stop = false
+const churn = (): void => { if (stop) return; pbkdf2('p', 's', 400_000, 32, 'sha512', () => churn()) }
+churn()
+
+const refresh = await acquireCacheRefreshLock({ cacheDir, heartbeatMs: 10_000 })
+stop = true
+await writeFile(join(barrierDir, `owner.${refresh.outcome}`), refresh.outcome === 'acquired' ? refresh.handle.token : '')
+if (refresh.outcome === 'acquired') {
+ await writeFile(join(barrierDir, `owner.verify.${await refresh.handle.verifyStillOwner()}`), '')
+ await refresh.handle.release()
+}
diff --git a/tests/fixtures/mock-idp.ts b/tests/fixtures/mock-idp.ts
index e873b6b..d70b9f5 100644
--- a/tests/fixtures/mock-idp.ts
+++ b/tests/fixtures/mock-idp.ts
@@ -34,6 +34,8 @@ export interface MockIdp {
revokedTokens: string[]
/** Authorization codes that have been exchanged */
exchangedCodes: string[]
+ /** OTLP trace batches received at POST /v1/traces */
+ tracesRequests: Array<{ auth: string | undefined; body: unknown }>
}
export async function startMockIdp(opts: MockIdpOptions = {}): Promise {
@@ -52,6 +54,7 @@ export async function startMockIdp(opts: MockIdpOptions = {}): Promise
issuedTokens: { access: [], refresh: [] },
revokedTokens: [],
exchangedCodes: [],
+ tracesRequests: [],
close: async () => {},
}
@@ -59,6 +62,20 @@ export async function startMockIdp(opts: MockIdpOptions = {}): Promise
const url = new URL(req.url ?? '/', `http://127.0.0.1:${state.port}`)
const path = url.pathname
+ // --- OTLP traces collector (records batches for push tests) ---
+ if (path === '/v1/traces' && req.method === 'POST') {
+ let body = ''
+ req.on('data', chunk => { body += chunk })
+ req.on('end', () => {
+ let parsed: unknown = null
+ try { parsed = JSON.parse(body || '{}') } catch { /* keep null */ }
+ state.tracesRequests.push({ auth: req.headers.authorization, body: parsed })
+ res.writeHead(200, { 'Content-Type': 'application/json' })
+ res.end('{}')
+ })
+ return
+ }
+
// --- Discovery doc ---
if (path === '/.well-known/codeburn-export.json') {
res.writeHead(200, { 'Content-Type': 'application/json' })
diff --git a/tests/parser-cache-refresh-timeout.test.ts b/tests/parser-cache-refresh-timeout.test.ts
index 9241577..8883c5a 100644
--- a/tests/parser-cache-refresh-timeout.test.ts
+++ b/tests/parser-cache-refresh-timeout.test.ts
@@ -7,7 +7,7 @@ vi.mock('../src/cache-refresh-lock.js', () => ({
acquireCacheRefreshLock: async () => ({ outcome: 'timed-out' as const }),
}))
-import { clearSessionCache, parseAllSessions } from '../src/parser.js'
+import { clearSessionCache, isSessionHydrationComplete, parseAllSessions } from '../src/parser.js'
import { sessionCachePath } from '../src/session-cache.js'
let root: string
@@ -59,4 +59,46 @@ describe('parseAllSessions warm refresh timeout', () => {
expect(output(await parseAllSessions(undefined, 'claude'))).toBe(50)
expect(await readFile(sessionCachePath(), 'utf-8')).toBe(before)
})
+
+ // The snapshot a timed-out refresh serves is only as good as what has changed
+ // under it. Anything the daily backfill finalizes off a snapshot that skipped
+ // real files freezes those days out of history for good, so the completeness
+ // signal has to distinguish the two cases.
+ it('does not report a complete hydration when the served snapshot is stale', async () => {
+ await writeSession(50)
+ await parseAllSessions(undefined, 'claude')
+ expect(isSessionHydrationComplete()).toBe(true)
+
+ await writeSession(5000)
+ clearSessionCache()
+ await parseAllSessions(undefined, 'claude')
+ expect(isSessionHydrationComplete()).toBe(false)
+ })
+
+ it('does not report a complete hydration when a session file is missing from the snapshot', async () => {
+ await writeSession(50)
+ await parseAllSessions(undefined, 'claude')
+
+ await writeFile(join(sessionPath, '..', 'other.jsonl'), JSON.stringify({
+ type: 'assistant',
+ sessionId: 'sess-2',
+ timestamp: '2026-05-16T10:00:00Z',
+ cwd: '/tmp/proj',
+ message: {
+ id: 'msg-other', type: 'message', role: 'assistant', model: 'claude-sonnet-4-5',
+ content: [], usage: { input_tokens: 100, output_tokens: 7 },
+ },
+ }) + '\n')
+ clearSessionCache()
+ await parseAllSessions(undefined, 'claude')
+ expect(isSessionHydrationComplete()).toBe(false)
+ })
+
+ it('still reports a complete hydration when nothing changed under the snapshot', async () => {
+ await writeSession(50)
+ await parseAllSessions(undefined, 'claude')
+ clearSessionCache()
+ await parseAllSessions(undefined, 'claude')
+ expect(isSessionHydrationComplete()).toBe(true)
+ })
})
diff --git a/tests/parser.test.ts b/tests/parser.test.ts
index 5de2dad..211c41f 100644
--- a/tests/parser.test.ts
+++ b/tests/parser.test.ts
@@ -488,3 +488,114 @@ describe('(g) skill attribution is independent of turn category', () => {
expect(session!.skillBreakdown['telemetry-review']?.turns).toBe(1)
})
})
+
+// ═══════════════════════════════════════════════════════════════════════════
+// (h) Provider filter isolates claude: a --provider run must not
+// re-surface cached claude sessions through the orphan pass, while a run
+// that DOES include claude still preserves PR-bearing orphans.
+// ═══════════════════════════════════════════════════════════════════════════
+describe('(h) provider filter excludes claude from the orphan pass', () => {
+ const SYNTH_SOURCE = (path: string): SessionSource[] =>
+ [{ path, project: 'synth-proj', provider: 'test-synthetic' }]
+
+ // The provider lives on each parsed call, not on SessionSummary.
+ const providersOf = (projects: Awaited>): Set =>
+ new Set(projects
+ .flatMap(p => p.sessions)
+ .flatMap(s => s.turns)
+ .flatMap(t => t.assistantCalls)
+ .map(c => c.provider))
+
+ const SYNTH_CALL: ParsedProviderCall = {
+ provider: 'test-synthetic', model: 'gpt-4o',
+ inputTokens: 10, outputTokens: 5,
+ cacheCreationInputTokens: 0, cacheReadInputTokens: 0,
+ cachedInputTokens: 0, reasoningTokens: 0, webSearchRequests: 0,
+ costUSD: 0.25, tools: [], bashCommands: [],
+ skills: [],
+ timestamp: '2026-07-18T12:00:00.000Z',
+ speed: 'standard',
+ deduplicationKey: 'synth-isolation-call',
+ userMessage: '', sessionId: 'synth-isolation-session',
+ }
+
+ // A claude transcript carrying a pr-link: `prLinks` is exactly what lets a
+ // cached entry survive the write-mode orphan gate, so it is the shape that
+ // leaks. Cost is deliberately far larger than the synthetic call's, so a leak
+ // is unmistakable rather than a rounding difference.
+ async function writeClaudeSessionWithPrLink(): Promise {
+ const projectDir = join(tmpHome, '.claude', 'projects', 'leaky-app')
+ await mkdir(projectDir, { recursive: true })
+ const filePath = join(projectDir, 'session.jsonl')
+ await writeFile(filePath, [
+ JSON.stringify({
+ type: 'user', sessionId: 'claude-leak-1', timestamp: '2026-07-18T12:00:00.000Z',
+ message: { role: 'user', content: 'ship it' },
+ }),
+ JSON.stringify({
+ type: 'assistant', sessionId: 'claude-leak-1', timestamp: '2026-07-18T12:00:10.000Z',
+ message: {
+ id: 'msg-leak-1', type: 'message', role: 'assistant', model: 'claude-sonnet-4-5',
+ content: [{ type: 'text', text: 'done' }],
+ usage: { input_tokens: 900_000, output_tokens: 90_000 },
+ },
+ }),
+ JSON.stringify({
+ type: 'pr-link', sessionId: 'claude-leak-1', timestamp: '2026-07-18T12:00:20.000Z',
+ prUrl: 'https://github.com/getagentseal/codeburn/pull/1',
+ }),
+ ].join('\n') + '\n')
+ return filePath
+ }
+
+ it('does not surface cached claude sessions when filtering to another provider', async () => {
+ const synthFile = join(tmpHome, 'synth-isolation.txt')
+ await writeFile(synthFile, 'placeholder')
+ await writeClaudeSessionWithPrLink()
+
+ _synthSources = SYNTH_SOURCE(synthFile)
+ _synthYields = [SYNTH_CALL]
+
+ // Baseline: what the synthetic provider costs on its own, before anything
+ // claude-shaped has ever entered the session cache. Self-calibrating, since
+ // cost is re-derived from tokens by the pricing engine.
+ const baseline = await parseAllSessions(undefined, 'test-synthetic')
+ const synthOnlyCost = totalCost(baseline)
+ expect([...providersOf(baseline)]).toEqual(['test-synthetic'])
+ clearSessionCache()
+
+ // Warm the session cache so the claude file is persisted WITH its prLinks.
+ const all = await parseAllSessions(undefined, 'all')
+ expect(providersOf(all)).toContain('claude')
+ expect(totalCost(all)).toBeGreaterThan(synthOnlyCost)
+
+ clearSessionCache()
+
+ // Filtering to the synthetic provider must yield ONLY its own spend. Before
+ // the fix, claudeDirs was empty yet scanProjectDirs still ran, so every
+ // cached PR-bearing claude file was treated as a pruned orphan and re-added.
+ const filtered = await parseAllSessions(undefined, 'test-synthetic')
+
+ expect([...providersOf(filtered)]).toEqual(['test-synthetic'])
+ expect(totalCost(filtered)).toBeCloseTo(synthOnlyCost, 10)
+ })
+
+ it('still preserves a PR-bearing claude orphan when claude IS in scope', async () => {
+ const filePath = await writeClaudeSessionWithPrLink()
+ _synthSources = []
+ _synthYields = []
+
+ const before = await parseAllSessions(undefined, 'all')
+ const costBefore = totalCost(before)
+ expect(costBefore).toBeGreaterThan(0)
+
+ // Every claude transcript disappears from disk. Claude is still in scope, so
+ // the orphan pass must keep the PR-attributed spend alive — this is the case
+ // a naive `claudeDirs.length > 0` guard would silently break.
+ await unlink(filePath)
+ clearSessionCache()
+
+ const after = await parseAllSessions(undefined, 'all')
+ expect(totalCost(after)).toBeCloseTo(costBefore, 10)
+ })
+})
diff --git a/tests/project-filter-durable-totals.test.ts b/tests/project-filter-durable-totals.test.ts
new file mode 100644
index 0000000..ff030aa
--- /dev/null
+++ b/tests/project-filter-durable-totals.test.ts
@@ -0,0 +1,374 @@
+import { afterEach, beforeAll, beforeEach, describe, expect, it } from 'vitest'
+import { mkdir, rm, writeFile } from 'fs/promises'
+import { existsSync } from 'fs'
+import { tmpdir } from 'os'
+import { join } from 'path'
+
+import { DAILY_CACHE_VERSION, currentTzKey, type DailyCache, type DailyEntry } from '../src/daily-cache.js'
+import { loadPricing } from '../src/models.js'
+import { buildDurablePeriod, buildMenubarPayloadForRange, buildPeriodData, getDailyCacheConfigHash } from '../src/usage-aggregator.js'
+import { parseAllSessions, filterProjectsByName, clearSessionCache } from '../src/parser.js'
+import { renderOverview } from '../src/overview.js'
+import type { DateRange } from '../src/types.js'
+
+// The durable headline (overview / report Overview panel / menubar current) is
+// built from the carry-forward daily cache unioned with today's live parse, so
+// days whose session files expired still count. Historical days were sliced to
+// the requested PROVIDER but never to the requested PROJECT, so
+// `--project` / `--exclude` were silently ignored for every day except today:
+// the Overview total counted excluded projects while every detail panel (By
+// Project / By Activity / By Model, all built from the name-filtered live
+// parse) left them out, and the two panels could not be reconciled.
+//
+// Per-project day stats exist in the cache since v15 (DailyEntry.projects), so
+// the historical days CAN be sliced — that is what these tests pin down.
+
+const ROOT = join(tmpdir(), `codeburn-project-filter-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`)
+const ENV_KEYS = ['HOME', 'CODEBURN_CACHE_DIR', 'CLAUDE_CONFIG_DIR', 'CLAUDE_CONFIG_DIRS', 'CODEX_HOME'] as const
+let savedEnv: Record
+
+// One carried day, split across two projects. The day totals are the sum, so a
+// correct project slice returns strictly less than the whole day.
+const KEEP = { cost: 30, calls: 10, sessions: 1 }
+const DROP = { cost: 70, calls: 30, sessions: 2 }
+const DAY_COST = KEEP.cost + DROP.cost
+const DAY_CALLS = KEEP.calls + DROP.calls
+const DAY_SESSIONS = KEEP.sessions + DROP.sessions
+
+function daysAgoStr(n: number): string {
+ const d = new Date(Date.now() - n * 24 * 60 * 60 * 1000)
+ return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
+}
+
+/** A day only the cache holds (sources aged out), split across two projects. */
+function carriedDayWithProjects(date: string): DailyEntry {
+ const projects = {
+ 'keep-me': { cost: KEEP.cost, calls: KEEP.calls, savingsUSD: 0, sessions: KEEP.sessions, path: '/Users/gone/keep-me' },
+ 'drop-me': { cost: DROP.cost, calls: DROP.calls, savingsUSD: 0, sessions: DROP.sessions, path: '/Users/gone/drop-me' },
+ }
+ return {
+ date,
+ cost: DAY_COST,
+ savingsUSD: 0,
+ calls: DAY_CALLS,
+ sessions: DAY_SESSIONS,
+ inputTokens: 5000,
+ outputTokens: 2000,
+ cacheReadTokens: 0,
+ cacheWriteTokens: 0,
+ editTurns: 4,
+ oneShotTurns: 2,
+ models: { 'Opus 4.8': { calls: DAY_CALLS, cost: DAY_COST, savingsUSD: 0, inputTokens: 5000, outputTokens: 2000, cacheReadTokens: 0, cacheWriteTokens: 0 } },
+ categories: { coding: { turns: 10, cost: DAY_COST, savingsUSD: 0, editTurns: 4, oneShotTurns: 2 } },
+ providers: {
+ claude: {
+ calls: DAY_CALLS, cost: DAY_COST, savingsUSD: 0, sessions: DAY_SESSIONS,
+ inputTokens: 5000, outputTokens: 2000, cacheReadTokens: 0, cacheWriteTokens: 0,
+ editTurns: 4, oneShotTurns: 2,
+ models: { 'Opus 4.8': { calls: DAY_CALLS, cost: DAY_COST, savingsUSD: 0, inputTokens: 5000, outputTokens: 2000, cacheReadTokens: 0, cacheWriteTokens: 0 } },
+ categories: { coding: { turns: 10, cost: DAY_COST, savingsUSD: 0, editTurns: 4, oneShotTurns: 2 } },
+ projects,
+ },
+ },
+ projects,
+ carried: true,
+ }
+}
+
+/** A carried day recorded before v15: totals, but no per-project split at all. */
+function carriedDayWithoutProjects(date: string): DailyEntry {
+ const day = carriedDayWithProjects(date)
+ delete day.projects
+ delete day.providers['claude']!.projects
+ return day
+}
+
+async function seedCache(...days: DailyEntry[]): Promise {
+ const cache: DailyCache = {
+ version: DAILY_CACHE_VERSION,
+ savingsConfigHash: getDailyCacheConfigHash(),
+ tzKey: currentTzKey(),
+ lastComputedDate: daysAgoStr(1),
+ days,
+ complete: true,
+ }
+ await writeFile(join(ROOT, 'cache', `daily-cache.v${DAILY_CACHE_VERSION}.json`), JSON.stringify(cache), 'utf-8')
+}
+
+/** A real, priced Claude session dated TODAY, under project dir `live-proj`. */
+async function seedLiveTodaySession(): Promise {
+ const projectDir = join(ROOT, 'home', '.claude', 'projects', 'live-proj')
+ await mkdir(projectDir, { recursive: true })
+ const now = new Date()
+ // Timestamps a few minutes OLD, clamped into today: a wall-clock hour (12:00)
+ // is in the future whenever the suite runs before noon, and the periods here
+ // end at `new Date()`, so a fixed hour made the live half of the union vanish
+ // for a morning run. Relative-and-clamped keeps the session inside today and
+ // in the past at every hour.
+ const midnight = new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime()
+ const minutesAgo = (m: number): string => new Date(Math.max(midnight, now.getTime() - m * 60_000)).toISOString()
+ const line = (id: string, t: string): string => JSON.stringify({
+ type: 'assistant',
+ timestamp: t,
+ sessionId: 's-today',
+ message: {
+ type: 'message', role: 'assistant', model: 'claude-3-5-sonnet-20241022', id,
+ content: [],
+ usage: { input_tokens: 90000, output_tokens: 12000, cache_creation_input_tokens: 0, cache_read_input_tokens: 300000 },
+ },
+ })
+ await writeFile(join(projectDir, 's-today.jsonl'), [line('m1', minutesAgo(10)), line('m2', minutesAgo(5))].join('\n') + '\n', 'utf-8')
+}
+
+/** What the name-filtered live parse alone reports — the By Project panel's source. */
+async function liveOnly(range: DateRange, include: string[], exclude: string[]): Promise<{ cost: number; calls: number }> {
+ clearSessionCache()
+ const projects = filterProjectsByName(await parseAllSessions(range, 'all'), include, exclude)
+ const data = buildPeriodData('live', projects)
+ return { cost: data.cost, calls: data.calls }
+}
+
+beforeAll(async () => {
+ await loadPricing()
+})
+
+beforeEach(async () => {
+ savedEnv = Object.fromEntries(ENV_KEYS.map(k => [k, process.env[k]]))
+ await mkdir(join(ROOT, 'home', '.claude'), { recursive: true })
+ await mkdir(join(ROOT, 'cache'), { recursive: true })
+ process.env['HOME'] = join(ROOT, 'home')
+ process.env['CODEBURN_CACHE_DIR'] = join(ROOT, 'cache')
+ process.env['CLAUDE_CONFIG_DIR'] = join(ROOT, 'home', '.claude')
+ delete process.env['CLAUDE_CONFIG_DIRS']
+ delete process.env['CODEX_HOME']
+ clearSessionCache()
+})
+
+afterEach(async () => {
+ clearSessionCache()
+ for (const k of ENV_KEYS) {
+ if (savedEnv[k] === undefined) delete process.env[k]
+ else process.env[k] = savedEnv[k]
+ }
+ if (existsSync(ROOT)) await rm(ROOT, { recursive: true, force: true })
+})
+
+// A fixed 20-day window back from now: it always spans the carried day (10 days
+// ago) and today, so the period never depends on where "now" falls in the
+// calendar. A real `month` range drops the 10-days-ago day whenever today is
+// within 10 days of the 1st, which made these tests flake early in each month.
+const coveringRange = (): DateRange => ({
+ start: new Date(Date.now() - 20 * 24 * 60 * 60 * 1000),
+ end: new Date(),
+})
+
+describe('durable headline honours --project / --exclude on carried days', () => {
+ it('drops an excluded project from the headline so it reconciles with the By Project panel', async () => {
+ await seedCache(carriedDayWithProjects(daysAgoStr(10)))
+ await seedLiveTodaySession()
+ const range = coveringRange()
+
+ const live = await liveOnly(range, [], ['drop-me'])
+ clearSessionCache()
+ const durable = await buildDurablePeriod({ range, label: 'p' }, { provider: 'all', exclude: ['drop-me'] })
+
+ // The headline counts the surviving sessions plus ONLY the kept project's
+ // carried cost — not the whole carried day.
+ expect(durable.data.cost).toBeCloseTo(live.cost + KEEP.cost, 6)
+ expect(durable.data.calls).toBe(live.calls + KEEP.calls)
+ // And it reconciles with the detail panels, which see the same live parse.
+ const byProject = durable.liveProjects.reduce((s, p) => s + p.totalCostUSD, 0)
+ expect(durable.data.cost).toBeCloseTo(byProject + KEEP.cost, 6)
+ })
+
+ it('keeps only the named project when --project is given', async () => {
+ await seedCache(carriedDayWithProjects(daysAgoStr(10)))
+ await seedLiveTodaySession()
+ const range = coveringRange()
+
+ const live = await liveOnly(range, ['keep-me'], [])
+ clearSessionCache()
+ const durable = await buildDurablePeriod({ range, label: 'p' }, { provider: 'all', project: ['keep-me'] })
+
+ expect(durable.data.cost).toBeCloseTo(live.cost + KEEP.cost, 6)
+ expect(durable.data.calls).toBe(live.calls + KEEP.calls)
+ })
+
+ it('matches a filter against the project path as well as its name', async () => {
+ await seedCache(carriedDayWithProjects(daysAgoStr(10)))
+ await seedLiveTodaySession()
+ const range = coveringRange()
+
+ const live = await liveOnly(range, [], ['/Users/gone/drop-me'])
+ clearSessionCache()
+ const durable = await buildDurablePeriod({ range, label: 'p' }, { provider: 'all', exclude: ['/Users/gone/drop-me'] })
+
+ expect(durable.data.cost).toBeCloseTo(live.cost + KEEP.cost, 6)
+ })
+
+ it('keeps a project whose name is a prototype member (constructor) in the sliced total', async () => {
+ // A project directory can legitimately be named "constructor"/"valueOf"/etc.
+ // The day cache must carry it as an own key: if the load path drops it, the
+ // day's per-project split no longer sums to the day cost, and the sliced
+ // headline silently loses that project's spend.
+ const day = carriedDayWithProjects(daysAgoStr(10))
+ const protoName = 'constructor'
+ Object.defineProperty(day.projects!, protoName, {
+ value: { cost: 25, calls: 5, savingsUSD: 0, sessions: 1, path: '/Users/gone/constructor' },
+ enumerable: true, writable: true, configurable: true,
+ })
+ day.cost += 25
+ day.calls += 5
+ await seedCache(day)
+ await seedLiveTodaySession()
+ const range = coveringRange()
+
+ // Keep everything (exclude a non-matching term): the constructor project's
+ // $25 must be present alongside keep-me and drop-me.
+ const durable = await buildDurablePeriod({ range, label: 'p' }, { provider: 'all', exclude: ['zzz-nomatch'] })
+ const live = await liveOnly(range, [], ['zzz-nomatch'])
+ expect(durable.data.cost).toBeCloseTo(live.cost + DAY_COST + 25, 6)
+ expect(durable.unattributedCostUSD).toBe(0)
+ })
+
+ it('contributes nothing from a carried day whose every project is excluded', async () => {
+ await seedCache(carriedDayWithProjects(daysAgoStr(10)))
+ await seedLiveTodaySession()
+ const range = coveringRange()
+
+ const live = await liveOnly(range, [], ['keep-me', 'drop-me'])
+ clearSessionCache()
+ const durable = await buildDurablePeriod({ range, label: 'p' }, { provider: 'all', exclude: ['keep-me', 'drop-me'] })
+
+ expect(durable.data.cost).toBeCloseTo(live.cost, 6)
+ expect(durable.carriedCostUSD).toBe(0)
+ })
+
+ it('applies the project filter underneath a provider filter', async () => {
+ await seedCache(carriedDayWithProjects(daysAgoStr(10)))
+ await seedLiveTodaySession()
+ const range = coveringRange()
+
+ // Stated as the delta the filter must produce, so the assertion holds
+ // whatever the provider-scoped live parse contributes.
+ clearSessionCache()
+ const unfiltered = await buildDurablePeriod({ range, label: 'p' }, { provider: 'claude' })
+ clearSessionCache()
+ const filtered = await buildDurablePeriod({ range, label: 'p' }, { provider: 'claude', exclude: ['drop-me'] })
+
+ expect(unfiltered.data.cost - filtered.data.cost).toBeCloseTo(DROP.cost, 6)
+ expect(unfiltered.data.calls - filtered.data.calls).toBe(DROP.calls)
+ })
+
+ it('keeps the menubar payload in step with the report under a project filter', async () => {
+ await seedCache(carriedDayWithProjects(daysAgoStr(10)))
+ await seedLiveTodaySession()
+ const range = coveringRange()
+
+ clearSessionCache()
+ const menubar = await buildMenubarPayloadForRange({ range, label: 'p' }, { provider: 'all', exclude: ['drop-me'], optimize: false, timeline: false })
+ clearSessionCache()
+ const durable = await buildDurablePeriod({ range, label: 'p' }, { provider: 'all', exclude: ['drop-me'] })
+
+ // Both surfaces route the headline through the one shared builder, so a
+ // project filter must land on them identically.
+ expect(menubar.current.cost).toBe(durable.data.cost)
+ expect(menubar.current.calls).toBe(durable.data.calls)
+ expect(menubar.current.inputTokens).toBe(durable.data.inputTokens)
+ })
+
+ it('leaves the unfiltered headline exactly as it was', async () => {
+ await seedCache(carriedDayWithProjects(daysAgoStr(10)))
+ await seedLiveTodaySession()
+ const range = coveringRange()
+
+ const live = await liveOnly(range, [], [])
+ clearSessionCache()
+ const durable = await buildDurablePeriod({ range, label: 'p' }, { provider: 'all' })
+
+ expect(durable.data.cost).toBeCloseTo(live.cost + DAY_COST, 6)
+ expect(durable.data.calls).toBe(live.calls + DAY_CALLS)
+ expect(durable.carriedCostUSD).toBeCloseTo(DAY_COST, 6)
+ })
+})
+
+describe('carried days with no per-project split (pre-v15)', () => {
+ it('sets the unfilterable day aside instead of leaking it into a filtered headline', async () => {
+ await seedCache(carriedDayWithoutProjects(daysAgoStr(10)))
+ await seedLiveTodaySession()
+ const range = coveringRange()
+
+ const live = await liveOnly(range, [], ['drop-me'])
+ clearSessionCache()
+ const durable = await buildDurablePeriod({ range, label: 'p' }, { provider: 'all', exclude: ['drop-me'] })
+
+ // The day cannot be attributed to any project, so a project-filtered total
+ // cannot claim it. It is reported separately rather than silently folded in.
+ expect(durable.data.cost).toBeCloseTo(live.cost, 6)
+ expect(durable.unattributedCostUSD).toBeCloseTo(DAY_COST, 6)
+ })
+
+ it('reports a provider slice with no project split as unattributed too', async () => {
+ // A v14-era provider slice carried into a v15 day: the day knows its project
+ // split, that provider's slice does not. Under --provider the headline reads
+ // the slice, so the day drops out — say how much rather than lose it quietly.
+ const day = carriedDayWithProjects(daysAgoStr(10))
+ delete day.providers['claude']!.projects
+ await seedCache(day)
+ await seedLiveTodaySession()
+ const range = coveringRange()
+
+ clearSessionCache()
+ const durable = await buildDurablePeriod({ range, label: 'p' }, { provider: 'claude', exclude: ['drop-me'] })
+
+ expect(durable.unattributedCostUSD).toBeCloseTo(DAY_COST, 6)
+ expect(durable.carriedCostUSD).toBe(0)
+ })
+
+ it('says so in the overview instead of just showing a short total', async () => {
+ // Two cached days: one that cannot be attributed (the footnote's subject) and
+ // one that can. The attributable day keeps the headline non-zero from the
+ // CACHE alone, so the assertion tests the footnote rather than depending on
+ // the live parse to keep renderOverview off its "No usage found" path.
+ await seedCache(carriedDayWithoutProjects(daysAgoStr(10)), carriedDayWithProjects(daysAgoStr(5)))
+ await seedLiveTodaySession()
+ const range = coveringRange()
+
+ clearSessionCache()
+ const durable = await buildDurablePeriod({ range, label: 'This month' }, { provider: 'all', exclude: ['drop-me'] })
+ expect(durable.unattributedCostUSD).toBeGreaterThan(0)
+ expect(durable.data.cost).toBeGreaterThan(0)
+
+ const rendered = renderOverview(durable.liveProjects, {
+ label: 'This month',
+ color: false,
+ durable: {
+ cost: durable.data.cost,
+ savingsUSD: durable.data.savingsUSD,
+ calls: durable.data.calls,
+ sessions: durable.data.sessions,
+ inputTokens: durable.data.inputTokens,
+ outputTokens: durable.data.outputTokens,
+ cacheReadTokens: durable.data.cacheReadTokens,
+ cacheWriteTokens: durable.data.cacheWriteTokens,
+ days: durable.days,
+ carriedCostUSD: durable.carriedCostUSD,
+ unattributedCostUSD: durable.unattributedCostUSD,
+ },
+ })
+ expect(rendered).toContain('no per-project history')
+ })
+
+ it('still counts the day in full when no project filter is active', async () => {
+ await seedCache(carriedDayWithoutProjects(daysAgoStr(10)))
+ await seedLiveTodaySession()
+ const range = coveringRange()
+
+ const live = await liveOnly(range, [], [])
+ clearSessionCache()
+ const durable = await buildDurablePeriod({ range, label: 'p' }, { provider: 'all' })
+
+ expect(durable.data.cost).toBeCloseTo(live.cost + DAY_COST, 6)
+ expect(durable.unattributedCostUSD).toBe(0)
+ })
+})
diff --git a/tests/provider-turn-grouping.test.ts b/tests/provider-turn-grouping.test.ts
index bee7585..e729c5d 100644
--- a/tests/provider-turn-grouping.test.ts
+++ b/tests/provider-turn-grouping.test.ts
@@ -98,6 +98,47 @@ describe('provider turn grouping', () => {
expect(session.categoryBreakdown[turn.category].oneShotTurns).toBe(0)
})
+ it('classifies a range-sliced turn from the whole turn, not the surviving calls (#852)', async () => {
+ const chatsDir = join(home, '.gemini', 'tmp', 'project-b', 'chats')
+ await mkdir(chatsDir, { recursive: true })
+ await writeFile(join(chatsDir, 'session-slice.json'), JSON.stringify({
+ sessionId: 'gemini-slice-1',
+ startTime: '2026-05-16T10:00:00.000Z',
+ messages: [
+ { id: 'u1', timestamp: '2026-05-16T10:00:00.000Z', type: 'user', content: 'read then edit src/parser.ts' },
+ {
+ id: 'g1', timestamp: '2026-05-16T10:00:00.000Z', type: 'gemini', content: 'reading',
+ model: 'gemini-3.1-pro-preview', tokens: { input: 100, output: 30 },
+ toolCalls: [{ id: 't1', name: 'read_file', args: { path: 'src/parser.ts' } }],
+ },
+ {
+ id: 'g2', timestamp: '2026-05-16T11:00:00.000Z', type: 'gemini', content: 'editing',
+ model: 'gemini-3.1-pro-preview', tokens: { input: 90, output: 25 },
+ toolCalls: [{ id: 't2', name: 'edit_file', args: { path: 'src/parser.ts' } }],
+ },
+ ],
+ }))
+
+ const parseAllSessions = await loadParser()
+ // A range that keeps the 10:00 Read call but excludes the 11:00 Edit call,
+ // so the turn is sliced. `turnSlicedToRange`/`callsInRange` compare absolute
+ // times, so this is timezone-independent.
+ const sliceRange: DateRange = {
+ start: new Date('2026-05-16T10:00:00.000Z'),
+ end: new Date('2026-05-16T10:30:00.000Z'),
+ }
+ const projects = await parseAllSessions(sliceRange, 'gemini')
+ const turn = projects[0]!.sessions[0]!.turns[0]!
+
+ // Cost/calls are sliced to the range: only the Read call survives.
+ expect(turn.assistantCalls.map(c => c.deduplicationKey)).toEqual(['gemini:gemini-slice-1:g1'])
+ // But category/hasEdits are whole-turn judgments — the Edit is part of the
+ // exchange — so they stay classified from the FULL turn, matching the Claude
+ // path rather than being re-derived from the partial slice (which alone reads
+ // as a no-edit exploration turn).
+ expect(turn.hasEdits).toBe(true)
+ })
+
it('groups Mistral Vibe assistant messages and uses Vibe session_cost when present', async () => {
const sessionDir = join(vibeHome, 'logs', 'session', 'session_20260516_100000_vibe')
await mkdir(sessionDir, { recursive: true })
@@ -197,3 +238,46 @@ describe('provider turn grouping', () => {
}
})
})
+
+describe('provider turn range filtering', () => {
+ it('keeps the in-range calls of a codex turn that spans midnight instead of dropping the whole turn', async () => {
+ // Regression test for #852: the range filter keyed on the turn's FIRST
+ // call timestamp, so a long autonomous turn starting 23:59 the previous
+ // day was excluded from the next day's view entirely, losing every
+ // post-midnight call. One turn (t1) here has two token_count events
+ // straddling midnight; only the post-midnight call may survive.
+ const codexHome = join(home, 'codex')
+ const sessionDir = join(codexHome, 'sessions', '2026', '05', '15')
+ await mkdir(sessionDir, { recursive: true })
+ const lines = [
+ JSON.stringify({ type: 'session_meta', timestamp: '2026-05-15T23:55:00Z', payload: { session_id: 'sess-span', model: 'gpt-5.5', cwd: '/Users/test/project-a', originator: 'codex_cli_rs' } }),
+ JSON.stringify({ type: 'response_item', timestamp: '2026-05-15T23:57:00Z', payload: { type: 'message', role: 'user', content: [{ type: 'input_text', text: 'run the long task' }] } }),
+ JSON.stringify({ type: 'response_item', timestamp: '2026-05-15T23:58:00Z', payload: { type: 'function_call', name: 'exec_command', arguments: JSON.stringify({ command: 'npm test' }) } }),
+ JSON.stringify({ type: 'event_msg', timestamp: '2026-05-15T23:59:00Z', payload: { type: 'token_count', info: { last_token_usage: { input_tokens: 100, output_tokens: 30 }, total_token_usage: { total_tokens: 130 } } } }),
+ JSON.stringify({ type: 'response_item', timestamp: '2026-05-16T00:10:00Z', payload: { type: 'function_call', name: 'exec_command', arguments: JSON.stringify({ command: 'npm run build' }) } }),
+ JSON.stringify({ type: 'event_msg', timestamp: '2026-05-16T00:15:00Z', payload: { type: 'token_count', info: { last_token_usage: { input_tokens: 80, output_tokens: 20 }, total_token_usage: { total_tokens: 230 } } } }),
+ ]
+ await writeFile(join(sessionDir, 'rollout-span.jsonl'), lines.join('\n') + '\n')
+
+ process.env['CODEX_HOME'] = codexHome
+ try {
+ const parseAllSessions = await loadParser()
+ const projects = await parseAllSessions(dayRange(), 'codex')
+ const session = projects[0]!.sessions[0]!
+ const turn = session.turns[0]!
+
+ expect(session.turns).toHaveLength(1)
+ expect(turn.assistantCalls.map(call => new Date(call.timestamp).toISOString())).toEqual([
+ '2026-05-16T00:15:00.000Z',
+ ])
+ // The slice re-anchors the turn's timestamp from the user-message time
+ // (2026-05-15T23:57Z) to the first surviving call, so turn-anchored
+ // bucketing lands the slice on the day its calls actually fall in.
+ expect(new Date(turn.timestamp).toISOString()).toBe('2026-05-16T00:15:00.000Z')
+ expect(session.totalInputTokens).toBe(80)
+ expect(session.totalOutputTokens).toBe(20)
+ } finally {
+ delete process.env['CODEX_HOME']
+ }
+ })
+})
diff --git a/tests/providers/codex.test.ts b/tests/providers/codex.test.ts
index 9595e35..dba279d 100644
--- a/tests/providers/codex.test.ts
+++ b/tests/providers/codex.test.ts
@@ -160,6 +160,56 @@ describe('codex provider - session discovery', () => {
}])
})
+ it('deduplicates the same session_id across active and archived roots', async () => {
+ const sharedLines = [
+ sessionMeta({ cwd: '/Users/test/shared', session_id: 'sess-shared' }),
+ tokenCount({ last: { input: 100, output: 50 }, total: { total: 150 } }),
+ ]
+ const activePath = await writeSession(tmpDir, '2026-04-14', 'rollout-shared.jsonl', sharedLines)
+ const archivedCopyPath = await writeArchivedSession(tmpDir, 'rollout-shared.jsonl', sharedLines)
+ const distinctPath = await writeArchivedSession(tmpDir, 'rollout-distinct.jsonl', [
+ sessionMeta({ cwd: '/Users/test/distinct', session_id: 'sess-distinct' }),
+ tokenCount({ last: { input: 200, output: 50 }, total: { total: 250 } }),
+ ])
+
+ const provider = createCodexProvider(tmpDir)
+ const sessions = await provider.discoverSessions()
+ const paths = sessions.map(session => session.path)
+
+ expect(sessions).toHaveLength(2)
+ expect(paths).toEqual(expect.arrayContaining([activePath, distinctPath]))
+ expect(paths).not.toContain(archivedCopyPath)
+ })
+
+ it('does not double-count usage for an archived copy while counting distinct sessions', async () => {
+ const sharedLines = [
+ sessionMeta({ session_id: 'sess-shared' }),
+ tokenCount({ last: { input: 100, output: 50 }, total: { total: 150 } }),
+ ]
+ await writeSession(tmpDir, '2026-04-14', 'rollout-shared.jsonl', sharedLines)
+ await writeArchivedSession(tmpDir, 'rollout-shared-copy.jsonl', sharedLines)
+ await writeArchivedSession(tmpDir, 'rollout-distinct.jsonl', [
+ sessionMeta({ session_id: 'sess-distinct' }),
+ tokenCount({ last: { input: 200, output: 50 }, total: { total: 250 } }),
+ ])
+
+ const provider = createCodexProvider(tmpDir)
+ const sessions = await provider.discoverSessions()
+ const seenKeys = new Set()
+ const calls: ParsedProviderCall[] = []
+ for (const session of sessions) {
+ for await (const call of provider.createSessionParser(session, seenKeys).parse()) {
+ calls.push(call)
+ }
+ }
+
+ expect(calls.map(call => call.sessionId).sort()).toEqual(['sess-distinct', 'sess-shared'])
+ expect(calls.reduce(
+ (total, call) => total + call.inputTokens + call.cachedInputTokens + call.outputTokens + call.reasoningTokens,
+ 0,
+ )).toBe(400)
+ })
+
it('returns empty for non-existent directory', async () => {
const provider = createCodexProvider('/nonexistent/path/that/does/not/exist')
const sessions = await provider.discoverSessions()
@@ -332,6 +382,89 @@ describe('codex provider - JSONL parsing', () => {
expect(call.deduplicationKey).toContain('codex:')
})
+ it('parses large rollout lines and computes active timing for custom tool calls', async () => {
+ const largeTokenLine = JSON.stringify({
+ type: 'event_msg',
+ timestamp: '2026-04-14T10:01:10Z',
+ payload: {
+ type: 'token_count',
+ info: {
+ last_token_usage: { input_tokens: 100, cached_input_tokens: 0, output_tokens: 100, reasoning_output_tokens: 20, total_tokens: 220 },
+ total_token_usage: { input_tokens: 100, cached_input_tokens: 0, output_tokens: 100, reasoning_output_tokens: 20, total_tokens: 220 },
+ },
+ rate_limits: { filler: 'x'.repeat(40_000) },
+ },
+ })
+ const largeCompleteLine = JSON.stringify({
+ type: 'event_msg',
+ timestamp: '2026-04-14T10:01:11Z',
+ payload: { type: 'task_complete', last_agent_message: 'x'.repeat(40_000), duration_ms: 10_000 },
+ })
+ const filePath = await writeSession(tmpDir, '2026-04-14', 'rollout-timing.jsonl', [
+ sessionMeta({ session_id: 'sess-timing', model: 'gpt-5.5' }),
+ JSON.stringify({ type: 'event_msg', timestamp: '2026-04-14T10:00:00Z', payload: { type: 'task_started', turn_id: 'turn-1' } }),
+ userMessage('run the tool'),
+ JSON.stringify({ type: 'response_item', timestamp: '2026-04-14T10:00:02Z', payload: { type: 'custom_tool_call', call_id: 'call-1', name: 'exec' } }),
+ JSON.stringify({ type: 'response_item', timestamp: '2026-04-14T10:00:05Z', payload: { type: 'custom_tool_call_output', call_id: 'call-1', output: 'done' } }),
+ largeTokenLine,
+ largeCompleteLine,
+ ])
+
+ const provider = createCodexProvider(tmpDir)
+ const source = { path: filePath, project: 'test', provider: 'codex' }
+ const calls: ParsedProviderCall[] = []
+ for await (const call of provider.createSessionParser(source, new Set()).parse()) calls.push(call)
+
+ expect(calls).toHaveLength(1)
+ expect(calls[0]).toMatchObject({
+ outputTokens: 100,
+ reasoningTokens: 20,
+ tools: ['Bash'],
+ activeDurationMs: 7000,
+ activeGeneratedTokens: 120,
+ toolWaitMs: 3000,
+ })
+ })
+
+ it('keeps estimated output parsing for large token lines without usage info', async () => {
+ // Some rollout variants put token_count metadata beyond the compact head
+ // or omit `info` entirely. The line must still reach the character-based
+ // estimate path rather than being interpreted as an empty usage object.
+ const largeTokenLine = JSON.stringify({
+ type: 'event_msg',
+ timestamp: '2026-04-14T10:01:10Z',
+ payload: { type: 'token_count' },
+ filler: 'x'.repeat(40_000),
+ })
+ const assistantLine = JSON.stringify({
+ type: 'response_item',
+ timestamp: '2026-04-14T10:01:05Z',
+ payload: {
+ type: 'message',
+ role: 'assistant',
+ content: [{ type: 'output_text', text: 'generated response '.repeat(100) }],
+ },
+ })
+ const filePath = await writeSession(tmpDir, '2026-04-14', 'rollout-estimated-large.jsonl', [
+ sessionMeta({ session_id: 'sess-estimated-large', model: 'gpt-5.5' }),
+ userMessage('summarize the result'),
+ assistantLine,
+ largeTokenLine,
+ ])
+
+ const provider = createCodexProvider(tmpDir)
+ const source = { path: filePath, project: 'test', provider: 'codex' }
+ const calls: ParsedProviderCall[] = []
+ for await (const call of provider.createSessionParser(source, new Set()).parse()) calls.push(call)
+
+ expect(calls).toHaveLength(1)
+ expect(calls[0]).toMatchObject({
+ model: 'gpt-5.5',
+ costIsEstimated: true,
+ })
+ expect(calls[0]!.outputTokens).toBeGreaterThan(0)
+ })
+
it('attributes MCP calls emitted as event_msg/mcp_tool_call_end', async () => {
const filePath = await writeSession(tmpDir, '2026-04-14', 'rollout-mcp.jsonl', [
sessionMeta({ session_id: 'sess-mcp', model: 'gpt-5.5' }),
@@ -356,6 +489,154 @@ describe('codex provider - JSONL parsing', () => {
expect(calls[0]!.tools).toEqual(['mcp__github__get_issue'])
})
+ it('subtracts native MCP wait time from active timing', async () => {
+ const filePath = await writeSession(tmpDir, '2026-04-14', 'rollout-mcp-timing.jsonl', [
+ sessionMeta({ session_id: 'sess-mcp-timing', model: 'gpt-5.5' }),
+ JSON.stringify({ type: 'event_msg', timestamp: '2026-04-14T10:00:00Z', payload: { type: 'task_started' } }),
+ userMessage('look up the issue'),
+ JSON.stringify({
+ type: 'event_msg',
+ timestamp: '2026-04-14T10:00:05Z',
+ payload: {
+ type: 'mcp_tool_call_end',
+ call_id: 'mcp-1',
+ invocation: { server: 'github', tool: 'get_issue', arguments: {} },
+ duration: { secs: 3, nanos: 0 },
+ },
+ }),
+ tokenCount({
+ timestamp: '2026-04-14T10:00:08Z',
+ last: { input: 300, output: 100 },
+ total: { total: 400 },
+ }),
+ JSON.stringify({ type: 'event_msg', timestamp: '2026-04-14T10:00:10Z', payload: { type: 'task_complete', duration_ms: 10_000 } }),
+ ])
+
+ const provider = createCodexProvider(tmpDir)
+ const source = { path: filePath, project: 'test', provider: 'codex' }
+ const calls: ParsedProviderCall[] = []
+ for await (const call of provider.createSessionParser(source, new Set()).parse()) calls.push(call)
+
+ expect(calls).toHaveLength(1)
+ expect(calls[0]).toMatchObject({ activeDurationMs: 7000, toolWaitMs: 3000 })
+ })
+
+ it('keeps MCP attribution on large result lines', async () => {
+ const largeMcpLine = JSON.stringify({
+ type: 'event_msg',
+ timestamp: '2026-04-14T10:00:05Z',
+ payload: {
+ type: 'mcp_tool_call_end',
+ call_id: 'mcp-large',
+ invocation: { server: 'github', tool: 'get_issue', arguments: { duration: '1s', body: 'x'.repeat(100_000) } },
+ duration: { secs: 3, nanos: 0 },
+ result: { Ok: { content: [{ type: 'text', text: 'x'.repeat(40_000) }] } },
+ },
+ })
+ const filePath = await writeSession(tmpDir, '2026-04-14', 'rollout-mcp-large.jsonl', [
+ sessionMeta({ session_id: 'sess-mcp-large', model: 'gpt-5.5' }),
+ JSON.stringify({ type: 'event_msg', timestamp: '2026-04-14T10:00:00Z', payload: { type: 'task_started' } }),
+ userMessage('look up the issue'),
+ largeMcpLine,
+ tokenCount({ timestamp: '2026-04-14T10:00:08Z', last: { input: 300, output: 100 }, total: { total: 400 } }),
+ JSON.stringify({ type: 'event_msg', timestamp: '2026-04-14T10:00:10Z', payload: { type: 'task_complete', duration_ms: 10_000 } }),
+ ])
+
+ const provider = createCodexProvider(tmpDir)
+ const source = { path: filePath, project: 'test', provider: 'codex' }
+ const calls: ParsedProviderCall[] = []
+ for await (const call of provider.createSessionParser(source, new Set()).parse()) calls.push(call)
+
+ expect(calls).toHaveLength(1)
+ expect(calls[0]).toMatchObject({ tools: ['mcp__github__get_issue'], activeDurationMs: 7000, toolWaitMs: 3000 })
+ })
+
+ it('prefers payload-level duration over a nested duration_ms in large mcp_tool_call_end lines', async () => {
+ // Regression guard: a naive first-match regex would pick up the
+ // `duration_ms: 9999` inside invocation.arguments instead of the payload-level
+ // `duration: { secs: 3 }`. The depth-aware payload scan must win.
+ const largeMcpLine = JSON.stringify({
+ type: 'event_msg',
+ timestamp: '2026-04-14T10:00:05Z',
+ payload: {
+ type: 'mcp_tool_call_end',
+ call_id: 'mcp-duration-collision',
+ invocation: { server: 'github', tool: 'get_issue', arguments: { duration_ms: 9999, body: 'x'.repeat(40_000) } },
+ duration: { secs: 3, nanos: 0 },
+ result: { Ok: { content: [{ type: 'text', text: 'x'.repeat(40_000) }] } },
+ },
+ })
+ const filePath = await writeSession(tmpDir, '2026-04-14', 'rollout-mcp-duration-collision.jsonl', [
+ sessionMeta({ session_id: 'sess-mcp-duration-collision', model: 'gpt-5.5' }),
+ JSON.stringify({ type: 'event_msg', timestamp: '2026-04-14T10:00:00Z', payload: { type: 'task_started' } }),
+ userMessage('look up the issue'),
+ largeMcpLine,
+ tokenCount({ timestamp: '2026-04-14T10:00:08Z', last: { input: 300, output: 100 }, total: { total: 400 } }),
+ JSON.stringify({ type: 'event_msg', timestamp: '2026-04-14T10:00:10Z', payload: { type: 'task_complete', duration_ms: 10_000 } }),
+ ])
+
+ const provider = createCodexProvider(tmpDir)
+ const source = { path: filePath, project: 'test', provider: 'codex' }
+ const calls: ParsedProviderCall[] = []
+ for await (const call of provider.createSessionParser(source, new Set()).parse()) calls.push(call)
+
+ expect(calls).toHaveLength(1)
+ expect(calls[0]).toMatchObject({ tools: ['mcp__github__get_issue'], activeDurationMs: 7000, toolWaitMs: 3000 })
+ })
+
+ it('attributes a task_complete over everything since the last task_started, even across a suppressed one', async () => {
+ // A mid-file session_meta carrying forked_from_id re-arms the fork-replay
+ // cutoff, which swallows the task_started right behind it while its
+ // task_complete lands past the cutoff. Attribution then has to span both
+ // turns, exactly as it did before calls were buffered per task.
+ const filePath = await writeSession(tmpDir, '2026-04-14', 'rollout-suppressed-task-start.jsonl', [
+ sessionMeta({ session_id: 'sess-suppressed-start', model: 'gpt-5.5' }),
+ JSON.stringify({ type: 'event_msg', timestamp: '2026-04-14T10:00:00Z', payload: { type: 'task_started' } }),
+ userMessage('first ask'),
+ tokenCount({ timestamp: '2026-04-14T10:00:05Z', last: { input: 300, output: 100 }, total: { total: 400 } }),
+ JSON.stringify({ type: 'event_msg', timestamp: '2026-04-14T10:00:10Z', payload: { type: 'task_complete', duration_ms: 10_000 } }),
+ sessionMeta({ timestamp: '2026-04-14T10:00:11Z', session_id: 'sess-suppressed-start', model: 'gpt-5.5', forked_from_id: 'sess-parent' }),
+ JSON.stringify({ type: 'event_msg', timestamp: '2026-04-14T10:00:12Z', payload: { type: 'task_started' } }),
+ userMessage('second ask', '2026-04-14T10:00:18Z'),
+ tokenCount({ timestamp: '2026-04-14T10:00:20Z', last: { input: 300, output: 300 }, total: { total: 1000 } }),
+ JSON.stringify({ type: 'event_msg', timestamp: '2026-04-14T10:00:25Z', payload: { type: 'task_complete', duration_ms: 5_000 } }),
+ ])
+
+ const provider = createCodexProvider(tmpDir)
+ const source = { path: filePath, project: 'test', provider: 'codex' }
+ const calls: ParsedProviderCall[] = []
+ for await (const call of provider.createSessionParser(source, new Set()).parse()) calls.push(call)
+
+ expect(calls).toHaveLength(2)
+ // The second task_complete re-attributes the first turn too, so the 5s
+ // window is split across both by generated tokens rather than leaving the
+ // first turn pinned to its own 10s window.
+ expect(calls[0]!.activeDurationMs).toBeCloseTo(1250, 6)
+ expect(calls[1]!.activeDurationMs).toBeCloseTo(3750, 6)
+ expect(calls[0]!.activeDurationMs! + calls[1]!.activeDurationMs!).toBeCloseTo(5000, 6)
+ })
+
+ it('omits active timing when recorded tool wait consumes the task duration', async () => {
+ const filePath = await writeSession(tmpDir, '2026-04-14', 'rollout-degenerate-timing.jsonl', [
+ sessionMeta({ session_id: 'sess-degenerate-timing', model: 'gpt-5.5' }),
+ JSON.stringify({ type: 'event_msg', timestamp: '2026-04-14T10:00:00Z', payload: { type: 'task_started' } }),
+ userMessage('wait for the tool'),
+ JSON.stringify({ type: 'response_item', timestamp: '2026-04-14T10:00:00Z', payload: { type: 'custom_tool_call', call_id: 'call-1', name: 'exec' } }),
+ JSON.stringify({ type: 'response_item', timestamp: '2026-04-14T10:00:10Z', payload: { type: 'custom_tool_call_output', call_id: 'call-1', output: 'done' } }),
+ tokenCount({ timestamp: '2026-04-14T10:00:12Z', last: { input: 300, output: 100 }, total: { total: 400 } }),
+ JSON.stringify({ type: 'event_msg', timestamp: '2026-04-14T10:00:13Z', payload: { type: 'task_complete', duration_ms: 10_000 } }),
+ ])
+
+ const provider = createCodexProvider(tmpDir)
+ const source = { path: filePath, project: 'test', provider: 'codex' }
+ const calls: ParsedProviderCall[] = []
+ for await (const call of provider.createSessionParser(source, new Set()).parse()) calls.push(call)
+
+ expect(calls).toHaveLength(1)
+ expect(calls[0]!.activeDurationMs).toBeUndefined()
+ expect(calls[0]!.toolWaitMs).toBeUndefined()
+ })
+
it('attributes CLI-wrapped MCP calls (mcp-cli call server tool) to MCP + Bash', async () => {
const execStr = (command: string) => JSON.stringify({
type: 'response_item',
diff --git a/tests/sync-attribution-cli.test.ts b/tests/sync-attribution-cli.test.ts
new file mode 100644
index 0000000..295350f
--- /dev/null
+++ b/tests/sync-attribution-cli.test.ts
@@ -0,0 +1,157 @@
+/**
+ * CLI-level tests for `codeburn sync push --attribution`.
+ *
+ * Drives the real commander action against a mock IdP + collector:
+ * - --dry-run --attribution: NO telemetry reaches the traces endpoint
+ * - push WITHOUT the flag: no attribution span names on the wire
+ * - push WITH the flag: attribution spans arrive alongside usage spans
+ */
+
+import { execFileSync } from 'node:child_process'
+import { mkdtemp, rm, writeFile } from 'node:fs/promises'
+import { tmpdir } from 'node:os'
+import { join } from 'node:path'
+
+import { describe, it, expect, beforeAll, afterAll, beforeEach, afterEach, vi } from 'vitest'
+import { Command } from 'commander'
+
+import { startMockIdp, type MockIdp } from './fixtures/mock-idp.js'
+import type { ProjectSummary, SessionSummary, ParsedApiCall, TokenUsage } from '../src/types.js'
+
+const { parseAllSessionsMock } = vi.hoisted(() => ({ parseAllSessionsMock: vi.fn() }))
+vi.mock('../src/parser.js', () => ({ parseAllSessions: parseAllSessionsMock }))
+
+function git(cwd: string, args: string[], env: Record = {}): string {
+ return execFileSync('git', args, { cwd, encoding: 'utf-8', env: { ...process.env, ...env } }).trim()
+}
+
+function makeUsage(): TokenUsage {
+ return { inputTokens: 10, outputTokens: 5, cacheCreationInputTokens: 0, cacheReadInputTokens: 0, cachedInputTokens: 0, reasoningTokens: 0, webSearchRequests: 0 }
+}
+
+function makeCall(key: string, ts: string): ParsedApiCall {
+ return {
+ provider: 'test', model: 'test-model', usage: makeUsage(), costUSD: 0.01,
+ tools: [], mcpTools: [], skills: [], subagentTypes: [], hasAgentSpawn: false,
+ hasPlanMode: false, speed: 'standard', timestamp: ts, bashCommands: [],
+ deduplicationKey: key,
+ }
+}
+
+function makeSession(id: string, first: string, last: string, calls: ParsedApiCall[]): SessionSummary {
+ return {
+ sessionId: id, project: 'app', firstTimestamp: first, lastTimestamp: last,
+ totalCostUSD: 0.01, totalSavingsUSD: 0, totalInputTokens: 10, totalOutputTokens: 5,
+ totalReasoningTokens: 0, totalCacheReadTokens: 0, totalCacheWriteTokens: 0,
+ apiCalls: calls.length,
+ turns: [{ userMessage: 'x', assistantCalls: calls, timestamp: first, sessionId: id, category: 'coding', retries: 0, hasEdits: false }],
+ modelBreakdown: {}, toolBreakdown: {}, mcpBreakdown: {}, bashBreakdown: {},
+ categoryBreakdown: {} as SessionSummary['categoryBreakdown'], skillBreakdown: {}, subagentBreakdown: {},
+ }
+}
+
+let idp: MockIdp
+let tmpHome: string
+let repoDir: string
+const originalHome = process.env.HOME
+const originalXdg = process.env.XDG_CACHE_HOME
+const originalStore = process.env.CODEBURN_SYNC_TOKEN_STORE
+
+async function runPush(args: string[]): Promise {
+ const { registerSyncCommands } = await import('../src/sync/cli.js')
+ const program = new Command()
+ program.exitOverride() // throw instead of process.exit on commander errors
+ registerSyncCommands(program)
+ await program.parseAsync(['node', 'codeburn', 'sync', 'push', ...args])
+}
+
+beforeAll(async () => {
+ idp = await startMockIdp({ rotateTokens: false })
+ process.env.CODEBURN_SYNC_TOKEN_STORE = 'file'
+
+ // Real git repo with a remote — a recent commit inside the session window
+ repoDir = await mkdtemp(join(tmpdir(), 'codeburn-attr-cli-repo-'))
+ git(repoDir, ['init', '-b', 'main'])
+ git(repoDir, ['config', 'user.email', 't@e.com'])
+ git(repoDir, ['config', 'user.name', 'T'])
+ git(repoDir, ['remote', 'add', 'origin', 'git@github.com:acme/cli-widget.git'])
+ await writeFile(join(repoDir, 'f.txt'), 'x\n')
+ const commitIso = new Date(Date.now() - 60 * 60 * 1000).toISOString()
+ git(repoDir, ['add', '.'])
+ git(repoDir, ['commit', '-m', 'feat: recent'], { GIT_AUTHOR_DATE: commitIso, GIT_COMMITTER_DATE: commitIso })
+})
+
+afterAll(async () => {
+ await idp.close()
+ await rm(repoDir, { recursive: true, force: true })
+ if (originalStore === undefined) delete process.env.CODEBURN_SYNC_TOKEN_STORE
+ else process.env.CODEBURN_SYNC_TOKEN_STORE = originalStore
+})
+
+beforeEach(async () => {
+ tmpHome = await mkdtemp(join(tmpdir(), 'codeburn-attr-cli-'))
+ process.env.HOME = tmpHome
+ process.env.XDG_CACHE_HOME = join(tmpHome, '.cache')
+ idp.tracesRequests.length = 0
+
+ // Configure sync against the mock IdP + store the refresh token
+ const { writeSyncConfig } = await import('../src/sync/config.js')
+ const { createCredentialStore } = await import('../src/sync/credentials.js')
+ writeSyncConfig({ baseUrl: idp.baseUrl, clientId: 'mock-client-id', tracesPath: '/v1/traces', issuer: idp.baseUrl })
+ createCredentialStore().store('mock-refresh-token-v1')
+
+ // One session in the last hour, working in the real repo
+ const now = Date.now()
+ const first = new Date(now - 90 * 60 * 1000).toISOString()
+ const last = new Date(now - 30 * 60 * 1000).toISOString()
+ const session = makeSession('cli-sess-1', first, last, [makeCall(`call-${now}`, first)])
+ parseAllSessionsMock.mockResolvedValue([
+ { project: 'app', projectPath: repoDir, sessions: [session] } as ProjectSummary,
+ ])
+
+ // The push action reads process.cwd() for the attribution cwd — run from
+ // a neutral non-repo dir so nothing can come from a cwd fallback.
+ vi.spyOn(process, 'cwd').mockReturnValue(tmpHome)
+})
+
+afterEach(async () => {
+ vi.restoreAllMocks()
+ process.exitCode = 0
+ process.env.HOME = originalHome
+ if (originalXdg === undefined) delete process.env.XDG_CACHE_HOME
+ else process.env.XDG_CACHE_HOME = originalXdg
+ await rm(tmpHome, { recursive: true, force: true })
+})
+
+const spanNames = (): string[] =>
+ idp.tracesRequests.flatMap(r => {
+ const body = r.body as { resourceSpans?: Array<{ scopeSpans: Array<{ spans: Array<{ name: string }> }> }> }
+ return (body.resourceSpans ?? []).flatMap(rs => rs.scopeSpans.flatMap(ss => ss.spans.map(s => s.name)))
+ })
+
+describe('sync push --attribution (CLI level)', () => {
+ it('--dry-run --attribution sends nothing to the traces endpoint', async () => {
+ await runPush(['--dry-run', '--attribution'])
+ expect(idp.tracesRequests).toHaveLength(0)
+ })
+
+ it('push WITHOUT --attribution never emits attribution span names', async () => {
+ await runPush([])
+ expect(idp.tracesRequests.length).toBeGreaterThan(0)
+ const names = spanNames()
+ expect(names.length).toBeGreaterThan(0)
+ expect(names).not.toContain('codeburn.session.attribution')
+ expect(names).not.toContain('codeburn.commit')
+ expect(JSON.stringify(idp.tracesRequests)).not.toContain('git.sha')
+ })
+
+ it('push WITH --attribution emits usage + attribution spans', async () => {
+ await runPush(['--attribution'])
+ const names = spanNames()
+ expect(names).toContain('test/test-model') // usage span
+ expect(names).toContain('codeburn.session.attribution') // session span
+ expect(names).toContain('codeburn.commit') // commit span
+ const wire = JSON.stringify(idp.tracesRequests)
+ expect(wire).toContain('github.com/acme/cli-widget')
+ })
+})
diff --git a/tests/sync-attribution.test.ts b/tests/sync-attribution.test.ts
new file mode 100644
index 0000000..d4b8f6c
--- /dev/null
+++ b/tests/sync-attribution.test.ts
@@ -0,0 +1,753 @@
+/**
+ * Tests for sync git attribution (sync push --attribution).
+ *
+ * Covers: remote URL normalization, session→commit attribution record
+ * computation (reusing the yield engine), state-encoding dedup keys,
+ * attribution OTLP span construction, and the send/ledger pipeline.
+ */
+
+import { execFileSync } from 'node:child_process'
+import { mkdtemp, rm, writeFile } from 'node:fs/promises'
+import { tmpdir } from 'node:os'
+import { join } from 'node:path'
+import { createServer, type Server } from 'node:http'
+
+import { describe, it, expect, beforeEach, afterEach } from 'vitest'
+
+import type { ProjectSummary, SessionSummary } from '../src/types.js'
+import {
+ normalizeRemoteUrl,
+ computeAttributionRecords,
+ sanitizePrLinks,
+ MAX_PR_LINKS_PER_SESSION,
+ type SessionAttributionRecord,
+} from '../src/yield.js'
+import {
+ flattenAttributionRecords,
+ commitAttributionKey,
+ sessionAttributionKey,
+ buildAttributionOtlpPayload,
+ batchAttributionItems,
+ deriveTraceId,
+ SESSION_ATTRIBUTION_SPAN_NAME,
+ COMMIT_ATTRIBUTION_SPAN_NAME,
+ type OtlpAttribute,
+} from '../src/sync/otlp.js'
+
+// ── Git fixtures (mirrors yield-repo-grouping.test.ts) ────────────────
+
+function git(cwd: string, args: string[], env: Record = {}): string {
+ return execFileSync('git', args, {
+ cwd,
+ encoding: 'utf-8',
+ env: { ...process.env, ...env },
+ }).trim()
+}
+
+function initRepo(dir: string): void {
+ git(dir, ['init', '-b', 'main'])
+ git(dir, ['config', 'user.email', 'test@example.com'])
+ git(dir, ['config', 'user.name', 'Test'])
+}
+
+function commitAt(dir: string, message: string, iso: string): void {
+ git(dir, ['add', '.'])
+ git(dir, ['commit', '-m', message], {
+ GIT_AUTHOR_DATE: iso,
+ GIT_COMMITTER_DATE: iso,
+ })
+}
+
+function makeSession(overrides: Partial): SessionSummary {
+ return {
+ sessionId: 'session',
+ project: 'app',
+ firstTimestamp: '2026-01-01T10:00:00.000Z',
+ lastTimestamp: '2026-01-01T11:00:00.000Z',
+ totalCostUSD: 1,
+ totalSavingsUSD: 0,
+ totalInputTokens: 0,
+ totalOutputTokens: 0,
+ totalReasoningTokens: 0,
+ totalCacheReadTokens: 0,
+ totalCacheWriteTokens: 0,
+ apiCalls: 1,
+ turns: [],
+ modelBreakdown: {},
+ toolBreakdown: {},
+ mcpBreakdown: {},
+ bashBreakdown: {},
+ categoryBreakdown: {} as SessionSummary['categoryBreakdown'],
+ skillBreakdown: {},
+ subagentBreakdown: {},
+ ...overrides,
+ }
+}
+
+const range = {
+ start: new Date('2026-01-01T00:00:00.000Z'),
+ end: new Date('2026-01-02T00:00:00.000Z'),
+}
+
+// ── normalizeRemoteUrl ────────────────────────────────────────────────
+
+describe('normalizeRemoteUrl', () => {
+ it('normalizes scp-like ssh remotes', () => {
+ expect(normalizeRemoteUrl('git@github.com:acme/widget.git')).toBe('github.com/acme/widget')
+ expect(normalizeRemoteUrl('git@GitHub.com:Acme/Widget')).toBe('github.com/Acme/Widget')
+ })
+
+ it('normalizes ssh:// remotes, dropping user and port', () => {
+ expect(normalizeRemoteUrl('ssh://git@github.com/acme/widget.git')).toBe('github.com/acme/widget')
+ expect(normalizeRemoteUrl('ssh://git@gitlab.example.com:2222/group/sub/repo.git')).toBe('gitlab.example.com/group/sub/repo')
+ })
+
+ it('normalizes https remotes and strips embedded credentials', () => {
+ expect(normalizeRemoteUrl('https://github.com/acme/widget.git')).toBe('github.com/acme/widget')
+ expect(normalizeRemoteUrl('https://user:s3cret-token@github.com/acme/widget.git')).toBe('github.com/acme/widget')
+ expect(normalizeRemoteUrl('https://github.com/acme/widget/')).toBe('github.com/acme/widget')
+ })
+
+ it('returns null for local paths and file:// remotes', () => {
+ expect(normalizeRemoteUrl('/home/dev/repos/widget')).toBeNull()
+ expect(normalizeRemoteUrl('file:///home/dev/repos/widget')).toBeNull()
+ expect(normalizeRemoteUrl('../relative/repo')).toBeNull()
+ expect(normalizeRemoteUrl('')).toBeNull()
+ })
+
+ it('rejects Windows drive-letter paths (never a remote identity)', () => {
+ expect(normalizeRemoteUrl('C:/Users/alice/private/repo')).toBeNull()
+ expect(normalizeRemoteUrl('C:\\Users\\alice\\private\\repo')).toBeNull()
+ expect(normalizeRemoteUrl('c:/repo')).toBeNull()
+ expect(normalizeRemoteUrl('Z:\\work\\nda-client-repo')).toBeNull()
+ // Drive-relative (no slash after colon) — single-char host rejection
+ expect(normalizeRemoteUrl('C:repo')).toBeNull()
+ expect(normalizeRemoteUrl('c:relative\\path')).toBeNull()
+ })
+
+ it('rejects other adversarial forms without over-rejecting real remotes', () => {
+ // Single-character "host" is never a real remote host
+ expect(normalizeRemoteUrl('a:path/to/repo')).toBeNull()
+ expect(normalizeRemoteUrl('git@C:/foo')).toBeNull()
+ // Dotless intranet hosts remain valid (2+ chars)
+ expect(normalizeRemoteUrl('gitserver:team/repo.git')).toBe('gitserver/team/repo')
+ expect(normalizeRemoteUrl('git@gitbox:org/repo.git')).toBe('gitbox/org/repo')
+ })
+
+ it('never leaks credentials via scp-branch backtracking on malformed remotes', () => {
+ // Credential-prefixed remotes: the userinfo split happens BEFORE host
+ // matching, so a token can never be re-parsed as host:path.
+ expect(normalizeRemoteUrl('x-access-token:ghp_LIVETOKEN_abcdefghijklmnop@github.com/acme/private-repo.git')).toBeNull()
+ expect(normalizeRemoteUrl('oauth2:glpat-TOKEN@gitlab.com/org/repo.git')).toBeNull()
+ // One dropped slash: not a URL, must not fall through as host "https"
+ expect(normalizeRemoteUrl('https:/user:ghp_TOKEN@github.com/org/repo.git')).toBeNull()
+ // Multiple @: split at the first, residual @ fails the allow-list
+ expect(normalizeRemoteUrl('a@b@github.com:org/repo.git')).toBeNull()
+ expect(normalizeRemoteUrl('user@host:path@with-at')).toBeNull()
+ })
+
+ it('rejects transport-helper remotes and enforces shape + length on the identity', () => {
+ // git-remote-ext: embeds a local SSH key path
+ expect(normalizeRemoteUrl('ext::ssh -i /Users/me/.ssh/id_ed25519_work git@github.com %S /acme/private.git')).toBeNull()
+ expect(normalizeRemoteUrl('ext::sh -c whatever')).toBeNull()
+ // git-remote-codecommit: embeds an AWS profile name
+ expect(normalizeRemoteUrl('codecommit::us-east-1://MyAwsProfile@MyRepo')).toBeNull()
+ expect(normalizeRemoteUrl('codecommit::us-east-1://MyRepo')).toBeNull()
+ // Length bound
+ expect(normalizeRemoteUrl(`git@github.com:org/${'a'.repeat(300)}.git`)).toBeNull()
+ // Path segments must be repo-shaped (no spaces, colons, @)
+ expect(normalizeRemoteUrl('gitserver:has space/repo.git')).toBeNull()
+ // Legit multi-segment (GitLab subgroup) paths survive the allow-list
+ expect(normalizeRemoteUrl('https://gitlab.example.com/group/sub/repo.git')).toBe('gitlab.example.com/group/sub/repo')
+ })
+
+ it('normalizes .GIT case-insensitively and collapses doubled slashes to one join key', () => {
+ expect(normalizeRemoteUrl('git@github.com:acme/Repo.GIT')).toBe('github.com/acme/Repo')
+ expect(normalizeRemoteUrl('https://github.com/acme/Repo.Git')).toBe('github.com/acme/Repo')
+ expect(normalizeRemoteUrl('https://github.com/acme//repo.git')).toBe('github.com/acme/repo')
+ expect(normalizeRemoteUrl('git@github.com:acme//repo.git')).toBe('github.com/acme/repo')
+ })
+})
+
+// ── computeAttributionRecords ─────────────────────────────────────────
+
+describe('computeAttributionRecords', () => {
+ it('attributes commits with normalized remote, inMain, and timestamps', async () => {
+ const repoDir = await mkdtemp(join(tmpdir(), 'codeburn-attr-repo-'))
+ try {
+ initRepo(repoDir)
+ git(repoDir, ['remote', 'add', 'origin', 'git@github.com:acme/widget.git'])
+ await writeFile(join(repoDir, 'file.txt'), 'hello\n')
+ commitAt(repoDir, 'feat: shipped', '2026-01-01T10:30:00Z')
+ const sha = git(repoDir, ['rev-parse', 'HEAD'])
+
+ const session = makeSession({
+ sessionId: 'sess-a',
+ prLinks: ['https://github.com/acme/widget/pull/12'],
+ })
+ const projects = [
+ { project: 'app', projectPath: repoDir, sessions: [session] } as ProjectSummary,
+ ]
+
+ const records = computeAttributionRecords(projects, range, repoDir)
+
+ expect(records).toHaveLength(1)
+ const record = records[0]!
+ expect(record.sessionId).toBe('sess-a')
+ expect(record.repo).toBe('github.com/acme/widget')
+ expect(record.prLinks).toEqual(['https://github.com/acme/widget/pull/12'])
+ expect(record.commits).toHaveLength(1)
+ expect(record.commits[0]).toMatchObject({ sha, inMain: true, wasReverted: false })
+ expect(new Date(record.commits[0]!.timestamp).toISOString()).toBe('2026-01-01T10:30:00.000Z')
+ expect(record.firstTimestamp).toBe('2026-01-01T10:00:00.000Z')
+ expect(record.lastTimestamp).toBe('2026-01-01T11:00:00.000Z')
+ } finally {
+ await rm(repoDir, { recursive: true, force: true })
+ }
+ })
+
+ it('omits empty sessions that lost nothing — commits aged out of range are NOT retracted', async () => {
+ const repoDir = await mkdtemp(join(tmpdir(), 'codeburn-attr-empty-'))
+ try {
+ initRepo(repoDir)
+ git(repoDir, ['remote', 'add', 'origin', 'git@github.com:acme/widget.git'])
+ await writeFile(join(repoDir, 'file.txt'), 'hello\n')
+ // Commit outside every session window: no session competes for it, so
+ // nobody "lost" it. Even if this session previously synced a commit
+ // (now outside the --since range), emitting an empty record here would
+ // permanently zero a still-correct server-side count.
+ commitAt(repoDir, 'feat: unrelated', '2026-01-01T20:00:00Z')
+
+ const session = makeSession({ sessionId: 'sess-idle' })
+ const projects = [
+ { project: 'app', projectPath: repoDir, sessions: [session] } as ProjectSummary,
+ ]
+
+ expect(computeAttributionRecords(projects, range, repoDir)).toEqual([])
+
+ // …and therefore nothing can be sent, even with prior ledger state for
+ // this session (simulating an earlier wider---since push).
+ const { writeLedger } = await import('../src/sync/ledger.js')
+ writeLedger([{ key: 'attr:s:sess-idle:0123456789abcdef', ts: '2026-01-01T10:00:00.000Z' }])
+ const { collectUnsentAttribution } = await import('../src/sync/push.js')
+ expect(collectUnsentAttribution(computeAttributionRecords(projects, range, repoDir)).unsent).toEqual([])
+ } finally {
+ await rm(repoDir, { recursive: true, force: true })
+ }
+ })
+
+ it('emits a retraction candidate for a session that lost its commit to a tighter window', async () => {
+ const repoDir = await mkdtemp(join(tmpdir(), 'codeburn-attr-lost-'))
+ try {
+ initRepo(repoDir)
+ git(repoDir, ['remote', 'add', 'origin', 'git@github.com:acme/widget.git'])
+ await writeFile(join(repoDir, 'file.txt'), 'hello\n')
+ commitAt(repoDir, 'feat: contested', '2026-01-01T10:30:00Z')
+
+ const tight = makeSession({
+ sessionId: 'sess-tight',
+ firstTimestamp: '2026-01-01T10:15:00.000Z',
+ lastTimestamp: '2026-01-01T10:45:00.000Z',
+ })
+ const broadLoser = makeSession({ sessionId: 'sess-broad-loser' })
+ const projects = [
+ { project: 'app', projectPath: repoDir, sessions: [tight, broadLoser] } as ProjectSummary,
+ ]
+
+ const records = computeAttributionRecords(projects, range, repoDir)
+ const loser = records.find(r => r.sessionId === 'sess-broad-loser')!
+ // The loser IS emitted (retraction candidate: lostCandidacy) with zero
+ // commits — the sync layer decides whether a prior state warrants
+ // actually sending it.
+ expect(loser).toBeDefined()
+ expect(loser.commits).toEqual([])
+ expect(loser.repo).toBe('github.com/acme/widget')
+ } finally {
+ await rm(repoDir, { recursive: true, force: true })
+ }
+ })
+
+ it('drops commits when the repo has no remote, but keeps PR-linked sessions', async () => {
+ const repoDir = await mkdtemp(join(tmpdir(), 'codeburn-attr-noremote-'))
+ try {
+ initRepo(repoDir) // no origin remote
+ await writeFile(join(repoDir, 'file.txt'), 'hello\n')
+ commitAt(repoDir, 'feat: local only', '2026-01-01T10:30:00Z')
+
+ const withPr = makeSession({
+ sessionId: 'sess-pr',
+ prLinks: ['https://github.com/acme/widget/pull/7'],
+ firstTimestamp: '2026-01-01T10:15:00.000Z',
+ lastTimestamp: '2026-01-01T10:45:00.000Z',
+ })
+ const withoutPr = makeSession({ sessionId: 'sess-nopr' })
+ const projects = [
+ { project: 'app', projectPath: repoDir, sessions: [withPr, withoutPr] } as ProjectSummary,
+ ]
+
+ const records = computeAttributionRecords(projects, range, repoDir)
+
+ // sess-pr wins the commit window but has no repo identity, so commits
+ // are dropped; the PR link alone justifies the record. sess-nopr has
+ // nothing joinable and is omitted.
+ expect(records).toHaveLength(1)
+ expect(records[0]!.sessionId).toBe('sess-pr')
+ expect(records[0]!.repo).toBeNull()
+ expect(records[0]!.commits).toEqual([])
+ expect(records[0]!.prLinks).toEqual(['https://github.com/acme/widget/pull/7'])
+ } finally {
+ await rm(repoDir, { recursive: true, force: true })
+ }
+ })
+
+ it('never egresses the cwd repo for sessions whose project path did not resolve (fallback)', async () => {
+ // The reviewer's repro: push from inside a private repo while a session's
+ // project path no longer resolves. The fallback identity must NOT leak
+ // the cwd repo's remote or commits into that session's attribution.
+ const cwdRepo = await mkdtemp(join(tmpdir(), 'codeburn-attr-privatecwd-'))
+ try {
+ initRepo(cwdRepo)
+ git(cwdRepo, ['remote', 'add', 'origin', 'git@github.com:secret-org/nda-client-repo.git'])
+ await writeFile(join(cwdRepo, 'file.txt'), 'confidential\n')
+ commitAt(cwdRepo, 'feat: private work', '2026-01-01T10:30:00Z')
+
+ // Session A: project path is gone (deleted dir) — falls back to cwd
+ const orphanNoPr = makeSession({ sessionId: 'orphan-nopr', ...{ firstTimestamp: '2026-01-01T10:15:00.000Z', lastTimestamp: '2026-01-01T10:45:00.000Z' } })
+ // Session B: also fallback, but carries a PR link (session-native, safe)
+ const orphanWithPr = makeSession({
+ sessionId: 'orphan-pr',
+ prLinks: ['https://github.com/acme/widget/pull/9'],
+ firstTimestamp: '2026-01-01T12:00:00.000Z',
+ lastTimestamp: '2026-01-01T12:30:00.000Z',
+ })
+ // Session C: genuinely belongs to the cwd repo (own path resolves)
+ const genuine = makeSession({ sessionId: 'genuine-cwd', firstTimestamp: '2026-01-01T10:00:00.000Z', lastTimestamp: '2026-01-01T11:00:00.000Z' })
+
+ const projects = [
+ { project: 'ghost', projectPath: join(cwdRepo, 'no-such-dir-anymore-xyz'), sessions: [orphanNoPr] },
+ { project: 'ghost2', projectPath: '', sessions: [orphanWithPr] },
+ { project: 'real', projectPath: cwdRepo, sessions: [genuine] },
+ ] as ProjectSummary[]
+
+ const records = computeAttributionRecords(projects, range, cwdRepo)
+
+ // orphan-nopr: nothing joinable -> no record at all
+ expect(records.find(r => r.sessionId === 'orphan-nopr')).toBeUndefined()
+ // orphan-pr: PR link only — no repo, no commits
+ const pr = records.find(r => r.sessionId === 'orphan-pr')!
+ expect(pr.repo).toBeNull()
+ expect(pr.commits).toEqual([])
+ // genuine cwd session keeps full attribution
+ const own = records.find(r => r.sessionId === 'genuine-cwd')!
+ expect(own.repo).toBe('github.com/secret-org/nda-client-repo')
+ expect(own.commits).toHaveLength(1)
+ // The private repo identity appears ONLY on the genuine record
+ const leaked = records.filter(r => r.sessionId !== 'genuine-cwd' && JSON.stringify(r).includes('secret-org'))
+ expect(leaked).toEqual([])
+ } finally {
+ await rm(cwdRepo, { recursive: true, force: true })
+ }
+ })
+
+ it('fallback sessions cannot steal a commit from a genuine session', async () => {
+ const cwdRepo = await mkdtemp(join(tmpdir(), 'codeburn-attr-steal-'))
+ try {
+ initRepo(cwdRepo)
+ git(cwdRepo, ['remote', 'add', 'origin', 'git@github.com:acme/widget.git'])
+ await writeFile(join(cwdRepo, 'file.txt'), 'x\n')
+ commitAt(cwdRepo, 'feat: mine', '2026-01-01T10:30:00Z')
+
+ // Fallback session has the TIGHTER window (would win under old logic);
+ // genuine session has the broader window.
+ const fallbackTight = makeSession({
+ sessionId: 'fallback-tight',
+ prLinks: ['https://github.com/acme/widget/pull/2'],
+ firstTimestamp: '2026-01-01T10:25:00.000Z',
+ lastTimestamp: '2026-01-01T10:35:00.000Z',
+ })
+ const genuineBroad = makeSession({ sessionId: 'genuine-broad' })
+
+ const projects = [
+ { project: 'ghost', projectPath: '', sessions: [fallbackTight] },
+ { project: 'real', projectPath: cwdRepo, sessions: [genuineBroad] },
+ ] as ProjectSummary[]
+
+ const records = computeAttributionRecords(projects, range, cwdRepo)
+
+ expect(records.find(r => r.sessionId === 'fallback-tight')!.commits).toEqual([])
+ expect(records.find(r => r.sessionId === 'genuine-broad')!.commits).toHaveLength(1)
+ } finally {
+ await rm(cwdRepo, { recursive: true, force: true })
+ }
+ })
+
+ it('awards each commit to a single session (tightest window)', async () => {
+ const repoDir = await mkdtemp(join(tmpdir(), 'codeburn-attr-overlap-'))
+ try {
+ initRepo(repoDir)
+ git(repoDir, ['remote', 'add', 'origin', 'https://github.com/acme/widget.git'])
+ await writeFile(join(repoDir, 'file.txt'), 'hello\n')
+ commitAt(repoDir, 'feat: shared window', '2026-01-01T10:30:00Z')
+
+ const tight = makeSession({
+ sessionId: 'sess-tight',
+ firstTimestamp: '2026-01-01T10:15:00.000Z',
+ lastTimestamp: '2026-01-01T10:45:00.000Z',
+ })
+ const broad = makeSession({ sessionId: 'sess-broad' })
+ const projects = [
+ { project: 'app', projectPath: repoDir, sessions: [tight, broad] } as ProjectSummary,
+ ]
+
+ const records = computeAttributionRecords(projects, range, repoDir)
+
+ // Both sessions get records (the loser is a retraction candidate),
+ // but the commit is awarded exactly once — to the tighter window.
+ expect(records).toHaveLength(2)
+ const tightRecord = records.find(r => r.sessionId === 'sess-tight')!
+ const broadRecord = records.find(r => r.sessionId === 'sess-broad')!
+ expect(tightRecord.commits).toHaveLength(1)
+ expect(broadRecord.commits).toEqual([])
+ } finally {
+ await rm(repoDir, { recursive: true, force: true })
+ }
+ })
+})
+
+// ── Dedup keys and flattening ─────────────────────────────────────────
+
+function makeRecord(overrides: Partial = {}): SessionAttributionRecord {
+ return {
+ sessionId: 'sess-1',
+ project: 'app',
+ repo: 'github.com/acme/widget',
+ prLinks: ['https://github.com/acme/widget/pull/3'],
+ commits: [
+ { sha: 'a'.repeat(40), timestamp: '2026-01-01T10:30:00.000Z', inMain: true, wasReverted: false },
+ ],
+ firstTimestamp: '2026-01-01T10:00:00.000Z',
+ lastTimestamp: '2026-01-01T11:00:00.000Z',
+ ...overrides,
+ }
+}
+
+describe('attribution dedup keys', () => {
+ it('encodes commit state so a state transition mints a new key', () => {
+ const before = commitAttributionKey('sess-1', 'abc123', false, false)
+ const merged = commitAttributionKey('sess-1', 'abc123', true, false)
+ const reverted = commitAttributionKey('sess-1', 'abc123', true, true)
+ expect(new Set([before, merged, reverted]).size).toBe(3)
+ // Same state = same key (ledger dedupes repeats)
+ expect(commitAttributionKey('sess-1', 'abc123', true, false)).toBe(merged)
+ })
+
+ it('session key is stable for identical state and changes with commit state', () => {
+ const record = makeRecord()
+ expect(sessionAttributionKey(record)).toBe(sessionAttributionKey(makeRecord()))
+
+ const mutated = makeRecord({
+ commits: [{ sha: 'a'.repeat(40), timestamp: '2026-01-01T10:30:00.000Z', inMain: true, wasReverted: true }],
+ })
+ expect(sessionAttributionKey(mutated)).not.toBe(sessionAttributionKey(record))
+ })
+
+ it('session key changes when the window or project changes (ongoing sessions re-emit)', () => {
+ const base = makeRecord()
+ const grown = makeRecord({ lastTimestamp: '2026-01-01T12:00:00.000Z' })
+ const renamed = makeRecord({ project: 'app-renamed' })
+ expect(sessionAttributionKey(grown)).not.toBe(sessionAttributionKey(base))
+ expect(sessionAttributionKey(renamed)).not.toBe(sessionAttributionKey(base))
+ })
+
+ it('flattens one session item plus one item per commit', () => {
+ const items = flattenAttributionRecords([makeRecord()])
+ expect(items).toHaveLength(2)
+ expect(items[0]).toMatchObject({ kind: 'session', commitCount: 1, endTimestamp: '2026-01-01T11:00:00.000Z' })
+ expect(items[1]).toMatchObject({ kind: 'commit', sha: 'a'.repeat(40), inMain: true, wasReverted: false })
+ expect(items.map(i => i.dedupKey)).toEqual([
+ sessionAttributionKey(makeRecord()),
+ commitAttributionKey('sess-1', 'a'.repeat(40), true, false),
+ ])
+ })
+})
+
+// ── PR link sanitization ──────────────────────────────────────────────
+
+describe('sanitizePrLinks', () => {
+ it('keeps only https URLs shaped like org/repo/pull/N', () => {
+ expect(sanitizePrLinks([
+ 'https://github.com/acme/widget/pull/12',
+ 'https://ghe.corp.example.com/team/svc/pull/3', // GHE hosts allowed
+ 'http://github.com/acme/widget/pull/12', // not https
+ 'javascript:alert(1)', // not a URL shape we accept
+ 'https://github.com/acme/widget/issues/12', // not a PR path
+ 'https://github.com/acme/widget/pull/12/files', // extra path segment
+ 'not a url at all',
+ '',
+ 'https://github.com/acme/widget/pull/notanumber',
+ ])).toEqual([
+ 'https://ghe.corp.example.com/team/svc/pull/3',
+ 'https://github.com/acme/widget/pull/12',
+ ])
+ })
+
+ it('rebuilds links from origin + pathname: userinfo, query, and fragment never survive', () => {
+ expect(sanitizePrLinks([
+ 'https://alice:ghp_TOKEN@github.com/acme/widget/pull/5',
+ 'https://github.com/acme/widget/pull/7?notification_referrer_id=xyz',
+ 'https://github.com/acme/widget/pull/6#pullrequestreview-123',
+ ])).toEqual([
+ 'https://github.com/acme/widget/pull/5',
+ 'https://github.com/acme/widget/pull/6',
+ 'https://github.com/acme/widget/pull/7',
+ ])
+ })
+
+ it('dedupes links that collapse to the same rebuilt URL', () => {
+ expect(sanitizePrLinks([
+ 'https://github.com/acme/widget/pull/9',
+ 'https://github.com/acme/widget/pull/9?ref=a',
+ 'https://github.com/acme/widget/pull/9#comment',
+ ])).toEqual(['https://github.com/acme/widget/pull/9'])
+ })
+
+ it('drops oversized inputs and caps the count per session', () => {
+ // A long referrer query is stripped, so the link survives...
+ const longQuery = `https://github.com/acme/widget/pull/1?x=${'a'.repeat(300)}`
+ expect(sanitizePrLinks([longQuery])).toEqual(['https://github.com/acme/widget/pull/1'])
+ // ...but pathological inputs beyond the input bound are dropped outright
+ const huge = `https://github.com/acme/widget/pull/1?x=${'a'.repeat(600)}`
+ expect(sanitizePrLinks([huge])).toEqual([])
+ // And a rebuilt link that is itself oversized is dropped
+ const longPath = `https://github.com/${'o'.repeat(150)}/${'r'.repeat(80)}/pull/1`
+ expect(sanitizePrLinks([longPath])).toEqual([])
+
+ const many = Array.from({ length: 30 }, (_, i) => `https://github.com/acme/widget/pull/${i + 1}`)
+ expect(sanitizePrLinks(many)).toHaveLength(MAX_PR_LINKS_PER_SESSION)
+ })
+})
+
+// ── OTLP payload ──────────────────────────────────────────────────────
+
+function attrMap(attributes: OtlpAttribute[]): Record {
+ return Object.fromEntries(attributes.map(a => [a.key, a.value]))
+}
+
+describe('buildAttributionOtlpPayload', () => {
+ it('builds session and commit spans sharing the session traceId', () => {
+ const items = flattenAttributionRecords([makeRecord()])
+ const payload = buildAttributionOtlpPayload(items)
+
+ const resource = payload.resourceSpans[0]!
+ const resourceAttrs = attrMap(resource.resource.attributes)
+ expect(resourceAttrs['codeburn.attribution_methodology']).toEqual({ stringValue: 'timestamp-window' })
+ expect(resourceAttrs['codeburn.device_id']).toBeDefined()
+
+ const spans = resource.scopeSpans[0]!.spans
+ expect(spans).toHaveLength(2)
+
+ const sessionSpan = spans.find(s => s.name === SESSION_ATTRIBUTION_SPAN_NAME)!
+ const commitSpan = spans.find(s => s.name === COMMIT_ATTRIBUTION_SPAN_NAME)!
+ expect(sessionSpan.traceId).toBe(deriveTraceId('sess-1'))
+ expect(commitSpan.traceId).toBe(deriveTraceId('sess-1'))
+ expect(sessionSpan.spanId).not.toBe(commitSpan.spanId)
+
+ const sessionAttrs = attrMap(sessionSpan.attributes)
+ expect(sessionAttrs['ai.session_id']).toEqual({ stringValue: 'sess-1' })
+ expect(sessionAttrs['ai.project']).toEqual({ stringValue: 'app' })
+ expect(sessionAttrs['git.repo']).toEqual({ stringValue: 'github.com/acme/widget' })
+ expect(sessionAttrs['git.commit_count']).toEqual({ intValue: '1' })
+ expect(sessionAttrs['git.pr_links']).toEqual({
+ arrayValue: { values: [{ stringValue: 'https://github.com/acme/widget/pull/3' }] },
+ })
+ // Session span carries the real window as its duration
+ expect(sessionSpan.startTimeUnixNano).toBe((BigInt(new Date('2026-01-01T10:00:00.000Z').getTime()) * 1_000_000n).toString())
+ expect(sessionSpan.endTimeUnixNano).toBe((BigInt(new Date('2026-01-01T11:00:00.000Z').getTime()) * 1_000_000n).toString())
+
+ const commitAttrs = attrMap(commitSpan.attributes)
+ expect(commitAttrs['git.sha']).toEqual({ stringValue: 'a'.repeat(40) })
+ expect(commitAttrs['git.in_main']).toEqual({ boolValue: true })
+ expect(commitAttrs['git.was_reverted']).toEqual({ boolValue: false })
+ expect(commitAttrs['git.repo']).toEqual({ stringValue: 'github.com/acme/widget' })
+ })
+
+ it('omits git.repo when null and pr_links when empty', () => {
+ const items = flattenAttributionRecords([makeRecord({ repo: null, prLinks: [], commits: [] })])
+ const payload = buildAttributionOtlpPayload(items)
+ const spans = payload.resourceSpans[0]!.scopeSpans[0]!.spans
+ expect(spans).toHaveLength(1)
+ const attrs = attrMap(spans[0]!.attributes)
+ expect(attrs['git.repo']).toBeUndefined()
+ expect(attrs['git.pr_links']).toBeUndefined()
+ expect(attrs['git.commit_count']).toEqual({ intValue: '0' })
+ })
+
+ it('batches items by maxBatchSize', () => {
+ const items = flattenAttributionRecords([makeRecord(), makeRecord({ sessionId: 'sess-2' })])
+ expect(batchAttributionItems(items, 3).map(b => b.length)).toEqual([3, 1])
+ })
+
+ it('clamps span end time: never 0, never earlier than start + 1ms', () => {
+ // Session window ends BEFORE it starts (out-of-order provider timestamps)
+ const outOfOrder = flattenAttributionRecords([makeRecord({
+ firstTimestamp: '2026-01-01T11:00:00.000Z',
+ lastTimestamp: '2026-01-01T10:00:00.000Z',
+ })])
+ const span1 = buildAttributionOtlpPayload(outOfOrder).resourceSpans[0]!.scopeSpans[0]!.spans[0]!
+ expect(BigInt(span1.endTimeUnixNano)).toBe(BigInt(span1.startTimeUnixNano) + 1_000_000n)
+
+ // Malformed end timestamp (toUnixNano -> 0)
+ const malformed = flattenAttributionRecords([makeRecord({ lastTimestamp: 'not-a-date' })])
+ const span2 = buildAttributionOtlpPayload(malformed).resourceSpans[0]!.scopeSpans[0]!.spans[0]!
+ expect(span2.endTimeUnixNano).not.toBe('0')
+ expect(BigInt(span2.endTimeUnixNano)).toBe(BigInt(span2.startTimeUnixNano) + 1_000_000n)
+ })
+})
+
+// ── Send + ledger pipeline ────────────────────────────────────────────
+
+type MockResponse = { status: number; body?: unknown; headers?: Record }
+
+function startMockOtlp(responses: MockResponse[]): Promise<{
+ url: string
+ server: Server
+ requests: Array<{ auth: string | undefined; body: unknown }>
+}> {
+ const requests: Array<{ auth: string | undefined; body: unknown }> = []
+ let idx = 0
+
+ return new Promise(resolve => {
+ const server = createServer((req, res) => {
+ let raw = ''
+ req.on('data', c => { raw += c })
+ req.on('end', () => {
+ requests.push({ auth: req.headers.authorization, body: JSON.parse(raw || '{}') })
+ const r = responses[Math.min(idx, responses.length - 1)]!
+ idx++
+ res.writeHead(r.status, { 'Content-Type': 'application/json', ...r.headers })
+ res.end(r.body !== undefined ? JSON.stringify(r.body) : '{}')
+ })
+ })
+ server.listen(0, '127.0.0.1', () => {
+ const addr = server.address() as { port: number }
+ resolve({ url: `http://127.0.0.1:${addr.port}/v1/traces`, server, requests })
+ })
+ })
+}
+
+let tmpDir: string
+const originalHome = process.env.HOME
+const originalXdgCache = process.env.XDG_CACHE_HOME
+
+beforeEach(async () => {
+ tmpDir = await mkdtemp(join(tmpdir(), 'codeburn-attr-push-'))
+ process.env.HOME = tmpDir
+ process.env.XDG_CACHE_HOME = join(tmpDir, '.cache')
+})
+
+afterEach(async () => {
+ process.env.HOME = originalHome
+ if (originalXdgCache === undefined) delete process.env.XDG_CACHE_HOME
+ else process.env.XDG_CACHE_HOME = originalXdgCache
+ await rm(tmpDir, { recursive: true, force: true })
+})
+
+describe('sendAttributionBatches + collectUnsentAttribution', () => {
+ it('sends attribution spans, ledgers dedup keys, and filters them on the next collect', async () => {
+ const { sendAttributionBatches, collectUnsentAttribution } = await import('../src/sync/push.js')
+ const { readLedger } = await import('../src/sync/ledger.js')
+
+ const record = makeRecord()
+ const first = collectUnsentAttribution([record])
+ expect(first.unsent).toHaveLength(2)
+
+ const mock = await startMockOtlp([{ status: 200 }])
+ try {
+ const result = await sendAttributionBatches({
+ endpoint: mock.url,
+ accessToken: 'token-1',
+ batches: [first.unsent],
+ })
+
+ expect(result.outcome).toBe('complete')
+ expect(result.totalSent).toBe(2)
+ expect(result.totalCostSent).toBe(0)
+ expect(mock.requests).toHaveLength(1)
+ expect(mock.requests[0]!.auth).toBe('Bearer token-1')
+
+ const body = mock.requests[0]!.body as { resourceSpans: Array<{ scopeSpans: Array<{ spans: Array<{ name: string }> }> }> }
+ const names = body.resourceSpans[0]!.scopeSpans[0]!.spans.map(s => s.name).sort()
+ expect(names).toEqual([COMMIT_ATTRIBUTION_SPAN_NAME, SESSION_ATTRIBUTION_SPAN_NAME])
+
+ const ledgered = readLedger().map(e => e.key).sort()
+ expect(ledgered).toEqual(first.unsent.map(i => i.dedupKey).sort())
+
+ // Identical state on the next push: nothing unsent
+ expect(collectUnsentAttribution([record]).unsent).toEqual([])
+
+ // State transition (commit reverted): the changed facts re-send
+ const mutated = makeRecord({
+ commits: [{ sha: 'a'.repeat(40), timestamp: '2026-01-01T10:30:00.000Z', inMain: true, wasReverted: true }],
+ })
+ const after = collectUnsentAttribution([mutated])
+ expect(after.unsent.map(i => i.kind).sort()).toEqual(['commit', 'session'])
+ } finally {
+ mock.server.close()
+ }
+ })
+
+ it('retracts a session span when its commit migrates to a tighter-window session', async () => {
+ const { sendAttributionBatches, collectUnsentAttribution } = await import('../src/sync/push.js')
+
+ const sha = 'b'.repeat(40)
+ const commit = { sha, timestamp: '2026-01-01T10:30:00.000Z', inMain: true, wasReverted: false }
+
+ // Push 1: session A (broad window) owns the commit
+ const push1 = collectUnsentAttribution([makeRecord({ sessionId: 'sess-A', prLinks: [], commits: [commit] })])
+ expect(push1.unsent).toHaveLength(2)
+ const mock = await startMockOtlp([{ status: 200 }])
+ try {
+ await sendAttributionBatches({ endpoint: mock.url, accessToken: 't', batches: [push1.unsent] })
+
+ // Push 2: a later-parsed tighter session B now wins the commit; A is empty
+ const push2 = collectUnsentAttribution([
+ makeRecord({ sessionId: 'sess-A', prLinks: [], commits: [] }), // loser: retraction candidate
+ makeRecord({ sessionId: 'sess-B', prLinks: [], commits: [commit] }), // winner
+ ])
+
+ // A re-emits with commit_count 0 (retraction), B emits session + commit
+ const kinds = push2.unsent.map(i => `${i.sessionId}:${i.kind}`).sort()
+ expect(kinds).toEqual(['sess-A:session', 'sess-B:commit', 'sess-B:session'])
+ const retraction = push2.unsent.find(i => i.sessionId === 'sess-A')!
+ expect(retraction.commitCount).toBe(0)
+ expect(retraction.dedupKey).not.toBe(push1.unsent.find(i => i.kind === 'session')!.dedupKey)
+
+ // A session that was NEVER sent stays excluded when empty
+ const neverSent = collectUnsentAttribution([
+ makeRecord({ sessionId: 'sess-never', prLinks: [], commits: [] }),
+ ])
+ expect(neverSent.unsent).toEqual([])
+ } finally {
+ mock.server.close()
+ }
+ })
+
+ it('does not ledger on server error', async () => {
+ const { sendAttributionBatches } = await import('../src/sync/push.js')
+ const { readLedger } = await import('../src/sync/ledger.js')
+
+ const items = flattenAttributionRecords([makeRecord()])
+ const mock = await startMockOtlp([{ status: 500 }])
+ try {
+ const result = await sendAttributionBatches({
+ endpoint: mock.url,
+ accessToken: 'token-1',
+ batches: [items],
+ })
+ expect(result.outcome).toBe('server-error')
+ expect(readLedger()).toEqual([])
+ } finally {
+ mock.server.close()
+ }
+ })
+})