Merge branch 'main' into fix/opencode-session-model

This commit is contained in:
AgentSeal 2026-08-03 22:56:30 +02:00
commit 80f5ed6cf7
101 changed files with 8547 additions and 471 deletions

View file

@ -0,0 +1,45 @@
name: Build Windows Store package
on:
workflow_dispatch:
pull_request:
paths:
- .github/workflows/build-windows-store.yml
- app/**
- src/**
- package.json
- package-lock.json
permissions:
contents: read
jobs:
appx:
runs-on: windows-latest
steps:
- uses: actions/checkout@v6
- uses: actions/setup-node@v6
with:
node-version: 22.13.0
cache: npm
cache-dependency-path: |
package-lock.json
app/package-lock.json
- name: Install CLI dependencies
run: npm ci
- name: Install desktop dependencies
run: npm ci --prefix app
- name: Build Microsoft Store package
run: npm --prefix app run package:store
- name: Upload Store package
uses: actions/upload-artifact@v6
with:
name: CodeBurn-Microsoft-Store
path: app/release/CodeBurn-Store-*.appx
if-no-files-found: error
retention-days: 14

View file

@ -1,5 +1,27 @@
# Changelog
## 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. (#833)
- **Combined-device scope in the desktop Dashboard**, mirroring the menu bar. A Local / Combined toggle aggregates paired-device usage in the Overview hero and the menu bar badge, degrading gracefully to the local figure when a peer is unreachable; the badge then shows a dimmed `reachable/total` marker so a momentary drop to the local number reads as "a peer is unreachable" rather than a glitch. (#866, #867, thanks @marcreynolds)
### 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)
- **Midnight-straddling turns keep both halves.** A turn whose calls span local midnight was attributed whole to its start day, so `codeburn today` under-reported until the turn ended and multi-day totals mis-split it. Calls are now range-filtered inside the turn so each day gets the calls that belong to it, and By Activity and the daily turn counts reconcile with the headline. (#853, thanks @KENSHI601)
- **`--provider <x>` no longer leaks Claude spend into the detail panels.** A provider-filtered run still ran the Claude scan, whose orphan pass re-injected every cached Claude session, so By Project / By Model / By Activity showed Claude usage under, e.g., `--provider cursor` while the headline was correct. (#872, thanks @ozymandiashh)
- **A degraded session parse no longer freezes daily history.** A read-only parse that served a stale or missing session file was treated as complete and finalized days it never covered, freezing warm-cache ingestion; a corrupt refresh lock is now recovered rather than ending ingestion, and a legitimately idle tail is no longer re-derived on every launch. (#856, thanks @avs-io)
- **Pi / Oh My Pi transcripts with a leading title record are discovered.** OMP writes a `type: "title"` line before the session header; discovery now scans a bounded number of leading lines for the first session record instead of requiring it on the first physical line. (#846, #859, thanks @jbspeakr, @avs-io)
### Fixed
- Claude Desktop and Cowork sessions are discovered for Windows Microsoft Store (MSIX) installs. (#611)
## 0.9.19 - 2026-07-20
One version across every surface: CLI, macOS menubar, and the desktop app all ship as 0.9.19.

View file

@ -1,5 +1,5 @@
<p align="center">
<a href="https://claude.com/open-source-max"><img src="https://img.shields.io/badge/Claude_for_Open_Source-Recipient-da7756?style=for-the-badge&labelColor=1a1a1a" alt="Claude for Open Source Recipient" /></a>
<a href="https://claude.com/open-source-max"><img src="https://raw.githubusercontent.com/getagentseal/codeburn/main/assets/open-source-recipient.png" alt="Codex and Claude for Open Source Recipient" width="720" /></a>
</p>
<p align="center">

View file

@ -66,6 +66,7 @@ npm --prefix app run package # macOS, both arm64 and x64
npm --prefix app run package:arm64 # macOS arm64 only (faster on Apple Silicon)
npm --prefix app run package:x64 # macOS x64 only
npm --prefix app run package:win # Windows NSIS installer, x64
npm --prefix app run package:store # Microsoft Store AppX, x64 (Windows host only)
npm --prefix app run package:linux # Linux AppImage, x64
```
@ -184,6 +185,27 @@ shows **"Windows protected your PC"**. Users click **"More info" → "Run
anyway"** to launch it. This is expected for an unsigned build; the only fix is
a purchased code-signing (Authenticode/EV) certificate.
### Microsoft Store (`package:store`)
The Store build is a separate AppX target so the GitHub NSIS installer remains
unchanged. AppX packaging requires Windows 10 or newer and is built by the
manual `Build Windows Store package` GitHub Actions workflow on
`windows-latest`. Download its `CodeBurn-Microsoft-Store` workflow artifact and
upload the contained `CodeBurn-Store-<version>-x64.appx` file in Partner Center.
The manifest identity must exactly match the reserved Partner Center product:
- Identity name: `Codeburn.CodeBurn`
- Publisher: `CN=3EFA3336-87E1-46F2-9DFA-2EB5A7693F89`
- Publisher display name: `Codeburn`
- Store ID: `9P0R4ZL5XMB8`
The Store package is intentionally unsigned: Microsoft signs it during Store
submission. Direct sideloading requires a separate trusted or development
certificate. The AppX declares `runFullTrust` (electron-builder's required
default for Electron apps), so CodeBurn retains access to the user's local
provider session files rather than running in a UWP application sandbox.
### Linux (`package:linux`)
`electron-builder --linux` produces a single artifact in `app/release/`:

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

View file

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

View file

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

View file

@ -20,7 +20,7 @@ async function invoke<T>(channel: string, ...args: unknown[]): Promise<T> {
// 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),

View file

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

View file

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

View file

@ -16,6 +16,7 @@
"package:arm64": "npm run stage-cli && npm run build && electron-builder --mac --arm64",
"package:x64": "npm run stage-cli && npm run build && electron-builder --mac --x64",
"package:win": "npm run stage-cli && npm run build && electron-builder --win",
"package:store": "npm run stage-cli && npm run build && electron-builder --win appx --x64",
"package:linux": "npm run stage-cli && npm run build && electron-builder --linux"
},
"dependencies": {
@ -100,6 +101,20 @@
"perMachine": false,
"artifactName": "CodeBurn-Setup-${version}.${ext}"
},
"appx": {
"applicationId": "CodeBurn",
"identityName": "Codeburn.CodeBurn",
"publisher": "CN=3EFA3336-87E1-46F2-9DFA-2EB5A7693F89",
"publisherDisplayName": "Codeburn",
"displayName": "CodeBurn",
"artifactName": "CodeBurn-Store-${version}-${arch}.${ext}",
"backgroundColor": "#15100D",
"languages": [
"en-US"
],
"minVersion": "10.0.17763.0",
"maxVersionTested": "10.0.26100.0"
},
"linux": {
"target": [
{

View file

@ -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<MenubarPayload>>(),
getOverview: vi.fn<(period: string, provider: string, range?: DateRange, configSource?: string | null, background?: boolean, scope?: string) => Promise<MenubarPayload>>(),
getSpendFlow: vi.fn<(period: string, provider: string, range?: DateRange) => Promise<SpendFlow>>(),
getOptimizeReport: vi.fn<(period: string, provider: string, range?: DateRange) => Promise<OptimizeJsonReport>>(),
getModels: vi.fn(),
@ -273,6 +273,25 @@ describe('App shortcuts', () => {
})
})
it('drives combined-scope overview fetches and persists the Scope setting', async () => {
render(<App />)
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(<App />)
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).

View file

@ -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<Array<{ id: string; label: string }>>([])
const [customRange, setCustomRange] = useState<DateRange | null>(null)
const [claudeConfigSource, setClaudeConfigSource] = useState<string | null>(initialConfigSource)
const [scope, setScopeState] = useState<Scope>(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<MenubarPayload>(
() => 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<string | null>(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<Set<string>>(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 (
<Window>
@ -494,12 +534,12 @@ function AppMain() {
{section === 'plans' ? (
<Plans period={period} refreshToken={refreshToken} onNavigate={navigate} ready={ready} />
) : section === 'settings' ? (
<Settings period={period} refreshToken={refreshToken} onNavigate={navigate} initialPane={settingsPane} claudeConfigs={claudeConfigs} claudeConfigSource={claudeConfigSource} onConfigMutated={onConfigMutated} />
<Settings period={period} refreshToken={refreshToken} onNavigate={navigate} initialPane={settingsPane} claudeConfigs={claudeConfigs} claudeConfigSource={claudeConfigSource} onConfigMutated={onConfigMutated} scope={scope} onScopeChange={onScopeChange} />
) : (
<>
<TopBar
title={SECTION_TITLES[section]}
scope={scope}
scope={scopeCaption}
period={period}
onPeriodChange={onPeriodChange}
customRange={customRange}
@ -514,7 +554,7 @@ function AppMain() {
/>
<div className={motionClass('body', 'section-fade')}>
{section === 'overview' ? (
<OverviewContent period={period} provider={provider} range={customRange} overview={overview} onNavigate={navigate} ready={ready} />
<OverviewContent period={period} provider={provider} range={customRange} overview={overview} onNavigate={navigate} ready={ready} scope={scope} />
) : section === 'sessions' ? (
<Sessions period={period} provider={provider} range={customRange} refreshToken={refreshToken} detectedProviders={detectedProviders} onProviderChange={onProviderSelect} ready={ready} />
) : section === 'pullRequests' ? (

View file

@ -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<QuotaProvider[]>
// `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<MenubarPayload>
// `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<MenubarPayload>
getPlans(period: Period): Promise<StatusJson>
getActReport(): Promise<ActReportJson>
readonly platform: string

View file

@ -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(<OverviewContent period="30days" provider="all" overview={polled(payload)} scope="combined" />)
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(<OverviewContent period="30days" provider="all" overview={polled(payload)} scope="local" />)
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<MenubarPayload> = {

View file

@ -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 <OverviewContent period={period} provider={provider} overview={overview} />
}
/** 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 (
<div className="ov-combined-devices">
<div className="ov-combined-head">{usage.combined.reachableCount} of {usage.combined.deviceCount} devices</div>
{usage.perDevice.map(device => (
<div className={device.error ? 'ov-combined-row err' : 'ov-combined-row'} key={device.id}>
<span className="ov-combined-name">{device.local ? `${device.name} · this device` : device.name}</span>
<span className="ov-combined-val">{device.error ?? formatUsd(device.cost)}</span>
</div>
))}
</div>
)
}
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<MenubarPayload>
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 && <StaleBanner error={error} />}
<div className="ov-card ov-hero-split" aria-label="Key performance indicators">
<div className="ov-hero-main">
<div className="ov-hero-top"><span className="ov-label">{data.current.label}</span><span className="ov-streak"><b>{streakDays(data.history.daily, now)}</b>-day streak</span></div>
<CountUp value={data.current.cost} animateKey={animateKey} />
<div className="ov-hero-sub">{data.current.calls.toLocaleString('en-US')} calls · {data.current.sessions.toLocaleString('en-US')} sessions</div>
{saved > 0 && (
<div className="ov-saved-line"><span>Saved by applied fixes</span><strong>{formatUsd(saved)}</strong><small>across {applied} {applied === 1 ? 'fix' : 'fixes'}</small></div>
)}
{localSaved > 0 && (
<div className="ov-saved-line"><span>Saved via local models</span><strong>{formatUsd(localSaved)}</strong><small>local-model routing</small></div>
)}
<div className="ov-hero-top"><span className="ov-label">{combined ? `Combined · ${data.current.label}` : data.current.label}</span><span className="ov-streak"><b>{streakDays(data.history.daily, now)}</b>-day streak</span></div>
<CountUp value={heroCost} animateKey={animateKey} />
<div className="ov-hero-sub">{heroCalls.toLocaleString('en-US')} calls · {heroSessions.toLocaleString('en-US')} sessions</div>
{combined
? <CombinedDevices usage={combined} />
: (
<>
{saved > 0 && (
<div className="ov-saved-line"><span>Saved by applied fixes</span><strong>{formatUsd(saved)}</strong><small>across {applied} {applied === 1 ? 'fix' : 'fixes'}</small></div>
)}
{localSaved > 0 && (
<div className="ov-saved-line"><span>Saved via local models</span><strong>{formatUsd(localSaved)}</strong><small>local-model routing</small></div>
)}
</>
)}
</div>
<ActivityHeatmap daily={data.history.daily} bare />
<EfficiencyScorecard current={data.current} bare />

View file

@ -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(<Settings period="month" scope="local" onScopeChange={onScopeChange} />)
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(<Settings period="week" />)

View file

@ -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<Pane>(initialPane ?? 'general')
return (
@ -113,7 +113,7 @@ export function Settings({ period, refreshToken = 0, onNavigate, initialPane, cl
))}
</nav>
<main className="set-pane">
{pane === 'general' && <GeneralPane period={period} refreshToken={refreshToken} claudeConfigs={claudeConfigs} claudeConfigSource={claudeConfigSource} onConfigMutated={onConfigMutated} />}
{pane === 'general' && <GeneralPane period={period} refreshToken={refreshToken} claudeConfigs={claudeConfigs} claudeConfigSource={claudeConfigSource} onConfigMutated={onConfigMutated} scope={scope} onScopeChange={onScopeChange} />}
{pane === 'providers' && <ProvidersPane period={period} refreshToken={refreshToken} />}
{pane === 'aliases' && <AliasesPane refreshToken={refreshToken} onConfigMutated={onConfigMutated} />}
{pane === 'pricing' && <PricingPane refreshToken={refreshToken} onConfigMutated={onConfigMutated} />}
@ -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<StatusJson>(() => codeburn.getPlans(period), [period, refreshToken, currencyNonce])
const [theme, setTheme] = useState<Theme>(() => {
@ -202,6 +202,7 @@ function GeneralPane({ period, refreshToken, claudeConfigs, claudeConfigSource,
<button className="set-text-button" onClick={() => void codeburn.resetCurrency().then(finishCurrency)}>Reset to USD</button>
</span></div>
<div className="about-row"><label className="tx" htmlFor="settings-period">Default period<small>Applied on next launch.</small></label><span className="r"><Dropdown id="settings-period" ariaLabel="Default period" value={defaultPeriod} options={[{ value: 'today', label: 'Today' }, { value: 'week', label: '7d' }, { value: '30days', label: '30d' }, { value: 'month', label: 'Month' }, { value: 'all', label: 'All' }]} onChange={value => { setDefaultPeriod(value); writeSetting('codeburn.defaultPeriod', value) }} width={92} /></span></div>
<div className="about-row"><label className="tx" htmlFor="settings-scope">Scope<small>Combined aggregates usage across every paired device, like the menubar. Local shows this device only.</small></label><span className="r"><Dropdown id="settings-scope" ariaLabel="Scope" value={scope} options={[{ value: 'local', label: 'Local' }, { value: 'combined', label: 'Combined' }]} onChange={value => onScopeChange?.(value)} width={110} /></span></div>
<div className="about-row"><label className="tx" htmlFor="settings-refresh">Refresh every<small>How often data auto-refreshes. Manual updates only on R.</small></label><span className="r"><Dropdown id="settings-refresh" ariaLabel="Refresh every" value={cadence.value} options={REFRESH_OPTIONS.map(option => ({ value: option.value, label: option.label }))} onChange={cadence.setValue} width={124} /></span></div>
<div className="about-row"><label className="tx" htmlFor="settings-budget">Daily budget<small>Warns at 80%, alerts at 100%.</small></label><span className="r"><Dropdown id="settings-budget" ariaLabel="Daily budget" value={budgetKind} options={[{ value: 'off', label: 'Off' }, { value: 'usd', label: 'USD amount' }, { value: 'tokens', label: 'Tokens' }]} onChange={value => { const kind = value as 'off' | 'usd' | 'tokens'; setBudgetKind(kind); persistBudget(kind, budgetInput) }} width={120} />{budgetKind !== 'off' && <input className="set-input" type="text" inputMode="decimal" aria-label="Daily budget amount" placeholder={budgetKind === 'usd' ? 'USD' : 'tokens'} value={budgetInput} onChange={event => { setBudgetInput(event.target.value); persistBudget(budgetKind, event.target.value) }} style={{ width: 90 }} />}</span></div>
{budgetError && <p className="set-action-msg error">{budgetError}</p>}

View file

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

View file

@ -68,7 +68,11 @@ writeFileSync(
// extraneous warnings, so capture stdout regardless of exit code.
let listed = ''
try {
listed = execFileSync('npm', ['ls', '--omit=dev', '--all', '--parseable'], {
// Execute npm's JavaScript entry point with the current Node binary. Windows
// exposes npm as a .cmd shim, which execFile cannot launch without a shell.
const npmCli = process.env.npm_execpath
if (!npmCli) throw new Error('npm_execpath is unavailable')
listed = execFileSync(process.execPath, [npmCli, 'ls', '--omit=dev', '--all', '--parseable'], {
cwd: root,
encoding: 'utf8',
maxBuffer: 64 * 1024 * 1024,
@ -81,11 +85,17 @@ try {
// Map each back to its top-level node_modules entry (`name` or `@scope/name`),
// then copy those dirs whole — a package's own nested node_modules comes with
// it, which is exactly the closure it needs at runtime.
const prefix = rootModules + '/'
// `npm ls --parseable` uses native separators on Windows. Normalize both sides
// before extracting the package name so Store builds do not treat a populated
// node_modules tree as empty merely because it uses `\\` instead of `/`.
const prefix = rootModules.replaceAll('\\', '/') + '/'
const comparisonPrefix = process.platform === 'win32' ? prefix.toLowerCase() : prefix
const topLevel = new Set()
for (const line of listed.split('\n')) {
if (!line.startsWith(prefix)) continue
const rest = line.slice(prefix.length)
const normalizedLine = line.trim().replaceAll('\\', '/')
const comparisonLine = process.platform === 'win32' ? normalizedLine.toLowerCase() : normalizedLine
if (!comparisonLine.startsWith(comparisonPrefix)) continue
const rest = normalizedLine.slice(prefix.length)
const match = rest.match(/^(@[^/]+\/[^/]+|[^/]+)/)
if (match) topLevel.add(match[1])
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 458 KiB

View file

@ -12,11 +12,26 @@ Anthropic Claude Code CLI and Claude Desktop's local agent mode.
|---|---|
| Claude Code CLI | `$CLAUDE_CONFIG_DIR` if set, otherwise `~/.claude/projects/` |
| Claude Desktop (macOS) | `~/Library/Application Support/Claude/local-agent-mode-sessions/` |
| Claude Desktop (Windows) | `%APPDATA%/Claude/local-agent-mode-sessions/` |
| Claude Desktop (Windows, classic) | `%APPDATA%/Claude/local-agent-mode-sessions/` |
| Claude Desktop (Windows, MSIX) | `%LOCALAPPDATA%/Packages/<Claude package>/LocalCache/Roaming/Claude/local-agent-mode-sessions/` |
| Claude Desktop (Linux) | `~/.config/Claude/local-agent-mode-sessions/` |
For Desktop, `findDesktopProjectDirs` walks up to 8 levels deep looking for `projects/` subdirectories, skipping `node_modules` and `.git`.
Desktop session roots are resolved in this order:
1. A non-empty `CODEBURN_DESKTOP_SESSIONS_DIR` overrides discovery and is the
only returned root.
2. macOS uses the single path shown above.
3. Windows always includes the classic path first. It then scans
`%LOCALAPPDATA%/Packages` for package directories whose names start with
`Claude_` or contain `.Claude_`, sorted by package name, and includes only
packages whose full MSIX sessions path exists as a directory.
4. Other platforms use the single Linux path shown above.
All returned roots are absolute, resolved, and deduplicated. Missing or
unreadable Windows package directories are ignored.
## Storage format
JSONL, one event per line, per session file. Sessions live under `<project>/<sessionId>.jsonl`.

View file

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

View file

@ -8,14 +8,23 @@ MoonshotAI Kimi Code local session usage and tool activity.
## Where it reads from
The provider reads `~/.kimi-code` by default and honors the Kimi Code CLI's `KIMI_CODE_HOME` environment variable. It scans:
By default the provider scans every known Kimi Code runtime store:
```text
$KIMI_CODE_HOME/sessions/wd_*/session_*/
~/.kimi-code
~/Library/Application Support/kimi-desktop/daimon-share/daimon/runtime/kimi-code/home (Kimi desktop / IDE embedded runtime)
```
Setting `KIMI_CODE_HOME` (or passing a home override) narrows the scan to that single home. Inside each home it scans:
```text
$HOME/sessions/wd_*/<session-dir>/
├── state.json
└── agents/<agent-id>/wire.jsonl
```
Session directory naming depends on the host product: the CLI uses `session_*`, embedded runtimes use `conv-*` / `ctitle-*`. Any directory is accepted; the `agents/*/wire.jsonl` probe gates real sessions.
Every agent wire is a cache source. Main-agent and subagent calls share the session ID from the `session_*` directory. `state.json.workDir` supplies the project name and path. `probeRoots()` reports the resolved Kimi Code home for `codeburn doctor` even when there are no sessions.
## Storage format

View file

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

View file

@ -138,11 +138,19 @@ final class AppStore {
var codexError: String?
var codexLoadState: SubscriptionLoadState = CodexCredentialStore.isBootstrapCompleted ? .dormant : .notBootstrapped
var kimiUsage: KimiUsage?
var kimiError: String?
// No keychain dance for Kimi "connected" just means the CLI's
// credential file exists, so we start dormant and auto-activate on the
// first refresh tick.
var kimiLoadState: SubscriptionLoadState = KimiSubscriptionService.hasCredential ? .dormant : .notBootstrapped
/// Generation tokens for the in-flight refresh tasks. Incremented on every
/// disconnect / reset so a fetch that started before the disconnect cannot
/// resume after the await and re-populate the freshly-cleared state.
private var claudeRefreshGen: Int = 0
private var codexRefreshGen: Int = 0
private var kimiRefreshGen: Int = 0
private var cache: [PayloadCacheKey: CachedPayload] = [:]
private var cacheDate: String = ""
@ -263,6 +271,49 @@ 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
}
/// `(reachable, total)` only when combined scope is active and fewer paired
/// devices reported than are paired i.e. the badge total is degraded to
/// the reachable subset (a peer is asleep/off-network this cycle). The badge
/// shows this so a momentary drop to the local figure reads as "peer
/// unreachable", not a glitch. `nil` when every paired device reported (or
/// there is only one), and under local scope.
var menubarBadgeDeviceShortfall: (reachable: Int, total: Int)? {
guard let totals = menubarBadgeCombined, totals.reachableCount < totals.deviceCount else { return nil }
return (totals.reachableCount, totals.deviceCount)
}
/// 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? {
@ -1104,6 +1155,90 @@ final class AppStore {
}
}
// MARK: - Kimi Code
/// Unlike Claude/Codex there is no keychain bootstrap: reading the CLI's
/// credential file is prompt-free, so the first refresh tick activates
/// the dormant state automatically.
func bootstrapKimi() async {
// Capture the generation before the await so a disconnect that lands
// mid-fetch cannot be resurrected into .loaded when the fetch returns.
let gen = kimiRefreshGen
kimiLoadState = .bootstrapping
do {
let usage = try await KimiSubscriptionService.refresh()
guard gen == kimiRefreshGen else { return }
kimiUsage = usage
kimiError = nil
kimiLoadState = .loaded
} catch let err as KimiSubscriptionService.FetchError {
guard gen == kimiRefreshGen else { return }
applyKimiFetchError(err)
} catch {
guard gen == kimiRefreshGen else { return }
kimiError = sanitizeForUI(String(describing: error))
kimiLoadState = .failed
}
}
func refreshKimi() async {
_ = await refreshKimiReportingSuccess()
}
@discardableResult
func refreshKimiReportingSuccess() async -> Bool {
if case .dormant = kimiLoadState {
await bootstrapKimi()
return kimiLoadState == .loaded
}
guard KimiSubscriptionService.hasCredential else {
if kimiLoadState != .notBootstrapped { kimiLoadState = .notBootstrapped }
return false
}
let gen = kimiRefreshGen
if kimiUsage == nil { kimiLoadState = .loading }
do {
let usage = try await KimiSubscriptionService.refresh()
guard gen == kimiRefreshGen else { return false }
kimiUsage = usage
kimiError = nil
kimiLoadState = .loaded
return true
} catch let err as KimiSubscriptionService.FetchError {
guard gen == kimiRefreshGen else { return false }
applyKimiFetchError(err)
return false
} catch {
guard gen == kimiRefreshGen else { return false }
kimiError = sanitizeForUI(String(describing: error))
kimiLoadState = .failed
return false
}
}
func disconnectKimi() {
KimiSubscriptionService.disconnect()
kimiRefreshGen &+= 1
kimiUsage = nil
kimiError = nil
kimiLoadState = .notBootstrapped
NotificationCenter.default.post(name: .codeBurnSubscriptionDisconnected, object: nil)
}
private func applyKimiFetchError(_ err: KimiSubscriptionService.FetchError) {
let sanitized = sanitizeForUI(err.errorDescription)
kimiError = sanitized
if case .noCredentials = err {
kimiLoadState = .noCredentials
} else if err.isTerminal {
kimiLoadState = .terminalFailure(reason: sanitized)
} else if let retryAt = err.rateLimitRetryAt {
kimiLoadState = .transientFailure(retryAt: retryAt)
} else {
kimiLoadState = .failed
}
}
private func applyFetchError(_ err: ClaudeSubscriptionService.FetchError) {
let sanitized = sanitizeForUI(err.errorDescription)
subscriptionError = sanitized
@ -1172,6 +1307,10 @@ final class AppStore {
let worst = max(usage.primary?.usedPercent ?? 0, usage.secondary?.usedPercent ?? 0)
if worst > 0 { providers.append(("Codex", worst)) }
}
if let usage = kimiUsage, shouldIncludeCachedQuota(loadState: kimiLoadState) {
let worst = max(usage.primary?.usedPercent ?? 0, usage.details.map(\.usedPercent).max() ?? 0)
if worst > 0 { providers.append(("Kimi Code", worst)) }
}
let worst = providers.map(\.percent).max() ?? 0
let severity = QuotaSummary.severity(for: worst / 100)
let sorted = providers.sorted { $0.percent > $1.percent }
@ -1192,6 +1331,7 @@ final class AppStore {
switch filter {
case .claude: return claudeQuotaSummary(filter: filter)
case .codex: return codexQuotaSummary(filter: filter)
case .kimiCode: return kimiQuotaSummary(filter: filter)
default: return nil
}
}
@ -1281,22 +1421,78 @@ 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)
}
private func kimiQuotaSummary(filter: ProviderFilter) -> QuotaSummary? {
if case .notBootstrapped = kimiLoadState { return nil }
if case .bootstrapping = kimiLoadState { return nil }
if case .noCredentials = kimiLoadState { return nil }
let connection: QuotaSummary.Connection = {
switch kimiLoadState {
case .notBootstrapped, .dormant, .bootstrapping, .noCredentials: return .disconnected
case .loading: return kimiUsage == nil ? .loading : .stale
case .loaded: return .connected
case .failed: return kimiUsage == nil ? .loading : .stale
// Kimi tokens expire ~every 15 min and only the CLI renews them, so
// terminal is the steady state between CLI uses. Keep the last-known
// bars (marked stale) instead of flapping the chip to a reconnect
// card; the reconnect card is reserved for the genuinely-no-data case.
case let .terminalFailure(reason): return kimiUsage == nil ? .terminalFailure(reason: reason) : .stale
case .transientFailure: return .transientFailure
}
}()
var primary: QuotaSummary.Window?
var details: [QuotaSummary.Window] = []
if let usage = kimiUsage {
if let w = usage.primary {
let row = QuotaSummary.Window(label: w.label, percent: w.usedPercent / 100, resetsAt: w.resetsAt)
primary = row
details.append(row)
}
for w in usage.details {
let row = QuotaSummary.Window(label: w.label, percent: w.usedPercent / 100, resetsAt: w.resetsAt)
if primary == nil { primary = row }
details.append(row)
}
}
return QuotaSummary(providerFilter: filter, connection: connection, primary: primary, details: details, planLabel: kimiUsage?.plan ?? "Kimi Code", footerLines: [])
}
/// Persist one snapshot per window so we can answer "what did the prior cycle end at?"
/// when the current window has just reset and projection from current data isn't meaningful.
/// Also computes the effective_tokens consumed inside each 7-day window from local history,
@ -1400,6 +1596,7 @@ enum ProviderFilter: String, CaseIterable, Identifiable {
case ibmBob = "IBM Bob"
case kiro = "Kiro"
case kimi = "Kimi"
case kimiCode = "Kimi Code"
case lingtaiTui = "LingTai TUI"
case kiloCode = "KiloCode"
case openclaw = "OpenClaw"
@ -1432,6 +1629,7 @@ enum ProviderFilter: String, CaseIterable, Identifiable {
case .grok: ["grok", "grok build"]
case .hermes: ["hermes", "hermes agent"]
case .lingtaiTui: ["lingtai-tui", "lingtai tui"]
case .kimiCode: ["kimicode", "kimi code"]
default: [rawValue.lowercased()]
}
}
@ -1453,6 +1651,7 @@ enum ProviderFilter: String, CaseIterable, Identifiable {
case .kiloCode: "kilo-code"
case .kiro: "kiro"
case .kimi: "kimi"
case .kimiCode: "kimicode"
case .lingtaiTui: "lingtai-tui"
case .openclaw: "openclaw"
case .opencode: "opencode"

View file

@ -49,11 +49,14 @@ struct CodeBurnApp: App {
}
@MainActor
final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate {
final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate, NSMenuDelegate {
private var statusItem: NSStatusItem!
private var popover: NSPopover!
private var rightClickMonitor: Any?
private var lastContextMenuPresentedAt: Date = .distantPast
/// Held only while the right-click menu is open. Cleared in menuDidClose so
/// left-click goes back to the popover action instead of re-showing the menu.
private var contextMenu: NSMenu?
fileprivate let store = AppStore()
let updateChecker = UpdateChecker()
/// True while the displays are asleep. Refresh ticks skip spawning
@ -464,7 +467,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate {
// 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
@ -486,7 +489,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate {
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)
@ -519,6 +522,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate {
fileprivate var lastSubscriptionRefreshAt: Date?
fileprivate var lastCodexRefreshAt: Date?
fileprivate var lastKimiRefreshAt: Date?
private var claudeQuotaFailureCount = 0
private var nextClaudeQuotaRefreshAt: Date?
@ -614,8 +618,24 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate {
if let task = codexQuotaRefreshTask {
return await task.value
}
let task = Task { [store] in
await store.refreshCodexReportingSuccess()
// Kimi Code rides the same tick, but with its own cadence anchor:
// when Codex is not connected its refresh returns false immediately,
// so lastCodexRefreshAt never advances anchoring Kimi on it would
// poll api.kimi.com on every payload tick instead of the configured
// quota cadence. Anchor on attempt (not success) so a failing Kimi
// endpoint also respects the cadence.
let kimiDue: Bool = {
let cadence = SubscriptionRefreshCadence.current
guard cadence != .manual else { return false }
return Date().timeIntervalSince(lastKimiRefreshAt ?? .distantPast) >= TimeInterval(cadence.rawValue)
}()
if kimiDue { lastKimiRefreshAt = Date() }
let task = Task { [store, kimiDue] in
async let codex = store.refreshCodexReportingSuccess()
if kimiDue {
_ = await store.refreshKimiReportingSuccess()
}
return await codex
}
codexQuotaRefreshTask = task
let result = await task.value
@ -833,6 +853,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate {
func resetSubscriptionCadenceAnchor() {
lastSubscriptionRefreshAt = nil
lastCodexRefreshAt = nil
lastKimiRefreshAt = nil
claudeQuotaFailureCount = 0
nextClaudeQuotaRefreshAt = nil
}
@ -849,6 +870,9 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate {
_ = 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
@ -909,15 +933,25 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate {
button.sendAction(on: [.leftMouseUp, .rightMouseUp])
// macOS 27 no longer routes any right-mouse event to the status-item
// button's target/action. A global monitor still observes right-mouse-down;
// button's target/action. A global monitor still observes right-mouse-up;
// we hit-test it against our own status-item window and present the menu
// ourselves. Harmless and stable on 15/26 too (the debounce in
// showContextMenu prevents a double-present if the legacy path also fires).
rightClickMonitor = NSEvent.addGlobalMonitorForEvents(matching: [.rightMouseDown]) { [weak self] _ in
// ourselves.
//
// Must be mouse-*up*, not mouse-down: presenting on down starts menu
// tracking while the button is still held, so the matching rightMouseUp
// is treated as an outside click and the menu flashes then dismisses.
// Presenting on up (after the click completes) keeps it open. Harmless
// on 15/26 too (the debounce in showContextMenu prevents a double-present
// if the legacy path also fires).
rightClickMonitor = NSEvent.addGlobalMonitorForEvents(matching: StatusItemContextMenuPolicy.presentEventMask) { [weak self] _ in
guard let self,
let button = self.statusItem.button,
let window = button.window,
window.frame.contains(NSEvent.mouseLocation) else { return }
// Defer one turn so menu presentation is not nested inside the
// monitor callback. Safe on mouse-*up* (the click is already
// complete); on mouse-down a deferred present was killed by the
// matching up event and the menu only flashed.
DispatchQueue.main.async { self.showContextMenu(from: button) }
}
@ -992,23 +1026,31 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate {
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]
@ -1016,10 +1058,27 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate {
textAttrs[.foregroundColor] = NSColor.secondaryLabelColor
}
composed.append(NSAttributedString(string: valueText, attributes: textAttrs))
// Combined scope, but a paired device didn't report this cycle: append
// a dimmed "reachable/total" so the reduced total reads as "peer
// unreachable" rather than a glitch (mirrors the popover's device list).
if let shortfall = store.menubarBadgeDeviceShortfall {
let marker = " · \(shortfall.reachable)/\(shortfall.total)"
let markerAttrs: [NSAttributedString.Key: Any] = [
.font: font,
.baselineOffset: -1.0,
.foregroundColor: NSColor.secondaryLabelColor,
]
composed.append(NSAttributedString(string: marker, attributes: markerAttrs))
}
}
button.attributedTitle = composed
button.toolTip = "CodeBurn \(menubarPeriod.menubarMetricLabel)"
if let shortfall = store.menubarBadgeDeviceShortfall {
button.toolTip = "CodeBurn \(menubarPeriod.menubarMetricLabel) · \(shortfall.reachable) of \(shortfall.total) devices reporting"
} else {
button.toolTip = "CodeBurn \(menubarPeriod.menubarMetricLabel)"
}
persistBadgeStatusFile()
}
@ -1126,9 +1185,16 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate {
private func showContextMenu(from button: NSStatusBarButton) {
// Debounce: on macOS <= 26 both the legacy action path and the global
// monitor can fire for a single right-click. Present at most once per click.
let now = Date()
guard now.timeIntervalSince(lastContextMenuPresentedAt) > 0.3 else { return }
lastContextMenuPresentedAt = now
// Policy lives in StatusItemContextMenuPolicy so the gate is unit-tested (#802).
guard StatusItemContextMenuPolicy.acceptPresent(
now: Date(),
lastPresentedAt: &lastContextMenuPresentedAt
) else { return }
// Don't let an open popover steal the click / sit under the menu.
if popover?.isShown == true {
popover.performClose(nil)
}
let menu = NSMenu()
@ -1160,12 +1226,39 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate {
quitItem.target = self
menu.addItem(quitItem)
// Present directly. The previous `statusItem.menu = menu; button.performClick`
// trick relies on the click -> action path that macOS 27 changed; popUp is
// version-stable. Open a few px below the status item so the menu clears the
// menu bar: anchoring flush clips the top edge and makes macOS engage menu
// scrolling (a scroll chevron appears and the first row slides up on hover).
menu.popUp(positioning: nil, at: NSPoint(x: 0, y: button.bounds.height + 6), in: button)
// Present via the status item's own menu slot. AppKit positions and tracks
// that menu correctly under the status item (no scroll chevron / first-row
// jump). Manual NSMenu.popUp(at:in:) is what caused the jump: the menu was
// tracked against a point while the cursor still sat on the status item
// above it, so the first mouse move engaged scroll mode.
//
// #472 dropped this pattern because assigning `statusItem.menu` from the
// right-mouse *action* never ran on macOS 27 (right-clicks no longer reach
// the button action). We still open from our global rightMouseUp monitor
// (or the legacy action on 26); once we set `statusItem.menu` ourselves,
// performClick is just "open the attached menu" and works on 27 too.
//
// menuDidClose clears `statusItem.menu` so the next left-click hits our
// action (popover) instead of re-opening this menu.
menu.delegate = self
contextMenu = menu
statusItem.menu = menu
button.performClick(nil)
}
// MARK: - NSMenuDelegate
// AppKit invokes menu callbacks on the main thread. Clear the status-item
// menu slot so the next left-click hits our action (popover) again.
nonisolated func menuDidClose(_ menu: NSMenu) {
// Hop explicitly don't assumeIsolated across the NSMenuDelegate boundary
// under Swift 6 strict concurrency (NSMenu isn't Sendable).
DispatchQueue.main.async { [weak self] in
guard let self else { return }
// Always clear: we only ever attach our own context menu to the item.
self.statusItem.menu = nil
self.contextMenu = nil
}
}
/// One-line "today" summary for the context menu's usage row.

View file

@ -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<K: CodingKey>(_ c: KeyedDecodingContainer<K>, _ 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<K: CodingKey>(_ c: KeyedDecodingContainer<K>, _ key: K) -> Int? {
double(c, key).flatMap { Int(exactly: $0.rounded()) }
}
static func bool<K: CodingKey>(_ c: KeyedDecodingContainer<K>, _ 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<T: Decodable>: 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<AdditionalLimitDTO>].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

View file

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

View file

@ -0,0 +1,51 @@
import Foundation
/// Pure display-decision helpers for the Kimi Code quota surfaces.
///
/// Kimi Code tokens live ~15 min and only the Kimi CLI renews them, so the
/// load state sits in `.terminalFailure` as its dominant steady state between
/// CLI uses. The always-visible surfaces (Plan tab, tab-strip chip) must keep
/// showing the last good snapshot with a quiet caption instead of flapping to
/// a reconnect screen every cycle. The reconnect screen is reserved for the
/// no-data case, where there is genuinely nothing to show.
enum KimiQuotaPresentation {
/// Which Plan-tab subview to render, given the load state and whether a
/// last-known snapshot exists.
enum PlanContent: Equatable {
case noCredentials
case loading
case failed
case transientFailed
case reconnect(reason: String?)
/// Render the loaded usage bars. `idle` is true when the login has
/// gone terminal but a snapshot is still on hand the caller stamps a
/// quiet "run the CLI" caption instead of hiding the data.
case usage(idle: Bool)
}
static func planContent(loadState: SubscriptionLoadState, hasUsage: Bool) -> PlanContent {
switch loadState {
case .notBootstrapped, .noCredentials:
return .noCredentials
case .dormant, .bootstrapping:
return .loading
case .loading, .loaded:
return hasUsage ? .usage(idle: false) : .loading
case .failed:
return .failed
case .transientFailure:
return hasUsage ? .usage(idle: false) : .transientFailed
case .terminalFailure(let reason):
return hasUsage ? .usage(idle: true) : .reconnect(reason: reason)
}
}
/// Snapshot age past which a loaded view stamps an "as of <time>" caption,
/// so a bar can never silently masquerade as current. Kimi's ~15-min token
/// life makes 10 minutes the point where a snapshot is likely a cycle old.
static let stalenessThreshold: TimeInterval = 10 * 60
static func isStale(fetchedAt: Date, now: Date = Date()) -> Bool {
now.timeIntervalSince(fetchedAt) > stalenessThreshold
}
}

View file

@ -0,0 +1,372 @@
import Foundation
/// Live quota snapshot for a Kimi Code subscription, returned by
/// GET https://api.kimi.com/coding/v1/usages. Shape mirrors what
/// steipete/CodexBar consumes: a top-level `usage` envelope plus a
/// `limits` array of additional rate-limit windows. Numeric fields are
/// decoded leniently (String or Int/Double) because the API has shipped
/// both.
struct KimiUsage: Sendable, Equatable {
struct Window: Sendable, Equatable {
let label: String
let limit: Double
let used: Double
let remaining: Double?
let resetsAt: Date?
var usedPercent: Double { // 0.0 ... 100.0
guard limit > 0 else { return 0 }
return min(100, max(0, used / limit * 100))
}
}
/// The top-level `usage` envelope treated as the primary window.
let primary: Window?
/// Additional windows from the `limits` array (e.g. 5-hour rate limit).
let details: [Window]
/// Membership tier from user.membership.level (e.g. "Intermediate").
let plan: String?
/// Max parallel sessions from parallel.limit, when reported.
let parallelLimit: Int?
let fetchedAt: Date
}
/// Mirror of CodexSubscriptionService for Kimi Code. Reads the CLI's
/// credential file directly (~/.kimi-code/credentials/kimi-code.json)
/// no keychain bootstrap, no OAuth refresh. Tokens are short-lived
/// (~15 min) and only the Kimi CLI refreshes them, so an expired token is
/// a terminal state: the UI tells the user to run the CLI once.
enum KimiSubscriptionService {
private static let usageURL = URL(string: "https://api.kimi.com/coding/v1/usages")!
private static let usageBlockedUntilKey = "codeburn.kimi.usage.blockedUntil"
enum FetchError: Error, LocalizedError {
case noCredentials
case tokenExpired
case rateLimited(retryAt: Date)
case usageHTTPError(Int, String?)
case usageDecodeFailed
case network(Error)
var errorDescription: String? {
switch self {
case .noCredentials:
return "No Kimi Code credentials found. Sign in with the Kimi CLI first."
case .tokenExpired:
return "Kimi Code login expired. Run the Kimi CLI once to refresh, then try again."
case let .rateLimited(retryAt):
let f = RelativeDateTimeFormatter()
f.unitsStyle = .short
return "Kimi rate-limited the quota endpoint. Retrying \(f.localizedString(for: retryAt, relativeTo: Date()))."
case let .usageHTTPError(code, body):
return "Kimi quota fetch failed (HTTP \(code))\(body.map { ": \($0)" } ?? "")"
case .usageDecodeFailed: return "Kimi quota response was malformed."
case let .network(err): return "Network error: \(err.localizedDescription)"
}
}
var isTerminal: Bool {
if case .tokenExpired = self { return true }
if case .noCredentials = self { return true }
return false
}
var rateLimitRetryAt: Date? {
if case let .rateLimited(retryAt) = self { return retryAt }
return nil
}
}
// MARK: - Credential file
private struct CredentialFile: Decodable {
let accessToken: String
let expiresAt: Double
enum CodingKeys: String, CodingKey {
case accessToken = "access_token"
case expiresAt = "expires_at"
}
init(from decoder: Decoder) throws {
let c = try decoder.container(keyedBy: CodingKeys.self)
accessToken = try c.decode(String.self, forKey: .accessToken)
// Tolerate Int / Double / String epoch-seconds.
if let n = try? c.decode(Double.self, forKey: .expiresAt) {
expiresAt = n
} else if let s = try? c.decode(String.self, forKey: .expiresAt), let n = Double(s) {
expiresAt = n
} else {
throw DecodingError.dataCorruptedError(forKey: .expiresAt, in: c, debugDescription: "expires_at missing or not numeric")
}
}
}
private static var credentialsURL: URL {
let home = ProcessInfo.processInfo.environment["KIMI_CODE_HOME"]
?? NSHomeDirectory() + "/.kimi-code"
return URL(fileURLWithPath: home + "/credentials/kimi-code.json")
}
static var hasCredential: Bool {
FileManager.default.fileExists(atPath: credentialsURL.path)
}
/// Returns the access token only when it is still fresh (60s skew).
/// Throws noCredentials / tokenExpired otherwise.
private static func freshToken() throws -> String {
guard let data = FileManager.default.contents(atPath: credentialsURL.path),
let cred = try? JSONDecoder().decode(CredentialFile.self, from: data),
!cred.accessToken.isEmpty else {
throw FetchError.noCredentials
}
guard cred.expiresAt > Date().timeIntervalSince1970 + 60 else {
throw FetchError.tokenExpired
}
return cred.accessToken
}
private static func deviceId() -> String? {
let home = ProcessInfo.processInfo.environment["KIMI_CODE_HOME"]
?? NSHomeDirectory() + "/.kimi-code"
guard let data = FileManager.default.contents(atPath: home + "/device_id"),
let id = String(data: data, encoding: .utf8)?.trimmingCharacters(in: .whitespacesAndNewlines),
!id.isEmpty else { return nil }
return id
}
// MARK: - Fetch
static func refresh() async throws -> KimiUsage {
if let until = usageBlockedUntil(), until > Date() {
throw FetchError.rateLimited(retryAt: until)
}
let token = try freshToken()
var request = URLRequest(url: usageURL)
request.httpMethod = "GET"
request.timeoutInterval = 30
request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
request.setValue("application/json", forHTTPHeaderField: "Accept")
request.setValue("CodeBurn", forHTTPHeaderField: "User-Agent")
// Kimi server expects these platform headers (same as CodexBar sends).
request.setValue("kimi_code_cli", forHTTPHeaderField: "X-Msh-Platform")
if let deviceId = deviceId() {
request.setValue(deviceId, forHTTPHeaderField: "X-Msh-Device-Id")
}
let data: Data
let response: URLResponse
do {
(data, response) = try await URLSession.shared.data(for: request)
} catch {
throw FetchError.network(error)
}
guard let http = response as? HTTPURLResponse else {
throw FetchError.usageHTTPError(-1, nil)
}
switch http.statusCode {
case 200:
clearUsageBlock()
do {
return try parseUsage(data: data)
} catch {
// Never log the body account data readable via `log stream`.
NSLog("CodeBurn: kimi usage decode failed: %@", String(describing: error))
throw FetchError.usageDecodeFailed
}
case 401, 403:
// We don't self-refresh; surface as terminal so the UI prompts
// the user to run the CLI.
throw FetchError.tokenExpired
case 429:
let retryAfter = parseRetryAfterHeader(http.value(forHTTPHeaderField: "Retry-After"))
let until = recordUsageRateLimit(retryAfterSeconds: retryAfter)
throw FetchError.rateLimited(retryAt: until)
default:
throw FetchError.usageHTTPError(http.statusCode, String(data: data, encoding: .utf8))
}
}
// MARK: - Decode (internal so tests can drive fixtures)
private struct LenientDouble: Decodable {
let value: Double?
init(from decoder: Decoder) throws {
let c = try decoder.singleValueContainer()
if let n = try? c.decode(Double.self) { value = n }
else if let s = try? c.decode(String.self) { value = Double(s) }
else { value = nil }
}
}
private struct UsageEnvelopeDTO: Decodable {
let limit: LenientDouble?
let used: LenientDouble?
let remaining: LenientDouble?
let resetTime: LenientString?
let resetAt: LenientString?
let reset_time: LenientString?
let reset_at: LenientString?
var resetsAtRaw: String? {
resetTime?.value ?? resetAt?.value ?? reset_time?.value ?? reset_at?.value
}
}
/// Reset timestamps are normally ISO-8601 strings, but decode numbers
/// tolerantly too (epoch seconds) so a schema drift can't fail the whole
/// response. A plain String? would throw on a JSON number.
private struct LenientString: Decodable {
let value: String?
init(from decoder: Decoder) throws {
let c = try decoder.singleValueContainer()
if let s = try? c.decode(String.self) { value = s }
else if let n = try? c.decode(Double.self) { value = String(n) }
else { value = nil }
}
}
private struct LimitDTO: Decodable {
let window: WindowDTO?
let detail: UsageEnvelopeDTO?
struct WindowDTO: Decodable {
let duration: LenientDouble?
let timeUnit: String?
}
}
private struct ResponseDTO: Decodable {
let usage: UsageEnvelopeDTO?
let limits: [LimitDTO]?
let user: UserDTO?
let parallel: ParallelDTO?
struct UserDTO: Decodable {
let membership: MembershipDTO?
struct MembershipDTO: Decodable {
let level: String?
}
}
struct ParallelDTO: Decodable {
let limit: LenientDouble?
}
}
static func parseUsage(data: Data, now: Date = Date()) throws -> KimiUsage {
let root = try JSONDecoder().decode(ResponseDTO.self, from: data)
// The top-level usage envelope is the account's weekly quota (its
// reset lands ~7 days out), so label it like Claude's weekly window.
let primary = makeWindow(label: "Weekly", dto: root.usage)
let details: [KimiUsage.Window] = (root.limits ?? []).compactMap { limit in
guard let dto = limit.detail else { return nil }
let label = windowLabel(duration: limit.window?.duration?.value, timeUnit: limit.window?.timeUnit)
return makeWindow(label: label, dto: dto)
}
guard primary != nil || !details.isEmpty else {
throw FetchError.usageDecodeFailed
}
return KimiUsage(
primary: primary,
details: details,
plan: planName(from: root.user?.membership?.level),
parallelLimit: root.parallel?.limit?.value.map { Int($0) },
fetchedAt: now
)
}
/// "LEVEL_INTERMEDIATE" "Intermediate". Unknown / missing nil so the
/// UI falls back to the plain "Kimi Code" label.
private static func planName(from level: String?) -> String? {
guard var raw = level, !raw.isEmpty else { return nil }
if raw.hasPrefix("LEVEL_") { raw = String(raw.dropFirst("LEVEL_".count)) }
return raw.replacingOccurrences(of: "_", with: " ").capitalized
}
private static func makeWindow(label: String, dto: UsageEnvelopeDTO?) -> KimiUsage.Window? {
guard let dto, let limit = dto.limit?.value, limit > 0 else { return nil }
// Rate-limit windows report only limit + remaining derive used.
let used = dto.used?.value ?? max(0, limit - (dto.remaining?.value ?? limit))
return KimiUsage.Window(
label: label,
limit: limit,
used: used,
remaining: dto.remaining?.value,
resetsAt: dto.resetsAtRaw.flatMap(parseResetTime)
)
}
/// Window size human label. The API sends enum-style units
/// ("TIME_UNIT_MINUTE", duration 300) as well as plain ones ("hour"),
/// so normalize first; sub-hour durations roll up to hours when exact.
private static func windowLabel(duration: Double?, timeUnit: String?) -> String {
guard let duration, let rawUnit = timeUnit else { return "Rate Limit" }
var unit = rawUnit.lowercased()
if unit.hasPrefix("time_unit_") { unit = String(unit.dropFirst("time_unit_".count)) }
var d = duration
// Roll up exact sub-day durations: 300 minutes 5 hours.
if unit == "minute" || unit == "minutes", d.truncatingRemainder(dividingBy: 60) == 0, d >= 60 {
d /= 60; unit = "hour"
}
let i = Int(d)
switch unit {
case "minute", "minutes": return i == 1 ? "Minutely" : "\(i)-min"
case "hour", "hours": return i == 1 ? "Hourly" : "\(i)-hour"
case "day", "days":
if i == 1 { return "Daily" }
if i == 7 { return "Weekly" }
return "\(i)-day"
case "week", "weeks": return i == 1 ? "Weekly" : "\(i)-week"
case "month", "months": return i == 1 ? "Monthly" : "\(i)-month"
default: return "\(i) \(unit)"
}
}
/// resetTime arrives as ISO-8601 (fractional seconds optional) or epoch seconds.
private static func parseResetTime(_ raw: String) -> Date? {
let fractional = ISO8601DateFormatter()
fractional.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
if let date = fractional.date(from: raw) { return date }
let plain = ISO8601DateFormatter()
plain.formatOptions = [.withInternetDateTime]
if let date = plain.date(from: raw) { return date }
if let epoch = Double(raw) { return Date(timeIntervalSince1970: epoch) }
return nil
}
// MARK: - 429 backoff
private static func usageBlockedUntil() -> Date? {
UserDefaults.standard.object(forKey: usageBlockedUntilKey) as? Date
}
private static func clearUsageBlock() {
UserDefaults.standard.removeObject(forKey: usageBlockedUntilKey)
}
private static func parseRetryAfterHeader(_ value: String?) -> Int? {
guard let value = value?.trimmingCharacters(in: .whitespaces), !value.isEmpty else { return nil }
if let seconds = Int(value), seconds >= 0 { return seconds }
let f = DateFormatter()
f.locale = Locale(identifier: "en_US_POSIX")
f.timeZone = TimeZone(secondsFromGMT: 0)
f.dateFormat = "EEE, dd MMM yyyy HH:mm:ss zzz"
if let date = f.date(from: value) {
return max(0, Int(date.timeIntervalSinceNow))
}
return nil
}
private static func recordUsageRateLimit(retryAfterSeconds: Int?) -> Date {
let seconds = max(retryAfterSeconds ?? 300, 60)
let until = Date().addingTimeInterval(TimeInterval(seconds))
UserDefaults.standard.set(until, forKey: usageBlockedUntilKey)
return until
}
static func disconnect() {
clearUsageBlock()
}
}

View file

@ -14,6 +14,35 @@ private let maxUpdateStderrBytes = 64 * 1024
// `menubar --force`, so we refuse to run them and ask the user to upgrade the CLI first.
private let minCliVersionForUpdate = "0.9.9"
enum UpdateFailureStage: Equatable {
case check
case cliUpdate
case menubarUpdate
var badgeLabel: String {
switch self {
case .check: "Update Check Failed"
case .cliUpdate: "CLI Update Failed"
case .menubarUpdate: "Menubar Update Failed"
}
}
var summary: String {
switch self {
case .check: "CodeBurn could not check GitHub for updates."
case .cliUpdate: "CodeBurn could not update the CLI."
case .menubarUpdate: "CodeBurn could not update the menubar app."
}
}
var retryHelp: String {
switch self {
case .check: "Click to retry the update check."
case .cliUpdate, .menubarUpdate: "Click to retry the update."
}
}
}
private final class LockedDataBuffer: @unchecked Sendable {
private let lock = NSLock()
private var data = Data()
@ -38,6 +67,19 @@ final class UpdateChecker {
var installedCliVersion: String?
var isUpdating = false
var updateError: String?
var updateFailureStage: UpdateFailureStage?
var updateBadgeLabel: String {
if isUpdating { return "Updating..." }
return updateFailureStage?.badgeLabel ?? "Update"
}
var updateHelpText: String {
guard let error = updateError, let stage = updateFailureStage else {
return "Update the CLI and menubar to the latest release"
}
return "\(stage.summary)\n\n\(error)\n\n\(stage.retryHelp)"
}
var updateAvailable: Bool {
guard let latest = latestVersion else { return false }
@ -88,6 +130,7 @@ final class UpdateChecker {
func check() async {
updateError = nil
updateFailureStage = nil
installedCliVersion = Self.queryInstalledCliVersion()
guard let url = URL(string: releasesAPI) else { return }
var request = URLRequest(url: url)
@ -118,7 +161,8 @@ final class UpdateChecker {
UserDefaults.standard.set(version, forKey: cachedVersionKey)
if let cliVersion { UserDefaults.standard.set(cliVersion, forKey: cachedCliVersionKey) }
} catch {
updateError = "Update check failed: \(error.localizedDescription)"
updateFailureStage = .check
updateError = error.localizedDescription
NSLog("CodeBurn: update check failed: \(error)")
}
}
@ -195,9 +239,11 @@ final class UpdateChecker {
if cliUpdateAvailable || cliTooOldForUpdate {
isUpdating = true
updateError = nil
updateFailureStage = nil
let cliPath = CodeburnCLI.baseArgv().first ?? ""
guard let argv = Self.cliUpdateInvocation(cliPath: cliPath), let bin = argv.first else {
isUpdating = false
updateFailureStage = .cliUpdate
updateError = "Could not find the package manager for \(cliPath.isEmpty ? "the CLI" : cliPath). Run \u{201C}\(cliUpdateCommand)\u{201D} manually, then try again."
return
}
@ -209,6 +255,7 @@ final class UpdateChecker {
guard let self else { return }
if status != 0 {
self.isUpdating = false
self.updateFailureStage = .cliUpdate
self.updateError = stderr.isEmpty ? "CLI update failed (exit \(status))" : stderr
NSLog("CodeBurn: CLI update failed (exit \(status)): \(stderr)")
return
@ -263,11 +310,13 @@ final class UpdateChecker {
func performUpdate() {
installedCliVersion = Self.queryInstalledCliVersion()
if cliTooOldForUpdate {
updateFailureStage = .menubarUpdate
updateError = "Your codeburn CLI (\(AppVersion.display(installedCliVersion ?? ""))) is too old to update the menubar. Run “\(cliUpdateCommand)” first, then try again."
return
}
isUpdating = true
updateError = nil
updateFailureStage = nil
let process = CodeburnCLI.makeProcess(subcommand: ["menubar", "--force"])
let errPipe = Pipe()
@ -297,6 +346,7 @@ final class UpdateChecker {
guard let self else { return }
self.isUpdating = false
if proc.terminationStatus != 0 {
self.updateFailureStage = .menubarUpdate
self.updateError = stderr.isEmpty ? "Update failed (exit \(proc.terminationStatus))" : stderr
NSLog("CodeBurn: update failed (exit \(proc.terminationStatus)): \(stderr)")
} else {
@ -309,6 +359,7 @@ final class UpdateChecker {
try process.run()
} catch {
isUpdating = false
updateFailureStage = .menubarUpdate
updateError = error.localizedDescription
NSLog("CodeBurn: update spawn failed: \(error)")
}

View file

@ -0,0 +1,47 @@
import AppKit
import Foundation
/// Pure policy for the status-item right-click menu (#802).
///
/// Keeps the event-mask, debounce, and presentation choices out of AppDelegate
/// so they can be unit-tested without spinning up an NSStatusItem. The bugs this
/// encodes:
///
/// 1. **Flash** presenting on `rightMouseDown` lets the matching `rightMouseUp`
/// dismiss the menu immediately. Monitor (and legacy action) use mouse-*up*.
/// 2. **Jump/scroll** manual `NSMenu.popUp(at:in:)` tracks poorly while the
/// cursor still sits on the status item above the menu. Attach via
/// `statusItem.menu` + `performClick` instead.
/// 3. **Double-present** on macOS 26 both the button action and the global
/// monitor can fire for one click; debounce collapses them.
enum StatusItemContextMenuPolicy {
/// Global-monitor / action event. Must be mouse-up (see flash note above).
static let presentEventMask: NSEvent.EventTypeMask = .rightMouseUp
/// Minimum gap between presents. Covers the dual-path race on macOS 26.
static let presentDebounceSeconds: TimeInterval = 0.3
/// How the menu is shown once a present is accepted.
enum Presentation: Equatable {
/// Assign `statusItem.menu` then `button.performClick`. AppKit positions
/// and tracks the menu under the status item. Clear menu in menuDidClose.
case statusItemMenu
/// Manual `NSMenu.popUp(at:in:)`. Causes scroll-chevron jump on mouse move
/// when the cursor starts above the menu. Kept only as a named anti-pattern
/// so tests can lock that we do *not* use it.
case manualPopUp
}
static let presentation: Presentation = .statusItemMenu
/// Returns true and advances `lastPresentedAt` when a new present is allowed.
static func acceptPresent(
now: Date,
lastPresentedAt: inout Date,
debounce: TimeInterval = presentDebounceSeconds
) -> Bool {
guard now.timeIntervalSince(lastPresentedAt) > debounce else { return false }
lastPresentedAt = now
return true
}
}

View file

@ -494,6 +494,7 @@ extension ProviderFilter {
case .kiloCode: return Color(red: 0x00/255.0, green: 0x96/255.0, blue: 0x88/255.0)
case .kiro: return Color(red: 0x4A/255.0, green: 0x9E/255.0, blue: 0xC4/255.0)
case .kimi: return Color(red: 0xA4/255.0, green: 0xC6/255.0, blue: 0x39/255.0)
case .kimiCode: return Color(red: 0xA3/255.0, green: 0xE6/255.0, blue: 0x35/255.0)
case .lingtaiTui: return Color(red: 0x22/255.0, green: 0xA7/255.0, blue: 0xA0/255.0)
case .openclaw: return Color(red: 0xDA/255.0, green: 0x70/255.0, blue: 0x56/255.0)
case .opencode: return Color(red: 0x5B/255.0, green: 0x83/255.0, blue: 0x5B/255.0)

View file

@ -62,7 +62,7 @@ struct HeatmapSection: View {
// their own quota data sources.
InsightMode.allCases.filter { mode in
if mode == .plan {
return store.selectedProvider == .claude || store.selectedProvider == .codex
return store.selectedProvider == .claude || store.selectedProvider == .codex || store.selectedProvider == .kimiCode
}
return true
}
@ -80,6 +80,8 @@ struct HeatmapSection: View {
case .plan:
if store.selectedProvider == .codex {
CodexPlanInsight()
} else if store.selectedProvider == .kimiCode {
KimiPlanInsight()
} else {
PlanInsight(usage: store.subscription)
}
@ -2065,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)
@ -2107,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 {
@ -2145,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 }
@ -2158,6 +2199,117 @@ private struct CodexPlanInsight: View {
}
}
/// Plan tab for Kimi Code. Reads the CLI credential file (no keychain, no
/// OAuth refresh tokens are short-lived and only the CLI renews them), so
/// terminal failure means "run the CLI once to refresh your login".
private struct KimiPlanInsight: View {
@Environment(AppStore.self) private var store
var body: some View {
Group {
switch KimiQuotaPresentation.planContent(loadState: store.kimiLoadState, hasUsage: store.kimiUsage != nil) {
case .noCredentials:
PlanNoCredentialsView(
title: "No Kimi Code credentials found",
message: "Sign in with the Kimi CLI first. Then click Try Again."
) { Task { await store.bootstrapKimi() } }
case .loading:
PlanLoadingView(message: "Reading Kimi Code credentials...")
case .failed:
PlanFailedView(
error: store.kimiError
) { Task { await store.refreshKimi() } }
case .transientFailed:
PlanFailedView(
error: store.kimiError ?? "Kimi temporarily unreachable — retrying."
) { Task { await store.refreshKimi() } }
case let .reconnect(reason):
PlanReconnectView(
title: "Refresh Kimi Code login",
reason: reason,
fallback: "Kimi Code tokens are short-lived. Run the Kimi CLI once to refresh your login, then click Reconnect."
) { Task { await store.bootstrapKimi() } }
case let .usage(idle):
if let usage = store.kimiUsage {
loadedBody(usage: usage, idle: idle)
} else {
PlanLoadingView(message: "Reading Kimi Code credentials...")
}
}
}
}
@ViewBuilder
private func loadedBody(usage: KimiUsage, idle: Bool) -> some View {
VStack(alignment: .leading, spacing: 10) {
HStack(alignment: .firstTextBaseline) {
Text(usage.plan ?? "Kimi Code")
.font(.system(size: 13, weight: .semibold))
.foregroundStyle(.primary)
Spacer()
if let resetsAt = usage.primary?.resetsAt {
Text("Resets \(relativeReset(resetsAt))")
.font(.system(size: 10.5))
.foregroundStyle(.secondary)
}
}
if let primary = usage.primary {
UtilizationRow(
label: "\(primary.label) window",
percent: primary.usedPercent,
resetsAt: primary.resetsAt,
projection: nil
)
}
ForEach(Array(usage.details.enumerated()), id: \.offset) { _, window in
UtilizationRow(
label: "\(window.label) window",
percent: window.usedPercent,
resetsAt: window.resetsAt,
projection: nil
)
}
if let parallel = usage.parallelLimit, parallel > 0 {
HStack(alignment: .firstTextBaseline) {
Text("Parallel sessions")
.font(.system(size: 11, weight: .medium))
.foregroundStyle(.secondary)
Spacer()
Text("\(parallel)")
.font(.system(size: 10.5))
.foregroundStyle(.secondary)
}
}
if idle {
Text("Login idle. Run the Kimi CLI to refresh.")
.font(.system(size: 10))
.foregroundStyle(.tertiary)
}
if KimiQuotaPresentation.isStale(fetchedAt: usage.fetchedAt) {
Text("as of \(shortTime(usage.fetchedAt))")
.font(.system(size: 10))
.foregroundStyle(.tertiary)
}
}
.padding(.horizontal, 14)
.padding(.top, 4)
.padding(.bottom, 8)
}
private func relativeReset(_ date: Date) -> String {
let f = RelativeDateTimeFormatter()
f.unitsStyle = .short
return f.localizedString(for: date, relativeTo: Date())
}
private func shortTime(_ date: Date) -> String {
let f = DateFormatter()
f.timeStyle = .short
f.dateStyle = .none
return f.string(from: date)
}
}
private struct WindowProjection {
enum Source { case linear, historicalBaseline }
let percent: Double

View file

@ -92,6 +92,10 @@ struct MenuBarContent: View {
private var isFilteredEmpty: Bool {
guard store.selectedProvider != .all else { return false }
// Plan-capable providers keep their sections visible so the Plan tab
// (live subscription quota) stays reachable even on days with no
// local usage the quota endpoint doesn't depend on local sessions.
if store.selectedProvider == .claude || store.selectedProvider == .codex || store.selectedProvider == .kimiCode { return false }
if store.payload.current.cost > 0 || store.payload.current.calls > 0 { return false }
if providerHasCostInAllPayload { return false }
return true
@ -520,7 +524,9 @@ private struct UpdateBadge: View {
var body: some View {
Button {
if updateChecker.updateAvailable || updateChecker.cliUpdateAvailable {
if updateChecker.updateFailureStage == .check {
Task { await updateChecker.check() }
} else if updateChecker.updateAvailable || updateChecker.cliUpdateAvailable {
updateChecker.performFullUpdate()
} else {
Task { await updateChecker.check() }
@ -538,7 +544,7 @@ private struct UpdateBadge: View {
Image(systemName: "arrow.down.circle.fill")
.font(.system(size: 10))
}
Text(updateChecker.isUpdating ? "Updating..." : (updateChecker.updateError == nil ? "Update" : "Failed"))
Text(updateChecker.updateBadgeLabel)
.font(.system(size: 10, weight: .medium))
}
.padding(.horizontal, 8)
@ -548,7 +554,9 @@ private struct UpdateBadge: View {
.tint(Theme.brandAccent)
.controlSize(.mini)
.disabled(updateChecker.isUpdating)
.help(updateChecker.updateError ?? "Update the CLI and menubar to the latest release")
.help(updateChecker.updateHelpText)
.accessibilityLabel(updateChecker.updateBadgeLabel)
.accessibilityHint(updateChecker.updateHelpText)
}
}

View file

@ -21,6 +21,10 @@ struct SettingsView: View {
.tabItem { Label("Codex", systemImage: "chevron.left.forwardslash.chevron.right") }
.tag("codex")
KimiSettingsTab()
.tabItem { Label("Kimi", systemImage: "moon.stars") }
.tag("kimi")
DevinSettingsTab()
.tabItem { Label("Devin", systemImage: "flame.fill") }
.tag("devin")
@ -29,7 +33,9 @@ struct SettingsView: View {
.tabItem { Label("About", systemImage: "info.circle") }
.tag("about")
}
.frame(width: 520, height: 430)
// 6 tabs need ~600pt to render as a visible tab bar; narrower widths
// make SwiftUI collapse the tab bar into a ">>" overflow menu.
.frame(width: 600, height: 430)
}
}
@ -481,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: {
@ -606,6 +612,131 @@ private struct CodexConnectionRow: View {
}
}
// MARK: - Kimi Code
private struct KimiSettingsTab: View {
var body: some View {
Form {
Section("Connection") {
KimiConnectionRow()
}
Section {
Text("Kimi Code live-quota tracking reads `~/.kimi-code/credentials/kimi-code.json` directly — nothing is copied or stored. Access tokens are short-lived (~15 minutes) and only the Kimi CLI refreshes them, so if the connection shows as expired, run the Kimi CLI once and click Reconnect.")
.font(.system(size: 11))
.foregroundStyle(.secondary)
} header: {
Text("How it works")
}
}
.formStyle(.grouped)
.padding()
}
}
private struct KimiConnectionRow: View {
@Environment(AppStore.self) private var store
@State private var showDisconnectConfirm = false
var body: some View {
HStack(alignment: .top, spacing: 12) {
Image(systemName: stateIcon)
.font(.system(size: 18))
.foregroundStyle(stateTint)
.frame(width: 22)
VStack(alignment: .leading, spacing: 2) {
Text(stateTitle)
.font(.system(size: 12, weight: .semibold))
Text(stateDetail)
.font(.system(size: 11))
.foregroundStyle(.secondary)
.lineLimit(2)
}
Spacer()
actionButton
}
.padding(.vertical, 4)
}
private var stateIcon: String {
switch store.kimiLoadState {
case .loaded: return "checkmark.circle.fill"
case .terminalFailure: return "exclamationmark.triangle.fill"
case .transientFailure: return "clock.arrow.circlepath"
case .bootstrapping, .loading: return "ellipsis.circle"
case .notBootstrapped, .dormant, .noCredentials: return "link.circle"
case .failed: return "xmark.circle"
}
}
private var stateTint: Color {
switch store.kimiLoadState {
case .loaded: return .green
case .terminalFailure, .failed: return .red
case .transientFailure: return .orange
default: return .secondary
}
}
private var stateTitle: String {
switch store.kimiLoadState {
case .loaded: return "Connected"
case let .terminalFailure(reason): return reason ?? "Login refresh required"
case .transientFailure: return "Backing off"
case .bootstrapping: return "Connecting…"
case .loading: return "Refreshing…"
case .dormant: return "Ready"
case .notBootstrapped, .noCredentials: return "Not connected"
case .failed: return "Couldn't load Kimi quota"
}
}
private var stateDetail: String {
switch store.kimiLoadState {
case .loaded:
return "Live quota tracked from api.kimi.com."
case .terminalFailure:
return "Run the Kimi CLI once to refresh your login, then click Reconnect."
case .transientFailure: return store.kimiError ?? "Kimi rate-limited; auto-retrying."
case .bootstrapping: return "Reading ~/.kimi-code credentials."
case .loading: return "Background refresh in progress."
case .dormant: return "Tap Load Quota to fetch live usage from api.kimi.com."
case .notBootstrapped, .noCredentials:
return "Sign in with the Kimi CLI first, then click Connect."
case .failed: return store.kimiError ?? ""
}
}
@ViewBuilder
private var actionButton: some View {
switch store.kimiLoadState {
case .loaded, .transientFailure, .loading:
Button("Disconnect") { showDisconnectConfirm = true }
.confirmationDialog(
"Disconnect Kimi Code?",
isPresented: $showDisconnectConfirm
) {
Button("Disconnect", role: .destructive) {
store.disconnectKimi()
}
Button("Cancel", role: .cancel) {}
} message: {
Text("CodeBurn will stop tracking Kimi Code quota. Your ~/.kimi-code credentials are untouched — the Kimi CLI keeps working.")
}
case .terminalFailure, .noCredentials, .failed:
Button("Reconnect") { Task { await store.bootstrapKimi() } }
.buttonStyle(.borderedProminent)
case .dormant:
Button("Load Quota") { Task { await store.bootstrapKimi() } }
.buttonStyle(.borderedProminent)
case .notBootstrapped:
Button("Connect") { Task { await store.bootstrapKimi() } }
.buttonStyle(.borderedProminent)
case .bootstrapping:
ProgressView().controlSize(.small)
}
}
}
// MARK: - Devin
private struct DevinSettingsTab: View {

View file

@ -202,6 +202,113 @@ 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("badge reports a device shortfall when a paired peer is unreachable")
func menubarBadgeReportsDeviceShortfall() {
let store = AppStore()
store.suppressRefreshesForTesting()
let period = store.menubarPeriod
// Combined payload where only 1 of 2 paired devices reported this cycle
// (the peer is asleep/off-network), so the aggregate is degraded to local.
let degraded = CombinedUsage(
perDevice: [],
combined: CombinedUsageTotals(
cost: 30,
calls: 3,
sessions: 2,
inputTokens: 100,
outputTokens: 50,
cacheCreateTokens: 10,
cacheReadTokens: 20,
totalTokens: 180,
deviceCount: 2,
reachableCount: 1
)
)
store.setCachedPayloadForTesting(
menubarPayload(cost: 30, combined: degraded),
scope: .combined,
period: period,
provider: .all,
fetchedAt: Date()
)
store.selectedScope = .combined
let shortfall = store.menubarBadgeDeviceShortfall
#expect(shortfall?.reachable == 1)
#expect(shortfall?.total == 2)
}
@Test("badge reports no shortfall when every paired device reports")
func menubarBadgeNoShortfallWhenAllReachable() {
let store = AppStore()
store.suppressRefreshesForTesting()
let period = store.menubarPeriod
// combinedUsage() carries deviceCount == reachableCount == 1.
store.setCachedPayloadForTesting(
menubarPayload(cost: 30, combined: combinedUsage(cost: 30)),
scope: .combined,
period: period,
provider: .all,
fetchedAt: Date()
)
store.selectedScope = .combined
#expect(store.menubarBadgeDeviceShortfall == nil)
// Local scope never reports a shortfall.
store.selectedScope = .local
#expect(store.menubarBadgeDeviceShortfall == nil)
}
@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()

View file

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

View file

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

View file

@ -0,0 +1,68 @@
import Foundation
import Testing
@testable import CodeBurnMenubar
/// Kimi Code tokens live ~15 min and only the CLI renews them, so
/// `.terminalFailure` is the dominant steady state between CLI uses. These
/// tests pin the display decision: a terminal login with a snapshot on hand
/// must keep showing the bars (with a quiet idle caption), and only the
/// no-data case falls through to the reconnect screen.
@Suite("Kimi quota presentation")
struct KimiQuotaPresentationTests {
typealias Presentation = KimiQuotaPresentation
@Test("terminal failure with a snapshot keeps the usage bars, flagged idle")
func terminalWithUsageShowsIdleUsage() {
let content = Presentation.planContent(loadState: .terminalFailure(reason: "expired"), hasUsage: true)
#expect(content == .usage(idle: true))
}
@Test("terminal failure with no snapshot falls through to reconnect")
func terminalWithoutUsageShowsReconnect() {
let content = Presentation.planContent(loadState: .terminalFailure(reason: "expired"), hasUsage: false)
#expect(content == .reconnect(reason: "expired"))
}
@Test("loaded with a snapshot shows usage without the idle caption")
func loadedShowsPlainUsage() {
#expect(Presentation.planContent(loadState: .loaded, hasUsage: true) == .usage(idle: false))
}
@Test("transient failure keeps the last snapshot, else shows the retry screen")
func transientFailureFallsBackToUsage() {
#expect(Presentation.planContent(loadState: .transientFailure(retryAt: nil), hasUsage: true) == .usage(idle: false))
#expect(Presentation.planContent(loadState: .transientFailure(retryAt: nil), hasUsage: false) == .transientFailed)
}
@Test("credential-absent states route to the connect prompt")
func credentialStatesRouteToNoCredentials() {
#expect(Presentation.planContent(loadState: .notBootstrapped, hasUsage: false) == .noCredentials)
#expect(Presentation.planContent(loadState: .noCredentials, hasUsage: false) == .noCredentials)
}
@Test("dormant and bootstrapping render the loading state")
func dormantAndBootstrappingLoad() {
#expect(Presentation.planContent(loadState: .dormant, hasUsage: false) == .loading)
#expect(Presentation.planContent(loadState: .bootstrapping, hasUsage: false) == .loading)
}
@Test("loading with a snapshot shows usage; without one, the loading state")
func loadingPrefersExistingSnapshot() {
#expect(Presentation.planContent(loadState: .loading, hasUsage: true) == .usage(idle: false))
#expect(Presentation.planContent(loadState: .loading, hasUsage: false) == .loading)
}
@Test("a fresh snapshot is not stamped stale")
func freshSnapshotIsNotStale() {
let now = Date()
let fetchedAt = now.addingTimeInterval(-60) // 1 min old
#expect(Presentation.isStale(fetchedAt: fetchedAt, now: now) == false)
}
@Test("a snapshot older than the threshold is stamped stale")
func oldSnapshotIsStale() {
let now = Date()
let fetchedAt = now.addingTimeInterval(-11 * 60) // 11 min old
#expect(Presentation.isStale(fetchedAt: fetchedAt, now: now) == true)
}
}

View file

@ -0,0 +1,114 @@
import XCTest
@testable import CodeBurnMenubar
/// Fixture-driven decode tests for the Kimi Code /coding/v1/usages response.
/// The API has shipped numbers as both JSON numbers and strings, and the
/// reset timestamp under several key spellings (resetTime / reset_at / ...),
/// so the parser must tolerate all of them.
final class KimiUsageParsingTests: XCTestCase {
func testParsesNumericShapeWithResetTime() throws {
let json = """
{
"usage": {"limit": 100, "used": 40, "remaining": 60, "resetTime": "2026-07-30T12:00:00Z"},
"limits": [
{"window": {"duration": 5, "timeUnit": "hour"},
"detail": {"limit": 20, "used": 10, "remaining": 10, "resetTime": "2026-07-23T21:00:00Z"}}
]
}
""".data(using: .utf8)!
let usage = try KimiSubscriptionService.parseUsage(data: json)
XCTAssertEqual(usage.primary?.limit, 100)
XCTAssertEqual(usage.primary?.used, 40)
XCTAssertEqual(usage.primary?.usedPercent ?? -1, 40, accuracy: 0.001)
XCTAssertEqual(usage.primary?.remaining, 60)
XCTAssertNotNil(usage.primary?.resetsAt)
XCTAssertEqual(usage.details.count, 1)
XCTAssertEqual(usage.details.first?.label, "5-hour")
XCTAssertEqual(usage.details.first?.usedPercent ?? -1, 50, accuracy: 0.001)
}
func testParsesStringNumbersAndSnakeCaseReset() throws {
let json = """
{
"usage": {"limit": "500", "used": "123", "remaining": "377", "reset_at": "2026-07-30T12:00:00.000Z"},
"limits": []
}
""".data(using: .utf8)!
let usage = try KimiSubscriptionService.parseUsage(data: json)
XCTAssertEqual(usage.primary?.limit, 500)
XCTAssertEqual(usage.primary?.used, 123)
XCTAssertNotNil(usage.primary?.resetsAt)
}
func testWeeklyWindowLabel() throws {
let json = """
{
"limits": [
{"window": {"duration": 7, "timeUnit": "day"},
"detail": {"limit": 1000, "used": 250}}
]
}
""".data(using: .utf8)!
let usage = try KimiSubscriptionService.parseUsage(data: json)
XCTAssertNil(usage.primary)
XCTAssertEqual(usage.details.first?.label, "Weekly")
}
func testEpochResetTime() throws {
let json = """
{"usage": {"limit": 10, "used": 5, "resetTime": "1784900000"}}
""".data(using: .utf8)!
let usage = try KimiSubscriptionService.parseUsage(data: json)
XCTAssertEqual(usage.primary?.resetsAt, Date(timeIntervalSince1970: 1_784_900_000))
}
func testNumericEpochResetTime() throws {
// A JSON number (not string) must not fail the whole decode.
let json = """
{"usage": {"limit": 10, "used": 5, "resetTime": 1784900000}}
""".data(using: .utf8)!
let usage = try KimiSubscriptionService.parseUsage(data: json)
XCTAssertEqual(usage.primary?.resetsAt, Date(timeIntervalSince1970: 1_784_900_000))
}
func testLiveResponseShape() {
// Captured from GET https://api.kimi.com/coding/v1/usages (2026-07-23).
let json = """
{
"user": {"userId": "x", "region": "REGION_OVERSEA",
"membership": {"level": "LEVEL_INTERMEDIATE"}},
"usage": {"limit": "100", "used": "5", "remaining": "95",
"resetTime": "2026-07-30T13:27:17.211180Z"},
"limits": [
{"window": {"duration": 300, "timeUnit": "TIME_UNIT_MINUTE"},
"detail": {"limit": "100", "remaining": "100",
"resetTime": "2026-07-23T23:27:17.211180Z"}}
],
"parallel": {"limit": "20"}
}
""".data(using: .utf8)!
let usage = try! KimiSubscriptionService.parseUsage(data: json)
XCTAssertEqual(usage.plan, "Intermediate")
XCTAssertEqual(usage.parallelLimit, 20)
XCTAssertEqual(usage.primary?.label, "Weekly")
XCTAssertEqual(usage.primary?.usedPercent ?? -1, 5, accuracy: 0.001)
// 300 minutes rolls up to a 5-hour label; used derives from remaining.
XCTAssertEqual(usage.details.count, 1)
XCTAssertEqual(usage.details.first?.label, "5-hour")
XCTAssertEqual(usage.details.first?.usedPercent ?? -1, 0, accuracy: 0.001)
XCTAssertNotNil(usage.details.first?.resetsAt)
}
func testEmptyEnvelopeThrows() {
let json = "{}".data(using: .utf8)!
XCTAssertThrowsError(try KimiSubscriptionService.parseUsage(data: json))
}
func testZeroLimitWindowDropped() throws {
let json = """
{"usage": {"limit": 0, "used": 0}, "limits": []}
""".data(using: .utf8)!
XCTAssertThrowsError(try KimiSubscriptionService.parseUsage(data: json))
}
}

View file

@ -0,0 +1,87 @@
import AppKit
import XCTest
@testable import CodeBurnMenubar
/// Locks the right-click menu policy that fixed flash + scroll-jump (#802).
/// Does not drive a real NSStatusItem that needs manual/AppKit integration.
final class StatusItemContextMenuPolicyTests: XCTestCase {
func testPresentEventMaskIsRightMouseUpNotDown() {
// Presenting on mouse-down lets the matching up dismiss the menu (flash).
XCTAssertEqual(
StatusItemContextMenuPolicy.presentEventMask,
NSEvent.EventTypeMask.rightMouseUp
)
XCTAssertNotEqual(
StatusItemContextMenuPolicy.presentEventMask,
NSEvent.EventTypeMask.rightMouseDown
)
// Mask must include up and must not include down (single-bit masks here).
XCTAssertTrue(StatusItemContextMenuPolicy.presentEventMask.contains(.rightMouseUp))
XCTAssertFalse(StatusItemContextMenuPolicy.presentEventMask.contains(.rightMouseDown))
}
func testPresentationUsesStatusItemMenuNotManualPopUp() {
// Manual popUp tracks against a point while the cursor sits on the status
// item above the menu scroll chevron / Today-row jump on mouse move.
XCTAssertEqual(
StatusItemContextMenuPolicy.presentation,
.statusItemMenu
)
XCTAssertNotEqual(
StatusItemContextMenuPolicy.presentation,
.manualPopUp
)
}
func testDebounceAcceptsFirstPresent() {
var last = Date.distantPast
let now = Date(timeIntervalSince1970: 1_000)
XCTAssertTrue(
StatusItemContextMenuPolicy.acceptPresent(now: now, lastPresentedAt: &last)
)
XCTAssertEqual(last, now)
}
func testDebounceRejectsWithinWindow() {
let t0 = Date(timeIntervalSince1970: 1_000)
var last = t0
// Just inside the 0.3s window
let t1 = t0.addingTimeInterval(0.299)
XCTAssertFalse(
StatusItemContextMenuPolicy.acceptPresent(now: t1, lastPresentedAt: &last)
)
XCTAssertEqual(last, t0, "reject must not advance lastPresentedAt")
}
func testDebounceAcceptsAfterWindow() {
let t0 = Date(timeIntervalSince1970: 1_000)
var last = t0
let t1 = t0.addingTimeInterval(StatusItemContextMenuPolicy.presentDebounceSeconds + 0.001)
XCTAssertTrue(
StatusItemContextMenuPolicy.acceptPresent(now: t1, lastPresentedAt: &last)
)
XCTAssertEqual(last, t1)
}
func testDebounceBoundaryIsStrictlyGreaterThan() {
// Gate uses `>` not `>=`: exactly debounce seconds later is still rejected.
let t0 = Date(timeIntervalSince1970: 1_000)
var last = t0
let exact = t0.addingTimeInterval(StatusItemContextMenuPolicy.presentDebounceSeconds)
XCTAssertFalse(
StatusItemContextMenuPolicy.acceptPresent(now: exact, lastPresentedAt: &last)
)
XCTAssertEqual(last, t0)
}
func testCustomDebounceOverride() {
var last = Date(timeIntervalSince1970: 0)
let now = Date(timeIntervalSince1970: 0.5)
XCTAssertFalse(
StatusItemContextMenuPolicy.acceptPresent(now: now, lastPresentedAt: &last, debounce: 1.0)
)
XCTAssertTrue(
StatusItemContextMenuPolicy.acceptPresent(now: now, lastPresentedAt: &last, debounce: 0.4)
)
}
}

View file

@ -59,6 +59,44 @@ struct UpdateCheckerTests {
}
}
@Suite("Update failure presentation")
@MainActor
struct UpdateFailurePresentationTests {
@Test("identifies an automatic update check failure")
func updateCheckFailure() {
let checker = UpdateChecker()
checker.updateFailureStage = .check
checker.updateError = "GitHub returned HTTP 403."
#expect(checker.updateBadgeLabel == "Update Check Failed")
#expect(checker.updateHelpText.contains("could not check GitHub for updates"))
#expect(checker.updateHelpText.contains("GitHub returned HTTP 403."))
#expect(checker.updateHelpText.contains("retry the update check"))
}
@Test("distinguishes CLI update failures")
func cliUpdateFailure() {
let checker = UpdateChecker()
checker.updateFailureStage = .cliUpdate
checker.updateError = "npm exited with status 1."
#expect(checker.updateBadgeLabel == "CLI Update Failed")
#expect(checker.updateHelpText.contains("could not update the CLI"))
#expect(checker.updateHelpText.contains("npm exited with status 1."))
}
@Test("distinguishes menubar update failures")
func menubarUpdateFailure() {
let checker = UpdateChecker()
checker.updateFailureStage = .menubarUpdate
checker.updateError = "Checksum mismatch."
#expect(checker.updateBadgeLabel == "Menubar Update Failed")
#expect(checker.updateHelpText.contains("could not update the menubar app"))
#expect(checker.updateHelpText.contains("Checksum mismatch."))
}
}
// MARK: - one-click full update: package-manager resolution

View file

@ -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<void>, 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<ObservationResult> {
@ -100,6 +124,7 @@ async function observe(path: string): Promise<ObservationResult> {
// 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<ObservationResult> {
await delay(1)
continue
}
const parsed = JSON.parse(raw) as Partial<LockRecord>
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<LockRecord> | undefined
try { parsed = JSON.parse(raw) as Partial<LockRecord> } 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<ObservationResult> {
}
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<void> = 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)

View file

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

521
src/codex-throughput.ts Normal file
View file

@ -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<Array<[number, number]>>((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<string, number>
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<CodexThroughputPoint[]> {
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<CodexThroughputPoint[]> {
return new CodexThroughputReader().update(filePath, limit, true)
}
export async function newestCodexSession(sessions: Array<{ path: string }>): Promise<string | undefined> {
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')
}

View file

@ -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<DailyEntry['projects']> = {}
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<string, unknown>[]): DailyEntry[] {
}))
}
function migratedFrom(parsed: { version: number; lastComputedDate: string | null; savingsConfigHash?: string; tzKey?: string; days: Record<string, unknown>[]; complete?: boolean }): DailyCache {
function migratedFrom(parsed: { version: number; lastComputedDate: string | null; savingsConfigHash?: string; tzKey?: string; days: Record<string, unknown>[]; 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<DailyCache> {
// 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<string | null>((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

View file

@ -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'
@ -83,6 +85,7 @@ const PROVIDER_COLORS: Record<string, string> = {
opencode: '#A78BFA',
pi: '#F472B6',
kimi: '#B6E34A',
kimicode: '#A3E635',
all: '#FF8C42',
}
@ -195,9 +198,9 @@ function nextTick(): Promise<void> {
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
@ -439,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
@ -448,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]) => ({
@ -459,7 +467,7 @@ function ModelBreakdown({ projects, pw, bw }: { projects: ProjectSummary[]; pw:
return (
<Panel title="By Model" color={PANEL_COLORS.model} width={pw}>
<Text dimColor wrap="truncate-end">{''.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)}</Text>
<Text dimColor wrap="truncate-end">{''.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) : ''}</Text>
{sorted.map(([model, data], i) => {
const totalInput = data.freshInput + data.cacheRead + data.cacheWrite
const cacheHit = totalInput > 0 ? (data.cacheRead / totalInput) * 100 : 0
@ -468,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 (
<Text key={`${model}-${i}`} wrap="truncate-end">
<HBar value={data.costUSD} max={maxCost} width={bw} />
@ -476,6 +487,7 @@ function ModelBreakdown({ projects, pw, bw }: { projects: ProjectSummary[]; pw:
<Text>{cacheLabel.padStart(MODEL_COL_CACHE)}</Text>
<Text>{String(data.calls).padStart(MODEL_COL_CALLS)}</Text>
<Text>{oneShotLabel.padStart(MODEL_COL_ONESHOT)}</Text>
{showTps && <Text>{tpsLabel.padStart(MODEL_COL_TPS)}</Text>}
</Text>
)
})}
@ -487,6 +499,9 @@ function ModelBreakdown({ projects, pw, bw }: { projects: ProjectSummary[]; pw:
{anyEstimated && (
<Text dimColor wrap="truncate-end">~ estimated cost (priced from estimated tokens)</Text>
)}
{showTps && (
<Text dimColor wrap="truncate-end">~ Tok/s: generated tokens / active time; tool wait excluded</Text>
)}
</Panel>
)
}
@ -670,6 +685,7 @@ const PROVIDER_DISPLAY_NAMES: Record<string, string> = {
opencode: 'OpenCode',
pi: 'Pi',
kimi: 'Kimi',
kimicode: 'Kimi Code',
}
function getProviderDisplayName(name: string): string { return PROVIDER_DISPLAY_NAMES[name] ?? name }

View file

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

View file

@ -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<CodeburnConfig['priceOverrides']>[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 <n>', 'Number of recent checkpoints to scan', parseCodexTpsLimit, 10)
.option('--watch <seconds>', '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<void> => {
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<void>((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')

View file

@ -84,6 +84,7 @@ export type ProviderCost = {
import type { OptimizeResult } from './optimize.js'
import { getCurrency } from './currency.js'
import type { GranularHistory } from './granular-history.js'
import { getShortModelName } from './models.js'
import type { ReworkedFile } from './workflow-insights.js'
import type { PrRow, BranchRow } from './sessions-report.js'
@ -369,10 +370,24 @@ function buildTopActivities(categories: PeriodData['categories']): MenubarPayloa
}
function buildTopModels(models: PeriodData['models']): MenubarPayload['current']['topModels'] {
return models
.filter(m => m.name !== SYNTHETIC_MODEL_NAME)
// Day entries key models by the raw provider id (day-aggregator), so resolve
// display names here — the menubar shows "Kimi K3" rather than "k3". Ids that
// collapse to one display name (e.g. k3 and kimi-k3) merge into a single row.
const merged = new Map<string, { cost: number; calls: number; savingsUSD: number; estimatedCostUSD: number }>()
for (const m of models) {
if (m.name === SYNTHETIC_MODEL_NAME) continue
const name = getShortModelName(m.name)
const acc = merged.get(name) ?? { cost: 0, calls: 0, savingsUSD: 0, estimatedCostUSD: 0 }
acc.cost += m.cost
acc.calls += m.calls
acc.savingsUSD += m.savingsUSD ?? 0
acc.estimatedCostUSD += m.estimatedCostUSD ?? 0
merged.set(name, acc)
}
return [...merged.entries()]
.sort(([, a], [, b]) => b.cost - a.cost)
.slice(0, TOP_MODELS_LIMIT)
.map(m => ({ name: m.name, cost: m.cost, calls: m.calls, savingsUSD: m.savingsUSD, savingsBaselineModel: '', estimatedCostUSD: m.estimatedCostUSD ?? 0 }))
.map(([name, d]) => ({ name, cost: d.cost, calls: d.calls, savingsUSD: d.savingsUSD, savingsBaselineModel: '', estimatedCostUSD: d.estimatedCostUSD }))
}
function buildOptimize(optimize: OptimizeResult | null): MenubarPayload['optimize'] {

View file

@ -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<string,
const name = getShortModelName(model)
const totals = (modelTotals[name] ??= {
calls: 0, costUSD: 0, estimatedCostUSD: 0, freshInput: 0, cacheRead: 0, cacheWrite: 0,
activeDurationMs: 0, activeGeneratedTokens: 0,
})
totals.calls += data.calls
totals.costUSD += data.costUSD
@ -31,6 +34,8 @@ export function aggregateModelTotals(projects: ProjectSummary[]): Record<string,
totals.freshInput += data.tokens.inputTokens
totals.cacheRead += data.tokens.cacheReadInputTokens
totals.cacheWrite += data.tokens.cacheCreationInputTokens
totals.activeDurationMs += data.activeDurationMs ?? 0
totals.activeGeneratedTokens += data.activeGeneratedTokens ?? 0
}
}
}

View file

@ -290,6 +290,12 @@ const BUILTIN_ALIASES: Record<string, string> = {
'kimi-auto': 'kimi-k2-thinking',
'kimi-code': 'kimi-k2-thinking',
'kimi-for-coding': 'kimi-k2-thinking',
// Kimi Code wires report the bare `k3` id in llm.request.model; without an
// alias those calls priced at $0 and the provider looked absent in the UI.
'k3': 'kimi-k3',
// Kimi desktop/IDE embedded runtime serves `k3-agent` / `k2d6-agent`.
'k3-agent': 'kimi-k3',
'k2d6-agent': 'kimi-k2p6',
'mimo-v2-flash': 'xiaomi/mimo-v2-flash',
'kat-coder-pro-v1': 'kwaipilot/kat-coder-pro',
// Cursor emits dot-version tier-last names plus tier/reasoning suffixes
@ -879,6 +885,8 @@ const SHORT_NAMES: Record<string, string> = {
'gemini-2.5-flash': 'Gemini 2.5 Flash',
'kimi-k2-thinking-turbo': 'Kimi K2 Thinking Turbo',
'kimi-k2-thinking': 'Kimi K2 Thinking',
'kimi-k3': 'Kimi K3',
'kimi-k2p6': 'Kimi K2.6',
'kimi-thinking-preview': 'Kimi Thinking',
'kimi-k2.6': 'Kimi K2.6',
'kimi-k2.5': 'Kimi K2.5',

View file

@ -97,6 +97,9 @@ export type OverviewDurable = {
cacheWriteTokens: number
days: DailyEntry[]
carriedCostUSD: number
/// Cost a --project/--exclude filter could not attribute (cached days with no
/// per-project split). Optional so callers that never filter can omit it.
unattributedCostUSD?: number
}
export function renderOverview(
@ -350,5 +353,12 @@ export function renderOverview(
out.push(c.dim(` includes ${formatCost(durable.carriedCostUSD)} preserved from expired session logs`))
}
// A project filter cannot claim days the cache holds without a project split
// (recorded before that split existed), so they sit outside this total. Say how
// much rather than let the filtered figure look inexplicably short.
if (durable && (durable.unattributedCostUSD ?? 0) > 0) {
out.push(c.dim(` excludes ${formatCost(durable.unattributedCostUSD!)} from days with no per-project history`))
}
return out.join('\n') + '\n'
}

View file

@ -8,7 +8,7 @@ import { normalizeContentBlocks } from './content-utils.js'
import { discoverAllSessions, getProvider } from './providers/index.js'
import { flushCodexCache } from './codex-cache.js'
import { antigravityCascadeIdFromPath, flushAntigravityCache, shouldReparseAntigravitySource } from './providers/antigravity.js'
import { getDesktopSessionsDir } from './providers/claude.js'
import { getDesktopSessionsDirs } from './providers/claude.js'
import { isSqliteBusyError } from './sqlite.js'
import {
type CachedCall,
@ -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,
@ -81,9 +82,13 @@ function projectNameFromPath(projectPath: string, fallback: string): string {
// In both cases the grouping key comes from the Cowork space name resolved in
// claude.ts::discoverSessions().
function isCoworkSession(cwd: string, filePath: string): boolean {
const base = resolve(getDesktopSessionsDir())
const inBase = (p: string) => p.startsWith(base + sep) || p.startsWith(base + '/')
return inBase(resolve(cwd)) || inBase(resolve(filePath))
const resolvedCwd = resolve(cwd)
const resolvedFilePath = resolve(filePath)
return getDesktopSessionsDirs().some(base => {
const resolvedBase = resolve(base)
const inBase = (p: string) => p.startsWith(resolvedBase + sep) || p.startsWith(resolvedBase + '/')
return inBase(resolvedCwd) || inBase(resolvedFilePath)
})
}
async function resolveCanonicalProjectPath(cwd: string): Promise<{ path: string; isWorktree: boolean }> {
@ -1730,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 }
@ -1927,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') {
@ -1938,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++
@ -2196,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] : []
})
}
@ -2389,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,
}
}
@ -2425,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,
}
}
@ -2537,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,
})
}
@ -2782,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<T extends { timestamp: string }>(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<string>): 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[],
@ -2822,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
}
}
@ -2989,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)
@ -3015,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 } : {}),
@ -3038,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] })
}
}
}
@ -3149,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
@ -3285,9 +3372,13 @@ export function filterProjectsByDays(projects: ProjectSummary[], days: Set<strin
const anchors: SessionSummary[] = [...(project.subagentAnchors ?? [])]
const survivingIdentities = new Set<string>()
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)
@ -3494,7 +3585,13 @@ export function filterProjectsByDateRange(projects: ProjectSummary[], dateRange:
const anchors: SessionSummary[] = [...(project.subagentAnchors ?? [])]
const survivingIdentities = new Set<string>()
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
@ -3521,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<ProjectSummary[]> {
const key = cacheKey(dateRange, providerFilter)
const cached = sessionCache.get(key)
@ -3590,6 +3696,7 @@ async function runParse(
options: RunParseOptions = {},
): Promise<ProjectSummary[]> {
const { isCold = false, readOnly = false, refreshLock } = options
readOnlyServedStale = false
const seenMsgIds = new Set<string>()
const seenKeys = new Set<string>()
const allSources = await discoverAllSessions(providerFilter)
@ -3629,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 <other> 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[] = []
@ -3694,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

View file

@ -1,3 +1,4 @@
import { readdirSync, statSync } from 'fs'
import { readFile, readdir, stat } from 'fs/promises'
import { basename, delimiter as pathDelimiter, join, resolve } from 'path'
import { homedir } from 'os'
@ -101,15 +102,72 @@ export async function discoverClaudeConfigSources(): Promise<ClaudeConfigSource[
})))
}
export function getDesktopSessionsDir(): string {
// Filesystem changes under an unchanged input key intentionally do not invalidate this cache.
const desktopSessionsDirsCache = new Map<string, string[]>()
function cacheDesktopSessionsDirs(key: string, candidates: string[]): string[] {
const dirs = dedupeResolved(candidates.map(candidate => resolve(candidate)))
desktopSessionsDirsCache.set(key, dirs)
return [...dirs]
}
export function getDesktopSessionsDirs(): string[] {
const override = process.env['CODEBURN_DESKTOP_SESSIONS_DIR']
if (override) return override
if (process.platform === 'darwin') return join(homedir(), 'Library', 'Application Support', 'Claude', 'local-agent-mode-sessions')
if (process.platform === 'win32') {
const appData = process.env['APPDATA']?.trim()
return join(appData || join(homedir(), 'AppData', 'Roaming'), 'Claude', 'local-agent-mode-sessions')
const appDataInput = process.env['APPDATA']
const localAppDataInput = process.env['LOCALAPPDATA']
const platform = process.platform
const cacheKey = JSON.stringify([platform, override ?? null, appDataInput ?? null, localAppDataInput ?? null])
const cached = desktopSessionsDirsCache.get(cacheKey)
if (cached) return [...cached]
if (override) return cacheDesktopSessionsDirs(cacheKey, [override])
if (platform === 'darwin') {
return cacheDesktopSessionsDirs(
cacheKey,
[join(homedir(), 'Library', 'Application Support', 'Claude', 'local-agent-mode-sessions')],
)
}
return join(homedir(), '.config', 'Claude', 'local-agent-mode-sessions')
if (platform === 'win32') {
const appData = appDataInput?.trim()
const candidates = [
join(appData || join(homedir(), 'AppData', 'Roaming'), 'Claude', 'local-agent-mode-sessions'),
]
const localAppData = localAppDataInput?.trim()
const packagesDir = join(localAppData || join(homedir(), 'AppData', 'Local'), 'Packages')
try {
const entries = readdirSync(packagesDir, { withFileTypes: true })
.filter(entry =>
entry.isDirectory() &&
(entry.name.startsWith('Claude_') || entry.name.includes('.Claude_')),
)
.sort((a, b) => a.name < b.name ? -1 : a.name > b.name ? 1 : 0)
for (const entry of entries) {
const sessionsDir = join(
packagesDir,
entry.name,
'LocalCache',
'Roaming',
'Claude',
'local-agent-mode-sessions',
)
try {
if (statSync(sessionsDir).isDirectory()) candidates.push(sessionsDir)
} catch {
// A package may disappear or be unreadable while Packages is scanned.
}
}
} catch {
// Missing or unreadable Packages is equivalent to no MSIX candidates.
}
return cacheDesktopSessionsDirs(cacheKey, candidates)
}
return cacheDesktopSessionsDirs(
cacheKey,
[join(homedir(), '.config', 'Claude', 'local-agent-mode-sessions')],
)
}
async function findDesktopProjectDirs(base: string): Promise<string[]> {
@ -221,7 +279,7 @@ export const claude: Provider = {
async probeRoots(): Promise<ProbeRoot[]> {
const dirs = await getClaudeConfigDirs()
const roots: ProbeRoot[] = dirs.map(dir => ({ path: join(dir, 'projects'), label: 'projects' }))
roots.push({ path: getDesktopSessionsDir(), label: 'desktop' })
roots.push(...getDesktopSessionsDirs().map(path => ({ path, label: 'desktop' })))
return roots
},
@ -282,51 +340,52 @@ export const claude: Provider = {
)
}
const desktopBase = getDesktopSessionsDir()
const desktopDirs = await findDesktopProjectDirs(desktopBase)
const sep = desktopBase.includes('\\') ? '\\' : '/'
// Desktop / Cowork sessions belong to no CLAUDE_CONFIG_DIR. Tag them with a
// distinct source so a per-config view can account for them as their own
// "Claude Desktop" bucket instead of silently dropping them (which made
// sum-of-configs < All).
const desktopSourceId = 'claude-desktop:' + createHash('sha256').update(resolve(desktopBase)).digest('hex').slice(0, 16)
for (const dirPath of desktopDirs) {
const resolved = resolve(dirPath)
if (seenProjectDirs.has(resolved)) continue
seenProjectDirs.add(resolved)
for (const desktopBase of getDesktopSessionsDirs()) {
const desktopDirs = await findDesktopProjectDirs(desktopBase)
const sep = desktopBase.includes('\\') ? '\\' : '/'
// Desktop / Cowork sessions belong to no CLAUDE_CONFIG_DIR. Tag them with a
// distinct source so a per-config view can account for them as their own
// "Claude Desktop" bucket instead of silently dropping them (which made
// sum-of-configs < All).
const desktopSourceId = 'claude-desktop:' + createHash('sha256').update(resolve(desktopBase)).digest('hex').slice(0, 16)
for (const dirPath of desktopDirs) {
const resolved = resolve(dirPath)
if (seenProjectDirs.has(resolved)) continue
seenProjectDirs.add(resolved)
// For Claude Desktop local-agent-mode (Cowork) sessions, the project dir
// lives inside local_<sessionId>/.claude/projects/. We resolve the space
// name from the sibling .json and spaces.json so it groups correctly.
// Path structure: <desktopBase>/<appId>/<workspaceId>/local_<id>/.claude/projects/<slug>
let projectName = basename(dirPath)
const resolvedBase = resolve(desktopBase)
if (resolved.startsWith(resolvedBase + sep) || resolved.startsWith(resolvedBase + '/')) {
const rel = resolved.slice(resolvedBase.length + 1)
const parts = rel.split(/[/\\]/)
// parts = [appId, workspaceId, local_sessionId, .claude, projects, slug]
if (
parts.length >= 6 &&
parts[2]?.startsWith('local_') &&
parts[3] === '.claude' &&
parts[4] === 'projects'
) {
const workspaceDir = join(resolvedBase, parts[0]!, parts[1]!)
const sessionId = parts[2]!
const spaceName = await resolveCoworkSpaceName(workspaceDir, sessionId)
if (spaceName) projectName = spaceName
// For Claude Desktop local-agent-mode (Cowork) sessions, the project dir
// lives inside local_<sessionId>/.claude/projects/. We resolve the space
// name from the sibling .json and spaces.json so it groups correctly.
// Path structure: <desktopBase>/<appId>/<workspaceId>/local_<id>/.claude/projects/<slug>
let projectName = basename(dirPath)
const resolvedBase = resolve(desktopBase)
if (resolved.startsWith(resolvedBase + sep) || resolved.startsWith(resolvedBase + '/')) {
const rel = resolved.slice(resolvedBase.length + 1)
const parts = rel.split(/[/\\]/)
// parts = [appId, workspaceId, local_sessionId, .claude, projects, slug]
if (
parts.length >= 6 &&
parts[2]?.startsWith('local_') &&
parts[3] === '.claude' &&
parts[4] === 'projects'
) {
const workspaceDir = join(resolvedBase, parts[0]!, parts[1]!)
const sessionId = parts[2]!
const spaceName = await resolveCoworkSpaceName(workspaceDir, sessionId)
if (spaceName) projectName = spaceName
}
}
}
sources.push({
path: dirPath,
project: projectName,
provider: 'claude',
sourceId: desktopSourceId,
sourceLabel: 'Claude Desktop',
sourcePath: desktopBase,
sourceKind: 'claude-desktop',
})
sources.push({
path: dirPath,
project: projectName,
provider: 'claude',
sourceId: desktopSourceId,
sourceLabel: 'Claude Desktop',
sourcePath: desktopBase,
sourceKind: 'claude-desktop',
})
}
}
return sources

View file

@ -32,6 +32,9 @@ const modelDisplayEntries = Object.entries(modelDisplayNames).sort((a, b) => b[0
const toolNameMap: Record<string, string> = {
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<string, unknown>
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<SessionSource | nu
const s = await stat(filePath).catch(() => 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<SessionSource | nu
async function discoverSessionsInDir(codexDir: string): Promise<SessionSource[]> {
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<string>()
const sessionsDir = join(codexDir, 'sessions')
const years = await readdir(sessionsDir).catch(() => [] as string[])
@ -335,8 +504,9 @@ async function discoverSessionsInDir(codexDir: string): Promise<SessionSource[]>
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<SessionSource[]>
// 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<string>): 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<string, number>()
// 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<string>): 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<string>): 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<Array<[number, number]>>((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<string, unknown>
@ -490,6 +741,11 @@ function createParser(source: SessionSource, seenKeys: Set<string>): SessionPars
// attributed. Rebuild the canonical `mcp__<server>__<tool>` 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<string, unknown>)['invocation'] as Record<string, unknown> | 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<string>): 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<string>): SessionPars
...(pendingLocRemoved ? { locRemoved: pendingLocRemoved } : {}),
...(pendingEditFailed ? { editFailed: pendingEditFailed } : {}),
})
taskGeneratedTokens += estOutput
pendingTools = []
pendingToolSequence = []
@ -660,7 +917,7 @@ function createParser(source: SessionSource, seenKeys: Set<string>): SessionPars
0,
)
results.push({
pendingTaskCalls.push({
provider: 'codex',
model,
inputTokens: uncachedInputTokens,
@ -685,6 +942,7 @@ function createParser(source: SessionSource, seenKeys: Set<string>): SessionPars
...(pendingLocRemoved ? { locRemoved: pendingLocRemoved } : {}),
...(pendingEditFailed ? { editFailed: pendingEditFailed } : {}),
})
taskGeneratedTokens += outputTokens + reasoningTokens
pendingTools = []
pendingToolSequence = []
@ -701,6 +959,9 @@ function createParser(source: SessionSource, seenKeys: Set<string>): 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) {

View file

@ -71,8 +71,18 @@ function timestampIso(value: unknown): string {
return Number.isNaN(date.getTime()) ? '' : date.toISOString()
}
function kimicodeHome(override?: string): string {
return resolve(override || process.env['KIMI_CODE_HOME'] || join(homedir(), '.kimi-code'))
function kimicodeHomes(override?: string): string[] {
const explicit = override || process.env['KIMI_CODE_HOME']
if (explicit) return [resolve(explicit)]
// Default stores. Beyond the CLI's own ~/.kimi-code, embedded runtimes keep
// the same wire layout under their own home (Kimi desktop app, Kimi Code
// IDE); each home is scanned so embedded-agent usage is not invisible.
const home = homedir()
const homes = [
join(home, '.kimi-code'),
join(home, 'Library', 'Application Support', 'kimi-desktop', 'daimon-share', 'daimon', 'runtime', 'kimi-code', 'home'),
]
return [...new Set(homes.map(h => resolve(h)))]
}
async function directoryEntries(path: string) {
@ -120,7 +130,10 @@ async function discoverSources(root: string): Promise<SessionSource[]> {
const workDirPath = join(sessionsDir, workDirEntry.name)
for (const sessionEntry of await directoryEntries(workDirPath)) {
if (!sessionEntry.isDirectory() || !sessionEntry.name.startsWith('session_')) continue
// Session dir naming differs by host product: the CLI uses session_*,
// embedded runtimes (desktop app, IDE) use conv-*/ctitle-*. Any directory
// is accepted; the agents/*/wire.jsonl probe below gates real sessions.
if (!sessionEntry.isDirectory()) continue
const sessionDir = join(workDirPath, sessionEntry.name)
const state = await readState(sessionDir)
const project = projectFromWorkDir(state.workDir ?? '', workDirEntry.name)
@ -344,11 +357,15 @@ export function createKimicodeProvider(homeOverride?: string): Provider {
},
async probeRoots(): Promise<ProbeRoot[]> {
return [{ path: kimicodeHome(homeOverride), label: 'Kimi Code home' }]
return kimicodeHomes(homeOverride).map(path => ({ path, label: 'Kimi Code home' }))
},
async discoverSessions(): Promise<SessionSource[]> {
return discoverSources(kimicodeHome(homeOverride))
const all: SessionSource[] = []
for (const home of kimicodeHomes(homeOverride)) {
all.push(...await discoverSources(home))
}
return all.sort((a, b) => a.path.localeCompare(b.path))
},
createSessionParser(source: SessionSource, seenKeys: Set<string>): SessionParser {

View file

@ -2,7 +2,7 @@ import { readdir, stat } from 'fs/promises'
import { basename, join } from 'path'
import { homedir } from 'os'
import { readSessionFile } from '../fs-utils.js'
import { readSessionFile, readSessionLines } from '../fs-utils.js'
import { calculateCost } from '../models.js'
import { extractBashCommands } from '../bash-utils.js'
import { normalizeContentBlocks } from '../content-utils.js'
@ -90,16 +90,30 @@ function getOmpSessionsDir(override?: string): string {
return override ?? join(homedir(), '.omp', 'agent', 'sessions')
}
async function readFirstEntry(filePath: string): Promise<PiEntry | null> {
const content = await readSessionFile(filePath)
if (content === null) return null
const line = content.split('\n')[0]
if (!line?.trim()) return null
try {
return JSON.parse(line) as PiEntry
} catch {
return null
// OMP can write a fixed-width title metadata line (`type: "title"`) before the
// `type: "session"` header (issue #845), and either provider may pad the
// header with blank lines. Scan a bounded number of leading lines rather than
// just the first one, and rather than the whole file (issue #846 read each
// transcript in full to reach line 0), so discovery stays cheap even when a
// file turns out to have no session record at all (a message-only file, or a
// pathological run of blank/junk lines).
const MAX_HEADER_LINES_SCANNED = 20
async function readSessionEntry(filePath: string): Promise<PiEntry | null> {
let linesScanned = 0
for await (const line of readSessionLines(filePath)) {
if (linesScanned >= MAX_HEADER_LINES_SCANNED) break
linesScanned++
const trimmed = line.trim()
if (!trimmed) continue
try {
const entry = JSON.parse(trimmed) as PiEntry
if (entry.type === 'session') return entry
} catch {
continue
}
}
return null
}
async function discoverSessionsInDir(sessionsDir: string, providerName: string): Promise<SessionSource[]> {
@ -130,10 +144,10 @@ async function discoverSessionsInDir(sessionsDir: string, providerName: string):
const fileStat = await stat(filePath).catch(() => null)
if (!fileStat?.isFile()) continue
const first = await readFirstEntry(filePath)
if (!first || first.type !== 'session') continue
const entry = await readSessionEntry(filePath)
if (!entry) continue
const cwd = first.cwd ?? dirName
const cwd = entry.cwd ?? dirName
sources.push({ path: filePath, project: basename(cwd), provider: providerName })
}
}

View file

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

View file

@ -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<string, string> = {
// 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',
@ -338,6 +341,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'])

View file

@ -141,6 +141,42 @@ export interface CallbackResult {
port: number
}
/**
* Themed HTML for the OAuth callback landing page.
*
* Matches the CodeBurn dashboard theme (dash/src/index.css): warm paper
* surfaces, ink text, forest-green accent. Everything is inline this page
* is served once from a throwaway localhost server, so it must not depend
* on network fonts, external CSS, or dashboard assets.
*/
export function renderCallbackPage(ok: boolean, rawTitle: string, rawMessage: string): string {
// Current call sites pass literals, but escape anyway so a future caller
// interpolating IdP-influenced text (e.g. the callback `error` param) cannot
// turn this localhost page into an XSS sink.
const escapeHtml = (s: string) => s.replace(/[&<>"']/g, c =>
({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[c]!)
const title = escapeHtml(rawTitle)
const message = escapeHtml(rawMessage)
const accent = ok ? '#1f8a5b' : '#c8541f' // --primary / --chart-5 (terracotta)
const mark = ok ? '&#10003;' : '&#10005;' // ✓ / ✕
return `<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>CodeBurn ${title}</title>
</head>
<body style="margin:0;min-height:100vh;display:flex;align-items:center;justify-content:center;background:#e9e6de;color:#16181d;font-family:'Geist',system-ui,-apple-system,'Segoe UI',sans-serif;letter-spacing:-0.006em;">
<main style="background:#ffffff;border:1px solid rgba(23,27,32,0.08);border-radius:8px;padding:48px 56px;max-width:420px;text-align:center;box-shadow:0 1px 3px rgba(23,27,32,0.06);">
<div style="width:56px;height:56px;margin:0 auto 20px;border-radius:50%;background:${accent};color:#ffffff;font-size:28px;line-height:56px;font-weight:700;">${mark}</div>
<h1 style="margin:0 0 8px;font-size:20px;font-weight:600;color:#2c5242;">${title}</h1>
<p style="margin:0;font-size:15px;color:#5d626b;line-height:1.5;">${message}</p>
<p style="margin:28px 0 0;font-size:12px;color:#8a857c;">CodeBurn <span style="color:${accent};">&bull;</span> local sync login</p>
</main>
</body>
</html>`
}
export function startCallbackServer(
expectedState: string,
timeoutMs: number = 300_000, // 5 minutes
@ -179,8 +215,8 @@ export function startCallbackServer(
// Connection: close on every response — the callback server is
// single-purpose and must never leave pooled keep-alive sockets behind.
if (error) {
res.writeHead(400, { 'Content-Type': 'text/html', 'Connection': 'close' })
res.end('<html><body><h1>Login failed</h1><p>You can close this tab.</p></body></html>')
res.writeHead(400, { 'Content-Type': 'text/html; charset=utf-8', 'Connection': 'close' })
res.end(renderCallbackPage(false, 'Login failed', 'The identity provider rejected the login. You can close this tab and retry from the terminal.'))
clearTimeout(timer)
shutdown()
reject(new AuthError(`IdP returned error: ${error}`))
@ -199,8 +235,8 @@ export function startCallbackServer(
return
}
res.writeHead(200, { 'Content-Type': 'text/html', 'Connection': 'close' })
res.end('<html><body><h1>✓ Login successful</h1><p>You can close this tab.</p></body></html>')
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8', 'Connection': 'close' })
res.end(renderCallbackPage(true, 'Login successful', 'CodeBurn sync is now authenticated. You can close this tab and return to the terminal.'))
clearTimeout(timer)
shutdown()
resolve({ code, port: resolvedPort })

View file

@ -7,8 +7,9 @@
import type { Command } from 'commander'
import { randomBytes } from 'crypto'
import { fetchDiscoveryDoc } from './discovery.js'
import { fetchDiscoveryDoc, DiscoveryError } from './discovery.js'
import {
AuthError,
fetchOidcConfig,
generatePkce,
buildAuthUrl,
@ -21,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
@ -33,100 +35,120 @@ export function registerSyncCommands(program: Command): void {
.command('setup <url>')
.description('Configure sync with a remote endpoint (one-time)')
.action(async (url: string) => {
const baseUrl = url.replace(/\/$/, '')
process.stderr.write(`Fetching discovery doc from ${baseUrl}...\n`)
// 1. Fetch codeburn discovery doc
const discovery = await fetchDiscoveryDoc(baseUrl)
process.stderr.write(` Issuer: ${discovery.issuer}\n`)
process.stderr.write(` Client: ${discovery.client_id}\n`)
// 2. Fetch OIDC configuration from the issuer
const oidc = await fetchOidcConfig(discovery.issuer)
process.stderr.write(` Auth endpoint: ${oidc.authorization_endpoint}\n`)
// 3. Resolve scopes
const scopes = resolveScopes(discovery.scopes, oidc.scopes_supported)
// 4. Generate PKCE
const pkce = generatePkce()
const state = randomBytes(16).toString('hex')
// 5. Start callback server — await the actually-bound port (port
// fallback means it may not be the first in CALLBACK_PORTS)
const { promise: callbackPromise, ready } = startCallbackServer(state)
const port = await ready
const redirectUri = `http://127.0.0.1:${port}/callback`
// 6. Build auth URL and open browser
const authUrl = buildAuthUrl({
authorization_endpoint: oidc.authorization_endpoint,
client_id: discovery.client_id,
redirect_uri: redirectUri,
scopes,
state,
pkce,
})
process.stderr.write(`\nOpening browser for login...\n`)
process.stderr.write(`If the browser doesn't open, visit:\n ${authUrl}\n\n`)
// Open browser (best-effort, platform-specific).
// execFileSync with args array — authUrl comes from the remote discovery
// doc so it must never be shell-interpolated. Scheme is also validated.
try {
if (!/^https:\/\//.test(authUrl)) {
throw new Error('auth URL must be https')
const baseUrl = url.replace(/\/$/, '')
process.stderr.write(`Fetching discovery doc from ${baseUrl}...\n`)
// 1. Fetch codeburn discovery doc
const discovery = await fetchDiscoveryDoc(baseUrl)
process.stderr.write(` Issuer: ${discovery.issuer}\n`)
process.stderr.write(` Client: ${discovery.client_id}\n`)
// 2. Fetch OIDC configuration from the issuer
const oidc = await fetchOidcConfig(discovery.issuer)
process.stderr.write(` Auth endpoint: ${oidc.authorization_endpoint}\n`)
// 3. Resolve scopes
const scopes = resolveScopes(discovery.scopes, oidc.scopes_supported)
// 4. Generate PKCE
const pkce = generatePkce()
const state = randomBytes(16).toString('hex')
// 5. Start callback server — await the actually-bound port (port
// fallback means it may not be the first in CALLBACK_PORTS)
const { promise: callbackPromise, ready } = startCallbackServer(state)
// If all ports are in use, BOTH `ready` and `callbackPromise` reject.
// Mark callbackPromise as handled so the second rejection can't crash
// the process as an unhandled rejection while we're throwing from
// `await ready`. The later `await callbackPromise` still throws.
callbackPromise.catch(() => {})
const port = await ready
const redirectUri = `http://127.0.0.1:${port}/callback`
// 6. Build auth URL and open browser
const authUrl = buildAuthUrl({
authorization_endpoint: oidc.authorization_endpoint,
client_id: discovery.client_id,
redirect_uri: redirectUri,
scopes,
state,
pkce,
})
process.stderr.write(`\nOpening browser for login...\n`)
process.stderr.write(`If the browser doesn't open, visit:\n ${authUrl}\n\n`)
// Open browser (best-effort, platform-specific).
// execFileSync with args array — authUrl comes from the remote discovery
// doc so it must never be shell-interpolated. Scheme is also validated.
try {
if (!/^https:\/\//.test(authUrl)) {
throw new Error('auth URL must be https')
}
const { execFileSync } = await import('child_process')
if (process.platform === 'darwin') {
execFileSync('open', [authUrl], { stdio: 'ignore' })
} else if (process.platform === 'win32') {
// Do NOT use `cmd /c start` here: cmd.exe treats `&` as a command
// separator, so the auth URL's query string gets truncated at the
// first parameter and the IdP sees a bare /authorize request.
// rundll32's FileProtocolHandler receives the URL as a plain
// process argument with no shell parsing, so `&` survives intact.
execFileSync('rundll32', ['url.dll,FileProtocolHandler', authUrl], { stdio: 'ignore' })
} else {
execFileSync('xdg-open', [authUrl], { stdio: 'ignore' })
}
} catch {
// Browser open failed — user sees the URL above
}
const { execFileSync } = await import('child_process')
if (process.platform === 'darwin') {
execFileSync('open', [authUrl], { stdio: 'ignore' })
} else if (process.platform === 'win32') {
// `start` is a cmd builtin; empty first arg is the window title
execFileSync('cmd', ['/c', 'start', '', authUrl], { stdio: 'ignore' })
// 7. Wait for callback
process.stderr.write(`Waiting for login (5 min timeout)...\n`)
const callback = await callbackPromise
// 8. Exchange code for tokens
const tokenRedirectUri = `http://127.0.0.1:${callback.port}/callback`
const tokens = await exchangeCode(
oidc.token_endpoint,
callback.code,
pkce.code_verifier,
tokenRedirectUri,
discovery.client_id,
)
if (!tokens.refresh_token) {
process.stderr.write(`Warning: IdP did not return a refresh token. You may need to re-authenticate frequently.\n`)
}
// 9. Store credentials
const store = createCredentialStore()
if (tokens.refresh_token) {
store.store(tokens.refresh_token)
}
// 10. Write config
writeSyncConfig({
baseUrl,
clientId: discovery.client_id,
tracesPath: discovery.traces_path,
issuer: discovery.issuer,
})
process.stderr.write(`\n✓ Sync configured successfully.\n`)
process.stderr.write(` Endpoint: ${baseUrl}\n`)
process.stderr.write(` Token stored in: ${store.method()}\n`)
process.stderr.write(`\nRun \`codeburn sync push\` to send telemetry data.\n`)
} catch (err) {
// Known errors (login timeout, port exhaustion, discovery failures)
// get a clean one-line message instead of a raw Node crash dump.
if (err instanceof AuthError || err instanceof DiscoveryError) {
process.stderr.write(`\nError: ${err.message}\n`)
} else {
execFileSync('xdg-open', [authUrl], { stdio: 'ignore' })
process.stderr.write(`\nError: ${(err as Error).stack ?? (err as Error).message}\n`)
}
} catch {
// Browser open failed — user sees the URL above
process.exit(1)
}
// 7. Wait for callback
process.stderr.write(`Waiting for login (5 min timeout)...\n`)
const callback = await callbackPromise
// 8. Exchange code for tokens
const tokenRedirectUri = `http://127.0.0.1:${callback.port}/callback`
const tokens = await exchangeCode(
oidc.token_endpoint,
callback.code,
pkce.code_verifier,
tokenRedirectUri,
discovery.client_id,
)
if (!tokens.refresh_token) {
process.stderr.write(`Warning: IdP did not return a refresh token. You may need to re-authenticate frequently.\n`)
}
// 9. Store credentials
const store = createCredentialStore()
if (tokens.refresh_token) {
store.store(tokens.refresh_token)
}
// 10. Write config
writeSyncConfig({
baseUrl,
clientId: discovery.client_id,
tracesPath: discovery.traces_path,
issuer: discovery.issuer,
})
process.stderr.write(`\n✓ Sync configured successfully.\n`)
process.stderr.write(` Endpoint: ${baseUrl}\n`)
process.stderr.write(` Token stored in: ${store.method()}\n`)
process.stderr.write(`\nRun \`codeburn sync push\` to send telemetry data.\n`)
})
// --- status ---
@ -205,7 +227,8 @@ export function registerSyncCommands(program: Command): void {
.description('Push unsent telemetry data to the configured endpoint')
.option('--since <period>', '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 <url>` first.\n')
@ -256,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<ReturnType<typeof collectUnsentAttribution>>['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)
@ -264,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
@ -281,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')
@ -302,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`)
}
@ -316,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) {

View file

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

View file

@ -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<PushResult> {
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<T> = Omit<SendBatchesOptions, 'batches'> & {
batches: T[][]
buildPayload: (batch: T[]) => OtlpPayload
toOutbound: (item: T) => OutboundItem
}
async function sendBatchesCore<T>(opts: SendBatchesCoreOptions<T>): Promise<PushResult> {
assertHttps(opts.endpoint, 'Traces endpoint')
const log = opts.log ?? (() => {})
const sleep = opts.sleep ?? ((ms: number) => new Promise<void>(r => setTimeout(r, ms)))
@ -107,7 +133,7 @@ export async function sendBatches(opts: SendBatchesOptions): Promise<PushResult>
// 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<PushResult>
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<PushResult>
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<string>()
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<SendBatchesOptions, 'batches'> {
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<PushResult> {
return sendBatchesCore({
...opts,
buildPayload: buildAttributionOtlpPayload,
toOutbound: item => ({ key: item.dedupKey, ts: item.timestamp, costUSD: 0 }),
})
}
export { batchCalls }

View file

@ -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<string, { calls: number; costUSD: number; tokens: TokenUsage; savingsUSD: number; estimatedCostUSD?: number }>
modelBreakdown: Record<string, { calls: number; costUSD: number; tokens: TokenUsage; savingsUSD: number; estimatedCostUSD?: number; activeDurationMs?: number; activeGeneratedTokens?: number; toolWaitMs?: number }>
toolBreakdown: Record<string, { calls: number }>
mcpBreakdown: Record<string, { calls: number }>
bashBreakdown: Record<string, { calls: number }>

View file

@ -4,7 +4,7 @@ import { type PeriodData, type ProviderCost, type BreakdownArrays, type MenubarP
import { parseAllSessions, filterProjectsByName, filterProjectsByDays, filterProjectsByClaudeConfigSource, isSessionHydrationComplete } from './parser.js'
import { findUnpricedModels, getLocalModelSavingsConfigHash, getPriceOverridesConfigHash, getShortModelName, isExpectedFreeModel } from './models.js'
import { getAllProviders, safeDiscoverSessions } from './providers/index.js'
import { claude, getClaudeConfigDirs, getDesktopSessionsDir } from './providers/claude.js'
import { claude, getClaudeConfigDirs, getDesktopSessionsDirs } from './providers/claude.js'
import { stat } from 'node:fs/promises'
import { aggregateProjectsIntoDays, buildPeriodDataFromDays } from './day-aggregator.js'
import { aggregateModelEfficiency } from './model-efficiency.js'
@ -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.
@ -149,7 +149,11 @@ async function claudeConfigSelector(projects: ProjectSummary[], selectedId?: str
// idle config or Claude Desktop is still selectable). Only worth it when a
// second source is possible: more than one config dir, or a Claude Desktop
// sessions dir exists. A plain single-config user skips it entirely.
const desktopExists = await stat(getDesktopSessionsDir()).then(s => s.isDirectory()).catch(() => false)
const desktopExists = (
await Promise.all(
getDesktopSessionsDirs().map(dir => stat(dir).then(s => s.isDirectory()).catch(() => false)),
)
).some(Boolean)
if ((await getClaudeConfigDirs()).length > 1 || desktopExists) {
for (const source of await claude.discoverSessions()) {
if ((source.sourceKind !== 'claude-config' && source.sourceKind !== 'claude-desktop') || !source.sourceId || !source.sourceLabel || !source.sourcePath) continue
@ -222,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<string, ProjectDayStats>,
include: string[],
exclude: string[],
): { cost: number; calls: number; savingsUSD: number; sessions: number; projects: Record<string, ProjectDayStats>; matched: number } {
const out = { cost: 0, calls: 0, savingsUSD: 0, sessions: 0, projects: {} as Record<string, ProjectDayStats>, 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<string, ProviderDaySlice> = {}
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<string> | 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
@ -262,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[]
@ -307,9 +422,14 @@ export async function buildDurablePeriod(periodInfo: PeriodInfo, opts: Aggregate
liveProjects = daysSelection ? filterProjectsByDays(raw, daysSelection.days) : raw
scanRange = periodInfo.range
// A period that reaches today contains today's turns already, so derive the
// today slice from the same parse instead of scanning today again.
// today slice from the same parse instead of scanning today again. Slice it
// to today first (filterProjectsByDays re-anchors a midnight-straddling
// turn to its surviving today calls), so today's category / turn count
// lands on today rather than staying anchored on the turn's yesterday
// start. Otherwise the post-midnight half vanishes from By Activity and the
// JSON daily turn count while the per-call cost/calls still bucket to today.
todayAllDays = rangeEndStr >= todayStr
? aggregateProjectsIntoDays(raw).filter(d => d.date === todayStr)
? aggregateProjectsIntoDays(filterProjectsByDays(raw, new Set([todayStr]))).filter(d => d.date === todayStr)
: aggregateProjectsIntoDays(fp(await parseAllSessions(todayRange, 'all'))).filter(d => d.date === todayStr)
}
} else {
@ -321,7 +441,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)
@ -338,6 +484,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!]),
)
@ -348,7 +509,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 }
}
/**

View file

@ -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<YieldSummary> {
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<string, RepoGroup> {
const repoIdentityCache = new Map<string, RepoIdentity | null>()
// 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<string, RepoGroup>()
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<YieldSummary> {
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<string>()
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<SessionSummary, CommitInfo[]>()
const lostCandidacyBySession = new Map<SessionSummary, boolean>()
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
}

View file

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

View file

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

View file

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

View file

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

View file

@ -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<string, string | undefined>
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,186 @@ 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<void> {
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<void> {
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('reconciles By Activity (categories) and daily turns with the headline on the multi-day all-provider view', async () => {
fakeNow()
try {
await seedStraddlingClaudeTurn()
const [costN, costN1] = await truthCosts('claude')
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' })
// By Activity must sum to the whole-range headline. Before this fix the
// today slice came from the unsliced whole-range parse, so the straddling
// turn's category stayed anchored on its yesterday start and the
// post-midnight half vanished from By Activity while cost / calls / By
// Model were already correct; categories summed to only the pre-midnight
// cost.
const categoryTotal = durable.data.categories.reduce((s, c) => s + c.cost, 0)
expect(categoryTotal).toBeCloseTo(costN + costN1, 8)
expect(categoryTotal).toBeCloseTo(durable.data.cost, 8)
// Today's day entry carries its turn's category, not calls>0 with an empty
// category map (which rendered as `turns: 0` in the JSON daily rows).
const dayN1 = durable.days.find(d => d.date === DAY_N1)
expect(dayN1?.calls).toBe(1)
expect(Object.keys(dayN1?.categories ?? {}).length).toBeGreaterThan(0)
} 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)
})

View file

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

View file

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

View file

@ -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> = {}): ProviderDaySlice {
return { cost, calls, savingsUSD: 0, ...extra }
}
function day(date: string, providers: Record<string, ProviderDaySlice>, overrides: Partial<DailyEntry> = {}): 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<ProjectSummary[]> => []
/// 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<DailyCache> = {}): Promise<void> {
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)
})
})

View file

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

View file

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

View file

@ -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<string, number> {
const byDate: Record<string, number> = {}
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)
})
})

View file

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

View file

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

View file

@ -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<MockIdp> {
@ -52,6 +54,7 @@ export async function startMockIdp(opts: MockIdpOptions = {}): Promise<MockIdp>
issuedTokens: { access: [], refresh: [] },
revokedTokens: [],
exchangedCodes: [],
tracesRequests: [],
close: async () => {},
}
@ -59,6 +62,20 @@ export async function startMockIdp(opts: MockIdpOptions = {}): Promise<MockIdp>
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' })

View file

@ -159,6 +159,31 @@ describe('buildMenubarPayload', () => {
expect(payload.current.topModels[0].name).toBe('Model0')
})
it('resolves raw model ids to display names in topModels and merges rows that collapse', () => {
// Day entries key models by the raw wire id (day-aggregator); the menubar
// must show friendly names and merge ids that share one (k3 + kimi-k3).
const period: PeriodData = {
label: 'Today',
cost: 0, calls: 0, sessions: 0,
inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0,
categories: [],
models: [
{ name: 'k3', cost: 2.5, calls: 78 },
{ name: 'kimi-k3', cost: 0.5, calls: 2 },
{ name: 'kimi-for-coding', cost: 0.06, calls: 13 },
{ name: 'k3-agent', cost: 1.2, calls: 40 },
],
}
const payload = buildMenubarPayload(period, [], null)
const kimiK3 = payload.current.topModels.find(m => m.name === 'Kimi K3')!
expect(kimiK3.cost).toBeCloseTo(2.5 + 0.5 + 1.2)
expect(kimiK3.calls).toBe(78 + 2 + 40)
const k2 = payload.current.topModels.find(m => m.name === 'Kimi K2 Thinking')!
expect(k2.cost).toBeCloseTo(0.06)
expect(payload.current.topModels.find(m => m.name === 'k3')).toBeUndefined()
expect(payload.current.topModels.find(m => m.name === 'k3-agent')).toBeUndefined()
})
it('caps topActivities at 20 so all task categories can surface', () => {
const period: PeriodData = {
label: 'Today',

View file

@ -53,7 +53,7 @@ describe('buildMenubarPayload: local-model savings', () => {
const local = payload.current.topModels.find(m => m.name === 'Local Model')!
expect(local.savingsUSD).toBe(5)
expect(local.cost).toBe(0)
const paid = payload.current.topModels.find(m => m.name === 'gpt-4o')!
const paid = payload.current.topModels.find(m => m.name === 'GPT-4o')!
expect(paid.savingsUSD).toBe(0)
})

View file

@ -672,6 +672,9 @@ describe('observed provider model aliases', () => {
const cases: Array<[string, string]> = [
['MiMo-V2-Flash', 'xiaomi/mimo-v2-flash'],
['KAT-Coder-Pro-V1', 'kwaipilot/kat-coder-pro'],
// Kimi Code wires report bare `k3` in llm.request.model; it must price
// through the kimi-k3 table entry, not fall through to $0.
['k3', 'kimi-k3'],
]
for (const [input, expectedModel] of cases) {
@ -685,6 +688,10 @@ describe('observed provider model aliases', () => {
})
}
it('k3 shows the Kimi K3 display name', () => {
expect(getShortModelName('k3')).toBe('Kimi K3')
})
it('does not map dated Qwen3 Max to a reseller price without provider context', () => {
expect(getModelCosts('qwen3-max-2026-01-23')).toBeNull()
expect(calculateCost('qwen3-max-2026-01-23', 1_000_000, 1_000_000, 0, 0, 0)).toBe(0)

View file

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

View file

@ -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 <other> 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<ReturnType<typeof parseAllSessions>>): Set<string> =>
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<string> {
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)
})
})

View file

@ -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<string, string | undefined>
// 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<void> {
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<void> {
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)
})
})

View file

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

View file

@ -1,10 +1,10 @@
import { mkdtemp, mkdir, rm, writeFile } from 'fs/promises'
import { delimiter as pathDelimiter, join } from 'path'
import { delimiter as pathDelimiter, join, resolve, sep } from 'path'
import { homedir, tmpdir } from 'os'
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
import { claude, getDesktopSessionsDir } from '../../src/providers/claude.js'
import { claude, getDesktopSessionsDirs } from '../../src/providers/claude.js'
import { clearSessionCache, filterProjectsByClaudeConfigSource, parseAllSessions } from '../../src/parser.js'
let tmpRoot: string
@ -13,6 +13,7 @@ const savedEnv = {
CLAUDE_CONFIG_DIRS: process.env['CLAUDE_CONFIG_DIRS'],
CODEBURN_DESKTOP_SESSIONS_DIR: process.env['CODEBURN_DESKTOP_SESSIONS_DIR'],
APPDATA: process.env['APPDATA'],
LOCALAPPDATA: process.env['LOCALAPPDATA'],
HOME: process.env['HOME'],
}
@ -39,6 +40,7 @@ beforeEach(async () => {
delete process.env['CLAUDE_CONFIG_DIRS']
delete process.env['CODEBURN_DESKTOP_SESSIONS_DIR']
delete process.env['APPDATA']
delete process.env['LOCALAPPDATA']
})
afterEach(async () => {
@ -218,30 +220,154 @@ describe('claude provider — CLAUDE_CONFIG_DIRS discovery', () => {
})
describe('claude provider — Desktop sessions dir', () => {
it('uses APPDATA as the Windows Claude Desktop sessions root', () => {
async function makeMsixSessionsDir(localAppData: string, packageName: string): Promise<string> {
const sessionsDir = join(
localAppData,
'Packages',
packageName,
'LocalCache',
'Roaming',
'Claude',
'local-agent-mode-sessions',
)
await mkdir(sessionsDir, { recursive: true })
return resolve(sessionsDir)
}
it('returns the classic Windows root first and discovers an MSIX sessions root', async () => {
const appData = join(tmpRoot, 'roaming-profile')
const localAppData = join(tmpRoot, 'local-profile')
const classic = join(appData, 'Claude', 'local-agent-mode-sessions')
const msix = await makeMsixSessionsDir(localAppData, 'Claude_fresh9k2m')
await mkdir(classic, { recursive: true })
process.env['APPDATA'] = appData
process.env['LOCALAPPDATA'] = localAppData
withPlatform('win32', () => {
expect(getDesktopSessionsDirs()).toEqual([resolve(classic), msix])
})
})
it('filters MSIX package names and keeps both supported Claude name shapes', async () => {
const appData = join(tmpRoot, 'roaming-profile')
const localAppData = join(tmpRoot, 'local-profile')
const classic = resolve(join(appData, 'Claude', 'local-agent-mode-sessions'))
const direct = await makeMsixSessionsDir(localAppData, 'Claude_new7v4p')
const publisher = await makeMsixSessionsDir(localAppData, 'SomePublisher.Claude_q8w3e')
await makeMsixSessionsDir(localAppData, 'ClaudeXtra_foo')
await makeMsixSessionsDir(localAppData, 'NotClaude_x')
process.env['APPDATA'] = appData
process.env['LOCALAPPDATA'] = localAppData
withPlatform('win32', () => {
expect(getDesktopSessionsDirs()).toEqual([classic, direct, publisher])
})
})
it('returns only the classic root when LOCALAPPDATA is unset and Packages is missing', () => {
const appData = join(tmpRoot, 'roaming-profile')
process.env['APPDATA'] = appData
delete process.env['LOCALAPPDATA']
withPlatform('win32', () => {
expect(getDesktopSessionsDir()).toBe(join(appData, 'Claude', 'local-agent-mode-sessions'))
expect(() => getDesktopSessionsDirs()).not.toThrow()
expect(getDesktopSessionsDirs()).toEqual([
resolve(join(appData, 'Claude', 'local-agent-mode-sessions')),
])
})
})
it('falls back to the legacy Windows roaming profile path when APPDATA is unset', () => {
it('falls back to the legacy Windows roaming path when APPDATA is unset or empty', () => {
const expected = resolve(join(homedir(), 'AppData', 'Roaming', 'Claude', 'local-agent-mode-sessions'))
delete process.env['APPDATA']
delete process.env['LOCALAPPDATA']
withPlatform('win32', () => {
expect(getDesktopSessionsDir()).toBe(join(homedir(), 'AppData', 'Roaming', 'Claude', 'local-agent-mode-sessions'))
expect(getDesktopSessionsDirs()).toEqual([expected])
process.env['APPDATA'] = ''
expect(getDesktopSessionsDirs()).toEqual([expected])
})
})
it('keeps CODEBURN_DESKTOP_SESSIONS_DIR ahead of Windows APPDATA discovery', () => {
it('excludes an MSIX package whose sessions subpath is missing', async () => {
const appData = join(tmpRoot, 'roaming-profile')
const localAppData = join(tmpRoot, 'local-profile')
await mkdir(join(localAppData, 'Packages', 'Claude_empty6r1t', 'LocalCache'), { recursive: true })
process.env['APPDATA'] = appData
process.env['LOCALAPPDATA'] = localAppData
withPlatform('win32', () => {
expect(getDesktopSessionsDirs()).toEqual([
resolve(join(appData, 'Claude', 'local-agent-mode-sessions')),
])
})
})
it('returns exactly the resolved override without platform discovery', async () => {
const override = join(tmpRoot, 'desktop-override')
const localAppData = join(tmpRoot, 'local-profile')
await makeMsixSessionsDir(localAppData, 'Claude_shouldnotappear5n8d')
process.env['CODEBURN_DESKTOP_SESSIONS_DIR'] = override
process.env['APPDATA'] = join(tmpRoot, 'roaming-profile')
process.env['LOCALAPPDATA'] = localAppData
withPlatform('win32', () => {
expect(getDesktopSessionsDir()).toBe(override)
expect(getDesktopSessionsDirs()).toEqual([resolve(override)])
})
})
it('resolves an override containing a parent segment and trailing separator', () => {
const override = join(tmpRoot, 'desktop-parent', 'nested') + `${sep}..${sep}sessions${sep}`
process.env['CODEBURN_DESKTOP_SESSIONS_DIR'] = override
withPlatform('win32', () => {
expect(getDesktopSessionsDirs()).toEqual([resolve(override)])
})
})
it('returns one platform root on darwin and linux without including MSIX packages', async () => {
const localAppData = join(tmpRoot, 'local-profile')
await makeMsixSessionsDir(localAppData, 'Claude_crossplatform3b7j')
process.env['LOCALAPPDATA'] = localAppData
withPlatform('darwin', () => {
expect(getDesktopSessionsDirs()).toEqual([
resolve(join(homedir(), 'Library', 'Application Support', 'Claude', 'local-agent-mode-sessions')),
])
})
withPlatform('linux', () => {
expect(getDesktopSessionsDirs()).toEqual([
resolve(join(homedir(), '.config', 'Claude', 'local-agent-mode-sessions')),
])
})
})
it('sorts two valid MSIX roots by package entry name', async () => {
const appData = join(tmpRoot, 'roaming-profile')
const localAppData = join(tmpRoot, 'local-profile')
const later = await makeMsixSessionsDir(localAppData, 'Claude_zulu4c9x')
const earlier = await makeMsixSessionsDir(localAppData, 'Alpha.Claude_alpha2m6v')
process.env['APPDATA'] = appData
process.env['LOCALAPPDATA'] = localAppData
withPlatform('win32', () => {
expect(getDesktopSessionsDirs()).toEqual([
resolve(join(appData, 'Claude', 'local-agent-mode-sessions')),
earlier,
later,
])
})
})
it('dedupes classic and MSIX candidates that resolve to the same path', async () => {
const localAppData = join(tmpRoot, 'local-profile')
const packageName = 'Claude_duplicate8h3s'
const sessionsDir = await makeMsixSessionsDir(localAppData, packageName)
process.env['APPDATA'] = join(localAppData, 'Packages', packageName, 'LocalCache', 'Roaming')
process.env['LOCALAPPDATA'] = localAppData
withPlatform('win32', () => {
expect(getDesktopSessionsDirs()).toEqual([sessionsDir])
})
})
})

View file

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

View file

@ -167,6 +167,40 @@ describe('Kimi Code provider', () => {
expect(sources.every(source => source.sourcePath === '/workspace/neutral-project')).toBe(true)
})
it('discovers embedded-runtime conv-* and ctitle-* session directories', async () => {
// Embedded runtimes (Kimi desktop app, Kimi Code IDE) name session dirs
// conv-*/ctitle-* instead of session_*; their wires carry the same event
// format and must be discovered and priced the same way.
for (const dirName of ['conv-abc123def456', 'ctitle-019f8f78-db81']) {
const agentDir = join(fixtureHome, 'sessions', 'wd_neutral-project_0123456789ab', dirName, 'agents', 'main')
await mkdir(agentDir, { recursive: true })
await writeFile(join(agentDir, '..', 'state.json'), JSON.stringify({
createdAt: '2026-07-01T10:00:00.000Z',
updatedAt: '2026-07-01T10:05:00.000Z',
workDir: '/workspace/neutral-project',
}))
await writeFile(join(agentDir, 'wire.jsonl'), [
JSON.stringify({ type: 'metadata', protocol_version: '1.4', created_at: 1782900000000 }),
JSON.stringify(prompt('hello from an embedded runtime', 1782900000000)),
JSON.stringify(request('0.1', 'k3-agent', 'k3-agent', 1782900001000)),
JSON.stringify(usage('k3-agent', 1782900002000, { input: 100, output: 50 })),
'',
].join('\n'))
}
const provider = createKimicodeProvider(fixtureHome)
const sources = await provider.discoverSessions()
expect(sources).toHaveLength(2)
expect(sources.every(source => source.project === 'neutral-project')).toBe(true)
const seen = new Set<string>()
const calls = (await Promise.all(sources.map(source => collect(provider, source, seen)))).flat()
expect(calls).toHaveLength(2)
expect(calls.every(call => call.model === 'k3-agent')).toBe(true)
// k3-agent prices through the kimi-k3 alias, never $0.
expect(calls.every(call => call.costUSD > 0)).toBe(true)
})
it('parses single-turn usage with the real model id and estimated token pricing', async () => {
const [wirePath] = await writeSession('single-turn', [{ id: 'main', lines: [
prompt('Summarize the neutral module.', 1782900000000),

View file

@ -110,13 +110,30 @@ describe('omp provider - session discovery', () => {
expect(sessions[0]!.project).toBe('myproject')
})
it('discovers title-first sessions', async () => {
const projectDir = join(tmpDir, '--Users-test-myproject--')
await writeSession(projectDir, 'title-first.jsonl', [
JSON.stringify({ type: 'title', title: 'OMP title-first session' }),
'',
sessionMeta({ cwd: '/Users/test/myproject' }),
assistantMessage({}),
])
const provider = createOmpProvider(tmpDir)
const sessions = await provider.discoverSessions()
expect(sessions).toHaveLength(1)
expect(sessions[0]!.provider).toBe('omp')
expect(sessions[0]!.project).toBe('myproject')
})
it('returns empty for non-existent directory', async () => {
const provider = createOmpProvider('/nonexistent/omp/path')
const sessions = await provider.discoverSessions()
expect(sessions).toEqual([])
})
it('skips files whose first line is not a session entry', async () => {
it('skips files without a session entry', async () => {
const projectDir = join(tmpDir, '--Users-test-myproject--')
await writeSession(projectDir, 'bad.jsonl', [
JSON.stringify({ type: 'message', id: 'x' }),

View file

@ -3,7 +3,7 @@ import { mkdtemp, mkdir, writeFile, rm } from 'fs/promises'
import { join } from 'path'
import { tmpdir } from 'os'
import { createPiProvider } from '../../src/providers/pi.js'
import { createPiProvider, createOmpProvider } from '../../src/providers/pi.js'
import type { ParsedProviderCall } from '../../src/providers/types.js'
import { classifyTurn } from '../../src/classifier.js'
import type { ParsedApiCall, ParsedTurn } from '../../src/types.js'
@ -59,6 +59,18 @@ function sessionMeta(opts: { id?: string; cwd?: string } = {}) {
})
}
// Oh My Pi (issue #845): a fixed-width title metadata line written before the
// `type: "session"` header, per upstream can1357/oh-my-pi@0ce330a.
function titleSlot(opts: { title?: string; pad?: string } = {}) {
return JSON.stringify({
type: 'title',
v: 1,
title: opts.title ?? 'My Session',
updatedAt: '2026-04-14T10:00:00.000Z',
pad: opts.pad ?? '',
})
}
function userMessage(text: string, timestamp?: string) {
return JSON.stringify({
type: 'message',
@ -165,7 +177,7 @@ describe('pi provider - session discovery', () => {
expect(sessions).toEqual([])
})
it('skips files whose first line is not a session entry', async () => {
it('skips files without a session entry', async () => {
const projectDir = join(tmpDir, '--Users-test-myproject--')
await writeSession(projectDir, 'bad.jsonl', [
JSON.stringify({ type: 'message', id: 'x' }),
@ -185,6 +197,119 @@ describe('pi provider - session discovery', () => {
const sessions = await provider.discoverSessions()
expect(sessions).toEqual([])
})
// Issue #845: OMP writes a `type: "title"` metadata line before the
// `type: "session"` header. Discovery must scan past it instead of only
// checking the first physical line.
it('discovers an OMP transcript with a title slot before the session record', async () => {
const projectDir = join(tmpDir, '--Users-test-myproject--')
await writeSession(projectDir, 'omp-session.jsonl', [
titleSlot({ title: 'Fix the bug' }),
sessionMeta({ cwd: '/Users/test/myproject' }),
assistantMessage({}),
])
const provider = createOmpProvider(tmpDir)
const sessions = await provider.discoverSessions()
expect(sessions).toHaveLength(1)
expect(sessions[0]!.provider).toBe('omp')
expect(sessions[0]!.project).toBe('myproject')
})
it('Pi and OMP discover an identical title-slot transcript the same way', async () => {
const projectDir = join(tmpDir, '--Users-test-myproject--')
const lines = [titleSlot(), sessionMeta({ cwd: '/Users/test/myproject' }), assistantMessage({})]
await writeSession(projectDir, 'shared.jsonl', lines)
const piSessions = await createPiProvider(tmpDir).discoverSessions()
const ompSessions = await createOmpProvider(tmpDir).discoverSessions()
expect(piSessions).toHaveLength(1)
expect(ompSessions).toHaveLength(1)
expect(piSessions[0]!.project).toBe(ompSessions[0]!.project)
})
it('tolerates blank lines before the session record', async () => {
const projectDir = join(tmpDir, '--Users-test-myproject--')
await writeSession(projectDir, 'blank-padded.jsonl', [
titleSlot(),
'',
'',
sessionMeta({ cwd: '/Users/test/myproject' }),
assistantMessage({}),
])
const provider = createOmpProvider(tmpDir)
const sessions = await provider.discoverSessions()
expect(sessions).toHaveLength(1)
})
it('skips a malformed leading JSON line without throwing', async () => {
const projectDir = join(tmpDir, '--Users-test-myproject--')
await writeSession(projectDir, 'malformed-head.jsonl', [
'{not valid json',
sessionMeta({ cwd: '/Users/test/myproject' }),
assistantMessage({}),
])
const provider = createOmpProvider(tmpDir)
await expect(provider.discoverSessions()).resolves.toHaveLength(1)
})
it('excludes a message-only file with no session record', async () => {
const projectDir = join(tmpDir, '--Users-test-myproject--')
await writeSession(projectDir, 'messages-only.jsonl', [
titleSlot(),
userMessage('hello'),
assistantMessage({}),
])
const provider = createOmpProvider(tmpDir)
const sessions = await provider.discoverSessions()
expect(sessions).toEqual([])
})
it('does not discover a session record beyond the bounded leading-line scan', async () => {
const projectDir = join(tmpDir, '--Users-test-myproject--')
// 25 non-session leading lines exceeds MAX_HEADER_LINES_SCANNED (20), so
// the session record on line 26 must NOT be found. This proves the scan
// is bounded rather than silently degrading into a full-file read.
const junkLines = Array.from({ length: 25 }, (_, i) => JSON.stringify({ type: 'message', id: `junk-${i}` }))
await writeSession(projectDir, 'too-deep.jsonl', [
...junkLines,
sessionMeta({ cwd: '/Users/test/myproject' }),
assistantMessage({}),
])
const provider = createOmpProvider(tmpDir)
const sessions = await provider.discoverSessions()
expect(sessions).toEqual([])
})
it('does not read an unreasonable amount of a large message-only file', async () => {
const projectDir = join(tmpDir, '--Users-test-myproject--')
// ~20MB of message lines with no session record anywhere. A bounded
// scan rejects this after its leading-line cap, not after reading the
// whole file; this keeps discovery cheap for provider directories full
// of large, irrelevant transcripts.
const bigPad = 'x'.repeat(50_000)
const junkLines = Array.from({ length: 400 }, (_, i) =>
JSON.stringify({ type: 'message', id: `junk-${i}`, pad: bigPad }))
await writeSession(projectDir, 'huge.jsonl', junkLines)
const provider = createOmpProvider(tmpDir)
const start = performance.now()
const sessions = await provider.discoverSessions()
const elapsedMs = performance.now() - start
expect(sessions).toEqual([])
// Generous ceiling: a genuinely bounded scan (20 lines) finishes in low
// single-digit milliseconds; reading the full ~20MB file would still be
// fast on local disk, so this mainly guards against a regression to an
// unbounded per-line-parse scan across the entire file.
expect(elapsedMs).toBeLessThan(500)
})
})
describe('pi provider - JSONL parsing', () => {

View file

@ -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, string> = {}): 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<void> {
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')
})
})

View file

@ -0,0 +1,753 @@
/**
* Tests for sync git attribution (sync push --attribution).
*
* Covers: remote URL normalization, sessioncommit 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, string> = {}): 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>): 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> = {}): 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<string, unknown> {
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<string, string> }
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()
}
})
})

Some files were not shown because too many files have changed in this diff Show more