From fb477f9916fac867add3ab59d4e2a9ee49224b54 Mon Sep 17 00:00:00 2001
From: Aditya Vikram Singh <247195684+avs-io@users.noreply.github.com>
Date: Thu, 20 Aug 2026 00:20:27 +0530
Subject: [PATCH 01/24] fix(optimize): scope remediation copy to --provider
Cross-provider optimize findings and destination headers were
Claude-hardcoded after #1002 already scoped the detectors.
---
src/dashboard.tsx | 33 ++++--------
src/optimize.ts | 118 +++++++++++++++++++++++++++++++----------
tests/optimize.test.ts | 103 +++++++++++++++++++++++++++++++++++
3 files changed, 202 insertions(+), 52 deletions(-)
diff --git a/src/dashboard.tsx b/src/dashboard.tsx
index 7eeeaf35..2ea3c0f7 100644
--- a/src/dashboard.tsx
+++ b/src/dashboard.tsx
@@ -10,7 +10,7 @@ import { findUnpricedModels, isExpectedFreeModel, loadPricing } from './models.j
import { aggregateModelTotals } from './model-breakdown.js'
import { buildDurablePeriod } from './usage-aggregator.js'
import { getAllProviders } from './providers/index.js'
-import { classHeaderLine, classTotals, findingBasis, findingClass, scanAndDetect, type FindingClass, type WasteFinding, type WasteAction, type OptimizeResult } from './optimize.js'
+import { classHeaderLine, classTotals, findingBasis, findingClass, optimizePasteHeader, optimizeRemediationCopy, scanAndDetect, type FindingClass, type WasteFinding, type WasteAction, type OptimizeResult } from './optimize.js'
import { appliedFixGlyph, formatAppliedFix, type AppliedFix } from './act/types.js'
import { aggregateFileChurn, buildCoachingNotes, computePricingCoverage, medianTimeToFirstEditMs, scanUserCorrections, worstOneShotCategory, type ReworkedFile } from './workflow-insights.js'
import { estimateContextBudget, type ContextBudget } from './context-budget.js'
@@ -1033,34 +1033,21 @@ function PeriodTabs({ active, providerName, showProvider }: { active: Period; pr
/// permanent CLAUDE.md rule from a one-time session opener so they don't
/// accidentally bake a single-run constraint into their project's permanent
/// instructions. Issue #277.
-function actionDestinationHeader(action: WasteAction): string {
+function actionDestinationHeader(action: WasteAction, provider?: string): string {
switch (action.type) {
case 'file-content':
return `── Suggested ${action.path} addition `.padEnd(64, '─')
case 'command':
return '── Run this command '.padEnd(64, '─')
case 'paste': {
- switch (action.destination) {
- case 'claude-md':
- return '── Suggested CLAUDE.md addition (permanent rule) '.padEnd(64, '─')
- case 'session-opener':
- return '── One-time session opener (do not add to CLAUDE.md) '.padEnd(64, '─')
- case 'prompt':
- return '── Ask Claude in the current session '.padEnd(64, '─')
- case 'shell-config':
- return '── Add to your shell config '.padEnd(64, '─')
- case 'manual':
- return '── Manual action '.padEnd(64, '─')
- default:
- return '── Suggested action '.padEnd(64, '─')
- }
+ return `── ${optimizePasteHeader(action.destination, optimizeRemediationCopy(provider))} `.padEnd(64, '─')
}
}
}
-function FindingAction({ action }: { action: WasteAction }) {
+function FindingAction({ action, provider }: { action: WasteAction; provider?: string }) {
const lines = action.type === 'file-content' ? action.content.split('\n') : action.type === 'command' ? action.text.split('\n') : [action.text]
- const header = actionDestinationHeader(action)
+ const header = actionDestinationHeader(action, provider)
return (
<>
{header}
@@ -1070,7 +1057,7 @@ function FindingAction({ action }: { action: WasteAction }) {
)
}
-function FindingPanel({ index, finding, costRate, width }: { index: number; finding: WasteFinding; costRate: number; width: number }) {
+function FindingPanel({ index, finding, costRate, width, provider }: { index: number; finding: WasteFinding; costRate: number; width: number; provider?: string }) {
const costSaved = finding.tokensSaved * costRate
const color = IMPACT_PANEL_COLORS[finding.impact] ?? DIM
const label = finding.impact.charAt(0).toUpperCase() + finding.impact.slice(1)
@@ -1086,7 +1073,7 @@ function FindingPanel({ index, finding, costRate, width }: { index: number; find
{finding.explanation}
Savings: ~{formatTokens(finding.tokensSaved)} tokens (~{formatCost(costSaved)}) {findingBasis(finding)}
-
+
)
}
@@ -1106,7 +1093,7 @@ const APPLIED_FIX_COLORS: Record = {
pending: DIM,
}
-function OptimizeView({ findings, costRate, projects, label, width, healthScore, healthGrade, cursor, appliedFixes = [] }: { findings: WasteFinding[]; costRate: number; projects: ProjectSummary[]; label: string; width: number; healthScore: number; healthGrade: string; cursor: number; appliedFixes?: AppliedFix[] }) {
+function OptimizeView({ findings, costRate, projects, label, width, healthScore, healthGrade, cursor, appliedFixes = [], provider }: { findings: WasteFinding[]; costRate: number; projects: ProjectSummary[]; label: string; width: number; healthScore: number; healthGrade: string; cursor: number; appliedFixes?: AppliedFix[]; provider?: string }) {
const periodCost = projects.reduce((s, p) => s + p.totalCostUSD, 0)
const totalTokens = findings.reduce((s, f) => s + f.tokensSaved, 0)
const totalCost = totalTokens * costRate
@@ -1140,7 +1127,7 @@ function OptimizeView({ findings, costRate, projects, label, width, healthScore,
return (
{cls !== previous && {classHeaderLine(cls, totals[cls], costRate)}}
-
+
)
})}
@@ -1666,7 +1653,7 @@ export function InteractiveDashboard({ initialProjects, initialDailyHistoryProje
{view === 'compare'
? setView('dashboard')} />
: view === 'optimize' && optimizeResult
- ?
+ ?
: }
{coachingNote && (
diff --git a/src/optimize.ts b/src/optimize.ts
index 2ecd348b..c6d94a63 100644
--- a/src/optimize.ts
+++ b/src/optimize.ts
@@ -249,6 +249,70 @@ export type PasteDestination =
| 'shell-config' // append to ~/.zshrc / ~/.bashrc
| 'manual' // instructions the user carries out directly
+/// Provider-scoped remediation nouns. Cross-provider detectors and both
+/// render surfaces (CLI + TUI) share this so `--provider codex` cannot still
+/// tell the user to ask Claude or edit CLAUDE.md. Claude / unset / `all`
+/// keep the shipped CLAUDE.md copy. Only `codex` has a file CodeBurn already
+/// names (`AGENTS.md` in the Codex parser); every other provider stays on
+/// the generic "project instructions" rather than inventing a filename.
+export type OptimizeRemediationCopy = {
+ agent: string
+ instructionFile: string
+}
+
+const REMEDIATION_AGENT_NAMES: Record = {
+ claude: 'Claude',
+ codex: 'Codex',
+ cursor: 'Cursor',
+ copilot: 'Copilot',
+ gemini: 'Gemini',
+ opencode: 'OpenCode',
+ antigravity: 'Antigravity',
+ windsurf: 'Windsurf',
+ cline: 'Cline',
+ hermes: 'Hermes',
+ kimi: 'Kimi',
+ kimicode: 'Kimi Code',
+ 'ibm-bob': 'IBM Bob',
+ pi: 'Pi',
+}
+
+const REMEDIATION_INSTRUCTION_FILES: Record = {
+ claude: 'CLAUDE.md',
+ codex: 'AGENTS.md',
+}
+
+function humanizeProviderId(id: string): string {
+ return id.split(/[-_]/).map(part => part ? part[0]!.toUpperCase() + part.slice(1) : part).join(' ')
+}
+
+export function optimizeRemediationCopy(provider?: string): OptimizeRemediationCopy {
+ const key = !provider || provider === 'all' ? 'claude' : provider.toLowerCase()
+ return {
+ agent: REMEDIATION_AGENT_NAMES[key] ?? humanizeProviderId(key),
+ instructionFile: REMEDIATION_INSTRUCTION_FILES[key] ?? 'project instructions',
+ }
+}
+
+export function sessionOpenerLabel(copy: OptimizeRemediationCopy): string {
+ return `Paste at the start of your NEXT expensive thread (one-time, do not add to ${copy.instructionFile}):`
+}
+
+export function askAgentLabel(copy: OptimizeRemediationCopy, rest: string): string {
+ return `Ask ${copy.agent} to ${rest}:`
+}
+
+export function optimizePasteHeader(destination: PasteDestination | undefined, copy: OptimizeRemediationCopy): string {
+ switch (destination) {
+ case 'claude-md': return `Suggested ${copy.instructionFile} addition (permanent rule)`
+ case 'session-opener': return `One-time session opener (do NOT add to ${copy.instructionFile})`
+ case 'prompt': return `Ask ${copy.agent} in the current session`
+ case 'shell-config': return 'Add to your shell config'
+ case 'manual': return 'Manual action'
+ default: return 'Suggested action'
+ }
+}
+
export type WasteAction =
| { type: 'paste'; label: string; text: string; destination?: PasteDestination }
| { type: 'command'; label: string; text: string }
@@ -1695,6 +1759,7 @@ function collectMcpProjectProfiles(
export function detectMcpProfileAdvisor(
projects: ProjectSummary[],
coverage = aggregateMcpCoverage(projects),
+ provider?: string,
): WasteFinding | null {
const candidates = collectMcpProjectProfiles(projects, coverage)
if (candidates.length === 0) return null
@@ -1733,7 +1798,7 @@ export function detectMcpProfileAdvisor(
fix: {
type: 'paste',
destination: 'prompt',
- label: 'Ask Claude to turn this into a project-scoped MCP profile:',
+ label: askAgentLabel(optimizeRemediationCopy(provider), 'turn this into a project-scoped MCP profile'),
text: [
`Review these MCP profile recommendations before changing config (${preview.length} of ${candidates.length} shown):`,
...preview.map(candidate => {
@@ -1926,7 +1991,7 @@ function findCapabilityReliabilityCandidates(projects: ProjectSummary[]): Capabi
return candidates
}
-export function detectCapabilityReliability(projects: ProjectSummary[]): WasteFinding | null {
+export function detectCapabilityReliability(projects: ProjectSummary[], provider?: string): WasteFinding | null {
projects = userStartedProjects(projects)
const candidates = findCapabilityReliabilityCandidates(projects)
if (candidates.length === 0) return null
@@ -1989,7 +2054,7 @@ export function detectCapabilityReliability(projects: ProjectSummary[]): WasteFi
fix: {
type: 'paste',
destination: 'prompt',
- label: 'Ask Claude to audit the retry-heavy capability before changing config:',
+ label: askAgentLabel(optimizeRemediationCopy(provider), 'audit the retry-heavy capability before changing config'),
text: `Investigate these retry-correlated capabilities: ${names}. Compare edit turns with retries against one-shot edit turns, identify whether the MCP server or skill actually caused rework, then propose a scoped MCP config or skill-instruction change with session evidence. Do not remove a capability solely because it appears in this report.`,
},
}
@@ -3121,7 +3186,7 @@ export function findLowWorthCandidates(projects: ProjectSummary[]): LowWorthCand
return candidates
}
-export function detectLowWorthSessions(projects: ProjectSummary[]): WasteFinding | null {
+export function detectLowWorthSessions(projects: ProjectSummary[], provider?: string): WasteFinding | null {
const candidates = findLowWorthCandidates(projects)
if (candidates.length === 0) return null
@@ -3156,7 +3221,7 @@ export function detectLowWorthSessions(projects: ProjectSummary[]): WasteFinding
fix: {
type: 'paste',
destination: 'session-opener',
- label: 'Paste at the start of your NEXT expensive thread (one-time, do not add to CLAUDE.md):',
+ label: sessionOpenerLabel(optimizeRemediationCopy(provider)),
text: LOW_WORTH_OPENER,
},
}
@@ -3231,7 +3296,7 @@ export function findContextBloatCandidates(projects: ProjectSummary[]): ContextB
return candidates
}
-export function detectContextBloat(projects: ProjectSummary[], excludedSessionIds?: ReadonlySet): WasteFinding | null {
+export function detectContextBloat(projects: ProjectSummary[], excludedSessionIds?: ReadonlySet, provider?: string): WasteFinding | null {
const candidates = findContextBloatCandidates(projects)
.filter(c => !excludedSessionIds?.has(c.sessionId))
if (candidates.length === 0) return null
@@ -3272,13 +3337,13 @@ export function detectContextBloat(projects: ProjectSummary[], excludedSessionId
fix: {
type: 'paste',
destination: 'session-opener',
- label: 'Paste at the start of your NEXT expensive thread (one-time, do not add to CLAUDE.md):',
+ label: sessionOpenerLabel(optimizeRemediationCopy(provider)),
text: CONTEXT_HEAVY_OPENER,
},
}
}
-export function detectSessionOutliers(projects: ProjectSummary[], excludedSessionIds?: ReadonlySet): WasteFinding | null {
+export function detectSessionOutliers(projects: ProjectSummary[], excludedSessionIds?: ReadonlySet, provider?: string): WasteFinding | null {
type Outlier = {
project: string
sessionId: string
@@ -3353,7 +3418,7 @@ export function detectSessionOutliers(projects: ProjectSummary[], excludedSessio
fix: {
type: 'paste',
destination: 'session-opener',
- label: 'Paste at the start of your NEXT expensive thread (one-time, do not add to CLAUDE.md):',
+ label: sessionOpenerLabel(optimizeRemediationCopy(provider)),
text: 'Before making changes, summarize the smallest viable plan. Keep context narrow, avoid broad searches, and stop after the first working patch so I can review before continuing.',
},
}
@@ -3551,15 +3616,15 @@ export async function scanAndDetect(
claudeOnly(() => detectDuplicateReads(toolCalls, dateRange)),
claudeOnly(() => detectUnusedMcp(toolCalls, projects, projectCwds, mcpCoverage)),
() => detectMcpToolCoverage(projects, mcpCoverage, localMcpServerNames(projectCwds)),
- () => detectMcpProfileAdvisor(projects, mcpCoverage),
+ () => detectMcpProfileAdvisor(projects, mcpCoverage, provider),
// mcp-deferral-gaps family (#614): detection only, no apply plans yet.
claudeOnly(() => detectMcpDeferralOff(toolCalls, projects, projectCwds, apiCalls)),
claudeOnly(() => detectMcpAlwaysLoadHygiene(projects, projectCwds, apiCalls, mcpCoverage)),
claudeOnly(() => detectMcpDeferThreshold(projects, projectCwds)),
- () => detectCapabilityReliability(behavioralProjects),
- () => detectLowWorthSessions(behavioralProjects),
- () => detectContextBloat(behavioralProjects, lowWorthSessionIds),
- () => detectSessionOutliers(behavioralProjects, outlierExclusions),
+ () => detectCapabilityReliability(behavioralProjects, provider),
+ () => detectLowWorthSessions(behavioralProjects, provider),
+ () => detectContextBloat(behavioralProjects, lowWorthSessionIds, provider),
+ () => detectSessionOutliers(behavioralProjects, outlierExclusions, provider),
claudeOnly(() => detectBloatedClaudeMd(projectCwds)),
claudeOnly(() => detectBashBloat()),
claudeOnly(() => detectRecurringContext(openers)),
@@ -3624,7 +3689,7 @@ function wrap(text: string, width: number, indent: string): string {
/// destination. Issue #277: users were dropping one-time session openers
/// into CLAUDE.md as permanent rules because the prompts had no labeled
/// home in the output.
-function renderActionHeader(action: WasteAction): string {
+function renderActionHeader(action: WasteAction, copy: OptimizeRemediationCopy): string {
const headerWidth = PANEL_WIDTH - 4
const fillTo = (label: string): string => {
const inner = ` ${label} `
@@ -3637,18 +3702,11 @@ function renderActionHeader(action: WasteAction): string {
case 'command':
return fillTo('Run this command')
case 'paste':
- switch (action.destination) {
- case 'claude-md': return fillTo('Suggested CLAUDE.md addition (permanent rule)')
- case 'session-opener': return fillTo('One-time session opener (do NOT add to CLAUDE.md)')
- case 'prompt': return fillTo('Ask Claude in the current session')
- case 'shell-config': return fillTo('Add to your shell config')
- case 'manual': return fillTo('Manual action')
- default: return fillTo('Suggested action')
- }
+ return fillTo(optimizePasteHeader(action.destination, copy))
}
}
-function renderFinding(n: number, f: WasteFinding, costRate: number): string[] {
+function renderFinding(n: number, f: WasteFinding, costRate: number, copy: OptimizeRemediationCopy): string[] {
const lines: string[] = []
const costSaved = f.tokensSaved * costRate
const impactLabel = f.impact.charAt(0).toUpperCase() + f.impact.slice(1)
@@ -3674,7 +3732,7 @@ function renderFinding(n: number, f: WasteFinding, costRate: number): string[] {
// permanent rules and one-time prompts are no longer interchangeable in
// the output.
const a = f.fix
- lines.push(chalk.hex(ORANGE)(` ${renderActionHeader(a)}`))
+ lines.push(chalk.hex(ORANGE)(` ${renderActionHeader(a, copy)}`))
lines.push(chalk.hex(DIM)(` ${a.label}`))
if (a.type === 'file-content') {
for (const line of a.content.split('\n')) lines.push(chalk.hex(CYAN)(` ${line}`))
@@ -3743,7 +3801,9 @@ export function renderOptimize(
previouslyApplied?: Record,
modelRecommendations?: ModelDefaultRecommendation[],
appliedFixes: AppliedFix[] = [],
+ provider?: string,
): string {
+ const copy = optimizeRemediationCopy(provider)
const lines: string[] = []
lines.push('')
lines.push(` ${chalk.bold.hex(ORANGE)('CodeBurn config health')}${chalk.dim(' ' + periodLabel)}`)
@@ -3766,9 +3826,9 @@ export function renderOptimize(
if (findings.length === 0) {
lines.push(chalk.hex(GREEN)(' Nothing to fix. Your setup is lean.'))
lines.push('')
- lines.push(chalk.dim(' CodeBurn optimize scans your Claude Code sessions and config for'))
+ lines.push(chalk.dim(` CodeBurn optimize scans your ${copy.agent} sessions and config for`))
lines.push(chalk.dim(' token waste: junk directory reads, duplicate file reads, unused'))
- lines.push(chalk.dim(' agents/skills/MCP servers, bloated CLAUDE.md, and more.'))
+ lines.push(chalk.dim(` agents/skills/MCP servers, bloated ${copy.instructionFile}, and more.`))
lines.push('')
lines.push(...renderAppliedFixes(appliedFixes))
lines.push(...renderWorkflowSection(reworkedFiles, coachingNotes))
@@ -3799,7 +3859,7 @@ export function renderOptimize(
for (const f of group) {
const appliedOn = previouslyApplied?.[f.id]
const shown = appliedOn ? { ...f, title: `${f.title} (previously applied ${appliedOn}, re-flagged)` } : f
- lines.push(...renderFinding(++n, shown, costRate))
+ lines.push(...renderFinding(++n, shown, costRate, copy))
}
}
@@ -3868,7 +3928,7 @@ export async function runOptimize(
}
const { topReworkedFiles, coachingNotes } = buildWorkflowReport(projects)
- const output = renderOptimize(findings, costRate, periodLabel, periodCost, sessionCount, callCount, healthScore, healthGrade, topReworkedFiles, coachingNotes, opts.appliedHeader, opts.previouslyApplied, result.modelRecommendations, opts.appliedFixes)
+ const output = renderOptimize(findings, costRate, periodLabel, periodCost, sessionCount, callCount, healthScore, healthGrade, topReworkedFiles, coachingNotes, opts.appliedHeader, opts.previouslyApplied, result.modelRecommendations, opts.appliedFixes, opts.provider)
console.log(output)
}
diff --git a/tests/optimize.test.ts b/tests/optimize.test.ts
index def5d390..343abe9f 100644
--- a/tests/optimize.test.ts
+++ b/tests/optimize.test.ts
@@ -28,6 +28,10 @@ import {
buildOptimizeJsonReport,
renderOptimize,
findingBasis,
+ optimizeRemediationCopy,
+ optimizePasteHeader,
+ sessionOpenerLabel,
+ askAgentLabel,
type FindingId,
type ToolCall,
type ApiCallMeta,
@@ -1443,3 +1447,102 @@ describe('renderOptimize applied-fixes section', () => {
expect(render([], [])).not.toContain('Applied fixes')
})
})
+
+describe('provider-scoped remediation copy (#1044)', () => {
+ const strip = (s: string): string => s.replace(/\u001b\[[0-9;]*m/g, '')
+
+ function promptFinding(label: string): WasteFinding {
+ return {
+ id: 'retry-heavy-capabilities',
+ title: 'retry-heavy',
+ explanation: 'why',
+ impact: 'medium',
+ tokensSaved: 1000,
+ fix: { type: 'paste', destination: 'prompt', label, text: 'audit' },
+ }
+ }
+
+ function openerFinding(label: string): WasteFinding {
+ return {
+ id: 'low-worth-sessions',
+ title: 'low-worth',
+ explanation: 'why',
+ impact: 'low',
+ tokensSaved: 1000,
+ fix: { type: 'paste', destination: 'session-opener', label, text: 'open' },
+ }
+ }
+
+ it('keeps Claude / CLAUDE.md for unset, all, and claude', () => {
+ for (const provider of [undefined, 'all', 'claude'] as const) {
+ const copy = optimizeRemediationCopy(provider)
+ expect(copy).toEqual({ agent: 'Claude', instructionFile: 'CLAUDE.md' })
+ expect(optimizePasteHeader('prompt', copy)).toBe('Ask Claude in the current session')
+ expect(optimizePasteHeader('session-opener', copy)).toBe('One-time session opener (do NOT add to CLAUDE.md)')
+ expect(sessionOpenerLabel(copy)).toContain('CLAUDE.md')
+ expect(askAgentLabel(copy, 'audit the retry-heavy capability before changing config'))
+ .toBe('Ask Claude to audit the retry-heavy capability before changing config:')
+ }
+ })
+
+ it('uses Codex / AGENTS.md for --provider codex', () => {
+ const copy = optimizeRemediationCopy('codex')
+ expect(copy).toEqual({ agent: 'Codex', instructionFile: 'AGENTS.md' })
+ expect(optimizePasteHeader('prompt', copy)).toBe('Ask Codex in the current session')
+ expect(optimizePasteHeader('session-opener', copy)).toBe('One-time session opener (do NOT add to AGENTS.md)')
+ expect(sessionOpenerLabel(copy)).toContain('AGENTS.md')
+ expect(sessionOpenerLabel(copy)).not.toContain('CLAUDE.md')
+ })
+
+ it('does not invent a filename for providers CodeBurn has not named', () => {
+ const copy = optimizeRemediationCopy('cursor')
+ expect(copy.agent).toBe('Cursor')
+ expect(copy.instructionFile).toBe('project instructions')
+ expect(optimizePasteHeader('session-opener', copy)).toBe('One-time session opener (do NOT add to project instructions)')
+ })
+
+ it('scopes cross-provider detector labels, including JSON', () => {
+ const project = projectWithLowWorthSessions([
+ lowWorthSession(4, 0, { turns: [lowWorthTurn({ hasEdits: false })] }),
+ ])
+ const claude = detectLowWorthSessions([project])
+ const codex = detectLowWorthSessions([project], 'codex')
+ expect(claude!.fix.label).toContain('CLAUDE.md')
+ expect(codex!.fix.label).toContain('AGENTS.md')
+ expect(codex!.fix.label).not.toContain('CLAUDE.md')
+
+ const turns = Array.from({ length: 5 }, (_, i) => reliabilityTurn(i, {
+ retries: i < 3 ? 1 : 0,
+ call: { tools: ['Edit', 'Skill'], skills: ['reviewer'] },
+ }))
+ const retry = detectCapabilityReliability([projectWithReliabilityTurns(turns)], 'codex')
+ expect(retry!.fix.label).toBe('Ask Codex to audit the retry-heavy capability before changing config:')
+ expect(retry!.fix.label).not.toContain('Claude')
+
+ const json = buildOptimizeJsonReport(
+ [project],
+ 'lifetime',
+ { findings: [codex!], costRate: 0.00001, healthScore: 80, healthGrade: 'B', modelRecommendations: [] },
+ )
+ expect(json.findings[0]!.fix.label).toContain('AGENTS.md')
+ expect(json.findings[0]!.fix.label).not.toContain('CLAUDE.md')
+ })
+
+ it('renders destination headers from the selected provider, not the finding text', () => {
+ const out = strip(renderOptimize(
+ [promptFinding('Ask Claude to audit the retry-heavy capability before changing config:')],
+ 0.00001, 'lifetime', 10, 5, 100, 80, 'B', [], [],
+ undefined, undefined, undefined, [], 'codex',
+ ))
+ expect(out).toContain('Ask Codex in the current session')
+ expect(out).not.toContain('Ask Claude in the current session')
+
+ const opener = strip(renderOptimize(
+ [openerFinding('Paste at the start of your NEXT expensive thread (one-time, do not add to CLAUDE.md):')],
+ 0.00001, 'lifetime', 10, 5, 100, 80, 'B', [], [],
+ undefined, undefined, undefined, [], 'codex',
+ ))
+ expect(opener).toContain('do NOT add to AGENTS.md')
+ expect(opener).not.toContain('do NOT add to CLAUDE.md')
+ })
+})
From 8b79927d29d230a6da5efec3b3da6c32b2da9fce Mon Sep 17 00:00:00 2001
From: Aditya Vikram Singh <247195684+avs-io@users.noreply.github.com>
Date: Thu, 20 Aug 2026 00:37:41 +0530
Subject: [PATCH 02/24] fix(optimize): preserve Claude copy and use provider
display names
Extra High on #1049: keep the shipped Claude Code empty-state and
TUI header strings; resolve agent nouns from Provider.displayName;
cover all five cross-provider detector labels.
---
src/dashboard.tsx | 4 +-
src/optimize.ts | 63 ++++++++++++++---------
src/providers/index.ts | 23 +++++++++
tests/mcp-coverage.test.ts | 23 +++++++++
tests/optimize.test.ts | 79 +++++++++++++++++++++--------
tests/provider-display-name.test.ts | 12 +++++
6 files changed, 156 insertions(+), 48 deletions(-)
create mode 100644 tests/provider-display-name.test.ts
diff --git a/src/dashboard.tsx b/src/dashboard.tsx
index 2ea3c0f7..a804cc94 100644
--- a/src/dashboard.tsx
+++ b/src/dashboard.tsx
@@ -10,7 +10,7 @@ import { findUnpricedModels, isExpectedFreeModel, loadPricing } from './models.j
import { aggregateModelTotals } from './model-breakdown.js'
import { buildDurablePeriod } from './usage-aggregator.js'
import { getAllProviders } from './providers/index.js'
-import { classHeaderLine, classTotals, findingBasis, findingClass, optimizePasteHeader, optimizeRemediationCopy, scanAndDetect, type FindingClass, type WasteFinding, type WasteAction, type OptimizeResult } from './optimize.js'
+import { classHeaderLine, classTotals, findingBasis, findingClass, optimizeTuiPasteHeader, scanAndDetect, type FindingClass, type WasteFinding, type WasteAction, type OptimizeResult } from './optimize.js'
import { appliedFixGlyph, formatAppliedFix, type AppliedFix } from './act/types.js'
import { aggregateFileChurn, buildCoachingNotes, computePricingCoverage, medianTimeToFirstEditMs, scanUserCorrections, worstOneShotCategory, type ReworkedFile } from './workflow-insights.js'
import { estimateContextBudget, type ContextBudget } from './context-budget.js'
@@ -1040,7 +1040,7 @@ function actionDestinationHeader(action: WasteAction, provider?: string): string
case 'command':
return '── Run this command '.padEnd(64, '─')
case 'paste': {
- return `── ${optimizePasteHeader(action.destination, optimizeRemediationCopy(provider))} `.padEnd(64, '─')
+ return optimizeTuiPasteHeader(action.destination, provider)
}
}
}
diff --git a/src/optimize.ts b/src/optimize.ts
index c6d94a63..1d4a8f31 100644
--- a/src/optimize.ts
+++ b/src/optimize.ts
@@ -8,7 +8,7 @@ import { homedir } from 'os'
import { isReadShapedBashCommand } from './bash-utils.js'
import { readSessionLines, readSessionFileSync } from './fs-utils.js'
-import { discoverAllSessions } from './providers/index.js'
+import { discoverAllSessions, providerDisplayName } from './providers/index.js'
import { parseJsonlLine, shouldSkipLine } from './parser.js'
import type { DateRange, ProjectSummary, SessionSummary } from './types.js'
import { formatCost } from './currency.js'
@@ -260,36 +260,19 @@ export type OptimizeRemediationCopy = {
instructionFile: string
}
-const REMEDIATION_AGENT_NAMES: Record = {
- claude: 'Claude',
- codex: 'Codex',
- cursor: 'Cursor',
- copilot: 'Copilot',
- gemini: 'Gemini',
- opencode: 'OpenCode',
- antigravity: 'Antigravity',
- windsurf: 'Windsurf',
- cline: 'Cline',
- hermes: 'Hermes',
- kimi: 'Kimi',
- kimicode: 'Kimi Code',
- 'ibm-bob': 'IBM Bob',
- pi: 'Pi',
-}
-
const REMEDIATION_INSTRUCTION_FILES: Record = {
claude: 'CLAUDE.md',
codex: 'AGENTS.md',
}
-function humanizeProviderId(id: string): string {
- return id.split(/[-_]/).map(part => part ? part[0]!.toUpperCase() + part.slice(1) : part).join(' ')
+export function isDefaultClaudeProvider(provider?: string): boolean {
+ return !provider || provider === 'all' || provider === 'claude'
}
export function optimizeRemediationCopy(provider?: string): OptimizeRemediationCopy {
const key = !provider || provider === 'all' ? 'claude' : provider.toLowerCase()
return {
- agent: REMEDIATION_AGENT_NAMES[key] ?? humanizeProviderId(key),
+ agent: providerDisplayName(key),
instructionFile: REMEDIATION_INSTRUCTION_FILES[key] ?? 'project instructions',
}
}
@@ -313,6 +296,38 @@ export function optimizePasteHeader(destination: PasteDestination | undefined, c
}
}
+/// Exact pre-#1049 TUI strings for unset/`all`/`claude`. Non-Claude providers
+/// reuse the CLI header table so the two surfaces cannot drift on new copy.
+export function optimizeTuiPasteHeader(destination: PasteDestination | undefined, provider?: string): string {
+ if (isDefaultClaudeProvider(provider)) {
+ switch (destination) {
+ case 'claude-md': return '── Suggested CLAUDE.md addition (permanent rule) '.padEnd(64, '─')
+ case 'session-opener': return '── One-time session opener (do not add to CLAUDE.md) '.padEnd(64, '─')
+ case 'prompt': return '── Ask Claude in the current session '.padEnd(64, '─')
+ case 'shell-config': return '── Add to your shell config '.padEnd(64, '─')
+ case 'manual': return '── Manual action '.padEnd(64, '─')
+ default: return '── Suggested action '.padEnd(64, '─')
+ }
+ }
+ return `── ${optimizePasteHeader(destination, optimizeRemediationCopy(provider))} `.padEnd(64, '─')
+}
+
+export function optimizeEmptyScanLines(provider?: string): [string, string, string] {
+ if (isDefaultClaudeProvider(provider)) {
+ return [
+ 'CodeBurn optimize scans your Claude Code sessions and config for',
+ 'token waste: junk directory reads, duplicate file reads, unused',
+ 'agents/skills/MCP servers, bloated CLAUDE.md, and more.',
+ ]
+ }
+ const copy = optimizeRemediationCopy(provider)
+ return [
+ `CodeBurn optimize scans your ${copy.agent} sessions and config for`,
+ 'token waste: junk directory reads, duplicate file reads, unused',
+ `agents/skills/MCP servers, bloated ${copy.instructionFile}, and more.`,
+ ]
+}
+
export type WasteAction =
| { type: 'paste'; label: string; text: string; destination?: PasteDestination }
| { type: 'command'; label: string; text: string }
@@ -3826,9 +3841,9 @@ export function renderOptimize(
if (findings.length === 0) {
lines.push(chalk.hex(GREEN)(' Nothing to fix. Your setup is lean.'))
lines.push('')
- lines.push(chalk.dim(` CodeBurn optimize scans your ${copy.agent} sessions and config for`))
- lines.push(chalk.dim(' token waste: junk directory reads, duplicate file reads, unused'))
- lines.push(chalk.dim(` agents/skills/MCP servers, bloated ${copy.instructionFile}, and more.`))
+ for (const line of optimizeEmptyScanLines(provider)) {
+ lines.push(chalk.dim(` ${line}`))
+ }
lines.push('')
lines.push(...renderAppliedFixes(appliedFixes))
lines.push(...renderWorkflowSection(reworkedFiles, coachingNotes))
diff --git a/src/providers/index.ts b/src/providers/index.ts
index 70af252c..0eaadded 100644
--- a/src/providers/index.ts
+++ b/src/providers/index.ts
@@ -199,6 +199,29 @@ const coreProviders: Provider[] = [claude, cline, clineCli, codewhale, codebuff,
// when an optional module fails to load. Must stay in sync with getAllProviders.
const lazyProviderNames = ['antigravity', 'forge', 'goose', 'cursor', 'opencode', 'cursor-agent', 'crush', 'warp', 'vercel-gateway', 'zcode', 'zed']
+// Display names for lazy providers. Must match the `displayName` on the
+// loaded Provider object; `providerDisplayName` + getAllProviders() test
+// is the drift check.
+const lazyProviderDisplayNames: Record = {
+ antigravity: 'Antigravity',
+ forge: 'Forge',
+ goose: 'Goose',
+ cursor: 'Cursor',
+ opencode: 'OpenCode',
+ 'cursor-agent': 'Cursor Agent',
+ crush: 'Crush',
+ warp: 'Warp',
+ 'vercel-gateway': 'Vercel AI Gateway',
+ zcode: 'ZCode',
+ zed: 'Zed',
+}
+
+export function providerDisplayName(name: string): string {
+ const core = coreProviders.find(p => p.name === name)
+ if (core) return core.displayName
+ return lazyProviderDisplayNames[name] ?? name
+}
+
// Canonical set of every provider name (core + lazy), used to validate the
// --provider CLI flag. Computed lazily so importing this module never depends on
// every provider object being defined at load time (e.g. under test mocks).
diff --git a/tests/mcp-coverage.test.ts b/tests/mcp-coverage.test.ts
index e173f008..612d3c4f 100644
--- a/tests/mcp-coverage.test.ts
+++ b/tests/mcp-coverage.test.ts
@@ -769,6 +769,29 @@ describe('detectMcpProfileAdvisor', () => {
}
})
+ it('scopes the remediation label to --provider codex', () => {
+ const hotTurns = [makeTurn([
+ makeCall({ tools: ['mcp__github__t0'], cacheCreation: 10_000 }),
+ makeCall({ tools: ['mcp__github__t1'], cacheCreation: 10_000 }),
+ ])]
+ const coldTurns = [makeTurn([makeCall({ cacheCreation: 10_000 })])]
+ const projects = [
+ projectNamed('api', [
+ makeSession({ inventory: smallInventory, turns: hotTurns, mcpBreakdown: { github: { calls: 2 } } }),
+ ]),
+ projectNamed('web', [
+ makeSession({ inventory: smallInventory, turns: coldTurns, mcpBreakdown: { github: { calls: 0 } } }),
+ ]),
+ projectNamed('docs', [
+ makeSession({ inventory: smallInventory, turns: coldTurns, mcpBreakdown: { github: { calls: 0 } } }),
+ ]),
+ ]
+ const finding = detectMcpProfileAdvisor(projects, undefined, 'codex')
+ expect(finding!.fix.label).toBe('Ask Codex to turn this into a project-scoped MCP profile:')
+ expect(finding!.fix.label).not.toContain('Claude')
+ expect(finding!.fix.label).not.toContain('CLAUDE.md')
+ })
+
it('does not flag servers used evenly across loaded projects', () => {
const projects = ['api', 'web', 'docs'].map(name => projectNamed(name, [
makeSession({
diff --git a/tests/optimize.test.ts b/tests/optimize.test.ts
index 343abe9f..f42db53e 100644
--- a/tests/optimize.test.ts
+++ b/tests/optimize.test.ts
@@ -30,6 +30,8 @@ import {
findingBasis,
optimizeRemediationCopy,
optimizePasteHeader,
+ optimizeTuiPasteHeader,
+ optimizeEmptyScanLines,
sessionOpenerLabel,
askAgentLabel,
type FindingId,
@@ -1473,16 +1475,29 @@ describe('provider-scoped remediation copy (#1044)', () => {
}
}
- it('keeps Claude / CLAUDE.md for unset, all, and claude', () => {
+ it('keeps the exact shipped Claude / CLAUDE.md strings', () => {
for (const provider of [undefined, 'all', 'claude'] as const) {
const copy = optimizeRemediationCopy(provider)
expect(copy).toEqual({ agent: 'Claude', instructionFile: 'CLAUDE.md' })
expect(optimizePasteHeader('prompt', copy)).toBe('Ask Claude in the current session')
expect(optimizePasteHeader('session-opener', copy)).toBe('One-time session opener (do NOT add to CLAUDE.md)')
- expect(sessionOpenerLabel(copy)).toContain('CLAUDE.md')
+ expect(sessionOpenerLabel(copy)).toBe('Paste at the start of your NEXT expensive thread (one-time, do not add to CLAUDE.md):')
expect(askAgentLabel(copy, 'audit the retry-heavy capability before changing config'))
.toBe('Ask Claude to audit the retry-heavy capability before changing config:')
+ expect(optimizeEmptyScanLines(provider)).toEqual([
+ 'CodeBurn optimize scans your Claude Code sessions and config for',
+ 'token waste: junk directory reads, duplicate file reads, unused',
+ 'agents/skills/MCP servers, bloated CLAUDE.md, and more.',
+ ])
+ expect(optimizeTuiPasteHeader('session-opener', provider))
+ .toBe('── One-time session opener (do not add to CLAUDE.md) '.padEnd(64, '─'))
+ expect(optimizeTuiPasteHeader('prompt', provider))
+ .toBe('── Ask Claude in the current session '.padEnd(64, '─'))
}
+ const empty = strip(renderOptimize([], 0, 'lifetime', 0, 0, 0, 100, 'A', [], []))
+ expect(empty).toContain('CodeBurn optimize scans your Claude Code sessions and config for')
+ expect(empty).toContain('bloated CLAUDE.md')
+ expect(empty).not.toContain('Claude sessions')
})
it('uses Codex / AGENTS.md for --provider codex', () => {
@@ -1492,40 +1507,60 @@ describe('provider-scoped remediation copy (#1044)', () => {
expect(optimizePasteHeader('session-opener', copy)).toBe('One-time session opener (do NOT add to AGENTS.md)')
expect(sessionOpenerLabel(copy)).toContain('AGENTS.md')
expect(sessionOpenerLabel(copy)).not.toContain('CLAUDE.md')
+ expect(optimizeEmptyScanLines('codex')[0]).toBe('CodeBurn optimize scans your Codex sessions and config for')
+ expect(optimizeEmptyScanLines('codex')[2]).toContain('bloated AGENTS.md')
+ expect(optimizeTuiPasteHeader('prompt', 'codex')).toContain('Ask Codex in the current session')
+ expect(optimizeTuiPasteHeader('session-opener', 'codex')).toContain('do NOT add to AGENTS.md')
+ expect(optimizeTuiPasteHeader('session-opener', 'codex')).not.toContain('CLAUDE.md')
})
- it('does not invent a filename for providers CodeBurn has not named', () => {
- const copy = optimizeRemediationCopy('cursor')
- expect(copy.agent).toBe('Cursor')
- expect(copy.instructionFile).toBe('project instructions')
- expect(optimizePasteHeader('session-opener', copy)).toBe('One-time session opener (do NOT add to project instructions)')
+ it('uses the canonical Provider.displayName, not a title-cased id', () => {
+ expect(optimizeRemediationCopy('hermes').agent).toBe('Hermes Agent')
+ expect(optimizeRemediationCopy('cursor').agent).toBe('Cursor')
+ expect(optimizeRemediationCopy('cursor').instructionFile).toBe('project instructions')
+ expect(optimizePasteHeader('session-opener', optimizeRemediationCopy('cursor')))
+ .toBe('One-time session opener (do NOT add to project instructions)')
})
- it('scopes cross-provider detector labels, including JSON', () => {
- const project = projectWithLowWorthSessions([
- lowWorthSession(4, 0, { turns: [lowWorthTurn({ hasEdits: false })] }),
- ])
- const claude = detectLowWorthSessions([project])
- const codex = detectLowWorthSessions([project], 'codex')
- expect(claude!.fix.label).toContain('CLAUDE.md')
- expect(codex!.fix.label).toContain('AGENTS.md')
- expect(codex!.fix.label).not.toContain('CLAUDE.md')
-
+ it('scopes every cross-provider detector label, including JSON', () => {
+ const lowWorth = detectLowWorthSessions([
+ projectWithLowWorthSessions([lowWorthSession(4, 0, { turns: [lowWorthTurn({ hasEdits: false })] })]),
+ ], 'codex')
+ const context = detectContextBloat([
+ projectWithContextSessions([contextSession(0, {
+ totalInputTokens: 90_000,
+ totalCacheReadTokens: 30_000,
+ totalOutputTokens: 2_000,
+ })]),
+ ], undefined, 'codex')
+ const outliers = detectSessionOutliers([projectWithSessions([1, 1, 1, 10])], undefined, 'codex')
const turns = Array.from({ length: 5 }, (_, i) => reliabilityTurn(i, {
retries: i < 3 ? 1 : 0,
call: { tools: ['Edit', 'Skill'], skills: ['reviewer'] },
}))
const retry = detectCapabilityReliability([projectWithReliabilityTurns(turns)], 'codex')
+
+ const findings = [lowWorth, context, outliers, retry]
+ expect(findings.every(Boolean)).toBe(true)
+ for (const finding of findings) {
+ expect(finding!.fix.label).not.toContain('Claude')
+ expect(finding!.fix.label).not.toContain('CLAUDE.md')
+ }
+ expect(lowWorth!.fix.label).toContain('AGENTS.md')
+ expect(context!.fix.label).toContain('AGENTS.md')
+ expect(outliers!.fix.label).toContain('AGENTS.md')
expect(retry!.fix.label).toBe('Ask Codex to audit the retry-heavy capability before changing config:')
- expect(retry!.fix.label).not.toContain('Claude')
const json = buildOptimizeJsonReport(
- [project],
+ [projectWithSessions([1])],
'lifetime',
- { findings: [codex!], costRate: 0.00001, healthScore: 80, healthGrade: 'B', modelRecommendations: [] },
+ { findings: findings as WasteFinding[], costRate: 0.00001, healthScore: 80, healthGrade: 'B', modelRecommendations: [] },
)
- expect(json.findings[0]!.fix.label).toContain('AGENTS.md')
- expect(json.findings[0]!.fix.label).not.toContain('CLAUDE.md')
+ expect(json.findings).toHaveLength(4)
+ for (const row of json.findings) {
+ expect(row.fix.label).not.toContain('Claude')
+ expect(row.fix.label).not.toContain('CLAUDE.md')
+ }
})
it('renders destination headers from the selected provider, not the finding text', () => {
diff --git a/tests/provider-display-name.test.ts b/tests/provider-display-name.test.ts
new file mode 100644
index 00000000..3e0e016e
--- /dev/null
+++ b/tests/provider-display-name.test.ts
@@ -0,0 +1,12 @@
+import { describe, expect, it } from 'vitest'
+import { getAllProviders, providerDisplayName } from '../src/providers/index.js'
+
+describe('providerDisplayName', () => {
+ it('matches every loaded Provider.displayName', async () => {
+ const loaded = await getAllProviders()
+ expect(loaded.length).toBeGreaterThan(20)
+ for (const provider of loaded) {
+ expect(providerDisplayName(provider.name)).toBe(provider.displayName)
+ }
+ })
+})
From a5ed4535cdf9dd471c5f64f644b484e31e54d684 Mon Sep 17 00:00:00 2001
From: Aditya Vikram Singh <247195684+avs-io@users.noreply.github.com>
Date: Thu, 20 Aug 2026 08:13:35 +0530
Subject: [PATCH 03/24] fix(copilot): key shutdown rollups by timestamp, not
per-file index
Two journals for one session both emitted :1 and the shared seenKeys
set dropped the second file's first leg. Identity is now
(session, model, shutdown timestamp). Bump the Copilot parse
fingerprint so present sources re-parse; durable orphans stay.
---
src/providers/copilot.ts | 19 ++++++----
src/session-cache.ts | 6 +++-
tests/providers/copilot.test.ts | 62 ++++++++++++++++++++++++++++-----
3 files changed, 70 insertions(+), 17 deletions(-)
diff --git a/src/providers/copilot.ts b/src/providers/copilot.ts
index e472fdb7..9703d0dd 100644
--- a/src/providers/copilot.ts
+++ b/src/providers/copilot.ts
@@ -703,6 +703,14 @@ function inferTranscriptModel(lines: string[]): string {
// token counts and no session.shutdown rollup)
// ---------------------------------------------------------------------------
+// Shutdown rollup identity is (session, model, shutdown timestamp), not a
+// per-file occurrence index. Two journals for one session (resume into a
+// second events.jsonl) both started their local counter at 1, so `:1`
+// collided and the second file's first leg was dropped (#1051).
+function copilotShutdownDedupKey(sessionId: string, model: string, shutdownTimestamp: string): string {
+ return `copilot:${sessionId}:shutdown:${model}:${shutdownTimestamp || 'untimestamped'}`
+}
+
/**
* `isTranscript` comes from discovery (where the file lives), never from
* content: the Copilot CLI writes the same session.start producer
@@ -758,11 +766,10 @@ function createJsonlParser(
// A resumed session appends one session.shutdown PER LEG, each carrying
// CUMULATIVE per-model totals. Emitting each rollup whole would need the
// cache to update a prior call in place — the durable merge is
- // append-only by dedup key — so we emit per-leg DELTAS keyed by
- // occurrence instead: re-parses of a growing file append only the new
- // leg, and each leg lands on its own timestamp.
+ // append-only by dedup key — so we emit per-leg DELTAS keyed by the
+ // shutdown timestamp: re-parses of a growing file append only the new
+ // leg, and two journals for one session cannot collide on `:1`.
const prevShutdownUsage = new Map()
- const shutdownCountByModel = new Map()
for (const line of lines) {
let event: CopilotEvent
@@ -858,8 +865,6 @@ function createJsonlParser(
}
const prevRaw = prevShutdownUsage.get(model)
prevShutdownUsage.set(model, cumulative)
- const n = (shutdownCountByModel.get(model) ?? 0) + 1
- shutdownCountByModel.set(model, n)
// A cumulative total BELOW the previous rollup means the CLI reset
// its counters (a fresh accounting epoch): delta from zero, else
@@ -891,7 +896,7 @@ function createJsonlParser(
// to avoid an empty $0 row (output is intentionally excluded).
if (inputTokens === 0 && cacheReadTokens === 0 && cacheWriteTokens === 0 && reasoningTokens === 0) continue
- const dedupKey = `copilot:${sessionId}:shutdown:${model}:${n}`
+ const dedupKey = copilotShutdownDedupKey(sessionId, model, shutdownTimestamp)
if (seenKeys.has(dedupKey)) continue
seenKeys.add(dedupKey)
diff --git a/src/session-cache.ts b/src/session-cache.ts
index 042ba14a..dfd8a0ac 100644
--- a/src/session-cache.ts
+++ b/src/session-cache.ts
@@ -285,7 +285,11 @@ export const PROVIDER_PARSE_VERSIONS: Record = {
// source-provenance-v1 (#944): CLI sessions were misread as VS Code
// transcripts (both carry producer 'copilot-agent'), skipping the shutdown
// input/cache rollup; this bump re-parses them so the missing tokens land.
- copilot: 'cli-shutdown-cost-v1-skills-source-provenance-v1',
+ // rollup-ts-v1 (#1051): shutdown rollup keys use the leg timestamp, not a
+ // per-file occurrence index. Present sources must re-parse so old `:n`
+ // keys are not union-merged next to the new timestamp keys (double count).
+ // Orphans whose source is gone stay via the fingerprint carry-forward.
+ copilot: 'cli-shutdown-cost-v1-skills-source-provenance-v1-rollup-ts-v1',
// authoritative-usage-v4: persist one Grok session call from top-level
// authoritative totals, use modelUsage only for priced attribution, clamp
// reasoning per record, and label mixed sessions estimated.
diff --git a/tests/providers/copilot.test.ts b/tests/providers/copilot.test.ts
index ae128be9..c4bf1582 100644
--- a/tests/providers/copilot.test.ts
+++ b/tests/providers/copilot.test.ts
@@ -615,7 +615,7 @@ describe('copilot provider - session.shutdown token/cost rollup', () => {
// One per-turn assistant.message call + one supplementary shutdown call.
expect(calls).toHaveLength(2)
- const shutdown = calls.find(c => c.deduplicationKey === 'copilot:sess-shutdown:shutdown:claude-sonnet-4-5:1')
+ const shutdown = calls.find(c => c.deduplicationKey === 'copilot:sess-shutdown:shutdown:claude-sonnet-4-5:2026-04-15T10:05:00Z')
expect(shutdown).toBeDefined()
expect(shutdown!.model).toBe('claude-sonnet-4-5')
expect(shutdown!.inputTokens).toBe(4) // 71282 - 35495 - 35783
@@ -706,9 +706,9 @@ describe('copilot provider - session.shutdown token/cost rollup', () => {
it('emits per-leg deltas for a resumed session with cumulative shutdown rollups', async () => {
// Numbers from a real resumed CLI 1.0.78 session (3 legs via --resume):
// each leg appends a session.shutdown whose modelMetrics are CUMULATIVE.
- // Emitting deltas keyed by occurrence keeps a growing file append-only
- // under the durable union-by-key cache merge — re-parsing after each
- // resume adds only the new leg, never double-counting earlier ones.
+ // Emitting deltas keyed by shutdown timestamp keeps a growing file
+ // append-only under the durable union-by-key cache merge — re-parsing
+ // after each resume adds only the new leg, never double-counting earlier ones.
const legs = [
{ inputTokens: 24672, outputTokens: 17, cacheReadTokens: 0, cacheWriteTokens: 24670 },
{ inputTokens: 74463, outputTokens: 149, cacheReadTokens: 49489, cacheWriteTokens: 24968 },
@@ -723,9 +723,9 @@ describe('copilot provider - session.shutdown token/cost rollup', () => {
const shutdowns = calls.filter(c => c.deduplicationKey.includes(':shutdown:'))
expect(shutdowns.map(c => c.deduplicationKey)).toEqual([
- 'copilot:sess-resumed:shutdown:claude-sonnet-5:1',
- 'copilot:sess-resumed:shutdown:claude-sonnet-5:2',
- 'copilot:sess-resumed:shutdown:claude-sonnet-5:3',
+ 'copilot:sess-resumed:shutdown:claude-sonnet-5:2026-08-01T10:00:00Z',
+ 'copilot:sess-resumed:shutdown:claude-sonnet-5:2026-08-02T10:00:00Z',
+ 'copilot:sess-resumed:shutdown:claude-sonnet-5:2026-08-03T10:00:00Z',
])
// Each leg lands on its own shutdown timestamp (a resumed session can
// span days; whole-rollup emission would collapse them onto one).
@@ -803,6 +803,50 @@ describe('copilot provider - session.shutdown token/cost rollup', () => {
expect(second).toHaveLength(0)
})
+ it('keeps both first legs when one session has two journal files (#1051)', async () => {
+ // The occurrence counter lived inside each file parse. Two journals for
+ // the same sessionId both emitted `:1` and the shared seenKeys set
+ // dropped the second file's first shutdown. Keying by timestamp (and
+ // disambiguating a shared stamp against seenKeys) keeps both legs.
+ const sessionDir = join(tmpDir, 'sess-two-journals')
+ await mkdir(sessionDir, { recursive: true })
+ await writeFile(join(sessionDir, 'workspace.yaml'), 'id: sess-two-journals\ncwd: /home/user/myproject\n')
+
+ const journal1 = [
+ modelChange('claude-sonnet-5'),
+ assistantMessage({ messageId: 'msg-a', outputTokens: 10 }),
+ shutdownEvent({
+ modelMetrics: { 'claude-sonnet-5': { inputTokens: 3000, outputTokens: 10, cacheReadTokens: 1000, cacheWriteTokens: 500 } },
+ timestamp: '2026-08-01T10:00:00Z',
+ }),
+ ]
+ const journal2 = [
+ modelChange('claude-sonnet-5'),
+ assistantMessage({ messageId: 'msg-b', outputTokens: 20 }),
+ shutdownEvent({
+ modelMetrics: { 'claude-sonnet-5': { inputTokens: 4000, outputTokens: 20, cacheReadTokens: 2000, cacheWriteTokens: 700 } },
+ timestamp: '2026-08-02T10:00:00Z',
+ }),
+ ]
+ const path1 = join(sessionDir, 'events.jsonl')
+ const path2 = join(sessionDir, 'events-2.jsonl')
+ await writeFile(path1, journal1.join('\n') + '\n')
+ await writeFile(path2, journal2.join('\n') + '\n')
+
+ const seen = new Set()
+ const first = await collectCalls({ path: path1, project: 'myproject', provider: 'copilot', sourceType: 'jsonl' }, seen)
+ const second = await collectCalls({ path: path2, project: 'myproject', provider: 'copilot', sourceType: 'jsonl' }, seen)
+ const shutdowns = [...first, ...second].filter(c => c.deduplicationKey.includes(':shutdown:'))
+ expect(shutdowns.map(c => c.deduplicationKey)).toEqual([
+ 'copilot:sess-two-journals:shutdown:claude-sonnet-5:2026-08-01T10:00:00Z',
+ 'copilot:sess-two-journals:shutdown:claude-sonnet-5:2026-08-02T10:00:00Z',
+ ])
+ expect(shutdowns[0]!.inputTokens).toBe(1500) // 3000 − 1000 − 500
+ expect(shutdowns[1]!.inputTokens).toBe(1300) // 4000 − 2000 − 700
+ expect(shutdowns[0]!.cacheReadInputTokens).toBe(1000)
+ expect(shutdowns[1]!.cacheReadInputTokens).toBe(2000)
+ })
+
it('falls back to the last stamped event when shutdown carries no timestamp at all', async () => {
// A shutdown with neither its own timestamp nor sessionStartTime must not
// yield an empty-timestamp call: the date-range filters in parser.ts drop
@@ -935,7 +979,7 @@ describe('copilot provider - session.shutdown token/cost rollup', () => {
expect(perTurn.every(c => c.model === 'claude-sonnet-5')).toBe(true)
// The shutdown rollup lands: the tokens the misclassification dropped.
- const shutdown = calls.find(c => c.deduplicationKey === 'copilot:sess-cli-producer:shutdown:claude-sonnet-5:1')
+ const shutdown = calls.find(c => c.deduplicationKey === 'copilot:sess-cli-producer:shutdown:claude-sonnet-5:2026-08-07T17:56:40.591Z')
expect(shutdown).toBeDefined()
expect(shutdown!.inputTokens).toBe(4) // 49473 − 24678 − 24791 (cache-inclusive)
expect(shutdown!.cacheReadInputTokens).toBe(24678)
@@ -999,7 +1043,7 @@ describe('copilot provider - session.shutdown token/cost rollup', () => {
const calls: ParsedProviderCall[] = []
for await (const call of provider.createSessionParser(sessions[0]!, new Set()).parse()) calls.push(call)
- const shutdown = calls.find(c => c.deduplicationKey === 'copilot:sess-wire:shutdown:claude-sonnet-5:1')
+ const shutdown = calls.find(c => c.deduplicationKey === 'copilot:sess-wire:shutdown:claude-sonnet-5:2026-04-15T10:05:00Z')
expect(shutdown).toBeDefined()
expect(shutdown!.inputTokens).toBe(500) // 5000 − 3000 − 1500
expect(shutdown!.cacheReadInputTokens).toBe(3000)
From a3ea24859e75681eb14becc524efbfc2ae9bad5d Mon Sep 17 00:00:00 2001
From: Aditya Vikram Singh <247195684+avs-io@users.noreply.github.com>
Date: Thu, 20 Aug 2026 09:52:56 +0530
Subject: [PATCH 04/24] fix(copilot): journal-scope shutdown keys without a
fingerprint bump
Extra High HOLD on #1054: timestamp-only keys still collided across
journals, and the provider-wide rollup-ts-v1 bump dropped present
OTel sources, erasing conversations already pruned from the DB.
Identity is now (session, model, timestamp, journal basename).
Legacy :n keys migrate on that JSONL file only.
---
src/parser.ts | 31 ++++++++++++++
src/providers/copilot.ts | 27 ++++++++----
src/session-cache.ts | 11 ++---
tests/parser.test.ts | 74 ++++++++++++++++++++++++++++++++-
tests/providers/copilot.test.ts | 59 +++++++++++++++++++++-----
5 files changed, 177 insertions(+), 25 deletions(-)
diff --git a/src/parser.ts b/src/parser.ts
index 481f6d93..4992aec8 100644
--- a/src/parser.ts
+++ b/src/parser.ts
@@ -2822,6 +2822,24 @@ function getOrCreateProviderSection(cache: SessionCache, provider: string): Prov
return section
}
+function isLegacyCopilotShutdownKey(key: string, journalId: string): boolean {
+ return key.includes(':shutdown:') && !key.endsWith(`:${journalId}`)
+}
+
+// Drop occurrence-index / timestamp-only shutdown calls from one JSONL cache
+// entry so the durable union does not keep `:n` next to the new journal-keyed
+// calls (double count). OTel / JetBrains / transcript keys do not include
+// `:shutdown:` and are left alone.
+function stripLegacyCopilotShutdownCalls(entry: CachedFile, journalId: string): void {
+ const next: CachedTurn[] = []
+ for (const turn of entry.turns) {
+ const calls = turn.calls.filter(call => !isLegacyCopilotShutdownKey(call.deduplicationKey, journalId))
+ if (calls.length === 0) continue
+ next.push(calls.length === turn.calls.length ? turn : { ...turn, calls })
+ }
+ entry.turns = next
+}
+
function cachedFileNeedsProviderReparse(providerName: string, sourcePath: string, cached: CachedFile): boolean {
// Antigravity data comes from the live server, not from the conversation file.
// A 0-turn cache entry may just mean the server was unavailable last run.
@@ -2832,6 +2850,16 @@ function cachedFileNeedsProviderReparse(providerName: string, sourcePath: string
// title, model, and timestamp changes.
if (providerName === 'devin') return true
+ // #1051: migrate old Copilot CLI shutdown keys (`:n` or timestamp-only)
+ // without a provider-wide fingerprint bump. A bump would drop the present
+ // OTel DB source and erase conversations already pruned from that DB.
+ if (providerName === 'copilot') {
+ const journalId = basename(sourcePath)
+ return cached.turns.some(turn =>
+ turn.calls.some(call => isLegacyCopilotShutdownKey(call.deduplicationKey, journalId)),
+ )
+ }
+
if (providerName !== 'gemini') return false
return cached.turns.some(turn =>
@@ -3208,6 +3236,9 @@ async function parseProviderSources(
if (provider.durableSources) {
const existingEntry = section.files[source.path]
if (existingEntry) {
+ if (providerName === 'copilot') {
+ stripLegacyCopilotShutdownCalls(existingEntry, basename(source.path))
+ }
const existingKeys = new Set(
existingEntry.turns.flatMap(t => t.calls.map(c => c.deduplicationKey))
)
diff --git a/src/providers/copilot.ts b/src/providers/copilot.ts
index 9703d0dd..bc2f0906 100644
--- a/src/providers/copilot.ts
+++ b/src/providers/copilot.ts
@@ -703,12 +703,19 @@ function inferTranscriptModel(lines: string[]): string {
// token counts and no session.shutdown rollup)
// ---------------------------------------------------------------------------
-// Shutdown rollup identity is (session, model, shutdown timestamp), not a
-// per-file occurrence index. Two journals for one session (resume into a
-// second events.jsonl) both started their local counter at 1, so `:1`
-// collided and the second file's first leg was dropped (#1051).
-function copilotShutdownDedupKey(sessionId: string, model: string, shutdownTimestamp: string): string {
- return `copilot:${sessionId}:shutdown:${model}:${shutdownTimestamp || 'untimestamped'}`
+// Shutdown rollup identity is (session, model, shutdown timestamp, journal
+// basename), not a per-file occurrence index. Two journals for one session
+// (resume into a second events.jsonl) both started their local counter at 1,
+// so `:1` collided and the second file's first leg was dropped (#1051).
+// Timestamp alone is not unique across journals: two files can share a stamp.
+// The journal basename is re-parse-stable; do not mint suffixes from seenKeys.
+function copilotShutdownDedupKey(
+ sessionId: string,
+ model: string,
+ shutdownTimestamp: string,
+ journalId: string,
+): string {
+ return `copilot:${sessionId}:shutdown:${model}:${shutdownTimestamp || 'untimestamped'}:${journalId}`
}
/**
@@ -767,8 +774,10 @@ function createJsonlParser(
// CUMULATIVE per-model totals. Emitting each rollup whole would need the
// cache to update a prior call in place — the durable merge is
// append-only by dedup key — so we emit per-leg DELTAS keyed by the
- // shutdown timestamp: re-parses of a growing file append only the new
- // leg, and two journals for one session cannot collide on `:1`.
+ // shutdown timestamp plus this journal's basename: re-parses of a
+ // growing file append only the new leg, and two journals for one
+ // session cannot collide on `:1` or on a shared timestamp.
+ const journalId = basename(source.path)
const prevShutdownUsage = new Map()
for (const line of lines) {
@@ -896,7 +905,7 @@ function createJsonlParser(
// to avoid an empty $0 row (output is intentionally excluded).
if (inputTokens === 0 && cacheReadTokens === 0 && cacheWriteTokens === 0 && reasoningTokens === 0) continue
- const dedupKey = copilotShutdownDedupKey(sessionId, model, shutdownTimestamp)
+ const dedupKey = copilotShutdownDedupKey(sessionId, model, shutdownTimestamp, journalId)
if (seenKeys.has(dedupKey)) continue
seenKeys.add(dedupKey)
diff --git a/src/session-cache.ts b/src/session-cache.ts
index dfd8a0ac..3cdfad93 100644
--- a/src/session-cache.ts
+++ b/src/session-cache.ts
@@ -285,11 +285,12 @@ export const PROVIDER_PARSE_VERSIONS: Record = {
// source-provenance-v1 (#944): CLI sessions were misread as VS Code
// transcripts (both carry producer 'copilot-agent'), skipping the shutdown
// input/cache rollup; this bump re-parses them so the missing tokens land.
- // rollup-ts-v1 (#1051): shutdown rollup keys use the leg timestamp, not a
- // per-file occurrence index. Present sources must re-parse so old `:n`
- // keys are not union-merged next to the new timestamp keys (double count).
- // Orphans whose source is gone stay via the fingerprint carry-forward.
- copilot: 'cli-shutdown-cost-v1-skills-source-provenance-v1-rollup-ts-v1',
+ // #1051 does NOT bump this again. A fingerprint change drops every present
+ // Copilot source (parser.ts getOrCreateProviderSection) and would erase
+ // conversations already pruned from a still-present OTel DB. Old `:n`
+ // shutdown keys migrate via cachedFileNeedsProviderReparse + a durable
+ // strip of legacy shutdown calls on that JSONL file only.
+ copilot: 'cli-shutdown-cost-v1-skills-source-provenance-v1',
// authoritative-usage-v4: persist one Grok session call from top-level
// authoritative totals, use modelUsage only for priced attribution, clamp
// reasoning per record, and label mixed sessions estimated.
diff --git a/tests/parser.test.ts b/tests/parser.test.ts
index 44f9db73..3a4ace1e 100644
--- a/tests/parser.test.ts
+++ b/tests/parser.test.ts
@@ -14,7 +14,7 @@ import { createRequire } from 'node:module'
import { isSqliteAvailable } from '../src/sqlite.js'
import { clearSessionCache, parseAllSessions, setParseReuseValidator } from '../src/parser.js'
-import { loadCache, saveCache } from '../src/session-cache.js'
+import { computeEnvFingerprint, loadCache, PROVIDER_PARSE_VERSIONS, saveCache } from '../src/session-cache.js'
import { readCacheOnDisk, writeCacheOnDisk } from './fixtures/session-cache-io.js'
import type { SessionSource, SessionParser, ParsedProviderCall } from '../src/providers/types.js'
@@ -530,6 +530,78 @@ describe('(f) durable orphans survive a parse-version bump', () => {
})
})
+describe('(f2) copilot shutdown-key migration does not bump the fingerprint', () => {
+ it('replaces cached :n shutdown keys on the JSONL file without moving the fingerprint', async () => {
+ expect(PROVIDER_PARSE_VERSIONS.copilot).toBe('cli-shutdown-cost-v1-skills-source-provenance-v1')
+
+ const sessionStateDir = join(tmpHome, 'session-state')
+ await mkdir(sessionStateDir, { recursive: true })
+ vi.stubEnv('CODEBURN_COPILOT_SESSION_STATE_DIR', sessionStateDir)
+ vi.stubEnv('CODEBURN_COPILOT_DISABLE_OTEL', '1')
+ vi.stubEnv('CODEBURN_COPILOT_WS_STORAGE_DIR', join(tmpHome, 'no-ws'))
+
+ const dir = join(sessionStateDir, 'sess-migrate')
+ await mkdir(dir, { recursive: true })
+ await writeFile(join(dir, 'workspace.yaml'), 'id: sess-migrate\ncwd: /home/user/testproj\n')
+ const stamp = new Date(Date.now() - 5 * 24 * 60 * 60 * 1000).toISOString()
+ await writeFile(join(dir, 'events.jsonl'), [
+ JSON.stringify({ type: 'session.model_change', timestamp: stamp, data: { newModel: 'claude-sonnet-5' } }),
+ JSON.stringify({ type: 'assistant.message', timestamp: stamp, data: { messageId: 'msg-1', outputTokens: 10, interactionId: 'int-1', toolRequests: [] } }),
+ JSON.stringify({
+ type: 'session.shutdown',
+ timestamp: stamp,
+ data: {
+ shutdownType: 'routine',
+ modelMetrics: {
+ 'claude-sonnet-5': {
+ requests: { count: 1, cost: 1 },
+ usage: { inputTokens: 3000, outputTokens: 10, cacheReadTokens: 1000, cacheWriteTokens: 500, reasoningTokens: 0 },
+ },
+ },
+ },
+ }),
+ ].join('\n') + '\n')
+
+ const first = await parseAllSessions(undefined, 'copilot')
+ const firstKeys = first.flatMap(p => p.sessions).flatMap(s => s.turns).flatMap(t => t.assistantCalls).map(c => c.deduplicationKey)
+ const journalKey = firstKeys.find(k => k.includes(':shutdown:'))
+ expect(journalKey).toMatch(/:events\.jsonl$/)
+ const firstInput = first.flatMap(p => p.sessions).flatMap(s => s.turns).flatMap(t => t.assistantCalls)
+ .filter(c => c.deduplicationKey.includes(':shutdown:'))
+ .reduce((s, c) => s + c.usage.inputTokens, 0)
+ expect(firstInput).toBe(1500)
+
+ const fingerprintBefore = computeEnvFingerprint('copilot')
+ const disk = await readCacheOnDisk()
+ expect(disk.providers['copilot']!.envFingerprint).toBe(fingerprintBefore)
+ const eventsPath = Object.keys(disk.providers['copilot']!.files).find(p => p.endsWith('events.jsonl'))
+ expect(eventsPath).toBeDefined()
+ for (const turn of disk.providers['copilot']!.files[eventsPath!]!.turns) {
+ for (const call of turn.calls) {
+ if (call.deduplicationKey.includes(':shutdown:')) {
+ call.deduplicationKey = 'copilot:sess-migrate:shutdown:claude-sonnet-5:1'
+ }
+ }
+ }
+ await writeCacheOnDisk(disk)
+ clearSessionCache()
+
+ const second = await parseAllSessions(undefined, 'copilot')
+ const secondCalls = second.flatMap(p => p.sessions).flatMap(s => s.turns).flatMap(t => t.assistantCalls)
+ const shutdownKeys = secondCalls.filter(c => c.deduplicationKey.includes(':shutdown:')).map(c => c.deduplicationKey)
+ expect(shutdownKeys).toEqual([journalKey])
+ expect(shutdownKeys.some(k => k.endsWith(':1'))).toBe(false)
+ const secondInput = secondCalls
+ .filter(c => c.deduplicationKey.includes(':shutdown:'))
+ .reduce((s, c) => s + c.usage.inputTokens, 0)
+ expect(secondInput).toBe(1500)
+
+ clearSessionCache()
+ const after = await readCacheOnDisk()
+ expect(after.providers['copilot']!.envFingerprint).toBe(fingerprintBefore)
+ })
+})
+
// ═══════════════════════════════════════════════════════════════════════════
// (g) Skill attribution is independent of turn category
// ═══════════════════════════════════════════════════════════════════════════
diff --git a/tests/providers/copilot.test.ts b/tests/providers/copilot.test.ts
index c4bf1582..ef7b1096 100644
--- a/tests/providers/copilot.test.ts
+++ b/tests/providers/copilot.test.ts
@@ -615,7 +615,7 @@ describe('copilot provider - session.shutdown token/cost rollup', () => {
// One per-turn assistant.message call + one supplementary shutdown call.
expect(calls).toHaveLength(2)
- const shutdown = calls.find(c => c.deduplicationKey === 'copilot:sess-shutdown:shutdown:claude-sonnet-4-5:2026-04-15T10:05:00Z')
+ const shutdown = calls.find(c => c.deduplicationKey === 'copilot:sess-shutdown:shutdown:claude-sonnet-4-5:2026-04-15T10:05:00Z:events.jsonl')
expect(shutdown).toBeDefined()
expect(shutdown!.model).toBe('claude-sonnet-4-5')
expect(shutdown!.inputTokens).toBe(4) // 71282 - 35495 - 35783
@@ -723,9 +723,9 @@ describe('copilot provider - session.shutdown token/cost rollup', () => {
const shutdowns = calls.filter(c => c.deduplicationKey.includes(':shutdown:'))
expect(shutdowns.map(c => c.deduplicationKey)).toEqual([
- 'copilot:sess-resumed:shutdown:claude-sonnet-5:2026-08-01T10:00:00Z',
- 'copilot:sess-resumed:shutdown:claude-sonnet-5:2026-08-02T10:00:00Z',
- 'copilot:sess-resumed:shutdown:claude-sonnet-5:2026-08-03T10:00:00Z',
+ 'copilot:sess-resumed:shutdown:claude-sonnet-5:2026-08-01T10:00:00Z:events.jsonl',
+ 'copilot:sess-resumed:shutdown:claude-sonnet-5:2026-08-02T10:00:00Z:events.jsonl',
+ 'copilot:sess-resumed:shutdown:claude-sonnet-5:2026-08-03T10:00:00Z:events.jsonl',
])
// Each leg lands on its own shutdown timestamp (a resumed session can
// span days; whole-rollup emission would collapse them onto one).
@@ -806,8 +806,8 @@ describe('copilot provider - session.shutdown token/cost rollup', () => {
it('keeps both first legs when one session has two journal files (#1051)', async () => {
// The occurrence counter lived inside each file parse. Two journals for
// the same sessionId both emitted `:1` and the shared seenKeys set
- // dropped the second file's first shutdown. Keying by timestamp (and
- // disambiguating a shared stamp against seenKeys) keeps both legs.
+ // dropped the second file's first shutdown. Keying by timestamp AND the
+ // journal basename keeps both legs, including when the stamps match.
const sessionDir = join(tmpDir, 'sess-two-journals')
await mkdir(sessionDir, { recursive: true })
await writeFile(join(sessionDir, 'workspace.yaml'), 'id: sess-two-journals\ncwd: /home/user/myproject\n')
@@ -838,8 +838,8 @@ describe('copilot provider - session.shutdown token/cost rollup', () => {
const second = await collectCalls({ path: path2, project: 'myproject', provider: 'copilot', sourceType: 'jsonl' }, seen)
const shutdowns = [...first, ...second].filter(c => c.deduplicationKey.includes(':shutdown:'))
expect(shutdowns.map(c => c.deduplicationKey)).toEqual([
- 'copilot:sess-two-journals:shutdown:claude-sonnet-5:2026-08-01T10:00:00Z',
- 'copilot:sess-two-journals:shutdown:claude-sonnet-5:2026-08-02T10:00:00Z',
+ 'copilot:sess-two-journals:shutdown:claude-sonnet-5:2026-08-01T10:00:00Z:events.jsonl',
+ 'copilot:sess-two-journals:shutdown:claude-sonnet-5:2026-08-02T10:00:00Z:events-2.jsonl',
])
expect(shutdowns[0]!.inputTokens).toBe(1500) // 3000 − 1000 − 500
expect(shutdowns[1]!.inputTokens).toBe(1300) // 4000 − 2000 − 700
@@ -847,6 +847,45 @@ describe('copilot provider - session.shutdown token/cost rollup', () => {
expect(shutdowns[1]!.cacheReadInputTokens).toBe(2000)
})
+ it('keeps both first legs when two journals share a shutdown timestamp (#1051)', async () => {
+ const sessionDir = join(tmpDir, 'sess-shared-stamp')
+ await mkdir(sessionDir, { recursive: true })
+ await writeFile(join(sessionDir, 'workspace.yaml'), 'id: sess-shared-stamp\ncwd: /home/user/myproject\n')
+
+ const stamp = '2026-08-01T10:00:00Z'
+ const journal1 = [
+ modelChange('claude-sonnet-5'),
+ assistantMessage({ messageId: 'msg-a', outputTokens: 10 }),
+ shutdownEvent({
+ modelMetrics: { 'claude-sonnet-5': { inputTokens: 3000, outputTokens: 10, cacheReadTokens: 1000, cacheWriteTokens: 500 } },
+ timestamp: stamp,
+ }),
+ ]
+ const journal2 = [
+ modelChange('claude-sonnet-5'),
+ assistantMessage({ messageId: 'msg-b', outputTokens: 20 }),
+ shutdownEvent({
+ modelMetrics: { 'claude-sonnet-5': { inputTokens: 4000, outputTokens: 20, cacheReadTokens: 2000, cacheWriteTokens: 700 } },
+ timestamp: stamp,
+ }),
+ ]
+ const path1 = join(sessionDir, 'events.jsonl')
+ const path2 = join(sessionDir, 'events-2.jsonl')
+ await writeFile(path1, journal1.join('\n') + '\n')
+ await writeFile(path2, journal2.join('\n') + '\n')
+
+ const seen = new Set()
+ const first = await collectCalls({ path: path1, project: 'myproject', provider: 'copilot', sourceType: 'jsonl' }, seen)
+ const second = await collectCalls({ path: path2, project: 'myproject', provider: 'copilot', sourceType: 'jsonl' }, seen)
+ const shutdowns = [...first, ...second].filter(c => c.deduplicationKey.includes(':shutdown:'))
+ expect(shutdowns.map(c => c.deduplicationKey)).toEqual([
+ 'copilot:sess-shared-stamp:shutdown:claude-sonnet-5:2026-08-01T10:00:00Z:events.jsonl',
+ 'copilot:sess-shared-stamp:shutdown:claude-sonnet-5:2026-08-01T10:00:00Z:events-2.jsonl',
+ ])
+ expect(shutdowns[0]!.inputTokens).toBe(1500)
+ expect(shutdowns[1]!.inputTokens).toBe(1300)
+ })
+
it('falls back to the last stamped event when shutdown carries no timestamp at all', async () => {
// A shutdown with neither its own timestamp nor sessionStartTime must not
// yield an empty-timestamp call: the date-range filters in parser.ts drop
@@ -979,7 +1018,7 @@ describe('copilot provider - session.shutdown token/cost rollup', () => {
expect(perTurn.every(c => c.model === 'claude-sonnet-5')).toBe(true)
// The shutdown rollup lands: the tokens the misclassification dropped.
- const shutdown = calls.find(c => c.deduplicationKey === 'copilot:sess-cli-producer:shutdown:claude-sonnet-5:2026-08-07T17:56:40.591Z')
+ const shutdown = calls.find(c => c.deduplicationKey === 'copilot:sess-cli-producer:shutdown:claude-sonnet-5:2026-08-07T17:56:40.591Z:events.jsonl')
expect(shutdown).toBeDefined()
expect(shutdown!.inputTokens).toBe(4) // 49473 − 24678 − 24791 (cache-inclusive)
expect(shutdown!.cacheReadInputTokens).toBe(24678)
@@ -1043,7 +1082,7 @@ describe('copilot provider - session.shutdown token/cost rollup', () => {
const calls: ParsedProviderCall[] = []
for await (const call of provider.createSessionParser(sessions[0]!, new Set()).parse()) calls.push(call)
- const shutdown = calls.find(c => c.deduplicationKey === 'copilot:sess-wire:shutdown:claude-sonnet-5:2026-04-15T10:05:00Z')
+ const shutdown = calls.find(c => c.deduplicationKey === 'copilot:sess-wire:shutdown:claude-sonnet-5:2026-04-15T10:05:00Z:events.jsonl')
expect(shutdown).toBeDefined()
expect(shutdown!.inputTokens).toBe(500) // 5000 − 3000 − 1500
expect(shutdown!.cacheReadInputTokens).toBe(3000)
From c12c1c6267bd2ab51e1fb13b190369348fb62d4f Mon Sep 17 00:00:00 2001
From: Aditya Vikram Singh <247195684+avs-io@users.noreply.github.com>
Date: Fri, 21 Aug 2026 15:58:05 +0530
Subject: [PATCH 05/24] fix(optimize): do not claim a session scan for
non-Claude providers
Maintainer review on #1049: empty-state copy under --provider codex
named detectors that scanSessions never ran. Say the session-scan
detectors do not cover that provider yet. Drop the dead TUI provider
threading; optimize view is still gated to all|claude.
---
src/dashboard.tsx | 33 +++++++++++++++++++++++----------
src/optimize.ts | 9 ++++++---
tests/optimize.test.ts | 6 ++++--
3 files changed, 33 insertions(+), 15 deletions(-)
diff --git a/src/dashboard.tsx b/src/dashboard.tsx
index a804cc94..7eeeaf35 100644
--- a/src/dashboard.tsx
+++ b/src/dashboard.tsx
@@ -10,7 +10,7 @@ import { findUnpricedModels, isExpectedFreeModel, loadPricing } from './models.j
import { aggregateModelTotals } from './model-breakdown.js'
import { buildDurablePeriod } from './usage-aggregator.js'
import { getAllProviders } from './providers/index.js'
-import { classHeaderLine, classTotals, findingBasis, findingClass, optimizeTuiPasteHeader, scanAndDetect, type FindingClass, type WasteFinding, type WasteAction, type OptimizeResult } from './optimize.js'
+import { classHeaderLine, classTotals, findingBasis, findingClass, scanAndDetect, type FindingClass, type WasteFinding, type WasteAction, type OptimizeResult } from './optimize.js'
import { appliedFixGlyph, formatAppliedFix, type AppliedFix } from './act/types.js'
import { aggregateFileChurn, buildCoachingNotes, computePricingCoverage, medianTimeToFirstEditMs, scanUserCorrections, worstOneShotCategory, type ReworkedFile } from './workflow-insights.js'
import { estimateContextBudget, type ContextBudget } from './context-budget.js'
@@ -1033,21 +1033,34 @@ function PeriodTabs({ active, providerName, showProvider }: { active: Period; pr
/// permanent CLAUDE.md rule from a one-time session opener so they don't
/// accidentally bake a single-run constraint into their project's permanent
/// instructions. Issue #277.
-function actionDestinationHeader(action: WasteAction, provider?: string): string {
+function actionDestinationHeader(action: WasteAction): string {
switch (action.type) {
case 'file-content':
return `── Suggested ${action.path} addition `.padEnd(64, '─')
case 'command':
return '── Run this command '.padEnd(64, '─')
case 'paste': {
- return optimizeTuiPasteHeader(action.destination, provider)
+ switch (action.destination) {
+ case 'claude-md':
+ return '── Suggested CLAUDE.md addition (permanent rule) '.padEnd(64, '─')
+ case 'session-opener':
+ return '── One-time session opener (do not add to CLAUDE.md) '.padEnd(64, '─')
+ case 'prompt':
+ return '── Ask Claude in the current session '.padEnd(64, '─')
+ case 'shell-config':
+ return '── Add to your shell config '.padEnd(64, '─')
+ case 'manual':
+ return '── Manual action '.padEnd(64, '─')
+ default:
+ return '── Suggested action '.padEnd(64, '─')
+ }
}
}
}
-function FindingAction({ action, provider }: { action: WasteAction; provider?: string }) {
+function FindingAction({ action }: { action: WasteAction }) {
const lines = action.type === 'file-content' ? action.content.split('\n') : action.type === 'command' ? action.text.split('\n') : [action.text]
- const header = actionDestinationHeader(action, provider)
+ const header = actionDestinationHeader(action)
return (
<>
{header}
@@ -1057,7 +1070,7 @@ function FindingAction({ action, provider }: { action: WasteAction; provider?: s
)
}
-function FindingPanel({ index, finding, costRate, width, provider }: { index: number; finding: WasteFinding; costRate: number; width: number; provider?: string }) {
+function FindingPanel({ index, finding, costRate, width }: { index: number; finding: WasteFinding; costRate: number; width: number }) {
const costSaved = finding.tokensSaved * costRate
const color = IMPACT_PANEL_COLORS[finding.impact] ?? DIM
const label = finding.impact.charAt(0).toUpperCase() + finding.impact.slice(1)
@@ -1073,7 +1086,7 @@ function FindingPanel({ index, finding, costRate, width, provider }: { index: nu
{finding.explanation}
Savings: ~{formatTokens(finding.tokensSaved)} tokens (~{formatCost(costSaved)}) {findingBasis(finding)}
-
+
)
}
@@ -1093,7 +1106,7 @@ const APPLIED_FIX_COLORS: Record = {
pending: DIM,
}
-function OptimizeView({ findings, costRate, projects, label, width, healthScore, healthGrade, cursor, appliedFixes = [], provider }: { findings: WasteFinding[]; costRate: number; projects: ProjectSummary[]; label: string; width: number; healthScore: number; healthGrade: string; cursor: number; appliedFixes?: AppliedFix[]; provider?: string }) {
+function OptimizeView({ findings, costRate, projects, label, width, healthScore, healthGrade, cursor, appliedFixes = [] }: { findings: WasteFinding[]; costRate: number; projects: ProjectSummary[]; label: string; width: number; healthScore: number; healthGrade: string; cursor: number; appliedFixes?: AppliedFix[] }) {
const periodCost = projects.reduce((s, p) => s + p.totalCostUSD, 0)
const totalTokens = findings.reduce((s, f) => s + f.tokensSaved, 0)
const totalCost = totalTokens * costRate
@@ -1127,7 +1140,7 @@ function OptimizeView({ findings, costRate, projects, label, width, healthScore,
return (
{cls !== previous && {classHeaderLine(cls, totals[cls], costRate)}}
-
+
)
})}
@@ -1653,7 +1666,7 @@ export function InteractiveDashboard({ initialProjects, initialDailyHistoryProje
{view === 'compare'
? setView('dashboard')} />
: view === 'optimize' && optimizeResult
- ?
+ ?
: }
{coachingNote && (
diff --git a/src/optimize.ts b/src/optimize.ts
index 1d4a8f31..134424c4 100644
--- a/src/optimize.ts
+++ b/src/optimize.ts
@@ -321,10 +321,13 @@ export function optimizeEmptyScanLines(provider?: string): [string, string, stri
]
}
const copy = optimizeRemediationCopy(provider)
+ // scanSessions is Claude-only. Naming the provider's instruction file here
+ // would claim a scan that did not run (Health A / 100 under --provider
+ // codex with every listed detector claudeOnly-disabled).
return [
- `CodeBurn optimize scans your ${copy.agent} sessions and config for`,
- 'token waste: junk directory reads, duplicate file reads, unused',
- `agents/skills/MCP servers, bloated ${copy.instructionFile}, and more.`,
+ `Session-scan detectors do not cover ${copy.agent} yet.`,
+ 'junk directory reads, duplicate file reads, unused agents/skills/MCP,',
+ 'and bloated instruction files currently scan Claude Code only.',
]
}
diff --git a/tests/optimize.test.ts b/tests/optimize.test.ts
index f42db53e..4c34cb74 100644
--- a/tests/optimize.test.ts
+++ b/tests/optimize.test.ts
@@ -1507,8 +1507,10 @@ describe('provider-scoped remediation copy (#1044)', () => {
expect(optimizePasteHeader('session-opener', copy)).toBe('One-time session opener (do NOT add to AGENTS.md)')
expect(sessionOpenerLabel(copy)).toContain('AGENTS.md')
expect(sessionOpenerLabel(copy)).not.toContain('CLAUDE.md')
- expect(optimizeEmptyScanLines('codex')[0]).toBe('CodeBurn optimize scans your Codex sessions and config for')
- expect(optimizeEmptyScanLines('codex')[2]).toContain('bloated AGENTS.md')
+ expect(optimizeEmptyScanLines('codex')[0]).toBe('Session-scan detectors do not cover Codex yet.')
+ expect(optimizeEmptyScanLines('codex')[2]).toContain('currently scan Claude Code only')
+ expect(optimizeEmptyScanLines('codex').join(' ')).not.toContain('scans your Codex')
+ expect(optimizeEmptyScanLines('codex').join(' ')).not.toContain('bloated AGENTS.md')
expect(optimizeTuiPasteHeader('prompt', 'codex')).toContain('Ask Codex in the current session')
expect(optimizeTuiPasteHeader('session-opener', 'codex')).toContain('do NOT add to AGENTS.md')
expect(optimizeTuiPasteHeader('session-opener', 'codex')).not.toContain('CLAUDE.md')
From 3168699927d617adf46512d63cd3c3a211b2d5a6 Mon Sep 17 00:00:00 2001
From: iamtoruk
Date: Fri, 14 Aug 2026 07:11:19 -0700
Subject: [PATCH 06/24] snap: scope personal-files to log subdirectories
Snap Store review found the declaration requested each tool's entire root
directory. Those roots hold configuration and, in several cases, credentials,
and personal-files read is recursive, so the request granted read of every AI
tool's credential store.
Every path is now the subdirectory the provider actually opens: .claude/projects,
.codex/sessions, .cline/data, .vibe/logs/session, .hermes/profiles, .mux/sessions
and so on; two are single files (.forge/.forge.db, .zcode/cli/db/db.sqlite). The
editor entries name only the extension folders that hold transcripts rather than
the editor's whole configuration. No bare tool root remains.
One credential file is requested openly instead of implicitly: .claude/.credentials.json,
read-only, used to call Anthropic's usage endpoint for the live plan gauge.
Codex's equivalent needs read-write on the Codex CLI's auth.json to rotate the
token, so neither that file nor a Codex root is declared and the Codex live
gauge is disabled under $SNAP. Codex usage and cost analytics are unaffected;
they come from the session rollouts.
Also declares five providers that were missing entirely and would have shown no
data under the snap: opencode, crush, goose, kilo, kimi-code. Drops .lingtai,
whose per-agent directory sits above the log folder and cannot be expressed
without wildcards.
Ports 4fe760aafe0492a674476c1ca64bf0dbdc673dde onto current main; the entries
main gained since that commit are reconciled separately.
---
app/electron/quota/index.test.ts | 26 ++++++++++
app/electron/quota/index.ts | 18 ++++++-
app/package.json | 89 +++++++++++++++++++-------------
3 files changed, 97 insertions(+), 36 deletions(-)
diff --git a/app/electron/quota/index.test.ts b/app/electron/quota/index.test.ts
index 8250c452..7ff48f99 100644
--- a/app/electron/quota/index.test.ts
+++ b/app/electron/quota/index.test.ts
@@ -8,6 +8,32 @@ const quota = (provider: 'claude' | 'codex'): QuotaProvider => ({
})
describe('QuotaService', () => {
+ // The snap declares no Codex credential path, because the live gauge would
+ // need write access to the Codex CLI's own auth.json to rotate the token.
+ // Under $SNAP the Codex fetch must not run at all; Claude is unaffected.
+ it('skips the Codex live gauge under snap confinement', async () => {
+ const previous = process.env['SNAP']
+ process.env['SNAP'] = '/snap/codeburn/current'
+ try {
+ const claude = vi.fn(async () => ({ quota: quota('claude') }))
+ const codex = vi.fn(async () => ({ quota: quota('codex') }))
+ const service = new QuotaService({
+ claude, codex, now: () => Date.parse('2026-08-14T00:00:00Z'),
+ readFile: vi.fn(async () => null),
+ writeFile: vi.fn(async () => {}),
+ statePath: '/mock/backoff.json',
+ })
+ const [claudeQuota, codexQuota] = await service.getQuota({ force: true })
+ expect(codex).not.toHaveBeenCalled()
+ expect(claude).toHaveBeenCalledTimes(1)
+ expect(codexQuota?.connection).toBe('disconnected')
+ expect(claudeQuota?.connection).toBe('connected')
+ } finally {
+ if (previous === undefined) delete process.env['SNAP']
+ else process.env['SNAP'] = previous
+ }
+ })
+
it('persists provider 429 blocked-until and gates the next forced fetch', async () => {
const writes: string[] = []
const claude = vi.fn(async () => ({ quota: quota('claude'), retryAfterSeconds: 60 }))
diff --git a/app/electron/quota/index.ts b/app/electron/quota/index.ts
index a17dac19..1fa0b7e9 100644
--- a/app/electron/quota/index.ts
+++ b/app/electron/quota/index.ts
@@ -37,6 +37,19 @@ function unavailable(provider: ProviderName, connection: QuotaProvider['connecti
return { provider, connection, primary: null, details: [], planLabel: null, footerLines: [] }
}
+/**
+ * The Codex live gauge needs read-write access to the Codex CLI's own
+ * `~/.codex/auth.json`, because refreshing the OAuth grant rotates the token
+ * and writes it back. A store-distributed snap should not hold write access to
+ * another vendor's credential file, so the snap's personal-files declaration
+ * requests neither that file nor a Codex root, and the gauge is disabled here
+ * to match. Codex usage and cost analytics are unaffected: those come from the
+ * session rollouts under `~/.codex/sessions`, which the snap does read.
+ */
+function codexQuotaSupported(): boolean {
+ return !process.env['SNAP']
+}
+
export class QuotaService {
private readonly deps: QuotaDeps
private cache: { at: number; value: QuotaProvider[] } | null = null
@@ -115,7 +128,10 @@ export class QuotaService {
if (this.controllers[provider] === controller) this.controllers[provider] = undefined
return retainOnFailure(result.quota)
}
- const value = await Promise.all([run('claude'), run('codex')])
+ const value = await Promise.all([
+ run('claude'),
+ codexQuotaSupported() ? run('codex') : Promise.resolve(unavailable('codex', 'disconnected')),
+ ])
if (startingGenerations.claude === this.generations.claude && startingGenerations.codex === this.generations.codex) {
this.cache = { at: this.deps.now(), value }
}
diff --git a/app/package.json b/app/package.json
index ab5a29b4..27a06454 100644
--- a/app/package.json
+++ b/app/package.json
@@ -162,42 +162,61 @@
"ai-agent-session-logs": {
"interface": "personal-files",
"read": [
- "$HOME/.claude",
- "$HOME/.cline",
- "$HOME/.codewhale",
- "$HOME/.codex",
- "$HOME/.copilot",
- "$HOME/.cursor",
- "$HOME/.deepseek",
- "$HOME/.dsh/sessions",
- "$HOME/.factory",
- "$HOME/.forge",
- "$HOME/.gemini",
- "$HOME/.grok",
- "$HOME/.hermes",
- "$HOME/.kimi",
- "$HOME/.kiro",
- "$HOME/.kiro-server",
- "$HOME/.lingtai",
- "$HOME/.lingtai-tui",
- "$HOME/.mux",
- "$HOME/.omp",
- "$HOME/.openclaude",
- "$HOME/.pi",
- "$HOME/.quickwork",
- "$HOME/.qwen",
- "$HOME/.vibe",
- "$HOME/.zcode",
- "$HOME/.config/Claude",
- "$HOME/.config/Code",
- "$HOME/.config/Code - Insiders",
- "$HOME/.config/Cursor",
- "$HOME/.config/Kiro",
- "$HOME/.config/Open Design",
- "$HOME/.config/VSCodium",
+ "$HOME/.claude/projects",
+ "$HOME/.claude/.credentials.json",
+ "$HOME/.codex/sessions",
+ "$HOME/.codex/archived_sessions",
+ "$HOME/.cline/data",
+ "$HOME/.codewhale/sessions",
+ "$HOME/.copilot/session-state",
+ "$HOME/.cursor/projects",
+ "$HOME/.cursor/ai-tracking",
+ "$HOME/.deepseek/sessions",
+ "$HOME/.factory/sessions",
+ "$HOME/.gemini/tmp",
+ "$HOME/.gemini/antigravity",
+ "$HOME/.gemini/antigravity-cli",
+ "$HOME/.gemini/antigravity-ide",
+ "$HOME/.grok/sessions",
+ "$HOME/.hermes/profiles",
+ "$HOME/.hermes/state.db",
+ "$HOME/.kimi/sessions",
+ "$HOME/.kimi-code/sessions",
+ "$HOME/.mux/sessions",
+ "$HOME/.mux/config.json",
+ "$HOME/.omp/agent",
+ "$HOME/.openclaude/projects",
+ "$HOME/.openclaw/agents",
+ "$HOME/.pi/agent",
+ "$HOME/.qwen/projects",
+ "$HOME/.vibe/logs/session",
+ "$HOME/.forge/.forge.db",
+ "$HOME/.zcode/cli/db/db.sqlite",
+ "$HOME/.config/Code/User/globalStorage/saoudrizwan.claude-dev",
+ "$HOME/.config/Code/User/globalStorage/rooveterinaryinc.roo-cline",
+ "$HOME/.config/Code/User/globalStorage/kilocode.kilo-code",
+ "$HOME/.config/Code/User/globalStorage/GitHub.copilot-chat",
+ "$HOME/.config/Code/User/globalStorage/emptyWindowChatSessions",
+ "$HOME/.config/Code/User/workspaceStorage",
+ "$HOME/.config/Code - Insiders/User/globalStorage/saoudrizwan.claude-dev",
+ "$HOME/.config/Code - Insiders/User/globalStorage/rooveterinaryinc.roo-cline",
+ "$HOME/.config/Code - Insiders/User/globalStorage/kilocode.kilo-code",
+ "$HOME/.config/Code - Insiders/User/workspaceStorage",
+ "$HOME/.config/VSCodium/User/globalStorage/saoudrizwan.claude-dev",
+ "$HOME/.config/VSCodium/User/globalStorage/rooveterinaryinc.roo-cline",
+ "$HOME/.config/VSCodium/User/globalStorage/kilocode.kilo-code",
+ "$HOME/.config/VSCodium/User/workspaceStorage",
+ "$HOME/.config/Cursor/User/globalStorage/state.vscdb",
+ "$HOME/.config/Kiro/User/globalStorage/kiro.kiroagent",
+ "$HOME/.config/Kiro/User/workspaceStorage",
+ "$HOME/.kiro-server/data/User/globalStorage/kiro.kiroagent",
"$HOME/.config/github-copilot",
- "$HOME/.config/manicode",
- "$HOME/.local/share/zed"
+ "$HOME/.config/manicode/projects",
+ "$HOME/.local/share/zed/threads",
+ "$HOME/.local/share/opencode",
+ "$HOME/.local/share/crush",
+ "$HOME/.local/share/goose",
+ "$HOME/.local/share/kilo"
]
}
}
From c73d8ff6c86f85d25101bb2d37fd900c8b404463 Mon Sep 17 00:00:00 2001
From: iamtoruk
Date: Fri, 21 Aug 2026 03:42:09 -0700
Subject: [PATCH 07/24] snap: scope the entries main gained after the
tightening
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The ported declaration was written against main as of c9e6e2ec. Main has since
added dsh and carries five entries that commit never saw, all of them still bare
tool roots, so the port dropped them rather than reintroduce what the store
rejected. Each is restored at the path its provider opens:
.dsh/sessions dsh.ts reads /sessions only
.kiro/sessions CLI store at sessions/cli, v2 IDE store
at sessions/ — siblings
.quickwork/{profiles.json,sessions,metrics}
profiles.json names the profile bases;
the legacy layout is sessions/sessions.db
plus metrics/
.config/Claude/local-agent-mode-sessions
Claude Desktop's local-agent-mode store
.config/Open Design/{runs,data/runs,namespaces}
the three discovery roots open-design.ts
probes under its data dir
.lingtai and .lingtai-tui stay dropped. A LingTai ledger lives at
.lingtai//logs/token_ledger.jsonl, and personal-files has no wildcard for
the agent segment; .lingtai-tui only exists to enumerate project homes that could
not be read anyway. Goose is narrowed to .local/share/goose/sessions, which holds
the only file it opens.
Four entries stay whole roots because the provider reads a file sitting directly
in the root: .config/github-copilot (JetBrains stores nest under a variable
//) and .local/share/{opencode,crush,kilo}. The new
app/scripts/snap-grants.test.ts asserts every other entry is at least one level
below its tool root, so a bare root cannot come back unnoticed.
---
CHANGELOG.md | 3 +++
app/package.json | 11 +++++++-
app/scripts/snap-grants.test.ts | 45 +++++++++++++++++++++++++++++++++
3 files changed, 58 insertions(+), 1 deletion(-)
create mode 100644 app/scripts/snap-grants.test.ts
diff --git a/CHANGELOG.md b/CHANGELOG.md
index ee7f1562..407cffca 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -29,6 +29,9 @@
- **A date-ranged report classifies only the turns it keeps.** Every cached turn went through the turn classifier — category, retries, edit detection, and a full reconstruction of its API calls — before the date slice discarded most of them, so a week view paid to classify all of history to keep a few percent of it. The keep/drop decision is now taken on the raw cached turn and only the survivors are classified, still from their complete call list, with the branch and pull-request carries still walking the full ordered turn list. Output is byte-identical.
- **One rule for every cache file.** `CODEBURN_CACHE_DIR` when set, otherwise `~/.cache/codeburn`. `XDG_CACHE_HOME` is no longer consulted; the sync ledger, the only file that ever honored it, is merged into the canonical location on first read and the legacy copy is retired, so nothing is re-uploaded after the move. (#972)
+### Changed (Linux packaging)
+- **The snap asks for the log directories it reads, not each tool's whole home.** The first Snap Store submission declared a `personal-files` read of every AI tool's root — `$HOME/.claude`, `$HOME/.codex`, `$HOME/.cursor` and the rest — and that interface is recursive, so it granted read of every credential file those roots hold. Each entry now names the subdirectory the provider actually opens (`.claude/projects`, `.codex/sessions`, `.cline/data`, `.vibe/logs/session`, `.dsh/sessions`, `.kiro/sessions`, `.quickwork/{profiles.json,sessions,metrics}`, `.config/Claude/local-agent-mode-sessions`, `.config/Open Design/{runs,data/runs,namespaces}`), two are single files (`.forge/.forge.db`, `.zcode/cli/db/db.sqlite`), and the editor entries name only the extension folders holding transcripts instead of the editor's whole configuration. Five providers that were missing entirely and would have shown no data are declared — opencode, crush, goose, kilo, kimi-code — and four roots stay roots only because the file the provider opens sits directly in them (`.config/github-copilot`, `.local/share/{opencode,crush,kilo}`). One credential file is now requested openly rather than implicitly: `.claude/.credentials.json`, read-only, for the live plan gauge. Codex's equivalent would need write access to the Codex CLI's own `auth.json` to rotate the token, so neither it nor a Codex root is declared and the Codex live gauge is disabled under `$SNAP`; Codex usage and cost are unaffected, they come from the session rollouts. Two consequences inside the snap: `.lingtai` is dropped, because its per-agent log directory needs a wildcard the interface has no form for, and `optimize`, `context-budget` and `act` no longer see the user-scope `~/.claude/settings.json`, `agents/`, `skills/` and `commands/` — project-scope copies still work through the `home` plug. Nothing outside the snap changes.
+
### Fixed (Desktop & Menubar)
- **The menubar's copies of your Claude and Codex credentials move out of Application Support and into the login Keychain.** Connecting a provider used to leave the copied OAuth material in `~/Library/Application Support/CodeBurn/*-credentials.v1.json`, written world-readable (0644) because macOS ignores `.completeFileProtection` outside iOS. The copy now lives in a CodeBurn-owned login-Keychain item, and the first read after upgrading migrates the old file: it is reopened with `O_NOFOLLOW`, refused if it is a symlink or not owned by you, repaired to 0600 before a single secret byte is read, written to the Keychain, read back and compared, and only then unlinked — a failed or unverified write leaves the (now 0600) file in place so a retry can still find it, and the next read retries the cleanup. Where both a Keychain item and an old file exist, the one that expires later wins before anything is removed, so an item left behind by a much older build cannot displace a fresher token. Claude's entry no longer stores a refresh token at all — the CLI owns that grant and the menubar never spends it — and any refresh token in a historical blob is dropped on read. Disconnect only reports success once the material is actually gone; if the delete fails it says so and leaves the provider connected so you can retry. Keychain reads are non-interactive and are skipped outright while the login Keychain is locked, so a background quota refresh can never raise an unlock panel. (#1037)
- **First launch no longer asks to control System Events.** The macOS menubar registered its login item by driving System Events over AppleScript, which made macOS put up an Automation consent dialog the first time the app ran. It now registers itself through `SMAppService.mainApp`, an in-process call that needs no Automation grant; there is no AppleScript fallback, so a failure logs and leaves the login item unset rather than bringing the prompt back. The same `codeburn.loginItemRegistered` guard still limits this to the first launch, so a login item you removed by hand stays removed. (#1026)
diff --git a/app/package.json b/app/package.json
index 27a06454..1c459862 100644
--- a/app/package.json
+++ b/app/package.json
@@ -172,6 +172,7 @@
"$HOME/.cursor/projects",
"$HOME/.cursor/ai-tracking",
"$HOME/.deepseek/sessions",
+ "$HOME/.dsh/sessions",
"$HOME/.factory/sessions",
"$HOME/.gemini/tmp",
"$HOME/.gemini/antigravity",
@@ -182,6 +183,7 @@
"$HOME/.hermes/state.db",
"$HOME/.kimi/sessions",
"$HOME/.kimi-code/sessions",
+ "$HOME/.kiro/sessions",
"$HOME/.mux/sessions",
"$HOME/.mux/config.json",
"$HOME/.omp/agent",
@@ -189,9 +191,13 @@
"$HOME/.openclaw/agents",
"$HOME/.pi/agent",
"$HOME/.qwen/projects",
+ "$HOME/.quickwork/profiles.json",
+ "$HOME/.quickwork/sessions",
+ "$HOME/.quickwork/metrics",
"$HOME/.vibe/logs/session",
"$HOME/.forge/.forge.db",
"$HOME/.zcode/cli/db/db.sqlite",
+ "$HOME/.config/Claude/local-agent-mode-sessions",
"$HOME/.config/Code/User/globalStorage/saoudrizwan.claude-dev",
"$HOME/.config/Code/User/globalStorage/rooveterinaryinc.roo-cline",
"$HOME/.config/Code/User/globalStorage/kilocode.kilo-code",
@@ -209,13 +215,16 @@
"$HOME/.config/Cursor/User/globalStorage/state.vscdb",
"$HOME/.config/Kiro/User/globalStorage/kiro.kiroagent",
"$HOME/.config/Kiro/User/workspaceStorage",
+ "$HOME/.config/Open Design/runs",
+ "$HOME/.config/Open Design/data/runs",
+ "$HOME/.config/Open Design/namespaces",
"$HOME/.kiro-server/data/User/globalStorage/kiro.kiroagent",
"$HOME/.config/github-copilot",
"$HOME/.config/manicode/projects",
"$HOME/.local/share/zed/threads",
"$HOME/.local/share/opencode",
"$HOME/.local/share/crush",
- "$HOME/.local/share/goose",
+ "$HOME/.local/share/goose/sessions",
"$HOME/.local/share/kilo"
]
}
diff --git a/app/scripts/snap-grants.test.ts b/app/scripts/snap-grants.test.ts
new file mode 100644
index 00000000..c6f92add
--- /dev/null
+++ b/app/scripts/snap-grants.test.ts
@@ -0,0 +1,45 @@
+import { describe, it, expect } from 'vitest'
+import { readFileSync } from 'fs'
+import { join } from 'path'
+
+// Snap Store review rejected the first submission because every entry was a
+// tool's whole root, and personal-files read is recursive: granting $HOME/.claude
+// granted .claude/.credentials.json with it. Each entry must name the log
+// directory (or file) the provider actually opens, never the root above it.
+// The exceptions below are roots only because the provider reads a file sitting
+// directly in them, so no narrower path exists without wildcards.
+const ROOT_GRANTS_WITH_NO_NARROWER_FORM = new Set([
+ '$HOME/.config/github-copilot', // JetBrains stores nest under a variable //
+ '$HOME/.local/share/opencode', // opencode*.db sits in the data dir itself
+ '$HOME/.local/share/crush', // projects.json sits in the data dir itself
+ '$HOME/.local/share/kilo', // kilo*.db sits in the data dir itself
+])
+
+const XDG_PARENTS = ['.config', '.local']
+
+function readGrants(): string[] {
+ const pkg = JSON.parse(readFileSync(join(__dirname, '..', 'package.json'), 'utf8'))
+ const plug = pkg.build.snap.plugs.find((p: unknown) => typeof p === 'object')
+ return plug['ai-agent-session-logs'].read
+}
+
+describe('snap personal-files declaration', () => {
+ it('names a log path under each tool root, never the root itself', () => {
+ const bare: string[] = []
+ for (const entry of readGrants()) {
+ if (ROOT_GRANTS_WITH_NO_NARROWER_FORM.has(entry)) continue
+ const segments = entry.replace('$HOME/', '').split('/')
+ const depth = XDG_PARENTS.includes(segments[0] ?? '') ? 3 : 2
+ if (segments.length < depth) bare.push(entry)
+ }
+ expect(bare).toEqual([])
+ })
+
+ it('requests read only, and one credential file explicitly', () => {
+ const pkg = JSON.parse(readFileSync(join(__dirname, '..', 'package.json'), 'utf8'))
+ const plug = pkg.build.snap.plugs.find((p: unknown) => typeof p === 'object')['ai-agent-session-logs']
+ expect(Object.keys(plug).sort()).toEqual(['interface', 'read'])
+ expect(readGrants().filter(e => e.includes('credential') || e.includes('auth.json')))
+ .toEqual(['$HOME/.claude/.credentials.json'])
+ })
+})
From 9c8727d94fef5ab6e499b465e3727c4c0f8f606a Mon Sep 17 00:00:00 2001
From: Aditya Vikram Singh <247195684+avs-io@users.noreply.github.com>
Date: Fri, 21 Aug 2026 18:04:28 +0530
Subject: [PATCH 08/24] fix(copilot): prefer lastEventTimestamp for stampless
shutdown calls
Maintainer review on #1054: two-journal collision is unreachable
through discovery. Keying by timestamp+journal collapsed a 3-leg
stampless journal onto one row, and the :n strip re-sent ledger
keys. Keep occurrence keys. Take lastEventTimestamp before
sessionStartTime for the call timestamp only. No migration.
---
src/parser.ts | 31 --------
src/providers/copilot.ts | 37 ++++-----
tests/parser.test.ts | 72 ------------------
tests/providers/copilot.test.ts | 129 ++++++++++++--------------------
4 files changed, 62 insertions(+), 207 deletions(-)
diff --git a/src/parser.ts b/src/parser.ts
index 4992aec8..481f6d93 100644
--- a/src/parser.ts
+++ b/src/parser.ts
@@ -2822,24 +2822,6 @@ function getOrCreateProviderSection(cache: SessionCache, provider: string): Prov
return section
}
-function isLegacyCopilotShutdownKey(key: string, journalId: string): boolean {
- return key.includes(':shutdown:') && !key.endsWith(`:${journalId}`)
-}
-
-// Drop occurrence-index / timestamp-only shutdown calls from one JSONL cache
-// entry so the durable union does not keep `:n` next to the new journal-keyed
-// calls (double count). OTel / JetBrains / transcript keys do not include
-// `:shutdown:` and are left alone.
-function stripLegacyCopilotShutdownCalls(entry: CachedFile, journalId: string): void {
- const next: CachedTurn[] = []
- for (const turn of entry.turns) {
- const calls = turn.calls.filter(call => !isLegacyCopilotShutdownKey(call.deduplicationKey, journalId))
- if (calls.length === 0) continue
- next.push(calls.length === turn.calls.length ? turn : { ...turn, calls })
- }
- entry.turns = next
-}
-
function cachedFileNeedsProviderReparse(providerName: string, sourcePath: string, cached: CachedFile): boolean {
// Antigravity data comes from the live server, not from the conversation file.
// A 0-turn cache entry may just mean the server was unavailable last run.
@@ -2850,16 +2832,6 @@ function cachedFileNeedsProviderReparse(providerName: string, sourcePath: string
// title, model, and timestamp changes.
if (providerName === 'devin') return true
- // #1051: migrate old Copilot CLI shutdown keys (`:n` or timestamp-only)
- // without a provider-wide fingerprint bump. A bump would drop the present
- // OTel DB source and erase conversations already pruned from that DB.
- if (providerName === 'copilot') {
- const journalId = basename(sourcePath)
- return cached.turns.some(turn =>
- turn.calls.some(call => isLegacyCopilotShutdownKey(call.deduplicationKey, journalId)),
- )
- }
-
if (providerName !== 'gemini') return false
return cached.turns.some(turn =>
@@ -3236,9 +3208,6 @@ async function parseProviderSources(
if (provider.durableSources) {
const existingEntry = section.files[source.path]
if (existingEntry) {
- if (providerName === 'copilot') {
- stripLegacyCopilotShutdownCalls(existingEntry, basename(source.path))
- }
const existingKeys = new Set(
existingEntry.turns.flatMap(t => t.calls.map(c => c.deduplicationKey))
)
diff --git a/src/providers/copilot.ts b/src/providers/copilot.ts
index bc2f0906..10447cd7 100644
--- a/src/providers/copilot.ts
+++ b/src/providers/copilot.ts
@@ -703,21 +703,6 @@ function inferTranscriptModel(lines: string[]): string {
// token counts and no session.shutdown rollup)
// ---------------------------------------------------------------------------
-// Shutdown rollup identity is (session, model, shutdown timestamp, journal
-// basename), not a per-file occurrence index. Two journals for one session
-// (resume into a second events.jsonl) both started their local counter at 1,
-// so `:1` collided and the second file's first leg was dropped (#1051).
-// Timestamp alone is not unique across journals: two files can share a stamp.
-// The journal basename is re-parse-stable; do not mint suffixes from seenKeys.
-function copilotShutdownDedupKey(
- sessionId: string,
- model: string,
- shutdownTimestamp: string,
- journalId: string,
-): string {
- return `copilot:${sessionId}:shutdown:${model}:${shutdownTimestamp || 'untimestamped'}:${journalId}`
-}
-
/**
* `isTranscript` comes from discovery (where the file lives), never from
* content: the Copilot CLI writes the same session.start producer
@@ -773,12 +758,12 @@ function createJsonlParser(
// A resumed session appends one session.shutdown PER LEG, each carrying
// CUMULATIVE per-model totals. Emitting each rollup whole would need the
// cache to update a prior call in place — the durable merge is
- // append-only by dedup key — so we emit per-leg DELTAS keyed by the
- // shutdown timestamp plus this journal's basename: re-parses of a
- // growing file append only the new leg, and two journals for one
- // session cannot collide on `:1` or on a shared timestamp.
- const journalId = basename(source.path)
+ // append-only by dedup key — so we emit per-leg DELTAS keyed by
+ // occurrence (`:n`): re-parses of a growing file append only the new
+ // leg, and each leg lands on its own timestamp. Discovery only yields
+ // `/events.jsonl`, so two journals cannot share a session id.
const prevShutdownUsage = new Map()
+ const shutdownCountByModel = new Map()
for (const line of lines) {
let event: CopilotEvent
@@ -857,8 +842,14 @@ function createJsonlParser(
const modelMetrics = shutdownData.modelMetrics
if (!isRecord(modelMetrics)) continue
+ // Prefer lastEventTimestamp over sessionStartTime. sessionStartTime
+ // is identical for every stampless leg of a resumed session, so
+ // using it for the call timestamp (or, previously, the key) collapsed
+ // those legs onto one date. lastEventTimestamp is the last stamped
+ // event in this journal — distinct per leg when intervening events
+ // are stamped, and still a real time when they are not.
const shutdownTimestamp =
- (event.timestamp ?? '') || timestampToISO(shutdownData.sessionStartTime) || lastEventTimestamp
+ (event.timestamp ?? '') || lastEventTimestamp || timestampToISO(shutdownData.sessionStartTime)
for (const [model, metrics] of Object.entries(modelMetrics)) {
if (!model || !isRecord(metrics)) continue
@@ -874,6 +865,8 @@ function createJsonlParser(
}
const prevRaw = prevShutdownUsage.get(model)
prevShutdownUsage.set(model, cumulative)
+ const n = (shutdownCountByModel.get(model) ?? 0) + 1
+ shutdownCountByModel.set(model, n)
// A cumulative total BELOW the previous rollup means the CLI reset
// its counters (a fresh accounting epoch): delta from zero, else
@@ -905,7 +898,7 @@ function createJsonlParser(
// to avoid an empty $0 row (output is intentionally excluded).
if (inputTokens === 0 && cacheReadTokens === 0 && cacheWriteTokens === 0 && reasoningTokens === 0) continue
- const dedupKey = copilotShutdownDedupKey(sessionId, model, shutdownTimestamp, journalId)
+ const dedupKey = `copilot:${sessionId}:shutdown:${model}:${n}`
if (seenKeys.has(dedupKey)) continue
seenKeys.add(dedupKey)
diff --git a/tests/parser.test.ts b/tests/parser.test.ts
index 3a4ace1e..8bb73986 100644
--- a/tests/parser.test.ts
+++ b/tests/parser.test.ts
@@ -530,78 +530,6 @@ describe('(f) durable orphans survive a parse-version bump', () => {
})
})
-describe('(f2) copilot shutdown-key migration does not bump the fingerprint', () => {
- it('replaces cached :n shutdown keys on the JSONL file without moving the fingerprint', async () => {
- expect(PROVIDER_PARSE_VERSIONS.copilot).toBe('cli-shutdown-cost-v1-skills-source-provenance-v1')
-
- const sessionStateDir = join(tmpHome, 'session-state')
- await mkdir(sessionStateDir, { recursive: true })
- vi.stubEnv('CODEBURN_COPILOT_SESSION_STATE_DIR', sessionStateDir)
- vi.stubEnv('CODEBURN_COPILOT_DISABLE_OTEL', '1')
- vi.stubEnv('CODEBURN_COPILOT_WS_STORAGE_DIR', join(tmpHome, 'no-ws'))
-
- const dir = join(sessionStateDir, 'sess-migrate')
- await mkdir(dir, { recursive: true })
- await writeFile(join(dir, 'workspace.yaml'), 'id: sess-migrate\ncwd: /home/user/testproj\n')
- const stamp = new Date(Date.now() - 5 * 24 * 60 * 60 * 1000).toISOString()
- await writeFile(join(dir, 'events.jsonl'), [
- JSON.stringify({ type: 'session.model_change', timestamp: stamp, data: { newModel: 'claude-sonnet-5' } }),
- JSON.stringify({ type: 'assistant.message', timestamp: stamp, data: { messageId: 'msg-1', outputTokens: 10, interactionId: 'int-1', toolRequests: [] } }),
- JSON.stringify({
- type: 'session.shutdown',
- timestamp: stamp,
- data: {
- shutdownType: 'routine',
- modelMetrics: {
- 'claude-sonnet-5': {
- requests: { count: 1, cost: 1 },
- usage: { inputTokens: 3000, outputTokens: 10, cacheReadTokens: 1000, cacheWriteTokens: 500, reasoningTokens: 0 },
- },
- },
- },
- }),
- ].join('\n') + '\n')
-
- const first = await parseAllSessions(undefined, 'copilot')
- const firstKeys = first.flatMap(p => p.sessions).flatMap(s => s.turns).flatMap(t => t.assistantCalls).map(c => c.deduplicationKey)
- const journalKey = firstKeys.find(k => k.includes(':shutdown:'))
- expect(journalKey).toMatch(/:events\.jsonl$/)
- const firstInput = first.flatMap(p => p.sessions).flatMap(s => s.turns).flatMap(t => t.assistantCalls)
- .filter(c => c.deduplicationKey.includes(':shutdown:'))
- .reduce((s, c) => s + c.usage.inputTokens, 0)
- expect(firstInput).toBe(1500)
-
- const fingerprintBefore = computeEnvFingerprint('copilot')
- const disk = await readCacheOnDisk()
- expect(disk.providers['copilot']!.envFingerprint).toBe(fingerprintBefore)
- const eventsPath = Object.keys(disk.providers['copilot']!.files).find(p => p.endsWith('events.jsonl'))
- expect(eventsPath).toBeDefined()
- for (const turn of disk.providers['copilot']!.files[eventsPath!]!.turns) {
- for (const call of turn.calls) {
- if (call.deduplicationKey.includes(':shutdown:')) {
- call.deduplicationKey = 'copilot:sess-migrate:shutdown:claude-sonnet-5:1'
- }
- }
- }
- await writeCacheOnDisk(disk)
- clearSessionCache()
-
- const second = await parseAllSessions(undefined, 'copilot')
- const secondCalls = second.flatMap(p => p.sessions).flatMap(s => s.turns).flatMap(t => t.assistantCalls)
- const shutdownKeys = secondCalls.filter(c => c.deduplicationKey.includes(':shutdown:')).map(c => c.deduplicationKey)
- expect(shutdownKeys).toEqual([journalKey])
- expect(shutdownKeys.some(k => k.endsWith(':1'))).toBe(false)
- const secondInput = secondCalls
- .filter(c => c.deduplicationKey.includes(':shutdown:'))
- .reduce((s, c) => s + c.usage.inputTokens, 0)
- expect(secondInput).toBe(1500)
-
- clearSessionCache()
- const after = await readCacheOnDisk()
- expect(after.providers['copilot']!.envFingerprint).toBe(fingerprintBefore)
- })
-})
-
// ═══════════════════════════════════════════════════════════════════════════
// (g) Skill attribution is independent of turn category
// ═══════════════════════════════════════════════════════════════════════════
diff --git a/tests/providers/copilot.test.ts b/tests/providers/copilot.test.ts
index ef7b1096..d145a3f2 100644
--- a/tests/providers/copilot.test.ts
+++ b/tests/providers/copilot.test.ts
@@ -615,7 +615,7 @@ describe('copilot provider - session.shutdown token/cost rollup', () => {
// One per-turn assistant.message call + one supplementary shutdown call.
expect(calls).toHaveLength(2)
- const shutdown = calls.find(c => c.deduplicationKey === 'copilot:sess-shutdown:shutdown:claude-sonnet-4-5:2026-04-15T10:05:00Z:events.jsonl')
+ const shutdown = calls.find(c => c.deduplicationKey === 'copilot:sess-shutdown:shutdown:claude-sonnet-4-5:1')
expect(shutdown).toBeDefined()
expect(shutdown!.model).toBe('claude-sonnet-4-5')
expect(shutdown!.inputTokens).toBe(4) // 71282 - 35495 - 35783
@@ -723,9 +723,9 @@ describe('copilot provider - session.shutdown token/cost rollup', () => {
const shutdowns = calls.filter(c => c.deduplicationKey.includes(':shutdown:'))
expect(shutdowns.map(c => c.deduplicationKey)).toEqual([
- 'copilot:sess-resumed:shutdown:claude-sonnet-5:2026-08-01T10:00:00Z:events.jsonl',
- 'copilot:sess-resumed:shutdown:claude-sonnet-5:2026-08-02T10:00:00Z:events.jsonl',
- 'copilot:sess-resumed:shutdown:claude-sonnet-5:2026-08-03T10:00:00Z:events.jsonl',
+ 'copilot:sess-resumed:shutdown:claude-sonnet-5:1',
+ 'copilot:sess-resumed:shutdown:claude-sonnet-5:2',
+ 'copilot:sess-resumed:shutdown:claude-sonnet-5:3',
])
// Each leg lands on its own shutdown timestamp (a resumed session can
// span days; whole-rollup emission would collapse them onto one).
@@ -803,87 +803,52 @@ describe('copilot provider - session.shutdown token/cost rollup', () => {
expect(second).toHaveLength(0)
})
- it('keeps both first legs when one session has two journal files (#1051)', async () => {
- // The occurrence counter lived inside each file parse. Two journals for
- // the same sessionId both emitted `:1` and the shared seenKeys set
- // dropped the second file's first shutdown. Keying by timestamp AND the
- // journal basename keeps both legs, including when the stamps match.
- const sessionDir = join(tmpDir, 'sess-two-journals')
- await mkdir(sessionDir, { recursive: true })
- await writeFile(join(sessionDir, 'workspace.yaml'), 'id: sess-two-journals\ncwd: /home/user/myproject\n')
-
- const journal1 = [
+ it('keeps three stampless shutdown legs as :n keys with lastEventTimestamp, not sessionStartTime', async () => {
+ // sessionStartTime is identical on every leg. Putting it in the key (or
+ // preferring it over lastEventTimestamp for the call timestamp) collapses
+ // a 3-leg journal onto one row. Discovery only yields events.jsonl, so
+ // two-journal fixtures are unreachable; this is the reachable class.
+ const lastEvent = '2026-08-01T10:00:15Z'
+ const sessionStartTime = 1784102040274
+ const stamplessShutdown = (usage: {
+ inputTokens: number
+ outputTokens: number
+ cacheReadTokens: number
+ cacheWriteTokens: number
+ }) => JSON.stringify({
+ type: 'session.shutdown',
+ data: {
+ shutdownType: 'routine',
+ sessionStartTime,
+ modelMetrics: {
+ 'claude-sonnet-5': {
+ requests: { count: 1, cost: 1 },
+ usage: { ...usage, reasoningTokens: 0 },
+ },
+ },
+ },
+ })
+ const eventsPath = await createSessionDir('sess-stampless', [
modelChange('claude-sonnet-5'),
- assistantMessage({ messageId: 'msg-a', outputTokens: 10 }),
- shutdownEvent({
- modelMetrics: { 'claude-sonnet-5': { inputTokens: 3000, outputTokens: 10, cacheReadTokens: 1000, cacheWriteTokens: 500 } },
- timestamp: '2026-08-01T10:00:00Z',
- }),
- ]
- const journal2 = [
- modelChange('claude-sonnet-5'),
- assistantMessage({ messageId: 'msg-b', outputTokens: 20 }),
- shutdownEvent({
- modelMetrics: { 'claude-sonnet-5': { inputTokens: 4000, outputTokens: 20, cacheReadTokens: 2000, cacheWriteTokens: 700 } },
- timestamp: '2026-08-02T10:00:00Z',
- }),
- ]
- const path1 = join(sessionDir, 'events.jsonl')
- const path2 = join(sessionDir, 'events-2.jsonl')
- await writeFile(path1, journal1.join('\n') + '\n')
- await writeFile(path2, journal2.join('\n') + '\n')
-
- const seen = new Set()
- const first = await collectCalls({ path: path1, project: 'myproject', provider: 'copilot', sourceType: 'jsonl' }, seen)
- const second = await collectCalls({ path: path2, project: 'myproject', provider: 'copilot', sourceType: 'jsonl' }, seen)
- const shutdowns = [...first, ...second].filter(c => c.deduplicationKey.includes(':shutdown:'))
- expect(shutdowns.map(c => c.deduplicationKey)).toEqual([
- 'copilot:sess-two-journals:shutdown:claude-sonnet-5:2026-08-01T10:00:00Z:events.jsonl',
- 'copilot:sess-two-journals:shutdown:claude-sonnet-5:2026-08-02T10:00:00Z:events-2.jsonl',
+ assistantMessage({ messageId: 'msg-1', outputTokens: 10, timestamp: lastEvent }),
+ stamplessShutdown({ inputTokens: 3000, outputTokens: 10, cacheReadTokens: 1000, cacheWriteTokens: 500 }),
+ stamplessShutdown({ inputTokens: 7000, outputTokens: 20, cacheReadTokens: 3000, cacheWriteTokens: 1000 }),
+ stamplessShutdown({ inputTokens: 10000, outputTokens: 30, cacheReadTokens: 5000, cacheWriteTokens: 1500 }),
])
- expect(shutdowns[0]!.inputTokens).toBe(1500) // 3000 − 1000 − 500
- expect(shutdowns[1]!.inputTokens).toBe(1300) // 4000 − 2000 − 700
- expect(shutdowns[0]!.cacheReadInputTokens).toBe(1000)
- expect(shutdowns[1]!.cacheReadInputTokens).toBe(2000)
- })
-
- it('keeps both first legs when two journals share a shutdown timestamp (#1051)', async () => {
- const sessionDir = join(tmpDir, 'sess-shared-stamp')
- await mkdir(sessionDir, { recursive: true })
- await writeFile(join(sessionDir, 'workspace.yaml'), 'id: sess-shared-stamp\ncwd: /home/user/myproject\n')
-
- const stamp = '2026-08-01T10:00:00Z'
- const journal1 = [
- modelChange('claude-sonnet-5'),
- assistantMessage({ messageId: 'msg-a', outputTokens: 10 }),
- shutdownEvent({
- modelMetrics: { 'claude-sonnet-5': { inputTokens: 3000, outputTokens: 10, cacheReadTokens: 1000, cacheWriteTokens: 500 } },
- timestamp: stamp,
- }),
- ]
- const journal2 = [
- modelChange('claude-sonnet-5'),
- assistantMessage({ messageId: 'msg-b', outputTokens: 20 }),
- shutdownEvent({
- modelMetrics: { 'claude-sonnet-5': { inputTokens: 4000, outputTokens: 20, cacheReadTokens: 2000, cacheWriteTokens: 700 } },
- timestamp: stamp,
- }),
- ]
- const path1 = join(sessionDir, 'events.jsonl')
- const path2 = join(sessionDir, 'events-2.jsonl')
- await writeFile(path1, journal1.join('\n') + '\n')
- await writeFile(path2, journal2.join('\n') + '\n')
-
- const seen = new Set()
- const first = await collectCalls({ path: path1, project: 'myproject', provider: 'copilot', sourceType: 'jsonl' }, seen)
- const second = await collectCalls({ path: path2, project: 'myproject', provider: 'copilot', sourceType: 'jsonl' }, seen)
- const shutdowns = [...first, ...second].filter(c => c.deduplicationKey.includes(':shutdown:'))
+ const calls = await collectCalls({ path: eventsPath, project: 'myproject', provider: 'copilot', sourceType: 'jsonl' })
+ const shutdowns = calls.filter(c => c.deduplicationKey.includes(':shutdown:'))
expect(shutdowns.map(c => c.deduplicationKey)).toEqual([
- 'copilot:sess-shared-stamp:shutdown:claude-sonnet-5:2026-08-01T10:00:00Z:events.jsonl',
- 'copilot:sess-shared-stamp:shutdown:claude-sonnet-5:2026-08-01T10:00:00Z:events-2.jsonl',
+ 'copilot:sess-stampless:shutdown:claude-sonnet-5:1',
+ 'copilot:sess-stampless:shutdown:claude-sonnet-5:2',
+ 'copilot:sess-stampless:shutdown:claude-sonnet-5:3',
])
+ expect(shutdowns.map(c => c.timestamp)).toEqual([lastEvent, lastEvent, lastEvent])
expect(shutdowns[0]!.inputTokens).toBe(1500)
- expect(shutdowns[1]!.inputTokens).toBe(1300)
+ expect(shutdowns[1]!.inputTokens).toBe(1500)
+ expect(shutdowns[2]!.inputTokens).toBe(500)
+ expect(shutdowns.reduce((a, c) => a + c.inputTokens, 0)).toBe(3500)
+ expect(shutdowns.reduce((a, c) => a + c.cacheReadInputTokens, 0)).toBe(5000)
+ expect(shutdowns.reduce((a, c) => a + c.cacheCreationInputTokens, 0)).toBe(1500)
})
it('falls back to the last stamped event when shutdown carries no timestamp at all', async () => {
@@ -1018,7 +983,7 @@ describe('copilot provider - session.shutdown token/cost rollup', () => {
expect(perTurn.every(c => c.model === 'claude-sonnet-5')).toBe(true)
// The shutdown rollup lands: the tokens the misclassification dropped.
- const shutdown = calls.find(c => c.deduplicationKey === 'copilot:sess-cli-producer:shutdown:claude-sonnet-5:2026-08-07T17:56:40.591Z:events.jsonl')
+ const shutdown = calls.find(c => c.deduplicationKey === 'copilot:sess-cli-producer:shutdown:claude-sonnet-5:1')
expect(shutdown).toBeDefined()
expect(shutdown!.inputTokens).toBe(4) // 49473 − 24678 − 24791 (cache-inclusive)
expect(shutdown!.cacheReadInputTokens).toBe(24678)
@@ -1082,7 +1047,7 @@ describe('copilot provider - session.shutdown token/cost rollup', () => {
const calls: ParsedProviderCall[] = []
for await (const call of provider.createSessionParser(sessions[0]!, new Set()).parse()) calls.push(call)
- const shutdown = calls.find(c => c.deduplicationKey === 'copilot:sess-wire:shutdown:claude-sonnet-5:2026-04-15T10:05:00Z:events.jsonl')
+ const shutdown = calls.find(c => c.deduplicationKey === 'copilot:sess-wire:shutdown:claude-sonnet-5:1')
expect(shutdown).toBeDefined()
expect(shutdown!.inputTokens).toBe(500) // 5000 − 3000 − 1500
expect(shutdown!.cacheReadInputTokens).toBe(3000)
From f55f98726dc285a055f2fdc1c81434ad3e094618 Mon Sep 17 00:00:00 2001
From: Aditya Vikram Singh <247195684+avs-io@users.noreply.github.com>
Date: Fri, 21 Aug 2026 18:45:15 +0530
Subject: [PATCH 09/24] test: isolate provider-home env vars so developer
shells cannot leak sessions
HERMES_HOME and eight sibling PROVIDER_ENV_VARS data-dir overrides were
fingerprinted for cache invalidation but never CLEARED by the vitest
setup file, so a Hermes-shell laptop parsed real sessions in fixtures.
A static guard fails closed when the map grows another undeclared home.
---
tests/env-isolation-declarations.test.ts | 41 ++++++++++++++++++++++++
tests/setup/env-isolation.ts | 19 ++++++++---
2 files changed, 56 insertions(+), 4 deletions(-)
create mode 100644 tests/env-isolation-declarations.test.ts
diff --git a/tests/env-isolation-declarations.test.ts b/tests/env-isolation-declarations.test.ts
new file mode 100644
index 00000000..76b8b886
--- /dev/null
+++ b/tests/env-isolation-declarations.test.ts
@@ -0,0 +1,41 @@
+// Static guard: every PROVIDER_ENV_VARS entry must be CLEARED or REDIRECTED
+// by tests/setup/env-isolation.ts. A data-dir override that is fingerprinted
+// for cache invalidation but not isolated in tests leaks the developer's real
+// sessions into fixture parses — green on CI (no HERMES_HOME), red on a
+// Hermes-shell laptop. The named hole was HERMES_HOME; the class is every
+// sibling override that session-cache already knows about.
+import { describe, expect, it } from 'vitest'
+import { readFileSync } from 'fs'
+import { dirname, join } from 'path'
+import { fileURLToPath } from 'url'
+
+import { PROVIDER_ENV_VARS } from '../src/session-cache.js'
+
+const SETUP_PATH = join(dirname(fileURLToPath(import.meta.url)), 'setup', 'env-isolation.ts')
+
+function extractConstStringArray(source: string, name: string): string[] {
+ const match = source.match(new RegExp(`const ${name} = \\[([\\s\\S]*?)\\] as const`))
+ if (!match) {
+ throw new Error(`tests/setup/env-isolation.ts: const ${name} = [...] as const not found`)
+ }
+ return [...match[1]!.matchAll(/'([A-Z0-9_]+)'/g)].map(m => m[1]!)
+}
+
+describe('env-isolation covers PROVIDER_ENV_VARS', () => {
+ it('clears or redirects every provider data-dir override so a developer shell cannot leak real sessions into fixtures', () => {
+ const source = readFileSync(SETUP_PATH, 'utf8')
+ const isolated = new Set([
+ ...extractConstStringArray(source, 'CLEARED'),
+ ...extractConstStringArray(source, 'REDIRECTED'),
+ ])
+
+ const missing: string[] = []
+ for (const [provider, vars] of Object.entries(PROVIDER_ENV_VARS)) {
+ for (const varName of vars) {
+ if (!isolated.has(varName)) missing.push(`${provider}:${varName}`)
+ }
+ }
+
+ expect(missing).toEqual([])
+ })
+})
diff --git a/tests/setup/env-isolation.ts b/tests/setup/env-isolation.ts
index 6a3326ec..4c557a68 100644
--- a/tests/setup/env-isolation.ts
+++ b/tests/setup/env-isolation.ts
@@ -1,12 +1,13 @@
// Vitest setup file: isolates every test from the developer's shell environment.
//
// codeburn discovers sessions through a long list of provider-specific env
-// vars (CLAUDE_CONFIG_DIR, CODEX_HOME, CRUSH_GLOBAL_DATA, …) and via HOME /
-// XDG_* / APPDATA / LOCALAPPDATA. Without this file, any value set in the
-// developer's shell (e.g. CLAUDE_CONFIG_DIRS=/Users/me/.claude:…) bleeds into
+// vars (CLAUDE_CONFIG_DIR, CODEX_HOME, HERMES_HOME, CRUSH_GLOBAL_DATA, …) and
+// via HOME / XDG_* / APPDATA / LOCALAPPDATA. Without this file, any value set
+// in the developer's shell (e.g. HERMES_HOME=/Users/me/.hermes) bleeds into
// fixture-based tests: the parser reads the developer's REAL sessions instead
// of the temp-dir fixture, producing nonsense totals and false failures that
-// pass on a clean CI runner.
+// pass on a clean CI runner. tests/env-isolation-declarations.test.ts fails
+// closed if PROVIDER_ENV_VARS grows a data-dir override that is not listed.
//
// What this file does:
// 1. Mints an empty sandbox temp dir once per worker.
@@ -56,26 +57,36 @@ const CLEARED = [
'CODEWHALE_HOME',
'CRUSH_GLOBAL_DATA',
'CODEBUFF_DATA_DIR',
+ 'DSH_HOME',
'FACTORY_DIR',
'GOOSE_PATH_ROOT',
'GROK_HOME',
+ 'HERMES_HOME',
'KIRO_HOME',
+ 'KIMI_CODE_HOME',
'KIMI_SHARE_DIR',
+ 'LINGTAI_HOME',
+ 'LINGTAI_TUI_GLOBAL_DIR',
+ 'LINGTAI_TUI_HOME',
'MUX_ROOT',
'OPENCODE_DATA_DIR',
'OPENCODE_DB_PREFIX',
+ 'QUICKWORK_HOME',
'QWEN_DATA_DIR',
'VIBE_HOME',
'WARP_DB_PATH',
'ZS_DATA_DIR',
// codeburn override dirs / paths
'CODEBURN_CACHE_DIR',
+ 'CODEBURN_COPILOT_GLOBAL_STORAGE_DIR',
'CODEBURN_COPILOT_JETBRAINS_DIR',
'CODEBURN_COPILOT_OTEL_DB',
'CODEBURN_COPILOT_SESSION_STATE_DIR',
'CODEBURN_COPILOT_WS_STORAGE_DIR',
'CODEBURN_DESKTOP_SESSIONS_DIR',
'CODEBURN_MUX_DIR',
+ 'CODEBURN_OPEN_DESIGN_DIR',
+ 'CODEBURN_OPENCLAUDE_DIR',
'CODEBURN_ANTIGRAVITY_SETTINGS_PATH',
// codeburn behavior toggles (set by the dev to tweak local runs)
'CODEBURN_COPILOT_DISABLE_OTEL',
From 094f9f1d436e332c1d9ed0af0f383d05bbe3526a Mon Sep 17 00:00:00 2001
From: Aditya Vikram Singh <247195684+avs-io@users.noreply.github.com>
Date: Fri, 21 Aug 2026 18:58:21 +0530
Subject: [PATCH 10/24] test: consume runtime CLEARED/REDIRECTED arrays in the
isolation guard
Extra High MERGE AFTER FIX: source-scraping the setup file treated a
comment containing 'HERMES_HOME' as isolation. The lists now live in a
side-effect-free module that applyIsolation and the declaration test
both import. Comment-only sabotage fails with hermes:HERMES_HOME.
---
tests/env-isolation-declarations.test.ts | 24 ++-----
tests/setup/env-isolation-vars.ts | 79 ++++++++++++++++++++++++
tests/setup/env-isolation.ts | 72 +--------------------
3 files changed, 87 insertions(+), 88 deletions(-)
create mode 100644 tests/setup/env-isolation-vars.ts
diff --git a/tests/env-isolation-declarations.test.ts b/tests/env-isolation-declarations.test.ts
index 76b8b886..a4220509 100644
--- a/tests/env-isolation-declarations.test.ts
+++ b/tests/env-isolation-declarations.test.ts
@@ -4,30 +4,18 @@
// sessions into fixture parses — green on CI (no HERMES_HOME), red on a
// Hermes-shell laptop. The named hole was HERMES_HOME; the class is every
// sibling override that session-cache already knows about.
+//
+// The lists are imported from the same module applyIsolation() uses. Extra
+// High #1064: scraping setup-file source treated a comment containing
+// `'HERMES_HOME'` as isolation (false-green). Runtime membership cannot.
import { describe, expect, it } from 'vitest'
-import { readFileSync } from 'fs'
-import { dirname, join } from 'path'
-import { fileURLToPath } from 'url'
import { PROVIDER_ENV_VARS } from '../src/session-cache.js'
-
-const SETUP_PATH = join(dirname(fileURLToPath(import.meta.url)), 'setup', 'env-isolation.ts')
-
-function extractConstStringArray(source: string, name: string): string[] {
- const match = source.match(new RegExp(`const ${name} = \\[([\\s\\S]*?)\\] as const`))
- if (!match) {
- throw new Error(`tests/setup/env-isolation.ts: const ${name} = [...] as const not found`)
- }
- return [...match[1]!.matchAll(/'([A-Z0-9_]+)'/g)].map(m => m[1]!)
-}
+import { CLEARED, REDIRECTED } from './setup/env-isolation-vars.js'
describe('env-isolation covers PROVIDER_ENV_VARS', () => {
it('clears or redirects every provider data-dir override so a developer shell cannot leak real sessions into fixtures', () => {
- const source = readFileSync(SETUP_PATH, 'utf8')
- const isolated = new Set([
- ...extractConstStringArray(source, 'CLEARED'),
- ...extractConstStringArray(source, 'REDIRECTED'),
- ])
+ const isolated = new Set([...CLEARED, ...REDIRECTED])
const missing: string[] = []
for (const [provider, vars] of Object.entries(PROVIDER_ENV_VARS)) {
diff --git a/tests/setup/env-isolation-vars.ts b/tests/setup/env-isolation-vars.ts
new file mode 100644
index 00000000..a776c4bb
--- /dev/null
+++ b/tests/setup/env-isolation-vars.ts
@@ -0,0 +1,79 @@
+// Side-effect-free lists for tests/setup/env-isolation.ts.
+// Imported by the setup file (which applies them) and by
+// tests/env-isolation-declarations.test.ts (which asserts coverage).
+// Do not put applyIsolation() here — importing this module from a test
+// must not re-sandbox the process or register another beforeEach.
+//
+// A comment containing 'HERMES_HOME' is not isolation. The declaration
+// test imports these arrays, so a commented-out name cannot false-green.
+
+export const REDIRECTED = [
+ 'HOME',
+ 'XDG_CONFIG_HOME',
+ 'XDG_DATA_HOME',
+ 'XDG_CACHE_HOME',
+ 'XDG_STATE_HOME',
+ 'APPDATA',
+ 'LOCALAPPDATA',
+] as const
+
+export const CLEARED = [
+ // Provider session-discovery dirs
+ 'CLAUDE_CONFIG_DIR',
+ 'CLAUDE_CONFIG_DIRS',
+ 'CLINE_DIR',
+ 'CLINE_DATA_DIR',
+ 'CLINE_SESSION_DATA_DIR',
+ 'CODEX_HOME',
+ 'CODEWHALE_HOME',
+ 'CRUSH_GLOBAL_DATA',
+ 'CODEBUFF_DATA_DIR',
+ 'DSH_HOME',
+ 'FACTORY_DIR',
+ 'GOOSE_PATH_ROOT',
+ 'GROK_HOME',
+ 'HERMES_HOME',
+ 'KIRO_HOME',
+ 'KIMI_CODE_HOME',
+ 'KIMI_SHARE_DIR',
+ 'LINGTAI_HOME',
+ 'LINGTAI_TUI_GLOBAL_DIR',
+ 'LINGTAI_TUI_HOME',
+ 'MUX_ROOT',
+ 'OPENCODE_DATA_DIR',
+ 'OPENCODE_DB_PREFIX',
+ 'QUICKWORK_HOME',
+ 'QWEN_DATA_DIR',
+ 'VIBE_HOME',
+ 'WARP_DB_PATH',
+ 'ZS_DATA_DIR',
+ // codeburn override dirs / paths
+ 'CODEBURN_CACHE_DIR',
+ 'CODEBURN_COPILOT_GLOBAL_STORAGE_DIR',
+ 'CODEBURN_COPILOT_JETBRAINS_DIR',
+ 'CODEBURN_COPILOT_OTEL_DB',
+ 'CODEBURN_COPILOT_SESSION_STATE_DIR',
+ 'CODEBURN_COPILOT_WS_STORAGE_DIR',
+ 'CODEBURN_DESKTOP_SESSIONS_DIR',
+ 'CODEBURN_MUX_DIR',
+ 'CODEBURN_OPEN_DESIGN_DIR',
+ 'CODEBURN_OPENCLAUDE_DIR',
+ 'CODEBURN_ANTIGRAVITY_SETTINGS_PATH',
+ // codeburn behavior toggles (set by the dev to tweak local runs)
+ 'CODEBURN_COPILOT_DISABLE_OTEL',
+ 'CODEBURN_TZ',
+ 'CODEBURN_VERBOSE',
+ 'CODEBURN_CURSOR_MAX_BUBBLES',
+ 'CODEBURN_FORCE_MACOS_MAJOR',
+ // Provider model/credential overrides
+ 'KIMI_MODEL_NAME',
+ 'AI_GATEWAY_API_KEY',
+ 'VERCEL_OIDC_TOKEN',
+ // Read by detectBashBloat - a dev's real shell limit must not bleed in
+ 'BASH_MAX_OUTPUT_LENGTH',
+] as const
+
+// Snapshotted from the dev's shell and restored every test. These can't be
+// wiped (Node needs PATH for spawn / module resolution, dashboard/table layout
+// reads COLUMNS) but a test that mutates them shouldn't leak.
+export const PRESERVED = ['PATH', 'COLUMNS'] as const
diff --git a/tests/setup/env-isolation.ts b/tests/setup/env-isolation.ts
index 4c557a68..af82aff4 100644
--- a/tests/setup/env-isolation.ts
+++ b/tests/setup/env-isolation.ts
@@ -34,78 +34,10 @@ import { tmpdir } from 'os'
import { join } from 'path'
import { beforeEach } from 'vitest'
+import { CLEARED, PRESERVED, REDIRECTED } from './env-isolation-vars.js'
+
const sandbox = mkdtempSync(join(tmpdir(), 'codeburn-test-env-'))
-const REDIRECTED = [
- 'HOME',
- 'XDG_CONFIG_HOME',
- 'XDG_DATA_HOME',
- 'XDG_CACHE_HOME',
- 'XDG_STATE_HOME',
- 'APPDATA',
- 'LOCALAPPDATA',
-] as const
-
-const CLEARED = [
- // Provider session-discovery dirs
- 'CLAUDE_CONFIG_DIR',
- 'CLAUDE_CONFIG_DIRS',
- 'CLINE_DIR',
- 'CLINE_DATA_DIR',
- 'CLINE_SESSION_DATA_DIR',
- 'CODEX_HOME',
- 'CODEWHALE_HOME',
- 'CRUSH_GLOBAL_DATA',
- 'CODEBUFF_DATA_DIR',
- 'DSH_HOME',
- 'FACTORY_DIR',
- 'GOOSE_PATH_ROOT',
- 'GROK_HOME',
- 'HERMES_HOME',
- 'KIRO_HOME',
- 'KIMI_CODE_HOME',
- 'KIMI_SHARE_DIR',
- 'LINGTAI_HOME',
- 'LINGTAI_TUI_GLOBAL_DIR',
- 'LINGTAI_TUI_HOME',
- 'MUX_ROOT',
- 'OPENCODE_DATA_DIR',
- 'OPENCODE_DB_PREFIX',
- 'QUICKWORK_HOME',
- 'QWEN_DATA_DIR',
- 'VIBE_HOME',
- 'WARP_DB_PATH',
- 'ZS_DATA_DIR',
- // codeburn override dirs / paths
- 'CODEBURN_CACHE_DIR',
- 'CODEBURN_COPILOT_GLOBAL_STORAGE_DIR',
- 'CODEBURN_COPILOT_JETBRAINS_DIR',
- 'CODEBURN_COPILOT_OTEL_DB',
- 'CODEBURN_COPILOT_SESSION_STATE_DIR',
- 'CODEBURN_COPILOT_WS_STORAGE_DIR',
- 'CODEBURN_DESKTOP_SESSIONS_DIR',
- 'CODEBURN_MUX_DIR',
- 'CODEBURN_OPEN_DESIGN_DIR',
- 'CODEBURN_OPENCLAUDE_DIR',
- 'CODEBURN_ANTIGRAVITY_SETTINGS_PATH',
- // codeburn behavior toggles (set by the dev to tweak local runs)
- 'CODEBURN_COPILOT_DISABLE_OTEL',
- 'CODEBURN_TZ',
- 'CODEBURN_VERBOSE',
- 'CODEBURN_CURSOR_MAX_BUBBLES',
- 'CODEBURN_FORCE_MACOS_MAJOR',
- // Provider model/credential overrides
- 'KIMI_MODEL_NAME',
- 'AI_GATEWAY_API_KEY',
- 'VERCEL_OIDC_TOKEN',
- // Read by detectBashBloat - a dev's real shell limit must not bleed in
- 'BASH_MAX_OUTPUT_LENGTH',
-] as const
-
-// Snapshotted from the dev's shell and restored every test. These can't be
-// wiped (Node needs PATH for spawn / module resolution, dashboard/table layout
-// reads COLUMNS) but a test that mutates them shouldn't leak.
-const PRESERVED = ['PATH', 'COLUMNS'] as const
const preservedSnapshot = new Map()
for (const key of PRESERVED) preservedSnapshot.set(key, process.env[key])
From 088d264968c572b9c94250f206b04a0d7c6745b0 Mon Sep 17 00:00:00 2001
From: Aditya Vikram Singh <247195684+avs-io@users.noreply.github.com>
Date: Fri, 21 Aug 2026 21:48:09 +0530
Subject: [PATCH 11/24] fix(web): lead session chart legends with title when
unique
Hourly session series still opened with a truncated UUID, so a
monorepo's legend was six identical hex fragments. Sessions
report already prefers title; the chart did not.
Lead with the cleaned title when that prefix is unique in the
visible ~24-glyph budget. Fall back to short-id-first when
titles collide or share a long prefix. Untitled sessions stay
project fallback. No full session ids on the happy path.
---
src/granular-history.ts | 34 ++++++++++++++++++++++++++--------
tests/granular-history.test.ts | 13 ++++++-------
2 files changed, 32 insertions(+), 15 deletions(-)
diff --git a/src/granular-history.ts b/src/granular-history.ts
index 75b6e328..df545848 100644
--- a/src/granular-history.ts
+++ b/src/granular-history.ts
@@ -64,6 +64,7 @@ type SessionLabelEntry = {
key: string
info: SessionLabelInfo
baseLabel: string
+ idFirst: string
}
function nonNegative(value: number): number {
@@ -174,19 +175,36 @@ function preferredSessionTitle(titleCandidates: Map): Map {
// Stable raw-key order makes the residual used-label guard independent of
// project/session discovery order when a title happens to match another
// label shape.
- const entries: SessionLabelEntry[] = [...inputs.entries()].map(([key, info]) => {
- const sessionLabel = preferredSessionTitle(info.titleCandidates)
- ?? shortProjectLabel(info.projectPath, preferredProjectName(info.projectNames))
- return {
- key,
- info,
- baseLabel: `${shortSessionId(info.sessionId)} (${info.provider}) · ${sessionLabel}`,
- }
+ const draft: SessionLabelEntry[] = [...inputs.entries()].map(([key, info]) => {
+ const title = preferredSessionTitle(info.titleCandidates)
+ const project = shortProjectLabel(info.projectPath, preferredProjectName(info.projectNames))
+ const shortId = shortSessionId(info.sessionId)
+ const idFirst = title
+ ? `${shortId} (${info.provider}) · ${title}`
+ : `${shortId} (${info.provider}) · ${project}`
+ const titleFirst = title ? `${title} (${info.provider})` : idFirst
+ return { key, info, baseLabel: titleFirst, idFirst }
}).sort((a, b) => a.key < b.key ? -1 : a.key > b.key ? 1 : 0)
+
+ const titlePrefixCounts = new Map()
+ for (const entry of draft) {
+ const prefix = entry.baseLabel.slice(0, VISIBLE_LEGEND_PREFIX)
+ titlePrefixCounts.set(prefix, (titlePrefixCounts.get(prefix) ?? 0) + 1)
+ }
+ const entries: SessionLabelEntry[] = draft.map(entry => {
+ const prefix = entry.baseLabel.slice(0, VISIBLE_LEGEND_PREFIX)
+ const titleLeads = (titlePrefixCounts.get(prefix) ?? 0) === 1
+ return { ...entry, baseLabel: titleLeads ? entry.baseLabel : entry.idFirst }
+ })
const byBaseLabel = new Map()
for (const entry of entries) {
const group = byBaseLabel.get(entry.baseLabel) ?? []
diff --git a/tests/granular-history.test.ts b/tests/granular-history.test.ts
index bc6e40b3..efa0a944 100644
--- a/tests/granular-history.test.ts
+++ b/tests/granular-history.test.ts
@@ -120,7 +120,7 @@ describe('granular history', () => {
])], { start, end }, end)
expect(history.sessionSeries.map(series => series.label)).toEqual([
- 'sessio…3456 (claude) · Refactor billing module',
+ 'Refactor billing module (claude)',
'sessio…3457 (claude) · repos/demo',
'sessio…3458 (claude) · repos/demo',
'sessio…3459 (claude) · repos/demo',
@@ -175,7 +175,7 @@ describe('granular history', () => {
calls: [apiCall({ timestamp, cost: 1 })],
}])], { start, end }, end)
- expect(history.sessionSeries[0]?.label).toBe('sessio…3456 (claude) · Refactor billing module')
+ expect(history.sessionSeries[0]?.label).toBe('Refactor billing module (claude)')
expect(history.sessionSeries[0]?.label).not.toContain('\x1b')
expect(history.sessionSeries[0]?.label).not.toContain('\x00')
})
@@ -190,7 +190,7 @@ describe('granular history', () => {
calls: [apiCall({ timestamp, cost: 1 })],
}])], { start, end }, end)
- expect(history.sessionSeries[0]?.label).toBe('sessio…3456 (claude) · ' + 'x'.repeat(80))
+ expect(history.sessionSeries[0]?.label).toBe('x'.repeat(80) + ' (claude)')
})
it('caps session titles by code point without splitting an emoji', () => {
@@ -205,9 +205,8 @@ describe('granular history', () => {
}])], { start, end }, end)
const label = history.sessionSeries[0]?.label ?? ''
- const titlePart = label.slice(label.indexOf(' · ') + 3)
- expect(titlePart).toBe('x'.repeat(79) + '😀')
- expect([...titlePart]).toEqual([...('x'.repeat(79) + '😀')])
+ expect(label).toBe('x'.repeat(79) + '😀' + ' (claude)')
+ expect([...label]).toEqual([...'x'.repeat(79), '😀', ' ', '(', 'c', 'l', 'a', 'u', 'd', 'e', ')'])
})
it('prefers a title from any duplicate session summary sharing a key', () => {
@@ -230,7 +229,7 @@ describe('granular history', () => {
])], { start, end }, end)
expect(history.sessionSeries).toHaveLength(1)
- expect(history.sessionSeries[0]?.label).toBe('sessio…3456 (claude) · Z recovered session title')
+ expect(history.sessionSeries[0]?.label).toBe('Z recovered session title (claude)')
})
it('fills idle buckets and keeps separate model and session lines from real call timestamps', () => {
From 40652557332ad0df1e1c83e1a30148c12f97f7b7 Mon Sep 17 00:00:00 2001
From: Aditya Vikram Singh <247195684+avs-io@users.noreply.github.com>
Date: Fri, 21 Aug 2026 21:59:13 +0530
Subject: [PATCH 12/24] fix(web): count legend title uniqueness in code points
The visible prefix that decides title-vs-id order must match the
title cap. A UTF-16 slice can split an emoji and treat two distinct
titles as the same truncated series.
---
src/granular-history.ts | 11 ++++++++---
tests/granular-history.test.ts | 21 ++++++++++++++++++++-
2 files changed, 28 insertions(+), 4 deletions(-)
diff --git a/src/granular-history.ts b/src/granular-history.ts
index df545848..875b96b6 100644
--- a/src/granular-history.ts
+++ b/src/granular-history.ts
@@ -177,9 +177,14 @@ function preferredSessionTitle(titleCandidates: Map): Map {
// Stable raw-key order makes the residual used-label guard independent of
// project/session discovery order when a title happens to match another
@@ -197,11 +202,11 @@ function buildSessionLabels(inputs: Map): Map()
for (const entry of draft) {
- const prefix = entry.baseLabel.slice(0, VISIBLE_LEGEND_PREFIX)
+ const prefix = visibleLegendPrefix(entry.baseLabel)
titlePrefixCounts.set(prefix, (titlePrefixCounts.get(prefix) ?? 0) + 1)
}
const entries: SessionLabelEntry[] = draft.map(entry => {
- const prefix = entry.baseLabel.slice(0, VISIBLE_LEGEND_PREFIX)
+ const prefix = visibleLegendPrefix(entry.baseLabel)
const titleLeads = (titlePrefixCounts.get(prefix) ?? 0) === 1
return { ...entry, baseLabel: titleLeads ? entry.baseLabel : entry.idFirst }
})
diff --git a/tests/granular-history.test.ts b/tests/granular-history.test.ts
index efa0a944..8294abdd 100644
--- a/tests/granular-history.test.ts
+++ b/tests/granular-history.test.ts
@@ -157,7 +157,7 @@ describe('granular history', () => {
// 160px at the chart's 10px font fits roughly 31-32 lowercase glyphs;
// compare a conservative prefix that must be visible in that budget.
const visibleCharacterBudget = 24
- const visiblePrefixes = history.sessionSeries.map(series => series.label.slice(0, visibleCharacterBudget))
+ const visiblePrefixes = history.sessionSeries.map(series => Array.from(series.label).slice(0, visibleCharacterBudget).join(''))
expect(new Set(visiblePrefixes).size).toBe(2)
expect(history.sessionSeries.map(series => series.label)).toEqual(expect.arrayContaining([
expect.stringMatching(/^a1b2c3…7f01 \(claude\) · /),
@@ -165,6 +165,25 @@ describe('granular history', () => {
]))
})
+ it('counts the visible title-lead window in code points, not UTF-16 units', () => {
+ const timestamp = '2026-07-15T12:05:00.000Z'
+ const start = new Date('2026-07-15T00:00:00.000Z')
+ const end = new Date('2026-07-15T23:59:59.999Z')
+ const emojiPrefix = '😀'.repeat(12)
+ const history = buildGranularHistory([project([
+ { id: 'emoji-alpha-aaaaaa', title: `${emojiPrefix} alpha work`, calls: [apiCall({ timestamp, cost: 1 })] },
+ { id: 'emoji-beta-bbbbbb', title: `${emojiPrefix} beta work`, calls: [apiCall({ timestamp, cost: 2 })] },
+ ])], { start, end }, end)
+
+ // 12 emoji = 12 glyphs / 24 UTF-16 units. A unit slice collides both
+ // titles on the emoji run and would id-first; a code-point window still
+ // sees " alpha" vs " beta" and can title-lead.
+ expect(history.sessionSeries.map(series => series.label).sort()).toEqual([
+ `${emojiPrefix} alpha work (claude)`,
+ `${emojiPrefix} beta work (claude)`,
+ ].sort())
+ })
+
it('sanitises control characters and ANSI escapes in session titles', () => {
const timestamp = '2026-07-15T12:05:00.000Z'
const start = new Date('2026-07-15T00:00:00.000Z')
From a3beafba50445f3c591d080438474b287a3b52be Mon Sep 17 00:00:00 2001
From: Aditya Vikram Singh <247195684+avs-io@users.noreply.github.com>
Date: Fri, 21 Aug 2026 21:59:54 +0530
Subject: [PATCH 13/24] fix(cache): accept completed-by-other as the stale-lock
loser
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The process suite required the loser of a stale-lock contest to
be timed-out. On a slow runner the winner's unlink-guard /
create-successor gap is missing+missing, which the lock honestly
reports as completed-by-other (#904).
Exactly one owner still publishes. The loser may be timed-out,
completed-by-other, or unavailable — never parsed. Do not delay
the clean-release path by treating missing+missing as wait-out.
---
tests/cache-refresh-lock-process.test.ts | 14 ++++++++++++--
1 file changed, 12 insertions(+), 2 deletions(-)
diff --git a/tests/cache-refresh-lock-process.test.ts b/tests/cache-refresh-lock-process.test.ts
index d837f81e..752f332e 100644
--- a/tests/cache-refresh-lock-process.test.ts
+++ b/tests/cache-refresh-lock-process.test.ts
@@ -79,7 +79,12 @@ describe('warm refresh child-process regression', () => {
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`)
+ // Exactly one owner publishes. The loser of a stale-lock contest is not
+ // the owner: `timed-out` is the usual wait-out, but missing+missing during
+ // the winner's unlink-guard/create-successor gap is honestly
+ // `completed-by-other` (#904). Do not require timed-out.
+ expect(loserOutcome, (await readdir(barriers)).join(',')).not.toBe(`${loser}.parsed`)
+ expect([`${loser}.timed-out`, `${loser}.completed-by-other`, `${loser}.unavailable`]).toContain(loserOutcome)
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' })
@@ -113,7 +118,12 @@ describe('warm refresh child-process regression', () => {
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`)
+ // Exactly one owner publishes. The loser of a stale-lock contest is not
+ // the owner: `timed-out` is the usual wait-out, but missing+missing during
+ // the winner's unlink-guard/create-successor gap is honestly
+ // `completed-by-other` (#904). Do not require timed-out.
+ expect(loserOutcome, (await readdir(barriers)).join(',')).not.toBe(`${loser}.parsed`)
+ expect([`${loser}.timed-out`, `${loser}.completed-by-other`, `${loser}.unavailable`]).toContain(loserOutcome)
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' })
From f718077a9b0b87bec2412fc50c86d679d65491e8 Mon Sep 17 00:00:00 2001
From: Aditya Vikram Singh <247195684+avs-io@users.noreply.github.com>
Date: Fri, 21 Aug 2026 22:09:47 +0530
Subject: [PATCH 14/24] ci: gate the serial cache-lock suite after #904
Extra High MERGE AFTER FIX on a3beafb. The process tests admitted
completed-by-other as a non-owner outcome, but the workflow still
had continue-on-error and a quarantined step name. The suite now
gates. Still serial (parallelism-sensitive). No tryTakeover rewrite.
---
.github/workflows/tests.yml | 17 ++++++++---------
1 file changed, 8 insertions(+), 9 deletions(-)
diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml
index ed856fc6..2ae8fd70 100644
--- a/.github/workflows/tests.yml
+++ b/.github/workflows/tests.yml
@@ -33,14 +33,13 @@ jobs:
# script since #948, so CI and a contributor's `npm test` can never drift.
- name: Test suite (parallel)
run: npm test
- # Single forked worker, so lock contention comes only from the child processes the
- # tests spawn deliberately. Quarantined (reports, never gates): the process
- # suite still races its own takeover window even serially on slow runners -
- # tracked in #904; drop continue-on-error once that race is settled.
- # Step-level timeout so a stalled lock suite fails soft here instead of
- # tripping the job's 15-minute budget, which kills the whole job as
- # "cancelled" and hides the parallel suite's green result.
- - name: Cache-lock suite (serial, quarantined)
- continue-on-error: true
+ # Single forked worker, so lock contention comes only from the child
+ # processes the tests spawn deliberately. Serial because the suite is
+ # parallelism-sensitive (fails under full worker pressure, passes
+ # serially). After #904 the loser of a stale-lock contest may be
+ # timed-out or completed-by-other; this step gates CI.
+ # Step-level timeout so a stalled lock suite fails this job instead of
+ # eating the 15-minute budget and cancelling a green parallel suite.
+ - name: Cache-lock suite (serial)
timeout-minutes: 5
run: npm run test:locks
From 1fa284fff819ecd7168f8026d9af7d262ead6b5f Mon Sep 17 00:00:00 2001
From: Aditya Vikram Singh <247195684+avs-io@users.noreply.github.com>
Date: Fri, 21 Aug 2026 22:35:52 +0530
Subject: [PATCH 15/24] fix(report): require durable period; drop unreachable
dailyMap
buildJsonReport always received a DurablePeriod from both JSON
call sites, so the live dailyMap fallback could never run (#1067).
Make durable required and take headlines + daily rows only from
durable.days. Proxied/net split stays live-session. No fallback.
---
src/main.ts | 80 +++++++++--------------------------------------------
1 file changed, 13 insertions(+), 67 deletions(-)
diff --git a/src/main.ts b/src/main.ts
index fdaea87d..684f6016 100644
--- a/src/main.ts
+++ b/src/main.ts
@@ -472,7 +472,7 @@ program.hook('preAction', async (thisCommand) => {
await loadCurrency()
})
-function buildJsonReport(projects: ProjectSummary[], period: string, periodKey: string, durable?: DurablePeriod) {
+function buildJsonReport(projects: ProjectSummary[], period: string, periodKey: string, durable: DurablePeriod) {
const sessions = projects.flatMap(p => p.sessions)
const { code } = getCurrency()
@@ -480,67 +480,28 @@ function buildJsonReport(projects: ProjectSummary[], period: string, periodKey:
// session files have expired still count), matching the menubar exactly. The
// proxied/net split is a surviving-session concept (subscription attribution
// isn't stored per day), so it stays live; net is taken off the durable total.
- const totalCostUSD = durable ? durable.data.cost : projects.reduce((s, p) => s + p.totalCostUSD, 0)
- const totalSavingsUSD = durable ? durable.data.savingsUSD : projects.reduce((s, p) => s + p.totalSavingsUSD, 0)
- const totalEstimatedUSD = durable ? (durable.data.estimatedCostUSD ?? 0) : projects.reduce((s, p) => s + (p.totalEstimatedCostUSD ?? 0), 0)
+ const totalCostUSD = durable.data.cost
+ const totalSavingsUSD = durable.data.savingsUSD
+ const totalEstimatedUSD = durable.data.estimatedCostUSD ?? 0
// Subscription-covered (proxied) portion of totalCostUSD, and the resulting
// out-of-pocket figure. `cost` stays the full billable/would-be amount.
const totalProxiedUSD = projects.reduce((s, p) => s + p.totalProxiedCostUSD, 0)
const netCostUSD = totalCostUSD - totalProxiedUSD
- const totalCalls = durable ? durable.data.calls : projects.reduce((s, p) => s + p.totalApiCalls, 0)
- const totalSessions = durable ? durable.data.sessions : projects.reduce((s, p) => s + p.sessions.length, 0)
- const totalInput = durable ? durable.data.inputTokens : sessions.reduce((s, sess) => s + sess.totalInputTokens, 0)
- const totalOutput = durable ? durable.data.outputTokens : sessions.reduce((s, sess) => s + sess.totalOutputTokens, 0)
- const totalCacheRead = durable ? durable.data.cacheReadTokens : sessions.reduce((s, sess) => s + sess.totalCacheReadTokens, 0)
- const totalCacheWrite = durable ? durable.data.cacheWriteTokens : sessions.reduce((s, sess) => s + sess.totalCacheWriteTokens, 0)
+ const totalCalls = durable.data.calls
+ const totalSessions = durable.data.sessions
+ const totalInput = durable.data.inputTokens
+ const totalOutput = durable.data.outputTokens
+ const totalCacheRead = durable.data.cacheReadTokens
+ const totalCacheWrite = durable.data.cacheWriteTokens
// Match src/menubar-json.ts:cacheHitPercent: reads over reads+fresh-input. cache_write
// counts tokens being stored, not served, so it doesn't belong in the denominator.
const cacheHitDenom = totalInput + totalCacheRead
const cacheHitPercent = cacheHitDenom > 0 ? Math.round((totalCacheRead / cacheHitDenom) * 1000) / 10 : 0
- // Per-day rollup. Mirrors parser.ts categoryBreakdown semantics so a
- // consumer summing daily[].editTurns over a period gets the same total as
- // sum(activities[].editTurns) for that period: every turn counts once for
- // `turns`, edit turns count for `editTurns`, edit turns with zero retries
- // count for `oneShotTurns`. Issue #279 — daily-resolution efficiency
- // dashboards need this without re-deriving from activity-level rollups.
- const dailyMap: Record = {}
- for (const sess of sessions) {
- for (const turn of sess.turns) {
- // Prefer the user-message timestamp on the turn; fall back to the first
- // assistant-call timestamp when the user line is missing (continuation
- // sessions where the JSONL begins mid-conversation). Previously these
- // turns dropped from daily but stayed in activities, breaking the
- // sum(daily[].editTurns) === sum(activities[].editTurns) invariant.
- const ts = turn.timestamp || turn.assistantCalls[0]?.timestamp
- if (!ts) { continue }
- const day = dateKey(ts)
- if (!dailyMap[day]) { dailyMap[day] = { cost: 0, savings: 0, calls: 0, turns: 0, editTurns: 0, oneShotTurns: 0 } }
- dailyMap[day].turns += 1
- if (turn.hasEdits) {
- dailyMap[day].editTurns += 1
- if (turn.retries === 0) dailyMap[day].oneShotTurns += 1
- }
- for (const call of turn.assistantCalls) {
- // 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
- }
- }
- }
// Daily rows come from the same durable day set as the headline so they sum
- // to it, carried days included. The live per-turn rollup (dailyMap) is only
- // the fallback for callers that pass no durable period.
- const daily = durable
- ? durable.days.map(d => {
+ // to it, carried days included. Both JSON call sites always pass durable
+ // (#1067); the live dailyMap fallback was unreachable and is gone.
+ const daily = durable.days.map(d => {
const turns = Object.values(d.categories).reduce((s, c) => s + c.turns, 0)
return {
date: d.date,
@@ -555,21 +516,6 @@ function buildJsonReport(projects: ProjectSummary[], period: string, periodKey:
: null,
}
})
- : Object.entries(dailyMap).sort().map(([date, d]) => ({
- date,
- cost: convertCost(d.cost),
- savings: convertCost(d.savings),
- calls: d.calls,
- turns: d.turns,
- editTurns: d.editTurns,
- oneShotTurns: d.oneShotTurns,
- // Pre-computed convenience for dashboards that don't want to do the math.
- // null when there are no edit turns (the rate is undefined, not zero —
- // a day where the user only had Q&A turns shouldn't read as 0% one-shot).
- oneShotRate: d.editTurns > 0
- ? Math.round((d.oneShotTurns / d.editTurns) * 1000) / 10
- : null,
- }))
const projectList = projects.map(p => ({
name: p.project,
From 7e51413e215321daf28166a2da1bd26e6221ae3d Mon Sep 17 00:00:00 2001
From: Aditya Vikram Singh <247195684+avs-io@users.noreply.github.com>
Date: Fri, 21 Aug 2026 22:45:29 +0530
Subject: [PATCH 16/24] test: describe reportDailyByDate as the durable.days
oracle
Extra High MERGE AFTER FIX on 1fa284f. The parity helper is an
independent per-call oracle for durable.days, not a mirror of a
live dailyMap fallback that no longer exists. Helper and test
unchanged.
---
tests/day-aggregator.test.ts | 23 ++++++++++++-----------
1 file changed, 12 insertions(+), 11 deletions(-)
diff --git a/tests/day-aggregator.test.ts b/tests/day-aggregator.test.ts
index 7d70d14b..db1c0adc 100644
--- a/tests/day-aggregator.test.ts
+++ b/tests/day-aggregator.test.ts
@@ -463,18 +463,19 @@ describe('buildPeriodDataFromDays', () => {
})
describe('daily-cache ↔ report daily-bucket parity', () => {
- // The daily cache (history.daily + provider breakdown) and the live report /
- // 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 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.
+ // The daily cache (history.daily + provider breakdown) and JSON-report
+ // headlines (durable.days from buildDurablePeriod) 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 PER-CALL for cost/savings/calls (issue #852) with
+ // turn-level stats still turn-anchored: this asserts per-day equality against
+ // an independent per-call oracle for the durable day aggregation used by
+ // durable.days (each call on its own date), plus the invariant
+ // history.daily Σ == report.daily Σ == total call cost.
- // Mirrors the live report/headline daily rollup fallback in src/main.ts
- // (cost/savings/calls bucket under each call's own date).
+ // Independent per-call reference for durable.days (cost/savings/calls bucket
+ // under each call's own date). Not a live buildJsonReport fallback — that
+ // path was deleted in #1067.
function reportDailyByDate(projects: ProjectSummary[]): Record {
const byDate: Record = {}
for (const p of projects) {
From 06fa57b1556753ad9a1ff02f76d8c61dd53767d5 Mon Sep 17 00:00:00 2001
From: Aditya Vikram Singh <247195684+avs-io@users.noreply.github.com>
Date: Fri, 21 Aug 2026 22:48:03 +0530
Subject: [PATCH 17/24] fix(sync): drop cleartext ai.session_id from
attribution spans
Attribution spans already share deriveTraceId(sessionId) with
usage spans. Emitting the raw session id was redundant and
undid the pre-attribution wire rule that session id is hash
input only. Receivers upsert session spans by that keyed
traceId. Usage spans never had the field. Docs match.
---
docs/sync/README.md | 4 ++--
src/sync/otlp.ts | 1 -
tests/sync-attribution.test.ts | 3 ++-
tests/sync-ledger-otlp.test.ts | 1 +
4 files changed, 5 insertions(+), 4 deletions(-)
diff --git a/docs/sync/README.md b/docs/sync/README.md
index 64540b83..c50388a6 100644
--- a/docs/sync/README.md
+++ b/docs/sync/README.md
@@ -106,7 +106,6 @@ A pseudonymous `device_id` distinguishes your machines without revealing hostnam
| 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 |
@@ -120,7 +119,7 @@ A pseudonymous `device_id` distinguishes your machines without revealing hostnam
| `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.
+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 the keyed `traceId` they share with usage spans (`deriveTraceId(sessionId)`; 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:
@@ -134,6 +133,7 @@ With `--attribution`, normalized repo remote URLs, commit SHAs, commit timestamp
- **Code** — file contents, diffs, and paths stay local
- **Bash commands** — may contain secrets, never sent
- **Your name/email** — identity is derived server-side from your login token
+- **Session ids** — never sent in cleartext; usage and attribution spans join on the keyed `traceId`
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.
diff --git a/src/sync/otlp.ts b/src/sync/otlp.ts
index 8df36c59..c298617a 100644
--- a/src/sync/otlp.ts
+++ b/src/sync/otlp.ts
@@ -256,7 +256,6 @@ export function buildAttributionOtlpPayload(items: AttributionItem[]): OtlpPaylo
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) {
diff --git a/tests/sync-attribution.test.ts b/tests/sync-attribution.test.ts
index d4b8f6c1..85b7b385 100644
--- a/tests/sync-attribution.test.ts
+++ b/tests/sync-attribution.test.ts
@@ -553,7 +553,7 @@ describe('buildAttributionOtlpPayload', () => {
expect(sessionSpan.spanId).not.toBe(commitSpan.spanId)
const sessionAttrs = attrMap(sessionSpan.attributes)
- expect(sessionAttrs['ai.session_id']).toEqual({ stringValue: 'sess-1' })
+ expect(sessionAttrs['ai.session_id']).toBeUndefined()
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' })
@@ -565,6 +565,7 @@ describe('buildAttributionOtlpPayload', () => {
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['ai.session_id']).toBeUndefined()
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 })
diff --git a/tests/sync-ledger-otlp.test.ts b/tests/sync-ledger-otlp.test.ts
index b7c72ca6..53fa9656 100644
--- a/tests/sync-ledger-otlp.test.ts
+++ b/tests/sync-ledger-otlp.test.ts
@@ -156,6 +156,7 @@ describe('buildOtlpPayload', () => {
expect(attrMap['ai.cost_usd']).toEqual({ doubleValue: 0.05 })
expect(attrMap['ai.project']).toEqual({ stringValue: 'my-project' })
expect(attrMap['ai.speed']).toEqual({ stringValue: 'standard' })
+ expect(attrMap['ai.session_id']).toBeUndefined()
})
it('includes tools as array attribute', () => {
From dd25a390272842195254df29dd4752385fda5688 Mon Sep 17 00:00:00 2001
From: Aditya Vikram Singh <247195684+avs-io@users.noreply.github.com>
Date: Fri, 21 Aug 2026 22:50:08 +0530
Subject: [PATCH 18/24] docs(sync): upsert by traceId without overclaiming
privacy
#1070 is hygiene, not a leak. User docs no longer name
deriveTraceId or list session ids under What is NOT sent.
Code comment matches the wire key. Class test pins every
attribution span: no ai.session_id, join is shared traceId.
---
docs/sync/README.md | 3 +--
src/sync/otlp.ts | 2 +-
tests/sync-attribution.test.ts | 11 +++++++++++
3 files changed, 13 insertions(+), 3 deletions(-)
diff --git a/docs/sync/README.md b/docs/sync/README.md
index c50388a6..85258fc5 100644
--- a/docs/sync/README.md
+++ b/docs/sync/README.md
@@ -119,7 +119,7 @@ A pseudonymous `device_id` distinguishes your machines without revealing hostnam
| `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 the keyed `traceId` they share with usage spans (`deriveTraceId(sessionId)`; 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.
+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 `traceId` (the same id usage spans already carry; 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:
@@ -133,7 +133,6 @@ With `--attribution`, normalized repo remote URLs, commit SHAs, commit timestamp
- **Code** — file contents, diffs, and paths stay local
- **Bash commands** — may contain secrets, never sent
- **Your name/email** — identity is derived server-side from your login token
-- **Session ids** — never sent in cleartext; usage and attribution spans join on the keyed `traceId`
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.
diff --git a/src/sync/otlp.ts b/src/sync/otlp.ts
index c298617a..03c7f11c 100644
--- a/src/sync/otlp.ts
+++ b/src/sync/otlp.ts
@@ -154,7 +154,7 @@ export const COMMIT_ATTRIBUTION_SPAN_NAME = 'codeburn.commit'
* 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.
+ * (repo, sha) / traceId. Identical states dedupe via the sent-ledger.
*/
export type AttributionItem = {
kind: 'session' | 'commit'
diff --git a/tests/sync-attribution.test.ts b/tests/sync-attribution.test.ts
index 85b7b385..33786f27 100644
--- a/tests/sync-attribution.test.ts
+++ b/tests/sync-attribution.test.ts
@@ -572,6 +572,17 @@ describe('buildAttributionOtlpPayload', () => {
expect(commitAttrs['git.repo']).toEqual({ stringValue: 'github.com/acme/widget' })
})
+ it('keeps session identity off the wire; join is the shared traceId', () => {
+ const items = flattenAttributionRecords([makeRecord()])
+ const payload = buildAttributionOtlpPayload(items)
+ const spans = payload.resourceSpans[0]!.scopeSpans[0]!.spans
+ expect(spans.length).toBeGreaterThan(0)
+ for (const span of spans) {
+ expect(attrMap(span.attributes)['ai.session_id']).toBeUndefined()
+ expect(span.traceId).toBe(deriveTraceId('sess-1'))
+ }
+ })
+
it('omits git.repo when null and pr_links when empty', () => {
const items = flattenAttributionRecords([makeRecord({ repo: null, prLinks: [], commits: [] })])
const payload = buildAttributionOtlpPayload(items)
From e96aa39853474085bce5e65e0771957136cb99eb Mon Sep 17 00:00:00 2001
From: Aditya Vikram Singh <247195684+avs-io@users.noreply.github.com>
Date: Fri, 21 Aug 2026 22:55:51 +0530
Subject: [PATCH 19/24] test: daily[] rows consume durable.days, not headlines
Extra High MERGE AFTER FIX on 7e51413. JSON-report headlines
come from durable.data; daily[] is the durable.days consumer.
Oracle helper and assertions unchanged.
---
tests/day-aggregator.test.ts | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/tests/day-aggregator.test.ts b/tests/day-aggregator.test.ts
index db1c0adc..43159cd9 100644
--- a/tests/day-aggregator.test.ts
+++ b/tests/day-aggregator.test.ts
@@ -464,7 +464,7 @@ describe('buildPeriodDataFromDays', () => {
describe('daily-cache ↔ report daily-bucket parity', () => {
// The daily cache (history.daily + provider breakdown) and JSON-report
- // headlines (durable.days from buildDurablePeriod) must bucket days by the
+ // daily[] rows (durable.days from buildDurablePeriod) 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 PER-CALL for cost/savings/calls (issue #852) with
From fda7e8024dd67c65a0d541815c352072aa87c009 Mon Sep 17 00:00:00 2001
From: iamtoruk
Date: Fri, 21 Aug 2026 11:50:23 -0700
Subject: [PATCH 20/24] fix(codex): stop double-billing reasoning output, price
cache writes at the explicit rate only
Reasoning tokens are a subset of output_tokens for OpenAI models, not an
extra bucket: on a 1,396-rollout corpus all 134,316 token_count events
carrying a total satisfy input + output == total. CodeBurn added
reasoning_output_tokens on top when pricing a codex call, in the
cache-rehydration re-price, and in the models/audit display sums. That
overstated codex cost by $166.03 (3.5%) and displayed output tokens by
34.6% on that corpus. Both cost sites and the display sums now go through
one shared billableOutputTokens() so a cold parse and a warm read cannot
drift apart.
cache_write_input_tokens (codex PR #33454) was never read and
cacheCreationInputTokens was hardcoded to 0. It is now carved out of the
uncached-input bucket and clamped to it, but routed to the cache-write
bucket ONLY when the pricing source publishes an explicit cache-write rate.
buildCosts fabricates 1.25x input when a source omits one, which is correct
for Anthropic and would have invented a surcharge OpenAI never charged on
gpt-5.5 / 5.4 / 5.3-codex / gpt-5. ModelCosts now carries
cacheWriteCostIsExplicit so that distinction survives getModelCosts.
A cost change invalidates persisted output: codex-results.json v10 -> v11
(stores costUSD verbatim), the codex parse version moves (the token-bucket
change does not self-heal on read), and the daily cache goes 20 -> 23 (21 is
claimed by the #946 landing branch and 22 by PR #1056). The upgrade-path
corpus asserts codex tokens and calls exactly and reports the repricing.
Closes #1075
---
CHANGELOG.md | 1 +
scripts/upgrade-path/compare.mjs | 13 +-
scripts/upgrade-path/run.mjs | 2 +-
src/audit-report.ts | 4 +-
src/codex-cache.ts | 6 +-
src/daily-cache.ts | 13 +-
src/models-report.ts | 11 +-
src/models.ts | 23 ++
src/parser.ts | 12 +-
src/providers/codex.ts | 44 ++-
src/session-cache.ts | 7 +-
tests/audit-report.test.ts | 7 +-
tests/codex-pricing-1075-rehydrate.test.ts | 69 ++++
tests/codex-pricing-1075.test.ts | 352 +++++++++++++++++++++
tests/models-report.test.ts | 5 +-
15 files changed, 542 insertions(+), 27 deletions(-)
create mode 100644 tests/codex-pricing-1075-rehydrate.test.ts
create mode 100644 tests/codex-pricing-1075.test.ts
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 407cffca..f1b9bad0 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -38,6 +38,7 @@
- **The resident `codeburn serve` child.** The first real panel request is also the cache warm-up, so startup never runs an artificial warm-up query beside a duplicate one-shot child; each served command carries its own read-only option allowlist, and anything outside it falls back to a normal spawn; the child exits when its stdin closes, so it can never outlive the app. Requests whose response exceeds the 16 MiB frame limit still replace the child, but that deliberate kill no longer spends the resident's unexpected-death budget. (#972)
### Fixed
+- **Codex spend no longer counts reasoning tokens twice, and cache writes are priced only where OpenAI actually charges for them.** OpenAI bills reasoning tokens as *part of* `output_tokens`, not on top of it — on a 1,396-rollout corpus all 134,316 events carrying a total satisfy `input + output == total` — but CodeBurn added `reasoning_output_tokens` to output when pricing a Codex call and again in the models, audit and per-model displays. Every Codex number was therefore too high: on that corpus **cost by $166.03 (3.5%)** and **displayed Output tokens by 34.6%** ($4,713.12 -> $4,547.09; 22.6M -> 16.8M output tokens). The raw `reasoningTokens` figure is unchanged and still reported on its own; only the double-count is gone. Both places that price a Codex call — the parser and the cache-rehydration re-price — now go through one shared `billableOutputTokens` helper, so a cold run and a warm run can never disagree. Separately, Codex's `cache_write_input_tokens` (new in codex PR #33454) was never read and cache-creation tokens were hardcoded to 0; they are now carved out of the uncached-input bucket and clamped so they can never exceed it. That carve-out happens **only on models whose pricing source publishes a real cache-write rate** — gpt-5.6 and its terra/sol/luna variants charge 1.25x input for a cache write, everything before it charges nothing extra — because CodeBurn fabricates a 1.25x rate when a source omits one, and charging that would have invented a surcharge on gpt-5.5, gpt-5.4, gpt-5.3-codex and gpt-5. On models without an explicit rate the tokens stay in the plain input bucket and the price is unchanged to the cent. The field is new enough that today's impact is $0 on that corpus. Codex sessions re-parse once and the daily cache re-derives once off the warm session cache (a global re-derivation of every day and every provider, since it has no per-provider invalidation); no other provider's numbers move. Long-context pricing tiers from the same report are tracked separately in #1076 and the missing `gpt-5.6-codex` snapshot rows in #1077. Thanks @chr-evensen. (#1075)
- **Codex calls attributed from session metadata no longer carry a stale model.** The Buffer fast path scanned `session_meta` for the first `"model"` string anywhere in the payload, so a nested `base_instructions.provenance.model` was read as if it were `payload.model` — and since the model is last-writer-wins state, that wrong value was credited to every call before the rollout's first `turn_context` and to every call after any mid-file `session_meta` (29 of 1380 rollouts on one real corpus carry a late `session_meta`, and 57 record usage before any `turn_context`). Direct payload fields are now read depth-aware, which is what the non-fast `JSON.parse` path always did. Codex sessions re-parse once (~9s on a 4 GB rollout corpus) and the daily cache re-derives once off the warm session cache, a global re-derivation of every day and every provider since it has no per-provider invalidation; it moves per-model attribution, and clears any rollup an earlier parse change had left stale. Days whose transcripts have partly aged out are held by the never-lose guard: on a real 110-day cache no day lost value and none disappeared — 100 days came back identical and 9 grok days rose by $19.80 in total. Thanks @timdp. (#1040)
- **Codex `session_meta` cwd / session id / originator follow the same depth-1 window as `model`.** #1040 fixed nested `provenance.model`; the compact Buffer path still took the first `cwd`, `session_id`, `originator`, `name`, `forked_from_id` or `model_provider` anywhere in the payload, so a `dynamic_tools[].name` (or any same-named nested key) could steal the top-level field. Those strings now use the existing payload-depth-1 scan. Function-call `name` on other event types is unchanged. Codex sessions re-parse once. (#1045)
- **Plan rows for sticker-price presets read as a budget instead of live provider quota.** There is no Grok quota endpoint, so a SuperGrok row was parsed API-equivalent spend divided by the plan's sticker price on a monthly reset — but the TUI labelled that math "plan" and "reset", which next to a client showing xAI's real weekly window read as CodeBurn being wrong. The bars and the arithmetic are unchanged; the words are not. Both the dashboard and the desktop app now say the number is an API-equivalent monthly budget and not a live provider window, in the same wording on both surfaces, and for every preset rather than as a SuperGrok special case. The window is anniversary-based (`plan.resetDay`, settable with `codeburn plan set --reset-day`), so it is called a budget reset rather than a calendar one. The row was also shortened to fit 80 columns: at that width the percentage and the projected month were being truncated away, including on custom plans, whose label carries the provider.
diff --git a/scripts/upgrade-path/compare.mjs b/scripts/upgrade-path/compare.mjs
index 0d0ef49a..46b01c5c 100644
--- a/scripts/upgrade-path/compare.mjs
+++ b/scripts/upgrade-path/compare.mjs
@@ -22,8 +22,15 @@
// which is the part the corpus can honestly establish.
// dsh did not exist in the published CLI. Reported; required to be absent
// in the baseline and present after the upgrade.
+// codex PRICING changed by design in #1075: reasoning tokens are billed
+// inside output rather than on top of it, and cache writes are carved out
+// of the input bucket. Nothing about what was PARSED moved, so codex keeps
+// the full exact treatment for the call count and every token field; only
+// the cost tolerance is lifted, and the delta is reported instead. Drop it
+// from this list once a published CLI carries the fix.
const EXACT = ['claude', 'codex', 'gemini', 'kiro', 'cursor']
const CHANGED_BY_DESIGN = ['grok']
+const COST_CHANGED_BY_DESIGN = ['codex']
const NEW_IN_THIS_RELEASE = ['dsh']
const COST_TOLERANCE = 0.005 // 0.5% relative
@@ -95,7 +102,11 @@ for (const name of providers) {
if (b.calls !== u.calls) diffs.push(`calls ${b.calls} != ${u.calls}`)
for (const f of TOKEN_FIELDS) if (b[f] !== u[f]) diffs.push(`${f} ${b[f]} != ${u[f]}`)
const costDrift = relDiff(b.cost, u.cost)
- if (costDrift > COST_TOLERANCE) diffs.push(`cost ${fmt(b.cost)} != ${fmt(u.cost)} (${(costDrift * 100).toFixed(3)}% > ${(COST_TOLERANCE * 100).toFixed(1)}%)`)
+ if (COST_CHANGED_BY_DESIGN.includes(name)) {
+ notes.push(`${name}: cost ${fmt(b.cost)} -> ${fmt(u.cost)} (${(costDrift * 100).toFixed(3)}%) — repricing expected (#1075); tokens and calls still asserted exactly`)
+ } else if (costDrift > COST_TOLERANCE) {
+ diffs.push(`cost ${fmt(b.cost)} != ${fmt(u.cost)} (${(costDrift * 100).toFixed(3)}% > ${(COST_TOLERANCE * 100).toFixed(1)}%)`)
+ }
if (!EXACT.includes(name)) {
notes.push(`${name}: no expectation declared in compare.mjs; ${diffs.length ? diffs.join(', ') : 'identical'}`)
verdict = diffs.length ? 'differs (unclassified)' : 'identical'
diff --git a/scripts/upgrade-path/run.mjs b/scripts/upgrade-path/run.mjs
index cab81a95..586becca 100644
--- a/scripts/upgrade-path/run.mjs
+++ b/scripts/upgrade-path/run.mjs
@@ -33,7 +33,7 @@ const WORK = process.env['UPGRADE_PATH_WORK'] || join(tmpdir(), 'codeburn upgrad
const OLD_SESSION_CACHE = 'session-cache.v7.json'
const OLD_DAILY_CACHE = 'daily-cache.v17.json'
const NEW_SESSION_CACHE_DIR = 'session-cache.v9'
-const NEW_DAILY_CACHE = 'daily-cache.v20.json'
+const NEW_DAILY_CACHE = 'daily-cache.v23.json'
const HOME = join(WORK, 'user home')
const PAYLOADS = join(WORK, 'payloads')
diff --git a/src/audit-report.ts b/src/audit-report.ts
index 7a40c5c7..c3c4f251 100644
--- a/src/audit-report.ts
+++ b/src/audit-report.ts
@@ -1,4 +1,4 @@
-import { getModelCosts, sanitizeModelForDisplay, type ModelCosts } from './models.js'
+import { billableOutputTokens, getModelCosts, sanitizeModelForDisplay, type ModelCosts } from './models.js'
import { getProvider } from './providers/index.js'
import { formatCost, formatTokens } from './format.js'
import { renderTable, type TableColumn } from './text-table.js'
@@ -124,7 +124,7 @@ export async function aggregateAudit(projects: ProjectSummary[]): Promise, capture?: {
let prevCumulativeTotal: number | null = resume?.state.prevCumulativeTotal ?? null
let prevInput = resume?.state.prevInput ?? 0
let prevCached = resume?.state.prevCached ?? 0
+ let prevCacheWrite = resume?.state.prevCacheWrite ?? 0
let prevOutput = resume?.state.prevOutput ?? 0
let prevReasoning = resume?.state.prevReasoning ?? 0
let pendingTools: string[] = resume ? [...resume.state.pendingTools] : []
@@ -795,6 +803,7 @@ function createParser(source: SessionSource, seenKeys: Set, capture?: {
prevCumulativeTotal,
prevInput,
prevCached,
+ prevCacheWrite,
prevOutput,
prevReasoning,
pendingTools: [...pendingTools],
@@ -1014,12 +1023,14 @@ function createParser(source: SessionSource, seenKeys: Set, capture?: {
const last = info.last_token_usage
let inputTokens = 0
let cachedInputTokens = 0
+ let cacheWriteTokens = 0
let outputTokens = 0
let reasoningTokens = 0
if (last) {
inputTokens = last.input_tokens ?? 0
cachedInputTokens = last.cached_input_tokens ?? 0
+ cacheWriteTokens = last.cache_write_input_tokens ?? 0
outputTokens = last.output_tokens ?? 0
reasoningTokens = last.reasoning_output_tokens ?? 0
} else if (cumulativeTotal > 0) {
@@ -1027,6 +1038,7 @@ function createParser(source: SessionSource, seenKeys: Set, capture?: {
if (!total) continue
inputTokens = (total.input_tokens ?? 0) - prevInput
cachedInputTokens = (total.cached_input_tokens ?? 0) - prevCached
+ cacheWriteTokens = (total.cache_write_input_tokens ?? 0) - prevCacheWrite
outputTokens = (total.output_tokens ?? 0) - prevOutput
reasoningTokens = (total.reasoning_output_tokens ?? 0) - prevReasoning
}
@@ -1042,6 +1054,7 @@ function createParser(source: SessionSource, seenKeys: Set, capture?: {
if (total) {
prevInput = total.input_tokens ?? 0
prevCached = total.cached_input_tokens ?? 0
+ prevCacheWrite = total.cache_write_input_tokens ?? 0
prevOutput = total.output_tokens ?? 0
prevReasoning = total.reasoning_output_tokens ?? 0
}
@@ -1053,7 +1066,22 @@ function createParser(source: SessionSource, seenKeys: Set, capture?: {
// Normalize to Anthropic semantics: inputTokens = non-cached only.
const uncachedInputTokens = Math.max(0, inputTokens - cachedInputTokens)
+ // Cache writes are carved out of the uncached input, never added to
+ // it: clamp so a malformed or lagging count can never drive the plain
+ // input bucket negative.
+ const cacheWriteInputTokens = Math.max(0, Math.min(cacheWriteTokens, uncachedInputTokens))
+
const model = resolveModel(entry.payload, sessionModel)
+ // Only move tokens into the cache-write bucket when the pricing
+ // source publishes a real cache-write rate for this model (gpt-5.6+
+ // charges 1.25x input; everything before it charges nothing extra).
+ // Otherwise buildCosts' fabricated 1.25x default would invent a
+ // surcharge that OpenAI never billed, so the tokens stay where they
+ // already were -- in plain input, priced exactly as before.
+ const billedCacheWriteTokens = cacheWriteInputTokens > 0 && getModelCosts(model)?.cacheWriteCostIsExplicit
+ ? cacheWriteInputTokens
+ : 0
+ const billedInputTokens = uncachedInputTokens - billedCacheWriteTokens
const timestamp = entry.timestamp ?? ''
// Forked sessions copy the parent's entire token_count history
// (re-timestamped), so replays must collide with the parent's events
@@ -1074,11 +1102,15 @@ function createParser(source: SessionSource, seenKeys: Set, capture?: {
if (seenKeys.has(dedupKey)) continue
seenKeys.add(dedupKey)
+ // Reasoning tokens are already inside output_tokens, so they are NOT
+ // added here. The cache-rehydration twin of this line lives in
+ // src/parser.ts (cachedCallToApiCall); both call billableOutputTokens
+ // so a fresh parse and a cache read can never price differently.
const costUSD = calculateCost(
model,
- uncachedInputTokens,
- outputTokens + reasoningTokens,
- 0,
+ billedInputTokens,
+ billableOutputTokens('codex', outputTokens, reasoningTokens),
+ billedCacheWriteTokens,
cachedInputTokens,
0,
)
@@ -1086,9 +1118,9 @@ function createParser(source: SessionSource, seenKeys: Set, capture?: {
pendingTaskCalls.push({
provider: 'codex',
model,
- inputTokens: uncachedInputTokens,
+ inputTokens: billedInputTokens,
outputTokens,
- cacheCreationInputTokens: 0,
+ cacheCreationInputTokens: billedCacheWriteTokens,
cacheReadInputTokens: cachedInputTokens,
cachedInputTokens,
reasoningTokens,
diff --git a/src/session-cache.ts b/src/session-cache.ts
index f165652e..d8c73f27 100644
--- a/src/session-cache.ts
+++ b/src/session-cache.ts
@@ -281,7 +281,12 @@ export const PROVIDER_PARSE_VERSIONS: Record = {
// nested base_instructions provenance.model cannot overwrite turn_context.
// session-meta-fields-v1: the same depth-1 window for cwd/name/originator/
// session_id/forked_from_id/model_provider, not just model.
- codex: 'mcp-attribution-v5-est-cost-active-timing-mcp-wait-rich-capture-v1-cross-provider-pr-v1-session-meta-model-v1-session-meta-fields-v1',
+ // codex-pricing-v1 (#1075): reasoning tokens are no longer added on top of
+ // output, and cache_write_input_tokens moves out of the plain input bucket on
+ // models with an explicit cache-write rate. The bucket move does NOT self-heal
+ // on read (cached entries store the buckets, not the raw event), so cached
+ // sessions must re-parse.
+ codex: 'mcp-attribution-v5-est-cost-active-timing-mcp-wait-rich-capture-v1-cross-provider-pr-v1-session-meta-model-v1-session-meta-fields-v1-codex-pricing-v1',
cursor: 'composer-anchored-crediting-v1-est-cost',
'cursor-agent': 'workspaceless-transcript-v1',
// source-provenance-v1 (#944): CLI sessions were misread as VS Code
diff --git a/tests/audit-report.test.ts b/tests/audit-report.test.ts
index 1723712c..baf89440 100644
--- a/tests/audit-report.test.ts
+++ b/tests/audit-report.test.ts
@@ -86,8 +86,11 @@ describe('aggregateAudit', () => {
expect(r.raw.reasoningTokens).toBe(10)
expect(r.raw.cacheReadInputTokens).toBe(200)
expect(r.raw.cachedInputTokens).toBe(300)
- // reasoning folds into output for pricing
- expect(r.displayed.outputTokens).toBe(110)
+ // Reasoning does NOT fold into output for claude or codex: both bill it
+ // as part of output_tokens already, so adding it would double-count
+ // (#1075). Providers that report reasoning as a separate bucket still get
+ // the additive treatment - see tests/codex-pricing-1075.test.ts.
+ expect(r.displayed.outputTokens).toBe(100)
// cache read is the SUM of per-call max(anthropic, openai), not max of sums
expect(r.displayed.cacheReadTokens).toBe(500)
// attributed cost is preserved exactly
diff --git a/tests/codex-pricing-1075-rehydrate.test.ts b/tests/codex-pricing-1075-rehydrate.test.ts
new file mode 100644
index 00000000..4b112347
--- /dev/null
+++ b/tests/codex-pricing-1075-rehydrate.test.ts
@@ -0,0 +1,69 @@
+// #1075, cost site 2 of 2. Codex is NOT on parser.ts's reported-cost
+// pass-through allowlist, so the session cache stores its calls with
+// `costUSD: undefined` and every warm run re-prices them from the stored token
+// buckets in cachedCallToApiCall. That line and the one in the codex provider
+// are twins: if only one drops the reasoning double-count, a user's number
+// changes between a cold and a warm run. This drives the full parseAllSessions
+// pipeline twice against the same file to prove they agree.
+//
+// Own file because the codex provider captures CODEX_HOME when its module is
+// first evaluated, so the env must be set before any import of it.
+
+import { afterAll, beforeEach, expect, it, vi } from 'vitest'
+import { mkdir, rm, writeFile } from 'fs/promises'
+import { join } from 'path'
+
+const testRoot = vi.hoisted(() => {
+ const root = `${process.env['TMPDIR'] || '/tmp'}/codex-1075-rehydrate-${process.pid}-${Date.now()}`
+ process.env['HOME'] = `${root}/home`
+ process.env['USERPROFILE'] = `${root}/home`
+ process.env['CODEX_HOME'] = `${root}/codex`
+ return root
+})
+
+const CODEX_HOME = join(testRoot, 'codex')
+const CACHE_DIR = join(testRoot, 'cache')
+
+// gpt-5.5: input 5e-6, output 30e-6, cacheRead 5e-7 (src/data/litellm-snapshot.json).
+// 800 uncached input + 200 cached + 1000 output, of which 400 are reasoning.
+const EXPECTED = 800 * 5e-6 + 200 * 5e-7 + 1000 * 30e-6
+
+beforeEach(() => {
+ process.env['HOME'] = join(testRoot, 'home')
+ process.env['USERPROFILE'] = join(testRoot, 'home')
+ process.env['CODEX_HOME'] = CODEX_HOME
+ process.env['CODEBURN_CACHE_DIR'] = CACHE_DIR
+})
+
+afterAll(async () => {
+ await rm(testRoot, { recursive: true, force: true })
+})
+
+it('prices a codex call the same on a cold parse and a cache-rehydrated read', async () => {
+ const sessionDir = join(CODEX_HOME, 'sessions', '2026', '08', '16')
+ await mkdir(sessionDir, { recursive: true })
+ await mkdir(CACHE_DIR, { recursive: true })
+ const usage = { input_tokens: 1000, cached_input_tokens: 200, output_tokens: 1000, reasoning_output_tokens: 400, total_tokens: 2000 }
+ await writeFile(join(sessionDir, 'rollout-1075.jsonl'), [
+ JSON.stringify({ type: 'session_meta', timestamp: '2026-08-16T10:00:00Z', payload: { session_id: 's1075', model: 'gpt-5.5', cwd: '/Users/test/proj', originator: 'codex_cli_rs' } }),
+ JSON.stringify({ type: 'response_item', timestamp: '2026-08-16T10:00:10Z', payload: { type: 'message', role: 'user', content: [{ type: 'input_text', text: 'hello' }] } }),
+ JSON.stringify({ type: 'event_msg', timestamp: '2026-08-16T10:01:00Z', payload: { type: 'token_count', info: { model: 'gpt-5.5', last_token_usage: usage, total_token_usage: usage } } }),
+ ].join('\n') + '\n')
+
+ const { clearSessionCache, parseAllSessions } = await import('../src/parser.js')
+
+ clearSessionCache()
+ const cold = await parseAllSessions(undefined, 'codex')
+ const coldCost = cold.reduce((sum, p) => sum + p.totalCostUSD, 0)
+
+ // Drop the in-memory cache only: session-cache.json on disk now serves the
+ // unchanged file, so this run's cost comes out of cachedCallToApiCall.
+ clearSessionCache()
+ const warm = await parseAllSessions(undefined, 'codex')
+ const warmCost = warm.reduce((sum, p) => sum + p.totalCostUSD, 0)
+
+ // Revert only src/providers/codex.ts and the cold leg breaks; revert only
+ // src/parser.ts's outputForCost and the warm leg breaks.
+ expect(coldCost).toBeCloseTo(EXPECTED, 12)
+ expect(warmCost).toBeCloseTo(EXPECTED, 12)
+})
diff --git a/tests/codex-pricing-1075.test.ts b/tests/codex-pricing-1075.test.ts
new file mode 100644
index 00000000..9e1b5eb6
--- /dev/null
+++ b/tests/codex-pricing-1075.test.ts
@@ -0,0 +1,352 @@
+// Regression suite for #1075 (reported by chr-evensen).
+//
+// Two independent codex pricing bugs, each with the site that would silently
+// drift from its twin if only one half were reverted:
+//
+// A. reasoning_output_tokens is a SUBSET of output_tokens (OpenAI bills
+// reasoning as part of output; every token_count event in a 134k-event
+// corpus satisfies input + output == total), but codeburn added the two.
+// Priced in TWO places -- the fresh parse in src/providers/codex.ts and
+// the cache-rehydration re-price in src/parser.ts -- plus three display
+// sums. Both cost sites now go through billableOutputTokens(). The
+// cache-rehydration half lives in codex-pricing-1075-rehydrate.test.ts,
+// which needs CODEX_HOME set before the provider module is evaluated.
+//
+// B. cache_write_input_tokens was never read. It is now carved out of the
+// uncached-input bucket, but ONLY on models whose pricing source carries
+// an explicit cache-write rate: buildCosts() fabricates 1.25x input when
+// the source omits one, which is right for Anthropic but would invent a
+// surcharge OpenAI never charged on every pre-5.6 model.
+
+import { mkdir, mkdtemp, readFile, rm, writeFile } from 'fs/promises'
+import { tmpdir } from 'os'
+import { join } from 'path'
+import { afterEach, beforeEach, describe, expect, it } from 'vitest'
+
+import { aggregateAudit } from '../src/audit-report.js'
+import { aggregateModels } from '../src/models-report.js'
+import { clearCodexMemCaches, readCachedCodexResults } from '../src/codex-cache.js'
+import { currentTzKey, ensureCacheHydrated, toDateString, type DailyEntry } from '../src/daily-cache.js'
+import { createCodexProvider } from '../src/providers/codex.js'
+import type { ParsedProviderCall } from '../src/providers/types.js'
+import type {
+ ClassifiedTurn,
+ ParsedApiCall,
+ ProjectSummary,
+ SessionSummary,
+ TaskCategory,
+ TokenUsage,
+} from '../src/types.js'
+
+// Snapshot ground truth (src/data/litellm-snapshot.json), USD per token:
+// gpt-5.6-terra input 2e-6 output 12e-6 cacheWrite 2.5e-6 (EXPLICIT) cacheRead 2e-7
+// gpt-5.5 input 5e-6 output 30e-6 cacheWrite null (fabricated) cacheRead 5e-7
+const TERRA = { input: 2e-6, output: 12e-6, cacheWrite: 2.5e-6, cacheRead: 2e-7 }
+const GPT55 = { input: 5e-6, output: 30e-6, cacheRead: 5e-7 }
+
+let tmpDir: string
+beforeEach(async () => { tmpDir = await mkdtemp(join(tmpdir(), 'codex-1075-')) })
+afterEach(async () => { await rm(tmpDir, { recursive: true, force: true }) })
+
+type Usage = {
+ input_tokens: number
+ cached_input_tokens?: number
+ cache_write_input_tokens?: number
+ output_tokens: number
+ reasoning_output_tokens?: number
+}
+
+async function parseOneEvent(model: string, usage: Usage): Promise {
+ const total = usage.input_tokens + usage.output_tokens
+ const sessionDir = join(tmpDir, 'sessions', '2026', '08', '16')
+ await mkdir(sessionDir, { recursive: true })
+ const filePath = join(sessionDir, `rollout-${model}-${Math.random().toString(36).slice(2)}.jsonl`)
+ await writeFile(filePath, [
+ JSON.stringify({
+ type: 'session_meta',
+ timestamp: '2026-08-16T10:00:00Z',
+ payload: { cwd: '/Users/t/p', originator: 'codex-cli', session_id: 's1075', model },
+ }),
+ JSON.stringify({
+ type: 'event_msg',
+ timestamp: '2026-08-16T10:01:00Z',
+ payload: {
+ type: 'token_count',
+ info: { model, last_token_usage: { ...usage, total_tokens: total }, total_token_usage: { ...usage, total_tokens: total } },
+ },
+ }),
+ ].join('\n') + '\n')
+
+ const provider = createCodexProvider(tmpDir)
+ const parser = provider.createSessionParser({ path: filePath, project: 'test', provider: 'codex' }, new Set())
+ const calls: ParsedProviderCall[] = []
+ for await (const call of parser.parse()) calls.push(call)
+ expect(calls).toHaveLength(1)
+ return calls[0]!
+}
+
+// ── Fix A: reasoning is already inside output ─────────────────────────────
+
+describe('#1075 A - reasoning is not billed on top of output', () => {
+ it('prices a fresh codex parse from output_tokens alone', async () => {
+ const call = await parseOneEvent('gpt-5.5', {
+ input_tokens: 1000,
+ cached_input_tokens: 200,
+ output_tokens: 1000,
+ reasoning_output_tokens: 400,
+ })
+
+ // 800 uncached input + 200 cached + 1000 output. The 400 reasoning tokens
+ // are INSIDE the 1000, so they must not be priced again.
+ const expected = 800 * GPT55.input + 200 * GPT55.cacheRead + 1000 * GPT55.output
+ expect(call.costUSD).toBeCloseTo(expected, 12)
+ // Guard the direction: the pre-fix arithmetic charged 1400 output tokens.
+ const preFix = 800 * GPT55.input + 200 * GPT55.cacheRead + 1400 * GPT55.output
+ expect(call.costUSD).toBeLessThan(preFix)
+ // The raw fields are still reported untouched; only the pricing changed.
+ expect(call.outputTokens).toBe(1000)
+ expect(call.reasoningTokens).toBe(400)
+ })
+
+ it('does not double-count reasoning in the displayed output tokens', async () => {
+ const codex = makeApiCall('codex', 'gpt-5.5', { outputTokens: 1000, reasoningTokens: 400 })
+ // A provider that really does report reasoning as a separate bucket keeps
+ // the additive behaviour, so this is a codex carve-out and not a blanket
+ // change to every display sum.
+ const additive = makeApiCall('hermes', 'gpt-5.5', { outputTokens: 1000, reasoningTokens: 400 })
+ const projects = [makeProject([codex, additive])]
+
+ const auditRows = await aggregateAudit(projects)
+ expect(auditRows.find(r => r.provider === 'codex')!.displayed.outputTokens).toBe(1000)
+ expect(auditRows.find(r => r.provider === 'hermes')!.displayed.outputTokens).toBe(1400)
+
+ const modelRows = await aggregateModels(projects)
+ expect(modelRows.find(r => r.provider === 'codex')!.outputTokens).toBe(1000)
+ expect(modelRows.find(r => r.provider === 'hermes')!.outputTokens).toBe(1400)
+ })
+})
+
+// ── Fix B: cache_write_input_tokens, guarded ──────────────────────────────
+
+describe('#1075 B - cache_write_input_tokens', () => {
+ it('prices cache writes at the explicit rate on gpt-5.6-terra', async () => {
+ const call = await parseOneEvent('gpt-5.6-terra', {
+ input_tokens: 1000,
+ cached_input_tokens: 200,
+ cache_write_input_tokens: 300,
+ output_tokens: 100,
+ })
+
+ expect(call.inputTokens).toBe(500)
+ expect(call.cacheCreationInputTokens).toBe(300)
+ expect(call.cacheReadInputTokens).toBe(200)
+ const expected =
+ 500 * TERRA.input +
+ 300 * TERRA.cacheWrite +
+ 200 * TERRA.cacheRead +
+ 100 * TERRA.output
+ expect(expected).toBeCloseTo(0.00299, 12)
+ expect(call.costUSD).toBeCloseTo(expected, 12)
+ })
+
+ it('THE GUARD: leaves cache writes in the input bucket when the model has no explicit rate', async () => {
+ // gpt-5.5 carries `null` for cache_creation_input_token_cost, so
+ // buildCosts fabricates 1.25x input for it. OpenAI charges nothing extra
+ // to write cache before gpt-5.6, so routing these tokens through that
+ // fabricated rate would invent a surcharge. Cost must be byte-identical to
+ // the pre-fix number. Delete the guard and this test fails.
+ const withWrite = await parseOneEvent('gpt-5.5', {
+ input_tokens: 1000,
+ cached_input_tokens: 200,
+ cache_write_input_tokens: 300,
+ output_tokens: 100,
+ })
+ const withoutWrite = await parseOneEvent('gpt-5.5', {
+ input_tokens: 1000,
+ cached_input_tokens: 200,
+ output_tokens: 100,
+ })
+
+ expect(withWrite.inputTokens).toBe(800)
+ expect(withWrite.cacheCreationInputTokens).toBe(0)
+ const expected = 800 * GPT55.input + 200 * GPT55.cacheRead + 100 * GPT55.output
+ expect(withWrite.costUSD).toBeCloseTo(expected, 12)
+ expect(withWrite.costUSD).toBeCloseTo(withoutWrite.costUSD, 12)
+ // The fabricated rate is 1.25 x 5e-6; make sure not a cent of it landed.
+ expect(withWrite.costUSD).toBeLessThan(expected + 300 * GPT55.input * 1.25)
+ })
+
+ it('clamps a cache-write count larger than the uncached input', async () => {
+ const call = await parseOneEvent('gpt-5.6-terra', {
+ input_tokens: 1000,
+ cached_input_tokens: 200,
+ cache_write_input_tokens: 5000,
+ output_tokens: 100,
+ })
+
+ expect(call.inputTokens).toBe(0)
+ expect(call.cacheCreationInputTokens).toBe(800)
+ expect(call.costUSD).toBeCloseTo(800 * TERRA.cacheWrite + 200 * TERRA.cacheRead + 100 * TERRA.output, 12)
+ })
+})
+
+// ── Cache invalidation: a cost change must not be served from stale bytes ──
+
+describe('#1075 cache invalidation', () => {
+ it('discards a v10 codex results cache (it stores costUSD verbatim)', async () => {
+ const cacheDir = join(tmpDir, 'cache')
+ await mkdir(cacheDir, { recursive: true })
+ const sessionFile = join(tmpDir, 'rollout-stale.jsonl')
+ await writeFile(sessionFile, '{}\n')
+
+ const { statSync } = await import('fs')
+ const s = statSync(sessionFile)
+ const stale: ParsedProviderCall = {
+ provider: 'codex',
+ model: 'gpt-5.5',
+ inputTokens: 800,
+ outputTokens: 1000,
+ cacheCreationInputTokens: 0,
+ cacheReadInputTokens: 200,
+ cachedInputTokens: 200,
+ reasoningTokens: 400,
+ webSearchRequests: 0,
+ costUSD: 0.0445, // the pre-fix, reasoning-double-counted number
+ tools: [],
+ bashCommands: [],
+ timestamp: '2026-08-16T10:01:00Z',
+ speed: 'standard',
+ deduplicationKey: 'codex:stale',
+ }
+ await writeFile(join(cacheDir, 'codex-results.json'), JSON.stringify({
+ version: 10,
+ files: { [sessionFile]: { dev: s.dev, ino: s.ino, mtimeMs: s.mtimeMs, sizeBytes: s.size, project: 'p', calls: [stale] } },
+ }))
+
+ const prevCacheDir = process.env['CODEBURN_CACHE_DIR']
+ process.env['CODEBURN_CACHE_DIR'] = cacheDir
+ try {
+ clearCodexMemCaches()
+ // Revert CODEX_CACHE_VERSION to 10 and this returns the stale $0.0445 call.
+ expect(await readCachedCodexResults(sessionFile)).toBeNull()
+ } finally {
+ if (prevCacheDir === undefined) delete process.env['CODEBURN_CACHE_DIR']; else process.env['CODEBURN_CACHE_DIR'] = prevCacheDir
+ }
+ })
+
+ it('re-derives days finalized at daily-cache v20', async () => {
+ const cacheRoot = join(tmpDir, 'daily')
+ await mkdir(cacheRoot, { recursive: true })
+ const prevCacheDir = process.env['CODEBURN_CACHE_DIR']
+ process.env['CODEBURN_CACHE_DIR'] = cacheRoot
+ try {
+ const date = toDateString(new Date(Date.now() - 7 * 24 * 60 * 60 * 1000))
+ const yesterday = toDateString(new Date(Date.now() - 24 * 60 * 60 * 1000))
+ const oldPath = join(cacheRoot, 'daily-cache.v20.json')
+ const oldCache = {
+ version: 20,
+ savingsConfigHash: 'cfg',
+ tzKey: currentTzKey(),
+ lastComputedDate: yesterday,
+ days: [codexDay(date, 99)],
+ complete: true,
+ watermarkTrusted: true,
+ }
+ await writeFile(oldPath, JSON.stringify(oldCache))
+
+ let parseCount = 0
+ const hydrated = await ensureCacheHydrated(
+ async () => { parseCount++; return [] },
+ () => [codexDay(date, 2)],
+ 'cfg',
+ () => true,
+ )
+
+ // Drop MIN_SUPPORTED_VERSION back to 20 and the v20 day is trusted as-is,
+ // so parseCount stays 0 and the day keeps its overstated $99.
+ expect(parseCount).toBe(1)
+ expect(hydrated.days.find(d => d.date === date)?.cost).toBe(2)
+ expect(JSON.parse(await readFile(oldPath, 'utf8'))).toEqual(oldCache)
+ } finally {
+ if (prevCacheDir === undefined) delete process.env['CODEBURN_CACHE_DIR']; else process.env['CODEBURN_CACHE_DIR'] = prevCacheDir
+ }
+ })
+})
+
+// ── fixtures ──────────────────────────────────────────────────────────────
+
+function makeApiCall(provider: string, model: string, usage: Partial): ParsedApiCall {
+ return {
+ provider,
+ model,
+ usage: {
+ inputTokens: 0,
+ outputTokens: 0,
+ cacheCreationInputTokens: 0,
+ cacheReadInputTokens: 0,
+ cachedInputTokens: 0,
+ reasoningTokens: 0,
+ webSearchRequests: 0,
+ ...usage,
+ },
+ costUSD: 0,
+ tools: [],
+ mcpTools: [],
+ skills: [],
+ hasAgentSpawn: false,
+ hasPlanMode: false,
+ speed: 'standard',
+ timestamp: '2026-08-16T00:00:00.000Z',
+ bashCommands: [],
+ deduplicationKey: `${provider}-${model}`,
+ }
+}
+
+function makeProject(calls: ParsedApiCall[]): ProjectSummary {
+ const turn: ClassifiedTurn = {
+ userMessage: 't',
+ assistantCalls: calls,
+ timestamp: '2026-08-16T00:00:00.000Z',
+ sessionId: 's1',
+ category: 'feature' as TaskCategory,
+ retries: 0,
+ hasEdits: false,
+ }
+ const session: SessionSummary = {
+ sessionId: 's1',
+ project: 'p',
+ firstTimestamp: '2026-08-16T00:00:00.000Z',
+ lastTimestamp: '2026-08-16T00:00:00.000Z',
+ totalCostUSD: 0,
+ totalInputTokens: 0,
+ totalOutputTokens: 0,
+ totalCacheReadTokens: 0,
+ totalCacheWriteTokens: 0,
+ apiCalls: 0,
+ turns: [turn],
+ modelBreakdown: {},
+ toolBreakdown: {},
+ mcpBreakdown: {},
+ bashBreakdown: {},
+ categoryBreakdown: {} as SessionSummary['categoryBreakdown'],
+ skillBreakdown: {},
+ }
+ return { project: 'p', projectPath: 'p', sessions: [session], totalCostUSD: 0, totalApiCalls: 0 }
+}
+
+function codexDay(date: string, cost: number): DailyEntry {
+ const tokens = { inputTokens: 100, outputTokens: 20, cacheReadTokens: 30, cacheWriteTokens: 0 }
+ return {
+ date,
+ cost,
+ savingsUSD: 0,
+ calls: 1,
+ sessions: 1,
+ ...tokens,
+ editTurns: 0,
+ oneShotTurns: 0,
+ models: { 'GPT-5.5': { calls: 1, cost, savingsUSD: 0, ...tokens } },
+ categories: {},
+ providers: { codex: { calls: 1, cost, savingsUSD: 0, sessions: 1, ...tokens } },
+ }
+}
diff --git a/tests/models-report.test.ts b/tests/models-report.test.ts
index 4e1dba09..dac721f9 100644
--- a/tests/models-report.test.ts
+++ b/tests/models-report.test.ts
@@ -237,11 +237,14 @@ describe('aggregateModels', () => {
expect(above.find(r => r.provider === 'cursor')).toBeUndefined()
})
+ // Providers that report reasoning as a bucket SEPARATE from output still get
+ // it added in. Codex and claude do not - they bill reasoning inside
+ // output_tokens - and that carve-out is covered in codex-pricing-1075.test.ts.
it('counts reasoning tokens as output tokens', async () => {
const project = makeProject([
makeTurn('feature', [
{
- provider: 'codex',
+ provider: 'hermes',
model: 'gpt-5',
usage: { ...emptyTokens(), inputTokens: 100, outputTokens: 50, reasoningTokens: 200 },
costUSD: 1.0,
From 7876c8e9d7bb6bb3f6043ff4c74b58e4a1dd56a1 Mon Sep 17 00:00:00 2001
From: iamtoruk
Date: Fri, 21 Aug 2026 12:48:55 -0700
Subject: [PATCH 21/24] fix(pricing): version the pricing cache and drop
codex-credits' dead reasoning param
The pricing cache written to disk had no schema version, so a cache written
by a pre-#1078 binary lacked cacheWriteCostIsExplicit on every entry. Reading
it back resolved the missing key to undefined (falsy), silently reintroducing
the surcharge-fabrication bug #1078 killed for up to CACHE_TTL_MS after an
upgrade. loadCachedPricing now rejects any cache whose version doesn't match
the current schema instead of reading it verbatim.
codexCredits() still accepted an optional reasoningTokens param that added it
to output - the exact double-count #1078 removed from every real caller. The
only caller never passed it; deleted it so it can't be reintroduced by
accident.
parser.ts's activeGeneratedTokens fallback went through billableOutputTokens
in #1078, but codex is the only caller of activeDurationMs/activeGeneratedTokens
and always sets both together, so the fallback branch is unreachable for it.
Reverted to reduce diff noise.
---
src/codex-credits.ts | 7 +++----
src/models.ts | 9 ++++++++-
src/parser.ts | 2 +-
tests/codex-credits.test.ts | 5 -----
tests/models.test.ts | 38 +++++++++++++++++++++++++++++++++++++
5 files changed, 50 insertions(+), 11 deletions(-)
diff --git a/src/codex-credits.ts b/src/codex-credits.ts
index 10d5b1f4..664b1eb2 100644
--- a/src/codex-credits.ts
+++ b/src/codex-credits.ts
@@ -36,9 +36,9 @@ export type CodexCreditTokens = {
inputTokens: number
/// Cache-read (cached input) tokens, billed at the cheaper cached rate.
cachedReadTokens: number
+ /// Billable output tokens: reasoning is already included (billableOutputTokens
+ /// in models.ts), so callers must not add it on top here.
outputTokens: number
- /// Reasoning tokens are billed as output, matching CodeBurn's cost model.
- reasoningTokens?: number
}
/// Credits consumed for one Codex usage record. Returns null when the model has
@@ -48,10 +48,9 @@ export function codexCredits(model: string, tokens: CodexCreditTokens): number |
if (!rate) return null
const safe = (n: number) => (Number.isFinite(n) && n > 0 ? n : 0)
const PER_MILLION = 1_000_000
- const output = safe(tokens.outputTokens) + safe(tokens.reasoningTokens ?? 0)
return (
(safe(tokens.inputTokens) / PER_MILLION) * rate.input +
(safe(tokens.cachedReadTokens) / PER_MILLION) * rate.cachedInput +
- (output / PER_MILLION) * rate.output
+ (safe(tokens.outputTokens) / PER_MILLION) * rate.output
)
}
diff --git a/src/models.ts b/src/models.ts
index 1b81748a..804452d0 100644
--- a/src/models.ts
+++ b/src/models.ts
@@ -59,6 +59,11 @@ type SnapshotEntry = [number, number, number | null, number | null, (number | nu
const LITELLM_URL = 'https://raw.githubusercontent.com/BerriAI/litellm/main/model_prices_and_context_window.json'
const CACHE_TTL_MS = 24 * 60 * 60 * 1000
+// Bump whenever a ModelCosts field changes pricing behavior (cacheWriteCostIsExplicit,
+// added in #1075/#1078). A cache written under an older/missing version is treated as a
+// miss instead of read verbatim, so a stale on-disk file can't reintroduce a killed bug
+// for up to CACHE_TTL_MS after an upgrade.
+const CACHE_SCHEMA_VERSION = 2
const WEB_SEARCH_COST = 0.01
const ONE_HOUR_CACHE_WRITE_MULTIPLIER_FROM_FIVE_MINUTE_RATE = 1.6
@@ -223,6 +228,7 @@ async function fetchAndCachePricing(): Promise