From 914355e92309e2bd20077e87e0fd0f0c83789409 Mon Sep 17 00:00:00 2001 From: Aditya Vikram Singh <247195684+avs-io@users.noreply.github.com> Date: Wed, 12 Aug 2026 19:57:08 +0530 Subject: [PATCH 01/85] fix(optimize): separate connector guidance from MCP removal --- src/optimize.ts | 47 +++++++++++++---- tests/mcp-coverage.test.ts | 98 ++++++++++++++++++++++++++++++++++++ tests/optimize-apply.test.ts | 36 +++++++++++++ 3 files changed, 171 insertions(+), 10 deletions(-) diff --git a/src/optimize.ts b/src/optimize.ts index 63d49330..36301b2a 100644 --- a/src/optimize.ts +++ b/src/optimize.ts @@ -1042,6 +1042,8 @@ export function detectMcpToolCoverage( const removeCommands: string[] = [] const unusedCountsByServer: Record = {} const flaggedServers: string[] = [] + const localServers: string[] = [] + const connectorServers: string[] = [] for (const c of flagged) { unusedCountsByServer[c.server] = c.toolsAvailable - c.toolsInvoked @@ -1050,7 +1052,12 @@ export function detectMcpToolCoverage( lines.push( `${c.server}: ${c.toolsInvoked}/${c.toolsAvailable} tools used (${pct}% coverage) across ${c.loadedSessions} session${c.loadedSessions === 1 ? '' : 's'}`, ) - removeCommands.push(`claude mcp remove '${c.server}'`) + if (c.server.startsWith('claude_ai_')) { + connectorServers.push(c.server) + } else { + localServers.push(c.server) + removeCommands.push(`claude mcp remove '${c.server}'`) + } } // Single combined cost pass: caps each call's contribution at the @@ -1064,6 +1071,30 @@ export function detectMcpToolCoverage( : flagged.length >= UNUSED_MCP_HIGH_THRESHOLD ? 'high' : 'medium' + // `claude_ai_*` is Claude Code's transcript namespace for server-side + // claude.ai connectors. Those connectors are not local mcpServers entries, + // so `claude mcp remove` and the file-editing apply plan cannot own them. + // Coverage is aggregate here; project-level config attribution is deliberately + // out of scope, hence the instruction to inspect /mcp per affected project. + const connectorGuidance = connectorServers.length > 0 + ? ` ${connectorServers.join(', ')} ${connectorServers.length === 1 ? 'is a claude.ai connector namespace' : 'are claude.ai connector namespaces'}, separate from any similarly named local MCP server. Transcript inventory is aggregated across the selected projects; use /mcp in each project where ${connectorServers.length === 1 ? 'it loads' : 'they load'}, or manage ${connectorServers.length === 1 ? 'it' : 'them'} in claude.ai Settings > Connectors.` + : '' + const fix: WasteAction = localServers.length > 0 + ? { + type: 'command', + label: localServers.length === 1 + ? 'Remove the underused local server, or trim its tools in your MCP config:' + : 'Remove underused local servers, or trim their tools in your MCP config:', + text: removeCommands.join('\n'), + } + : { + type: 'paste', + destination: 'prompt', + label: connectorServers.length === 1 + ? 'Manage the underused claude.ai connector where it loads:' + : 'Manage the underused claude.ai connectors where they load:', + text: `Open /mcp in each affected project and disable ${connectorServers.join(', ')}, or manage ${connectorServers.length === 1 ? 'it' : 'them'} in claude.ai Settings > Connectors.`, + } return { id: 'mcp-low-coverage', @@ -1071,17 +1102,13 @@ export function detectMcpToolCoverage( explanation: `Schema for unused tools is loaded into the system prompt every session and ` + `carried in the cached prefix on every turn. ` + - `${lines.join('; ')}.`, + `${lines.join('; ')}.${connectorGuidance}`, impact, tokensSaved, - fix: { - type: 'command', - label: flagged.length === 1 - ? 'Remove the underused server, or trim its tools in your MCP config:' - : 'Remove underused servers, or trim their tools in your MCP config:', - text: removeCommands.join('\n'), - }, - apply: { kind: 'mcp-remove', servers: flaggedServers }, + fix, + ...(localServers.length > 0 + ? { apply: { kind: 'mcp-remove' as const, servers: localServers } } + : {}), } } diff --git a/tests/mcp-coverage.test.ts b/tests/mcp-coverage.test.ts index a19ddc4d..c2443b37 100644 --- a/tests/mcp-coverage.test.ts +++ b/tests/mcp-coverage.test.ts @@ -333,6 +333,56 @@ describe('detectMcpToolCoverage', () => { expect(detectMcpToolCoverage([project([makeSession({})])])).toBeNull() }) + it('keeps claude.ai connector evidence but emits manual guidance instead of a local remove command', () => { + const server = 'claude_ai_Netlify' + const inventory = Array.from({ length: 20 }, (_, i) => `mcp__${server}__t${i}`) + const turns = [makeTurn([makeCall({ cacheCreation: 50_000 })])] + const sessions = [ + makeSession({ sessionId: 'a', inventory, turns }), + makeSession({ sessionId: 'b', inventory, turns }), + ] + + const finding = detectMcpToolCoverage([project(sessions)]) + + expect(finding).not.toBeNull() + expect(finding!.tokensSaved).toBe(20_000) + expect(finding!.explanation).toContain(server) + expect(finding!.explanation).toContain('/mcp') + expect(finding!.explanation).toContain('claude.ai Settings > Connectors') + expect(finding!.fix.type).toBe('paste') + if (finding!.fix.type === 'paste') { + expect(finding!.fix.destination).toBe('prompt') + expect(finding!.fix.text).toContain('/mcp') + expect(finding!.fix.text).toContain('claude.ai Settings > Connectors') + } + expect(JSON.stringify(finding)).not.toContain('claude mcp remove') + expect(finding!.apply).toBeUndefined() + }) + + it('pluralises manual guidance when only claude.ai connectors are flagged', () => { + const coverage = ['claude_ai_Slack', 'claude_ai_Google_Calendar'].map(server => ({ + server, + toolsAvailable: 20, + toolsInvoked: 0, + unusedTools: Array.from({ length: 20 }, (_, i) => `mcp__${server}__t${i}`), + invocations: 0, + loadedSessions: 2, + coverageRatio: 0, + })) + + const finding = detectMcpToolCoverage([], coverage) + + expect(finding).not.toBeNull() + expect(finding!.fix).toMatchObject({ + type: 'paste', + label: 'Manage the underused claude.ai connectors where they load:', + }) + if (finding!.fix.type === 'paste') { + expect(finding!.fix.text).toContain('manage them in claude.ai Settings > Connectors') + } + expect(finding!.apply).toBeUndefined() + }) + it('does not flag a server with healthy coverage', () => { const inventory = Array.from({ length: 20 }, (_, i) => `mcp__svc__t${i}`) const turns = [makeTurn( @@ -379,9 +429,57 @@ describe('detectMcpToolCoverage', () => { expect(finding!.explanation).toContain('1/30') expect(finding!.fix.type).toBe('command') expect((finding!.fix as { text: string }).text).toContain("claude mcp remove 'hf'") + expect(finding!.apply).toEqual({ kind: 'mcp-remove', servers: ['hf'] }) expect(finding!.tokensSaved).toBeGreaterThan(0) }) + it('keeps mixed connector guidance visible while making only the local server executable', () => { + const sessions: SessionSummary[] = [] + for (const server of ['filesystem', 'claude_ai_Slack']) { + const inventory = Array.from({ length: 20 }, (_, i) => `mcp__${server}__t${i}`) + sessions.push( + makeSession({ sessionId: `${server}-a`, inventory }), + makeSession({ sessionId: `${server}-b`, inventory }), + ) + } + + const finding = detectMcpToolCoverage([project(sessions)]) + + expect(finding).not.toBeNull() + expect(finding!.explanation).toContain('claude_ai_Slack') + expect(finding!.explanation).toContain('/mcp') + expect(finding!.explanation).toContain('claude.ai Settings > Connectors') + expect(finding!.fix).toEqual({ + type: 'command', + label: 'Remove the underused local server, or trim its tools in your MCP config:', + text: "claude mcp remove 'filesystem'", + }) + expect(finding!.apply).toEqual({ kind: 'mcp-remove', servers: ['filesystem'] }) + }) + + it('disambiguates a claude.ai connector from a similarly named local server', () => { + const sessions: SessionSummary[] = [] + for (const server of ['claude_ai_Netlify', 'netlify']) { + const inventory = Array.from({ length: 20 }, (_, i) => `mcp__${server}__t${i}`) + sessions.push( + makeSession({ sessionId: `${server}-a`, inventory }), + makeSession({ sessionId: `${server}-b`, inventory }), + ) + } + + const finding = detectMcpToolCoverage([project(sessions)]) + + expect(finding).not.toBeNull() + expect(finding!.explanation).toContain('claude_ai_Netlify') + expect(finding!.explanation).toContain('separate from any similarly named local MCP server') + expect(finding!.fix.type).toBe('command') + if (finding!.fix.type === 'command') { + expect(finding!.fix.text).toBe("claude mcp remove 'netlify'") + expect(finding!.fix.text).not.toContain('claude_ai_Netlify') + } + expect(finding!.apply).toEqual({ kind: 'mcp-remove', servers: ['netlify'] }) + }) + it('escalates impact to high when token waste crosses the threshold', () => { const inventory = Array.from({ length: 60 }, (_, i) => `mcp__big__t${i}`) // 60 tools * 400 tokens = 24k schema. With many sessions and large diff --git a/tests/optimize-apply.test.ts b/tests/optimize-apply.test.ts index 2b582284..d74ded99 100644 --- a/tests/optimize-apply.test.ts +++ b/tests/optimize-apply.test.ts @@ -102,6 +102,42 @@ describe('mcp-remove plan', () => { await undoAction({ id: rec.id }, { actionsDir: fx.actionsDir }) expect(await readFile(claudeJson, 'utf-8')).toBe(original) }) + + it('does not plan connector removal and removes only the local server from a mixed finding', async () => { + const coverage = (server: string): McpServerCoverage => ({ + server, + toolsAvailable: 20, + toolsInvoked: 0, + unusedTools: Array.from({ length: 20 }, (_, i) => `mcp__${server}__t${i}`), + invocations: 0, + loadedSessions: 2, + coverageRatio: 0, + }) + const connector = coverage('claude_ai_Netlify') + + const connectorOnly = detectMcpToolCoverage([], [connector])! + expect(connectorOnly.apply).toBeUndefined() + expect(planFor(connectorOnly)).toBeNull() + + const fx = await makeFixture() + const claudeJson = join(fx.home, '.claude.json') + await writeFile(claudeJson, JSON.stringify({ + mcpServers: { + filesystem: { command: 'filesystem' }, + netlify: { command: 'local-netlify' }, + }, + }, null, 2) + '\n') + + const mixed = detectMcpToolCoverage([], [connector, coverage('filesystem')])! + expect(mixed.apply).toEqual({ kind: 'mcp-remove', servers: ['filesystem'] }) + const plan = planFor(mixed, { homeDir: fx.home, cwd: fx.project }) + expect(plan).not.toBeNull() + + await runAction(plan!, fx.actionsDir) + expect(JSON.parse(await readFile(claudeJson, 'utf-8')).mcpServers).toEqual({ + netlify: { command: 'local-netlify' }, + }) + }) }) describe('mcp-project-scope plan', () => { From ad9fa70587dcfd08a0019effdc75172ca390d845 Mon Sep 17 00:00:00 2001 From: Aditya Vikram Singh <247195684+avs-io@users.noreply.github.com> Date: Wed, 12 Aug 2026 20:57:23 +0530 Subject: [PATCH 02/85] fix(optimize): scope MCP apply savings --- src/act/optimize-apply.ts | 3 ++- src/act/report.ts | 2 +- src/optimize.ts | 8 ++++++++ tests/act-report.test.ts | 40 ++++++++++++++++++++++++++++++++++++ tests/mcp-coverage.test.ts | 19 +++++++++-------- tests/optimize-apply.test.ts | 25 ++++++++++++++++++++++ 6 files changed, 87 insertions(+), 10 deletions(-) diff --git a/src/act/optimize-apply.ts b/src/act/optimize-apply.ts index 5b90685a..d85305d5 100644 --- a/src/act/optimize-apply.ts +++ b/src/act/optimize-apply.ts @@ -42,7 +42,8 @@ export function renderApplyList(appliable: FindingPlan[], manual: FindingPlan[], lines.push(chalk.bold(' Appliable config-class fixes:')) appliable.forEach((fp, i) => { const f = fp.finding - const savings = `~${formatTokens(f.tokensSaved)} tokens${costRate > 0 ? `, ~${formatCost(f.tokensSaved * costRate)}` : ''}` + const actionTokensSaved = f.applyTokensSaved ?? f.tokensSaved + const savings = `~${formatTokens(actionTokensSaved)} tokens${costRate > 0 ? `, ~${formatCost(actionTokensSaved * costRate)}` : ''}` lines.push('') lines.push(` ${i + 1}. ${f.title} ${chalk.hex('#FFD700')(`(${savings})`)}`) for (const line of changeLines(fp)) lines.push(chalk.dim(` ${line}`)) diff --git a/src/act/report.ts b/src/act/report.ts index 30c88120..5379ee81 100644 --- a/src/act/report.ts +++ b/src/act/report.ts @@ -688,7 +688,7 @@ export function captureBaseline(finding: WasteFinding, kind: ActionKind, ctx: Ca const common = { windowDays: ctx.windowDays, capturedAt: ctx.now.toISOString(), - estimatedTokens: Math.max(0, Math.round(finding.tokensSaved)), + estimatedTokens: Math.max(0, Math.round(finding.applyTokensSaved ?? finding.tokensSaved)), } if (MCP_KINDS.has(kind)) { diff --git a/src/optimize.ts b/src/optimize.ts index 36301b2a..d1b04769 100644 --- a/src/optimize.ts +++ b/src/optimize.ts @@ -300,6 +300,10 @@ export type WasteFinding = { explanation: string impact: Impact tokensSaved: number + /// Savings attributable to the automatic mutation when it covers only a + /// subset of the finding. Omitted when `tokensSaved` already describes the + /// whole apply action (or when the finding is manual-only). + applyTokensSaved?: number fix: WasteAction trend?: Trend apply?: FindingApply @@ -1066,6 +1070,9 @@ export function detectMcpToolCoverage( // bucket and overstate `tokensSaved`. const cost = estimateMcpSchemaCost(unusedCountsByServer, projects, flaggedServers) const tokensSaved = Math.round(cost.effectiveInputTokens) + const applyTokensSaved = localServers.length > 0 && connectorServers.length > 0 + ? Math.round(estimateMcpSchemaCost(unusedCountsByServer, projects, localServers).effectiveInputTokens) + : undefined const impact: Impact = tokensSaved >= MCP_COVERAGE_HIGH_IMPACT_TOKENS ? 'high' : flagged.length >= UNUSED_MCP_HIGH_THRESHOLD @@ -1105,6 +1112,7 @@ export function detectMcpToolCoverage( `${lines.join('; ')}.${connectorGuidance}`, impact, tokensSaved, + ...(applyTokensSaved !== undefined ? { applyTokensSaved } : {}), fix, ...(localServers.length > 0 ? { apply: { kind: 'mcp-remove' as const, servers: localServers } } diff --git a/tests/act-report.test.ts b/tests/act-report.test.ts index 80834d0d..b40648a0 100644 --- a/tests/act-report.test.ts +++ b/tests/act-report.test.ts @@ -762,3 +762,43 @@ describe('defer baseline capture', () => { expect(b).toBeUndefined() }) }) + +describe('partial-action baseline capture', () => { + it('persists the savings attributable to the local mutation, not the full mixed finding', () => { + const finding: WasteFinding = { + id: 'mcp-low-coverage', + title: '2 MCP servers with low tool coverage', + explanation: '', + impact: 'medium', + tokensSaved: 40_000, + applyTokensSaved: 20_000, + fix: { type: 'command', label: '', text: "claude mcp remove 'filesystem'" }, + apply: { kind: 'mcp-remove', servers: ['filesystem'] }, + } + const sessions = sessionsAt(2, daysAgo(1), { + mcpInventory: Array.from({ length: 20 }, (_, i) => `mcp__filesystem__t${i}`), + }) + + const baseline = captureBaseline(finding, 'mcp-remove', { + projects: [projectOf(sessions)], + coverage: [{ + server: 'filesystem', + toolsAvailable: 20, + toolsInvoked: 0, + unusedTools: [], + invocations: 0, + loadedSessions: 2, + coverageRatio: 0, + }], + windowDays: 14, + now: NOW, + }) + + expect(baseline).toMatchObject({ + estimatedTokens: 20_000, + sessions: 2, + metrics: { filesystem: 8_000 }, + }) + expect(finding.tokensSaved).toBe(40_000) + }) +}) diff --git a/tests/mcp-coverage.test.ts b/tests/mcp-coverage.test.ts index c2443b37..c036301b 100644 --- a/tests/mcp-coverage.test.ts +++ b/tests/mcp-coverage.test.ts @@ -434,18 +434,21 @@ describe('detectMcpToolCoverage', () => { }) it('keeps mixed connector guidance visible while making only the local server executable', () => { - const sessions: SessionSummary[] = [] - for (const server of ['filesystem', 'claude_ai_Slack']) { - const inventory = Array.from({ length: 20 }, (_, i) => `mcp__${server}__t${i}`) - sessions.push( - makeSession({ sessionId: `${server}-a`, inventory }), - makeSession({ sessionId: `${server}-b`, inventory }), - ) - } + const inventory = ['filesystem', 'claude_ai_Slack'].flatMap(server => + Array.from({ length: 20 }, (_, i) => `mcp__${server}__t${i}`), + ) + const sessions: SessionSummary[] = [ + makeSession({ sessionId: 'mixed-a', inventory, turns: [makeTurn([makeCall({ cacheCreation: 50_000 })])] }), + makeSession({ sessionId: 'mixed-b', inventory, turns: [makeTurn([makeCall({ cacheCreation: 50_000 })])] }), + ] const finding = detectMcpToolCoverage([project(sessions)]) expect(finding).not.toBeNull() + // The finding describes both opportunities: 40 unused tool schemas across + // two sessions = 40K effective tokens. The automatic mutation owns only + // the 20 local schemas = 20K; the connector portion remains manual. + expect(finding).toMatchObject({ tokensSaved: 40_000, applyTokensSaved: 20_000 }) expect(finding!.explanation).toContain('claude_ai_Slack') expect(finding!.explanation).toContain('/mcp') expect(finding!.explanation).toContain('claude.ai Settings > Connectors') diff --git a/tests/optimize-apply.test.ts b/tests/optimize-apply.test.ts index d74ded99..0ed495f6 100644 --- a/tests/optimize-apply.test.ts +++ b/tests/optimize-apply.test.ts @@ -5,6 +5,7 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { createHash } from 'node:crypto' import { PassThrough, Writable } from 'node:stream' +import stripAnsi from 'strip-ansi' import { planFor, planFindings, type PlanContext } from '../src/act/plans.js' import { renderApplyList, runOptimizeApply, type ApplyOptions } from '../src/act/optimize-apply.js' @@ -138,6 +139,30 @@ describe('mcp-remove plan', () => { netlify: { command: 'local-netlify' }, }) }) + + it('previews only the savings attributable to a mixed finding local mutation', async () => { + const fx = await makeFixture() + await writeFile(join(fx.home, '.claude.json'), JSON.stringify({ + mcpServers: { filesystem: { command: 'filesystem' } }, + }, null, 2) + '\n') + const finding: WasteFinding = { + id: 'mcp-low-coverage', + title: '2 MCP servers with low tool coverage', + explanation: '', + impact: 'medium', + tokensSaved: 80_000, + applyTokensSaved: 20_000, + fix: { type: 'command', label: '', text: "claude mcp remove 'filesystem'" }, + apply: { kind: 'mcp-remove', servers: ['filesystem'] }, + } + const plans = planFindings([finding], { homeDir: fx.home, cwd: fx.project }) + + const preview = stripAnsi(renderApplyList(plans, [], 0.000002)) + + expect(preview).toContain('(~20.0K tokens, ~$0.040)') + expect(preview).not.toContain('~80.0K tokens') + expect(preview).not.toContain('~$0.160') + }) }) describe('mcp-project-scope plan', () => { From 7187dd0da00871719fecac03f45d15ab4eceb1dc Mon Sep 17 00:00:00 2001 From: Aditya Vikram Singh <247195684+avs-io@users.noreply.github.com> Date: Wed, 12 Aug 2026 20:59:12 +0530 Subject: [PATCH 03/85] fix(optimize): exclude sidechains from session heuristics (#974) --- README.md | 6 + src/optimize.ts | 49 ++++-- src/parser.ts | 2 + src/session-cache.ts | 2 +- src/types.ts | 5 + tests/optimize-sidechains.test.ts | 237 ++++++++++++++++++++++++++++ tests/parser-subagent-range.test.ts | 14 +- 7 files changed, 301 insertions(+), 14 deletions(-) create mode 100644 tests/optimize-sidechains.test.ts diff --git a/README.md b/README.md index 2159e1da..8988a10d 100644 --- a/README.md +++ b/README.md @@ -156,6 +156,12 @@ codeburn optimize --format json # setup health + findings as JSON `codeburn optimize` scans your sessions and your `~/.claude/` setup for waste patterns: +For Claude Code, the optimize session count and session-level findings below +use user-started (main) sessions. Subagent sidechain transcripts are excluded +from that population because their delegated context and delivery behavior are +structurally different; their tokens, calls, and cost still count in all spend +totals and configuration-overhead findings. + - Files Claude re-reads across sessions (same content, same context, over and over) - Low Read:Edit ratio (editing without reading leads to retries and wasted tokens) - Wasted bash output (uncapped `BASH_MAX_OUTPUT_LENGTH`, trailing noise) diff --git a/src/optimize.ts b/src/optimize.ts index 63d49330..254231b2 100644 --- a/src/optimize.ts +++ b/src/optimize.ts @@ -1,10 +1,11 @@ import chalk from 'chalk' -import { isReadShapedBashCommand } from './bash-utils.js' +import { createHash } from 'node:crypto' import { readdir, stat } from 'fs/promises' import { existsSync, statSync } from 'fs' import { basename, join } from 'path' import { homedir } from 'os' +import { isReadShapedBashCommand } from './bash-utils.js' import { readSessionLines, readSessionFileSync } from './fs-utils.js' import { discoverAllSessions } from './providers/index.js' import { parseJsonlLine, shouldSkipLine } from './parser.js' @@ -2484,6 +2485,22 @@ function sessionTokenTotal(session: ProjectSummary['sessions'][number]): number + session.totalCacheWriteTokens } +// Sidechain transcripts are real usage, so they stay in project totals and in +// token/cost calibration. They are not user-started sessions, however, and +// should never enter optimize heuristics whose unit is a human work session. +// Keep that distinction local to optimize instead of deleting sidechains from +// ProjectSummary, which would under-report the work delegated to subagents. +function isOptimizeSession(session: ProjectSummary['sessions'][number]): boolean { + return session.isSidechain !== true +} + +function optimizeSessionCount(projects: ProjectSummary[]): number { + return projects.reduce( + (total, project) => total + project.sessions.filter(isOptimizeSession).length, + 0, + ) +} + function sessionEffectiveContextTokens(session: ProjectSummary['sessions'][number]): number { return session.totalInputTokens + session.totalCacheReadTokens * CACHE_READ_DISCOUNT @@ -2592,6 +2609,7 @@ export function findLowWorthCandidates(projects: ProjectSummary[]): LowWorthCand for (const project of projects) { for (const session of project.sessions) { + if (!isOptimizeSession(session)) continue if (session.totalCostUSD < WORTH_IT_MIN_COST_USD) continue if (sessionDeliveryCommand(session)) continue @@ -2692,7 +2710,7 @@ export function findContextBloatCandidates(projects: ProjectSummary[]): ContextB const candidates: ContextBloatCandidate[] = [] for (const project of projects) { - const sessions = [...project.sessions].sort((a, b) => + const sessions = project.sessions.filter(isOptimizeSession).sort((a, b) => new Date(a.firstTimestamp).getTime() - new Date(b.firstTimestamp).getTime() ) let previousInputTokens: number | null = null @@ -2805,7 +2823,7 @@ export function detectSessionOutliers(projects: ProjectSummary[], excludedSessio const outliers: Outlier[] = [] for (const project of projects) { - const sessions = project.sessions.filter(s => s.totalCostUSD > 0) + const sessions = project.sessions.filter(s => isOptimizeSession(s) && s.totalCostUSD > 0) if (sessions.length < MIN_SESSIONS_FOR_OUTLIER) continue const totalCost = sessions.reduce((sum, s) => sum + s.totalCostUSD, 0) @@ -2866,7 +2884,7 @@ function findYoungProjectFirstSessionIds(projects: ProjectSummary[]): Set() for (const project of projects) { - const costed = project.sessions.filter(s => s.totalCostUSD > 0) + const costed = project.sessions.filter(s => isOptimizeSession(s) && s.totalCostUSD > 0) if (costed.length >= YOUNG_PROJECT_SESSION_LIMIT) continue let firstSession: ProjectSummary['sessions'][number] | null = null @@ -2985,15 +3003,25 @@ export function cacheKey(projects: ProjectSummary[], dateRange: DateRange | unde // stale findings when cost/tokens moved (e.g. a re-price) while call count // held - reachable in the long-lived menubar process within the 60s TTL. // Cost is scaled to whole micro-dollars so float jitter cannot thrash the key. - let calls = 0, cost = 0, savings = 0, proxied = 0 + let calls = 0, cost = 0, savings = 0, proxied = 0, sessions = 0, sidechains = 0 + const sidechainIdentities: string[] = [] for (const p of projects) { calls += p.totalApiCalls cost += p.totalCostUSD savings += p.totalSavingsUSD proxied += p.totalProxiedCostUSD + sessions += p.sessions.length + for (const session of p.sessions) { + if (session.isSidechain !== true) continue + sidechains++ + sidechainIdentities.push(`${p.projectPath}\0${session.sessionId}`) + } } + const sidechainDigest = createHash('sha256') + .update(sidechainIdentities.sort().join('\0')) + .digest('base64url') // Costs scaled to whole micro-dollars so float jitter cannot thrash the key. - const fingerprint = `${projects.length}:${calls}:${Math.round(cost * 1e6)}:${Math.round(savings * 1e6)}:${Math.round(proxied * 1e6)}` + const fingerprint = `${projects.length}:${sessions}:${sidechains}:${sidechainDigest}:${calls}:${Math.round(cost * 1e6)}:${Math.round(savings * 1e6)}:${Math.round(proxied * 1e6)}` return `${dr}:${fingerprint}` } @@ -3205,7 +3233,7 @@ function renderOptimize( const issueSuffix = findings.length > 0 ? `, ${findings.length} issue${findings.length > 1 ? 's' : ''}` : '' lines.push(' ' + [ - `${sessionCount} sessions`, + `${sessionCount} session${sessionCount === 1 ? '' : 's'}`, `${callCount.toLocaleString()} calls`, chalk.hex(GOLD)(formatCost(periodCost)), `Health: ${chalk.bold.hex(GRADE_COLORS[healthGrade])(healthGrade)}${chalk.dim(` (${healthScore}/100${issueSuffix})`)}`, @@ -3295,7 +3323,7 @@ export async function runOptimize( const result = await scanAndDetect(projects, dateRange) const { findings, costRate, healthScore, healthGrade } = result - const sessions = projects.flatMap(p => p.sessions) + const sessionCount = optimizeSessionCount(projects) const periodCost = projects.reduce((s, p) => s + p.totalCostUSD, 0) const callCount = projects.reduce((s, p) => s + p.totalApiCalls, 0) @@ -3305,7 +3333,7 @@ export async function runOptimize( } const { topReworkedFiles, coachingNotes } = buildWorkflowReport(projects) - const output = renderOptimize(findings, costRate, periodLabel, periodCost, sessions.length, callCount, healthScore, healthGrade, topReworkedFiles, coachingNotes, opts.appliedHeader, opts.previouslyApplied, result.modelRecommendations) + const output = renderOptimize(findings, costRate, periodLabel, periodCost, sessionCount, callCount, healthScore, healthGrade, topReworkedFiles, coachingNotes, opts.appliedHeader, opts.previouslyApplied, result.modelRecommendations) console.log(output) } @@ -3315,7 +3343,6 @@ export function buildOptimizeJsonReport( result: OptimizeResult, dateRange?: DateRange, ): OptimizeJsonReport { - const sessions = projects.flatMap(p => p.sessions) const periodCostUSD = projects.reduce((s, p) => s + p.totalCostUSD, 0) const calls = projects.reduce((s, p) => s + p.totalApiCalls, 0) const potentialSavingsTokens = result.findings.reduce((s, f) => s + f.tokensSaved, 0) @@ -3335,7 +3362,7 @@ export function buildOptimizeJsonReport( healthGrade: result.healthGrade, findingCount: result.findings.length, periodCostUSD, - sessions: sessions.length, + sessions: optimizeSessionCount(projects), calls, potentialSavingsTokens, potentialSavingsCostUSD, diff --git a/src/parser.ts b/src/parser.ts index 712295b0..47aa9e05 100644 --- a/src/parser.ts +++ b/src/parser.ts @@ -2251,6 +2251,7 @@ async function scanProjectDirs( // on a resumed session) and derive the agent id from the `agent-` // filename. A sidechain whose parent id was never captured stays standalone. if (cachedFile.isSidechain) { + session.isSidechain = true if (cachedFile.parentSessionId) session.parentSessionId = cachedFile.parentSessionId session.agentId = sessionId.startsWith('agent-') ? sessionId.slice('agent-'.length) : sessionId } @@ -3327,6 +3328,7 @@ function carryLinkageFields(rebuilt: SessionSummary, original: SessionSummary): if (original.prLinks?.length) rebuilt.prLinks = original.prLinks if (original.prAttributionSource) rebuilt.prAttributionSource = original.prAttributionSource if (original.workingDirectory) rebuilt.workingDirectory = original.workingDirectory + if (original.isSidechain) rebuilt.isSidechain = true // prRefsAtRangeStart is NOT copied here: a narrower slice needs it recomputed at // the new boundary (see recomputeRangeStartPrRefs), not the wide range's value. if (original.parentSessionId) rebuilt.parentSessionId = original.parentSessionId diff --git a/src/session-cache.ts b/src/session-cache.ts index 2759520f..e9b6a163 100644 --- a/src/session-cache.ts +++ b/src/session-cache.ts @@ -100,7 +100,7 @@ export type CachedFile = { // is re-parsed only when the file changes (fingerprint differs). Carries no // turns, so it contributes no usage. (issue #441 follow-up) failed?: boolean - // Rich-session-capture, Claude session-level (capture-only; no report yet). + // Rich-session-capture, Claude session-level. // `title` is the LAST `ai-title` entry's text; `prLinks` accumulates every // `pr-link` entry's URL. `isSidechain` is true when any entry is a sidechain: // parentUuid references an intra-file entry uuid, not another session id, so it diff --git a/src/types.ts b/src/types.ts index a51ae672..b6b57b5c 100644 --- a/src/types.ts +++ b/src/types.ts @@ -205,6 +205,11 @@ export type SessionSummary = { /// correlations performed after all saved sessions have been parsed. prAttributionSource?: 'transcript' | 'explicit-reference' | 'working-directory' | 'launcher-prompt' source?: SessionSourceMetadata + /// Claude Code only: true when this record is a subagent (sidechain) + /// transcript rather than a user-started parent session. Sidechain spend is + /// real and remains in every cost/token/call aggregate; consumers that reason + /// about human session populations may exclude it explicitly. + isSidechain?: boolean // Claude Code only: agent type of a subagent transcript session // (`workflow-subagent`, `Explore`, `general-purpose`, …); undefined for // ordinary sessions. Drives the Claude-scoped agent-type breakdown. diff --git a/tests/optimize-sidechains.test.ts b/tests/optimize-sidechains.test.ts new file mode 100644 index 00000000..e3d7d039 --- /dev/null +++ b/tests/optimize-sidechains.test.ts @@ -0,0 +1,237 @@ +import { describe, expect, it, vi } from 'vitest' + +vi.mock('../src/providers/index.js', async (importOriginal) => { + type ProvidersModule = typeof import('../src/providers/index.js') + const actual = await importOriginal() + return { + ...actual, + async discoverAllSessions() { + return [] + }, + } +}) + +import { + buildOptimizeJsonReport, + cacheKey, + computeInputCostRate, + detectSessionOutliers, + findContextBloatCandidates, + findLowWorthCandidates, + runOptimize, + scanAndDetect, + type OptimizeResult, +} from '../src/optimize.js' +import type { ProjectSummary, SessionSummary } from '../src/types.js' + +function session( + sessionId: string, + overrides: Partial = {}, +): SessionSummary { + return { + sessionId, + project: 'app', + firstTimestamp: '2026-08-01T10:00:00.000Z', + lastTimestamp: '2026-08-01T10:30:00.000Z', + totalCostUSD: 1, + totalSavingsUSD: 0, + totalInputTokens: 1_000, + totalOutputTokens: 1_000, + totalReasoningTokens: 0, + totalCacheReadTokens: 0, + totalCacheWriteTokens: 0, + apiCalls: 1, + turns: [], + modelBreakdown: {}, + toolBreakdown: {}, + mcpBreakdown: {}, + bashBreakdown: {}, + categoryBreakdown: {} as SessionSummary['categoryBreakdown'], + skillBreakdown: {}, + subagentBreakdown: {}, + ...overrides, + } +} + +function sidechain( + sessionId: string, + overrides: Partial = {}, +): SessionSummary { + return session(sessionId, { + isSidechain: true, + parentSessionId: 'parent-session', + agentId: sessionId.replace(/^agent-/, ''), + ...overrides, + }) +} + +function project(sessions: SessionSummary[]): ProjectSummary { + return { + project: 'app', + projectPath: '/tmp/app', + sessions, + totalCostUSD: sessions.reduce((sum, item) => sum + item.totalCostUSD, 0), + totalSavingsUSD: sessions.reduce((sum, item) => sum + item.totalSavingsUSD, 0), + totalApiCalls: sessions.reduce((sum, item) => sum + item.apiCalls, 0), + totalProxiedCostUSD: 0, + } +} + +describe('optimize sidechain population (issue #974)', () => { + it('keeps sidechain spend out of the low-worth candidate population', () => { + const parent = session('parent', { + totalCostUSD: 4, + turns: [], + }) + const child = sidechain('agent-child', { + totalCostUSD: 12, + turns: [], + }) + + expect(findLowWorthCandidates([project([parent, child])]).map(item => item.sessionId)) + .toEqual(['parent']) + }) + + it('does not use a sidechain as a context-heavy candidate or growth baseline', () => { + const baseline = session('parent-baseline', { + firstTimestamp: '2026-08-01T10:00:00.000Z', + totalInputTokens: 20_000, + totalOutputTokens: 2_000, + }) + const child = sidechain('agent-child', { + firstTimestamp: '2026-08-02T10:00:00.000Z', + totalInputTokens: 200_000, + totalOutputTokens: 100, + }) + const candidate = session('parent-candidate', { + firstTimestamp: '2026-08-03T10:00:00.000Z', + totalInputTokens: 100_000, + totalOutputTokens: 2_000, + }) + + const candidates = findContextBloatCandidates([project([baseline, child, candidate])]) + + expect(candidates.map(item => item.sessionId)).toEqual(['parent-candidate']) + expect(candidates[0]!.growthRatio).toBe(5) + }) + + it('does not let a sidechain satisfy the peer-sample minimum for cost outliers', () => { + const sessions = [ + session('parent-cheap', { totalCostUSD: 1 }), + session('parent-expensive', { totalCostUSD: 10 }), + sidechain('agent-cheap', { totalCostUSD: 1 }), + ] + + expect(detectSessionOutliers([project(sessions)])).toBeNull() + }) + + it('never reports an expensive sidechain as a parent-session cost outlier', () => { + const sessions = [ + session('parent-1', { totalCostUSD: 1 }), + session('parent-2', { totalCostUSD: 1 }), + session('parent-3', { totalCostUSD: 1 }), + sidechain('agent-expensive', { totalCostUSD: 100 }), + ] + + expect(detectSessionOutliers([project(sessions)])).toBeNull() + }) + + it('counts only parent sessions while conserving sidechain cost, calls, and tokens', () => { + const projects = [project([ + session('parent', { + totalCostUSD: 3, + totalInputTokens: 100, + totalOutputTokens: 20, + apiCalls: 2, + }), + sidechain('agent-child', { + totalCostUSD: 7, + totalInputTokens: 900, + totalOutputTokens: 80, + apiCalls: 4, + }), + ])] + const result: OptimizeResult = { + findings: [], + costRate: computeInputCostRate(projects), + healthScore: 100, + healthGrade: 'A', + } + + const report = buildOptimizeJsonReport(projects, 'fixture', result) + + expect(report.summary.sessions).toBe(1) + expect(report.summary.periodCostUSD).toBe(10) + expect(report.summary.calls).toBe(6) + // Input-cost calibration keeps all spend and all input/cache tokens: + // ($10 * 0.7) / (100 + 900) tokens. + expect(report.summary.costRateUSD).toBeCloseTo(0.007, 12) + }) + + it('keeps sidechain spend out of every per-session finding in the optimize pipeline', async () => { + const projects = [project([ + sidechain('agent-only', { + totalCostUSD: 100, + totalInputTokens: 1_000_000, + totalOutputTokens: 100, + }), + ])] + + const result = await scanAndDetect(projects, { + start: new Date('2026-08-01T00:00:00.000Z'), + end: new Date('2026-08-02T00:00:00.000Z'), + }) + + expect(result.findings.map(finding => finding.id)).not.toContain('low-worth-sessions') + expect(result.findings.map(finding => finding.id)).not.toContain('context-heavy-sessions') + expect(result.findings.map(finding => finding.id)).not.toContain('cost-outliers') + }) + + it('uses the parent-session count in the text optimize headline', async () => { + const projects = [project([ + session('parent', { + totalCostUSD: 1, + bashBreakdown: { 'git commit -m shipped': { calls: 1 } }, + }), + sidechain('agent-child', { + totalCostUSD: 2, + bashBreakdown: { 'git commit -m irrelevant': { calls: 1 } }, + }), + ])] + const log = vi.spyOn(console, 'log').mockImplementation(() => undefined) + const stderr = vi.spyOn(process.stderr, 'write').mockImplementation(() => true) + try { + await runOptimize(projects, 'fixture', { + start: new Date('2026-08-01T00:00:00.000Z'), + end: new Date('2026-08-02T00:00:00.000Z'), + }) + const output = String(log.mock.calls.at(-1)?.[0] ?? '') + expect(output).toContain('1 session') + expect(output).not.toContain('2 sessions') + } finally { + log.mockRestore() + stderr.mockRestore() + } + }) + + it('separates cached optimize results when only sidechain classification changes', () => { + const range = { + start: new Date('2026-08-01T00:00:00.000Z'), + end: new Date('2026-08-02T00:00:00.000Z'), + } + const parentOnly = project([session('same')]) + const sidechainOnly = project([sidechain('same')]) + + expect(parentOnly.totalCostUSD).toBe(sidechainOnly.totalCostUSD) + expect(parentOnly.totalApiCalls).toBe(sidechainOnly.totalApiCalls) + expect(cacheKey([parentOnly], range)).not.toBe(cacheKey([sidechainOnly], range)) + + const firstSidechain = project([session('first'), sidechain('second')]) + const secondSidechain = project([sidechain('first'), session('second')]) + expect(firstSidechain.sessions.length).toBe(secondSidechain.sessions.length) + expect(firstSidechain.totalCostUSD).toBe(secondSidechain.totalCostUSD) + expect(firstSidechain.sessions.filter(item => item.isSidechain).length) + .toBe(secondSidechain.sessions.filter(item => item.isSidechain).length) + expect(cacheKey([firstSidechain], range)).not.toBe(cacheKey([secondSidechain], range)) + }) +}) diff --git a/tests/parser-subagent-range.test.ts b/tests/parser-subagent-range.test.ts index cd50665a..40713fec 100644 --- a/tests/parser-subagent-range.test.ts +++ b/tests/parser-subagent-range.test.ts @@ -4,6 +4,7 @@ import { join } from 'path' import { tmpdir } from 'os' import { parseAllSessions, filterProjectsByDays, clearSessionCache } from '../src/parser.js' +import { clearLoadCacheMemo } from '../src/session-cache.js' import { loadPricing } from '../src/models.js' import { aggregateByPr, prLinkedTotals } from '../src/sessions-report.js' @@ -64,8 +65,16 @@ describe('subagent fold across a date-range boundary', () => { const projects = await parseAllSessions(range, 'claude') // The child session is present (its work is in range) as a standalone session. - const childPresent = projects.some(p => p.sessions.some(s => s.sessionId === `agent-${AGENT}`)) - expect(childPresent).toBe(true) + const child = projects.flatMap(p => p.sessions).find(s => s.sessionId === `agent-${AGENT}`) + expect(child).toBeDefined() + expect(child!.isSidechain).toBe(true) + // Drop both in-process memo layers so the second parse reloads the persisted + // session cache. The marker must survive that warm-disk path too, not only + // the cold transcript parse. + clearSessionCache() + clearLoadCacheMemo() + const warmProjects = await parseAllSessions(range, 'claude') + expect(warmProjects.flatMap(p => p.sessions).find(s => s.sessionId === `agent-${AGENT}`)?.isSidechain).toBe(true) // The anchor parent (0 in-range turns) must NOT contaminate the sessions list; // it lives in subagentAnchors only. const anchorInSessions = projects.some(p => p.sessions.some(s => s.sessionId === PARENT)) @@ -101,6 +110,7 @@ describe('subagent fold across a date-range boundary', () => { const dayFiltered = filterProjectsByDays(projects, new Set(['2026-07-20'])) expect(dayFiltered.some(p => p.sessions.some(s => s.sessionId === PARENT))).toBe(false) // parent no longer a session expect(dayFiltered.some(p => (p.subagentAnchors ?? []).some(s => s.sessionId === PARENT))).toBe(true) // kept as anchor + expect(dayFiltered.flatMap(p => p.sessions).find(s => s.sessionId === `agent-${AGENT}`)?.isSidechain).toBe(true) // The child's spend still folds to the PR through the anchor. const row = aggregateByPr(dayFiltered).find(r => r.url === PR) From 1ea3d68e7167760f0d3b5019981db638bb73d519 Mon Sep 17 00:00:00 2001 From: Aditya Vikram Singh <247195684+avs-io@users.noreply.github.com> Date: Wed, 12 Aug 2026 21:51:57 +0530 Subject: [PATCH 04/85] fix(optimize): label connector actions clearly --- app/renderer/lib/types.ts | 2 +- app/renderer/sections/Optimize.test.tsx | 23 +++++++++ src/dashboard.tsx | 2 + src/optimize.ts | 14 ++++-- tests/dashboard.test.ts | 41 +++++++++++++++ tests/mcp-coverage.test.ts | 66 ++++++++++++++++++++++++- tests/optimize.test.ts | 4 +- 7 files changed, 144 insertions(+), 8 deletions(-) diff --git a/app/renderer/lib/types.ts b/app/renderer/lib/types.ts index 25394087..19a652c0 100644 --- a/app/renderer/lib/types.ts +++ b/app/renderer/lib/types.ts @@ -403,7 +403,7 @@ export type SpendFlow = { // ————— src/optimize.ts ————— export type WasteAction = - | { type: 'paste'; label: string; text: string; destination?: 'claude-md' | 'session-opener' | 'prompt' | 'shell-config' } + | { type: 'paste'; label: string; text: string; destination?: 'claude-md' | 'session-opener' | 'prompt' | 'shell-config' | 'manual' } | { type: 'command'; label: string; text: string } | { type: 'file-content'; label: string; path: string; content: string } diff --git a/app/renderer/sections/Optimize.test.tsx b/app/renderer/sections/Optimize.test.tsx index 5219afaf..f0ef0449 100644 --- a/app/renderer/sections/Optimize.test.tsx +++ b/app/renderer/sections/Optimize.test.tsx @@ -160,6 +160,29 @@ describe('Optimize', () => { expect(screen.getByText('{"batch":true}')).toBeInTheDocument() }) + it('renders and copies connector guidance as a manual action', async () => { + const report = makeOptimizeReport() + report.findings.push({ + id: 'mcp-low-coverage', title: 'Underused claude.ai connector', + explanation: 'The connector loads unused tools.', severity: 'medium', + trend: null, tokensSaved: 2_000, estimatedSavingsUSD: 1, + fix: { + type: 'paste', destination: 'manual', label: 'Manage the connector where it loads:', + text: 'Open /mcp and disable claude.ai Google Calendar.', + }, + }) + getOptimizeReport.mockResolvedValue(report) + render() + const row = await screen.findByRole('button', { name: /Underused claude.ai connector/ }) + fireEvent.click(row) + + expect(screen.getByText('Manage the connector where it loads:')).toBeInTheDocument() + expect(screen.getByText('Open /mcp and disable claude.ai Google Calendar.')).toBeInTheDocument() + expect(row.parentElement?.querySelector('.opt-fix')).toHaveClass('opt-fix-paste') + fireEvent.click(screen.getByRole('button', { name: 'Copy' })) + await waitFor(() => expect(writeText).toHaveBeenCalledWith('Open /mcp and disable claude.ai Google Calendar.')) + }) + it('switches to Reverts and Abandoned and shows only the matching yield details', async () => { render() await screen.findByText('Opus is doing your small talk') diff --git a/src/dashboard.tsx b/src/dashboard.tsx index b66b41cf..4c24579a 100644 --- a/src/dashboard.tsx +++ b/src/dashboard.tsx @@ -1046,6 +1046,8 @@ function actionDestinationHeader(action: WasteAction): string { 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, '─') } diff --git a/src/optimize.ts b/src/optimize.ts index d1b04769..b5284cb9 100644 --- a/src/optimize.ts +++ b/src/optimize.ts @@ -232,6 +232,7 @@ export type PasteDestination = | 'session-opener' // one-time paste at the start of a NEW session | 'prompt' // one-time ask in the current Claude conversation | 'shell-config' // append to ~/.zshrc / ~/.bashrc + | 'manual' // instructions the user carries out directly export type WasteAction = | { type: 'paste'; label: string; text: string; destination?: PasteDestination } @@ -1083,8 +1084,14 @@ export function detectMcpToolCoverage( // so `claude mcp remove` and the file-editing apply plan cannot own them. // Coverage is aggregate here; project-level config attribution is deliberately // out of scope, hence the instruction to inspect /mcp per affected project. + const connectorLabels = connectorServers.map(server => + `claude.ai ${server.slice('claude_ai_'.length).replaceAll('_', ' ')}`, + ) + const connectorEvidence = connectorServers.map((server, index) => + `${connectorLabels[index]} (${server})`, + ) const connectorGuidance = connectorServers.length > 0 - ? ` ${connectorServers.join(', ')} ${connectorServers.length === 1 ? 'is a claude.ai connector namespace' : 'are claude.ai connector namespaces'}, separate from any similarly named local MCP server. Transcript inventory is aggregated across the selected projects; use /mcp in each project where ${connectorServers.length === 1 ? 'it loads' : 'they load'}, or manage ${connectorServers.length === 1 ? 'it' : 'them'} in claude.ai Settings > Connectors.` + ? ` ${connectorEvidence.join(', ')} ${connectorServers.length === 1 ? 'is a claude.ai connector namespace' : 'are claude.ai connector namespaces'}, separate from any similarly named local MCP server. Transcript inventory is aggregated across the selected projects; use /mcp in each project where ${connectorServers.length === 1 ? 'it loads' : 'they load'}, or manage ${connectorServers.length === 1 ? 'it' : 'them'} in claude.ai Settings > Connectors.` : '' const fix: WasteAction = localServers.length > 0 ? { @@ -1096,11 +1103,11 @@ export function detectMcpToolCoverage( } : { type: 'paste', - destination: 'prompt', + destination: 'manual', label: connectorServers.length === 1 ? 'Manage the underused claude.ai connector where it loads:' : 'Manage the underused claude.ai connectors where they load:', - text: `Open /mcp in each affected project and disable ${connectorServers.join(', ')}, or manage ${connectorServers.length === 1 ? 'it' : 'them'} in claude.ai Settings > Connectors.`, + text: `Open /mcp in each affected project and disable ${connectorLabels.join(', ')}, or manage ${connectorServers.length === 1 ? 'it' : 'them'} in claude.ai Settings > Connectors.`, } return { @@ -3152,6 +3159,7 @@ function renderActionHeader(action: WasteAction): string { 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') } } diff --git a/tests/dashboard.test.ts b/tests/dashboard.test.ts index 9879c8a1..d121a2cb 100644 --- a/tests/dashboard.test.ts +++ b/tests/dashboard.test.ts @@ -395,6 +395,47 @@ describe('interactive terminal rendering', () => { expect(INTERACTIVE_RENDER_OPTIONS).toMatchObject({ alternateScreen: true }) }) + it('labels claude.ai connector remediation as a manual action', async () => { + const stdin = new PassThrough() as PassThrough & NodeJS.ReadStream + const stdout = new PassThrough() as PassThrough & NodeJS.WriteStream + stdin.isTTY = true + stdin.setRawMode = () => stdin + stdin.ref = () => stdin + stdin.unref = () => stdin + stdout.isTTY = true + stdout.columns = 120 + stdout.rows = 50 + const frames: string[] = [] + stdout.on('data', chunk => frames.push(stripAnsi(String(chunk)))) + + const inventory = Array.from({ length: 20 }, (_, i) => `mcp__claude_ai_Google_Calendar__t${i}`) + const sessions = ['connector-a', 'connector-b'].map((id, index) => { + const session = makeSession(id, 91.337 + index) + session.mcpInventory = inventory + return session + }) + const app = render(React.createElement(InteractiveDashboard, { + initialProjects: [makeProject('connector-manual-action', sessions)], + initialPeriod: 'today', + initialProvider: 'all', + refreshSeconds: 0, + windowColumns: 120, + }), { stdin, stdout, debug: true, interactive: true, patchConsole: false }) + onTestFinished(() => app.unmount()) + + await app.waitUntilRenderFlush() + stdin.write('o') + let frame = '' + for (let i = 0; i < 100 && !frame.includes('Manual action'); i++) { + await new Promise(resolve => setTimeout(resolve, 10)) + frame = frames.filter(value => value.trim()).at(-1) ?? '' + } + + expect(frame).toContain('Manual action') + expect(frame).toContain('claude.ai Google Calendar') + expect(frame).not.toContain('Ask Claude in the current session') + }) + it('leaves resize frame synchronization entirely to Ink', () => { const source = readFileSync(new URL('../src/dashboard.tsx', import.meta.url), 'utf8') expect(source).not.toContain('process.stdout.write(BSU)') diff --git a/tests/mcp-coverage.test.ts b/tests/mcp-coverage.test.ts index c036301b..6a18c325 100644 --- a/tests/mcp-coverage.test.ts +++ b/tests/mcp-coverage.test.ts @@ -1,10 +1,12 @@ -import { describe, it, expect } from 'vitest' +import { describe, it, expect, vi } from 'vitest' import { aggregateMcpCoverage, + buildOptimizeJsonReport, detectMcpProfileAdvisor, detectMcpToolCoverage, estimateMcpSchemaCost, + runOptimize, } from '../src/optimize.js' import type { ClassifiedTurn, @@ -346,19 +348,78 @@ describe('detectMcpToolCoverage', () => { expect(finding).not.toBeNull() expect(finding!.tokensSaved).toBe(20_000) + // Keep the transcript namespace as evidence, but name the connector the + // way users actually see it in /mcp and claude.ai Settings. expect(finding!.explanation).toContain(server) + expect(finding!.explanation).toContain('claude.ai Netlify') expect(finding!.explanation).toContain('/mcp') expect(finding!.explanation).toContain('claude.ai Settings > Connectors') expect(finding!.fix.type).toBe('paste') if (finding!.fix.type === 'paste') { - expect(finding!.fix.destination).toBe('prompt') + expect(finding!.fix.destination).toBe('manual') expect(finding!.fix.text).toContain('/mcp') + expect(finding!.fix.text).toContain('claude.ai Netlify') + expect(finding!.fix.text).not.toContain(server) expect(finding!.fix.text).toContain('claude.ai Settings > Connectors') } expect(JSON.stringify(finding)).not.toContain('claude mcp remove') expect(finding!.apply).toBeUndefined() }) + it('renders connector-only remediation as a manual action, never an Ask Claude prompt', async () => { + const server = 'claude_ai_Google_Calendar' + const inventory = Array.from({ length: 20 }, (_, i) => `mcp__${server}__t${i}`) + const turns = [makeTurn([makeCall({ cacheCreation: 50_000 })])] + const projects = [project([ + makeSession({ sessionId: 'a', inventory, turns }), + makeSession({ sessionId: 'b', inventory, turns }), + ])] + const log = vi.spyOn(console, 'log').mockImplementation(() => undefined) + + try { + await runOptimize(projects, 'Test period') + const output = log.mock.calls.map(args => args.join(' ')).join('\n') + expect(output).toContain('Manual action') + expect(output).toContain('claude.ai Google Calendar') + expect(output).not.toContain('Ask Claude in the current session') + } finally { + log.mockRestore() + } + }) + + it('keeps the public optimize JSON envelope while marking connector guidance manual', () => { + const server = 'claude_ai_Slack' + const coverage = [{ + server, + toolsAvailable: 20, + toolsInvoked: 0, + unusedTools: Array.from({ length: 20 }, (_, i) => `mcp__${server}__t${i}`), + invocations: 0, + loadedSessions: 2, + coverageRatio: 0, + }] + const finding = detectMcpToolCoverage([], coverage)! + + const report = buildOptimizeJsonReport([], 'Test period', { + findings: [finding], + costRate: 0, + healthScore: 90, + healthGrade: 'A', + }) + + expect(report.findings[0]).toMatchObject({ + id: 'mcp-low-coverage', + tokensSaved: 0, + fix: { + type: 'paste', + destination: 'manual', + text: expect.stringContaining('claude.ai Slack'), + }, + }) + expect(report.findings[0]).not.toHaveProperty('apply') + expect(report.findings[0]).not.toHaveProperty('applyTokensSaved') + }) + it('pluralises manual guidance when only claude.ai connectors are flagged', () => { const coverage = ['claude_ai_Slack', 'claude_ai_Google_Calendar'].map(server => ({ server, @@ -375,6 +436,7 @@ describe('detectMcpToolCoverage', () => { expect(finding).not.toBeNull() expect(finding!.fix).toMatchObject({ type: 'paste', + destination: 'manual', label: 'Manage the underused claude.ai connectors where they load:', }) if (finding!.fix.type === 'paste') { diff --git a/tests/optimize.test.ts b/tests/optimize.test.ts index bc72dd88..db14c94d 100644 --- a/tests/optimize.test.ts +++ b/tests/optimize.test.ts @@ -1197,9 +1197,9 @@ describe('paste-fix destination tagging (issue #277)', () => { if (f.fix.type === 'paste') { expect( f.fix.destination, - `finding "${f.title}" has paste fix without destination — pick one of: claude-md / session-opener / prompt / shell-config` + `finding "${f.title}" has paste fix without destination — pick one of: claude-md / session-opener / prompt / shell-config / manual` ).toBeDefined() - expect(['claude-md', 'session-opener', 'prompt', 'shell-config']) + expect(['claude-md', 'session-opener', 'prompt', 'shell-config', 'manual']) .toContain(f.fix.destination) } } From 8785b0dc55b119b4de9bcff7c947e070cf7cf343 Mon Sep 17 00:00:00 2001 From: Aditya Vikram Singh <247195684+avs-io@users.noreply.github.com> Date: Wed, 12 Aug 2026 22:40:25 +0530 Subject: [PATCH 05/85] fix(optimize): conserve MCP action savings --- src/act/optimize-apply.ts | 32 +++++++- src/act/plans.ts | 23 ++++-- src/act/report.ts | 27 +++++-- src/act/types.ts | 6 ++ src/optimize.ts | 138 +++++++++++++++++++++++--------- tests/act-report.test.ts | 137 +++++++++++++++++++++++++++++++- tests/mcp-coverage.test.ts | 108 +++++++++++++++++++++++++ tests/optimize-apply.test.ts | 150 +++++++++++++++++++++++++++++++++++ 8 files changed, 567 insertions(+), 54 deletions(-) diff --git a/src/act/optimize-apply.ts b/src/act/optimize-apply.ts index d85305d5..19b25550 100644 --- a/src/act/optimize-apply.ts +++ b/src/act/optimize-apply.ts @@ -37,17 +37,43 @@ function changeLines(fp: FindingPlan): string[] { }) } +function planTokensSaved(fp: FindingPlan): number { + if (fp.plan?.mcpSavingsUncertain) return Number.NaN + const byServer = fp.finding.applyTokensSavedByServer + const affected = fp.plan?.affectedMcpServers + if (byServer && affected) return affected.reduce((sum, server) => sum + (byServer[server] ?? 0), 0) + return fp.finding.applyTokensSaved ?? fp.finding.tokensSaved +} + +function manualActionLines(fp: FindingPlan): string[] { + if (fp.finding.manualFollowUp) { + return [fp.finding.manualFollowUp.label, fp.finding.manualFollowUp.text] + } + const action = fp.finding.fix + if (action.type === 'paste' && action.destination === 'manual') { + return [action.label, action.text] + } + return [] +} + export function renderApplyList(appliable: FindingPlan[], manual: FindingPlan[], costRate: number): string { const lines: string[] = [''] lines.push(chalk.bold(' Appliable config-class fixes:')) appliable.forEach((fp, i) => { const f = fp.finding - const actionTokensSaved = f.applyTokensSaved ?? f.tokensSaved - const savings = `~${formatTokens(actionTokensSaved)} tokens${costRate > 0 ? `, ~${formatCost(actionTokensSaved * costRate)}` : ''}` + const actionTokensSaved = planTokensSaved(fp) + const savings = Number.isFinite(actionTokensSaved) + ? `~${formatTokens(actionTokensSaved)} tokens${costRate > 0 ? `, ~${formatCost(actionTokensSaved * costRate)}` : ''}` + : 'Savings not estimated' lines.push('') lines.push(` ${i + 1}. ${f.title} ${chalk.hex('#FFD700')(`(${savings})`)}`) + if (fp.plan?.affectedMcpServers?.length) { + const servers = fp.plan.affectedMcpServers.join(', ') + lines.push(chalk.yellow(` Removes local MCP server${fp.plan.affectedMcpServers.length === 1 ? '' : 's'}: ${servers}`)) + } for (const line of changeLines(fp)) lines.push(chalk.dim(` ${line}`)) for (const note of fp.notes) lines.push(chalk.yellow(` ! ${note}`)) + for (const line of manualActionLines(fp)) lines.push(chalk.cyan(` ${line}`)) }) if (manual.length > 0) { lines.push('') @@ -55,6 +81,7 @@ export function renderApplyList(appliable: FindingPlan[], manual: FindingPlan[], for (const fp of manual) { lines.push(chalk.dim(` - ${fp.finding.title} [${fp.finding.id}] manual`)) for (const note of fp.notes) lines.push(chalk.yellow(` ! ${note}`)) + for (const line of manualActionLines(fp)) lines.push(chalk.cyan(` ${line}`)) } } lines.push('') @@ -130,6 +157,7 @@ export async function runOptimizeApply( print(chalk.dim('\n No appliable config-class fixes for this period.')) for (const fp of manual) { for (const note of fp.notes) print(chalk.yellow(` ! ${fp.finding.id}: ${note}`)) + for (const line of manualActionLines(fp)) print(chalk.cyan(` ${line}`)) } print() return diff --git a/src/act/plans.ts b/src/act/plans.ts index b3ee4e39..7d71fe2a 100644 --- a/src/act/plans.ts +++ b/src/act/plans.ts @@ -275,12 +275,15 @@ function pathNoteAdder(pathNotes: Record): (path: string, note: } function buildMcpRemove(finding: WasteFinding, r: ResolvedPaths): BuiltPlan { - const servers = finding.apply?.kind === 'mcp-remove' ? finding.apply.servers : [] + const servers = finding.apply?.kind === 'mcp-remove' + ? [...new Set(finding.apply.servers)] + : [] const searchPaths = [r.projectMcpJson, r.projectSettings, r.projectSettingsLocal, r.userClaudeJson] const docs = new ConfigDocs(r.homeDir) const skips: string[] = [] const pathNotes: Record = {} const addPathNote = pathNoteAdder(pathNotes) + const affectedServers: string[] = [] for (const server of servers) { let removed = false @@ -291,14 +294,24 @@ function buildMcpRemove(finding: WasteFinding, r: ResolvedPaths): BuiltPlan { if (res.removed) removed = true if (res.projectEntries.length > 0) addPathNote(path, projectRemovalNote(server, res.projectEntries, r.homeDir)) } - if (!removed) skips.push(`skipped ${server}: not found in editable config (plugin or managed config?)`) + if (removed) affectedServers.push(server) + else skips.push(`skipped ${server}: not found in editable config (plugin or managed config?)`) } const changes = docs.changes() const notes = [...docs.errorNotes(), ...skips] + const attribution = finding.applyTokensSavedByServer + const partialWithoutAttribution = affectedServers.length < servers.length && !attribution + const affectedMissingAttribution = attribution !== undefined + && affectedServers.some(server => !Object.hasOwn(attribution, server)) + const savingsUncertain = docs.errorNotes().length > 0 + || partialWithoutAttribution + || affectedMissingAttribution if (changes.length === 0) return { plan: null, notes } + const plan = mcpPlan('mcp-remove', finding.id, `Remove ${affectedServers.length === 1 ? 'an MCP server' : 'MCP servers'} from config`, changes, affectedServers) + if (savingsUncertain) plan.mcpSavingsUncertain = true return { - plan: mcpPlan('mcp-remove', finding.id, `Remove ${changes.length === 1 ? 'an MCP server' : 'MCP servers'} from config`, changes), + plan, notes, ...(Object.keys(pathNotes).length > 0 ? { pathNotes } : {}), } @@ -371,8 +384,8 @@ function buildMcpProjectScope(finding: WasteFinding, r: ResolvedPaths): BuiltPla } } -function mcpPlan(kind: ActionKind, findingId: string, description: string, changes: PlannedChange[]): ActionPlan { - return { kind, findingId, description, changes } +function mcpPlan(kind: ActionKind, findingId: string, description: string, changes: PlannedChange[], affectedMcpServers?: string[]): ActionPlan { + return { kind, findingId, description, changes, ...(affectedMcpServers ? { affectedMcpServers } : {}) } } // --------------------------------------------------------------------------- diff --git a/src/act/report.ts b/src/act/report.ts index 5379ee81..52b0aaab 100644 --- a/src/act/report.ts +++ b/src/act/report.ts @@ -666,7 +666,8 @@ type CaptureCtx = { now: Date } -function mcpServersFromApply(finding: WasteFinding): string[] { +function mcpServersFromApply(finding: WasteFinding, affectedMcpServers?: string[]): string[] { + if (affectedMcpServers) return affectedMcpServers if (finding.apply?.kind === 'mcp-remove') return finding.apply.servers if (finding.apply?.kind === 'mcp-project-scope') return finding.apply.servers.map(s => s.server) return [] @@ -684,7 +685,12 @@ function deferServers(finding: WasteFinding, ctx: CaptureCtx): string[] { return observedMcpServers(ctx.projects) } -export function captureBaseline(finding: WasteFinding, kind: ActionKind, ctx: CaptureCtx): ActionBaseline | undefined { +export function captureBaseline( + finding: WasteFinding, + kind: ActionKind, + ctx: CaptureCtx, + affectedMcpServers?: string[], +): ActionBaseline | undefined { const common = { windowDays: ctx.windowDays, capturedAt: ctx.now.toISOString(), @@ -692,16 +698,24 @@ export function captureBaseline(finding: WasteFinding, kind: ActionKind, ctx: Ca } if (MCP_KINDS.has(kind)) { - const servers = mcpServersFromApply(finding) + const servers = mcpServersFromApply(finding, affectedMcpServers) if (servers.length === 0) return undefined const covByServer = new Map(ctx.coverage.map(c => [c.server, c])) const metrics: Record = {} for (const server of servers) { const cov = covByServer.get(server) - const tools = cov && cov.toolsAvailable > 0 ? cov.toolsAvailable : TOOLS_PER_MCP_SERVER + // Removal realizes only the unused schema that the low-coverage + // detector estimated. If coverage is unavailable, omit the numeric + // claim instead of inventing a five-tool baseline. + const tools = finding.id === 'mcp-low-coverage' + ? cov?.unusedTools.length ?? 0 + : cov && cov.toolsAvailable > 0 ? cov.toolsAvailable : TOOLS_PER_MCP_SERVER metrics[server] = tools * TOKENS_PER_MCP_TOOL } - return { ...common, sessions: countSessionsLoading(ctx.projects, servers), metrics } + const estimatedTokens = finding.applyTokensSavedByServer + ? Math.round(servers.reduce((sum, server) => sum + (finding.applyTokensSavedByServer?.[server] ?? 0), 0)) + : common.estimatedTokens + return { ...common, estimatedTokens, sessions: countSessionsLoading(ctx.projects, servers), metrics } } if (DEFER_KINDS.has(kind)) { @@ -750,7 +764,8 @@ export async function captureBaselinesForPlans( const projects = await loadProjects({ start, end: now }) const ctx: CaptureCtx = { projects, coverage: aggregateMcpCoverage(projects), windowDays: BASELINE_WINDOW_DAYS, now } for (const fp of applicable) { - const baseline = captureBaseline(fp.finding, fp.plan!.kind, ctx) + if (fp.plan!.mcpSavingsUncertain) continue + const baseline = captureBaseline(fp.finding, fp.plan!.kind, ctx, fp.plan!.affectedMcpServers) if (baseline) fp.plan!.baseline = baseline } } diff --git a/src/act/types.ts b/src/act/types.ts index 4142abe4..8aa3fbc9 100644 --- a/src/act/types.ts +++ b/src/act/types.ts @@ -66,4 +66,10 @@ export type ActionPlan = { findingId?: string | null changes: PlannedChange[] baseline?: ActionBaseline + // MCP plans only: exact server identities the generated file mutations own. + // Preview and baseline capture must not claim skipped/managed targets. + affectedMcpServers?: string[] + // Relevant config scopes could not all be read, so removal may proceed + // with warnings but savings/baseline claims must be suppressed. + mcpSavingsUncertain?: boolean } diff --git a/src/optimize.ts b/src/optimize.ts index b5284cb9..a4b84780 100644 --- a/src/optimize.ts +++ b/src/optimize.ts @@ -305,6 +305,14 @@ export type WasteFinding = { /// subset of the finding. Omitted when `tokensSaved` already describes the /// whole apply action (or when the finding is manual-only). applyTokensSaved?: number + /// Per-server shares from the same capped cost pass as `tokensSaved`. + /// Internal apply/report consumers use this to price only targets that a + /// concrete mutation plan can actually edit; JSON output remains stable. + applyTokensSavedByServer?: Record + /// Additional by-hand action retained when `fix` is an executable local + /// command (for example, connector guidance beside a local MCP removal). + /// Internal apply UI metadata; the stable optimize JSON mapper omits it. + manualFollowUp?: { label: string; text: string } fix: WasteAction trend?: Trend apply?: FindingApply @@ -794,6 +802,12 @@ type McpSchemaCostEstimate = { effectiveInputTokens: number } +type McpSchemaCostAttribution = McpSchemaCostEstimate & { + byServer: Record +} + +type McpUnusedToolsByServer = Record + /** * Aggregate MCP inventory and invocations across the projects in scope. * @@ -969,49 +983,86 @@ export function estimateMcpSchemaCost( counts = unusedToolCounts } - const totalUnusedSchemaTokens = servers.reduce( - (s, srv) => s + (counts[srv] ?? 0) * TOKENS_PER_MCP_TOOL, - 0, - ) - if (totalUnusedSchemaTokens === 0) { - return { cacheWriteTokens: 0, cacheReadTokens: 0, effectiveInputTokens: 0 } + const attributed = estimateMcpSchemaCostAttributed(counts, projects, servers) + return { + cacheWriteTokens: attributed.cacheWriteTokens, + cacheReadTokens: attributed.cacheReadTokens, + effectiveInputTokens: attributed.effectiveInputTokens, + } +} + +function estimateMcpSchemaCostAttributed( + unusedToolsByServer: McpUnusedToolsByServer, + projects: ProjectSummary[], + servers: string[], +): McpSchemaCostAttribution { + servers = [...new Set(servers)] + const byServer: Record = {} + for (const server of servers) { + byServer[server] = { cacheWriteTokens: 0, cacheReadTokens: 0, effectiveInputTokens: 0 } } - const serverSet = new Set(servers) - let cacheWriteTokens = 0 - let cacheReadTokens = 0 + const addBucket = ( + loaded: Array<{ server: string; schemaTokens: number }>, + bucket: number, + key: 'cacheWriteTokens' | 'cacheReadTokens', + ): void => { + if (bucket <= 0) return + const totalSchemaTokens = loaded.reduce((sum, entry) => sum + entry.schemaTokens, 0) + if (totalSchemaTokens <= 0) return + const charged = Math.min(totalSchemaTokens, bucket) + for (const entry of loaded) { + byServer[entry.server]![key] += charged * (entry.schemaTokens / totalSchemaTokens) + } + } for (const project of projects) { for (const session of project.sessions) { - // A session counts only if its observed inventory included at least - // one of the flagged servers — same invariant `aggregateMcpCoverage` - // uses for `loadedSessions`. - let loaded = false - for (const fqn of session.mcpInventory ?? []) { - const seg = fqn.split('__')[1] - if (seg && serverSet.has(seg)) { loaded = true; break } + const inventory = new Set(session.mcpInventory ?? []) + const inventoryCounts = new Map() + for (const fqn of inventory) { + const parts = fqn.split('__') + if (parts[0] !== 'mcp' || !parts[1] || parts.length < 3) continue + inventoryCounts.set(parts[1], (inventoryCounts.get(parts[1]) ?? 0) + 1) } - if (!loaded) continue + + const loaded: Array<{ server: string; schemaTokens: number }> = [] + for (const server of servers) { + const unused = unusedToolsByServer[server] + const toolCount = typeof unused === 'number' + ? Math.min(unused, inventoryCounts.get(server) ?? 0) + : [...new Set(unused ?? [])].reduce((count, fqn) => count + (inventory.has(fqn) ? 1 : 0), 0) + if (toolCount > 0) loaded.push({ server, schemaTokens: toolCount * TOKENS_PER_MCP_TOOL }) + } + if (loaded.length === 0) continue for (const turn of session.turns) { for (const call of turn.assistantCalls) { - // Both buckets can be non-zero on the same call (cache rebuild - // alongside a partial read), so account for them independently. - // The cap is applied to the combined unused-schema budget so - // multiple flagged servers cannot all claim the same call. - if (call.usage.cacheCreationInputTokens > 0) { - cacheWriteTokens += Math.min(totalUnusedSchemaTokens, call.usage.cacheCreationInputTokens) - } - if (call.usage.cacheReadInputTokens > 0) { - cacheReadTokens += Math.min(totalUnusedSchemaTokens, call.usage.cacheReadInputTokens) - } + // A cache bucket is shared by every flagged schema loaded on this + // call. Charge it once, then attribute the capped amount in + // proportion to each server's unused schema. This conserves the + // combined total and makes any local-only subset additive. + addBucket(loaded, call.usage.cacheCreationInputTokens, 'cacheWriteTokens') + addBucket(loaded, call.usage.cacheReadInputTokens, 'cacheReadTokens') } } } } - const effectiveInputTokens = cacheWriteTokens * CACHE_WRITE_MULTIPLIER + cacheReadTokens * CACHE_READ_DISCOUNT - return { cacheWriteTokens, cacheReadTokens, effectiveInputTokens } + let cacheWriteTokens = 0 + let cacheReadTokens = 0 + for (const estimate of Object.values(byServer)) { + estimate.effectiveInputTokens = estimate.cacheWriteTokens * CACHE_WRITE_MULTIPLIER + + estimate.cacheReadTokens * CACHE_READ_DISCOUNT + cacheWriteTokens += estimate.cacheWriteTokens + cacheReadTokens += estimate.cacheReadTokens + } + return { + cacheWriteTokens, + cacheReadTokens, + effectiveInputTokens: cacheWriteTokens * CACHE_WRITE_MULTIPLIER + cacheReadTokens * CACHE_READ_DISCOUNT, + byServer, + } } /** @@ -1045,13 +1096,13 @@ export function detectMcpToolCoverage( const lines: string[] = [] const removeCommands: string[] = [] - const unusedCountsByServer: Record = {} + const unusedToolsByServer: Record = {} const flaggedServers: string[] = [] const localServers: string[] = [] const connectorServers: string[] = [] for (const c of flagged) { - unusedCountsByServer[c.server] = c.toolsAvailable - c.toolsInvoked + unusedToolsByServer[c.server] = c.unusedTools flaggedServers.push(c.server) const pct = Math.round(c.coverageRatio * 100) lines.push( @@ -1069,10 +1120,15 @@ export function detectMcpToolCoverage( // total unused-schema budget across all flagged servers, so two // flagged servers cannot independently claim the same call's cache // bucket and overstate `tokensSaved`. - const cost = estimateMcpSchemaCost(unusedCountsByServer, projects, flaggedServers) + const cost = estimateMcpSchemaCostAttributed(unusedToolsByServer, projects, flaggedServers) const tokensSaved = Math.round(cost.effectiveInputTokens) + const applyTokensSavedByServer = Object.fromEntries(localServers.map(server => [ + server, + cost.byServer[server]?.effectiveInputTokens ?? 0, + ])) + const localTokensSaved = Object.values(applyTokensSavedByServer).reduce((sum, value) => sum + value, 0) const applyTokensSaved = localServers.length > 0 && connectorServers.length > 0 - ? Math.round(estimateMcpSchemaCost(unusedCountsByServer, projects, localServers).effectiveInputTokens) + ? Math.round(localTokensSaved) : undefined const impact: Impact = tokensSaved >= MCP_COVERAGE_HIGH_IMPACT_TOKENS ? 'high' @@ -1093,6 +1149,14 @@ export function detectMcpToolCoverage( const connectorGuidance = connectorServers.length > 0 ? ` ${connectorEvidence.join(', ')} ${connectorServers.length === 1 ? 'is a claude.ai connector namespace' : 'are claude.ai connector namespaces'}, separate from any similarly named local MCP server. Transcript inventory is aggregated across the selected projects; use /mcp in each project where ${connectorServers.length === 1 ? 'it loads' : 'they load'}, or manage ${connectorServers.length === 1 ? 'it' : 'them'} in claude.ai Settings > Connectors.` : '' + const connectorAction = connectorServers.length > 0 + ? { + label: connectorServers.length === 1 + ? 'Manage the underused claude.ai connector where it loads:' + : 'Manage the underused claude.ai connectors where they load:', + text: `Open /mcp in each affected project and disable ${connectorLabels.join(', ')}, or manage ${connectorServers.length === 1 ? 'it' : 'them'} in claude.ai Settings > Connectors.`, + } + : undefined const fix: WasteAction = localServers.length > 0 ? { type: 'command', @@ -1104,10 +1168,8 @@ export function detectMcpToolCoverage( : { type: 'paste', destination: 'manual', - label: connectorServers.length === 1 - ? 'Manage the underused claude.ai connector where it loads:' - : 'Manage the underused claude.ai connectors where they load:', - text: `Open /mcp in each affected project and disable ${connectorLabels.join(', ')}, or manage ${connectorServers.length === 1 ? 'it' : 'them'} in claude.ai Settings > Connectors.`, + label: connectorAction!.label, + text: connectorAction!.text, } return { @@ -1120,6 +1182,8 @@ export function detectMcpToolCoverage( impact, tokensSaved, ...(applyTokensSaved !== undefined ? { applyTokensSaved } : {}), + ...(localServers.length > 0 ? { applyTokensSavedByServer } : {}), + ...(localServers.length > 0 && connectorAction ? { manualFollowUp: connectorAction } : {}), fix, ...(localServers.length > 0 ? { apply: { kind: 'mcp-remove' as const, servers: localServers } } diff --git a/tests/act-report.test.ts b/tests/act-report.test.ts index b40648a0..9179a60a 100644 --- a/tests/act-report.test.ts +++ b/tests/act-report.test.ts @@ -8,10 +8,12 @@ import { buildActReportJson, buildOptimizeAppliedHeader, captureBaseline, + captureBaselinesForPlans, computeActReport, renderActReport, } from '../src/act/report.js' import type { ActionRecord } from '../src/act/types.js' +import type { FindingPlan } from '../src/act/plans.js' import type { WasteFinding } from '../src/optimize.js' import type { ClassifiedTurn, ProjectSummary } from '../src/types.js' @@ -784,11 +786,11 @@ describe('partial-action baseline capture', () => { coverage: [{ server: 'filesystem', toolsAvailable: 20, - toolsInvoked: 0, - unusedTools: [], + toolsInvoked: 3, + unusedTools: Array.from({ length: 17 }, (_, i) => `mcp__filesystem__unused${i}`), invocations: 0, loadedSessions: 2, - coverageRatio: 0, + coverageRatio: 3 / 20, }], windowDays: 14, now: NOW, @@ -797,8 +799,135 @@ describe('partial-action baseline capture', () => { expect(baseline).toMatchObject({ estimatedTokens: 20_000, sessions: 2, - metrics: { filesystem: 8_000 }, + metrics: { filesystem: 6_800 }, }) expect(finding.tokensSaved).toBe(40_000) }) + + it('prices and measures only servers owned by the concrete mutation plan', () => { + const finding: WasteFinding = { + id: 'mcp-low-coverage', + title: '2 MCP servers with low tool coverage', + explanation: '', + impact: 'medium', + tokensSaved: 30_000, + applyTokensSaved: 30_000, + applyTokensSavedByServer: { filesystem: 10_000, managed: 20_000 }, + fix: { type: 'command', label: '', text: '' }, + apply: { kind: 'mcp-remove', servers: ['filesystem', 'managed'] }, + } + const sessions = sessionsAt(2, daysAgo(1), { + mcpInventory: [ + ...Array.from({ length: 17 }, (_, i) => `mcp__filesystem__t${i}`), + ...Array.from({ length: 12 }, (_, i) => `mcp__managed__t${i}`), + ], + }) + const coverage = [ + { + server: 'filesystem', toolsAvailable: 20, toolsInvoked: 3, + unusedTools: Array.from({ length: 17 }, (_, i) => `mcp__filesystem__t${i}`), + invocations: 3, loadedSessions: 2, coverageRatio: 3 / 20, + }, + { + server: 'managed', toolsAvailable: 20, toolsInvoked: 8, + unusedTools: Array.from({ length: 12 }, (_, i) => `mcp__managed__t${i}`), + invocations: 8, loadedSessions: 2, coverageRatio: 8 / 20, + }, + ] + + const baseline = captureBaseline(finding, 'mcp-remove', { + projects: [projectOf(sessions)], coverage, windowDays: 14, now: NOW, + }, ['filesystem']) + + expect(baseline).toMatchObject({ + estimatedTokens: 10_000, + sessions: 2, + metrics: { filesystem: 6_800 }, + }) + expect(baseline!.metrics).not.toHaveProperty('managed') + }) + + it('does not invent a low-coverage schema baseline when coverage is unavailable', () => { + const finding: WasteFinding = { + id: 'mcp-low-coverage', + title: '1 MCP server with low tool coverage', + explanation: '', + impact: 'medium', + tokensSaved: 10_000, + applyTokensSavedByServer: { filesystem: 10_000 }, + fix: { type: 'command', label: '', text: '' }, + apply: { kind: 'mcp-remove', servers: ['filesystem'] }, + } + + const baseline = captureBaseline(finding, 'mcp-remove', { + projects: [projectOf(sessionsAt(2, daysAgo(1)))], + coverage: [], + windowDays: 14, + now: NOW, + }, ['filesystem']) + + expect(baseline).toMatchObject({ + estimatedTokens: 10_000, + metrics: { filesystem: 0 }, + }) + }) + + it('stamps a narrowed plan with only its concrete server baseline', async () => { + const finding: WasteFinding = { + id: 'mcp-low-coverage', title: '2 MCP servers', explanation: '', impact: 'medium', + tokensSaved: 30_000, applyTokensSaved: 30_000, + applyTokensSavedByServer: { filesystem: 10_000, managed: 20_000 }, + fix: { type: 'command', label: '', text: '' }, + apply: { kind: 'mcp-remove', servers: ['filesystem', 'managed'] }, + } + const plan: FindingPlan = { + finding, + notes: [], + plan: { + kind: 'mcp-remove', description: 'Remove filesystem', changes: [], + affectedMcpServers: ['filesystem'], + }, + } + const sessions = sessionsAt(2, daysAgo(1), { + mcpInventory: [ + ...Array.from({ length: 17 }, (_, i) => `mcp__filesystem__t${i}`), + ...Array.from({ length: 12 }, (_, i) => `mcp__managed__t${i}`), + ], + }) + + await captureBaselinesForPlans([plan], { + now: NOW, + loadProjects: async () => [projectOf(sessions)], + }) + + expect(plan.plan?.baseline).toMatchObject({ + estimatedTokens: 10_000, + metrics: { filesystem: 6_800 }, + }) + expect(plan.plan?.baseline?.metrics).not.toHaveProperty('managed') + }) + + it('does not stamp a numeric baseline onto an uncertain partial mutation', async () => { + const finding: WasteFinding = { + id: 'mcp-low-coverage', title: '1 MCP server', explanation: '', impact: 'medium', + tokensSaved: 10_000, applyTokensSavedByServer: { filesystem: 10_000 }, + fix: { type: 'command', label: '', text: '' }, + apply: { kind: 'mcp-remove', servers: ['filesystem'] }, + } + const plan: FindingPlan = { + finding, + notes: ['could not parse .mcp.json'], + plan: { + kind: 'mcp-remove', description: 'Remove filesystem', changes: [], + affectedMcpServers: ['filesystem'], mcpSavingsUncertain: true, + }, + } + + await captureBaselinesForPlans([plan], { + now: NOW, + loadProjects: async () => [projectOf(sessionsAt(2, daysAgo(1)))], + }) + + expect(plan.plan?.baseline).toBeUndefined() + }) }) diff --git a/tests/mcp-coverage.test.ts b/tests/mcp-coverage.test.ts index 6a18c325..77301bc5 100644 --- a/tests/mcp-coverage.test.ts +++ b/tests/mcp-coverage.test.ts @@ -315,6 +315,23 @@ describe('estimateMcpSchemaCost', () => { expect(cost.cacheWriteTokens).toBe(24_000) }) + it('does not count a duplicated server identifier twice', () => { + const inventory = Array.from({ length: 20 }, (_, i) => `mcp__svc__t${i}`) + const sessions = [makeSession({ + inventory, + turns: [makeTurn([makeCall({ cacheCreation: 50_000 })])], + })] + + const cost = estimateMcpSchemaCost( + { svc: 20 }, + [project(sessions)], + ['svc', 'svc'], + ) + + expect(cost.cacheWriteTokens).toBe(8_000) + expect(cost.effectiveInputTokens).toBe(10_000) + }) + it('still works with the single-server signature (backward compat)', () => { const turns = [makeTurn([makeCall({ cacheCreation: 50_000 })])] const sessions = [makeSession({ @@ -418,6 +435,64 @@ describe('detectMcpToolCoverage', () => { }) expect(report.findings[0]).not.toHaveProperty('apply') expect(report.findings[0]).not.toHaveProperty('applyTokensSaved') + expect(report.findings[0]).not.toHaveProperty('applyTokensSavedByServer') + expect(report.findings[0]).not.toHaveProperty('manualFollowUp') + }) + + it('intersects globally unused tool identities with each session inventory', () => { + const server = 'filesystem' + const coverage = [{ + server, + toolsAvailable: 20, + toolsInvoked: 0, + unusedTools: Array.from({ length: 20 }, (_, i) => `mcp__${server}__t${i}`), + invocations: 0, + loadedSessions: 2, + coverageRatio: 0, + }] + const sessions = [5, 20].map((count, index) => makeSession({ + sessionId: `s${index}`, + inventory: Array.from({ length: count }, (_, i) => `mcp__${server}__t${i}`), + turns: [makeTurn([makeCall({ cacheCreation: 50_000 })])], + })) + + const finding = detectMcpToolCoverage([project(sessions)], coverage) + + // 5*400 and 20*400, each at 1.25x cache-write pricing. + expect(finding).toMatchObject({ tokensSaved: 12_500 }) + expect(finding!.applyTokensSavedByServer?.filesystem).toBe(12_500) + }) + + it('conserves simultaneous cache-write and cache-read buckets with fractional shares', () => { + const inventory = [ + ...Array.from({ length: 15 }, (_, i) => `mcp__filesystem__t${i}`), + ...Array.from({ length: 11 }, (_, i) => `mcp__claude_ai_Slack__t${i}`), + ] + const coverage: McpServerCoverage[] = [ + { + server: 'filesystem', toolsAvailable: 15, toolsInvoked: 0, + unusedTools: inventory.slice(0, 15), invocations: 0, loadedSessions: 2, coverageRatio: 0, + }, + { + server: 'claude_ai_Slack', toolsAvailable: 11, toolsInvoked: 0, + unusedTools: inventory.slice(15), invocations: 0, loadedSessions: 2, coverageRatio: 0, + }, + ] + // Duplicate inventory entries must not increase the schema share. + const sessionInventory = [...inventory, inventory[0]!, inventory[15]!] + const sessions = ['a', 'b'].map(sessionId => makeSession({ + sessionId, + inventory: sessionInventory, + turns: [makeTurn([makeCall({ cacheCreation: 5_001, cacheRead: 3_333 })])], + })) + + const finding = detectMcpToolCoverage([project(sessions)], coverage)! + const total = 2 * (5_001 * 1.25 + 3_333 * 0.10) + const local = total * (15 / 26) + + expect(finding.tokensSaved).toBe(Math.round(total)) + expect(finding.applyTokensSaved).toBe(Math.round(local)) + expect(finding.applyTokensSavedByServer?.filesystem).toBeCloseTo(local, 8) }) it('pluralises manual guidance when only claude.ai connectors are flagged', () => { @@ -522,6 +597,39 @@ describe('detectMcpToolCoverage', () => { expect(finding!.apply).toEqual({ kind: 'mcp-remove', servers: ['filesystem'] }) }) + it('attributes a capped mixed cache bucket proportionally to the local action', () => { + const inventory = ['filesystem', 'claude_ai_Slack'].flatMap(server => + Array.from({ length: 20 }, (_, i) => `mcp__${server}__t${i}`), + ) + const sessions = ['a', 'b'].map(sessionId => makeSession({ + sessionId, + inventory, + turns: [makeTurn([makeCall({ cacheCreation: 10_000 })])], + })) + + const finding = detectMcpToolCoverage([project(sessions)]) + + // Each call's 10K cache bucket is shared evenly by two 8K schemas. + // Total: 2 * 10K * 1.25 = 25K. The local mutation owns half. + expect(finding).toMatchObject({ tokensSaved: 25_000, applyTokensSaved: 12_500 }) + }) + + it('charges only the flagged servers actually loaded in each session', () => { + const sessions = ['filesystem', 'claude_ai_Slack'].flatMap(server => + ['a', 'b'].map(suffix => makeSession({ + sessionId: `${server}-${suffix}`, + inventory: Array.from({ length: 20 }, (_, i) => `mcp__${server}__t${i}`), + turns: [makeTurn([makeCall({ cacheCreation: 50_000 })])], + })), + ) + + const finding = detectMcpToolCoverage([project(sessions)]) + + // Four sessions each load one 8K schema. The combined finding must not + // charge both schemas to every session merely because both are flagged. + expect(finding).toMatchObject({ tokensSaved: 40_000, applyTokensSaved: 20_000 }) + }) + it('disambiguates a claude.ai connector from a similarly named local server', () => { const sessions: SessionSummary[] = [] for (const server of ['claude_ai_Netlify', 'netlify']) { diff --git a/tests/optimize-apply.test.ts b/tests/optimize-apply.test.ts index 0ed495f6..c8f8c094 100644 --- a/tests/optimize-apply.test.ts +++ b/tests/optimize-apply.test.ts @@ -163,6 +163,109 @@ describe('mcp-remove plan', () => { expect(preview).not.toContain('~80.0K tokens') expect(preview).not.toContain('~$0.160') }) + + it('scopes targets and savings to local servers actually present in editable config', async () => { + const fx = await makeFixture() + await writeFile(join(fx.home, '.claude.json'), JSON.stringify({ + mcpServers: { filesystem: { command: 'filesystem' } }, + }, null, 2) + '\n') + const finding: WasteFinding = { + id: 'mcp-low-coverage', + title: '3 MCP servers with low tool coverage', + explanation: '', + impact: 'medium', + tokensSaved: 60_000, + applyTokensSaved: 30_000, + applyTokensSavedByServer: { filesystem: 10_000, managed: 20_000 }, + fix: { type: 'command', label: '', text: "claude mcp remove 'filesystem'\nclaude mcp remove 'managed'" }, + apply: { kind: 'mcp-remove', servers: ['filesystem', 'managed'] }, + } + + const plans = planFindings([finding], { homeDir: fx.home, cwd: fx.project }) + const preview = stripAnsi(renderApplyList(plans.filter(p => p.plan), plans.filter(p => !p.plan), 0)) + + expect(plans[0]!.plan?.affectedMcpServers).toEqual(['filesystem']) + expect(preview).toContain('Removes local MCP server: filesystem') + expect(preview).toContain('~10.0K tokens') + expect(preview).not.toContain('~30.0K tokens') + expect(preview).toContain('skipped managed: not found in editable config') + }) + + it('suppresses savings for a legacy partial plan without per-server attribution', async () => { + const fx = await makeFixture() + await writeFile(join(fx.home, '.claude.json'), JSON.stringify({ + mcpServers: { filesystem: { command: 'filesystem' } }, + }, null, 2) + '\n') + const finding: WasteFinding = { + id: 'mcp-low-coverage', + title: '2 MCP servers with low tool coverage', + explanation: '', + impact: 'medium', + tokensSaved: 30_000, + applyTokensSaved: 30_000, + fix: { type: 'command', label: '', text: '' }, + apply: { kind: 'mcp-remove', servers: ['filesystem', 'managed'] }, + } + + const plans = planFindings([finding], { homeDir: fx.home, cwd: fx.project }) + const preview = stripAnsi(renderApplyList(plans.filter(p => p.plan), plans.filter(p => !p.plan), 0)) + + expect(plans[0]!.plan?.affectedMcpServers).toEqual(['filesystem']) + expect(plans[0]!.plan?.mcpSavingsUncertain).toBe(true) + expect(preview).toContain('Savings not estimated') + expect(preview).not.toContain('~30.0K tokens') + }) + + it('deduplicates repeated removal targets before planning and pricing them', async () => { + const fx = await makeFixture() + await writeFile(join(fx.home, '.claude.json'), JSON.stringify({ + mcpServers: { filesystem: { command: 'filesystem' } }, + }, null, 2) + '\n') + const finding: WasteFinding = { + id: 'mcp-low-coverage', + title: '1 MCP server with low tool coverage', + explanation: '', + impact: 'medium', + tokensSaved: 10_000, + applyTokensSavedByServer: { filesystem: 10_000 }, + fix: { type: 'command', label: '', text: '' }, + apply: { kind: 'mcp-remove', servers: ['filesystem', 'filesystem'] }, + } + + const plans = planFindings([finding], { homeDir: fx.home, cwd: fx.project }) + const preview = stripAnsi(renderApplyList(plans.filter(p => p.plan), [], 0)) + + expect(plans[0]!.plan?.affectedMcpServers).toEqual(['filesystem']) + expect(preview).toContain('~10.0K tokens') + expect(preview).not.toContain('~20.0K tokens') + }) + + it('does not claim savings when another relevant config scope is unreadable', async () => { + const fx = await makeFixture() + await writeFile(join(fx.home, '.claude.json'), JSON.stringify({ + mcpServers: { filesystem: { command: 'filesystem' } }, + }, null, 2) + '\n') + await writeFile(join(fx.project, '.mcp.json'), 'not json{{{') + const finding: WasteFinding = { + id: 'mcp-low-coverage', + title: '1 MCP server with low tool coverage', + explanation: '', + impact: 'medium', + tokensSaved: 10_000, + applyTokensSavedByServer: { filesystem: 10_000 }, + fix: { type: 'command', label: '', text: "claude mcp remove 'filesystem'" }, + apply: { kind: 'mcp-remove', servers: ['filesystem'] }, + } + + const plans = planFindings([finding], { homeDir: fx.home, cwd: fx.project }) + const preview = stripAnsi(renderApplyList(plans.filter(p => p.plan), plans.filter(p => !p.plan), 0)) + + expect(plans[0]!.plan?.affectedMcpServers).toEqual(['filesystem']) + expect(plans[0]!.plan?.mcpSavingsUncertain).toBe(true) + expect(preview).toContain('Savings not estimated') + expect(preview).toContain('could not parse') + expect(preview).not.toContain('~10.0K tokens') + }) }) describe('mcp-project-scope plan', () => { @@ -479,6 +582,53 @@ async function threeFindingFixture(): Promise<{ fx: Fixture; findings: WasteFind } describe('runOptimizeApply end-to-end', () => { + it('prints connector-only manual guidance when there is nothing to apply', async () => { + const fx = await makeFixture() + const connector: McpServerCoverage = { + server: 'claude_ai_Netlify', + toolsAvailable: 20, + toolsInvoked: 0, + unusedTools: Array.from({ length: 20 }, (_, i) => `mcp__claude_ai_Netlify__t${i}`), + invocations: 0, + loadedSessions: 2, + coverageRatio: 0, + } + const finding = detectMcpToolCoverage([], [connector])! + const io = makeIo() + + await runOptimizeApply([], undefined, applyOpts(fx, io, { findings: [finding], yes: true })) + + expect(io.stdout()).toContain('No appliable config-class fixes') + expect(io.stdout()).toContain('/mcp') + expect(io.stdout()).toContain('claude.ai Netlify') + expect(io.stdout()).not.toContain('claude mcp remove') + }) + + it('names the exact local removal target and preserves connector follow-up in a mixed preview', async () => { + const fx = await makeFixture() + await writeFile(join(fx.home, '.claude.json'), JSON.stringify({ + mcpServers: { filesystem: { command: 'filesystem' }, netlify: { command: 'local-netlify' } }, + }, null, 2) + '\n') + const coverage = (server: string): McpServerCoverage => ({ + server, + toolsAvailable: 20, + toolsInvoked: 0, + unusedTools: Array.from({ length: 20 }, (_, i) => `mcp__${server}__t${i}`), + invocations: 0, + loadedSessions: 2, + coverageRatio: 0, + }) + const finding = detectMcpToolCoverage([], [coverage('filesystem'), coverage('claude_ai_Netlify')])! + const io = makeIo() + + await runOptimizeApply([], undefined, applyOpts(fx, io, { findings: [finding], dryRun: true })) + + expect(io.stdout()).toContain('Removes local MCP server: filesystem') + expect(io.stdout()).toContain('/mcp') + expect(io.stdout()).toContain('claude.ai Netlify') + expect(io.stdout()).not.toContain("claude mcp remove 'claude_ai_Netlify'") + }) + it('--yes applies every plan and prints journal short ids with the undo hint', async () => { const { fx, findings } = await threeFindingFixture() const io = makeIo() From ee025bafcaac00ebdf08170d1df02a0c82f4bf3c Mon Sep 17 00:00:00 2001 From: Emre K <110906681+kocaemre@users.noreply.github.com> Date: Wed, 12 Aug 2026 22:18:24 +0200 Subject: [PATCH 06/85] Add unpriced filter to models report --- src/main.ts | 13 ++++++-- tests/models-report.test.ts | 65 ++++++++++++++++++++++++++++++++++++- 2 files changed, 75 insertions(+), 3 deletions(-) diff --git a/src/main.ts b/src/main.ts index d201920b..7684dac9 100644 --- a/src/main.ts +++ b/src/main.ts @@ -2075,6 +2075,7 @@ program .option('--by-agent', 'One row per (provider, model, agent) instead of one row per (provider, model). Claude subagent transcripts only; other providers and main sessions bucket under "main"') .option('--top ', 'Show only the top N rows', (v: string) => parseInt(v, 10)) .option('--min-cost ', 'Hide rows below this cost threshold', (v: string) => parseFloat(v)) + .option('--unpriced', 'Show only models with usage that currently price at $0') .option('--no-totals', 'Suppress the footer totals row') .option('--format ', 'Output format: table, markdown, json, csv', 'table') .action(async (opts) => { @@ -2099,13 +2100,21 @@ program } const projects = await parseAllSessions(range, opts.provider) - const rows = await aggregateModels(projects, { + let rows = await aggregateModels(projects, { byTask: !!opts.byTask, byAgent: !!opts.byAgent, taskFilter: opts.task, topN: typeof opts.top === 'number' && Number.isFinite(opts.top) ? opts.top : undefined, - minCost: typeof opts.minCost === 'number' && Number.isFinite(opts.minCost) ? opts.minCost : 0.01, + minCost: typeof opts.minCost === 'number' && Number.isFinite(opts.minCost) ? opts.minCost : (opts.unpriced ? 0 : 0.01), }) + if (opts.unpriced) { + rows = rows.filter(row => findUnpricedModels([{ + model: row.model, + calls: row.calls, + cost: row.costUSD, + tokens: row.totalTokens, + }]).length > 0) + } const fmt = (opts.format ?? 'table').toLowerCase() if (rows.length === 0 && (fmt === 'table' || fmt === 'markdown')) { diff --git a/tests/models-report.test.ts b/tests/models-report.test.ts index 33317fd8..8808ca58 100644 --- a/tests/models-report.test.ts +++ b/tests/models-report.test.ts @@ -1,6 +1,9 @@ +import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' import { spawnSync } from 'node:child_process' -import { describe, it, expect } from 'vitest' +import { describe, it, expect, vi } from 'vitest' import chalk from 'chalk' import stripAnsi from 'strip-ansi' @@ -713,6 +716,66 @@ describe('renderCsv', () => { }) describe('models CLI breakdown flags', () => { + vi.setConfig({ testTimeout: 30_000 }) + + it('filters the models report to unpriced rows', async () => { + const home = await mkdtemp(join(tmpdir(), 'codeburn-models-unpriced-')) + try { + const projectDir = join(home, '.claude', 'projects', 'models-unpriced') + await mkdir(projectDir, { recursive: true }) + await writeFile(join(projectDir, 'session.jsonl'), [ + JSON.stringify({ + type: 'user', + sessionId: 'models-unpriced-session', + timestamp: '2026-05-09T00:00:00.000Z', + cwd: '/tmp/models-unpriced', + message: { role: 'user', content: 'Use one priced and one unpriced model.' }, + }), + JSON.stringify({ + type: 'assistant', + sessionId: 'models-unpriced-session', + timestamp: '2026-05-09T00:01:00.000Z', + cwd: '/tmp/models-unpriced', + message: { + id: 'priced', + type: 'message', + role: 'assistant', + model: 'claude-sonnet-4-6', + content: [{ type: 'text', text: 'priced' }], + usage: { input_tokens: 1000, output_tokens: 100, cache_read_input_tokens: 0, cache_creation_input_tokens: 0 }, + }, + }), + JSON.stringify({ + type: 'assistant', + sessionId: 'models-unpriced-session', + timestamp: '2026-05-09T00:02:00.000Z', + cwd: '/tmp/models-unpriced', + message: { + id: 'unpriced', + type: 'message', + role: 'assistant', + model: 'zz-unpriced-frontier-model', + content: [{ type: 'text', text: 'unpriced' }], + usage: { input_tokens: 2000, output_tokens: 200, cache_read_input_tokens: 0, cache_creation_input_tokens: 0 }, + }, + }), + ].join('\n') + '\n') + + const res = spawnSync( + process.execPath, + ['--import', 'tsx', 'src/cli.ts', 'models', '--unpriced', '--from', '2026-05-09', '--to', '2026-05-09', '--provider', 'claude', '--format', 'json'], + { cwd: process.cwd(), env: { ...process.env, HOME: home, CLAUDE_CONFIG_DIR: join(home, '.claude'), CODEBURN_CACHE_DIR: join(home, '.cache', 'codeburn'), TZ: 'UTC' }, encoding: 'utf-8', timeout: 30_000 }, + ) + + expect(res.status, `stdout: ${res.stdout}\nstderr: ${res.stderr}`).toBe(0) + const rows = JSON.parse(res.stdout) as Array<{ model: string; calls: number }> + expect(rows.map(row => row.model)).toEqual(['zz-unpriced-frontier-model']) + expect(rows[0]?.calls).toBe(1) + } finally { + await rm(home, { recursive: true, force: true }) + } + }) + it('rejects --by-task and --by-agent together with a clear error and exit 1', () => { const res = spawnSync( process.execPath, From 89bd1a9e318f4844f274baf74336a8ef1b16d951 Mon Sep 17 00:00:00 2001 From: Aditya Vikram Singh <247195684+avs-io@users.noreply.github.com> Date: Thu, 13 Aug 2026 18:52:34 +0530 Subject: [PATCH 07/85] fix(act): keep connector follow-up explicitly pending --- src/act/optimize-apply.ts | 11 ++++++++++- tests/optimize-apply.test.ts | 27 +++++++++++++++++++++++++++ 2 files changed, 37 insertions(+), 1 deletion(-) diff --git a/src/act/optimize-apply.ts b/src/act/optimize-apply.ts index 19b25550..72ddfbc6 100644 --- a/src/act/optimize-apply.ts +++ b/src/act/optimize-apply.ts @@ -73,7 +73,11 @@ export function renderApplyList(appliable: FindingPlan[], manual: FindingPlan[], } for (const line of changeLines(fp)) lines.push(chalk.dim(` ${line}`)) for (const note of fp.notes) lines.push(chalk.yellow(` ! ${note}`)) - for (const line of manualActionLines(fp)) lines.push(chalk.cyan(` ${line}`)) + const manualLines = manualActionLines(fp) + if (manualLines.length > 0) { + lines.push(chalk.cyan(' Manual follow-up (not applied):')) + for (const line of manualLines) lines.push(chalk.cyan(` ${line}`)) + } }) if (manual.length > 0) { lines.push('') @@ -206,6 +210,11 @@ export async function runOptimizeApply( const record = await runAction(fp.plan!, opts.actionsDir) print(` Applied ${chalk.bold(shortId(record.id))} ${record.description}`) print(chalk.dim(` Undo anytime: codeburn act undo ${shortId(record.id)}`)) + const manualLines = manualActionLines(fp) + if (manualLines.length > 0) { + print(chalk.cyan(' Still requires manual action:')) + for (const line of manualLines) print(chalk.cyan(` ${line}`)) + } } catch (e) { errout.write(chalk.red(` Failed to apply ${fp.finding.id}: ${e instanceof Error ? e.message : String(e)}`) + '\n') process.exitCode = 1 diff --git a/tests/optimize-apply.test.ts b/tests/optimize-apply.test.ts index c8f8c094..4d6014c2 100644 --- a/tests/optimize-apply.test.ts +++ b/tests/optimize-apply.test.ts @@ -629,6 +629,33 @@ describe('runOptimizeApply end-to-end', () => { expect(io.stdout()).not.toContain("claude mcp remove 'claude_ai_Netlify'") }) + it('keeps mixed connector follow-up explicitly pending after applying the local fix', async () => { + const fx = await makeFixture() + await writeFile(join(fx.home, '.claude.json'), JSON.stringify({ + mcpServers: { filesystem: { command: 'filesystem' } }, + }, null, 2) + '\n') + const coverage = (server: string): McpServerCoverage => ({ + server, + toolsAvailable: 20, + toolsInvoked: 0, + unusedTools: Array.from({ length: 20 }, (_, i) => `mcp__${server}__t${i}`), + invocations: 0, + loadedSessions: 2, + coverageRatio: 0, + }) + const finding = detectMcpToolCoverage([], [coverage('filesystem'), coverage('claude_ai_Netlify')])! + const io = makeIo() + + await runOptimizeApply([], undefined, applyOpts(fx, io, { findings: [finding], yes: true })) + + const out = io.stdout() + expect(out).toContain('Manual follow-up (not applied):') + expect(out).toContain('Still requires manual action:') + expect(out).toContain('claude.ai Netlify') + expect(await readRecords(fx.actionsDir)).toHaveLength(1) + expect(JSON.parse(await readFile(join(fx.home, '.claude.json'), 'utf-8')).mcpServers).toEqual({}) + }) + it('--yes applies every plan and prints journal short ids with the undo hint', async () => { const { fx, findings } = await threeFindingFixture() const io = makeIo() From eb7ebb534c9ec45f7043cf1893e5fc1d5b1ad7d0 Mon Sep 17 00:00:00 2001 From: Aditya Vikram Singh <247195684+avs-io@users.noreply.github.com> Date: Thu, 13 Aug 2026 19:33:26 +0530 Subject: [PATCH 08/85] ci(desktop): guard Windows installer artifacts --- .github/workflows/build-windows-installer.yml | 60 ++++++++++++ app/DISTRIBUTION.md | 46 ++++++---- app/scripts/verify-windows-installer.mjs | 64 +++++++++++++ app/scripts/verify-windows-installer.test.ts | 92 +++++++++++++++++++ 4 files changed, 243 insertions(+), 19 deletions(-) create mode 100644 .github/workflows/build-windows-installer.yml create mode 100644 app/scripts/verify-windows-installer.mjs create mode 100644 app/scripts/verify-windows-installer.test.ts diff --git a/.github/workflows/build-windows-installer.yml b/.github/workflows/build-windows-installer.yml new file mode 100644 index 00000000..7638a707 --- /dev/null +++ b/.github/workflows/build-windows-installer.yml @@ -0,0 +1,60 @@ +name: Build Windows installer + +on: + workflow_dispatch: + pull_request: + paths: + - .github/workflows/build-windows-installer.yml + - app/** + - src/** + - scripts/** + - package.json + - package-lock.json + push: + tags: + - 'desktop-v*' + +permissions: + contents: read + +jobs: + nsis: + runs-on: windows-latest + steps: + - uses: actions/checkout@v6 + + - uses: actions/setup-node@v6 + with: + node-version: 22.13.0 + cache: npm + cache-dependency-path: | + package-lock.json + app/package-lock.json + + - name: Install CLI dependencies + run: npm ci + + - name: Install desktop dependencies + run: npm ci --prefix app + + - name: Build NSIS installer + run: npm --prefix app run package:win + + - name: Verify installer manifest + shell: pwsh + run: | + if ($env:GITHUB_REF_TYPE -eq 'tag') { + node app/scripts/verify-windows-installer.mjs --tag $env:GITHUB_REF_NAME + } else { + node app/scripts/verify-windows-installer.mjs + } + + - name: Upload installer artifact + uses: actions/upload-artifact@v6 + with: + name: CodeBurn-Windows-Installer + path: | + app/release/CodeBurn-Setup-*.exe + app/release/CodeBurn-Setup-*.exe.blockmap + if-no-files-found: error + retention-days: 14 diff --git a/app/DISTRIBUTION.md b/app/DISTRIBUTION.md index 69165022..94255dac 100644 --- a/app/DISTRIBUTION.md +++ b/app/DISTRIBUTION.md @@ -3,10 +3,10 @@ This document describes how to produce distributable macOS, Windows, and Linux builds of the Electron desktop app. The macOS build is ad-hoc-signed and **not notarized** (no paid Apple Developer account); the Windows and Linux -builds are **unsigned**. There is no CI automation for any of this yet (unlike -the CLI and menubar release processes in `../RELEASING.md`) — packaging is run -by hand on a maintainer's machine. All three targets are produced by -`electron-builder` and can be cross-built from a single macOS host. +builds are **unsigned**. Windows NSIS packages are built and checked by the +`Build Windows installer` GitHub Actions workflow; the other desktop packages +are still produced by hand. All three targets are produced by +`electron-builder`. ## The bundled CLI (no install prerequisite) @@ -161,12 +161,11 @@ the NSIS and AppImage tooling on first run. ### Windows (`package:win`) -`electron-builder --win` produces a single artifact in `app/release/`: +`electron-builder --win` produces a single installer in `app/release/`: -- **`CodeBurn Setup 0.9.15.exe`** — the NSIS installer (the version number - tracks `package.json`; note the spaces in the filename). A `.exe.blockmap` - is written alongside it (differential-update metadata, unused — no - auto-updater yet). +- **`CodeBurn-Setup-0.9.15.exe`** — the NSIS installer (the version number + tracks `package.json`). A `.exe.blockmap` is written alongside it + (differential-update metadata, unused — no auto-updater yet). Config (`build.win` + `build.nsis`): @@ -236,8 +235,7 @@ taskbar/dock; it does not affect packaging or launch. ## Releases -There is no release CI for the desktop app yet (see the note at the top). When -a maintainer cuts a desktop release by hand, the GitHub tag convention is: +When a maintainer cuts a desktop release, the GitHub tag convention is: ``` desktop-v # e.g. desktop-v0.9.15 @@ -245,14 +243,24 @@ desktop-v # e.g. desktop-v0.9.15 This mirrors the menubar's `mac-v` convention (see `../RELEASING.md`) and keeps the desktop app's tags in their own namespace, separate from the CLI -(`v`) and the menubar (`mac-v`). Upload all of the artifacts -above — the four macOS `.dmg`/`.zip` files, `CodeBurn-Setup-.exe`, -and `CodeBurn-.AppImage` — to the GitHub Release created at that -tag. The website's download links **pin that tag** in their URLs, so the -release name and the artifact filenames must match exactly. (The Windows -installer uses an explicit `nsis.artifactName` of -`CodeBurn-Setup-${version}.${ext}` — electron-builder's default contains -spaces, which make ugly percent-encoded URLs.) +(`v`) and the menubar (`mac-v`). + +Pushing a `desktop-v` tag runs the `Build Windows installer` workflow +on `windows-latest`. The workflow requires the tag version, root package +version, and app package version to agree, and it fails unless the build emits +exactly one `CodeBurn-Setup-.exe` and one matching +`.exe.blockmap`. It uploads both files as the `CodeBurn-Windows-Installer` +Actions artifact. The workflow has read-only repository permissions and does +**not** publish release assets automatically. + +Before publishing the GitHub Release, the release owner must download that +workflow artifact and manually upload both Windows files along with the four +macOS `.dmg`/`.zip` files and `CodeBurn-.AppImage`. Confirm the live +release contains every required platform asset before announcing it. The +website's download links **pin that tag** in their URLs, so a release with a +missing installer is broken even when another Windows distribution channel is +available. The Windows installer uses an explicit `nsis.artifactName` of +`CodeBurn-Setup-${version}.${ext}`. ## Verifying a build diff --git a/app/scripts/verify-windows-installer.mjs b/app/scripts/verify-windows-installer.mjs new file mode 100644 index 00000000..a135eb61 --- /dev/null +++ b/app/scripts/verify-windows-installer.mjs @@ -0,0 +1,64 @@ +#!/usr/bin/env node + +import { readFileSync, readdirSync } from 'node:fs' +import { basename, join, resolve } from 'node:path' + +function fail(message) { + console.error(`Windows installer manifest invalid: ${message}`) + process.exitCode = 1 +} + +function option(name, fallback) { + const index = process.argv.indexOf(name) + if (index === -1) return fallback + if (!process.argv[index + 1]) throw new Error(`${name} requires a value`) + return process.argv[index + 1] +} + +function packageVersion(path) { + return JSON.parse(readFileSync(path, 'utf8')).version +} + +function filesBelow(directory) { + return readdirSync(directory, { recursive: true, withFileTypes: true }) + .filter(entry => entry.isFile()) + .map(entry => basename(entry.name)) +} + +try { + const root = resolve(option('--root', new URL('../..', import.meta.url).pathname)) + const artifacts = resolve(option('--artifacts', join(root, 'app', 'release'))) + const tag = option('--tag', '') + const rootVersion = packageVersion(join(root, 'package.json')) + const appVersion = packageVersion(join(root, 'app', 'package.json')) + + if (rootVersion !== appVersion) { + fail(`root version ${rootVersion} does not match app version ${appVersion}`) + } + + if (tag && tag !== `desktop-v${appVersion}`) { + fail(`${tag} does not match app version ${appVersion}`) + } + + const files = filesBelow(artifacts) + const expectedArtifacts = [ + `CodeBurn-Setup-${appVersion}.exe`, + `CodeBurn-Setup-${appVersion}.exe.blockmap`, + ] + for (const expected of expectedArtifacts) { + const count = files.filter(file => file === expected).length + if (count !== 1) fail(`expected exactly one ${expected}, found ${count}`) + } + + const installerArtifacts = files.filter(file => /^CodeBurn-Setup-.*\.exe(?:\.blockmap)?$/.test(file)) + const unexpected = installerArtifacts.filter(file => !expectedArtifacts.includes(file)) + if (unexpected.length > 0) { + fail(`unexpected Windows installer artifacts: ${unexpected.join(', ')}`) + } + + if (!process.exitCode) { + console.log(`Windows installer manifest verified for ${appVersion}`) + } +} catch (error) { + fail(error instanceof Error ? error.message : String(error)) +} diff --git a/app/scripts/verify-windows-installer.test.ts b/app/scripts/verify-windows-installer.test.ts new file mode 100644 index 00000000..b8f488fc --- /dev/null +++ b/app/scripts/verify-windows-installer.test.ts @@ -0,0 +1,92 @@ +import { mkdtempSync, mkdirSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { spawnSync } from 'node:child_process' +import { describe, expect, it } from 'vitest' + +const verifier = new URL('./verify-windows-installer.mjs', import.meta.url) + +function fixture(options: { + appVersion?: string + rootVersion?: string + files?: string[] + tag?: string +} = {}) { + const root = mkdtempSync(join(tmpdir(), 'codeburn-windows-manifest-')) + const appDir = join(root, 'app') + const releaseDir = join(appDir, 'release') + mkdirSync(releaseDir, { recursive: true }) + + const appVersion = options.appVersion ?? '1.2.3' + writeFileSync(join(root, 'package.json'), JSON.stringify({ version: options.rootVersion ?? appVersion })) + writeFileSync(join(appDir, 'package.json'), JSON.stringify({ version: appVersion })) + for (const file of options.files ?? [ + `CodeBurn-Setup-${appVersion}.exe`, + `CodeBurn-Setup-${appVersion}.exe.blockmap`, + ]) { + const path = join(releaseDir, file) + mkdirSync(join(path, '..'), { recursive: true }) + writeFileSync(path, 'fixture') + } + + const args = [verifier.pathname, '--root', root, '--artifacts', releaseDir] + if (options.tag) args.push('--tag', options.tag) + return spawnSync(process.execPath, args, { encoding: 'utf8' }) +} + +describe('Windows installer release manifest verifier', () => { + it('accepts one exact installer and blockmap for matching package versions and tag', () => { + const result = fixture({ tag: 'desktop-v1.2.3' }) + + expect(result.status).toBe(0) + expect(result.stdout).toContain('Windows installer manifest verified for 1.2.3') + }) + + it('rejects a desktop tag that does not match the app version', () => { + const result = fixture({ tag: 'desktop-v1.2.4' }) + + expect(result.status).toBe(1) + expect(result.stderr).toContain('desktop-v1.2.4 does not match app version 1.2.3') + }) + + it('rejects divergent root and app versions', () => { + const result = fixture({ rootVersion: '1.2.2' }) + + expect(result.status).toBe(1) + expect(result.stderr).toContain('root version 1.2.2 does not match app version 1.2.3') + }) + + it('rejects a missing installer blockmap', () => { + const result = fixture({ files: ['CodeBurn-Setup-1.2.3.exe'] }) + + expect(result.status).toBe(1) + expect(result.stderr).toContain('expected exactly one CodeBurn-Setup-1.2.3.exe.blockmap, found 0') + }) + + it('rejects duplicate expected artifacts in nested output directories', () => { + const result = fixture({ + files: [ + 'CodeBurn-Setup-1.2.3.exe', + 'CodeBurn-Setup-1.2.3.exe.blockmap', + 'duplicate/CodeBurn-Setup-1.2.3.exe', + ], + }) + + expect(result.status).toBe(1) + expect(result.stderr).toContain('expected exactly one CodeBurn-Setup-1.2.3.exe, found 2') + }) + + it('rejects stale installer artifacts from another version', () => { + const result = fixture({ + files: [ + 'CodeBurn-Setup-1.2.3.exe', + 'CodeBurn-Setup-1.2.3.exe.blockmap', + 'CodeBurn-Setup-1.2.2.exe', + 'CodeBurn-Setup-1.2.2.exe.blockmap', + ], + }) + + expect(result.status).toBe(1) + expect(result.stderr).toContain('unexpected Windows installer artifacts') + }) +}) From fe6d18357374a7f35d7d1789fa9e8bc4fe4c20d1 Mon Sep 17 00:00:00 2001 From: Aditya Vikram Singh <247195684+avs-io@users.noreply.github.com> Date: Thu, 13 Aug 2026 19:47:31 +0530 Subject: [PATCH 09/85] fix(release): harden Windows installer verification --- .github/workflows/build-windows-installer.yml | 30 +++++- RELEASING.md | 4 +- app/DISTRIBUTION.md | 9 +- app/scripts/verify-windows-installer.mjs | 91 +++++++++++++------ app/scripts/verify-windows-installer.test.ts | 55 ++++++++++- app/scripts/windows-installer-paths.d.mts | 1 + app/scripts/windows-installer-paths.mjs | 8 ++ 7 files changed, 163 insertions(+), 35 deletions(-) create mode 100644 app/scripts/windows-installer-paths.d.mts create mode 100644 app/scripts/windows-installer-paths.mjs diff --git a/.github/workflows/build-windows-installer.yml b/.github/workflows/build-windows-installer.yml index 7638a707..8373e4a2 100644 --- a/.github/workflows/build-windows-installer.yml +++ b/.github/workflows/build-windows-installer.yml @@ -2,6 +2,11 @@ name: Build Windows installer on: workflow_dispatch: + inputs: + release_tag: + description: Existing desktop-v* release to verify after manual asset upload + required: false + type: string pull_request: paths: - .github/workflows/build-windows-installer.yml @@ -13,12 +18,15 @@ on: push: tags: - 'desktop-v*' + release: + types: [published] permissions: contents: read jobs: nsis: + if: ${{ github.event_name != 'release' && !(github.event_name == 'workflow_dispatch' && inputs.release_tag != '') }} runs-on: windows-latest steps: - uses: actions/checkout@v6 @@ -37,6 +45,9 @@ jobs: - name: Install desktop dependencies run: npm ci --prefix app + - name: Test installer verifier + run: npm --prefix app test -- scripts/verify-windows-installer.test.ts + - name: Build NSIS installer run: npm --prefix app run package:win @@ -57,4 +68,21 @@ jobs: app/release/CodeBurn-Setup-*.exe app/release/CodeBurn-Setup-*.exe.blockmap if-no-files-found: error - retention-days: 14 + retention-days: 30 + + verify-release-assets: + if: ${{ (github.event_name == 'release' && startsWith(github.event.release.tag_name, 'desktop-v')) || (github.event_name == 'workflow_dispatch' && inputs.release_tag != '') }} + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + + - name: Verify live desktop release assets + env: + GH_TOKEN: ${{ github.token }} + RELEASE_TAG: ${{ github.event.release.tag_name || inputs.release_tag }} + run: | + gh api "repos/${{ github.repository }}/releases/tags/$RELEASE_TAG" \ + --jq '[.assets[].name]' > "$RUNNER_TEMP/release-assets.json" + node app/scripts/verify-windows-installer.mjs \ + --tag "$RELEASE_TAG" \ + --release-assets "$RUNNER_TEMP/release-assets.json" diff --git a/RELEASING.md b/RELEASING.md index df1c6754..ab14983c 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -2,7 +2,9 @@ This document describes the actual steps a maintainer takes to cut a CLI or macOS menubar release. CLI releases are run by hand with `npm publish`; macOS menubar releases are automated by `.github/workflows/release-menubar.yml` when a `mac-v*` tag is pushed. -The Electron desktop app (`app/`) has no CI automation yet, but it is released manually under `desktop-v` tags: build the artifacts on a macOS host (see `app/DISTRIBUTION.md`) and `gh release upload desktop-v … --clobber` them onto the release. See `app/DISTRIBUTION.md` for how to build and distribute it as an ad-hoc-signed, non-notarized macOS build (plus unsigned Windows and Linux builds). +The Electron desktop app (`app/`) is released manually under `desktop-v` tags. Build macOS and Linux artifacts as described in `app/DISTRIBUTION.md`; the tag also runs the read-only `Build Windows installer` workflow on `windows-latest`. Download its `CodeBurn-Windows-Installer` artifact and upload both the `.exe` and `.exe.blockmap` with the other platform assets. The workflow never publishes release assets. + +Before announcing a desktop release, the release owner must confirm the live GitHub Release contains all four macOS `.dmg`/`.zip` files, the Linux `.AppImage`, and both Windows installer files. Publishing the Release runs the workflow's read-only live-asset verification job. If assets are uploaded after publication, rerun `Build Windows installer` with the `release_tag` input and require that verification job to pass. A failed or missing verification is a release blocker. ## Versioning diff --git a/app/DISTRIBUTION.md b/app/DISTRIBUTION.md index 94255dac..5fedd46d 100644 --- a/app/DISTRIBUTION.md +++ b/app/DISTRIBUTION.md @@ -249,9 +249,10 @@ Pushing a `desktop-v` tag runs the `Build Windows installer` workflow on `windows-latest`. The workflow requires the tag version, root package version, and app package version to agree, and it fails unless the build emits exactly one `CodeBurn-Setup-.exe` and one matching -`.exe.blockmap`. It uploads both files as the `CodeBurn-Windows-Installer` +`.exe.blockmap` at the top level of `app/release/`. It uploads those exact +top-level filenames as the `CodeBurn-Windows-Installer` Actions artifact. The workflow has read-only repository permissions and does -**not** publish release assets automatically. +**not** publish release assets automatically. Artifacts are retained for 30 days. Before publishing the GitHub Release, the release owner must download that workflow artifact and manually upload both Windows files along with the four @@ -262,6 +263,10 @@ missing installer is broken even when another Windows distribution channel is available. The Windows installer uses an explicit `nsis.artifactName` of `CodeBurn-Setup-${version}.${ext}`. +Publishing the Release triggers a read-only live-asset check. If the files are +uploaded afterward, rerun the workflow manually with `release_tag` set to the +existing `desktop-v` tag and require the verification job to pass. + ## Verifying a build ```sh diff --git a/app/scripts/verify-windows-installer.mjs b/app/scripts/verify-windows-installer.mjs index a135eb61..dfd4b18a 100644 --- a/app/scripts/verify-windows-installer.mjs +++ b/app/scripts/verify-windows-installer.mjs @@ -2,6 +2,7 @@ import { readFileSync, readdirSync } from 'node:fs' import { basename, join, resolve } from 'node:path' +import { rootFromModuleUrl } from './windows-installer-paths.mjs' function fail(message) { console.error(`Windows installer manifest invalid: ${message}`) @@ -20,44 +21,78 @@ function packageVersion(path) { } function filesBelow(directory) { - return readdirSync(directory, { recursive: true, withFileTypes: true }) + return readdirSync(directory, { withFileTypes: true }) .filter(entry => entry.isFile()) .map(entry => basename(entry.name)) } -try { - const root = resolve(option('--root', new URL('../..', import.meta.url).pathname)) - const artifacts = resolve(option('--artifacts', join(root, 'app', 'release'))) - const tag = option('--tag', '') - const rootVersion = packageVersion(join(root, 'package.json')) - const appVersion = packageVersion(join(root, 'app', 'package.json')) +function releaseVersion(tag) { + const match = /^desktop-v(.+)$/.exec(tag) + if (!match) throw new Error(`${tag || '(missing tag)'} is not a desktop release tag`) + return match[1] +} - if (rootVersion !== appVersion) { - fail(`root version ${rootVersion} does not match app version ${appVersion}`) +function verifyLiveRelease(tag, assetPath) { + const version = releaseVersion(tag) + const assets = JSON.parse(readFileSync(assetPath, 'utf8')) + if (!Array.isArray(assets) || assets.some(asset => typeof asset !== 'string')) { + throw new Error('release asset manifest must be a JSON array of names') } - - if (tag && tag !== `desktop-v${appVersion}`) { - fail(`${tag} does not match app version ${appVersion}`) - } - - const files = filesBelow(artifacts) - const expectedArtifacts = [ - `CodeBurn-Setup-${appVersion}.exe`, - `CodeBurn-Setup-${appVersion}.exe.blockmap`, + const required = [ + `CodeBurn-${version}-arm64.dmg`, + `CodeBurn-${version}.dmg`, + `CodeBurn-${version}-arm64-mac.zip`, + `CodeBurn-${version}-mac.zip`, + `CodeBurn-${version}.AppImage`, + `CodeBurn-Setup-${version}.exe`, + `CodeBurn-Setup-${version}.exe.blockmap`, ] - for (const expected of expectedArtifacts) { - const count = files.filter(file => file === expected).length - if (count !== 1) fail(`expected exactly one ${expected}, found ${count}`) + for (const expected of required) { + const count = assets.filter(asset => asset === expected).length + if (count === 0) fail(`live release is missing ${expected}`) + if (count > 1) fail(`live release contains ${count} copies of ${expected}`) } + if (!process.exitCode) console.log(`Live desktop release assets verified for ${version}`) +} - const installerArtifacts = files.filter(file => /^CodeBurn-Setup-.*\.exe(?:\.blockmap)?$/.test(file)) - const unexpected = installerArtifacts.filter(file => !expectedArtifacts.includes(file)) - if (unexpected.length > 0) { - fail(`unexpected Windows installer artifacts: ${unexpected.join(', ')}`) - } +try { + const tag = option('--tag', '') + const releaseAssets = option('--release-assets', '') + if (releaseAssets) { + verifyLiveRelease(tag, resolve(releaseAssets)) + } else { + const root = resolve(option('--root', rootFromModuleUrl(import.meta.url))) + const artifacts = resolve(option('--artifacts', join(root, 'app', 'release'))) + const rootVersion = packageVersion(join(root, 'package.json')) + const appVersion = packageVersion(join(root, 'app', 'package.json')) - if (!process.exitCode) { - console.log(`Windows installer manifest verified for ${appVersion}`) + if (rootVersion !== appVersion) { + fail(`root version ${rootVersion} does not match app version ${appVersion}`) + } + + if (tag && tag !== `desktop-v${appVersion}`) { + fail(`${tag} does not match app version ${appVersion}`) + } + + const files = filesBelow(artifacts) + const expectedArtifacts = [ + `CodeBurn-Setup-${appVersion}.exe`, + `CodeBurn-Setup-${appVersion}.exe.blockmap`, + ] + for (const expected of expectedArtifacts) { + const count = files.filter(file => file === expected).length + if (count !== 1) fail(`expected exactly one ${expected}, found ${count}`) + } + + const installerArtifacts = files.filter(file => /^CodeBurn-Setup-.*\.exe(?:\.blockmap)?$/.test(file)) + const unexpected = installerArtifacts.filter(file => !expectedArtifacts.includes(file)) + if (unexpected.length > 0) { + fail(`unexpected Windows installer artifacts: ${unexpected.join(', ')}`) + } + + if (!process.exitCode) { + console.log(`Windows installer manifest verified for ${appVersion}`) + } } } catch (error) { fail(error instanceof Error ? error.message : String(error)) diff --git a/app/scripts/verify-windows-installer.test.ts b/app/scripts/verify-windows-installer.test.ts index b8f488fc..0c63c4f0 100644 --- a/app/scripts/verify-windows-installer.test.ts +++ b/app/scripts/verify-windows-installer.test.ts @@ -3,6 +3,7 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { spawnSync } from 'node:child_process' import { describe, expect, it } from 'vitest' +import { rootFromModuleUrl } from './windows-installer-paths.mjs' const verifier = new URL('./verify-windows-installer.mjs', import.meta.url) @@ -34,7 +35,27 @@ function fixture(options: { return spawnSync(process.execPath, args, { encoding: 'utf8' }) } +function releaseFixture(files: string[]) { + const root = mkdtempSync(join(tmpdir(), 'codeburn-windows-release-')) + const assets = join(root, 'assets.json') + writeFileSync(assets, JSON.stringify(files)) + return spawnSync(process.execPath, [ + verifier.pathname, + '--tag', + 'desktop-v1.2.3', + '--release-assets', + assets, + ], { encoding: 'utf8' }) +} + describe('Windows installer release manifest verifier', () => { + it('converts a Windows module URL into a valid drive-letter repository root', () => { + expect(rootFromModuleUrl( + 'file:///D:/a/codeburn/codeburn/app/scripts/verify-windows-installer.mjs', + true, + )).toBe('D:\\a\\codeburn\\codeburn') + }) + it('accepts one exact installer and blockmap for matching package versions and tag', () => { const result = fixture({ tag: 'desktop-v1.2.3' }) @@ -63,17 +84,16 @@ describe('Windows installer release manifest verifier', () => { expect(result.stderr).toContain('expected exactly one CodeBurn-Setup-1.2.3.exe.blockmap, found 0') }) - it('rejects duplicate expected artifacts in nested output directories', () => { + it('requires installer artifacts at the documented top-level output', () => { const result = fixture({ files: [ - 'CodeBurn-Setup-1.2.3.exe', 'CodeBurn-Setup-1.2.3.exe.blockmap', 'duplicate/CodeBurn-Setup-1.2.3.exe', ], }) expect(result.status).toBe(1) - expect(result.stderr).toContain('expected exactly one CodeBurn-Setup-1.2.3.exe, found 2') + expect(result.stderr).toContain('expected exactly one CodeBurn-Setup-1.2.3.exe, found 0') }) it('rejects stale installer artifacts from another version', () => { @@ -89,4 +109,33 @@ describe('Windows installer release manifest verifier', () => { expect(result.status).toBe(1) expect(result.stderr).toContain('unexpected Windows installer artifacts') }) + + it('accepts a complete live desktop release asset manifest', () => { + const result = releaseFixture([ + 'CodeBurn-1.2.3-arm64.dmg', + 'CodeBurn-1.2.3.dmg', + 'CodeBurn-1.2.3-arm64-mac.zip', + 'CodeBurn-1.2.3-mac.zip', + 'CodeBurn-1.2.3.AppImage', + 'CodeBurn-Setup-1.2.3.exe', + 'CodeBurn-Setup-1.2.3.exe.blockmap', + ]) + + expect(result.status).toBe(0) + expect(result.stdout).toContain('Live desktop release assets verified for 1.2.3') + }) + + it('rejects a live desktop release missing the Windows installer', () => { + const result = releaseFixture([ + 'CodeBurn-1.2.3-arm64.dmg', + 'CodeBurn-1.2.3.dmg', + 'CodeBurn-1.2.3-arm64-mac.zip', + 'CodeBurn-1.2.3-mac.zip', + 'CodeBurn-1.2.3.AppImage', + 'CodeBurn-Setup-1.2.3.exe.blockmap', + ]) + + expect(result.status).toBe(1) + expect(result.stderr).toContain('live release is missing CodeBurn-Setup-1.2.3.exe') + }) }) diff --git a/app/scripts/windows-installer-paths.d.mts b/app/scripts/windows-installer-paths.d.mts new file mode 100644 index 00000000..7074fa43 --- /dev/null +++ b/app/scripts/windows-installer-paths.d.mts @@ -0,0 +1 @@ +export function rootFromModuleUrl(moduleUrl: string | URL, windows?: boolean): string diff --git a/app/scripts/windows-installer-paths.mjs b/app/scripts/windows-installer-paths.mjs new file mode 100644 index 00000000..b2b0eb45 --- /dev/null +++ b/app/scripts/windows-installer-paths.mjs @@ -0,0 +1,8 @@ +import { posix, win32 } from 'node:path' +import { fileURLToPath } from 'node:url' + +export function rootFromModuleUrl(moduleUrl, windows = process.platform === 'win32') { + const path = windows ? win32 : posix + const scriptPath = fileURLToPath(moduleUrl, { windows }) + return path.resolve(path.dirname(scriptPath), '..', '..') +} From 864991fe3fdca280c9dd695d95a3eef478a50c5c Mon Sep 17 00:00:00 2001 From: Aditya Vikram Singh <247195684+avs-io@users.noreply.github.com> Date: Thu, 13 Aug 2026 20:01:00 +0530 Subject: [PATCH 10/85] fix(release): verify all desktop assets --- RELEASING.md | 2 +- app/DISTRIBUTION.md | 6 ++-- app/scripts/verify-windows-installer.mjs | 2 ++ app/scripts/verify-windows-installer.test.ts | 31 ++++++++++++++++++-- 4 files changed, 36 insertions(+), 5 deletions(-) diff --git a/RELEASING.md b/RELEASING.md index ab14983c..83f739a2 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -4,7 +4,7 @@ This document describes the actual steps a maintainer takes to cut a CLI or macO The Electron desktop app (`app/`) is released manually under `desktop-v` tags. Build macOS and Linux artifacts as described in `app/DISTRIBUTION.md`; the tag also runs the read-only `Build Windows installer` workflow on `windows-latest`. Download its `CodeBurn-Windows-Installer` artifact and upload both the `.exe` and `.exe.blockmap` with the other platform assets. The workflow never publishes release assets. -Before announcing a desktop release, the release owner must confirm the live GitHub Release contains all four macOS `.dmg`/`.zip` files, the Linux `.AppImage`, and both Windows installer files. Publishing the Release runs the workflow's read-only live-asset verification job. If assets are uploaded after publication, rerun `Build Windows installer` with the `release_tag` input and require that verification job to pass. A failed or missing verification is a release blocker. +Before announcing a desktop release, the release owner must confirm the live GitHub Release contains all four macOS `.dmg`/`.zip` files, the Linux `.AppImage`, `.deb`, and `.rpm`, and both Windows installer files. Publishing the Release runs the workflow's read-only live-asset verification job. If assets are uploaded after publication, rerun `Build Windows installer` with the `release_tag` input and require that verification job to pass. A failed or missing verification is a release blocker. ## Versioning diff --git a/app/DISTRIBUTION.md b/app/DISTRIBUTION.md index 5fedd46d..431c1900 100644 --- a/app/DISTRIBUTION.md +++ b/app/DISTRIBUTION.md @@ -256,8 +256,10 @@ Actions artifact. The workflow has read-only repository permissions and does Before publishing the GitHub Release, the release owner must download that workflow artifact and manually upload both Windows files along with the four -macOS `.dmg`/`.zip` files and `CodeBurn-.AppImage`. Confirm the live -release contains every required platform asset before announcing it. The +macOS `.dmg`/`.zip` files, `CodeBurn-.AppImage`, +`codeburn-desktop__amd64.deb`, and +`codeburn-desktop-.x86_64.rpm`. Confirm the live release contains +every required platform asset before announcing it. The website's download links **pin that tag** in their URLs, so a release with a missing installer is broken even when another Windows distribution channel is available. The Windows installer uses an explicit `nsis.artifactName` of diff --git a/app/scripts/verify-windows-installer.mjs b/app/scripts/verify-windows-installer.mjs index dfd4b18a..84d499e6 100644 --- a/app/scripts/verify-windows-installer.mjs +++ b/app/scripts/verify-windows-installer.mjs @@ -44,6 +44,8 @@ function verifyLiveRelease(tag, assetPath) { `CodeBurn-${version}-arm64-mac.zip`, `CodeBurn-${version}-mac.zip`, `CodeBurn-${version}.AppImage`, + `codeburn-desktop_${version}_amd64.deb`, + `codeburn-desktop-${version}.x86_64.rpm`, `CodeBurn-Setup-${version}.exe`, `CodeBurn-Setup-${version}.exe.blockmap`, ] diff --git a/app/scripts/verify-windows-installer.test.ts b/app/scripts/verify-windows-installer.test.ts index 0c63c4f0..2dd252f8 100644 --- a/app/scripts/verify-windows-installer.test.ts +++ b/app/scripts/verify-windows-installer.test.ts @@ -2,10 +2,12 @@ import { mkdtempSync, mkdirSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { spawnSync } from 'node:child_process' +import { fileURLToPath } from 'node:url' import { describe, expect, it } from 'vitest' import { rootFromModuleUrl } from './windows-installer-paths.mjs' const verifier = new URL('./verify-windows-installer.mjs', import.meta.url) +const verifierPath = fileURLToPath(verifier) function fixture(options: { appVersion?: string @@ -30,7 +32,7 @@ function fixture(options: { writeFileSync(path, 'fixture') } - const args = [verifier.pathname, '--root', root, '--artifacts', releaseDir] + const args = [verifierPath, '--root', root, '--artifacts', releaseDir] if (options.tag) args.push('--tag', options.tag) return spawnSync(process.execPath, args, { encoding: 'utf8' }) } @@ -40,7 +42,7 @@ function releaseFixture(files: string[]) { const assets = join(root, 'assets.json') writeFileSync(assets, JSON.stringify(files)) return spawnSync(process.execPath, [ - verifier.pathname, + verifierPath, '--tag', 'desktop-v1.2.3', '--release-assets', @@ -117,6 +119,8 @@ describe('Windows installer release manifest verifier', () => { 'CodeBurn-1.2.3-arm64-mac.zip', 'CodeBurn-1.2.3-mac.zip', 'CodeBurn-1.2.3.AppImage', + 'codeburn-desktop_1.2.3_amd64.deb', + 'codeburn-desktop-1.2.3.x86_64.rpm', 'CodeBurn-Setup-1.2.3.exe', 'CodeBurn-Setup-1.2.3.exe.blockmap', ]) @@ -132,10 +136,33 @@ describe('Windows installer release manifest verifier', () => { 'CodeBurn-1.2.3-arm64-mac.zip', 'CodeBurn-1.2.3-mac.zip', 'CodeBurn-1.2.3.AppImage', + 'codeburn-desktop_1.2.3_amd64.deb', + 'codeburn-desktop-1.2.3.x86_64.rpm', 'CodeBurn-Setup-1.2.3.exe.blockmap', ]) expect(result.status).toBe(1) expect(result.stderr).toContain('live release is missing CodeBurn-Setup-1.2.3.exe') }) + + it.each([ + 'codeburn-desktop_1.2.3_amd64.deb', + 'codeburn-desktop-1.2.3.x86_64.rpm', + ])('rejects a live desktop release missing %s', missing => { + const required = [ + 'CodeBurn-1.2.3-arm64.dmg', + 'CodeBurn-1.2.3.dmg', + 'CodeBurn-1.2.3-arm64-mac.zip', + 'CodeBurn-1.2.3-mac.zip', + 'CodeBurn-1.2.3.AppImage', + 'codeburn-desktop_1.2.3_amd64.deb', + 'codeburn-desktop-1.2.3.x86_64.rpm', + 'CodeBurn-Setup-1.2.3.exe', + 'CodeBurn-Setup-1.2.3.exe.blockmap', + ] + const result = releaseFixture(required.filter(asset => asset !== missing)) + + expect(result.status).toBe(1) + expect(result.stderr).toContain(`live release is missing ${missing}`) + }) }) From 8883ec44122b7f9fbbd7f205e13c684973b69a8b Mon Sep 17 00:00:00 2001 From: Aditya Vikram Singh <247195684+avs-io@users.noreply.github.com> Date: Thu, 13 Aug 2026 20:17:28 +0530 Subject: [PATCH 11/85] docs: clarify authoritative Windows installer build --- RELEASING.md | 4 ++-- app/DISTRIBUTION.md | 15 +++++++++------ 2 files changed, 11 insertions(+), 8 deletions(-) diff --git a/RELEASING.md b/RELEASING.md index 83f739a2..be443b3d 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -1,6 +1,6 @@ # Releasing CodeBurn -This document describes the actual steps a maintainer takes to cut a CLI or macOS menubar release. CLI releases are run by hand with `npm publish`; macOS menubar releases are automated by `.github/workflows/release-menubar.yml` when a `mac-v*` tag is pushed. +This document describes the actual steps a maintainer takes to cut CLI, macOS menubar, and Electron desktop releases. CLI releases are run by hand with `npm publish`; macOS menubar releases are automated by `.github/workflows/release-menubar.yml` when a `mac-v*` tag is pushed. The Electron desktop app (`app/`) is released manually under `desktop-v` tags. Build macOS and Linux artifacts as described in `app/DISTRIBUTION.md`; the tag also runs the read-only `Build Windows installer` workflow on `windows-latest`. Download its `CodeBurn-Windows-Installer` artifact and upload both the `.exe` and `.exe.blockmap` with the other platform assets. The workflow never publishes release assets. @@ -199,4 +199,4 @@ For the menubar, tag a new mac-v0.9.9 and let the workflow build and upload it. ## Summary -The CLI release is manual: bump the version, update `CHANGELOG.md`, commit, run `npm publish`, then tag and create a GitHub Release. The macOS menubar release is automated: pushing a `mac-v*` tag fires `.github/workflows/release-menubar.yml`, which builds, signs, zips, and publishes the bundle. The homebrew-core formula is updated automatically or via `brew bump-formula-pr`. +The CLI release is manual: bump the version, update `CHANGELOG.md`, commit, run `npm publish`, then tag and create a GitHub Release. The macOS menubar release is automated: pushing a `mac-v*` tag fires `.github/workflows/release-menubar.yml`, which builds, signs, zips, and publishes the bundle. The Electron desktop release is assembled manually under a `desktop-v*` tag, with the release-authoritative Windows NSIS installer built by the read-only `windows-latest` workflow. The homebrew-core formula is updated automatically or via `brew bump-formula-pr`. diff --git a/app/DISTRIBUTION.md b/app/DISTRIBUTION.md index 431c1900..56609fae 100644 --- a/app/DISTRIBUTION.md +++ b/app/DISTRIBUTION.md @@ -93,8 +93,9 @@ self-contained bundle into `app/build/cli`; see "The bundled CLI" above), then `vite`), then `electron-builder --mac` (whose `afterPack` hook copies the staged CLI into the app). `package:win` and `package:linux` mirror it exactly, swapping the final flag for `electron-builder --win` and `electron-builder ---linux`. All three can run on the same macOS host — electron-builder downloads -the NSIS and AppImage tooling on first use. +--linux`. Developers can run all three locally on the same macOS host — +electron-builder downloads the NSIS and AppImage tooling on first use. Release +Windows installers are built by the `windows-latest` workflow described below. ### Artifacts @@ -154,10 +155,12 @@ separate `electron-builder.yml`): ## Windows and Linux builds -Both are cross-built from the same macOS host used for the mac build — no -Windows or Linux machine, and no `wine`, is required. electron-builder 26 -embeds the Windows executable's icon/version resources natively and downloads -the NSIS and AppImage tooling on first run. +Developers can cross-build both locally from the same macOS host used for the +mac build — no Windows or Linux machine, and no `wine`, is required. +Release-authoritative Windows NSIS installers are instead built by the `Build +Windows installer` workflow on `windows-latest`. electron-builder 26 embeds the +Windows executable's icon/version resources natively and downloads the NSIS and +AppImage tooling on first run. ### Windows (`package:win`) From d841ea59d7a61a7a2180766ccae5b716b1516d61 Mon Sep 17 00:00:00 2001 From: Aditya Vikram Singh <247195684+avs-io@users.noreply.github.com> Date: Thu, 13 Aug 2026 20:44:11 +0530 Subject: [PATCH 12/85] fix: retain discovered durable history --- CHANGELOG.md | 2 ++ docs/providers/copilot.md | 3 ++- src/parser.ts | 7 ++++--- tests/parser.test.ts | 37 +++++++++++++++++++++++++++++++------ 4 files changed, 39 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 477c7f4f..fc0b5e5f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,8 @@ ## Unreleased +- **Old durable sources remain visible while they still exist.** The 90-day session-cache age-out now applies only after a durable source disappears from discovery, so an unchanged older Copilot source keeps reporting usage and reuses its persisted fingerprint instead of being reparsed and immediately discarded. (#987) + ## 0.9.20 - 2026-08-10 ### Added diff --git a/docs/providers/copilot.md b/docs/providers/copilot.md index 478687e2..30e97421 100644 --- a/docs/providers/copilot.md +++ b/docs/providers/copilot.md @@ -39,7 +39,8 @@ instead of trying to dedupe across stores. wrong-schema, OTel is skipped and the JSONL/transcript sources are used as a fallback. - **Durable cache (monotonic totals).** Copilot is marked `durableSources`: OTel-derived cache entries are never evicted when VS Code prunes old spans from the DB, so - month-to-date totals do not drop as the DB rotates. Entries age out after 90 days. + month-to-date totals do not drop as the DB rotates. Orphaned entries age out after + 90 days; sources still present in discovery remain cached regardless of call age. - **Upgrade note.** The first run after upgrading to the OTel version bumps the copilot parse version, which discards the prior copilot cache. Spans already pruned from the DB before the upgrade cannot be recovered, so monotonicity starts from the upgrade point, diff --git a/src/parser.ts b/src/parser.ts index 712295b0..bdeae348 100644 --- a/src/parser.ts +++ b/src/parser.ts @@ -3039,8 +3039,9 @@ async function parseProviderSources( } } - // 90-day age-out for durable providers: remove entries whose newest call is - // older than 90 days so the cache doesn't grow unboundedly over time. + // 90-day age-out for durable providers: prune only orphaned entries whose + // newest call is older than 90 days. Still-discovered sources remain live + // regardless of age and keep their persisted fingerprint for reuse. if (!readOnly && provider.durableSources) { const cutoffMs = Date.now() - 90 * 24 * 60 * 60 * 1000 for (const [cachedPath, cachedFile] of Object.entries(section.files)) { @@ -3049,7 +3050,7 @@ async function parseProviderSources( .map(c => new Date(c.timestamp).getTime()) .filter(ts => !isNaN(ts)) .reduce((max, ts) => Math.max(max, ts), 0) - if (newestTs > 0 && newestTs < cutoffMs) { + if (!allDiscoveredFiles.has(cachedPath) && newestTs > 0 && newestTs < cutoffMs) { delete section.files[cachedPath] ;(diskCache as { _dirty?: boolean })._dirty = true } diff --git a/tests/parser.test.ts b/tests/parser.test.ts index 4bd0c5c2..a7e08ebe 100644 --- a/tests/parser.test.ts +++ b/tests/parser.test.ts @@ -23,6 +23,7 @@ import type { SessionSource, SessionParser, ParsedProviderCall } from '../src/pr let _synthSources: SessionSource[] = [] let _synthDurable = false let _synthYields: ParsedProviderCall[] = [] +let _synthParseCalls = 0 vi.mock('../src/providers/index.js', async (importOriginal) => { type Mod = typeof import('../src/providers/index.js') @@ -52,6 +53,7 @@ vi.mock('../src/providers/index.js', async (importOriginal) => { createSessionParser(_s: SessionSource, _k: Set): SessionParser { return { async *parse(): AsyncGenerator { + _synthParseCalls++ for (const call of _synthYields) { // Respect seenKeys so that when multiple sources share the same // dedup key, only the first source yields it (mirrors real parsers). @@ -190,6 +192,7 @@ beforeEach(async () => { _synthSources = [] _synthDurable = false _synthYields = [] + _synthParseCalls = 0 }) afterEach(async () => { @@ -354,7 +357,7 @@ describe('(d) non-durable provider evicts deleted sources', () => { // (e) 90-day age-out: orphan ≥ 91d old is pruned; ≤ 89d is retained // ═══════════════════════════════════════════════════════════════════════════ describe('(e) 90-day age-out for durable providers', () => { - it('prunes an orphaned cache entry whose newest call is 91 days old', async () => { + it('keeps a discovered 91-day source persisted until discovery removes it', async () => { const synthFile = join(tmpHome, 'synth-age.txt') await writeFile(synthFile, 'placeholder') @@ -374,15 +377,37 @@ describe('(e) 90-day age-out for durable providers', () => { userMessage: 'old', sessionId: 'synth-old', }] - // First parse: cached with 91d-old timestamp → immediately pruned by 90-day check + // First refresh: a still-discovered durable source is live and persisted, + // regardless of the age of its newest call. const proj1 = await parseAllSessions(undefined, 'test-synthetic') - expect(totalOutput(proj1)).toBe(0) // pruned right away + expect.soft(totalOutput(proj1)).toBe(8) + expect.soft(_synthParseCalls).toBe(1) - // Confirm: entry is not in the persistent cache after first parse + const cache1 = await loadCache() + const persisted1 = cache1.providers['test-synthetic']?.files[synthFile] + expect.soft(persisted1).toBeDefined() + + // Second refresh: force the public seam through the persisted cache. The + // unchanged fingerprint must serve the cached parse without invoking the + // provider parser again. clearSessionCache() - _synthSources = [] // no longer discovered const proj2 = await parseAllSessions(undefined, 'test-synthetic') - expect(totalOutput(proj2)).toBe(0) + expect.soft(totalOutput(proj2)).toBe(8) + expect.soft(_synthParseCalls).toBe(1) + + const cache2 = await loadCache() + expect.soft(cache2.providers['test-synthetic']?.files[synthFile]?.fingerprint) + .toEqual(persisted1?.fingerprint) + + // Third refresh: once discovery removes the old source, it becomes an + // orphan and the durable 90-day age-out prunes it from results and disk. + clearSessionCache() + _synthSources = [] + const proj3 = await parseAllSessions(undefined, 'test-synthetic') + expect.soft(totalOutput(proj3)).toBe(0) + + const cache3 = await loadCache() + expect.soft(cache3.providers['test-synthetic']?.files[synthFile]).toBeUndefined() }) it('retains an orphaned cache entry whose newest call is 89 days old', async () => { From 7a3b4af9e9bf3002de45927f51c8c924bc84ad6a Mon Sep 17 00:00:00 2001 From: Aditya Vikram Singh <247195684+avs-io@users.noreply.github.com> Date: Fri, 14 Aug 2026 02:51:54 +0530 Subject: [PATCH 13/85] fix(optimize): isolate sidechains from behavior --- README.md | 10 +-- src/act/model-defaults.ts | 8 +- src/optimize.ts | 32 +++++-- src/parser.ts | 5 +- src/session-population.ts | 34 ++++++++ src/workflow-insights.ts | 9 +- tests/act-model-defaults.test.ts | 7 ++ tests/optimize-fs.test.ts | 72 ++++++++++++++++ tests/optimize-sidechains.test.ts | 106 +++++++++++++++++++++++- tests/parser-large-json-scanner.test.ts | 2 + tests/parser-large-session.test.ts | 3 +- tests/workflow-insights.test.ts | 21 +++++ 12 files changed, 285 insertions(+), 24 deletions(-) create mode 100644 src/session-population.ts diff --git a/README.md b/README.md index 8988a10d..3195ae13 100644 --- a/README.md +++ b/README.md @@ -156,11 +156,11 @@ codeburn optimize --format json # setup health + findings as JSON `codeburn optimize` scans your sessions and your `~/.claude/` setup for waste patterns: -For Claude Code, the optimize session count and session-level findings below -use user-started (main) sessions. Subagent sidechain transcripts are excluded -from that population because their delegated context and delivery behavior are -structurally different; their tokens, calls, and cost still count in all spend -totals and configuration-overhead findings. +For Claude Code, the optimize session count, behavioral findings, coaching, and +model-default recommendations use user-started (main) sessions. Subagent +sidechain transcripts are excluded from that population because their delegated +context and delivery behavior are structurally different; their tokens, calls, +and cost still count in all spend totals and configuration-overhead findings. - Files Claude re-reads across sessions (same content, same context, over and over) - Low Read:Edit ratio (editing without reading leads to retries and wasted tokens) diff --git a/src/act/model-defaults.ts b/src/act/model-defaults.ts index 537f6a0e..6e2e7615 100644 --- a/src/act/model-defaults.ts +++ b/src/act/model-defaults.ts @@ -2,6 +2,7 @@ import { readFile } from 'node:fs/promises' import { join } from 'node:path' import { aggregateModelStats, type ModelStats } from '../compare-stats.js' +import { withUserStartedSessions } from '../session-population.js' import type { ProjectSummary } from '../types.js' import { sha256File } from './backup.js' import type { ActionPlan } from './types.js' @@ -73,15 +74,16 @@ function isDebuggingHeavy(project: ProjectSummary): boolean { } export function recommendModelDefault(project: ProjectSummary, opts: { now?: Date } = {}): ModelDefaultRecommendation | null { + const behavioralProject = withUserStartedSessions(project) const now = opts.now ?? new Date() - const stats = aggregateModelStats([project]) + const stats = aggregateModelStats([behavioralProject]) .filter(s => s.model !== '' && s.editTurns >= MIN_EDIT_TURNS) .sort((a, b) => b.editTurns - a.editTurns || b.editCost - a.editCost) const current = stats[0] if (!current) return null - const providers = providerByModel(project) + const providers = providerByModel(behavioralProject) const provider = providers.get(current.model) if (!provider || !isRecent(current.lastSeen, now)) return null @@ -89,7 +91,7 @@ export function recommendModelDefault(project: ProjectSummary, opts: { now?: Dat const currentCost = costPerEdit(current) if (!Number.isFinite(currentCost) || currentCost <= 0) return null - const debuggingHeavy = isDebuggingHeavy(project) + const debuggingHeavy = isDebuggingHeavy(behavioralProject) const tolerance = debuggingHeavy ? 0 : ONE_SHOT_TOLERANCE const candidates = stats diff --git a/src/optimize.ts b/src/optimize.ts index 254231b2..0d3fbda5 100644 --- a/src/optimize.ts +++ b/src/optimize.ts @@ -13,6 +13,7 @@ import type { DateRange, ProjectSummary, SessionSummary } from './types.js' import { formatCost } from './currency.js' import { formatTokens } from './format.js' import { recommendModelDefault, type ModelDefaultRecommendation } from './act/model-defaults.js' +import { isUserStartedSession, userStartedProjects } from './session-population.js' import { aggregateFileChurn, buildCoachingNotes, scanUserCorrections, medianTimeToFirstEditMs, worstOneShotCategory, type ReworkedFile } from './workflow-insights.js' // ============================================================================ @@ -355,6 +356,7 @@ export type ToolCall = { sessionId: string project: string recent?: boolean + isSidechain?: boolean } export type ApiCallMeta = { @@ -480,6 +482,7 @@ export async function scanJsonlFile( const userMessages: string[] = [] const sessionId = basename(filePath, '.jsonl') let lastVersion = '' + let fileIsSidechain = false const skipThreshold = dateRange ? new Date(dateRange.start.getTime() - 86_400_000).toISOString() @@ -495,6 +498,11 @@ export async function scanJsonlFile( if (!parsed) continue const entry = parsed as Record + if (entry.isSidechain === true && !fileIsSidechain) { + fileIsSidechain = true + for (const call of calls) call.isSidechain = true + } + if (entry.version && typeof entry.version === 'string') lastVersion = entry.version const ts = typeof entry.timestamp === 'string' ? entry.timestamp : undefined @@ -545,6 +553,7 @@ export async function scanJsonlFile( sessionId, project, recent, + isSidechain: fileIsSidechain, }) } } @@ -656,6 +665,7 @@ export function loadMcpConfigs(projectCwds: Iterable, homeDir = homedir( // ============================================================================ export function detectJunkReads(calls: ToolCall[], dateRange?: DateRange): WasteFinding | null { + calls = calls.filter(call => call.isSidechain !== true) const dirCounts = new Map() let totalJunkReads = 0 let recentJunkReads = 0 @@ -706,6 +716,7 @@ export function detectJunkReads(calls: ToolCall[], dateRange?: DateRange): Waste } export function detectDuplicateReads(calls: ToolCall[], dateRange?: DateRange): WasteFinding | null { + calls = calls.filter(call => call.isSidechain !== true) const sessionFiles = new Map>() for (const call of calls) { @@ -1515,6 +1526,7 @@ function findCapabilityReliabilityCandidates(projects: ProjectSummary[]): Capabi } export function detectCapabilityReliability(projects: ProjectSummary[]): WasteFinding | null { + projects = userStartedProjects(projects) const candidates = findCapabilityReliabilityCandidates(projects) if (candidates.length === 0) return null @@ -2198,6 +2210,7 @@ export const EDIT_TOOL_NAMES = new Set(['Edit', 'Write', 'FileEditTool', 'FileWr export const BASH_TOOL_NAMES = new Set(['Bash', 'BashTool', 'PowerShellTool']) export function detectLowReadEditRatio(calls: ToolCall[]): WasteFinding | null { + calls = calls.filter(call => call.isSidechain !== true) let reads = 0 let edits = 0 let recentEdits = 0 @@ -2491,7 +2504,7 @@ function sessionTokenTotal(session: ProjectSummary['sessions'][number]): number // Keep that distinction local to optimize instead of deleting sidechains from // ProjectSummary, which would under-report the work delegated to subagents. function isOptimizeSession(session: ProjectSummary['sessions'][number]): boolean { - return session.isSidechain !== true + return isUserStartedSession(session) } function optimizeSessionCount(projects: ProjectSummary[]): number { @@ -3038,6 +3051,7 @@ export async function scanAndDetect( if (cached && Date.now() - cached.ts < RESULT_CACHE_TTL_MS) return cached.data const costRate = computeInputCostRate(projects) + const behavioralProjects = userStartedProjects(projects) const { toolCalls, projectCwds, apiCalls, userMessages } = await scanSessions(dateRange) const mcpCoverage = aggregateMcpCoverage(projects) @@ -3045,13 +3059,13 @@ export async function scanAndDetect( // Priority order for the per-session findings: low-worth → context-bloat → // outliers. Each later detector excludes sessions already named by an // earlier one so a single session is not listed in three findings. - const lowWorthSessionIds = new Set(findLowWorthCandidates(projects).map(c => c.sessionId)) + const lowWorthSessionIds = new Set(findLowWorthCandidates(behavioralProjects).map(c => c.sessionId)) const contextBloatVisibleIds = new Set( - findContextBloatCandidates(projects) + findContextBloatCandidates(behavioralProjects) .filter(c => !lowWorthSessionIds.has(c.sessionId)) .map(c => c.sessionId), ) - const firstSessionIds = findYoungProjectFirstSessionIds(projects) + const firstSessionIds = findYoungProjectFirstSessionIds(behavioralProjects) const outlierExclusions = new Set([...lowWorthSessionIds, ...contextBloatVisibleIds, ...firstSessionIds]) const syncDetectors: Array<() => WasteFinding | null> = [ () => detectCacheBloat(apiCalls, projects, dateRange), @@ -3065,10 +3079,10 @@ export async function scanAndDetect( () => detectMcpDeferralOff(toolCalls, projects, projectCwds, apiCalls), () => detectMcpAlwaysLoadHygiene(projects, projectCwds, apiCalls, mcpCoverage), () => detectMcpDeferThreshold(projects, projectCwds), - () => detectCapabilityReliability(projects), - () => detectLowWorthSessions(projects), - () => detectContextBloat(projects, lowWorthSessionIds), - () => detectSessionOutliers(projects, outlierExclusions), + () => detectCapabilityReliability(behavioralProjects), + () => detectLowWorthSessions(behavioralProjects), + () => detectContextBloat(behavioralProjects, lowWorthSessionIds), + () => detectSessionOutliers(behavioralProjects, outlierExclusions), () => detectBloatedClaudeMd(projectCwds), () => detectBashBloat(), ] @@ -3088,7 +3102,7 @@ export async function scanAndDetect( const { score, grade } = computeHealth(findings) const modelRecommendations: ModelDefaultRecommendation[] = [] - for (const project of projects) { + for (const project of behavioralProjects) { const rec = recommendModelDefault(project, { now: dateRange?.end }) if (rec) modelRecommendations.push(rec) } diff --git a/src/parser.ts b/src/parser.ts index 47aa9e05..861b05e6 100644 --- a/src/parser.ts +++ b/src/parser.ts @@ -531,7 +531,7 @@ function extractObjectFields( return captured } -const LARGE_ROOT_FIELDS = ['type', 'timestamp', 'sessionId', 'cwd', 'gitBranch', 'attachment', 'message'] as const +const LARGE_ROOT_FIELDS = ['type', 'timestamp', 'sessionId', 'cwd', 'gitBranch', 'attachment', 'message', 'isSidechain'] as const const LARGE_ASSISTANT_MESSAGE_FIELDS = ['model', 'usage', 'id', 'content'] as const function parseLargeJsonl(line: string | Buffer): JournalEntry | null { @@ -545,6 +545,9 @@ function parseLargeJsonl(line: string | Buffer): JournalEntry | null { if (!type) return null const entry: JournalEntry = { type } + if (root['isSidechain']?.kind === 'scalar' && source.slice(root['isSidechain'].start, root['isSidechain'].end) === 'true') { + entry.isSidechain = true + } const timestamp = readJsonString(source, root['timestamp']) const sessionId = readJsonString(source, root['sessionId']) const cwd = readJsonString(source, root['cwd']) diff --git a/src/session-population.ts b/src/session-population.ts new file mode 100644 index 00000000..965f3591 --- /dev/null +++ b/src/session-population.ts @@ -0,0 +1,34 @@ +import type { ProjectSummary, SessionSummary } from './types.js' + +/** + * Sidechains are real usage, but they are not user-started work sessions. + * Behavioral consumers should use this predicate or the projected project + * view below; accounting and configuration consumers should use the originals. + */ +export function isUserStartedSession(session: SessionSummary): boolean { + return session.isSidechain !== true +} + +export function withUserStartedSessions(project: ProjectSummary): ProjectSummary { + const sessions = project.sessions.filter(isUserStartedSession) + if (sessions.length === project.sessions.length) return project + + const totalCostUSD = sessions.reduce((sum, session) => sum + session.totalCostUSD, 0) + return { + ...project, + sessions, + totalCostUSD, + totalSavingsUSD: sessions.reduce((sum, session) => sum + session.totalSavingsUSD, 0), + totalEstimatedCostUSD: project.totalEstimatedCostUSD === undefined + ? undefined + : sessions.reduce((sum, session) => sum + (session.totalEstimatedCostUSD ?? 0), 0), + totalApiCalls: sessions.reduce((sum, session) => sum + session.apiCalls, 0), + // Proxy coverage is project-scoped and applies to every retained session + // whenever it applies to the source project. + totalProxiedCostUSD: project.totalProxiedCostUSD > 0 ? totalCostUSD : 0, + } +} + +export function userStartedProjects(projects: ProjectSummary[]): ProjectSummary[] { + return projects.map(withUserStartedSessions) +} diff --git a/src/workflow-insights.ts b/src/workflow-insights.ts index 3407fc92..f596a789 100644 --- a/src/workflow-insights.ts +++ b/src/workflow-insights.ts @@ -1,6 +1,7 @@ import { homedir } from 'os' import { EDIT_TOOLS } from './classifier.js' +import { userStartedProjects } from './session-population.js' import { CATEGORY_LABELS, type ProjectSummary, type TaskCategory } from './types.js' // User-side mirror of compare-stats.ts scanSelfCorrections (which scans the @@ -44,7 +45,7 @@ export type UserCorrectionStats = { export function scanUserCorrections(projects: ProjectSummary[]): UserCorrectionStats { let corrections = 0 let userTurns = 0 - for (const project of projects) { + for (const project of userStartedProjects(projects)) { for (const session of project.sessions) { // A correction is a FOLLOW-UP by definition: the session's opening // prompt cannot be correcting this assistant, however correction-shaped @@ -95,7 +96,7 @@ export function sessionTimeToFirstEditMs(session: ProjectSummary['sessions'][num export function medianTimeToFirstEditMs(projects: ProjectSummary[]): number | null { const samples: number[] = [] - for (const project of projects) { + for (const project of userStartedProjects(projects)) { for (const session of project.sessions) { const ms = sessionTimeToFirstEditMs(session) if (ms !== null) samples.push(ms) @@ -140,7 +141,7 @@ export function aggregateFileChurn(projects: ProjectSummary[], limit = 15): Rewo type Acc = { path: string; sessions: Set; edits: number } const byPath = new Map() - for (const project of projects) { + for (const project of userStartedProjects(projects)) { for (const session of project.sessions) { for (const turn of session.turns) { for (const call of turn.assistantCalls) { @@ -191,7 +192,7 @@ export const MIN_ONE_SHOT_EDIT_TURNS = 5 /// model-efficiency and the report's category one-shot figures. export function worstOneShotCategory(projects: ProjectSummary[], minEditTurns = MIN_ONE_SHOT_EDIT_TURNS): CategoryOneShot | null { const acc = new Map() - for (const project of projects) { + for (const project of userStartedProjects(projects)) { for (const session of project.sessions) { for (const [cat, d] of Object.entries(session.categoryBreakdown)) { const e = acc.get(cat) ?? { editTurns: 0, oneShotTurns: 0 } diff --git a/tests/act-model-defaults.test.ts b/tests/act-model-defaults.test.ts index 6de3651b..bcdfcdbd 100644 --- a/tests/act-model-defaults.test.ts +++ b/tests/act-model-defaults.test.ts @@ -212,6 +212,13 @@ describe('model default recommendations', () => { expect(recommendModelDefault(project, { now: NOW })).toBeNull() }) + + it('never recommends an actionable default from sidechain-only behavior', () => { + const project = recommendationProject() + project.sessions[0]!.isSidechain = true + + expect(recommendModelDefault(project, { now: NOW })).toBeNull() + }) }) describe('model default apply plan', () => { diff --git a/tests/optimize-fs.test.ts b/tests/optimize-fs.test.ts index 2364e08a..0baf316f 100644 --- a/tests/optimize-fs.test.ts +++ b/tests/optimize-fs.test.ts @@ -20,6 +20,9 @@ import { detectUnusedMcp, detectBashBloat, detectGhostCommands, + detectDuplicateReads, + detectJunkReads, + detectLowReadEditRatio, loadMcpConfigs, scanJsonlFile, scanAndDetect, @@ -294,6 +297,75 @@ describe('scanJsonlFile', () => { expect(result.calls[0].name).toBe('Read') }) + it('marks tool calls from sidechain transcript entries', async () => { + const root = makeFixtureRoot() + const filePath = join(root, 'agent-reviewer.jsonl') + const now = new Date().toISOString() + writeFile(filePath, JSON.stringify({ + type: 'assistant', isSidechain: true, timestamp: now, + message: { content: [{ type: 'tool_use', name: 'Edit', input: { file_path: '/x/foo.ts' } }] }, + })) + + const result = await scanJsonlFile(filePath, 'p1', undefined) + + expect(result.calls).toHaveLength(1) + expect(result.calls[0]!.isSidechain).toBe(true) + }) + + it('classifies every tool call in a transcript when a later large entry marks it as sidechain', async () => { + const root = makeFixtureRoot() + const filePath = join(root, 'agent-reviewer.jsonl') + const now = new Date().toISOString() + const assistant = (name: string, isSidechain?: boolean, padding = '') => JSON.stringify({ + type: 'assistant', + ...(isSidechain === true ? { isSidechain: true } : {}), + timestamp: now, + cwd: '/x', + padding, + message: { + model: 'claude-sonnet-4-5', + usage: { cache_creation_input_tokens: 1 }, + content: [{ type: 'tool_use', name, input: { file_path: `/x/${name}.ts` } }], + }, + }) + writeFile(filePath, [ + JSON.stringify({ type: 'user', timestamp: now, cwd: '/x', message: { content: 'delegate this' } }), + assistant('Read'), + assistant('Edit', true, 'x'.repeat(40_000)), + assistant('Bash'), + ].join('\n')) + + const result = await scanJsonlFile(filePath, 'p1', undefined) + + expect(result.calls.map(call => [call.name, call.isSidechain])).toEqual([ + ['Read', true], + ['Edit', true], + ['Bash', true], + ]) + expect(result.apiCalls).toHaveLength(3) + expect(result.cwds).toHaveLength(4) + expect(result.userMessages).toEqual(['delegate this']) + }) + + it('excludes marked sidechain calls from raw human-behavior detectors', () => { + const editCalls = Array.from({ length: 10 }, (_, index) => ({ + name: 'Edit', input: { file_path: `/src/${index}.ts` }, + sessionId: 'agent-reviewer', project: 'p1', isSidechain: true, + })) + const junkReads = Array.from({ length: 6 }, () => ({ + name: 'Read', input: { file_path: '/app/node_modules/pkg/index.js' }, + sessionId: 'agent-reviewer', project: 'p1', isSidechain: true, + })) + const repeatReads = Array.from({ length: 6 }, () => ({ + name: 'Read', input: { file_path: '/app/src/a.ts' }, + sessionId: 'agent-reviewer', project: 'p1', isSidechain: true, + })) + + expect(detectLowReadEditRatio(editCalls)).toBeNull() + expect(detectJunkReads(junkReads)).toBeNull() + expect(detectDuplicateReads(repeatReads)).toBeNull() + }) + it('skips malformed JSONL lines without crashing', async () => { const root = makeFixtureRoot() const filePath = join(root, 'session.jsonl') diff --git a/tests/optimize-sidechains.test.ts b/tests/optimize-sidechains.test.ts index e3d7d039..c54d9b45 100644 --- a/tests/optimize-sidechains.test.ts +++ b/tests/optimize-sidechains.test.ts @@ -15,6 +15,7 @@ import { buildOptimizeJsonReport, cacheKey, computeInputCostRate, + detectCapabilityReliability, detectSessionOutliers, findContextBloatCandidates, findLowWorthCandidates, @@ -22,7 +23,47 @@ import { scanAndDetect, type OptimizeResult, } from '../src/optimize.js' -import type { ProjectSummary, SessionSummary } from '../src/types.js' +import type { ClassifiedTurn, ProjectSummary, SessionSummary } from '../src/types.js' + +function behavioralTurn( + model: string, + index: number, + options: { retries?: number; costUSD?: number; userMessage?: string } = {}, +): ClassifiedTurn { + const timestamp = new Date(Date.parse('2026-08-01T10:00:00.000Z') + index * 1_000).toISOString() + return { + userMessage: options.userMessage ?? 'edit the code', + timestamp, + sessionId: 'agent-behavior', + category: 'feature', + retries: options.retries ?? 0, + hasEdits: true, + assistantCalls: [{ + provider: 'claude', + model, + usage: { + inputTokens: 100, + outputTokens: 50, + cacheCreationInputTokens: 0, + cacheReadInputTokens: 0, + cachedInputTokens: 0, + reasoningTokens: 0, + webSearchRequests: 0, + }, + costUSD: options.costUSD ?? 1, + tools: ['Edit'], + mcpTools: [], + skills: [], + subagentTypes: [], + hasAgentSpawn: false, + hasPlanMode: false, + speed: 'standard', + timestamp, + bashCommands: [], + deduplicationKey: `${model}-${index}`, + }], + } +} function session( sessionId: string, @@ -78,6 +119,69 @@ function project(sessions: SessionSummary[]): ProjectSummary { } describe('optimize sidechain population (issue #974)', () => { + it('does not recommend a model default from sidechain-only edit behavior', async () => { + const sonnetTurns = Array.from({ length: 35 }, (_, index) => + behavioralTurn('claude-sonnet-4-20250514', index, { + retries: index >= 32 ? 1 : 0, + costUSD: 2, + })) + const haikuTurns = Array.from({ length: 32 }, (_, index) => + behavioralTurn('claude-haiku-3-5-20241022', index + 35, { + retries: index >= 29 ? 1 : 0, + costUSD: 0.9, + })) + const child = sidechain('agent-behavior', { turns: [...sonnetTurns, ...haikuTurns] }) + const projects = [project([child])] + + const result = await scanAndDetect(projects, { + start: new Date('2026-08-01T00:00:00.000Z'), + end: new Date('2026-08-02T00:00:00.000Z'), + }) + + expect(result.modelRecommendations).toEqual([]) + }) + + it('does not emit coaching from sidechain-only correction behavior', () => { + const turns = Array.from({ length: 66 }, (_, index) => + behavioralTurn('claude-sonnet-4-20250514', index, { + userMessage: index === 0 ? 'review the code' : 'you missed the edge case', + })) + const child = sidechain('agent-corrections', { + totalCostUSD: 9, + totalInputTokens: 6_600, + totalOutputTokens: 3_300, + apiCalls: 66, + turns, + }) + const projects = [project([child])] + const result: OptimizeResult = { + findings: [], + costRate: computeInputCostRate(projects), + healthScore: 100, + healthGrade: 'A', + modelRecommendations: [], + } + + const report = buildOptimizeJsonReport(projects, 'fixture', result) + + expect(report.coachingNotes).toEqual([]) + expect(report.summary.periodCostUSD).toBe(9) + expect(report.summary.calls).toBe(66) + }) + + it('does not report retry-heavy capabilities from sidechain-only edits', () => { + const turns = Array.from({ length: 5 }, (_, index) => { + const item = behavioralTurn('claude-sonnet-4-20250514', index, { + retries: index < 3 ? 1 : 0, + }) + item.assistantCalls[0]!.skills = ['reviewer'] + return item + }) + const child = sidechain('agent-capability', { turns }) + + expect(detectCapabilityReliability([project([child])])).toBeNull() + }) + it('keeps sidechain spend out of the low-worth candidate population', () => { const parent = session('parent', { totalCostUSD: 4, diff --git a/tests/parser-large-json-scanner.test.ts b/tests/parser-large-json-scanner.test.ts index af0668b0..00ebe5de 100644 --- a/tests/parser-large-json-scanner.test.ts +++ b/tests/parser-large-json-scanner.test.ts @@ -21,6 +21,7 @@ function largeUserLine(): string { function largeAssistantLine(): string { return JSON.stringify({ type: 'assistant', + isSidechain: true, sessionId: 's1', timestamp: '2026-05-01T00:00:01Z', cwd: '/repo', @@ -55,6 +56,7 @@ describe('large JSONL compact scanner', () => { it('extracts capped tool inputs needed by optimize', () => { const parsed = parseJsonlLine(Buffer.from(largeAssistantLine())) + expect(parsed?.isSidechain).toBe(true) const msg = parsed?.message expect(msg?.role).toBe('assistant') if (msg?.role !== 'assistant') return diff --git a/tests/parser-large-session.test.ts b/tests/parser-large-session.test.ts index 9ef1b8bc..c4d6a07c 100644 --- a/tests/parser-large-session.test.ts +++ b/tests/parser-large-session.test.ts @@ -65,7 +65,7 @@ function assistantLine(sessionId: string, timestamp: string, messageId: string, function messageFirstLargeAssistantLine(sessionId: string, timestamp: string, messageId: string): string { const hugeText = 'y'.repeat(3_000_000) - return `{"parentUuid":"u1","isSidechain":false,"message":{"model":"claude-sonnet-4-5","id":"${messageId}","type":"message","role":"assistant","content":[{"type":"text","text":"${hugeText}"},{"type":"tool_use","id":"tu-large","name":"Edit","input":{"file_path":"/tmp/x","old_string":"a","new_string":"b"}}],"usage":{"input_tokens":1000,"output_tokens":100,"cache_read_input_tokens":5000}},"uuid":"a1","timestamp":"${timestamp}","type":"assistant","sessionId":"${sessionId}","cwd":"/projects/app"}` + return `{"parentUuid":"u1","isSidechain":true,"message":{"model":"claude-sonnet-4-5","id":"${messageId}","type":"message","role":"assistant","content":[{"type":"text","text":"${hugeText}"},{"type":"tool_use","id":"tu-large","name":"Edit","input":{"file_path":"/tmp/x","old_string":"a","new_string":"b"}}],"usage":{"input_tokens":1000,"output_tokens":100,"cache_read_input_tokens":5000}},"uuid":"a1","timestamp":"${timestamp}","type":"assistant","sessionId":"${sessionId}","cwd":"/projects/app"}` } function attachmentLine(sessionId: string, timestamp: string): string { @@ -227,6 +227,7 @@ describe('parseAllSessions with large Claude fixture', () => { expect(projects.length).toBeGreaterThan(0) const sess = projects[0]!.sessions[0]! + expect(sess.isSidechain).toBe(true) expect(sess.apiCalls).toBe(1) expect(sess.totalInputTokens).toBe(1000) expect(sess.totalOutputTokens).toBe(100) diff --git a/tests/workflow-insights.test.ts b/tests/workflow-insights.test.ts index 2e44687e..bb18d759 100644 --- a/tests/workflow-insights.test.ts +++ b/tests/workflow-insights.test.ts @@ -353,4 +353,25 @@ describe('review-findings regressions', () => { // 95 local calls + 5 unpriced cloud calls: coverage must be 0, not 0.95. expect(computePricingCoverage(5, 5)).toBe(0) }) + + it('excludes sidechain-only work from every human workflow signal', () => { + const editCall = call({ + tools: ['Edit'], + timestamp: '2026-06-01T10:06:00Z', + toolSequence: [[{ tool: 'Edit', file: '/home/u/app/src/a.ts' }]], + }) + const sidechain = session('agent-reviewer', [ + turn({ userMessage: 'review the change', timestamp: '2026-06-01T10:00:00Z' }), + turn({ userMessage: 'you missed the edge case', calls: [editCall], timestamp: '2026-06-01T10:06:00Z' }), + turn({ userMessage: 'that is still wrong', timestamp: '2026-06-01T10:07:00Z' }), + turn({ userMessage: 'revert that change', timestamp: '2026-06-01T10:08:00Z' }), + ], { feature: cat(10, 0) } as SessionSummary['categoryBreakdown']) + sidechain.isSidechain = true + const projects = [project([sidechain])] + + expect(scanUserCorrections(projects)).toEqual({ corrections: 0, userTurns: 0, correctionRate: null }) + expect(medianTimeToFirstEditMs(projects)).toBeNull() + expect(aggregateFileChurn(projects)).toEqual([]) + expect(worstOneShotCategory(projects)).toBeNull() + }) }) From 02ddc3c4137e30155f9eb1074bc3fe0d4891ab6b Mon Sep 17 00:00:00 2001 From: Aditya Vikram Singh <247195684+avs-io@users.noreply.github.com> Date: Fri, 14 Aug 2026 03:19:38 +0530 Subject: [PATCH 14/85] Complete unpriced model guidance --- README.md | 1 + src/dashboard.tsx | 6 +- src/main.ts | 41 +++++--- src/models.ts | 7 +- tests/cli-models-unpriced.test.ts | 149 ++++++++++++++++++++++++++++++ tests/dashboard.test.ts | 51 ++++++++++ 6 files changed, 239 insertions(+), 16 deletions(-) create mode 100644 tests/cli-models-unpriced.test.ts diff --git a/README.md b/README.md index 2159e1da..9d1d8176 100644 --- a/README.md +++ b/README.md @@ -487,6 +487,7 @@ Sync sends token counts, costs, models, and projects, never prompts or code. Thi | `codeburn models --format markdown` | Emit a paste-friendly markdown table | | `codeburn models --task feature` | Filter to feature-development work | | `codeburn models --provider claude` | Filter to a single provider | +| `codeburn models --unpriced` | List models counted at $0 because pricing is unknown; JSON preserves exact raw IDs for `model-alias` | Left/right arrow keys switch between Today, 7 Days, 30 Days, Month, 6 Months, and Lifetime (use `--from` / `--to` for an exact historical window). Up/down scroll the full dashboard one line, Page Up/Page Down move one screen, and Home/End jump to either end. The main Daily Activity panel shows at least 10 dates from scrollable full history: use `j`/`k` to move one day, Shift+Space/Space to page, and `g`/`G` to jump to either end. Panels flow in the same order across three columns at maximum width, two at medium width, and one when narrow. In the three-column layout, all panels widen equally by one character for every three additional terminal columns until the dashboard reaches the lesser of 256 characters or the widest renderable source row. Press `q` to quit, `1` `2` `3` `4` `5` `6` as period shortcuts, `c` to open model comparison, or `o` to open optimize. Today, 7 Days, and concrete-day views refresh in place at most once per minute by default (`--refresh 0` to disable) without changing the active view or scroll position. The heavier aggregate views remain static between deliberate navigation changes. The dashboard also shows average cost per session and the five most expensive sessions across all projects. diff --git a/src/dashboard.tsx b/src/dashboard.tsx index b66b41cf..d58dfd53 100644 --- a/src/dashboard.tsx +++ b/src/dashboard.tsx @@ -652,8 +652,10 @@ function ModelBreakdown({ projects, pw, bw }: { projects: ProjectSummary[]; pw: ) })} {unpriced.length > 0 && ( - - {`! ${unpriced.length} model${unpriced.length === 1 ? '' : 's'} unpriced at $0, fix: codeburn model-alias (${unpriced.slice(0, 2).map(u => u.model).join(', ')}${unpriced.length > 2 ? ', ...' : ''})`} + + {pw <= 44 + ? 'codeburn models --unpriced' + : `! ${unpriced.length} unpriced: codeburn models --unpriced`} )} {anyEstimated && ( diff --git a/src/main.ts b/src/main.ts index 7684dac9..9c7d0355 100644 --- a/src/main.ts +++ b/src/main.ts @@ -2,7 +2,7 @@ import { isAbsolute } from 'path' import { Command, Option } from 'commander' import { installMenubarApp } from './menubar-installer.js' import { exportCsv, exportJson, type PeriodExport } from './export.js' -import { findUnpricedModels, loadPricing, setModelAliases, setPriceOverrides, setLocalModelSavings, setProxyPaths, normalizeProxyPath } from './models.js' +import { findUnpricedModels, loadPricing, sanitizeModelForDisplay, setModelAliases, setPriceOverrides, setLocalModelSavings, setProxyPaths, normalizeProxyPath } from './models.js' import { parseAllSessions, filterProjectsByName, filterProjectsByDateRange, clearSessionCache, setInteractiveScanUI } from './parser.js' import { allProviderNames, getAllProviders } from './providers/index.js' import { getProvider } from './providers/index.js' @@ -2100,35 +2100,50 @@ program } const projects = await parseAllSessions(range, opts.provider) + const topN = typeof opts.top === 'number' && Number.isFinite(opts.top) ? opts.top : undefined + const minCost = typeof opts.minCost === 'number' && Number.isFinite(opts.minCost) + ? opts.minCost + : opts.unpriced ? undefined : 0.01 let rows = await aggregateModels(projects, { byTask: !!opts.byTask, byAgent: !!opts.byAgent, taskFilter: opts.task, - topN: typeof opts.top === 'number' && Number.isFinite(opts.top) ? opts.top : undefined, - minCost: typeof opts.minCost === 'number' && Number.isFinite(opts.minCost) ? opts.minCost : (opts.unpriced ? 0 : 0.01), + topN: opts.unpriced ? undefined : topN, + minCost, }) if (opts.unpriced) { - rows = rows.filter(row => findUnpricedModels([{ - model: row.model, - calls: row.calls, - cost: row.costUSD, - tokens: row.totalTokens, - }]).length > 0) + rows = rows + .filter(row => findUnpricedModels([{ + model: row.model, + calls: row.calls, + cost: row.costUSD, + tokens: row.totalTokens, + }]).length > 0) + .sort((a, b) => (b.totalTokens - a.totalTokens) || (b.calls - a.calls) + || (a.provider < b.provider ? -1 : a.provider > b.provider ? 1 : 0) + || (a.model < b.model ? -1 : a.model > b.model ? 1 : 0)) + if (topN !== undefined) rows = rows.slice(0, topN) } const fmt = (opts.format ?? 'table').toLowerCase() if (rows.length === 0 && (fmt === 'table' || fmt === 'markdown')) { - process.stdout.write('No model usage found for the selected period.\n') + process.stdout.write(opts.unpriced + ? 'No unpriced models found for the selected period.\n' + : 'No model usage found for the selected period.\n') return } + const renderRows = opts.unpriced && fmt !== 'json' + ? rows.map(row => ({ ...row, modelDisplayName: sanitizeModelForDisplay(row.model) })) + : rows if (fmt === 'json') { process.stdout.write(renderJson(rows) + '\n') } else if (fmt === 'csv') { - process.stdout.write(renderCsv(rows, { byTask: !!opts.byTask, byAgent: !!opts.byAgent }) + '\n') + process.stdout.write(renderCsv(renderRows, { byTask: !!opts.byTask, byAgent: !!opts.byAgent }) + '\n') } else if (fmt === 'markdown' || fmt === 'md') { - process.stdout.write(renderMarkdown(rows, { byTask: !!opts.byTask, byAgent: !!opts.byAgent, showTotals: opts.totals !== false }) + '\n') + process.stdout.write(renderMarkdown(renderRows, { byTask: !!opts.byTask, byAgent: !!opts.byAgent, showTotals: opts.totals !== false }) + '\n') } else if (fmt === 'table') { - process.stdout.write(renderTable(rows, { byTask: !!opts.byTask, byAgent: !!opts.byAgent, showTotals: opts.totals !== false }) + '\n') + process.stdout.write(renderTable(renderRows, { byTask: !!opts.byTask, byAgent: !!opts.byAgent, showTotals: opts.totals !== false }) + '\n') + if (opts.unpriced) process.stdout.write('Fix: codeburn model-alias "" \n') } else { process.stderr.write(`codeburn: unknown --format "${opts.format}". Choose table, markdown, json, or csv.\n`) process.exit(1) diff --git a/src/models.ts b/src/models.ts index f1d9ad0b..52b5eed7 100644 --- a/src/models.ts +++ b/src/models.ts @@ -795,6 +795,11 @@ function shouldWarnAboutUnknownModel(name: string): boolean { return true } +/** Render provider-supplied model IDs without terminal control characters. */ +export function sanitizeModelForDisplay(model: string): string { + return model.replace(/[\x00-\x1F\x7F-\x9F]/g, '?').slice(0, 200) +} + export function calculateCost( model: string, inputTokens: number, @@ -812,7 +817,7 @@ export function calculateCost( // Strip control characters and cap length: model names come from JSONL // payloads written by external tools, so a hostile or corrupt file // could embed terminal escape sequences here. - const safeName = model.replace(/[\x00-\x1F\x7F-\x9F]/g, '?').slice(0, 200) + const safeName = sanitizeModelForDisplay(model) const aliasHint = `Map it with: codeburn model-alias "${safeName}" , or track local-model savings with: codeburn model-savings "${safeName}" ` process.stderr.write( `codeburn: no pricing data for model "${safeName}" — costs for this model will show $0. ` + diff --git a/tests/cli-models-unpriced.test.ts b/tests/cli-models-unpriced.test.ts new file mode 100644 index 00000000..397e1e0e --- /dev/null +++ b/tests/cli-models-unpriced.test.ts @@ -0,0 +1,149 @@ +import { spawnSync } from 'node:child_process' +import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' + +import { describe, expect, it } from 'vitest' + +function runCli(args: string[], home: string, locale?: string) { + return spawnSync(process.execPath, ['--import', 'tsx', 'src/cli.ts', ...args], { + cwd: process.cwd(), + env: { + ...process.env, + HOME: home, + USERPROFILE: home, + CLAUDE_CONFIG_DIR: join(home, '.claude'), + CODEBURN_CACHE_DIR: join(home, '.cache', 'codeburn'), + TZ: 'UTC', + ...(locale ? { LANG: locale, LC_ALL: locale } : {}), + }, + encoding: 'utf-8', + timeout: 30_000, + }) +} + +function userLine(timestamp: string): string { + return JSON.stringify({ + type: 'user', sessionId: 'unpriced-969', timestamp, cwd: '/tmp/unpriced-969', + message: { role: 'user', content: 'inspect pricing coverage' }, + }) +} + +function assistantLine(model: string, timestamp: string, messageId: string, input: number): string { + return JSON.stringify({ + type: 'assistant', sessionId: 'unpriced-969', timestamp, cwd: '/tmp/unpriced-969', + message: { + id: messageId, type: 'message', role: 'assistant', model, + content: [{ type: 'text', text: 'done' }], + usage: { + input_tokens: input, output_tokens: 100, + cache_read_input_tokens: 0, cache_creation_input_tokens: 0, + }, + }, + }) +} + +async function withFixture(lines: string[], run: (home: string) => void): Promise { + const home = await mkdtemp(join(tmpdir(), 'codeburn-models-unpriced-')) + try { + const projectDir = join(home, '.claude', 'projects', 'unpriced-969') + await mkdir(projectDir, { recursive: true }) + await writeFile(join(projectDir, 'session.jsonl'), `${lines.join('\n')}\n`) + run(home) + } finally { + await rm(home, { recursive: true, force: true }) + } +} + +const range = ['--from', '2026-05-20', '--to', '2026-05-20', '--provider', 'claude'] + +describe('codeburn models --unpriced public CLI', () => { + it('filters before --top and returns the largest unpriced raw ID deterministically', async () => { + await withFixture([ + userLine('2026-05-20T10:00:00.000Z'), + assistantLine('acme/unknown-small-969', '2026-05-20T10:01:00.000Z', 'small', 1_000), + assistantLine('claude-opus-4-6', '2026-05-20T10:02:00.000Z', 'priced', 20_000), + assistantLine('acme/unknown-large-969', '2026-05-20T10:03:00.000Z', 'large', 9_000), + ], home => { + const result = runCli(['models', '--unpriced', '--top', '1', '--format', 'json', ...range], home) + expect(result.status, result.stderr).toBe(0) + expect(JSON.parse(result.stdout)).toEqual([ + expect.objectContaining({ model: 'acme/unknown-large-969', totalTokens: 9_100 }), + ]) + }) + }) + + it('orders tied unpriced rows identically across host locales', async () => { + await withFixture([ + userLine('2026-05-20T10:00:00.000Z'), + assistantLine('acme/z-unknown-969', '2026-05-20T10:01:00.000Z', 'z-model', 1_000), + assistantLine('acme/ä-unknown-969', '2026-05-20T10:02:00.000Z', 'a-umlaut-model', 1_000), + ], home => { + const args = ['models', '--unpriced', '--format', 'json', ...range] + const english = runCli(args, home, 'en_US.UTF-8') + const swedish = runCli(args, home, 'sv_SE.UTF-8') + expect(english.status, english.stderr).toBe(0) + expect(swedish.status, swedish.stderr).toBe(0) + const models = (stdout: string) => (JSON.parse(stdout) as Array<{ model: string }>).map(row => row.model) + expect(models(english.stdout)).toEqual(['acme/z-unknown-969', 'acme/ä-unknown-969']) + expect(models(swedish.stdout)).toEqual(models(english.stdout)) + }) + }) + + it('honors an explicitly supplied finite --min-cost threshold', async () => { + await withFixture([ + userLine('2026-05-20T10:00:00.000Z'), + assistantLine('acme/unknown-zero-969', '2026-05-20T10:01:00.000Z', 'zero', 1_000), + ], home => { + const result = runCli(['models', '--unpriced', '--min-cost', '0.01', '--format', 'json', ...range], home) + expect(result.status, result.stderr).toBe(0) + expect(JSON.parse(result.stdout)).toEqual([]) + }) + }) + + it('lists every unpriced model in table output with an actionable hint', async () => { + await withFixture([ + userLine('2026-05-20T10:00:00.000Z'), + assistantLine('acme/unknown-alpha-969', '2026-05-20T10:01:00.000Z', 'alpha', 1_000), + assistantLine('acme/unknown-beta-969', '2026-05-20T10:02:00.000Z', 'beta', 2_000), + assistantLine('claude-opus-4-6', '2026-05-20T10:03:00.000Z', 'priced', 3_000), + ], home => { + const result = runCli(['models', '--unpriced', ...range], home) + expect(result.status, result.stderr).toBe(0) + expect(result.stdout).toContain('acme/unknown-alpha-969') + expect(result.stdout).toContain('acme/unknown-beta-969') + expect(result.stdout).not.toContain('claude-opus-4-6') + expect(result.stdout).toContain('codeburn model-alias "" ') + }) + }) + + it('reports a clean period explicitly', async () => { + const home = await mkdtemp(join(tmpdir(), 'codeburn-models-unpriced-empty-')) + try { + const result = runCli(['models', '--unpriced', ...range], home) + expect(result.status, result.stderr).toBe(0) + expect(result.stdout).toBe('No unpriced models found for the selected period.\n') + } finally { + await rm(home, { recursive: true, force: true }) + } + }) + + it('sanitizes hostile IDs in human formats while JSON stays lossless', async () => { + const hostile = `acme/alpha\u001b]0;forged\u0007click\u001b[31m\nforged-row-${'x'.repeat(300)}` + await withFixture([ + userLine('2026-05-20T10:00:00.000Z'), + assistantLine(hostile, '2026-05-20T10:01:00.000Z', 'hostile', 1_000), + ], home => { + for (const format of ['table', 'markdown', 'csv']) { + const result = runCli(['models', '--unpriced', '--format', format, ...range], home) + expect(result.status, `${format}: ${result.stderr}`).toBe(0) + expect(result.stdout).not.toMatch(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F-\x9F]/) + expect(result.stdout).not.toContain('\nforged-row-') + expect(result.stdout).not.toContain('x'.repeat(201)) + } + const json = runCli(['models', '--unpriced', '--format', 'json', ...range], home) + expect(json.status, json.stderr).toBe(0) + expect((JSON.parse(json.stdout) as Array<{ model: string }>)[0]?.model).toBe(hostile) + }) + }, 15_000) +}) diff --git a/tests/dashboard.test.ts b/tests/dashboard.test.ts index 9879c8a1..9d96a8df 100644 --- a/tests/dashboard.test.ts +++ b/tests/dashboard.test.ts @@ -395,6 +395,57 @@ describe('interactive terminal rendering', () => { expect(INTERACTIVE_RENDER_OPTIONS).toMatchObject({ alternateScreen: true }) }) + it.each([ + { columns: 42, expected: 'codeburn models --unpriced' }, + { columns: 43, expected: 'codeburn models --unpriced' }, + { columns: 44, expected: 'codeburn models --unpriced' }, + { columns: 80, expected: '! 10 unpriced: codeburn models --unpriced' }, + ])('shows an actionable unpriced-model command in a real $columns-column Ink frame', async ({ columns, expected }) => { + const stdin = new PassThrough() as PassThrough & NodeJS.ReadStream + const stdout = new PassThrough() as PassThrough & NodeJS.WriteStream + stdin.isTTY = true + stdin.setRawMode = () => stdin + stdin.ref = () => stdin + stdin.unref = () => stdin + stdout.isTTY = true + stdout.columns = columns + stdout.rows = 100 + const frames: string[] = [] + stdout.on('data', chunk => frames.push(stripAnsi(String(chunk)))) + + const session = makeSession('unpriced-session', 0) + for (let index = 0; index < 10; index++) { + const model = `vendor-${index}/unknown-model-${index}-969` + session.modelBreakdown[model] = { + calls: 1, + costUSD: 0, + savingsUSD: 0, + tokens: { + inputTokens: 1_000, + outputTokens: 100, + cacheCreationInputTokens: 0, + cacheReadInputTokens: 0, + cachedInputTokens: 0, + reasoningTokens: 0, + webSearchRequests: 0, + }, + } + } + + const app = render(React.createElement(InteractiveDashboard, { + initialProjects: [makeProject('unpriced-project', [session])], + initialPeriod: 'today', + initialProvider: 'all', + refreshSeconds: 0, + windowColumns: columns, + }), { stdin, stdout, debug: true, interactive: true, patchConsole: false }) + onTestFinished(() => app.unmount()) + await app.waitUntilRenderFlush() + + const frame = frames.filter(value => value.trim()).at(-1) ?? '' + expect(frame).toContain(expected) + }) + it('leaves resize frame synchronization entirely to Ink', () => { const source = readFileSync(new URL('../src/dashboard.tsx', import.meta.url), 'utf8') expect(source).not.toContain('process.stdout.write(BSU)') From 0b6141929d1332a3b002eb2196e32b59386bb7a7 Mon Sep 17 00:00:00 2001 From: MiloMMIN <143987911+MiloMMIN@users.noreply.github.com> Date: Sun, 16 Aug 2026 16:03:07 +0800 Subject: [PATCH 15/85] feat: add DeepSeek Harness (dsh) provider Reads DSH sessions from $DSH_HOME/sessions (default ~/.dsh/sessions): one directory per session holding session.jsonl.zstd (or an uncompressed session.jsonl when compression=none). The .zstd log is a concatenation of independent zstd frames (one per appended event batch), which node:zlib's one-shot API cannot decode whole; the provider ports the frame-boundary scan from the official @deepseek-ai/dsh-session-persistence-jsonl package and decompresses frame by frame. zstd needs Node >= 22.15; older runtimes get a notice and DSH data is skipped. Usage follows dsh-token-meter semantics: an assistant/message usage report is the final value for its (turn, step) and replaces the earlier assistant/chunk sample instead of double counting. Models come from the most recent request/header config; reasoning tokens are billed at the output rate. One parsed call per (turn, step), dedup key dsh:::. --- src/providers/dsh.ts | 481 ++++++++++++++++++++++++ src/providers/index.ts | 3 +- src/session-cache.ts | 2 + tests/provider-env-declarations.test.ts | 1 + tests/provider-registry.test.ts | 2 +- tests/providers/dsh.test.ts | 406 ++++++++++++++++++++ 6 files changed, 893 insertions(+), 2 deletions(-) create mode 100644 src/providers/dsh.ts create mode 100644 tests/providers/dsh.test.ts diff --git a/src/providers/dsh.ts b/src/providers/dsh.ts new file mode 100644 index 00000000..7595e106 --- /dev/null +++ b/src/providers/dsh.ts @@ -0,0 +1,481 @@ +import { open, readdir, readFile, stat } from 'fs/promises' +import { join } from 'path' +import { homedir } from 'os' +import zlib from 'zlib' + +import { readSessionFile } from '../fs-utils.js' +import { calculateCost, getShortModelName } from '../models.js' +import { extractBashCommands } from '../bash-utils.js' +import type { ProbeRoot, Provider, SessionSource, SessionParser, ParsedProviderCall } from './types.js' + +// DeepSeek Harness (dsh) stores one session per directory: +// /sessions//session-/session.jsonl.zstd +// (or an uncompressed session.jsonl when compression=none). The .zstd file is +// a concatenation of INDEPENDENT zstd frames — one per appended event batch — +// so node:zlib's one-shot zstdDecompressSync (which decodes a single frame) +// must be driven frame-by-frame behind a structural frame-boundary scan. The +// scan below is a port of scanZstdFrames from the official +// @deepseek-ai/dsh-session-persistence-jsonl package. + +// zstd landed in node:zlib in 22.15 / 23.8; the package floor is lower, so the +// provider degrades with a notice instead of assuming the export exists. +const zstdDecompress = (zlib as { zstdDecompressSync?: (buf: Buffer) => Buffer }).zstdDecompressSync + +const ZSTD_MAGIC = 0xfd2fb528 + +type ZstdFrame = { start: number; end: number } + +// Locate complete frames without decompressing their blocks. An EOF inside the +// final frame (a torn append from a crashed writer) returns its start so the +// caller can ignore the tail; invalid complete structure rejects. +function scanZstdFrames(buffer: Buffer, maxFrames = Number.POSITIVE_INFINITY): { frames: ZstdFrame[]; tornStart?: number } { + const frames: ZstdFrame[] = [] + let offset = 0 + while (offset < buffer.length) { + const start = offset + if (buffer.length - offset < 4) return { frames, tornStart: start } + if (buffer.readUInt32LE(offset) !== ZSTD_MAGIC) { + throw new Error(`invalid zstd frame magic at byte ${offset}`) + } + offset += 4 + if (offset === buffer.length) return { frames, tornStart: start } + const descriptor = buffer.readUInt8(offset)! + offset += 1 + if ((descriptor & 24) !== 0) throw new Error(`reserved frame-header bit at byte ${offset - 1}`) + const contentSizeFlag = descriptor >>> 6 + const singleSegment = (descriptor & 32) !== 0 + const checksum = (descriptor & 4) !== 0 + const dictionaryFlag = descriptor & 3 + const dictionaryBytes = dictionaryFlag === 3 ? 4 : dictionaryFlag + const contentSizeBytes = contentSizeFlag === 0 ? (singleSegment ? 1 : 0) : 1 << contentSizeFlag + const remainingHeaderBytes = (singleSegment ? 0 : 1) + dictionaryBytes + contentSizeBytes + if (buffer.length - offset < remainingHeaderBytes) return { frames, tornStart: start } + offset += remainingHeaderBytes + for (;;) { + if (buffer.length - offset < 3) return { frames, tornStart: start } + const blockHeader = buffer.readUIntLE(offset, 3) + offset += 3 + const lastBlock = (blockHeader & 1) !== 0 + const blockType = (blockHeader >>> 1) & 3 + const blockSize = blockHeader >>> 3 + if (blockType === 3) throw new Error(`reserved block type at byte ${offset - 3}`) + const payloadBytes = blockType === 1 ? 1 : blockSize + if (buffer.length - offset < payloadBytes) return { frames, tornStart: start } + offset += payloadBytes + if (lastBlock) break + } + if (checksum) { + if (buffer.length - offset < 4) return { frames, tornStart: start } + offset += 4 + } + frames.push({ start, end: offset }) + if (frames.length === maxFrames) return { frames } + } + return { frames } +} + +type DshUsage = { + inputTokens?: number + outputTokens?: number + cacheReadTokens?: number + cacheWriteTokens?: number + reasoningTokens?: number +} + +type DshEvent = { + type?: string + seq?: number + time?: number + // Session header fields live at the top level of the first event. + id?: string + cwd?: string + data?: { + turn?: number + step?: number + content?: Array<{ type?: string; text?: string }> + header?: { config?: { model?: string; provider?: string } } + chunk?: { type?: string; usage?: DshUsage } + usage?: DshUsage + name?: string + arguments?: string + } +} + +type StepBucket = { + usage: DshUsage + // A usage report from assistant/message is the final value for its + // (turn, step) and replaces an earlier assistant/chunk sample (the two are + // adjacent reports of the same API call, per dsh-token-meter's usage + // projection). Time follows the winning report. + final: boolean + time?: number + // Model in force when this step's usage was reported (the most recent + // request/header config at that point in the log). + model: string + tools: string[] + skills: string[] + bashCommands: string[] +} + +const toolNameMap: Record = { + bash: 'Bash', + pwsh: 'Bash', + read: 'Read', + write: 'Write', + edit: 'Edit', + str_replace_editor: 'Edit', + glob: 'Glob', + grep: 'Grep', + todo_write: 'TodoWrite', + todo: 'TodoWrite', + web_search: 'WebSearch', + skill: 'Skill', + agent: 'Agent', + ask_user_question: 'AskUserQuestion', +} + +function mapToolName(raw: string): string { + return toolNameMap[raw] ?? raw +} + +function getDshHome(override?: string): string { + // An empty-string DSH_HOME is treated as unset. + return override ?? (process.env['DSH_HOME'] || undefined) ?? join(homedir(), '.dsh') +} + +// DSH writes native-platform paths into the header (backslashes on Windows); +// split on both separators so discovery is correct on any host. +function projectFromCwd(cwd: string, fallback: string): string { + const segments = cwd.split(/[\\/]/).filter(Boolean) + return segments[segments.length - 1] ?? fallback +} + +// Decode every complete frame and yield its JSONL lines. A torn final frame is +// ignored; a structurally corrupt file throws for the caller to report. +function* readZstdLines(buffer: Buffer, maxFrames = Number.POSITIVE_INFINITY): Generator { + const { frames } = scanZstdFrames(buffer, maxFrames) + for (const frame of frames) { + const text = zstdDecompress!(buffer.subarray(frame.start, frame.end)).toString('utf-8') + for (const line of text.split('\n')) { + if (line.trim()) yield line + } + } +} + +async function readEventLines(filePath: string): Promise { + if (filePath.endsWith('.zstd')) { + if (!zstdDecompress) { + process.stderr.write('codeburn: DSH sessions need Node >= 22.15 (zstd support); skipping DSH usage.\n') + return null + } + let buffer: Buffer + try { + buffer = await readFile(filePath) + } catch { + return null + } + try { + return [...readZstdLines(buffer)] + } catch (err) { + process.stderr.write(`codeburn: skipped corrupt DSH session log ${filePath}: ${err instanceof Error ? err.message : err}\n`) + return null + } + } + const content = await readSessionFile(filePath) + if (content === null) return null + return content.split('\n').filter(l => l.trim()) +} + +// Cheap discovery probe: decompress ONLY the first frame (the session header +// batch) instead of the whole log. The header frame is tiny, so a bounded head +// read almost always contains it; fall back to a full read when it does not. +async function readSessionHeader(filePath: string): Promise { + const firstLine = async (): Promise => { + if (filePath.endsWith('.zstd')) { + if (!zstdDecompress) return null + let head: Buffer + try { + const handle = await open(filePath, 'r') + try { + const size = (await handle.stat()).size + const length = Math.min(size, 256 * 1024) + head = Buffer.alloc(length) + await handle.read(head, 0, length, 0) + } finally { + await handle.close() + } + } catch { + return null + } + let { frames } = scanZstdFrames(head, 1) + if (frames.length === 0) { + // Head read did not cover one full frame; take the whole file. + try { + const full = await readFile(filePath) + frames = scanZstdFrames(full, 1).frames + if (frames.length === 0) return null + head = full + } catch { + return null + } + } + const text = zstdDecompress(head.subarray(frames[0]!.start, frames[0]!.end)).toString('utf-8') + return text.split('\n').find(l => l.trim()) ?? null + } + const content = await readSessionFile(filePath) + return content?.split('\n').find(l => l.trim()) ?? null + } + + try { + const line = await firstLine() + if (!line) return null + const event = JSON.parse(line) as DshEvent + return event.type === 'session' ? event : null + } catch { + return null + } +} + +async function discoverSessionsInDir(sessionsDir: string): Promise { + const sources: SessionSource[] = [] + + let projectDirs: string[] + try { + projectDirs = await readdir(sessionsDir) + } catch { + return sources + } + + for (const dirName of projectDirs) { + const dirPath = join(sessionsDir, dirName) + const dirStat = await stat(dirPath).catch(() => null) + if (!dirStat?.isDirectory()) continue + + let sessionDirs: string[] + try { + sessionDirs = await readdir(dirPath) + } catch { + continue + } + + for (const sessionDir of sessionDirs) { + const sessionPath = join(dirPath, sessionDir) + const sessionStat = await stat(sessionPath).catch(() => null) + if (!sessionStat?.isDirectory()) continue + + // Compressed log first; the uncompressed variant exists when + // compression=none. Never both for the same session. + let filePath: string | null = null + for (const name of ['session.jsonl.zstd', 'session.jsonl']) { + const candidate = join(sessionPath, name) + const fileStat = await stat(candidate).catch(() => null) + if (fileStat?.isFile()) { + filePath = candidate + break + } + } + if (!filePath) continue + + const header = await readSessionHeader(filePath) + if (!header) continue + + const cwd = typeof header.cwd === 'string' && header.cwd.trim() ? header.cwd : dirName + sources.push({ path: filePath, project: projectFromCwd(cwd, dirName), provider: 'dsh' }) + } + } + + return sources +} + +function parseToolArguments(raw: string | undefined): Record | null { + if (!raw) return null + try { + const parsed = JSON.parse(raw) as unknown + return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed as Record : null + } catch { + return null + } +} + +function createParser(source: SessionSource, seenKeys: Set): SessionParser { + return { + async *parse(): AsyncGenerator { + const lines = await readEventLines(source.path) + if (!lines) return + + let sessionId = '' + let cwd = '' + let model = 'unknown' + let currentTurn = 0 + const userMessageByTurn = new Map() + const buckets = new Map() + + for (const line of lines) { + let event: DshEvent + try { + event = JSON.parse(line) as DshEvent + } catch { + continue + } + + if (event.type === 'session') { + sessionId = event.id ?? sessionId + cwd = event.cwd ?? cwd + continue + } + + if (event.type === 'turn/start') { + currentTurn = event.data?.turn ?? currentTurn + continue + } + + if (event.type === 'request/header') { + // Emitted at most once per request; steps after the last header + // inherit its config as their model. + const headerModel = event.data?.header?.config?.model + if (typeof headerModel === 'string' && headerModel) model = headerModel + continue + } + + if (event.type === 'user/message') { + const texts = (event.data?.content ?? []) + .filter(c => c.type === 'text' && typeof c.text === 'string' && c.text) + .map(c => c.text!) + if (texts.length > 0) userMessageByTurn.set(currentTurn, texts.join(' ')) + continue + } + + if (event.type === 'tool/call') { + const turn = event.data?.turn ?? currentTurn + const step = event.data?.step ?? 0 + const rawName = event.data?.name + if (!rawName) continue + const key = `${turn}:${step}` + let bucket = buckets.get(key) + if (!bucket) { + bucket = { usage: {}, final: false, model, tools: [], skills: [], bashCommands: [] } + buckets.set(key, bucket) + } + bucket.tools.push(mapToolName(rawName)) + const args = parseToolArguments(event.data?.arguments) + if ((rawName === 'bash' || rawName === 'pwsh') && typeof args?.['command'] === 'string') { + bucket.bashCommands.push(...extractBashCommands(args['command'])) + } + if (rawName === 'skill' && typeof args?.['name'] === 'string') { + bucket.skills.push(args['name']) + } + continue + } + + let usage: DshUsage | undefined + let isFinal = false + if (event.type === 'assistant/chunk' && event.data?.chunk?.type === 'usage') { + usage = event.data.chunk.usage + } else if (event.type === 'assistant/message' && event.data?.usage) { + usage = event.data.usage + isFinal = true + } else { + continue + } + if (!usage) continue + + const turn = event.data?.turn ?? currentTurn + const step = event.data?.step ?? 0 + const key = `${turn}:${step}` + let bucket = buckets.get(key) + if (!bucket) { + bucket = { usage: {}, final: false, model, tools: [], skills: [], bashCommands: [] } + buckets.set(key, bucket) + } + // A final report replaces an earlier sample; a late sample never + // overwrites a final one. The model snapshot follows the winning + // report (a header can change the model mid-turn between steps). + if (isFinal || !bucket.final) { + bucket.usage = usage + bucket.final = isFinal + bucket.time = event.time + bucket.model = model + } + } + + const sortedKeys = [...buckets.keys()].sort((a, b) => { + const [ta, sa] = a.split(':').map(Number) + const [tb, sb] = b.split(':').map(Number) + return ta! - tb! || sa! - sb! + }) + + for (const key of sortedKeys) { + const bucket = buckets.get(key)! + const input = bucket.usage.inputTokens ?? 0 + const output = bucket.usage.outputTokens ?? 0 + const cacheRead = bucket.usage.cacheReadTokens ?? 0 + const cacheWrite = bucket.usage.cacheWriteTokens ?? 0 + const reasoning = bucket.usage.reasoningTokens ?? 0 + if (input + output + cacheRead + cacheWrite + reasoning === 0) continue + + const dedupKey = `dsh:${sessionId || source.path}:${key}` + if (seenKeys.has(dedupKey)) continue + seenKeys.add(dedupKey) + + // DSH bills reasoning tokens at the output rate (same as Gemini). + const costUSD = calculateCost(bucket.model, input, output + reasoning, cacheWrite, cacheRead, 0) + const [turn] = key.split(':').map(Number) + + yield { + provider: 'dsh', + model: bucket.model, + inputTokens: input, + outputTokens: output, + cacheCreationInputTokens: cacheWrite, + cacheReadInputTokens: cacheRead, + cachedInputTokens: cacheRead, + reasoningTokens: reasoning, + webSearchRequests: 0, + costUSD, + tools: [...new Set(bucket.tools)], + bashCommands: bucket.bashCommands, + skills: bucket.skills.length > 0 ? [...new Set(bucket.skills)] : undefined, + timestamp: typeof bucket.time === 'number' ? new Date(bucket.time).toISOString() : '', + speed: 'standard', + deduplicationKey: dedupKey, + userMessage: userMessageByTurn.get(turn!) ?? '', + sessionId: sessionId || source.path, + project: cwd ? projectFromCwd(cwd, source.project) : source.project, + projectPath: cwd || undefined, + } + } + }, + } +} + +export function createDshProvider(dshHomeOverride?: string): Provider { + const dshHome = getDshHome(dshHomeOverride) + const sessionsDir = join(dshHome, 'sessions') + + return { + name: 'dsh', + displayName: 'DeepSeek Harness', + + modelDisplayName(model: string): string { + return getShortModelName(model) + }, + + toolDisplayName(rawTool: string): string { + return mapToolName(rawTool) + }, + + async probeRoots(): Promise { + return [{ path: sessionsDir, label: 'sessions' }] + }, + + async discoverSessions(): Promise { + return discoverSessionsInDir(sessionsDir) + }, + + createSessionParser(source: SessionSource, seenKeys: Set): SessionParser { + return createParser(source, seenKeys) + }, + } +} + +export const dsh = createDshProvider() diff --git a/src/providers/index.ts b/src/providers/index.ts index bf035f06..70af252c 100644 --- a/src/providers/index.ts +++ b/src/providers/index.ts @@ -7,6 +7,7 @@ import { codex } from './codex.js' import { copilot } from './copilot.js' import { droid } from './droid.js' import { devin } from './devin.js' +import { dsh } from './dsh.js' import { gemini } from './gemini.js' import { hermes } from './hermes.js' import { ibmBob } from './ibm-bob.js' @@ -192,7 +193,7 @@ async function loadZed(): Promise { } } -const coreProviders: Provider[] = [claude, cline, clineCli, codewhale, codebuff, codex, copilot, devin, droid, gemini, hermes, ibmBob, kiloCode, kiro, kimi, kimicode, lingtaiTui, mistralVibe, mux, openclaw, openclaude, openDesign, pi, omp, qwen, quickdesk, rooCode, zerostack, grok] +const coreProviders: Provider[] = [claude, cline, clineCli, codewhale, codebuff, codex, copilot, devin, droid, dsh, gemini, hermes, ibmBob, kiloCode, kiro, kimi, kimicode, lingtaiTui, mistralVibe, mux, openclaw, openclaude, openDesign, pi, omp, qwen, quickdesk, rooCode, zerostack, grok] // Lazily loaded providers, listed by name so --provider validation works even // when an optional module fails to load. Must stay in sync with getAllProviders. diff --git a/src/session-cache.ts b/src/session-cache.ts index 2759520f..242551d1 100644 --- a/src/session-cache.ts +++ b/src/session-cache.ts @@ -198,6 +198,7 @@ export const PROVIDER_ENV_VARS: Record = { hermes: ['HERMES_HOME'], 'lingtai-tui': ['LINGTAI_HOME', 'LINGTAI_TUI_HOME', 'LINGTAI_TUI_GLOBAL_DIR'], droid: ['FACTORY_DIR'], + dsh: ['DSH_HOME'], cursor: ['CODEBURN_CURSOR_MAX_BUBBLES'], // XDG_DATA_HOME is stale here (cursor-agent never reads it) but deliberately // kept: removing it would force a re-parse to fix nothing. @@ -266,6 +267,7 @@ export const PROVIDER_PARSE_VERSIONS: Record = { // input/cache rollup; this bump re-parses them so the missing tokens land. copilot: 'cli-shutdown-cost-v1-skills-source-provenance-v1', grok: 'estimated-cost-v1', + dsh: 'v1', hermes: 'reasoning-output-accounting-v1-est-cost', 'lingtai-tui': 'token-ledger-registry-activity-v3', 'ibm-bob': 'worktree-project-grouping-v1', diff --git a/tests/provider-env-declarations.test.ts b/tests/provider-env-declarations.test.ts index 2044571f..9e374057 100644 --- a/tests/provider-env-declarations.test.ts +++ b/tests/provider-env-declarations.test.ts @@ -34,6 +34,7 @@ const FILE_PROVIDERS: Record = { 'codex.ts': ['codex'], 'copilot.ts': ['copilot'], 'droid.ts': ['droid'], + 'dsh.ts': ['dsh'], 'hermes.ts': ['hermes'], 'lingtai-tui.ts': ['lingtai-tui'], // Its only literal read is CODEBURN_CURSOR_MAX_BUBBLES (cursor.ts:692). diff --git a/tests/provider-registry.test.ts b/tests/provider-registry.test.ts index 23b383e4..f2481e5c 100644 --- a/tests/provider-registry.test.ts +++ b/tests/provider-registry.test.ts @@ -14,7 +14,7 @@ function fakeProvider(name: string, discover: Provider['discoverSessions']): Pro describe('provider registry', () => { it('has core providers registered synchronously', () => { - expect(providers.map(p => p.name)).toEqual(['claude', 'cline', 'cline-cli', 'codewhale', 'codebuff', 'codex', 'copilot', 'devin', 'droid', 'gemini', 'hermes', 'ibm-bob', 'kilo-code', 'kiro', 'kimi', 'kimicode', 'lingtai-tui', 'mistral-vibe', 'mux', 'openclaw', 'openclaude', 'open-design', 'pi', 'omp', 'qwen', 'quickdesk', 'roo-code', 'zerostack', 'grok']) + expect(providers.map(p => p.name)).toEqual(['claude', 'cline', 'cline-cli', 'codewhale', 'codebuff', 'codex', 'copilot', 'devin', 'droid', 'dsh', 'gemini', 'hermes', 'ibm-bob', 'kilo-code', 'kiro', 'kimi', 'kimicode', 'lingtai-tui', 'mistral-vibe', 'mux', 'openclaw', 'openclaude', 'open-design', 'pi', 'omp', 'qwen', 'quickdesk', 'roo-code', 'zerostack', 'grok']) }) it('codebuff tool display names normalize codebuff-native names to canonical set', () => { diff --git a/tests/providers/dsh.test.ts b/tests/providers/dsh.test.ts new file mode 100644 index 00000000..b5fbfcb0 --- /dev/null +++ b/tests/providers/dsh.test.ts @@ -0,0 +1,406 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest' +import { mkdtemp, mkdir, writeFile, rm } from 'fs/promises' +import { join } from 'path' +import { homedir, tmpdir } from 'os' +import zlib from 'zlib' + +import { createDshProvider } from '../../src/providers/dsh.js' +import { calculateCost } from '../../src/models.js' +import type { ParsedProviderCall } from '../../src/providers/types.js' + +// DSH session logs are concatenations of INDEPENDENT zstd frames (one per +// appended event batch), so fixtures must compress each batch separately — +// a single zstdCompressSync over the whole file is a different (single-frame) +// format than what DSH writes. + +const zstdCompress = (zlib as { zstdCompressSync?: (buf: Buffer) => Buffer }).zstdCompressSync + +let tmpDir: string + +beforeEach(async () => { + tmpDir = await mkdtemp(join(tmpdir(), 'dsh-test-')) +}) + +afterEach(async () => { + await rm(tmpDir, { recursive: true, force: true }) +}) + +function sessionHeader(opts: { id?: string; cwd?: string } = {}) { + return JSON.stringify({ + type: 'session', + version: 0, + id: opts.id ?? 'session-00000000-0000-0000-0000-000000000001', + createdAt: 1786707336131, + cwd: opts.cwd ?? 'C:\\Users\\test\\myproject', + delegationDepth: 0, + agentPreset: 'cordis', + }) +} + +function requestHeader(model: string, time = 1786707337000) { + return JSON.stringify({ + type: 'request/header', + seq: 10, + time, + data: { header: { config: { provider: 'deepseek-official', model, reasoningEffort: 'max', maxTokens: 256000 } } }, + }) +} + +function turnStart(turn: number, time: number) { + return JSON.stringify({ type: 'turn/start', seq: 1, time, data: { turn } }) +} + +function userMessage(text: string, time: number) { + return JSON.stringify({ + type: 'user/message', + seq: 2, + time, + data: { content: [{ type: 'text', text }], source: { kind: 'user' }, role: 'user', id: 'msg-1' }, + }) +} + +function chunkUsage(turn: number, step: number, usage: Record, time: number) { + return JSON.stringify({ + type: 'assistant/chunk', + seq: 3, + time, + data: { turn, step, chunk: { type: 'usage', usage } }, + }) +} + +function assistantMessage(turn: number, step: number, usage: Record | undefined, time: number) { + return JSON.stringify({ + type: 'assistant/message', + seq: 4, + time, + data: { + turn, + step, + message: { role: 'assistant', content: [{ type: 'text', text: 'done' }] }, + ...(usage ? { usage } : {}), + }, + }) +} + +function toolCall(turn: number, step: number, name: string, args: Record, time: number) { + return JSON.stringify({ + type: 'tool/call', + seq: 5, + time, + data: { turn, step, callId: `call_${name}`, name, arguments: JSON.stringify(args) }, + }) +} + +// Write one frame per batch of lines, matching DSH's append-per-batch layout. +async function writeZstdSession(projectDirName: string, sessionDirName: string, batches: string[][]) { + const dir = join(tmpDir, 'sessions', projectDirName, sessionDirName) + await mkdir(dir, { recursive: true }) + const filePath = join(dir, 'session.jsonl.zstd') + const frames = batches.map(lines => zstdCompress!(Buffer.from(lines.join('\n') + '\n', 'utf-8'))) + await writeFile(filePath, Buffer.concat(frames)) + return filePath +} + +async function writePlainSession(projectDirName: string, sessionDirName: string, lines: string[]) { + const dir = join(tmpDir, 'sessions', projectDirName, sessionDirName) + await mkdir(dir, { recursive: true }) + const filePath = join(dir, 'session.jsonl') + await writeFile(filePath, lines.join('\n') + '\n') + return filePath +} + +async function parseAll(provider: ReturnType, filePath: string): Promise { + const source = { path: filePath, project: 'myproject', provider: 'dsh' } + const calls: ParsedProviderCall[] = [] + for await (const call of provider.createSessionParser(source, new Set()).parse()) { + calls.push(call) + } + return calls +} + +describe('dsh provider - session discovery', () => { + it('discovers a multi-frame zstd session, project from the header cwd', async () => { + await writeZstdSession('--C-Users-test-myproject--', 'session-abc', [ + [sessionHeader({ cwd: 'C:\\Users\\test\\myproject' })], + [assistantMessage(1, 1, { inputTokens: 100, outputTokens: 10 }, 1786707340000)], + ]) + + const provider = createDshProvider(tmpDir) + const sessions = await provider.discoverSessions() + + expect(sessions).toHaveLength(1) + expect(sessions[0]!.provider).toBe('dsh') + expect(sessions[0]!.project).toBe('myproject') + expect(sessions[0]!.path).toContain('session.jsonl.zstd') + }) + + it('discovers the uncompressed session.jsonl variant (compression=none)', async () => { + await writePlainSession('--home-u-proj--', 'session-plain', [ + sessionHeader({ cwd: '/home/u/proj' }), + assistantMessage(1, 1, { inputTokens: 100, outputTokens: 10 }, 1786707340000), + ]) + + const provider = createDshProvider(tmpDir) + const sessions = await provider.discoverSessions() + + expect(sessions).toHaveLength(1) + expect(sessions[0]!.path).toContain('session.jsonl') + expect(sessions[0]!.path).not.toContain('zstd') + expect(sessions[0]!.project).toBe('proj') + }) + + it('returns empty for a non-existent home', async () => { + const provider = createDshProvider('/nonexistent/dsh/home') + expect(await provider.discoverSessions()).toEqual([]) + }) + + it('skips session dirs without a session log', async () => { + await mkdir(join(tmpDir, 'sessions', '--x--', 'session-empty'), { recursive: true }) + const provider = createDshProvider(tmpDir) + expect(await provider.discoverSessions()).toEqual([]) + }) + + it('DSH_HOME relocates discovery; an empty string is treated as unset', async () => { + const home = join(tmpDir, 'dsh-home') + await mkdir(join(home, 'sessions', '--x--', 'session-env'), { recursive: true }) + await writeFile( + join(home, 'sessions', '--x--', 'session-env', 'session.jsonl'), + sessionHeader({ cwd: '/x' }) + '\n', + ) + + const saved = process.env['DSH_HOME'] + process.env['DSH_HOME'] = home + try { + const sessions = await createDshProvider().discoverSessions() + expect(sessions).toHaveLength(1) + } finally { + if (saved === undefined) delete process.env['DSH_HOME'] + else process.env['DSH_HOME'] = saved + } + + process.env['DSH_HOME'] = '' + try { + const roots = await createDshProvider().probeRoots!() + expect(roots).toEqual([{ path: join(homedir(), '.dsh', 'sessions'), label: 'sessions' }]) + } finally { + if (saved === undefined) delete process.env['DSH_HOME'] + else process.env['DSH_HOME'] = saved + } + }) + + it('probeRoots reports the sessions dir under the factory root', async () => { + expect(await createDshProvider('/tmp/dsh-a').probeRoots!()).toEqual([ + { path: join('/tmp/dsh-a', 'sessions'), label: 'sessions' }, + ]) + }) +}) + +describe('dsh provider - parsing', () => { + it('decodes events spread across multiple independent zstd frames', async () => { + const filePath = await writeZstdSession('--C-Users-test-myproject--', 'session-multi', [ + [sessionHeader({ id: 'session-multi', cwd: 'C:\\Users\\test\\myproject' })], + [turnStart(1, 1786707339000), userMessage('build the thing', 1786707339100)], + [chunkUsage(1, 1, { inputTokens: 500, outputTokens: 50 }, 1786707340000)], + [chunkUsage(1, 2, { inputTokens: 800, outputTokens: 80 }, 1786707341000)], + ]) + + const calls = await parseAll(createDshProvider(tmpDir), filePath) + expect(calls).toHaveLength(2) + expect(calls[0]!.inputTokens).toBe(500) + expect(calls[1]!.inputTokens).toBe(800) + }) + + it('a final assistant/message usage REPLACES the earlier chunk sample for the same turn/step', async () => { + const filePath = await writeZstdSession('--C-Users-test-myproject--', 'session-replace', [ + [sessionHeader({ id: 'session-replace' })], + [turnStart(1, 1786707339000)], + // Early sample, then the final report of the SAME API call: the totals + // must come from the final report only, not the sum of both. + [chunkUsage(1, 1, { inputTokens: 14900, outputTokens: 600, reasoningTokens: 500 }, 1786707340000)], + [assistantMessage(1, 1, { inputTokens: 14981, outputTokens: 656, cacheReadTokens: 0, reasoningTokens: 609 }, 1786707340050)], + ]) + + const calls = await parseAll(createDshProvider(tmpDir), filePath) + expect(calls).toHaveLength(1) + expect(calls[0]!.inputTokens).toBe(14981) + expect(calls[0]!.outputTokens).toBe(656) + expect(calls[0]!.reasoningTokens).toBe(609) + expect(calls[0]!.timestamp).toBe(new Date(1786707340050).toISOString()) + }) + + it('a chunk sample arriving after the final report does not overwrite it', async () => { + const filePath = await writeZstdSession('--C-Users-test-myproject--', 'session-late', [ + [sessionHeader({ id: 'session-late' })], + [assistantMessage(1, 1, { inputTokens: 100, outputTokens: 10 }, 1786707340050)], + [chunkUsage(1, 1, { inputTokens: 999, outputTokens: 99 }, 1786707340100)], + ]) + + const calls = await parseAll(createDshProvider(tmpDir), filePath) + expect(calls).toHaveLength(1) + expect(calls[0]!.inputTokens).toBe(100) + }) + + it('falls back to the chunk sample when no assistant/message usage arrives', async () => { + const filePath = await writeZstdSession('--C-Users-test-myproject--', 'session-sample', [ + [sessionHeader({ id: 'session-sample' })], + [chunkUsage(2, 3, { inputTokens: 42, outputTokens: 7 }, 1786707340000)], + ]) + + const calls = await parseAll(createDshProvider(tmpDir), filePath) + expect(calls).toHaveLength(1) + expect(calls[0]!.inputTokens).toBe(42) + expect(calls[0]!.deduplicationKey).toBe('dsh:session-sample:2:3') + }) + + it('steps inherit the model of the most recent request/header', async () => { + const filePath = await writeZstdSession('--C-Users-test-myproject--', 'session-model', [ + [sessionHeader({ id: 'session-model' })], + [requestHeader('deepseek-v4-pro', 1786707337000)], + [assistantMessage(1, 1, { inputTokens: 100, outputTokens: 10 }, 1786707340000)], + [assistantMessage(1, 2, { inputTokens: 200, outputTokens: 20 }, 1786707341000)], + [requestHeader('deepseek-v4-flash', 1786707342000)], + [assistantMessage(2, 1, { inputTokens: 300, outputTokens: 30 }, 1786707343000)], + ]) + + const calls = await parseAll(createDshProvider(tmpDir), filePath) + expect(calls.map(c => c.model)).toEqual(['deepseek-v4-pro', 'deepseek-v4-pro', 'deepseek-v4-flash']) + }) + + it('bills reasoning tokens at the output rate', async () => { + const filePath = await writeZstdSession('--C-Users-test-myproject--', 'session-reason', [ + [sessionHeader({ id: 'session-reason' })], + [requestHeader('deepseek-v4-pro')], + [assistantMessage(1, 1, { inputTokens: 1000, outputTokens: 100, cacheWriteTokens: 50, cacheReadTokens: 500, reasoningTokens: 400 }, 1786707340000)], + ]) + + const calls = await parseAll(createDshProvider(tmpDir), filePath) + expect(calls).toHaveLength(1) + expect(calls[0]!.costUSD).toBeCloseTo(calculateCost('deepseek-v4-pro', 1000, 500, 50, 500, 0), 12) + }) + + it('collects mapped tools, skill names and bash commands from tool/call events', async () => { + const filePath = await writeZstdSession('--C-Users-test-myproject--', 'session-tools', [ + [sessionHeader({ id: 'session-tools' })], + [ + toolCall(1, 1, 'read', { path: '/x/a.ts' }, 1786707339500), + toolCall(1, 1, 'edit', { path: '/x/a.ts' }, 1786707339600), + toolCall(1, 1, 'bash', { command: 'git status && bun test' }, 1786707339700), + toolCall(1, 1, 'skill', { name: 'coding-agent-orchestration' }, 1786707339800), + toolCall(1, 1, 'cordis_run', { id: 'j1' }, 1786707339900), + chunkUsage(1, 1, { inputTokens: 100, outputTokens: 10 }, 1786707340000), + ], + ]) + + const calls = await parseAll(createDshProvider(tmpDir), filePath) + expect(calls).toHaveLength(1) + expect(calls[0]!.tools).toEqual(['Read', 'Edit', 'Bash', 'Skill', 'cordis_run']) + expect(calls[0]!.bashCommands).toEqual(['git', 'bun']) + expect(calls[0]!.skills).toEqual(['coding-agent-orchestration']) + }) + + it('pairs the user message of the turn and carries session id and project', async () => { + const filePath = await writeZstdSession('--C-Users-test-myproject--', 'session-ctx', [ + [sessionHeader({ id: 'session-ctx', cwd: 'C:\\Users\\test\\myproject' })], + [turnStart(1, 1786707339000), userMessage('first question', 1786707339100)], + [chunkUsage(1, 1, { inputTokens: 100, outputTokens: 10 }, 1786707340000)], + [turnStart(2, 1786707350000), userMessage('second question', 1786707350100)], + [chunkUsage(2, 1, { inputTokens: 200, outputTokens: 20 }, 1786707351000)], + ]) + + const calls = await parseAll(createDshProvider(tmpDir), filePath) + expect(calls).toHaveLength(2) + expect(calls[0]!.userMessage).toBe('first question') + expect(calls[1]!.userMessage).toBe('second question') + expect(calls[0]!.sessionId).toBe('session-ctx') + expect(calls[0]!.project).toBe('myproject') + expect(calls[0]!.projectPath).toBe('C:\\Users\\test\\myproject') + }) + + it('parses the uncompressed session.jsonl variant', async () => { + const filePath = await writePlainSession('--home-u-proj--', 'session-plain', [ + sessionHeader({ id: 'session-plain', cwd: '/home/u/proj' }), + turnStart(1, 1786707339000), + userMessage('hello', 1786707339100), + chunkUsage(1, 1, { inputTokens: 123, outputTokens: 45 }, 1786707340000), + ]) + + const calls = await parseAll(createDshProvider(tmpDir), filePath) + expect(calls).toHaveLength(1) + expect(calls[0]!.inputTokens).toBe(123) + expect(calls[0]!.outputTokens).toBe(45) + }) + + it('skips buckets whose usage is all zero', async () => { + const filePath = await writeZstdSession('--C-Users-test-myproject--', 'session-zero', [ + [sessionHeader({ id: 'session-zero' })], + [assistantMessage(1, 1, { inputTokens: 0, outputTokens: 0 }, 1786707340000)], + ]) + + const calls = await parseAll(createDshProvider(tmpDir), filePath) + expect(calls).toHaveLength(0) + }) + + it('ignores a torn final frame appended by a crashed writer', async () => { + const dir = join(tmpDir, 'sessions', '--C-Users-test-myproject--', 'session-torn') + await mkdir(dir, { recursive: true }) + const filePath = join(dir, 'session.jsonl.zstd') + const good = zstdCompress!(Buffer.from( + sessionHeader({ id: 'session-torn' }) + '\n' + + chunkUsage(1, 1, { inputTokens: 100, outputTokens: 10 }, 1786707340000) + '\n', + )) + const torn = zstdCompress!(Buffer.from(chunkUsage(1, 2, { inputTokens: 1, outputTokens: 1 }, 1786707341000) + '\n')) + await writeFile(filePath, Buffer.concat([good, torn.subarray(0, Math.floor(torn.length / 2))])) + + const calls = await parseAll(createDshProvider(tmpDir), filePath) + expect(calls).toHaveLength(1) + expect(calls[0]!.inputTokens).toBe(100) + }) + + it('deduplicates (turn, step) calls seen across multiple parses', async () => { + const filePath = await writeZstdSession('--C-Users-test-myproject--', 'session-dedup', [ + [sessionHeader({ id: 'session-dedup' })], + [chunkUsage(1, 1, { inputTokens: 100, outputTokens: 10 }, 1786707340000)], + ]) + + const provider = createDshProvider(tmpDir) + const source = { path: filePath, project: 'myproject', provider: 'dsh' } + const seenKeys = new Set() + + const firstRun: ParsedProviderCall[] = [] + for await (const call of provider.createSessionParser(source, seenKeys).parse()) firstRun.push(call) + const secondRun: ParsedProviderCall[] = [] + for await (const call of provider.createSessionParser(source, seenKeys).parse()) secondRun.push(call) + + expect(firstRun).toHaveLength(1) + expect(secondRun).toHaveLength(0) + }) + + it('handles a missing session file gracefully', async () => { + const provider = createDshProvider(tmpDir) + const source = { path: join(tmpDir, 'nope', 'session.jsonl.zstd'), project: 'test', provider: 'dsh' } + const calls: ParsedProviderCall[] = [] + for await (const call of provider.createSessionParser(source, new Set()).parse()) calls.push(call) + expect(calls).toHaveLength(0) + }) +}) + +describe('dsh provider - display names', () => { + const provider = createDshProvider('/tmp') + + it('has correct name and displayName', () => { + expect(provider.name).toBe('dsh') + expect(provider.displayName).toBe('DeepSeek Harness') + }) + + it('maps deepseek models to readable names and passes unknown ids through', () => { + expect(provider.modelDisplayName('deepseek-v4-pro')).toBe('DeepSeek v4 Pro') + expect(provider.modelDisplayName('some-future-model')).toBe('some-future-model') + }) + + it('normalizes tool names, keeping unknown names raw', () => { + expect(provider.toolDisplayName('bash')).toBe('Bash') + expect(provider.toolDisplayName('pwsh')).toBe('Bash') + expect(provider.toolDisplayName('todo_write')).toBe('TodoWrite') + expect(provider.toolDisplayName('cordis_run')).toBe('cordis_run') + }) +}) From 4fa16a2293f21eaeabc5656232d87c03285ed783 Mon Sep 17 00:00:00 2001 From: iamtoruk Date: Mon, 17 Aug 2026 10:59:52 -0700 Subject: [PATCH 16/85] fix(dsh): correct usage attribution against the real session format Reviewed src/providers/dsh.ts against deepseek-harness @ 99f6f02f and fixed what the format says but the parser did not: - A forked session's log replays its parent's events verbatim, and codeburn parses the parent's own log as its own session, so every inherited call was billed twice. The header's parentSession + seedLength mark that prefix; events with seq < seedLength are now skipped. - The model now comes from the reporting assistant/message's own message.source, which is what actually served the step. request/header only describes the request DSH was about to make, and is the fallback. - user/message also carries agent-injected context (runtime snapshots, skill bodies) under source.kind 'plugin'; only a typed prompt becomes the preview, and it is bounded to 500 chars like every other provider rather than holding a whole injected system prompt per turn. - A log stamped with a session format version other than 0 is skipped with a notice. The format is pinned at 0 upstream with no compatibility implied, so reading a bumped format under today's assumptions would report confident wrong numbers. - Timestamps go through the seconds-vs-milliseconds guard and fall back to the header createdAt, so a call can no longer carry an empty timestamp and land in the undated cache shard. - The compressed read buffers the whole log to scan its frames, so it now takes the same oversize guard readSessionFile applies to the uncompressed variant. - The zstd-unavailable notice fired once per session log; each distinct notice is now emitted once. - Emit workingDirectory beside projectPath, as codex does. Tests add the upstream examples/acp-agent snapshot as a fixture, covering the real record shapes: packed reasoning-chunks/tool-call-chunks storage rows, a plugin-injected user/message beside the typed one, and both the streamed usage chunk and the final assistant/message usage for the same step. Plus the same snapshot re-encoded as multi-frame zstd with a torn tail (identical output), a forked session, an unsupported format version, and unparsable lines. --- src/providers/dsh.ts | 94 +++++++++++++++-- src/session-cache.ts | 5 +- tests/fixtures/dsh/bash-tool-turn.jsonl | 35 ++++++ tests/providers/dsh.test.ts | 135 +++++++++++++++++++++++- 4 files changed, 258 insertions(+), 11 deletions(-) create mode 100644 tests/fixtures/dsh/bash-tool-turn.jsonl diff --git a/src/providers/dsh.ts b/src/providers/dsh.ts index 7595e106..4fa8bd2d 100644 --- a/src/providers/dsh.ts +++ b/src/providers/dsh.ts @@ -3,7 +3,7 @@ import { join } from 'path' import { homedir } from 'os' import zlib from 'zlib' -import { readSessionFile } from '../fs-utils.js' +import { MAX_SESSION_FILE_BYTES, readSessionFile } from '../fs-utils.js' import { calculateCost, getShortModelName } from '../models.js' import { extractBashCommands } from '../bash-utils.js' import type { ProbeRoot, Provider, SessionSource, SessionParser, ParsedProviderCall } from './types.js' @@ -23,6 +23,23 @@ const zstdDecompress = (zlib as { zstdDecompressSync?: (buf: Buffer) => Buffer } const ZSTD_MAGIC = 0xfd2fb528 +// SESSION_FORMAT_VERSION in @deepseek-ai/dsh-session. DSH refuses to load a log +// stamped with any other version, and a bump means an event's meaning changed, +// so a foreign version is skipped rather than read with today's assumptions. +const SESSION_FORMAT_VERSION = 0 + +const MIN_REASONABLE_TIMESTAMP_MS = 1_000_000_000_000 + +// Discovery walks every session, so a per-file notice would repeat once per +// log; each distinct message is worth saying exactly once. +const noticed = new Set() + +function notice(message: string): void { + if (noticed.has(message)) return + noticed.add(message) + process.stderr.write(message) +} + type ZstdFrame = { start: number; end: number } // Locate complete frames without decompressing their blocks. An EOF inside the @@ -87,13 +104,21 @@ type DshEvent = { seq?: number time?: number // Session header fields live at the top level of the first event. + version?: number id?: string cwd?: string + createdAt?: number + parentSession?: string + seedLength?: number data?: { turn?: number step?: number content?: Array<{ type?: string; text?: string }> + // `user/message` carries the message author: a real prompt is + // `{ kind: 'user' }`, agent-injected context is `{ kind: 'plugin' }`. + source?: { kind?: string } header?: { config?: { model?: string; provider?: string } } + message?: { source?: { kind?: string; model?: string; provider?: string } } chunk?: { type?: string; usage?: DshUsage } usage?: DshUsage name?: string @@ -109,8 +134,9 @@ type StepBucket = { // projection). Time follows the winning report. final: boolean time?: number - // Model in force when this step's usage was reported (the most recent - // request/header config at that point in the log). + // Model that produced this step: the reporting assistant/message's own + // `message.source` when it names one, else the most recent request/header + // config (a header can change the model mid-turn between steps). model: string tools: string[] skills: string[] @@ -138,6 +164,25 @@ function mapToolName(raw: string): string { return toolNameMap[raw] ?? raw } +// A log stamped with a version this parser was not written against is skipped +// whole: a bump means an event's meaning changed, so reading it with today's +// assumptions would report confident wrong numbers. +function isReadableVersion(header: DshEvent, filePath: string): boolean { + if (header.version === SESSION_FORMAT_VERSION) return true + notice(`codeburn: skipping DSH session ${filePath}: unsupported session format version ${String(header.version)}; upgrade codeburn.\n`) + return false +} + +// DSH writes epoch milliseconds; promote a seconds-resolution value and reject +// what stays implausible, matching the guard cline-cli.ts uses on the hazard. +function isoTimestamp(value: number | undefined, fallback: string): string { + if (typeof value !== 'number' || !Number.isFinite(value) || value <= 0) return fallback + const ms = value < MIN_REASONABLE_TIMESTAMP_MS ? value * 1000 : value + const date = new Date(ms) + if (Number.isNaN(date.getTime()) || date.getTime() < MIN_REASONABLE_TIMESTAMP_MS) return fallback + return date.toISOString() +} + function getDshHome(override?: string): string { // An empty-string DSH_HOME is treated as unset. return override ?? (process.env['DSH_HOME'] || undefined) ?? join(homedir(), '.dsh') @@ -165,11 +210,18 @@ function* readZstdLines(buffer: Buffer, maxFrames = Number.POSITIVE_INFINITY): G async function readEventLines(filePath: string): Promise { if (filePath.endsWith('.zstd')) { if (!zstdDecompress) { - process.stderr.write('codeburn: DSH sessions need Node >= 22.15 (zstd support); skipping DSH usage.\n') + notice('codeburn: DSH sessions need Node >= 22.15 (zstd support); skipping DSH usage.\n') return null } let buffer: Buffer try { + // The whole log is buffered to scan its frames, so it needs the same + // oversize guard readSessionFile applies to the uncompressed variant. + const size = (await stat(filePath)).size + if (size > MAX_SESSION_FILE_BYTES) { + notice(`codeburn: skipped oversize DSH session log ${filePath} (${size} bytes)\n`) + return null + } buffer = await readFile(filePath) } catch { return null @@ -177,7 +229,7 @@ async function readEventLines(filePath: string): Promise { try { return [...readZstdLines(buffer)] } catch (err) { - process.stderr.write(`codeburn: skipped corrupt DSH session log ${filePath}: ${err instanceof Error ? err.message : err}\n`) + notice(`codeburn: skipped corrupt DSH session log ${filePath}: ${err instanceof Error ? err.message : err}\n`) return null } } @@ -230,7 +282,8 @@ async function readSessionHeader(filePath: string): Promise { const line = await firstLine() if (!line) return null const event = JSON.parse(line) as DshEvent - return event.type === 'session' ? event : null + if (event.type !== 'session') return null + return isReadableVersion(event, filePath) ? event : null } catch { return null } @@ -307,6 +360,11 @@ function createParser(source: SessionSource, seenKeys: Set): SessionPars let cwd = '' let model = 'unknown' let currentTurn = 0 + let sessionStart = '' + // Events a forked session inherited from its parent. They are a verbatim + // copy of the parent's log, which codeburn parses as its own session, so + // counting them here would bill the same calls twice. + let seedLength = 0 const userMessageByTurn = new Map() const buckets = new Map() @@ -319,11 +377,18 @@ function createParser(source: SessionSource, seenKeys: Set): SessionPars } if (event.type === 'session') { + if (!isReadableVersion(event, source.path)) return sessionId = event.id ?? sessionId cwd = event.cwd ?? cwd + sessionStart = isoTimestamp(event.createdAt, sessionStart) + if (typeof event.parentSession === 'string' && event.parentSession && typeof event.seedLength === 'number') { + seedLength = event.seedLength + } continue } + if (typeof event.seq === 'number' && event.seq < seedLength) continue + if (event.type === 'turn/start') { currentTurn = event.data?.turn ?? currentTurn continue @@ -338,10 +403,15 @@ function createParser(source: SessionSource, seenKeys: Set): SessionPars } if (event.type === 'user/message') { + // Plugin-injected context (runtime snapshots, skill bodies, file-change + // notices) rides the same event type as a typed prompt; only the latter + // is a useful preview. + if (event.data?.source?.kind !== 'user') continue + if (userMessageByTurn.has(currentTurn)) continue const texts = (event.data?.content ?? []) .filter(c => c.type === 'text' && typeof c.text === 'string' && c.text) .map(c => c.text!) - if (texts.length > 0) userMessageByTurn.set(currentTurn, texts.join(' ')) + if (texts.length > 0) userMessageByTurn.set(currentTurn, texts.join(' ').slice(0, 500)) continue } @@ -369,11 +439,16 @@ function createParser(source: SessionSource, seenKeys: Set): SessionPars let usage: DshUsage | undefined let isFinal = false + // The model that actually served the call, when the message records it. + // request/header only describes the request codeburn is about to see. + let reportedModel = model if (event.type === 'assistant/chunk' && event.data?.chunk?.type === 'usage') { usage = event.data.chunk.usage } else if (event.type === 'assistant/message' && event.data?.usage) { usage = event.data.usage isFinal = true + const messageModel = event.data.message?.source?.model + if (typeof messageModel === 'string' && messageModel) reportedModel = messageModel } else { continue } @@ -394,7 +469,7 @@ function createParser(source: SessionSource, seenKeys: Set): SessionPars bucket.usage = usage bucket.final = isFinal bucket.time = event.time - bucket.model = model + bucket.model = reportedModel } } @@ -435,13 +510,14 @@ function createParser(source: SessionSource, seenKeys: Set): SessionPars tools: [...new Set(bucket.tools)], bashCommands: bucket.bashCommands, skills: bucket.skills.length > 0 ? [...new Set(bucket.skills)] : undefined, - timestamp: typeof bucket.time === 'number' ? new Date(bucket.time).toISOString() : '', + timestamp: isoTimestamp(bucket.time, sessionStart), speed: 'standard', deduplicationKey: dedupKey, userMessage: userMessageByTurn.get(turn!) ?? '', sessionId: sessionId || source.path, project: cwd ? projectFromCwd(cwd, source.project) : source.project, projectPath: cwd || undefined, + workingDirectory: cwd || undefined, } } }, diff --git a/src/session-cache.ts b/src/session-cache.ts index 6ded7829..a1305ae1 100644 --- a/src/session-cache.ts +++ b/src/session-cache.ts @@ -285,7 +285,10 @@ export const PROVIDER_PARSE_VERSIONS: Record = { // input/cache rollup; this bump re-parses them so the missing tokens land. copilot: 'cli-shutdown-cost-v1-skills-source-provenance-v1', grok: 'estimated-cost-v1', - dsh: 'v1', + // seed-aware-v1: the parser now skips the parent events a forked session + // replays (double-counted before), takes the model from the reporting + // assistant/message, and keeps agent-injected context out of the preview. + dsh: 'seed-aware-v1', hermes: 'reasoning-output-accounting-v1-est-cost', 'lingtai-tui': 'token-ledger-registry-activity-v3', 'ibm-bob': 'worktree-project-grouping-v1', diff --git a/tests/fixtures/dsh/bash-tool-turn.jsonl b/tests/fixtures/dsh/bash-tool-turn.jsonl new file mode 100644 index 00000000..add85175 --- /dev/null +++ b/tests/fixtures/dsh/bash-tool-turn.jsonl @@ -0,0 +1,35 @@ +{"type":"session","version":0,"id":"e128dda9-ed11-4868-8266-0ef90d03c3d6","createdAt":1783352050748,"cwd":"/home/u/proj","delegationDepth":0} +{"type":"agent/inbox/spliced","seq":0,"time":1785498771334,"data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Use the bash tool to run exactly: echo TERMINAL_OK. Then reply with the single word DONE and stop."}],"source":{"kind":"user"},"role":"user","id":"38694db6-921d-41fd-b1fb-3b0c40caf67c"}]}} +{"type":"turn/start","seq":1,"time":1785821375023,"data":{"turn":1}} +{"type":"agent/inbox/spliced","seq":2,"time":1785821375023,"data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} +{"type":"step/start","seq":3,"time":1783352050755,"data":{"turn":1,"step":1}} +{"type":"user/message","seq":4,"time":1785498771360,"data":{"content":[{"type":"text","text":"Use the bash tool to run exactly: echo TERMINAL_OK. Then reply with the single word DONE and stop."}],"source":{"kind":"user"},"role":"user","id":"38694db6-921d-41fd-b1fb-3b0c40caf67c"},"surfaceOp":"append"} +{"type":"user/message","seq":5,"time":1785730424635,"data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","form":"snapshot","sections":[{"name":"sandbox:policy","text":"Current DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations."},{"name":"approval:policy","text":"Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}]},"role":"user","id":"80474489-442a-4e98-beef-df6cd1e85870"},"surfaceOp":"append"} +{"type":"session/title","seq":6,"time":1785730424635,"data":{"title":"Use the bash tool to","messageSeqs":[4],"source":{"kind":"fallback"}}} +{"type":"request/header","seq":7,"time":1785498771361,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"you are dsh","tools":[]},"reason":"initial"}} +{"type":"request/context","seq":8,"time":1785730424636,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} +{"type":"assistant/chunk","seq":9,"time":1783352051590,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} +{"type":"reasoning-chunks","seq0":10,"time0":1783352051618,"data":{"turn":1,"step":1,"index":0,"dt":[0,1,0,0,26,30,0,0,1,0,27,1,0,0,0,86,1],"texts":["The"," user"," wants"," me"," to"," run"," a"," simple"," bash"," command"," and"," then"," reply"," with"," \"","D","ONE","\"."]}} +{"type":"assistant/chunk","seq":28,"time":1783352051820,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}}} +{"type":"tool-call-chunks","seq0":29,"time0":1783352051820,"data":{"turn":1,"step":1,"index":1,"dt":[0,0,0,28,0,0,0,29,0,0,28,1,0,29,0,0,0,32,0,0,0,0,0,74,0,0,13,0,63,1],"id":"call_00_fkbBRJsUrGKd1pWVc4Gn8233","name":"bash","args":["","{","\"","command","\"",": ","\"","echo"," TER","MIN","AL","_OK","\"",", ","\"","description","\"",": ","\"","E","cho"," TER","MIN","AL","_OK"," to"," verify"," terminal"," access","\"","}"]}} +{"type":"assistant/chunk","seq":60,"time":1783352052118,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to run a simple bash command and then reply with \"DONE\"."}}}} +{"type":"assistant/chunk","seq":61,"time":1783352052118,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_00_fkbBRJsUrGKd1pWVc4Gn8233","name":"bash","arguments":"{\"command\": \"echo TERMINAL_OK\", \"description\": \"Echo TERMINAL_OK to verify terminal access\"}"}}}} +{"type":"assistant/chunk","seq":62,"time":1785498771373,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":2877,"outputTokens":90,"cacheReadTokens":0,"reasoningTokens":18}}}} +{"type":"assistant/chunk","seq":63,"time":1785730424645,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} +{"type":"assistant/message","seq":64,"time":1785730424645,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"reasoning","text":"The user wants me to run a simple bash command and then reply with \"DONE\"."},{"type":"tool-call","id":"call_00_fkbBRJsUrGKd1pWVc4Gn8233","name":"bash","arguments":"{\"command\": \"echo TERMINAL_OK\", \"description\": \"Echo TERMINAL_OK to verify terminal access\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"0a855246-fbf6-4f91-87b4-c6f1889effe7"},"usage":{"inputTokens":2877,"outputTokens":90,"cacheReadTokens":0,"reasoningTokens":18}},"sourceEventSeqs":[9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63],"surfaceOp":"append"} +{"type":"tool/call","seq":65,"time":1785730424646,"data":{"turn":1,"step":1,"callId":"call_00_fkbBRJsUrGKd1pWVc4Gn8233","name":"bash","arguments":"{\"command\": \"echo TERMINAL_OK\", \"description\": \"Echo TERMINAL_OK to verify terminal access\"}"}} +{"type":"tool/result","seq":66,"time":1785730424665,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_00_fkbBRJsUrGKd1pWVc4Gn8233"},"content":[{"type":"tool-result","toolCallId":"call_00_fkbBRJsUrGKd1pWVc4Gn8233","content":[{"type":"text","text":"TERMINAL_OK\n"}],"isError":false}],"role":"user","id":"908ca4f5-efbb-443b-9b07-acbf25edf954"}},"sourceEventSeqs":[65],"surfaceOp":"append"} +{"type":"step/end","seq":67,"time":1785730424665,"data":{"turn":1,"step":1}} +{"type":"step/start","seq":68,"time":1785730424676,"data":{"turn":1,"step":2}} +{"type":"assistant/chunk","seq":69,"time":1783352052780,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} +{"type":"reasoning-chunks","seq0":70,"time0":1783352052809,"data":{"turn":1,"step":2,"index":0,"dt":[29,0,0,29,0,0,0,0,0,28,1,28,1,0,0,32,0,0,0,0,0],"texts":["The"," command"," ran"," successfully"," and"," output"," \"","TER","MIN","AL","_OK","\"."," I"," should"," now"," reply"," with"," just"," \"","D","ONE","\"."]}} +{"type":"assistant/chunk","seq":92,"time":1783352052986,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} +{"type":"assistant/chunk","seq":93,"time":1783352052986,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"D"}}} +{"type":"assistant/chunk","seq":94,"time":1783352052986,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"ONE"}}} +{"type":"assistant/chunk","seq":95,"time":1783352052986,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The command ran successfully and output \"TERMINAL_OK\". I should now reply with just \"DONE\"."}}}} +{"type":"assistant/chunk","seq":96,"time":1783352052986,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"DONE"}}}} +{"type":"assistant/chunk","seq":97,"time":1785498771406,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":168,"outputTokens":25,"cacheReadTokens":2816,"reasoningTokens":22}}}} +{"type":"assistant/chunk","seq":98,"time":1785730424681,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":99,"time":1785730424681,"data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"reasoning","text":"The command ran successfully and output \"TERMINAL_OK\". I should now reply with just \"DONE\"."},{"type":"text","text":"DONE"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"aa705bf0-9b5b-4af3-9763-dbf93c98e4c4"},"usage":{"inputTokens":168,"outputTokens":25,"cacheReadTokens":2816,"reasoningTokens":22}},"sourceEventSeqs":[69,70,71,72,73,74,75,76,77,78,79,80,81,82,83,84,85,86,87,88,89,90,91,92,93,94,95,96,97,98],"surfaceOp":"append"} +{"type":"step/end","seq":100,"time":1785730424682,"data":{"turn":1,"step":2}} +{"type":"turn/end","seq":101,"time":1785730424682,"data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/tests/providers/dsh.test.ts b/tests/providers/dsh.test.ts index b5fbfcb0..05994566 100644 --- a/tests/providers/dsh.test.ts +++ b/tests/providers/dsh.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect, beforeEach, afterEach } from 'vitest' -import { mkdtemp, mkdir, writeFile, rm } from 'fs/promises' +import { mkdtemp, mkdir, writeFile, readFile, rm } from 'fs/promises' import { join } from 'path' import { homedir, tmpdir } from 'os' import zlib from 'zlib' @@ -404,3 +404,136 @@ describe('dsh provider - display names', () => { expect(provider.toolDisplayName('cordis_run')).toBe('cordis_run') }) }) + +describe('dsh provider - real log fidelity', () => { + // The upstream snapshot from deepseek-ai/deepseek-harness + // (examples/acp-agent/tests/snapshots/bash-tool-turn/session.jsonl), with its + // template placeholders filled in. It is the reference for every shape the + // parser reads: packed `reasoning-chunks`/`tool-call-chunks` storage rows, a + // plugin-injected user/message beside the typed one, and both the streamed + // usage chunk and the final assistant/message usage for the same step. + async function writeRealSession(): Promise { + const lines = (await readFile(join(import.meta.dirname, '../fixtures/dsh/bash-tool-turn.jsonl'), 'utf-8')) + .split('\n').filter(l => l.trim()) + return writePlainSession('--home-u-proj--', 'e128dda9-ed11-4868-8266-0ef90d03c3d6', lines) + } + + it('parses the upstream snapshot: two steps, exact usage, model from the message source', async () => { + const calls = await parseAll(createDshProvider(tmpDir), await writeRealSession()) + + expect(calls).toHaveLength(2) + expect(calls.map(c => c.model)).toEqual(['deepseek-v4-flash', 'deepseek-v4-flash']) + expect(calls[0]).toMatchObject({ + inputTokens: 2877, + outputTokens: 90, + cacheReadInputTokens: 0, + reasoningTokens: 18, + sessionId: 'e128dda9-ed11-4868-8266-0ef90d03c3d6', + project: 'proj', + projectPath: '/home/u/proj', + workingDirectory: '/home/u/proj', + }) + expect(calls[1]).toMatchObject({ inputTokens: 168, outputTokens: 25, cacheReadInputTokens: 2816, reasoningTokens: 22 }) + // Reasoning bills at the output rate, so it must not appear as input. + expect(calls[0]!.costUSD).toBe(calculateCost('deepseek-v4-flash', 2877, 90 + 18, 0, 0, 0)) + expect(calls[0]!.costUSD).toBeGreaterThan(0) + }) + + it('takes the typed prompt as the preview, not the plugin-injected context', async () => { + const calls = await parseAll(createDshProvider(tmpDir), await writeRealSession()) + + expect(calls[0]!.userMessage).toBe('Use the bash tool to run exactly: echo TERMINAL_OK. Then reply with the single word DONE and stop.') + expect(calls[0]!.userMessage).not.toContain('Current runtime context') + }) + + it('reads the tool call through the packed chunk rows around it', async () => { + const calls = await parseAll(createDshProvider(tmpDir), await writeRealSession()) + + expect(calls[0]!.tools).toEqual(['Bash']) + expect(calls[0]!.bashCommands).toEqual(['echo']) + }) +}) + +describe('dsh provider - defensive reads', () => { + it('skips a log stamped with an unsupported session format version', async () => { + const filePath = await writePlainSession('--home-u-proj--', 'session-future', [ + JSON.stringify({ type: 'session', version: 1, id: 'session-future', createdAt: 1786707336131, cwd: '/home/u/proj', delegationDepth: 0 }), + chunkUsage(1, 1, { inputTokens: 100, outputTokens: 10 }, 1786707340000), + ]) + + expect(await createDshProvider(tmpDir).discoverSessions()).toEqual([]) + expect(await parseAll(createDshProvider(tmpDir), filePath)).toEqual([]) + }) + + it('does not bill a forked session for the events it inherited from its parent', async () => { + const filePath = await writePlainSession('--home-u-proj--', 'session-fork', [ + JSON.stringify({ + type: 'session', version: 0, id: 'session-fork', createdAt: 1786707336131, + cwd: '/home/u/proj', parentSession: 'session-parent', seedLength: 3, delegationDepth: 0, + }), + // seq 0..2 are a verbatim copy of the parent's log, which codeburn parses + // as its own session; only seq >= 3 is this session's own work. + JSON.stringify({ type: 'turn/start', seq: 0, time: 1786707337000, data: { turn: 1 } }), + JSON.stringify({ type: 'assistant/message', seq: 1, time: 1786707337100, data: { turn: 1, step: 1, message: { role: 'assistant', content: [] }, usage: { inputTokens: 9999, outputTokens: 999 } } }), + JSON.stringify({ type: 'session/end-seed', seq: 2, time: 1786707337200, data: {} }), + JSON.stringify({ type: 'turn/start', seq: 3, time: 1786707338000, data: { turn: 2 } }), + JSON.stringify({ type: 'assistant/message', seq: 4, time: 1786707338100, data: { turn: 2, step: 1, message: { role: 'assistant', content: [] }, usage: { inputTokens: 100, outputTokens: 10 } } }), + ]) + + const calls = await parseAll(createDshProvider(tmpDir), filePath) + expect(calls).toHaveLength(1) + expect(calls[0]!.inputTokens).toBe(100) + }) + + it('ignores unknown event types, packed chunk rows, and unparsable lines', async () => { + const filePath = await writePlainSession('--home-u-proj--', 'session-noise', [ + sessionHeader({ id: 'session-noise', cwd: '/home/u/proj' }), + JSON.stringify({ type: 'agent/inbox/spliced', seq: 0, time: 1786707337000, data: { target: 'next-turn' } }), + JSON.stringify({ type: 'reasoning-chunks', seq0: 1, time0: 1786707337100, data: { turn: 1, step: 1, index: 0, dt: [0], texts: ['a', 'b'] } }), + '{ not json at all', + ' ', + chunkUsage(1, 1, { inputTokens: 100, outputTokens: 10 }, 1786707340000), + ]) + + const calls = await parseAll(createDshProvider(tmpDir), filePath) + expect(calls).toHaveLength(1) + expect(calls[0]!.inputTokens).toBe(100) + }) + + it('falls back to the header createdAt when a usage event carries no usable time', async () => { + const filePath = await writePlainSession('--home-u-proj--', 'session-notime', [ + JSON.stringify({ type: 'session', version: 0, id: 'session-notime', createdAt: 1786707336131, cwd: '/home/u/proj', delegationDepth: 0 }), + JSON.stringify({ type: 'assistant/message', seq: 1, data: { turn: 1, step: 1, message: { role: 'assistant', content: [] }, usage: { inputTokens: 100, outputTokens: 10 } } }), + ]) + + const calls = await parseAll(createDshProvider(tmpDir), filePath) + expect(calls).toHaveLength(1) + expect(calls[0]!.timestamp).toBe(new Date(1786707336131).toISOString()) + }) +}) + +describe('dsh provider - real log, real container', () => { + it('reads the upstream snapshot out of multi-frame zstd with a torn tail identically to plain jsonl', async () => { + const lines = (await readFile(join(import.meta.dirname, '../fixtures/dsh/bash-tool-turn.jsonl'), 'utf-8')) + .split('\n').filter(l => l.trim()) + const plain = await parseAll( + createDshProvider(tmpDir), + await writePlainSession('--home-u-proj--', 'plain', lines), + ) + + // Header batch, then three append batches — the layout DSH writes. + const dir = join(tmpDir, 'sessions', '--home-u-proj--', 'framed') + await mkdir(dir, { recursive: true }) + const filePath = join(dir, 'session.jsonl.zstd') + const frames = [[lines[0]!], lines.slice(1, 10), lines.slice(10, 25), lines.slice(25)] + .map(batch => zstdCompress!(Buffer.from(batch.join('\n') + '\n', 'utf-8'))) + // A crashed writer's half-written final batch, carrying usage that must not count. + const torn = zstdCompress!(Buffer.from(assistantMessage(9, 9, { inputTokens: 123456, outputTokens: 1 }, 1785730424999) + '\n', 'utf-8')) + await writeFile(filePath, Buffer.concat([...frames, torn.subarray(0, Math.floor(torn.length / 2))])) + + const framed = await parseAll(createDshProvider(tmpDir), filePath) + expect(framed.map(c => [c.inputTokens, c.outputTokens, c.reasoningTokens, c.model])) + .toEqual(plain.map(c => [c.inputTokens, c.outputTokens, c.reasoningTokens, c.model])) + expect(framed).toHaveLength(2) + }) +}) From d9a9486b6d3d2810fca1f9d9c2e4ee9cea6c5282 Mon Sep 17 00:00:00 2001 From: iamtoruk Date: Mon, 17 Aug 2026 10:59:58 -0700 Subject: [PATCH 17/85] docs(dsh): finish the provider registration checklist docs/providers/NEW_PROVIDER.md items the PR had not reached yet, plus the two surfaces that are functional rather than cosmetic: - docs/providers/dsh.md and its row in the provider index, documenting the storage layout, the JSONL-backend-only scope (the opt-in SQLite persistence backend is not read), and that DSH is a developer preview whose format version 0 implies no compatibility. - CHANGELOG entry under Unreleased. - README provider count 40 -> 41 and a data-locations row. - app/package.json: $HOME/.dsh in the snap personal-files allowlist, without which the Linux snap build cannot read DSH sessions at all. - UsageDataChangeGuard: the DSH sessions root, without which the menubar never notices a new session and does not refresh. - Bumps the dsh parse version, since the parser's attribution changed. --- CHANGELOG.md | 3 + README.md | 11 +-- app/package.json | 1 + docs/architecture.md | 2 +- docs/providers/README.md | 1 + docs/providers/dsh.md | 71 +++++++++++++++++++ .../Data/UsageDataChangeGuard.swift | 2 + package.json | 1 + 8 files changed, 86 insertions(+), 6 deletions(-) create mode 100644 docs/providers/dsh.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 9242c070..9ee86d04 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,9 @@ ## Unreleased +### Added (CLI) +- **DeepSeek Harness (`dsh`) is now a supported provider.** Reads DeepSeek's open-source agent harness from `~/.dsh/sessions` (`DSH_HOME` relocates the root), both the default zstd logs and the uncompressed `session.jsonl` variant. A `.zstd` log is a concatenation of independent zstd frames, one per write batch, so it is decoded frame by frame behind a structural frame scan and a torn trailing frame from a crashed writer is ignored rather than failing the file (needs Node 22.15+ for `zlib` zstd; below that dsh is skipped with a notice instead of counted as $0). One call per `(turn, step)`, with the step's final `assistant/message` usage superseding the streamed `assistant/chunk` sample of the same call rather than adding to it, the model taken from the message that served the step, and reasoning tokens billed at the output rate. DSH records tokens but no cost, so calls are priced from the shared tables. The events a forked session replays from its parent are skipped, since codeburn already counts the parent's own log. The session format is pinned at version 0 upstream with no compatibility implied, so a log stamped with any other version is skipped with a notice instead of read under today's assumptions. + ### Changed - **Codex rollouts parse across worker threads too, and the workload gate now takes bytes or files.** Codex is the bigger half of a real cold parse — a 4 GB rollout corpus against 1.8 GB of Claude sessions — and it was still decoding one file at a time. A whole-file rollout decode now runs on the same pool, against an empty dedup set, and comes back with the calls, the dedup keys it claimed, and the codex-cache entry it would have written; the parent installs all three in the serial loop's order, so `codex-results.json` and every payload come out byte-identical to a serial run. Cross-file state stays where it was: a forked rollout replaying its parent's token_count history collides on the parent's keys and is re-parsed in-process, and no worker ever touches the cache module's per-directory state. Files the Codex cache can serve exactly or resume into from a byte offset never reach a worker — they read a few KB and the resume state belongs to the parent. The workload gate is now pending BYTES alone (200 MB), not file count: 250 pending files holding under a megabyte between them spawned threads that made the run ~5% slower, while a few hundred huge rollouts were being turned away. The count takes `max(pendingFiles / 50, pendingBytes / 200 MB)`, and the per-thread memory budget is derived per parse as `clamp(256 MB, 2 × average pending file + 128 MB, 1 GB)` rather than a flat 256 MB — a 260 MB rollout peaks near 430 MB in its worker and scales linearly with the pool, so the flat figure over-subscribed exactly the workload this adds. The decision is per provider, and at most one pool is alive at a time. - **A large cold Claude parse now runs across worker threads.** Reading, decoding and line-parsing a session JSONL is per-file work that never touches anything shared, so it moves onto `worker_threads`; each worker ships its parsed turns back as a JSON string and the parent installs them in the exact order the serial loop would. Everything with cross-file state — the streaming-message dedup, canonical project paths, spawn links, PR correlation, progress saves — stays on the main thread, and a file whose message ids were already claimed by an earlier file (or whose worker failed) is simply re-parsed in-process, so the session cache and every payload are identical either way. On a 6 GB corpus a cold `status` drops from 27.5s to 14.8s with peak RSS up 2.27 GB → 2.52 GB. Threads only engage for a genuinely large cold parse: never with under 200 MB behind the pending whole-file re-parses, 2 or fewer cores, or under 4 GB of available memory — so warm and incremental runs are untouched and spawn nothing. Otherwise the count is `min(cores - 1, min(0.25 × available, 2 GB) / 256 MB, pendingFiles / 50)`, where available is `process.availableMemory()` (cgroup-aware in containers) rather than free memory, which on macOS reports free pages and would switch the feature on and off between runs. `CODEBURN_PARSE_WORKERS=0` forces the serial parse and `CODEBURN_PARSE_WORKERS=N` forces N (capped at the core count), both bypassing every gate; `CODEBURN_VERBOSE=1` prints the resolved count and why. diff --git a/README.md b/README.md index 2159e1da..ee283d92 100644 --- a/README.md +++ b/README.md @@ -25,7 +25,7 @@ Sponsor

-

If CodeBurn shows you something your bill never did, star the repo so other developers find it, and consider sponsoring to keep 40 integrations honest.

+

If CodeBurn shows you something your bill never did, star the repo so other developers find it, and consider sponsoring to keep 41 integrations honest.

@@ -61,11 +61,11 @@

Four surfaces, one source of truth: everything reads the session files already on your disk.

-**CodeBurn is a free, open-source, local-first tool that tracks AI coding token usage and cost across 40 tools and agents (Claude Code, Cursor, Codex, Gemini, Grok and more), broken down by model, project, and task.** +**CodeBurn is a free, open-source, local-first tool that tracks AI coding token usage and cost across 41 tools and agents (Claude Code, Cursor, Codex, Gemini, Grok and more), broken down by model, project, and task.** You pay for Claude, Codex, Cursor, and a stack of other AI tools. The bill tells you the total. It never tells you that half of it went to conversation instead of code, or that an expensive model burned your budget on work a cheaper one would have one-shot. -CodeBurn does. It reads the session files your tools already write to disk and breaks down every token and dollar by **task, model, tool, and project**, across **40 AI tools**. +CodeBurn does. It reads the session files your tools already write to disk and breaks down every token and dollar by **task, model, tool, and project**, across **41 AI tools**. Everything runs locally. No wrapper, no proxy, no API keys, nothing leaves your machine. Pricing comes from [LiteLLM](https://github.com/BerriAI/litellm), refreshed daily. @@ -683,6 +683,7 @@ These are starting points, not verdicts. A 60% cache hit on a single experimenta | **Cline / Roo Code / KiloCode** | VS Code `globalStorage` across VS Code, VS Code Insiders, and VSCodium (Cline at `saoudrizwan.claude-dev`, plus `~/.cline/data`) | Cline-family agents. CodeBurn reads `ui_messages.json` from each task directory, extracting token counts from `type: "say"` entries with `say: "api_req_started"`. | | **Cline CLI** | `~/.cline/data/sessions//` (honors `CLINE_SESSION_DATA_DIR`, `CLINE_DATA_DIR`, `CLINE_DIR`) | The Cline command-line agent, whose layout is unrelated to the VS Code extension's. Reads `.json` for session metadata and the rolled-up `usage`, and `.messages.json` for the per-message `metrics` block (input, output, cacheRead, cacheWrite, cost) that becomes one call each. | | **CodeWhale** | `~/.codewhale/sessions/*.json` plus unmigrated legacy `~/.deepseek/sessions/*.json`; `$CODEWHALE_HOME/sessions` is an exact override | Emits one cumulative record per saved session. CodeWhale exposes only `total_tokens`, so CodeBurn preserves that aggregate in the input column rather than inventing an input/output split. Cost is the exact stored parent-session plus subagent USD total; model pricing is used only when the cost snapshot is absent. Tool blocks, shell commands, skills, and subagent types are retained. | +| **DeepSeek Harness** (`dsh`) | `~/.dsh/sessions/----//session.jsonl.zstd` (or `session.jsonl` when compression is off); `DSH_HOME` relocates the root | DeepSeek's open-source agent harness, unrelated to the CodeWhale desktop app. The `.zstd` log is a concatenation of independent zstd frames (one per write batch), decoded frame by frame; needs Node 22.15+. One call per `(turn, step)`, with usage from the step's `assistant/message` (the streamed `assistant/chunk` sample is a draft of the same call, never a second one). DSH records tokens but no cost, so calls are priced from the shared tables with reasoning billed at the output rate. | | **IBM Bob** | `User/globalStorage/ibm.bob-code/tasks//` (GA `IBM Bob` and preview `Bob-IDE` app folders) | Reads `ui_messages.json` for API request token/cost records and `api_conversation_history.json` for the selected model. | | **Kimi Code CLI** | `$KIMI_SHARE_DIR/sessions///` or `~/.kimi/sessions///` | Reads `wire.jsonl` `StatusUpdate.token_usage` records, mapping `input_other`, `input_cache_read`, `input_cache_creation`, and `output` into the standard token columns; includes subagents under each session's `subagents/` folder. | | **LingTai TUI** | `~/.lingtai//logs/token_ledger.jsonl` plus project homes from `~/.lingtai-tui/registry.jsonl` (`/.lingtai//logs/token_ledger.jsonl`); honors `LINGTAI_HOME` / `LINGTAI_TUI_HOME` | Reads LingTai's append-only token ledger, mapping `input - cached` to fresh input, `cached` to cache reads, `output` to output, and `thinking` to reasoning. Nested daemon ledgers are skipped because parent ledgers already mirror daemon usage with `source`/`run_id` tags. | @@ -722,12 +723,12 @@ CodeBurn deduplicates messages (by API message ID for Claude, by cumulative toke CodeBurn is free, runs entirely on your machine, and exists to cut your AI bill. If it has already saved you more than a sponsorship costs, consider sending a little of that back. -Keeping 40 integrations accurate is constant work. The tools underneath change every week: Cursor reshapes its database, Claude moves a config path, new models ship at new prices. Sponsorship keeps CodeBurn current with all of it, so the numbers you see are always the real ones. +Keeping 41 integrations accurate is constant work. The tools underneath change every week: Cursor reshapes its database, Claude moves a config path, new models ship at new prices. Sponsorship keeps CodeBurn current with all of it, so the numbers you see are always the real ones. Where your sponsorship goes: - **Honest numbers.** New models and price changes are mapped quickly, so your cost is the real cost, not a guess. -- **More tools.** Every one of the 40 providers started as a single file. Sponsorship funds the next one. +- **More tools.** Every one of the 41 providers started as a single file. Sponsorship funds the next one. - **Fast fixes.** When a vendor breaks something, paid time is what gets it patched now instead of someday. Sponsoring as a team or company? Your logo lands right here, in front of every developer who opens the repo. The first sponsor gets it to themselves until the next one shows up. diff --git a/app/package.json b/app/package.json index efe4fde8..bb62141f 100644 --- a/app/package.json +++ b/app/package.json @@ -169,6 +169,7 @@ "$HOME/.copilot", "$HOME/.cursor", "$HOME/.deepseek", + "$HOME/.dsh", "$HOME/.factory", "$HOME/.forge", "$HOME/.gemini", diff --git a/docs/architecture.md b/docs/architecture.md index 2f7277b1..4125ae13 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -191,7 +191,7 @@ type Provider = { `src/providers/index.ts` registers providers across two tiers: -- **Eager**: `claude`, `cline`, `codewhale`, `codebuff`, `codex`, `copilot`, `devin`, `droid`, `gemini`, `hermes`, `ibm-bob`, `kilo-code`, `kiro`, `kimi`, `lingtai-tui`, `mistral-vibe`, `mux`, `openclaw`, `open-design`, `pi`, `omp`, `qwen`, `roo-code`, `zerostack`, `grok`. Imported at module load. +- **Eager**: `claude`, `cline`, `codewhale`, `codebuff`, `codex`, `copilot`, `devin`, `droid`, `dsh`, `gemini`, `hermes`, `ibm-bob`, `kilo-code`, `kiro`, `kimi`, `lingtai-tui`, `mistral-vibe`, `mux`, `openclaw`, `open-design`, `pi`, `omp`, `qwen`, `roo-code`, `zerostack`, `grok`. Imported at module load. - **Lazy**: `antigravity`, `forge`, `goose`, `cursor`, `opencode`, `cursor-agent`, `crush`, `warp`, `vercel-gateway`, `zcode`, `zed`. Imported via dynamic `import()` so the heavy dependencies (SQLite, protobuf, network clients) do not touch users who do not have those tools installed. Both lists hit the same `getAllProviders()` aggregator. A failed lazy import is silent and excludes that provider from the run. diff --git a/docs/providers/README.md b/docs/providers/README.md index 971ae2b4..938d5425 100644 --- a/docs/providers/README.md +++ b/docs/providers/README.md @@ -18,6 +18,7 @@ For the architectural picture, see `../architecture.md`. | [Copilot](copilot.md) | JSONL + SQLite (OTel) + Nitrite .db (JetBrains) | `src/providers/copilot.ts` | `tests/providers/copilot.test.ts` | | [Devin](devin.md) | JSON + SQLite enrichment | `src/providers/devin.ts` | `tests/providers/devin.test.ts` | | [Droid](droid.md) | JSONL | `src/providers/droid.ts` | `tests/providers/droid.test.ts` | +| [DeepSeek Harness](dsh.md) | JSONL (zstd frames) | `src/providers/dsh.ts` | `tests/providers/dsh.test.ts` | | [Gemini](gemini.md) | JSON / JSONL | `src/providers/gemini.ts` | none | | [Hermes Agent](hermes.md) | SQLite | `src/providers/hermes.ts` | `tests/providers/hermes.test.ts` | | [IBM Bob](ibm-bob.md) | JSON | `src/providers/ibm-bob.ts` | `tests/providers/ibm-bob.test.ts` | diff --git a/docs/providers/dsh.md b/docs/providers/dsh.md new file mode 100644 index 00000000..d3bb81ac --- /dev/null +++ b/docs/providers/dsh.md @@ -0,0 +1,71 @@ +# DeepSeek Harness (dsh) + +DeepSeek's open-source agent harness (`dsh`, npm `@deepseek-ai/dsh`). Unrelated to the [CodeWhale](codewhale.md) provider, which reads the DeepSeek desktop app. + +- **Source:** `src/providers/dsh.ts` +- **Loading:** eager (`src/providers/index.ts`) +- **Test:** `tests/providers/dsh.test.ts` + +## Where it reads from + +| Level | Env var | Default | +|---|---|---| +| sessions | — | `/sessions` | +| root | `DSH_HOME` | `~/.dsh` | + +An empty `DSH_HOME` is treated as unset. `probeRoots()` reports the resolved sessions dir, so `codeburn doctor` distinguishes "dsh not installed" from "`DSH_HOME` pointing somewhere empty". + +## Storage format + +``` +sessions/----// + session.jsonl.zstd default (compression: zstd) + session.jsonl when compression: none +``` + +Both variants are read; a session directory never holds both. The log is append-only JSONL whose first line is the session header: + +```jsonc +{ "type": "session", "version": 0, "id": "...", "createdAt": 1783352050748, + "cwd": "/home/u/proj", "parentSession": "...", "seedLength": 3, "delegationDepth": 0 } +``` + +`cwd` becomes `projectPath` / `workingDirectory` (git-repo attribution) and its last segment the project name. + +Every later line is one event `{ type, seq, time, data }`. The parser reads: + +| Event | Used for | +|---|---| +| `turn/start` | current turn number | +| `user/message` | the turn's preview, when `data.source.kind === 'user'` | +| `request/header` | `data.header.config.model` — the model for steps that follow | +| `assistant/chunk` with `chunk.type === 'usage'` | streamed usage sample for `(turn, step)` | +| `assistant/message` | final usage for `(turn, step)`, plus `data.message.source.model` | +| `tool/call` | tool names, bash commands, skill names | + +One parsed call per `(turn, step)` — one model call and the tools it requested. Dedup key: `dsh:::`. + +`.zstd` logs are a concatenation of **independent** zstd frames, one per write batch, so they are decoded frame by frame behind a structural frame scan ported from `@deepseek-ai/dsh-session-persistence-jsonl`. Needs Node 22.15+ for `zlib.zstdDecompressSync`; below that dsh is skipped with a notice instead of counted as $0. + +## Caching + +None at the provider level; the log file is the cached source path and the normal parser/cache layers apply. Cache invalidates on `DSH_HOME` (`PROVIDER_ENV_VARS`) and on parser changes (`PROVIDER_PARSE_VERSIONS`). + +## Quirks + +- **DSH is a developer preview.** `SESSION_FORMAT_VERSION` is pinned at `0` with "no compatibility implied" upstream, and breaking changes are expected. The parser reads version `0` only and skips a log stamped with anything else, with a notice — reading a bumped format under today's assumptions would report confident wrong numbers. **A version bump upstream means this parser needs updating, not just relaxing the check.** +- **The JSONL backend only.** DSH also ships an opt-in SQLite persistence backend (`@deepseek-ai/dsh-session-persistence-sqlite`); it is not the default and is not read. +- **DSH records tokens, never dollars.** `usage` is `{ inputTokens, outputTokens, cacheReadTokens?, cacheWriteTokens?, reasoningTokens? }` with no cost field, so every call is priced from the shared tables. Reasoning bills at the output rate (same as Gemini and Hermes): `outputTokens + reasoningTokens` goes into `calculateCost`, while the two stay separate on the emitted call. Tokens are the provider's own exact counts, so `costIsEstimated` stays false. +- **`assistant/message` usage wins over the `assistant/chunk` sample** for the same `(turn, step)` — the two are adjacent reports of one API call, not two calls. A late chunk never overwrites a final report, so the two are never summed. +- **The model comes from the message, not the request.** `data.message.source.model` is what actually served the step; `request/header` only describes the request DSH was about to make, and is the fallback when a message names no model. The `provider` field there (`deepseek-official`) is the upstream LLM route, not the tool — the codeburn provider name is always `dsh`. +- **A forked session's log replays its parent's events.** The header's `parentSession` + `seedLength` mark that prefix; codeburn parses the parent's own log as its own session, so events with `seq < seedLength` are skipped to avoid billing the same calls twice. +- **`user/message` also carries agent-injected context** (runtime snapshots, skill bodies, file-change notices) under `source.kind: 'plugin'`. Only `kind: 'user'` messages become the preview. +- **Delta chunks are packed.** Runs of streamed deltas are stored as `text-chunks` / `reasoning-chunks` / `tool-call-chunks` storage rows rather than one event per line. They carry no usage and no tool identity the `tool/call` event lacks, so they are ignored — as is any event type the parser does not know. +- **A torn final zstd frame is ignored.** A crashed writer leaves an incomplete trailing frame; the complete frames before it parse normally. A structurally corrupt file is skipped whole with a notice rather than throwing. + +## When fixing a bug here + +1. Reproduce with a minimal session dir: `sessions/--proj--//session.jsonl` (uncompressed is easiest to hand-write). +2. `tests/fixtures/dsh/bash-tool-turn.jsonl` is the upstream `examples/acp-agent/tests/snapshots/bash-tool-turn/session.jsonl` snapshot with its template placeholders filled in — refresh it from the DSH repo when the format moves. +3. Run `tests/providers/dsh.test.ts`. +4. `.zstd` fixtures must compress **each batch separately**; one `zstdCompressSync` over the whole file is a single-frame layout DSH never writes. diff --git a/mac/Sources/CodeBurnMenubar/Data/UsageDataChangeGuard.swift b/mac/Sources/CodeBurnMenubar/Data/UsageDataChangeGuard.swift index d2a48c36..e28eed9c 100644 --- a/mac/Sources/CodeBurnMenubar/Data/UsageDataChangeGuard.swift +++ b/mac/Sources/CodeBurnMenubar/Data/UsageDataChangeGuard.swift @@ -72,6 +72,8 @@ enum UsageDataChangeGuard { add(expand(environment["CODEWHALE_HOME"] ?? path(homeDirectory, ".codewhale"), homeDirectory: homeDirectory), scanFirstLevelDirectories: false) add(path(homeDirectory, ".deepseek", "sessions"), scanFirstLevelDirectories: false) add(path(homeDirectory, ".cline", "data"), scanFirstLevelDirectories: false) + let dshHome = expand(environment["DSH_HOME"] ?? path(homeDirectory, ".dsh"), homeDirectory: homeDirectory) + add(path(dshHome, "sessions")) add(expand(environment["CODEBUFF_DATA_DIR"] ?? path(xdgConfig, "manicode"), homeDirectory: homeDirectory), scanFirstLevelDirectories: false) let factoryHome = expand(environment["FACTORY_DIR"] ?? path(homeDirectory, ".factory"), homeDirectory: homeDirectory) add(path(factoryHome, "sessions"), scanFirstLevelDirectories: false) diff --git a/package.json b/package.json index 7d1b7e8d..e137066d 100644 --- a/package.json +++ b/package.json @@ -33,6 +33,7 @@ "pi", "codebuff", "codewhale", + "dsh", "ai-coding", "token-usage", "cost-tracking", From eb8ceeb86708da894b6f1265cb13ebafd061e367 Mon Sep 17 00:00:00 2001 From: iamtoruk Date: Mon, 17 Aug 2026 11:01:12 -0700 Subject: [PATCH 18/85] fix(dsh): bound the two remaining unbounded reads and the version notice - The unsupported-version notice is keyed on the version rather than the path: a DSH format bump makes every session unreadable at once, and one stderr line per session log is noise. - The discovery header read falls back to reading the whole file when a 256 KB head does not cover one full zstd frame. A fork's first write batch carries the entire inherited seed, so that is reachable on a real log; it now takes the same oversize guard as the parse read. --- src/providers/dsh.ts | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/src/providers/dsh.ts b/src/providers/dsh.ts index 4fa8bd2d..60141617 100644 --- a/src/providers/dsh.ts +++ b/src/providers/dsh.ts @@ -167,9 +167,11 @@ function mapToolName(raw: string): string { // A log stamped with a version this parser was not written against is skipped // whole: a bump means an event's meaning changed, so reading it with today's // assumptions would report confident wrong numbers. -function isReadableVersion(header: DshEvent, filePath: string): boolean { +function isReadableVersion(header: DshEvent): boolean { if (header.version === SESSION_FORMAT_VERSION) return true - notice(`codeburn: skipping DSH session ${filePath}: unsupported session format version ${String(header.version)}; upgrade codeburn.\n`) + // Keyed on the version, not the path: a DSH upgrade makes EVERY session + // unreadable at once, and one line per session log is noise, not a report. + notice(`codeburn: skipping DSH sessions written in session format version ${String(header.version)}; upgrade codeburn.\n`) return false } @@ -261,8 +263,11 @@ async function readSessionHeader(filePath: string): Promise { } let { frames } = scanZstdFrames(head, 1) if (frames.length === 0) { - // Head read did not cover one full frame; take the whole file. + // Head read did not cover one full frame; take the whole file. A fork's + // first batch carries the whole inherited seed, so this is reachable on + // a real log and needs the same oversize guard as the parse read. try { + if ((await stat(filePath)).size > MAX_SESSION_FILE_BYTES) return null const full = await readFile(filePath) frames = scanZstdFrames(full, 1).frames if (frames.length === 0) return null @@ -283,7 +288,7 @@ async function readSessionHeader(filePath: string): Promise { if (!line) return null const event = JSON.parse(line) as DshEvent if (event.type !== 'session') return null - return isReadableVersion(event, filePath) ? event : null + return isReadableVersion(event) ? event : null } catch { return null } @@ -377,7 +382,7 @@ function createParser(source: SessionSource, seenKeys: Set): SessionPars } if (event.type === 'session') { - if (!isReadableVersion(event, source.path)) return + if (!isReadableVersion(event)) return sessionId = event.id ?? sessionId cwd = event.cwd ?? cwd sessionStart = isoTimestamp(event.createdAt, sessionStart) From ffd92131266d89866d8a413114d6039989c59332 Mon Sep 17 00:00:00 2001 From: iamtoruk Date: Mon, 17 Aug 2026 14:32:55 -0700 Subject: [PATCH 20/85] test(dsh): skip zstd container tests where node:zlib lacks zstd, cover both Node lines in CI zstd landed in node:zlib in 22.15; the package floor and CI pin are 22.13, so the runtime already degrades with a notice there. The tests compressed fixtures at runtime and failed outright. Fixture writes now fall back to plain jsonl below 22.15 so parsing semantics still run, container-specific tests skip, and the Tests workflow runs on both 22.13.0 and latest 22.x. --- .github/workflows/tests.yml | 8 +++++++- tests/providers/dsh.test.ts | 19 ++++++++++++++----- 2 files changed, 21 insertions(+), 6 deletions(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index d81667d5..58fad55e 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -9,12 +9,18 @@ jobs: test: runs-on: ubuntu-latest timeout-minutes: 15 + strategy: + fail-fast: false + matrix: + # Package floor, and the newest 22.x so paths gated on later node:zlib + # features (zstd, 22.15+) get exercised. + node-version: [22.13.0, 22] steps: - uses: actions/checkout@v6 - uses: actions/setup-node@v4 with: - node-version: 22.13.0 + node-version: ${{ matrix.node-version }} cache: npm - run: npm ci - name: Typecheck diff --git a/tests/providers/dsh.test.ts b/tests/providers/dsh.test.ts index 05994566..477bb140 100644 --- a/tests/providers/dsh.test.ts +++ b/tests/providers/dsh.test.ts @@ -14,6 +14,10 @@ import type { ParsedProviderCall } from '../../src/providers/types.js' // format than what DSH writes. const zstdCompress = (zlib as { zstdCompressSync?: (buf: Buffer) => Buffer }).zstdCompressSync +// node:zlib gained zstd in 22.15; the package floor (and CI's pinned Node) is +// 22.13. Container-specific tests skip there; the rest fall back to plain jsonl +// so the parsing semantics are still exercised. +const itZstd = zstdCompress ? it : it.skip let tmpDir: string @@ -95,8 +99,13 @@ function toolCall(turn: number, step: number, name: string, args: Record lines.join('\n') + '\n').join('')) + return filePath + } const filePath = join(dir, 'session.jsonl.zstd') - const frames = batches.map(lines => zstdCompress!(Buffer.from(lines.join('\n') + '\n', 'utf-8'))) + const frames = batches.map(lines => zstdCompress(Buffer.from(lines.join('\n') + '\n', 'utf-8'))) await writeFile(filePath, Buffer.concat(frames)) return filePath } @@ -119,7 +128,7 @@ async function parseAll(provider: ReturnType, filePath } describe('dsh provider - session discovery', () => { - it('discovers a multi-frame zstd session, project from the header cwd', async () => { + itZstd('discovers a multi-frame zstd session, project from the header cwd', async () => { await writeZstdSession('--C-Users-test-myproject--', 'session-abc', [ [sessionHeader({ cwd: 'C:\\Users\\test\\myproject' })], [assistantMessage(1, 1, { inputTokens: 100, outputTokens: 10 }, 1786707340000)], @@ -196,7 +205,7 @@ describe('dsh provider - session discovery', () => { }) describe('dsh provider - parsing', () => { - it('decodes events spread across multiple independent zstd frames', async () => { + itZstd('decodes events spread across multiple independent zstd frames', async () => { const filePath = await writeZstdSession('--C-Users-test-myproject--', 'session-multi', [ [sessionHeader({ id: 'session-multi', cwd: 'C:\\Users\\test\\myproject' })], [turnStart(1, 1786707339000), userMessage('build the thing', 1786707339100)], @@ -340,7 +349,7 @@ describe('dsh provider - parsing', () => { expect(calls).toHaveLength(0) }) - it('ignores a torn final frame appended by a crashed writer', async () => { + itZstd('ignores a torn final frame appended by a crashed writer', async () => { const dir = join(tmpDir, 'sessions', '--C-Users-test-myproject--', 'session-torn') await mkdir(dir, { recursive: true }) const filePath = join(dir, 'session.jsonl.zstd') @@ -513,7 +522,7 @@ describe('dsh provider - defensive reads', () => { }) describe('dsh provider - real log, real container', () => { - it('reads the upstream snapshot out of multi-frame zstd with a torn tail identically to plain jsonl', async () => { + itZstd('reads the upstream snapshot out of multi-frame zstd with a torn tail identically to plain jsonl', async () => { const lines = (await readFile(join(import.meta.dirname, '../fixtures/dsh/bash-tool-turn.jsonl'), 'utf-8')) .split('\n').filter(l => l.trim()) const plain = await parseAll( From 09965f93aeb519a7a699474c83721ce2bd49986f Mon Sep 17 00:00:00 2001 From: iamtoruk Date: Mon, 17 Aug 2026 17:28:41 -0700 Subject: [PATCH 21/85] fix(dsh): cap zstd decode, coerce usage fields, scope the snap read Security audit follow-ups on the DeepSeek Harness provider. - **Decompression bomb.** Every zstd frame was decoded with no output bound, so a 16 KB crafted log expanded to ~916 MB of RSS (a 65 KB one declares 2 GB). Each frame now decodes under a 64 MB per-call cap, and the caps chain into a running per-file budget of MAX_SESSION_FILE_BYTES: a frame is given only the bytes the file has left, so node throws ERR_BUFFER_TOO_LARGE without allocating past the cap. The throw propagates out of the existing skip path, which discards the whole file rather than counting the frames read before the bomb, so a crafted tail cannot poison a partial total. The discovery header read takes the same per-frame cap. Measured on a 65 KB / 2 GB bomb: 916 MB -> 67 MB peak, zero calls emitted, one notice. Lines are still materialized eagerly; the byte budget bounds that, and making the read lazy would change readEventLines' contract for no further bound. - **Usage type confusion.** Token fields were read with `?? 0` and never type-checked, so a string or array inputTokens flowed into the global totals and the persisted cache, where `0 + [1, 2]` becomes "01,2". They now go through numberOrZero (copilot.ts semantics: finite, positive, else 0). All-zero calls are still skipped. - **Snap over-scope.** The personal-files read entry is `$HOME/.dsh/sessions` rather than all of `$HOME/.dsh`; the provider reads nothing else. - **Third-party notice.** scanZstdFrames is transcribed from @deepseek-ai/dsh-session-persistence-jsonl. The published npm package is BSD-3-Clause (Copyright (c) 2026, DeepSeek) while the monorepo source declares MIT for the same package; THIRD_PARTY_NOTICES.md reproduces the stricter of the two and ships via package.json `files`. --- THIRD_PARTY_NOTICES.md | 51 +++++++++++++++++++++++++ app/package.json | 2 +- package.json | 1 + src/providers/dsh.ts | 53 ++++++++++++++++++++------ tests/providers/dsh.test.ts | 76 ++++++++++++++++++++++++++++++++++++- 5 files changed, 168 insertions(+), 15 deletions(-) create mode 100644 THIRD_PARTY_NOTICES.md diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md new file mode 100644 index 00000000..c1ffcb8e --- /dev/null +++ b/THIRD_PARTY_NOTICES.md @@ -0,0 +1,51 @@ +# Third-party notices + +CodeBurn is MIT licensed (see `LICENSE`). It also contains code derived from the +projects below, which carry their own terms. Each notice is reproduced here as +those terms require. + +--- + +## @deepseek-ai/dsh-session-persistence-jsonl + +`scanZstdFrames` in `src/providers/dsh.ts` is a transcription of the function of +the same name in this package (`src/zstd.ts`), which is what lets CodeBurn read +a DeepSeek Harness session log without depending on the harness itself. No other +part of the package is used. + +Upstream declares two different licenses for this package: the published npm +package (0.0.1-rc.1) ships a BSD 3-Clause `LICENSE` and declares +`"license": "BSD-3-Clause"`, while the monorepo source it is built from +(`deepseek-ai/deepseek-harness`, `packages/session/session-persistence-jsonl`) +declares MIT. The stricter of the two is reproduced below. + +``` +BSD 3-Clause License + +Copyright (c) 2026, DeepSeek + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + +1. Redistributions of source code must retain the above copyright notice, this + list of conditions and the following disclaimer. + +2. Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. + +3. Neither the name of the copyright holder nor the names of its + contributors may be used to endorse or promote products derived from + this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE +FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. +``` diff --git a/app/package.json b/app/package.json index bb62141f..ab5a29b4 100644 --- a/app/package.json +++ b/app/package.json @@ -169,7 +169,7 @@ "$HOME/.copilot", "$HOME/.cursor", "$HOME/.deepseek", - "$HOME/.dsh", + "$HOME/.dsh/sessions", "$HOME/.factory", "$HOME/.forge", "$HOME/.gemini", diff --git a/package.json b/package.json index e137066d..c1288196 100644 --- a/package.json +++ b/package.json @@ -9,6 +9,7 @@ }, "files": [ "dist", + "THIRD_PARTY_NOTICES.md", "!dist/parse-worker.js.map" ], "scripts": { diff --git a/src/providers/dsh.ts b/src/providers/dsh.ts index 60141617..d82664db 100644 --- a/src/providers/dsh.ts +++ b/src/providers/dsh.ts @@ -15,17 +15,25 @@ import type { ProbeRoot, Provider, SessionSource, SessionParser, ParsedProviderC // so node:zlib's one-shot zstdDecompressSync (which decodes a single frame) // must be driven frame-by-frame behind a structural frame-boundary scan. The // scan below is a port of scanZstdFrames from the official -// @deepseek-ai/dsh-session-persistence-jsonl package. +// @deepseek-ai/dsh-session-persistence-jsonl package, which is third-party code +// under its own license - see THIRD_PARTY_NOTICES.md. // zstd landed in node:zlib in 22.15 / 23.8; the package floor is lower, so the // provider degrades with a notice instead of assuming the export exists. -const zstdDecompress = (zlib as { zstdDecompressSync?: (buf: Buffer) => Buffer }).zstdDecompressSync +const zstdDecompress = (zlib as { zstdDecompressSync?: (buf: Buffer, opts?: { maxOutputLength?: number }) => Buffer }).zstdDecompressSync const ZSTD_MAGIC = 0xfd2fb528 // SESSION_FORMAT_VERSION in @deepseek-ai/dsh-session. DSH refuses to load a log // stamped with any other version, and a bump means an event's meaning changed, // so a foreign version is skipped rather than read with today's assumptions. +// A zstd frame's declared content size is attacker-controlled, so a few KB of +// crafted input can expand to gigabytes. Every decode is capped: no single +// frame may exceed this, and no file may decode to more than it would have been +// allowed to occupy uncompressed (MAX_SESSION_FILE_BYTES). Overflow throws, and +// the caller skips the WHOLE file rather than counting the frames it got to. +const MAX_FRAME_DECODED_BYTES = 64 * 1024 * 1024 + const SESSION_FORMAT_VERSION = 0 const MIN_REASONABLE_TIMESTAMP_MS = 1_000_000_000_000 @@ -164,6 +172,13 @@ function mapToolName(raw: string): string { return toolNameMap[raw] ?? raw } +// Usage fields are whatever the JSON held. A string or array would flow +// straight into the global token totals and the persisted cache, where +// `0 + [1, 2]` silently becomes "01,2". Same semantics as copilot.ts. +function numberOrZero(raw: unknown): number { + return typeof raw === 'number' && Number.isFinite(raw) && raw > 0 ? raw : 0 +} + // A log stamped with a version this parser was not written against is skipped // whole: a bump means an event's meaning changed, so reading it with today's // assumptions would report confident wrong numbers. @@ -198,12 +213,24 @@ function projectFromCwd(cwd: string, fallback: string): string { } // Decode every complete frame and yield its JSONL lines. A torn final frame is -// ignored; a structurally corrupt file throws for the caller to report. -function* readZstdLines(buffer: Buffer, maxFrames = Number.POSITIVE_INFINITY): Generator { +// ignored; a structurally corrupt file, or one that decodes past `budget`, +// throws for the caller to report. Exported for the decode-budget test. +export function* readZstdLines( + buffer: Buffer, + maxFrames = Number.POSITIVE_INFINITY, + budget = MAX_SESSION_FILE_BYTES, +): Generator { const { frames } = scanZstdFrames(buffer, maxFrames) + let remaining = budget for (const frame of frames) { - const text = zstdDecompress!(buffer.subarray(frame.start, frame.end)).toString('utf-8') - for (const line of text.split('\n')) { + if (remaining <= 0) throw new Error(`decodes past the ${budget}-byte cap`) + // node throws ERR_BUFFER_TOO_LARGE without allocating past the cap, so the + // per-frame limit doubles as the running budget for the frames after it. + const decoded = zstdDecompress!(buffer.subarray(frame.start, frame.end), { + maxOutputLength: Math.min(remaining, MAX_FRAME_DECODED_BYTES), + }) + remaining -= decoded.length + for (const line of decoded.toString('utf-8').split('\n')) { if (line.trim()) yield line } } @@ -276,7 +303,9 @@ async function readSessionHeader(filePath: string): Promise { return null } } - const text = zstdDecompress(head.subarray(frames[0]!.start, frames[0]!.end)).toString('utf-8') + const text = zstdDecompress(head.subarray(frames[0]!.start, frames[0]!.end), { + maxOutputLength: MAX_FRAME_DECODED_BYTES, + }).toString('utf-8') return text.split('\n').find(l => l.trim()) ?? null } const content = await readSessionFile(filePath) @@ -486,11 +515,11 @@ function createParser(source: SessionSource, seenKeys: Set): SessionPars for (const key of sortedKeys) { const bucket = buckets.get(key)! - const input = bucket.usage.inputTokens ?? 0 - const output = bucket.usage.outputTokens ?? 0 - const cacheRead = bucket.usage.cacheReadTokens ?? 0 - const cacheWrite = bucket.usage.cacheWriteTokens ?? 0 - const reasoning = bucket.usage.reasoningTokens ?? 0 + const input = numberOrZero(bucket.usage.inputTokens) + const output = numberOrZero(bucket.usage.outputTokens) + const cacheRead = numberOrZero(bucket.usage.cacheReadTokens) + const cacheWrite = numberOrZero(bucket.usage.cacheWriteTokens) + const reasoning = numberOrZero(bucket.usage.reasoningTokens) if (input + output + cacheRead + cacheWrite + reasoning === 0) continue const dedupKey = `dsh:${sessionId || source.path}:${key}` diff --git a/tests/providers/dsh.test.ts b/tests/providers/dsh.test.ts index 477bb140..fc2142f9 100644 --- a/tests/providers/dsh.test.ts +++ b/tests/providers/dsh.test.ts @@ -1,10 +1,10 @@ import { describe, it, expect, beforeEach, afterEach } from 'vitest' -import { mkdtemp, mkdir, writeFile, readFile, rm } from 'fs/promises' +import { mkdtemp, mkdir, writeFile, readFile, rm, stat } from 'fs/promises' import { join } from 'path' import { homedir, tmpdir } from 'os' import zlib from 'zlib' -import { createDshProvider } from '../../src/providers/dsh.js' +import { createDshProvider, readZstdLines } from '../../src/providers/dsh.js' import { calculateCost } from '../../src/models.js' import type { ParsedProviderCall } from '../../src/providers/types.js' @@ -546,3 +546,75 @@ describe('dsh provider - real log, real container', () => { expect(framed).toHaveLength(2) }) }) + +describe('dsh provider - hostile input', () => { + itZstd('skips a session whose frames decompress to far more than the file cap', async () => { + const dir = join(tmpDir, 'sessions', '--home-u-proj--', 'session-bomb') + await mkdir(dir, { recursive: true }) + const filePath = join(dir, 'session.jsonl.zstd') + // 200 MB of zeros compresses to a few KB. Uncapped this decoded to ~916 MB + // of RSS for a 16 KB file; the per-frame cap now rejects it without + // allocating past the cap. + const bomb = zstdCompress!(Buffer.alloc(200 * 1024 * 1024)) + const good = zstdCompress!(Buffer.from( + sessionHeader({ id: 'session-bomb', cwd: '/home/u/proj' }) + '\n' + + chunkUsage(1, 1, { inputTokens: 100, outputTokens: 10 }, 1786707340000) + '\n', + 'utf-8', + )) + await writeFile(filePath, Buffer.concat([good, bomb])) + expect((await stat(filePath)).size).toBeLessThan(64 * 1024) + + // The whole file is skipped: the frames read before the bomb are not + // counted, so a crafted tail cannot poison a partial total. + expect(await parseAll(createDshProvider(tmpDir), filePath)).toEqual([]) + }) + + itZstd('stops decoding once the frames exceed the running budget', async () => { + const frame = zstdCompress!(Buffer.from('{"type":"turn/start","seq":0,"time":1,"data":{"turn":1}}\n', 'utf-8')) + const buffer = Buffer.concat([frame, frame, frame]) + + expect([...readZstdLines(buffer, Number.POSITIVE_INFINITY, 4096)]).toHaveLength(3) + // A budget under two frames' plaintext stops at the frame that overruns it. + expect(() => [...readZstdLines(buffer, Number.POSITIVE_INFINITY, 60)]).toThrow() + }) + + it('coerces non-numeric usage fields instead of poisoning the totals', async () => { + const filePath = await writePlainSession('--home-u-proj--', 'session-poison', [ + sessionHeader({ id: 'session-poison', cwd: '/home/u/proj' }), + JSON.stringify({ + type: 'assistant/message', seq: 1, time: 1786707340000, + data: { + turn: 1, step: 1, message: { role: 'assistant', content: [] }, + usage: { inputTokens: '999', outputTokens: [1, 2], reasoningTokens: 1e308 * 10, cacheReadTokens: -5, cacheWriteTokens: 7 }, + }, + }), + ]) + + const calls = await parseAll(createDshProvider(tmpDir), filePath) + expect(calls).toHaveLength(1) + // Only the one genuinely numeric field survives; every other shape is 0. + expect(calls[0]).toMatchObject({ + inputTokens: 0, + outputTokens: 0, + reasoningTokens: 0, + cacheReadInputTokens: 0, + cacheCreationInputTokens: 7, + }) + for (const value of [calls[0]!.inputTokens, calls[0]!.outputTokens, calls[0]!.costUSD]) { + expect(typeof value).toBe('number') + expect(Number.isFinite(value)).toBe(true) + } + }) + + it('still skips a call whose usage is all non-numeric', async () => { + const filePath = await writePlainSession('--home-u-proj--', 'session-poison-zero', [ + sessionHeader({ id: 'session-poison-zero', cwd: '/home/u/proj' }), + JSON.stringify({ + type: 'assistant/message', seq: 1, time: 1786707340000, + data: { turn: 1, step: 1, message: { role: 'assistant', content: [] }, usage: { inputTokens: '999', outputTokens: [1, 2] } }, + }), + ]) + + expect(await parseAll(createDshProvider(tmpDir), filePath)).toEqual([]) + }) +}) From eadc99ef997f822d58d5dcabc1b3b973e4da9795 Mon Sep 17 00:00:00 2001 From: Aditya Vikram Singh Date: Tue, 18 Aug 2026 06:28:50 +0530 Subject: [PATCH 22/85] fix(models): correct Sonnet 4 thinking alias (#982) Co-authored-by: Aditya Vikram Singh <247195684+avs-io@users.noreply.github.com> --- src/models.ts | 2 +- tests/models.test.ts | 10 +++++++++- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/src/models.ts b/src/models.ts index bf4ed448..0ad8fccc 100644 --- a/src/models.ts +++ b/src/models.ts @@ -307,7 +307,7 @@ const BUILTIN_ALIASES: Record = { // reports that quote literal slugs (e.g. forum.cursor.com/t/154933). 'claude-4-sonnet': 'claude-sonnet-4', 'claude-4-sonnet-1m': 'claude-sonnet-4', - 'claude-4-sonnet-thinking': 'claude-sonnet-4-5', + 'claude-4-sonnet-thinking': 'claude-sonnet-4', 'claude-4.5-sonnet': 'claude-sonnet-4-5', 'claude-4.5-sonnet-thinking': 'claude-sonnet-4-5', 'claude-4.6-sonnet': 'claude-sonnet-4-6', diff --git a/tests/models.test.ts b/tests/models.test.ts index e8910e37..3e2b1655 100644 --- a/tests/models.test.ts +++ b/tests/models.test.ts @@ -505,7 +505,7 @@ describe('Cursor model variants resolve to pricing', () => { // Sonnet family ['claude-4-sonnet', 'claude-sonnet-4'], ['claude-4-sonnet-1m', 'claude-sonnet-4'], - ['claude-4-sonnet-thinking', 'claude-sonnet-4-5'], + ['claude-4-sonnet-thinking', 'claude-sonnet-4'], ['claude-4.5-sonnet', 'claude-sonnet-4-5'], ['claude-4.5-sonnet-thinking', 'claude-sonnet-4-5'], ['claude-4.6-sonnet', 'claude-sonnet-4-6'], @@ -558,6 +558,14 @@ describe('Cursor model variants resolve to pricing', () => { expect(costs!.outputCostPerToken).toBe(expected!.outputCostPerToken) }) } + + // Regression for #912: Cursor's unversioned `claude-4-sonnet-thinking` + // slug is the thinking variant of Sonnet 4, not Sonnet 4.5. The two models + // currently share a price, so the display name pins the canonical identity + // independently of today's pricing coincidence. + it('keeps claude-4-sonnet-thinking in the Sonnet 4 model family', () => { + expect(getShortModelName('claude-4-sonnet-thinking')).toBe('Sonnet 4') + }) }) describe('Cursor house model pricing', () => { From e013f78d78d7b8cb1fd6849024a7ce1d90beec2a Mon Sep 17 00:00:00 2001 From: ozymandiashh <234437643+ozymandiashh@users.noreply.github.com> Date: Tue, 18 Aug 2026 04:07:53 +0300 Subject: [PATCH 23/85] fix(dash): lead the session legend with the session title MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Grouping the hourly chart by session labelled every series with the project path plus a truncated session id. In a monorepo every series shares that prefix, so the legend reads as half a dozen indistinguishable hex fragments — and telling one application's spend from another is the main reason to open that chart in the first place. The title is already there: SessionSummary.title is parsed from the transcript and the Context tab already displays it. The legend now prefers it, keeps the short id as the suffix so two sessions sharing a title stay distinguishable, and falls back to the existing project label when a session has no title. Titles come from transcripts, so they get the same treatment as model names: ANSI stripped, control characters flattened to spaces, whitespace collapsed, and a length cap so a 200-character title cannot dominate the legend or the tooltip. The label is built once per session rather than per API call, since every input to it is constant for a given series key. Reported in #997. The clickable-legend half of that issue is not included. --- CHANGELOG.md | 1 + src/granular-history.ts | 29 ++++++++++++++- tests/granular-history.test.ts | 67 +++++++++++++++++++++++++++++++++- 3 files changed, 95 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9242c070..5e3f4a53 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,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 +- **The session chart legend now leads with the session title instead of the project path.** Every series in a monorepo shared the same project prefix, so the only thing separating them was a truncated hex fragment — and per-application cost attribution is the main reason to open that chart. `SessionSummary.title` is already parsed and already rendered in the Context tab; the legend now prefers it, keeps the short session id as the disambiguator for sessions that share a title, and falls back to the previous project-based label when a session never produced one. Titles come from transcripts, so they are stripped of ANSI and control characters and capped before they reach either the legend or the tooltip. (#997) - **Bash command splitting was quadratic on long whitespace-heavy commands.** The separator regex retried its leading `\s*` from every offset; matching the separator alone and widening over whitespace by hand makes cold parse ~24% and warm ~40% faster on large corpora, output unchanged. - **Cold parse no longer retains full message bodies through cached previews.** `flatSlice` skipped its Buffer round-trip for strings already within the bound, but provider adapters pre-truncate user-message previews with `.slice(0, 500)` before the cache-site call — those pre-sliced views are still V8 SlicedStrings pinning their large parent, so the retention that OOM'd cold parses of large histories survived. The round-trip now always runs. - **Kiro sessions carry the real `projectPath`** (CLI meta.cwd, v2 `workspacePaths[0]`, workspace sessions' `workspaceDirectory`), so git-repo attribution can resolve them; previously they were attribution-blind. Bumps the kiro parse version, so the first run after upgrade re-parses kiro history once, and kiro sessions in linked git worktrees now group under the main repo. diff --git a/src/granular-history.ts b/src/granular-history.ts index aeecd565..2a4293b3 100644 --- a/src/granular-history.ts +++ b/src/granular-history.ts @@ -1,3 +1,5 @@ +import stripAnsi from 'strip-ansi' + import type { DateRange, ProjectSummary } from './types.js' const FIFTEEN_MINUTES = 15 @@ -5,6 +7,10 @@ const ONE_HOUR = 60 const ONE_DAY = 24 * 60 const MINUTE_MS = 60 * 1000 const MAX_SERIES_PER_METRIC = 6 +// Keep metadata bounded for both the max-w-40 legend and the tooltip: 80 +// characters preserves a useful title without letting the parser's 200-char +// transcript cap dominate either UI surface. +const MAX_SESSION_TITLE_LENGTH = 80 export type GranularSeries = { id: string @@ -99,6 +105,21 @@ function shortSessionId(sessionId: string): string { return trimmed.length > 12 ? `${trimmed.slice(0, 6)}…${trimmed.slice(-4)}` : trimmed || 'unknown' } +function cleanSessionTitle(title: string | undefined): string | undefined { + if (title === undefined) return undefined + + // Match the control-character range used by the model-name sanitizer. ANSI + // sequences are removed first; remaining controls become spaces so transcript + // line breaks cannot join words before internal whitespace is collapsed. + const cleaned = stripAnsi(title) + .replace(/[\x00-\x1F\x7F-\x9F]/g, ' ') + .replace(/\s+/g, ' ') + .trim() + if (!cleaned) return undefined + + return cleaned.slice(0, MAX_SESSION_TITLE_LENGTH).trimEnd() || undefined +} + // Legend labels: the sanitized project dir ("-Users-name-Projects-app") is // unreadable, so prefer the real projectPath's last two segments ("app/web"). // Fall back to the sanitized name when no usable path exists. @@ -214,7 +235,13 @@ export function buildGranularHistory( add(modelTotals, modelKey, cost, tokens) add(sessionTotals, sessionKey, cost, tokens) modelLabels.set(modelKey, modelKey === '' ? 'Other model' : modelKey) - sessionLabels.set(sessionKey, `${shortProjectLabel(project.projectPath, projectName)} · ${shortSessionId(session.sessionId)} (${call.provider})`) + // Every input is constant for a given sessionKey (the provider is part + // of the key), so build the label once instead of re-sanitizing the + // title on every call in the session. + if (!sessionLabels.has(sessionKey)) { + const sessionLabel = cleanSessionTitle(session.title) ?? shortProjectLabel(project.projectPath, projectName) + sessionLabels.set(sessionKey, `${sessionLabel} · ${shortSessionId(session.sessionId)} (${call.provider})`) + } callCount++ } } diff --git a/tests/granular-history.test.ts b/tests/granular-history.test.ts index 9eec5339..d5b94a5a 100644 --- a/tests/granular-history.test.ts +++ b/tests/granular-history.test.ts @@ -45,7 +45,7 @@ function apiCall(options: { } } -function project(sessions: Array<{ id: string; project?: string; calls: ParsedApiCall[] }>): ProjectSummary { +function project(sessions: Array<{ id: string; project?: string; title?: string; calls: ParsedApiCall[] }>): ProjectSummary { return { project: 'demo', projectPath: '/repos/demo', @@ -56,6 +56,7 @@ function project(sessions: Array<{ id: string; project?: string; calls: ParsedAp sessions: sessions.map(session => ({ sessionId: session.id, project: session.project ?? 'demo', + title: session.title, firstTimestamp: session.calls[0]?.timestamp ?? '', lastTimestamp: session.calls.at(-1)?.timestamp ?? '', totalCostUSD: session.calls.reduce((sum, call) => sum + call.costUSD, 0), @@ -107,6 +108,70 @@ describe('granular history', () => { expect(granularBucketMinutes(range(24 * 30))).toBe(1440) }) + it('prefers a sanitised session title and preserves the exact project fallback when it is missing or blank', () => { + 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 history = buildGranularHistory([project([ + { id: 'session-titled-123456', title: 'Refactor billing module', calls: [apiCall({ timestamp, cost: 1 })] }, + { id: 'session-absent-123457', calls: [apiCall({ timestamp, cost: 1 })] }, + { id: 'session-empty-123458', title: '', calls: [apiCall({ timestamp, cost: 1 })] }, + { id: 'session-blank-123459', title: ' \t\n ', calls: [apiCall({ timestamp, cost: 1 })] }, + ])], { start, end }, end) + + expect(history.sessionSeries.map(series => series.label)).toEqual([ + 'Refactor billing module · sessio…3456 (claude)', + 'repos/demo · sessio…3457 (claude)', + 'repos/demo · sessio…3458 (claude)', + 'repos/demo · sessio…3459 (claude)', + ]) + }) + + it('keeps identical session titles distinguishable with the short session id', () => { + 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 history = buildGranularHistory([project([ + { id: 'session-111111', title: 'Refactor billing module', calls: [apiCall({ timestamp, cost: 1 })] }, + { id: 'session-222222', title: 'Refactor billing module', calls: [apiCall({ timestamp, cost: 1 })] }, + ])], { start, end }, end) + + expect(history.sessionSeries.map(series => series.id)).toEqual(['session_0', 'session_1']) + expect(history.sessionSeries.map(series => series.label)).toEqual([ + 'Refactor billing module · sessio…1111 (claude)', + 'Refactor billing module · sessio…2222 (claude)', + ]) + expect(new Set(history.sessionSeries.map(series => series.label)).size).toBe(2) + }) + + 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') + const end = new Date('2026-07-15T23:59:59.999Z') + const history = buildGranularHistory([project([{ + id: 'session-sanitised-123456', + title: '\x1b[31mRefactor\x1b[0m\t billing\nmodule\x00', + calls: [apiCall({ timestamp, cost: 1 })], + }])], { start, end }, end) + + expect(history.sessionSeries[0]?.label).toBe('Refactor billing module · sessio…3456 (claude)') + expect(history.sessionSeries[0]?.label).not.toContain('\x1b') + expect(history.sessionSeries[0]?.label).not.toContain('\x00') + }) + + it('caps over-long session titles before putting them in the legend label', () => { + 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 history = buildGranularHistory([project([{ + id: 'session-long-title-123456', + title: 'x'.repeat(200), + calls: [apiCall({ timestamp, cost: 1 })], + }])], { start, end }, end) + + expect(history.sessionSeries[0]?.label).toBe('x'.repeat(80) + ' · sessio…3456 (claude)') + }) + it('fills idle buckets and keeps separate model and session lines from real call timestamps', () => { const start = new Date('2026-07-15T00:00:00.000Z') const end = new Date('2026-07-15T23:59:59.999Z') From cbb09d0170fad6804e1937ed1ffbba0e7de1cd96 Mon Sep 17 00:00:00 2001 From: "Dongmin,Yu" Date: Tue, 18 Aug 2026 10:30:11 +0900 Subject: [PATCH 24/85] fix(optimize): scope transcript-derived findings to the selected provider (#1003) * fix(optimize): scope transcript-derived findings to the selected provider scanSessions() always ran discoverAllSessions('claude'), so every finding it feeds was computed from Claude transcripts regardless of --provider, while the header (sessions, calls, cost) came from the already-filtered projects. Under --provider codex the two described different providers, and the Claude-derived numbers read as the selected provider's. Skip the scan when the filter excludes Claude, and skip the detectors it feeds rather than handing them an empty scan: emptiness reads as "never invoked", so an empty scan turned every skill, agent and command into a reported ghost. Findings derived from projects (MCP tool coverage, capability reliability, low-worth sessions, context bloat, outliers, model recommendations) already filter correctly and still run. The result cache key now carries the provider, since the provider decides whether the scan runs at all. * fix(optimize): thread the provider through the apply, aggregator and TUI scans The previous commit fixed one of four scanAndDetect callers. The other three carry a provider filter and dropped it: - act/optimize-apply.ts: `optimize --apply` branches in main.ts before the code that threads it, so a Codex-scoped run planned applies off Claude findings. This is the worst of the three because `unused-skills` is appliable and its plan moves directories out of ~/.claude/skills. - usage-aggregator.ts: AggregateOpts.provider was already honoured for the usage half but not for the optimize half, so the menubar, desktop and web surfaces carried the same mismatch. - dashboard.tsx: `p` cycles activeProvider and `o` opens optimize off the same state, so the TUI could show Claude findings under a Codex view. activeProvider joins the callback deps; reloadData already clears optimizeResult on a provider switch, so no extra invalidation is needed. Covered by a dry-run test on the apply path: under `provider: 'codex'` a fake home holding an uninvoked skill must plan nothing, and under 'claude' the same fixture must still plan the archive. --- src/act/optimize-apply.ts | 6 ++- src/dashboard.tsx | 4 +- src/main.ts | 6 +-- src/optimize.ts | 68 +++++++++++++++++++--------- src/usage-aggregator.ts | 2 +- tests/optimize-fs.test.ts | 93 ++++++++++++++++++++++++++++++++++++++- 6 files changed, 149 insertions(+), 30 deletions(-) diff --git a/src/act/optimize-apply.ts b/src/act/optimize-apply.ts index 5b90685a..b72af509 100644 --- a/src/act/optimize-apply.ts +++ b/src/act/optimize-apply.ts @@ -13,6 +13,10 @@ export type ApplyOptions = { yes?: boolean dryRun?: boolean only?: string + // Mirrors `optimize --provider`. The scan below only reads Claude + // transcripts, and this path does not just report findings, it plans and + // applies them - a Codex-scoped run must never offer to edit ~/.claude. + provider?: string actionsDir?: string ctx?: PlanContext // Test seams: crafted findings skip the session scan; streams default to @@ -103,7 +107,7 @@ export async function runOptimizeApply( let costRate = opts.costRate ?? 0 if (!findings) { errout.write(chalk.dim(' Analyzing your sessions...\n')) - const scanned = await scanAndDetect(projects, dateRange) + const scanned = await scanAndDetect(projects, dateRange, opts.provider) findings = scanned.findings costRate = scanned.costRate } diff --git a/src/dashboard.tsx b/src/dashboard.tsx index b66b41cf..9cc3db50 100644 --- a/src/dashboard.tsx +++ b/src/dashboard.tsx @@ -1460,14 +1460,14 @@ export function InteractiveDashboard({ initialProjects, initialDailyHistoryProje const generation = reloadGenerationRef.current setOptimizeLoading(true) try { - const result = await scanAndDetect(projects, currentRange()) + const result = await scanAndDetect(projects, currentRange(), activeProvider) if (reloadGenerationRef.current === generation) setOptimizeResult(result) } catch (error) { console.error(error) } finally { if (reloadGenerationRef.current === generation) setOptimizeLoading(false) } - }, [optimizeAvailable, projects, currentRange, optimizeLoading, optimizeResult]) + }, [optimizeAvailable, projects, currentRange, optimizeLoading, optimizeResult, activeProvider]) useEffect(() => { const refreshIntervalMs = getRefreshIntervalMs(refreshSeconds ?? 0) diff --git a/src/main.ts b/src/main.ts index d201920b..aa3063a4 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1831,7 +1831,7 @@ program const projects = await parseAllSessions(range, opts.provider) if (opts.apply) { const { runOptimizeApply } = await import('./act/optimize-apply.js') - await runOptimizeApply(projects, range, { yes: opts.yes, dryRun: opts.dryRun, only: opts.only }) + await runOptimizeApply(projects, range, { yes: opts.yes, dryRun: opts.dryRun, only: opts.only, provider: opts.provider }) return } assertFormat(format, ['text', 'json'], 'optimize') @@ -1849,9 +1849,9 @@ program appliedHeader = buildOptimizeAppliedHeader(applied) ?? undefined previouslyApplied = applied.appliedByFinding } catch { /* the header is optional; never block the findings */ } - await runOptimize(projects, label, range, { format, appliedHeader, previouslyApplied }) + await runOptimize(projects, label, range, { format, appliedHeader, previouslyApplied, provider: opts.provider }) } else { - await runOptimize(projects, label, range, { format }) + await runOptimize(projects, label, range, { format, provider: opts.provider }) } }) diff --git a/src/optimize.ts b/src/optimize.ts index 63d49330..190a8a8b 100644 --- a/src/optimize.ts +++ b/src/optimize.ts @@ -551,7 +551,18 @@ export async function scanJsonlFile( return { calls, cwds, apiCalls, userMessages } } -async function scanSessions(dateRange?: DateRange): Promise { +// The session scan reads Claude Code transcripts only, so a `--provider` that +// excludes Claude leaves nothing for it to do. Callers must also skip the +// detectors it feeds (see `claudeOnly` in scanAndDetect) — the empty scan +// returned here is an absence of measurement, not a measurement of absence. +export function providerCoversClaude(provider?: string): boolean { + return !provider || provider === 'all' || provider === 'claude' +} + +async function scanSessions(dateRange?: DateRange, provider?: string): Promise { + if (!providerCoversClaude(provider)) { + return { toolCalls: [], projectCwds: new Set(), apiCalls: [], userMessages: [] } + } const sources = await discoverAllSessions('claude') const allCalls: ToolCall[] = [] const allCwds = new Set() @@ -2977,7 +2988,7 @@ export function computeInputCostRate(projects: ProjectSummary[]): number { type CacheEntry = { data: OptimizeResult; ts: number } const resultCache = new Map() -export function cacheKey(projects: ProjectSummary[], dateRange: DateRange | undefined): string { +export function cacheKey(projects: ProjectSummary[], dateRange: DateRange | undefined, provider?: string): string { const dr = dateRange ? `${dateRange.start.getTime()}-${dateRange.end.getTime()}` : 'all' // Fingerprint enough of the dataset that two materially different inputs // cannot collide onto one cached OptimizeResult. Project count + api-call @@ -2994,23 +3005,27 @@ export function cacheKey(projects: ProjectSummary[], dateRange: DateRange | unde } // Costs scaled to whole micro-dollars so float jitter cannot thrash the key. const fingerprint = `${projects.length}:${calls}:${Math.round(cost * 1e6)}:${Math.round(savings * 1e6)}:${Math.round(proxied * 1e6)}` - return `${dr}:${fingerprint}` + // The provider decides whether the Claude session scan runs at all, so two + // filters that happen to share a project fingerprint must not share a result. + return `${provider ?? 'all'}:${dr}:${fingerprint}` } export async function scanAndDetect( projects: ProjectSummary[], dateRange?: DateRange, + provider?: string, ): Promise { if (projects.length === 0) { return { findings: [], costRate: 0, healthScore: 100, healthGrade: 'A', modelRecommendations: [] } } - const key = cacheKey(projects, dateRange) + const key = cacheKey(projects, dateRange, provider) const cached = resultCache.get(key) if (cached && Date.now() - cached.ts < RESULT_CACHE_TTL_MS) return cached.data const costRate = computeInputCostRate(projects) - const { toolCalls, projectCwds, apiCalls, userMessages } = await scanSessions(dateRange) + const scanCoversClaude = providerCoversClaude(provider) + const { toolCalls, projectCwds, apiCalls, userMessages } = await scanSessions(dateRange, provider) const mcpCoverage = aggregateMcpCoverage(projects) const findings: WasteFinding[] = [] @@ -3025,35 +3040,44 @@ export async function scanAndDetect( ) const firstSessionIds = findYoungProjectFirstSessionIds(projects) const outlierExclusions = new Set([...lowWorthSessionIds, ...contextBloatVisibleIds, ...firstSessionIds]) + // Detectors fed by the session scan or by `~/.claude` config only mean + // anything when the run covers Claude. Under a different `--provider` they + // must be skipped rather than handed an empty scan: emptiness reads as + // "never invoked", so every skill, agent and command would be reported as + // unused when it was simply not measured. + const claudeOnly = (detect: () => WasteFinding | null): (() => WasteFinding | null) => + scanCoversClaude ? detect : () => null const syncDetectors: Array<() => WasteFinding | null> = [ - () => detectCacheBloat(apiCalls, projects, dateRange), - () => detectLowReadEditRatio(toolCalls), - () => detectJunkReads(toolCalls, dateRange), - () => detectDuplicateReads(toolCalls, dateRange), - () => detectUnusedMcp(toolCalls, projects, projectCwds, mcpCoverage), + claudeOnly(() => detectCacheBloat(apiCalls, projects, dateRange)), + claudeOnly(() => detectLowReadEditRatio(toolCalls)), + claudeOnly(() => detectJunkReads(toolCalls, dateRange)), + claudeOnly(() => detectDuplicateReads(toolCalls, dateRange)), + claudeOnly(() => detectUnusedMcp(toolCalls, projects, projectCwds, mcpCoverage)), () => detectMcpToolCoverage(projects, mcpCoverage), () => detectMcpProfileAdvisor(projects, mcpCoverage), // mcp-deferral-gaps family (#614): detection only, no apply plans yet. - () => detectMcpDeferralOff(toolCalls, projects, projectCwds, apiCalls), - () => detectMcpAlwaysLoadHygiene(projects, projectCwds, apiCalls, mcpCoverage), - () => detectMcpDeferThreshold(projects, projectCwds), + claudeOnly(() => detectMcpDeferralOff(toolCalls, projects, projectCwds, apiCalls)), + claudeOnly(() => detectMcpAlwaysLoadHygiene(projects, projectCwds, apiCalls, mcpCoverage)), + claudeOnly(() => detectMcpDeferThreshold(projects, projectCwds)), () => detectCapabilityReliability(projects), () => detectLowWorthSessions(projects), () => detectContextBloat(projects, lowWorthSessionIds), () => detectSessionOutliers(projects, outlierExclusions), - () => detectBloatedClaudeMd(projectCwds), - () => detectBashBloat(), + claudeOnly(() => detectBloatedClaudeMd(projectCwds)), + claudeOnly(() => detectBashBloat()), ] for (const detect of syncDetectors) { const finding = detect() if (finding) findings.push(finding) } - const ghostResults = await Promise.all([ - detectGhostAgents(toolCalls), - detectGhostSkills(toolCalls), - detectGhostCommands(userMessages), - ]) + const ghostResults = scanCoversClaude + ? await Promise.all([ + detectGhostAgents(toolCalls), + detectGhostSkills(toolCalls), + detectGhostCommands(userMessages), + ]) + : [] for (const f of ghostResults) if (f) findings.push(f) findings.sort((a, b) => urgencyScore(b) - urgencyScore(a)) @@ -3281,7 +3305,7 @@ export async function runOptimize( projects: ProjectSummary[], periodLabel: string, dateRange?: DateRange, - opts: { format?: 'text' | 'json'; appliedHeader?: string; previouslyApplied?: Record } = {}, + opts: { format?: 'text' | 'json'; appliedHeader?: string; previouslyApplied?: Record; provider?: string } = {}, ): Promise { const format = opts.format ?? 'text' if (projects.length === 0 && format === 'text') { @@ -3293,7 +3317,7 @@ export async function runOptimize( process.stderr.write(chalk.dim(' Analyzing your sessions...\n')) } - const result = await scanAndDetect(projects, dateRange) + const result = await scanAndDetect(projects, dateRange, opts.provider) const { findings, costRate, healthScore, healthGrade } = result const sessions = projects.flatMap(p => p.sessions) const periodCost = projects.reduce((s, p) => s + p.totalCostUSD, 0) diff --git a/src/usage-aggregator.ts b/src/usage-aggregator.ts index 82aeb732..4350413b 100644 --- a/src/usage-aggregator.ts +++ b/src/usage-aggregator.ts @@ -935,7 +935,7 @@ export async function buildMenubarPayloadForRange(periodInfo: PeriodInfo, opts: } })() - const optimize = opts.optimize === false ? null : await scanAndDetect(scanProjects, scanRange) + const optimize = opts.optimize === false ? null : await scanAndDetect(scanProjects, scanRange, opts.provider) const granularRange = opts.daysSelection?.range ?? scanRange const granularHistory = opts.timeline === false ? undefined : buildGranularHistory(scanProjects, granularRange) return buildMenubarPayload(currentData, providers, optimize, dailyHistory, retryTax, routingWaste, breakdowns, claudeConfigs, granularHistory) diff --git a/tests/optimize-fs.test.ts b/tests/optimize-fs.test.ts index 2364e08a..7f9d76ea 100644 --- a/tests/optimize-fs.test.ts +++ b/tests/optimize-fs.test.ts @@ -1,4 +1,5 @@ -import { describe, it, expect, afterAll, beforeEach, vi } from 'vitest' +import { describe, it, expect, afterAll, afterEach, beforeEach, vi } from 'vitest' +import { Writable } from 'node:stream' import { mkdtempSync, rmSync, mkdirSync, writeFileSync, utimesSync } from 'fs' import { tmpdir } from 'os' import { join } from 'path' @@ -29,6 +30,8 @@ import { estimateContextBudget, discoverProjectCwd, } from '../src/context-budget.js' +import type { ProjectSummary } from '../src/types.js' +import { runOptimizeApply } from '../src/act/optimize-apply.js' // ============================================================================ // Helpers for filesystem fixtures @@ -371,6 +374,94 @@ describe('scanAndDetect', () => { expect(result.healthGrade).toBe('A') expect(result.costRate).toBe(0) }) + + // The session scan only ever reads Claude Code transcripts, so under a + // non-Claude --provider it used to report Claude-derived findings beside a + // header scoped to the other provider - e.g. `optimize --provider codex` + // printing a read/edit ratio counted from Claude sessions. + describe('provider scoping', () => { + // These fixtures live in the shared fake home, so they have to come back + // out: later suites in this file assert on an otherwise empty ~/.claude. + const CLAUDE_DIR = join(FAKE_HOME_FOR_MOCK, '.claude') + afterEach(() => { + for (const sub of ['projects', 'skills']) { + rmSync(join(CLAUDE_DIR, sub), { recursive: true, force: true }) + } + }) + + function claudeSessionWithEditHeavyTurns(): void { + const projectDir = join(CLAUDE_DIR, 'projects', 'provider-scope') + mkdirSync(projectDir, { recursive: true }) + const now = new Date().toISOString() + const entry = (name: string, file: string) => JSON.stringify({ + type: 'assistant', timestamp: now, + message: { content: [{ type: 'tool_use', name, input: { file_path: file } }] }, + }) + const lines = [entry('Read', '/src/a.ts')] + for (let i = 0; i < 12; i++) lines.push(entry('Edit', `/src/f${i}.ts`)) + writeFileSync(join(projectDir, 'session.jsonl'), lines.join('\n')) + } + + // scanAndDetect memoises on (provider, range, project fingerprint) for 60s, + // and the cache is module-level, so tests that differ only in what is on + // disk would serve each other's results. `seed` moves the fingerprint so + // each case scans for real. + function projectFixture(seed: number): ProjectSummary { + return { + project: 'provider-scope', + projectPath: '/tmp/provider-scope', + sessions: [], + totalCostUSD: 1, + totalApiCalls: 13 + seed, + } as unknown as ProjectSummary + } + + it('reports transcript-derived findings when scoped to claude', async () => { + claudeSessionWithEditHeavyTurns() + const result = await scanAndDetect([projectFixture(1)], undefined, 'claude') + expect(result.findings.map(f => f.id)).toContain('read-edit-ratio') + }) + + it('omits transcript-derived findings when scoped to another provider', async () => { + claudeSessionWithEditHeavyTurns() + mkdirSync(join(CLAUDE_DIR, 'skills', 'never-invoked'), { recursive: true }) + writeFileSync(join(CLAUDE_DIR, 'skills', 'never-invoked', 'SKILL.md'), '# skill\n') + + const result = await scanAndDetect([projectFixture(2)], undefined, 'codex') + const ids = result.findings.map(f => f.id) + + expect(ids).not.toContain('read-edit-ratio') + // An unmeasured skill must not be reported as an unused one: the scan + // returns nothing under this filter, which is not evidence of disuse. + expect(ids).not.toContain('unused-skills') + }) + + // The apply path reaches scanAndDetect through its own entry point, so it + // needs its own guard: `unused-skills` is appliable, and its plan moves + // directories out of ~/.claude/skills. Reporting a Codex-labelled finding + // is a wrong number; offering to archive every skill off one is a wrong + // number with side effects. + async function applyDryRun(provider: string): Promise { + const chunks: string[] = [] + const output = new Writable({ write(c, _e, cb) { chunks.push(String(c)); cb() } }) + const errorOutput = new Writable({ write(_c, _e, cb) { cb() } }) + await runOptimizeApply([projectFixture(3)], undefined, { provider, dryRun: true, output, errorOutput }) + return chunks.join('') + } + + it('plans no applies from Claude findings when scoped to another provider', async () => { + claudeSessionWithEditHeavyTurns() + mkdirSync(join(CLAUDE_DIR, 'skills', 'never-invoked'), { recursive: true }) + writeFileSync(join(CLAUDE_DIR, 'skills', 'never-invoked', 'SKILL.md'), '# skill\n') + + const codex = await applyDryRun('codex') + expect(codex).toContain('No appliable config-class fixes') + expect(codex).not.toContain('never-invoked') + + const claude = await applyDryRun('claude') + expect(claude).toContain('never-invoked') + }) + }) }) // ============================================================================ From 7e421b14bb2ded2075312e9e0ec86105c5480e9b Mon Sep 17 00:00:00 2001 From: "Emre K." <110906681+kocaemre@users.noreply.github.com> Date: Tue, 18 Aug 2026 04:38:19 +0300 Subject: [PATCH 25/85] Add unpriced filter to models report (#985) --- src/main.ts | 13 ++++++-- tests/models-report.test.ts | 65 ++++++++++++++++++++++++++++++++++++- 2 files changed, 75 insertions(+), 3 deletions(-) diff --git a/src/main.ts b/src/main.ts index aa3063a4..a8af9bba 100644 --- a/src/main.ts +++ b/src/main.ts @@ -2075,6 +2075,7 @@ program .option('--by-agent', 'One row per (provider, model, agent) instead of one row per (provider, model). Claude subagent transcripts only; other providers and main sessions bucket under "main"') .option('--top ', 'Show only the top N rows', (v: string) => parseInt(v, 10)) .option('--min-cost ', 'Hide rows below this cost threshold', (v: string) => parseFloat(v)) + .option('--unpriced', 'Show only models with usage that currently price at $0') .option('--no-totals', 'Suppress the footer totals row') .option('--format ', 'Output format: table, markdown, json, csv', 'table') .action(async (opts) => { @@ -2099,13 +2100,21 @@ program } const projects = await parseAllSessions(range, opts.provider) - const rows = await aggregateModels(projects, { + let rows = await aggregateModels(projects, { byTask: !!opts.byTask, byAgent: !!opts.byAgent, taskFilter: opts.task, topN: typeof opts.top === 'number' && Number.isFinite(opts.top) ? opts.top : undefined, - minCost: typeof opts.minCost === 'number' && Number.isFinite(opts.minCost) ? opts.minCost : 0.01, + minCost: typeof opts.minCost === 'number' && Number.isFinite(opts.minCost) ? opts.minCost : (opts.unpriced ? 0 : 0.01), }) + if (opts.unpriced) { + rows = rows.filter(row => findUnpricedModels([{ + model: row.model, + calls: row.calls, + cost: row.costUSD, + tokens: row.totalTokens, + }]).length > 0) + } const fmt = (opts.format ?? 'table').toLowerCase() if (rows.length === 0 && (fmt === 'table' || fmt === 'markdown')) { diff --git a/tests/models-report.test.ts b/tests/models-report.test.ts index 33317fd8..8808ca58 100644 --- a/tests/models-report.test.ts +++ b/tests/models-report.test.ts @@ -1,6 +1,9 @@ +import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' import { spawnSync } from 'node:child_process' -import { describe, it, expect } from 'vitest' +import { describe, it, expect, vi } from 'vitest' import chalk from 'chalk' import stripAnsi from 'strip-ansi' @@ -713,6 +716,66 @@ describe('renderCsv', () => { }) describe('models CLI breakdown flags', () => { + vi.setConfig({ testTimeout: 30_000 }) + + it('filters the models report to unpriced rows', async () => { + const home = await mkdtemp(join(tmpdir(), 'codeburn-models-unpriced-')) + try { + const projectDir = join(home, '.claude', 'projects', 'models-unpriced') + await mkdir(projectDir, { recursive: true }) + await writeFile(join(projectDir, 'session.jsonl'), [ + JSON.stringify({ + type: 'user', + sessionId: 'models-unpriced-session', + timestamp: '2026-05-09T00:00:00.000Z', + cwd: '/tmp/models-unpriced', + message: { role: 'user', content: 'Use one priced and one unpriced model.' }, + }), + JSON.stringify({ + type: 'assistant', + sessionId: 'models-unpriced-session', + timestamp: '2026-05-09T00:01:00.000Z', + cwd: '/tmp/models-unpriced', + message: { + id: 'priced', + type: 'message', + role: 'assistant', + model: 'claude-sonnet-4-6', + content: [{ type: 'text', text: 'priced' }], + usage: { input_tokens: 1000, output_tokens: 100, cache_read_input_tokens: 0, cache_creation_input_tokens: 0 }, + }, + }), + JSON.stringify({ + type: 'assistant', + sessionId: 'models-unpriced-session', + timestamp: '2026-05-09T00:02:00.000Z', + cwd: '/tmp/models-unpriced', + message: { + id: 'unpriced', + type: 'message', + role: 'assistant', + model: 'zz-unpriced-frontier-model', + content: [{ type: 'text', text: 'unpriced' }], + usage: { input_tokens: 2000, output_tokens: 200, cache_read_input_tokens: 0, cache_creation_input_tokens: 0 }, + }, + }), + ].join('\n') + '\n') + + const res = spawnSync( + process.execPath, + ['--import', 'tsx', 'src/cli.ts', 'models', '--unpriced', '--from', '2026-05-09', '--to', '2026-05-09', '--provider', 'claude', '--format', 'json'], + { cwd: process.cwd(), env: { ...process.env, HOME: home, CLAUDE_CONFIG_DIR: join(home, '.claude'), CODEBURN_CACHE_DIR: join(home, '.cache', 'codeburn'), TZ: 'UTC' }, encoding: 'utf-8', timeout: 30_000 }, + ) + + expect(res.status, `stdout: ${res.stdout}\nstderr: ${res.stderr}`).toBe(0) + const rows = JSON.parse(res.stdout) as Array<{ model: string; calls: number }> + expect(rows.map(row => row.model)).toEqual(['zz-unpriced-frontier-model']) + expect(rows[0]?.calls).toBe(1) + } finally { + await rm(home, { recursive: true, force: true }) + } + }) + it('rejects --by-task and --by-agent together with a clear error and exit 1', () => { const res = spawnSync( process.execPath, From 11173758b5721a9e5dd25af6198076ac2ede3400 Mon Sep 17 00:00:00 2001 From: ozymandiashh <234437643+ozymandiashh@users.noreply.github.com> Date: Tue, 18 Aug 2026 04:40:21 +0300 Subject: [PATCH 26/85] fix(models): let --unpriced survive --top, and document the flag `--top` is applied inside `aggregateModels`, before the unpriced filter runs, on rows sorted by cost + savings descending. Unpriced rows are $0 on both -- `findUnpricedModels` excludes anything carrying a local-savings baseline -- so they always sort last, and any `--top N` smaller than the number of priced models removed exactly the rows `--unpriced` exists to show. In table form the user then read "No model usage found for the selected period", which is the wrong answer twice over: they do have unpriced models, and nothing tells them the two flags fought. The slice now happens after the filter when `--unpriced` is set. Also adds the flag to the README models table and a changelog entry -- it was added for #969, and a filter nobody can discover does not help anyone find their unpriced models. Follow-up to #985. --- CHANGELOG.md | 4 +++ README.md | 1 + src/main.ts | 9 ++++++- tests/models-report.test.ts | 49 +++++++++++++++++++++++++++++++++++++ 4 files changed, 62 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9242c070..7a68d13d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,9 @@ ## Unreleased +### Added +- **`codeburn models --unpriced`.** The dashboard warns about models that price at $0 and points at `codeburn model-alias`, but the list itself was hard to get out of the TUI. This filters the plain-stdout `models` report to exactly those rows, reusing `findUnpricedModels` so local, free, aliased and price-overridden models are treated the same way the warning treats them, and defaulting that mode's min-cost to 0 so $0 rows are not pre-filtered away. Thanks @kocaemre. (#969) + ### Changed - **Codex rollouts parse across worker threads too, and the workload gate now takes bytes or files.** Codex is the bigger half of a real cold parse — a 4 GB rollout corpus against 1.8 GB of Claude sessions — and it was still decoding one file at a time. A whole-file rollout decode now runs on the same pool, against an empty dedup set, and comes back with the calls, the dedup keys it claimed, and the codex-cache entry it would have written; the parent installs all three in the serial loop's order, so `codex-results.json` and every payload come out byte-identical to a serial run. Cross-file state stays where it was: a forked rollout replaying its parent's token_count history collides on the parent's keys and is re-parsed in-process, and no worker ever touches the cache module's per-directory state. Files the Codex cache can serve exactly or resume into from a byte offset never reach a worker — they read a few KB and the resume state belongs to the parent. The workload gate is now pending BYTES alone (200 MB), not file count: 250 pending files holding under a megabyte between them spawned threads that made the run ~5% slower, while a few hundred huge rollouts were being turned away. The count takes `max(pendingFiles / 50, pendingBytes / 200 MB)`, and the per-thread memory budget is derived per parse as `clamp(256 MB, 2 × average pending file + 128 MB, 1 GB)` rather than a flat 256 MB — a 260 MB rollout peaks near 430 MB in its worker and scales linearly with the pool, so the flat figure over-subscribed exactly the workload this adds. The decision is per provider, and at most one pool is alive at a time. - **A large cold Claude parse now runs across worker threads.** Reading, decoding and line-parsing a session JSONL is per-file work that never touches anything shared, so it moves onto `worker_threads`; each worker ships its parsed turns back as a JSON string and the parent installs them in the exact order the serial loop would. Everything with cross-file state — the streaming-message dedup, canonical project paths, spawn links, PR correlation, progress saves — stays on the main thread, and a file whose message ids were already claimed by an earlier file (or whose worker failed) is simply re-parsed in-process, so the session cache and every payload are identical either way. On a 6 GB corpus a cold `status` drops from 27.5s to 14.8s with peak RSS up 2.27 GB → 2.52 GB. Threads only engage for a genuinely large cold parse: never with under 200 MB behind the pending whole-file re-parses, 2 or fewer cores, or under 4 GB of available memory — so warm and incremental runs are untouched and spawn nothing. Otherwise the count is `min(cores - 1, min(0.25 × available, 2 GB) / 256 MB, pendingFiles / 50)`, where available is `process.availableMemory()` (cgroup-aware in containers) rather than free memory, which on macOS reports free pages and would switch the feature on and off between runs. `CODEBURN_PARSE_WORKERS=0` forces the serial parse and `CODEBURN_PARSE_WORKERS=N` forces N (capped at the core count), both bypassing every gate; `CODEBURN_VERBOSE=1` prints the resolved count and why. @@ -15,6 +18,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 +- **`codeburn models --unpriced --top N` returned nothing.** `--top` is applied inside `aggregateModels`, before the unpriced filter, on rows sorted by cost + savings descending — and unpriced rows are $0 on both, so they sorted last and the slice removed exactly the rows the flag exists to show. A user with unpriced models was told they had none. The slice now runs after the filter. (#969) - **Bash command splitting was quadratic on long whitespace-heavy commands.** The separator regex retried its leading `\s*` from every offset; matching the separator alone and widening over whitespace by hand makes cold parse ~24% and warm ~40% faster on large corpora, output unchanged. - **Cold parse no longer retains full message bodies through cached previews.** `flatSlice` skipped its Buffer round-trip for strings already within the bound, but provider adapters pre-truncate user-message previews with `.slice(0, 500)` before the cache-site call — those pre-sliced views are still V8 SlicedStrings pinning their large parent, so the retention that OOM'd cold parses of large histories survived. The round-trip now always runs. - **Kiro sessions carry the real `projectPath`** (CLI meta.cwd, v2 `workspacePaths[0]`, workspace sessions' `workspaceDirectory`), so git-repo attribution can resolve them; previously they were attribution-blind. Bumps the kiro parse version, so the first run after upgrade re-parses kiro history once, and kiro sessions in linked git worktrees now group under the main repo. diff --git a/README.md b/README.md index 2159e1da..da3bc888 100644 --- a/README.md +++ b/README.md @@ -484,6 +484,7 @@ Sync sends token counts, costs, models, and projects, never prompts or code. Thi | `codeburn models --by-task` | Break each model into per-task-type rows | | `codeburn models --by-agent` | Break each model into per-agent rows: which agent drove which model's spend (`(main)` covers non-agent sessions; `--min-cost 0` shows sub-cent agents) | | `codeburn models --top 10` | Only the 10 most expensive models | +| `codeburn models --unpriced` | Only models with usage that currently price at $0 — the copyable form of the unpriced-models warning | | `codeburn models --format markdown` | Emit a paste-friendly markdown table | | `codeburn models --task feature` | Filter to feature-development work | | `codeburn models --provider claude` | Filter to a single provider | diff --git a/src/main.ts b/src/main.ts index a8af9bba..192cb72b 100644 --- a/src/main.ts +++ b/src/main.ts @@ -2100,11 +2100,17 @@ program } const projects = await parseAllSessions(range, opts.provider) + const topN = typeof opts.top === 'number' && Number.isFinite(opts.top) ? opts.top : undefined let rows = await aggregateModels(projects, { byTask: !!opts.byTask, byAgent: !!opts.byAgent, taskFilter: opts.task, - topN: typeof opts.top === 'number' && Number.isFinite(opts.top) ? opts.top : undefined, + // `aggregateModels` slices to topN on rows sorted by cost + savings + // descending. Unpriced rows are $0 on both (findUnpricedModels excludes + // anything with a local-savings baseline), so they always sort last and + // `--top` would remove exactly the rows `--unpriced` exists to show. + // Take the whole set here and slice after filtering instead. + topN: opts.unpriced ? undefined : topN, minCost: typeof opts.minCost === 'number' && Number.isFinite(opts.minCost) ? opts.minCost : (opts.unpriced ? 0 : 0.01), }) if (opts.unpriced) { @@ -2114,6 +2120,7 @@ program cost: row.costUSD, tokens: row.totalTokens, }]).length > 0) + if (topN !== undefined) rows = rows.slice(0, topN) } const fmt = (opts.format ?? 'table').toLowerCase() diff --git a/tests/models-report.test.ts b/tests/models-report.test.ts index 8808ca58..426f3eaa 100644 --- a/tests/models-report.test.ts +++ b/tests/models-report.test.ts @@ -776,6 +776,55 @@ describe('models CLI breakdown flags', () => { } }) + // `--top` is applied inside aggregateModels, before the unpriced filter runs, + // on rows sorted by cost + savings descending. Unpriced rows are $0 on both, + // so they sort last and a small --top removed exactly the rows --unpriced + // exists to surface: the user was told they had no unpriced models. + it('keeps unpriced rows when --unpriced is combined with --top', async () => { + const home = await mkdtemp(join(tmpdir(), 'codeburn-models-unpriced-top-')) + try { + const projectDir = join(home, '.claude', 'projects', 'models-unpriced-top') + await mkdir(projectDir, { recursive: true }) + const assistant = (id: string, model: string, timestamp: string, input: number) => JSON.stringify({ + type: 'assistant', + sessionId: 'models-unpriced-top-session', + timestamp, + cwd: '/tmp/models-unpriced-top', + message: { + id, type: 'message', role: 'assistant', model, + content: [{ type: 'text', text: id }], + usage: { input_tokens: input, output_tokens: 100, cache_read_input_tokens: 0, cache_creation_input_tokens: 0 }, + }, + }) + await writeFile(join(projectDir, 'session.jsonl'), [ + JSON.stringify({ + type: 'user', + sessionId: 'models-unpriced-top-session', + timestamp: '2026-05-09T00:00:00.000Z', + cwd: '/tmp/models-unpriced-top', + message: { role: 'user', content: 'Two priced models outrank the unpriced one.' }, + }), + // Both priced models cost more than the $0 unpriced row, so they take + // both --top slots unless the filter runs first. + assistant('opus', 'claude-opus-4-6', '2026-05-09T00:01:00.000Z', 5000), + assistant('sonnet', 'claude-sonnet-4-6', '2026-05-09T00:02:00.000Z', 3000), + assistant('unpriced', 'zz-unpriced-frontier-model', '2026-05-09T00:03:00.000Z', 2000), + ].join('\n') + '\n') + + const res = spawnSync( + process.execPath, + ['--import', 'tsx', 'src/cli.ts', 'models', '--unpriced', '--top', '2', '--from', '2026-05-09', '--to', '2026-05-09', '--provider', 'claude', '--format', 'json'], + { cwd: process.cwd(), env: { ...process.env, HOME: home, CLAUDE_CONFIG_DIR: join(home, '.claude'), CODEBURN_CACHE_DIR: join(home, '.cache', 'codeburn'), TZ: 'UTC' }, encoding: 'utf-8', timeout: 30_000 }, + ) + + expect(res.status, `stdout: ${res.stdout}\nstderr: ${res.stderr}`).toBe(0) + const rows = JSON.parse(res.stdout) as Array<{ model: string }> + expect(rows.map(row => row.model)).toEqual(['zz-unpriced-frontier-model']) + } finally { + await rm(home, { recursive: true, force: true }) + } + }) + it('rejects --by-task and --by-agent together with a clear error and exit 1', () => { const res = spawnSync( process.execPath, From b6a9622e07f99431ff2113b5b21eb963360c12f0 Mon Sep 17 00:00:00 2001 From: ozymandiashh <234437643+ozymandiashh@users.noreply.github.com> Date: Tue, 18 Aug 2026 04:54:22 +0300 Subject: [PATCH 27/85] fix(dash): disambiguate session legend labels, and stop $-patterns in the bootstrap Follow-ups to the title change on this branch, all from an adversarial pass over it. Two series could render byte-identical labels. sessionKey is provider/projectPath/sessionId, so the same session id under two project paths is two series -- a session resumed in a different cwd, or two Claude config directories holding the same project slug. The old label led with the project so they stayed apart; leading with the title dropped the only thing separating them. Labels are now built once at the end from per-key inputs: identical base labels get the short project label appended, and the residual case (two worktrees whose last two path segments match) falls back to the full path plus full session id. The label also depended on cache state. The hoist's comment claimed every input was constant per sessionKey, which is false: title and project name are not in the key, so two SessionSummary objects can share a key with different titles and whichever arrived first won. Reproduced through the real parser -- two config dirs, same project slug and session id, one with a title. Title candidates are now collected per key and a valid one is preferred over none, deterministically. The length cap sliced UTF-16 code units, so a title with an astral character on the boundary left a lone high surrogate in the payload. It counts code points now. Separately, and pre-existing rather than introduced here: the web dashboard injects its bootstrap with html.replace(string, string), so $-substitution patterns in the replacement are interpreted. A payload value containing $` or $' expands to the raw document around the match -- which contains -- so the '<' escaping upstream does not stop it. Device, project and model names already reached that sink; session titles only widen the surface. The injection is factored out and uses a replacer function. --- src/granular-history.ts | 103 ++++++++++++++++++++++++++++++--- src/web-dashboard.ts | 6 +- tests/granular-history.test.ts | 66 +++++++++++++++++++++ tests/web-dashboard.test.ts | 15 ++++- 4 files changed, 180 insertions(+), 10 deletions(-) diff --git a/src/granular-history.ts b/src/granular-history.ts index 2a4293b3..7d75e0bb 100644 --- a/src/granular-history.ts +++ b/src/granular-history.ts @@ -47,6 +47,20 @@ type RawBucket = { sessions: Map } +type SessionLabelInfo = { + provider: string + projectPath: string + projectNames: Set + sessionId: string + titleCandidates: Set +} + +type SessionLabelEntry = { + key: string + info: SessionLabelInfo + baseLabel: string +} + function nonNegative(value: number): number { return Number.isFinite(value) && value > 0 ? value : 0 } @@ -117,7 +131,73 @@ function cleanSessionTitle(title: string | undefined): string | undefined { .trim() if (!cleaned) return undefined - return cleaned.slice(0, MAX_SESSION_TITLE_LENGTH).trimEnd() || undefined + return Array.from(cleaned).slice(0, MAX_SESSION_TITLE_LENGTH).join('').trimEnd() || undefined +} + +function preferredProjectName(projectNames: Set): string { + return [...projectNames].sort()[0] ?? 'Unknown project' +} + +function preferredSessionTitle(titleCandidates: Set): string | undefined { + return [...titleCandidates] + .map(cleanSessionTitle) + .filter((title): title is string => title !== undefined) + .sort()[0] +} + +function buildSessionLabels(inputs: Map): Map { + const entries: SessionLabelEntry[] = [...inputs.entries()].map(([key, info]) => { + const sessionLabel = preferredSessionTitle(info.titleCandidates) + ?? shortProjectLabel(info.projectPath, preferredProjectName(info.projectNames)) + return { + key, + info, + baseLabel: `${sessionLabel} · ${shortSessionId(info.sessionId)} (${info.provider})`, + } + }) + const byBaseLabel = new Map() + for (const entry of entries) { + const group = byBaseLabel.get(entry.baseLabel) ?? [] + group.push(entry) + byBaseLabel.set(entry.baseLabel, group) + } + + const labels = new Map() + const usedLabels = new Set() + const setUniqueLabel = (entry: SessionLabelEntry, candidate: string): void => { + let label = candidate + if (usedLabels.has(label)) { + const identity = `${candidate} · ${entry.info.projectPath} · ${entry.info.sessionId}` + label = identity + let suffix = 2 + while (usedLabels.has(label)) label = `${identity} · ${suffix++}` + } + labels.set(entry.key, label) + usedLabels.add(label) + } + for (const group of byBaseLabel.values()) { + if (group.length === 1) { + setUniqueLabel(group[0]!, group[0]!.baseLabel) + continue + } + + const projectLabels = group.map(entry => shortProjectLabel(entry.info.projectPath, preferredProjectName(entry.info.projectNames))) + if (new Set(projectLabels).size === group.length) { + for (let i = 0; i < group.length; i++) { + const entry = group[i]! + setUniqueLabel(entry, `${entry.baseLabel} · ${projectLabels[i]}`) + } + continue + } + + // A short project label can still collide (for example two worktrees with + // the same final path segments). The full path + id is only used for this + // residual collision, and is unique because provider/path/id form the key. + for (const entry of group) { + setUniqueLabel(entry, `${entry.baseLabel} · ${entry.info.projectPath} · ${entry.info.sessionId}`) + } + } + return labels } // Legend labels: the sanitized project dir ("-Users-name-Projects-app") is @@ -205,7 +285,7 @@ export function buildGranularHistory( const modelTotals = new Map() const sessionTotals = new Map() const modelLabels = new Map() - const sessionLabels = new Map() + const sessionLabelInputs = new Map() let callCount = 0 for (const project of projects) { @@ -235,13 +315,19 @@ export function buildGranularHistory( add(modelTotals, modelKey, cost, tokens) add(sessionTotals, sessionKey, cost, tokens) modelLabels.set(modelKey, modelKey === '' ? 'Other model' : modelKey) - // Every input is constant for a given sessionKey (the provider is part - // of the key), so build the label once instead of re-sanitizing the - // title on every call in the session. - if (!sessionLabels.has(sessionKey)) { - const sessionLabel = cleanSessionTitle(session.title) ?? shortProjectLabel(project.projectPath, projectName) - sessionLabels.set(sessionKey, `${sessionLabel} · ${shortSessionId(session.sessionId)} (${call.provider})`) + // Collect raw metadata first. Titles are cleaned once per distinct + // session-key candidate after all calls are aggregated, so a late + // cache title can win without putting sanitisation on the call path. + const labelInfo = sessionLabelInputs.get(sessionKey) ?? { + provider: call.provider, + projectPath: project.projectPath, + projectNames: new Set(), + sessionId: session.sessionId, + titleCandidates: new Set(), } + labelInfo.projectNames.add(projectName) + if (session.title !== undefined) labelInfo.titleCandidates.add(session.title) + sessionLabelInputs.set(sessionKey, labelInfo) callCount++ } } @@ -252,6 +338,7 @@ export function buildGranularHistory( return { bucketMinutes, modelSeries: [], sessionSeries: [], points: [] } } + const sessionLabels = buildSessionLabels(sessionLabelInputs) const modelProjection = projectSeries(rawBuckets, 'models', modelTotals, modelLabels) const sessionProjection = projectSeries(rawBuckets, 'sessions', sessionTotals, sessionLabels) return { diff --git a/src/web-dashboard.ts b/src/web-dashboard.ts index fec1f9eb..400e7111 100644 --- a/src/web-dashboard.ts +++ b/src/web-dashboard.ts @@ -90,6 +90,10 @@ function openBrowser(url: string): void { } } +export function injectDashboardBootstrap(html: string, json: string): string { + return html.replace('\n \n ' + + const injected = injectDashboardBootstrap(html, json) + + expect(injected).toContain(`window.__CODEBURN_BOOTSTRAP__=${json}`) + expect(injected).toContain(`"name":"${payloadValue}"`) + }) +}) // Regression guard for the original bug: a bad `period` query used to hit // process.exit(1) and kill the long-running dashboard server. The handlers must From d81fca306686cfd64d8dc2f290665720ab1554d5 Mon Sep 17 00:00:00 2001 From: ozymandiashh <234437643+ozymandiashh@users.noreply.github.com> Date: Tue, 18 Aug 2026 05:34:51 +0300 Subject: [PATCH 28/85] fix(models): rank unpriced rows before --top slices them Filtering before the slice was necessary but not sufficient. Every unpriced row is $0 on both cost and savings -- findUnpricedModels excludes anything carrying a local-savings baseline -- so they all tie under aggregateModels' sort key, and Array#sort is stable. The surviving order was Map insertion order: the order each model's first assistant call appears in the transcript. So --unpriced --top N kept the N that showed up earliest, and a model holding almost all of the unpriced volume was dropped if it appeared late. findUnpricedModels already sorts by tokens descending, then calls, then model name, and the dashboard warning renders that order. It is now called once over the whole row set, its order becomes a rank index, and the rows are ranked before the slice -- so the CLI and the warning agree on which N, which is what the README row claims. In the breakdown modes several rows share one model, so they share that model's rank and N still counts rows. The previous test could not catch this: its fixture held one unpriced model, so --top 2 never truncated anything and deleting the slice line left the suite green. It now uses three unpriced models emitted in an order that differs from their size order, and asserts which two survive rather than only how many. --- CHANGELOG.md | 2 +- src/main.ts | 22 +++++++++++++++------- tests/models-report.test.ts | 21 ++++++++++----------- 3 files changed, 26 insertions(+), 19 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7a68d13d..3d305d47 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,7 +18,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 -- **`codeburn models --unpriced --top N` returned nothing.** `--top` is applied inside `aggregateModels`, before the unpriced filter, on rows sorted by cost + savings descending — and unpriced rows are $0 on both, so they sorted last and the slice removed exactly the rows the flag exists to show. A user with unpriced models was told they had none. The slice now runs after the filter. (#969) +- **`codeburn models --unpriced --top N` returned nothing for a `--top N` smaller than the number of priced models.** `--top` is applied inside `aggregateModels`, before the unpriced filter, on rows sorted cost-first — and unpriced rows are $0 on both, so they sorted last and the slice removed exactly the rows the flag exists to show. A user with unpriced models was told they had none. The slice now runs after the filter — and after ranking, because unpriced rows tie at $0 on both keys, so slicing them in aggregate order kept whichever models happened to appear earliest in the transcript rather than the largest. The order now matches the one the unpriced-models warning shows. (#969) - **Bash command splitting was quadratic on long whitespace-heavy commands.** The separator regex retried its leading `\s*` from every offset; matching the separator alone and widening over whitespace by hand makes cold parse ~24% and warm ~40% faster on large corpora, output unchanged. - **Cold parse no longer retains full message bodies through cached previews.** `flatSlice` skipped its Buffer round-trip for strings already within the bound, but provider adapters pre-truncate user-message previews with `.slice(0, 500)` before the cache-site call — those pre-sliced views are still V8 SlicedStrings pinning their large parent, so the retention that OOM'd cold parses of large histories survived. The round-trip now always runs. - **Kiro sessions carry the real `projectPath`** (CLI meta.cwd, v2 `workspacePaths[0]`, workspace sessions' `workspaceDirectory`), so git-repo attribution can resolve them; previously they were attribution-blind. Bumps the kiro parse version, so the first run after upgrade re-parses kiro history once, and kiro sessions in linked git worktrees now group under the main repo. diff --git a/src/main.ts b/src/main.ts index 192cb72b..25d8df08 100644 --- a/src/main.ts +++ b/src/main.ts @@ -2105,21 +2105,29 @@ program byTask: !!opts.byTask, byAgent: !!opts.byAgent, taskFilter: opts.task, - // `aggregateModels` slices to topN on rows sorted by cost + savings - // descending. Unpriced rows are $0 on both (findUnpricedModels excludes - // anything with a local-savings baseline), so they always sort last and - // `--top` would remove exactly the rows `--unpriced` exists to show. - // Take the whole set here and slice after filtering instead. + // `aggregateModels` filters and slices before the unpriced filter. Its + // rows are sorted cost-first, so a small --top would remove exactly the + // rows `--unpriced` exists to show. Take the whole set here and slice + // after filtering and ranking instead. topN: opts.unpriced ? undefined : topN, minCost: typeof opts.minCost === 'number' && Number.isFinite(opts.minCost) ? opts.minCost : (opts.unpriced ? 0 : 0.01), }) if (opts.unpriced) { - rows = rows.filter(row => findUnpricedModels([{ + const unpriced = findUnpricedModels(rows.map(row => ({ model: row.model, calls: row.calls, cost: row.costUSD, tokens: row.totalTokens, - }]).length > 0) + }))) + const unpricedRank = new Map() + for (const [rank, usage] of unpriced.entries()) { + // Breakdown modes can emit several rows for one model. Keep the first + // rank so all rows for that model stay together and N still counts rows. + if (!unpricedRank.has(usage.model)) unpricedRank.set(usage.model, rank) + } + rows = rows + .filter(row => unpricedRank.has(row.model)) + .sort((a, b) => (unpricedRank.get(a.model)! - unpricedRank.get(b.model)!)) if (topN !== undefined) rows = rows.slice(0, topN) } diff --git a/tests/models-report.test.ts b/tests/models-report.test.ts index 426f3eaa..4e1dba09 100644 --- a/tests/models-report.test.ts +++ b/tests/models-report.test.ts @@ -776,10 +776,8 @@ describe('models CLI breakdown flags', () => { } }) - // `--top` is applied inside aggregateModels, before the unpriced filter runs, - // on rows sorted by cost + savings descending. Unpriced rows are $0 on both, - // so they sort last and a small --top removed exactly the rows --unpriced - // exists to surface: the user was told they had no unpriced models. + // Unpriced rows all sort at $0 in aggregateModels, so the old implementation + // preserved transcript/Map order instead of findUnpricedModels' token order. it('keeps unpriced rows when --unpriced is combined with --top', async () => { const home = await mkdtemp(join(tmpdir(), 'codeburn-models-unpriced-top-')) try { @@ -802,13 +800,13 @@ describe('models CLI breakdown flags', () => { sessionId: 'models-unpriced-top-session', timestamp: '2026-05-09T00:00:00.000Z', cwd: '/tmp/models-unpriced-top', - message: { role: 'user', content: 'Two priced models outrank the unpriced one.' }, + message: { role: 'user', content: 'Three unpriced models arrive small-first.' }, }), - // Both priced models cost more than the $0 unpriced row, so they take - // both --top slots unless the filter runs first. - assistant('opus', 'claude-opus-4-6', '2026-05-09T00:01:00.000Z', 5000), - assistant('sonnet', 'claude-sonnet-4-6', '2026-05-09T00:02:00.000Z', 3000), - assistant('unpriced', 'zz-unpriced-frontier-model', '2026-05-09T00:03:00.000Z', 2000), + // Transcript order is deliberately different from token order: + // 1.1k, 9.1k, 5.1k total tokens. The two largest must survive --top 2. + assistant('small', 'zz-unpriced-small', '2026-05-09T00:01:00.000Z', 1000), + assistant('largest', 'zz-unpriced-largest', '2026-05-09T00:02:00.000Z', 9000), + assistant('middle', 'zz-unpriced-middle', '2026-05-09T00:03:00.000Z', 5000), ].join('\n') + '\n') const res = spawnSync( @@ -819,7 +817,8 @@ describe('models CLI breakdown flags', () => { expect(res.status, `stdout: ${res.stdout}\nstderr: ${res.stderr}`).toBe(0) const rows = JSON.parse(res.stdout) as Array<{ model: string }> - expect(rows.map(row => row.model)).toEqual(['zz-unpriced-frontier-model']) + expect(rows).toHaveLength(2) + expect(rows.map(row => row.model)).toEqual(['zz-unpriced-largest', 'zz-unpriced-middle']) } finally { await rm(home, { recursive: true, force: true }) } From 72aad9e01292b0f411f3cbce80d74788b6916655 Mon Sep 17 00:00:00 2001 From: ozymandiashh <234437643+ozymandiashh@users.noreply.github.com> Date: Tue, 18 Aug 2026 05:44:29 +0300 Subject: [PATCH 29/85] fix(grok): read the CLI's own completed-turn usage instead of estimating it Grok CLI writes a turn_completed update carrying a full usage object -- inputTokens, outputTokens, cachedReadTokens, cacheCreationTokens, reasoningTokens -- into the same updates.jsonl the parser already reads. We ignored it and reconstructed an estimate from _meta.totalTokens, a running context-size counter that rides on unrelated events, with a total < prevTotal * 0.5 reset as the turn boundary. On the cache-heavy session reported in #998 that reconstruction captured about 1.4% of the real cache-read volume and roughly 6% of the day's tokens, while over-counting output about fivefold. cacheCreationInputTokens and reasoningTokens were hardcoded to zero regardless of what the session held. The parser now reads turn_completed.usage, keyed by the record's snake_case prompt_id so a re-emitted turn cannot double count, and sums across turns. Two decompositions matter, both derivable from the reported numbers: totalTokens equals inputTokens + outputTokens exactly, so cachedReadTokens and cacheCreationTokens are subsets of input and are subtracted out per record before pricing, matching the cache-exclusive convention codex and copilot already use; and reasoningTokens is a subset of output. That second one needs care, because the repo contract is the opposite of Grok's: ParsedProviderCall.reasoningTokens is exclusive of outputTokens everywhere, and every consumer sums the two -- tests/providers/kiro.test.ts says so outright. So reasoning is clamped to the reported output and output is emitted without it, and the downstream sum reconstructs Grok's number. Without the clamp a record with reasoning > output produced a negative output and left the pipeline pricing reasoning instead. Multi-model attribution is deliberately out of scope. modelUsage only selects a priced attribution id; a session that used two models is priced at one rate. Splitting per model was tried and dropped: chooseAuthoritativeModel's priced-id fallback exists to avoid a truthful-but-$0 row when modelUsage names an id this checkout cannot price, and per-model pricing loses it -- the reporter's own session collapsed from $1.20 to near zero the moment a second id appeared. When no valid completed record exists -- older Grok CLI versions -- the old heuristic still runs, unchanged. The decision is taken from the deduplicated records rather than latched per line, so a superseded or all-zero record cannot flip a session off the heuristic and drop it. A session only partly covered by turn_completed records keeps costIsEstimated: true rather than presenting itself as fully provider-measured. costUsdTicks is deliberately not read. Its scale is undocumented, and guessing it would fabricate spend. Bumps the grok parse version, and DAILY_CACHE_VERSION with MIN_SUPPORTED_VERSION together, since the daily cache serves every day before today and retains ten years. Moving them in lockstep is what keeps the carry-forward lossless: the filename is version-suffixed, so the old file stays on disk and is adopted for days no source can still re-derive. Separately, detectContextBloat divided by outputTokens alone. Reasoning is stored beside output for every reasoning-bearing provider, so the detector saw a fraction of the generated tokens and invented high-impact findings -- a session whose provider-reported ratio is 20:1, below the 25:1 threshold, was reported as 133:1 with 710K tokens of claimed savings. It now uses the same output + reasoning sum the reports use, which fixes codex, kiro, hermes, qwen and cursor-agent too. Reported in #998. --- CHANGELOG.md | 1 + docs/providers/grok.md | 18 +- src/act/optimize-apply.ts | 6 +- src/daily-cache.ts | 11 +- src/dashboard.tsx | 4 +- src/main.ts | 19 +- src/models.ts | 2 +- src/optimize.ts | 72 ++--- src/providers/grok.ts | 325 +++++++++++++++++--- src/session-cache.ts | 5 +- src/usage-aggregator.ts | 2 +- tests/daily-cache-grok-rederivation.test.ts | 100 ++++++ tests/grok-parser-pipeline.test.ts | 266 ++++++++++++++++ tests/models-report.test.ts | 65 +--- tests/models.test.ts | 10 +- tests/optimize-fs.test.ts | 93 +----- tests/optimize.test.ts | 14 + tests/providers/grok.test.ts | 299 +++++++++++++++++- 18 files changed, 1020 insertions(+), 292 deletions(-) create mode 100644 tests/daily-cache-grok-rederivation.test.ts create mode 100644 tests/grok-parser-pipeline.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 9242c070..f140e812 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,7 @@ ## Unreleased ### Changed +- **Grok Build now uses the CLI's authoritative completed-turn usage.** `turn_completed.usage` records are deduplicated by prompt and emitted as one session-level call from verified top-level totals; `modelUsage` only selects a priced attribution id, so multi-model rate attribution remains deliberately out of scope. Reasoning is clamped to reported output before the exclusive output/reasoning split, and the old context-size heuristic remains for sessions without usable top-level usage. In mixed sessions, uncovered pre-upgrade turns are dropped and the row is marked estimated rather than claiming full provider coverage; already-cached Grok sessions re-parse once. (#998) - **Codex rollouts parse across worker threads too, and the workload gate now takes bytes or files.** Codex is the bigger half of a real cold parse — a 4 GB rollout corpus against 1.8 GB of Claude sessions — and it was still decoding one file at a time. A whole-file rollout decode now runs on the same pool, against an empty dedup set, and comes back with the calls, the dedup keys it claimed, and the codex-cache entry it would have written; the parent installs all three in the serial loop's order, so `codex-results.json` and every payload come out byte-identical to a serial run. Cross-file state stays where it was: a forked rollout replaying its parent's token_count history collides on the parent's keys and is re-parsed in-process, and no worker ever touches the cache module's per-directory state. Files the Codex cache can serve exactly or resume into from a byte offset never reach a worker — they read a few KB and the resume state belongs to the parent. The workload gate is now pending BYTES alone (200 MB), not file count: 250 pending files holding under a megabyte between them spawned threads that made the run ~5% slower, while a few hundred huge rollouts were being turned away. The count takes `max(pendingFiles / 50, pendingBytes / 200 MB)`, and the per-thread memory budget is derived per parse as `clamp(256 MB, 2 × average pending file + 128 MB, 1 GB)` rather than a flat 256 MB — a 260 MB rollout peaks near 430 MB in its worker and scales linearly with the pool, so the flat figure over-subscribed exactly the workload this adds. The decision is per provider, and at most one pool is alive at a time. - **A large cold Claude parse now runs across worker threads.** Reading, decoding and line-parsing a session JSONL is per-file work that never touches anything shared, so it moves onto `worker_threads`; each worker ships its parsed turns back as a JSON string and the parent installs them in the exact order the serial loop would. Everything with cross-file state — the streaming-message dedup, canonical project paths, spawn links, PR correlation, progress saves — stays on the main thread, and a file whose message ids were already claimed by an earlier file (or whose worker failed) is simply re-parsed in-process, so the session cache and every payload are identical either way. On a 6 GB corpus a cold `status` drops from 27.5s to 14.8s with peak RSS up 2.27 GB → 2.52 GB. Threads only engage for a genuinely large cold parse: never with under 200 MB behind the pending whole-file re-parses, 2 or fewer cores, or under 4 GB of available memory — so warm and incremental runs are untouched and spawn nothing. Otherwise the count is `min(cores - 1, min(0.25 × available, 2 GB) / 256 MB, pendingFiles / 50)`, where available is `process.availableMemory()` (cgroup-aware in containers) rather than free memory, which on macOS reports free pages and would switch the feature on and off between runs. `CODEBURN_PARSE_WORKERS=0` forces the serial parse and `CODEBURN_PARSE_WORKERS=N` forces N (capped at the core count), both bypassing every gate; `CODEBURN_VERBOSE=1` prints the resolved count and why. - **A warm launch rewrites only the month that changed, and a ranged query reads only the months it can report on.** Per-provider shards still meant one appended session republished that provider's entire history — 95 MB for Claude on a 6 GB corpus. Each provider's shard is now split again by the UTC month of the cached session's FIRST turn, a bucket that never moves as a session grows, so an append rewrites one month. Every shard records the newest month it holds, which lets `--period today/week` skip the shards that cannot contribute a turn to the range; the skipped months stay on disk untouched across the save, and providers whose cache is the only surviving record (durable) or whose parse fingerprint moved are always read in full. Remaining shards are read concurrently. Existing v8 and v7 caches are re-laid-out losslessly on first load and the old layout removed once the new one is published: nothing re-parses. diff --git a/docs/providers/grok.md b/docs/providers/grok.md index 4bed0ee8..ab874200 100644 --- a/docs/providers/grok.md +++ b/docs/providers/grok.md @@ -4,7 +4,7 @@ Grok Build, xAI's coding CLI. Sessions use the `grok-build` model by default. - **Source:** `src/providers/grok.ts` - **Loading:** eager (`src/providers/index.ts`) -- **Test:** `tests/providers/grok.test.ts` +- **Test:** `tests/grok-parser-pipeline.test.ts`, `tests/providers/grok.test.ts` ## Where it reads from @@ -13,19 +13,23 @@ Grok Build, xAI's coding CLI. Sessions use the `grok-build` model by default. ## Storage format -JSON + JSONL. `summary.json` holds the session id, cwd, timestamps, and `current_model_id`. `signals.json` holds `modelsUsed`, `toolsUsed`, and `contextTokensUsed`. `updates.jsonl` is the ACP log: each streamed chunk carries `params._meta.totalTokens` (running context size) and `params._meta.promptId` (one per turn). +JSON + JSONL. `summary.json` holds the session id, cwd, timestamps, and `current_model_id`. `signals.json` holds `modelsUsed`, `toolsUsed`, and `contextTokensUsed`. `updates.jsonl` is the ACP log: streamed chunks carry `params._meta.totalTokens` (running context size) and `params._meta.promptId` (one per turn); newer CLI versions also append `params.update.sessionUpdate: "turn_completed"` with snake_case `prompt_id` and a provider-recorded `usage` object. ## Token model -**Estimated.** Grok does not log billable input/output tokens. It only records the running context fill (`totalTokens` per chunk, and `contextTokensUsed` in signals). The parser reconstructs a rough estimate from the per-turn `totalTokens` curve: input is the context entering each turn, output is the context growth during it. The result is flagged `costIsEstimated` and re-priced with `calculateCost`. +**Authoritative when available.** A `turn_completed` record reports the whole input side in `inputTokens` and the whole output side in `outputTokens`. `cachedReadTokens` is treated as a subset of input; `cacheCreationTokens` is treated as another input subset by analogy because the record exposes no separate fresh-input field, with any per-record violation clamped locally. The top-level totals are the accounting basis. `modelUsage` is retained only as a model-attribution signal; multi-model attribution is deliberately out of scope, so one session uses one selected model's rate. `reasoningTokens` is clamped to that record's reported output before the parser emits exclusive output plus reasoning, preserving the reported total through the cache pipeline. These counts are provider-recorded, so `costIsEstimated` is false for fully covered sessions while CodeBurn applies its own pricing table. `costUsdTicks` is ignored because its scale is undocumented. + +**Estimated fallback.** Older sessions without any valid `turn_completed.usage` record use the running context fill (`totalTokens` per chunk) and the existing compaction-aware per-turn curve. That path remains flagged `costIsEstimated`; a completed record is never blended with the heuristic. + +If a session spans a CLI upgrade and has both completed records and streamed turns without a matching record, the all-or-nothing authoritative path keeps the recorded totals, drops the uncovered turns, and marks the emitted row `costIsEstimated: true`. A later parse can fill an open turn once it writes a record; pre-upgrade turns that never do are dropped. ## Pricing -`grok-build` is aliased to `grok-build-0.1` in `src/models.ts`, so it prices off the bundled LiteLLM fallback. Note that xAI's published API rate and the LiteLLM fallback figure differ, so treat the cost as an estimate and verify against your xAI usage console. +`grok-build` is aliased to `grok-build-0.1` in `src/models.ts`, so it prices off the bundled LiteLLM fallback. If `usage.modelUsage` contains a model id that CodeBurn can price, that id is preferred; when the real id is not priced yet, the existing summary/signals model is retained so a known alias does not become a $0 row. This is a single attribution choice for the session, not a per-model accounting split; multi-model rate attribution is a follow-up. CodeBurn still does not use Grok's undocumented `costUsdTicks`. ## Caching -None. +Authoritative records expose cache-read and cache-creation token counts. The legacy estimate has no cache-creation signal and keeps its inferred cache-read count. ## Deduplication @@ -33,7 +37,7 @@ Per `grok:::`. ## Quirks -- **No cache or output/tool-token split.** Only context fill is available, so cache fields are `0` and the cost is an estimate (likely an upper bound, since re-sent context is cached server-side and not exposed in the session files). +- **Two token paths.** Completed turns carry provider usage; sessions from older CLI versions have only the context curve and therefore remain estimates (likely an upper bound, since re-sent context is cached server-side and not exposed in those files). - **No bash-command capture.** Tool names come from `signals.toolsUsed`; per-command bash text is not extracted, so `bashCommands` is empty. - **Whole-session timestamp.** Spend is attributed to `updated_at`, since the context curve is cumulative. - **Subscription vs API.** Grok Build runs via either a metered xAI API account (tiered) or a SuperGrok subscription; the session files do not record which. @@ -41,5 +45,5 @@ Per `grok:::`. ## When fixing a bug here 1. Discovery: check the `sessions///` walk and the `GROK_HOME` resolution. -2. Token estimate: see `estimateTokens` (groups `updates.jsonl` by `promptId`). +2. Token accounting: see `parseUpdates` (deduplicates `turn_completed` by snake_case `prompt_id`, then falls back to grouping streamed chunks by camelCase `_meta.promptId`). 3. Add a fixture-format session under `tests/providers/grok.test.ts`; do not mock the filesystem. diff --git a/src/act/optimize-apply.ts b/src/act/optimize-apply.ts index b72af509..5b90685a 100644 --- a/src/act/optimize-apply.ts +++ b/src/act/optimize-apply.ts @@ -13,10 +13,6 @@ export type ApplyOptions = { yes?: boolean dryRun?: boolean only?: string - // Mirrors `optimize --provider`. The scan below only reads Claude - // transcripts, and this path does not just report findings, it plans and - // applies them - a Codex-scoped run must never offer to edit ~/.claude. - provider?: string actionsDir?: string ctx?: PlanContext // Test seams: crafted findings skip the session scan; streams default to @@ -107,7 +103,7 @@ export async function runOptimizeApply( let costRate = opts.costRate ?? 0 if (!findings) { errout.write(chalk.dim(' Analyzing your sessions...\n')) - const scanned = await scanAndDetect(projects, dateRange, opts.provider) + const scanned = await scanAndDetect(projects, dateRange) findings = scanned.findings costRate = scanned.costRate } diff --git a/src/daily-cache.ts b/src/daily-cache.ts index 76e787f0..b122e27e 100644 --- a/src/daily-cache.ts +++ b/src/daily-cache.ts @@ -6,6 +6,13 @@ import { join } from 'path' import { getCodeburnCacheDir } from './cache-dir.js' import type { DateRange, ProjectSummary } from './types.js' +// Bumped to 19: Grok authoritative usage now keeps one session-level rollup +// from top-level totals, clamps reasoning to reported output, and labels mixed +// authoritative/heuristic coverage. Days already finalized at v18 contain the +// per-model split from the previous draft, so raising MIN_SUPPORTED_VERSION +// forces a one-time re-derivation; v14 carry-forward keeps source-less history +// intact. +// // Bumped to 17: copilot CLI sessions were misclassified as VS Code transcripts // (#944), so days finalized at v16 or earlier carry output-only copilot costs — // the session.shutdown rollup's input/cache tokens were dropped. Raising @@ -74,8 +81,8 @@ import type { DateRange, ProjectSummary } from './types.js' // that older binaries skipped. v8 added local-model savings to the daily // rollup; the `savingsConfigHash` field is invalidated separately when the // user changes their `localModelSavings` mapping. -export const DAILY_CACHE_VERSION = 17 -const MIN_SUPPORTED_VERSION = 17 +export const DAILY_CACHE_VERSION = 19 +const MIN_SUPPORTED_VERSION = 19 // Version-suffixed so different binaries each own a distinct file and never // clobber an incompatible schema. Bumping the version mints a fresh filename; // adoptOlderDailyCaches then unions days out of every previous file (including diff --git a/src/dashboard.tsx b/src/dashboard.tsx index 9cc3db50..b66b41cf 100644 --- a/src/dashboard.tsx +++ b/src/dashboard.tsx @@ -1460,14 +1460,14 @@ export function InteractiveDashboard({ initialProjects, initialDailyHistoryProje const generation = reloadGenerationRef.current setOptimizeLoading(true) try { - const result = await scanAndDetect(projects, currentRange(), activeProvider) + const result = await scanAndDetect(projects, currentRange()) if (reloadGenerationRef.current === generation) setOptimizeResult(result) } catch (error) { console.error(error) } finally { if (reloadGenerationRef.current === generation) setOptimizeLoading(false) } - }, [optimizeAvailable, projects, currentRange, optimizeLoading, optimizeResult, activeProvider]) + }, [optimizeAvailable, projects, currentRange, optimizeLoading, optimizeResult]) useEffect(() => { const refreshIntervalMs = getRefreshIntervalMs(refreshSeconds ?? 0) diff --git a/src/main.ts b/src/main.ts index a8af9bba..d201920b 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1831,7 +1831,7 @@ program const projects = await parseAllSessions(range, opts.provider) if (opts.apply) { const { runOptimizeApply } = await import('./act/optimize-apply.js') - await runOptimizeApply(projects, range, { yes: opts.yes, dryRun: opts.dryRun, only: opts.only, provider: opts.provider }) + await runOptimizeApply(projects, range, { yes: opts.yes, dryRun: opts.dryRun, only: opts.only }) return } assertFormat(format, ['text', 'json'], 'optimize') @@ -1849,9 +1849,9 @@ program appliedHeader = buildOptimizeAppliedHeader(applied) ?? undefined previouslyApplied = applied.appliedByFinding } catch { /* the header is optional; never block the findings */ } - await runOptimize(projects, label, range, { format, appliedHeader, previouslyApplied, provider: opts.provider }) + await runOptimize(projects, label, range, { format, appliedHeader, previouslyApplied }) } else { - await runOptimize(projects, label, range, { format, provider: opts.provider }) + await runOptimize(projects, label, range, { format }) } }) @@ -2075,7 +2075,6 @@ program .option('--by-agent', 'One row per (provider, model, agent) instead of one row per (provider, model). Claude subagent transcripts only; other providers and main sessions bucket under "main"') .option('--top ', 'Show only the top N rows', (v: string) => parseInt(v, 10)) .option('--min-cost ', 'Hide rows below this cost threshold', (v: string) => parseFloat(v)) - .option('--unpriced', 'Show only models with usage that currently price at $0') .option('--no-totals', 'Suppress the footer totals row') .option('--format ', 'Output format: table, markdown, json, csv', 'table') .action(async (opts) => { @@ -2100,21 +2099,13 @@ program } const projects = await parseAllSessions(range, opts.provider) - let rows = await aggregateModels(projects, { + const rows = await aggregateModels(projects, { byTask: !!opts.byTask, byAgent: !!opts.byAgent, taskFilter: opts.task, topN: typeof opts.top === 'number' && Number.isFinite(opts.top) ? opts.top : undefined, - minCost: typeof opts.minCost === 'number' && Number.isFinite(opts.minCost) ? opts.minCost : (opts.unpriced ? 0 : 0.01), + minCost: typeof opts.minCost === 'number' && Number.isFinite(opts.minCost) ? opts.minCost : 0.01, }) - if (opts.unpriced) { - rows = rows.filter(row => findUnpricedModels([{ - model: row.model, - calls: row.calls, - cost: row.costUSD, - tokens: row.totalTokens, - }]).length > 0) - } const fmt = (opts.format ?? 'table').toLowerCase() if (rows.length === 0 && (fmt === 'table' || fmt === 'markdown')) { diff --git a/src/models.ts b/src/models.ts index 0ad8fccc..bf4ed448 100644 --- a/src/models.ts +++ b/src/models.ts @@ -307,7 +307,7 @@ const BUILTIN_ALIASES: Record = { // reports that quote literal slugs (e.g. forum.cursor.com/t/154933). 'claude-4-sonnet': 'claude-sonnet-4', 'claude-4-sonnet-1m': 'claude-sonnet-4', - 'claude-4-sonnet-thinking': 'claude-sonnet-4', + 'claude-4-sonnet-thinking': 'claude-sonnet-4-5', 'claude-4.5-sonnet': 'claude-sonnet-4-5', 'claude-4.5-sonnet-thinking': 'claude-sonnet-4-5', 'claude-4.6-sonnet': 'claude-sonnet-4-6', diff --git a/src/optimize.ts b/src/optimize.ts index 190a8a8b..aab24737 100644 --- a/src/optimize.ts +++ b/src/optimize.ts @@ -551,18 +551,7 @@ export async function scanJsonlFile( return { calls, cwds, apiCalls, userMessages } } -// The session scan reads Claude Code transcripts only, so a `--provider` that -// excludes Claude leaves nothing for it to do. Callers must also skip the -// detectors it feeds (see `claudeOnly` in scanAndDetect) — the empty scan -// returned here is an absence of measurement, not a measurement of absence. -export function providerCoversClaude(provider?: string): boolean { - return !provider || provider === 'all' || provider === 'claude' -} - -async function scanSessions(dateRange?: DateRange, provider?: string): Promise { - if (!providerCoversClaude(provider)) { - return { toolCalls: [], projectCwds: new Set(), apiCalls: [], userMessages: [] } - } +async function scanSessions(dateRange?: DateRange): Promise { const sources = await discoverAllSessions('claude') const allCalls: ToolCall[] = [] const allCwds = new Set() @@ -2711,7 +2700,9 @@ export function findContextBloatCandidates(projects: ProjectSummary[]): ContextB for (const session of sessions) { const inputTokens = sessionEffectiveContextTokens(session) - const outputTokens = session.totalOutputTokens + // Reasoning is stored separately from ordinary output, but both are + // generated tokens for this detector. Reports already use their sum. + const outputTokens = session.totalOutputTokens + session.totalReasoningTokens const ratio = inputTokens / Math.max(outputTokens, 1) const currentMs = new Date(session.firstTimestamp).getTime() const gapMs = previousTimestampMs !== null ? currentMs - previousTimestampMs : null @@ -2988,7 +2979,7 @@ export function computeInputCostRate(projects: ProjectSummary[]): number { type CacheEntry = { data: OptimizeResult; ts: number } const resultCache = new Map() -export function cacheKey(projects: ProjectSummary[], dateRange: DateRange | undefined, provider?: string): string { +export function cacheKey(projects: ProjectSummary[], dateRange: DateRange | undefined): string { const dr = dateRange ? `${dateRange.start.getTime()}-${dateRange.end.getTime()}` : 'all' // Fingerprint enough of the dataset that two materially different inputs // cannot collide onto one cached OptimizeResult. Project count + api-call @@ -3005,27 +2996,23 @@ export function cacheKey(projects: ProjectSummary[], dateRange: DateRange | unde } // Costs scaled to whole micro-dollars so float jitter cannot thrash the key. const fingerprint = `${projects.length}:${calls}:${Math.round(cost * 1e6)}:${Math.round(savings * 1e6)}:${Math.round(proxied * 1e6)}` - // The provider decides whether the Claude session scan runs at all, so two - // filters that happen to share a project fingerprint must not share a result. - return `${provider ?? 'all'}:${dr}:${fingerprint}` + return `${dr}:${fingerprint}` } export async function scanAndDetect( projects: ProjectSummary[], dateRange?: DateRange, - provider?: string, ): Promise { if (projects.length === 0) { return { findings: [], costRate: 0, healthScore: 100, healthGrade: 'A', modelRecommendations: [] } } - const key = cacheKey(projects, dateRange, provider) + const key = cacheKey(projects, dateRange) const cached = resultCache.get(key) if (cached && Date.now() - cached.ts < RESULT_CACHE_TTL_MS) return cached.data const costRate = computeInputCostRate(projects) - const scanCoversClaude = providerCoversClaude(provider) - const { toolCalls, projectCwds, apiCalls, userMessages } = await scanSessions(dateRange, provider) + const { toolCalls, projectCwds, apiCalls, userMessages } = await scanSessions(dateRange) const mcpCoverage = aggregateMcpCoverage(projects) const findings: WasteFinding[] = [] @@ -3040,44 +3027,35 @@ export async function scanAndDetect( ) const firstSessionIds = findYoungProjectFirstSessionIds(projects) const outlierExclusions = new Set([...lowWorthSessionIds, ...contextBloatVisibleIds, ...firstSessionIds]) - // Detectors fed by the session scan or by `~/.claude` config only mean - // anything when the run covers Claude. Under a different `--provider` they - // must be skipped rather than handed an empty scan: emptiness reads as - // "never invoked", so every skill, agent and command would be reported as - // unused when it was simply not measured. - const claudeOnly = (detect: () => WasteFinding | null): (() => WasteFinding | null) => - scanCoversClaude ? detect : () => null const syncDetectors: Array<() => WasteFinding | null> = [ - claudeOnly(() => detectCacheBloat(apiCalls, projects, dateRange)), - claudeOnly(() => detectLowReadEditRatio(toolCalls)), - claudeOnly(() => detectJunkReads(toolCalls, dateRange)), - claudeOnly(() => detectDuplicateReads(toolCalls, dateRange)), - claudeOnly(() => detectUnusedMcp(toolCalls, projects, projectCwds, mcpCoverage)), + () => detectCacheBloat(apiCalls, projects, dateRange), + () => detectLowReadEditRatio(toolCalls), + () => detectJunkReads(toolCalls, dateRange), + () => detectDuplicateReads(toolCalls, dateRange), + () => detectUnusedMcp(toolCalls, projects, projectCwds, mcpCoverage), () => detectMcpToolCoverage(projects, mcpCoverage), () => detectMcpProfileAdvisor(projects, mcpCoverage), // 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)), + () => detectMcpDeferralOff(toolCalls, projects, projectCwds, apiCalls), + () => detectMcpAlwaysLoadHygiene(projects, projectCwds, apiCalls, mcpCoverage), + () => detectMcpDeferThreshold(projects, projectCwds), () => detectCapabilityReliability(projects), () => detectLowWorthSessions(projects), () => detectContextBloat(projects, lowWorthSessionIds), () => detectSessionOutliers(projects, outlierExclusions), - claudeOnly(() => detectBloatedClaudeMd(projectCwds)), - claudeOnly(() => detectBashBloat()), + () => detectBloatedClaudeMd(projectCwds), + () => detectBashBloat(), ] for (const detect of syncDetectors) { const finding = detect() if (finding) findings.push(finding) } - const ghostResults = scanCoversClaude - ? await Promise.all([ - detectGhostAgents(toolCalls), - detectGhostSkills(toolCalls), - detectGhostCommands(userMessages), - ]) - : [] + const ghostResults = await Promise.all([ + detectGhostAgents(toolCalls), + detectGhostSkills(toolCalls), + detectGhostCommands(userMessages), + ]) for (const f of ghostResults) if (f) findings.push(f) findings.sort((a, b) => urgencyScore(b) - urgencyScore(a)) @@ -3305,7 +3283,7 @@ export async function runOptimize( projects: ProjectSummary[], periodLabel: string, dateRange?: DateRange, - opts: { format?: 'text' | 'json'; appliedHeader?: string; previouslyApplied?: Record; provider?: string } = {}, + opts: { format?: 'text' | 'json'; appliedHeader?: string; previouslyApplied?: Record } = {}, ): Promise { const format = opts.format ?? 'text' if (projects.length === 0 && format === 'text') { @@ -3317,7 +3295,7 @@ export async function runOptimize( process.stderr.write(chalk.dim(' Analyzing your sessions...\n')) } - const result = await scanAndDetect(projects, dateRange, opts.provider) + const result = await scanAndDetect(projects, dateRange) const { findings, costRate, healthScore, healthGrade } = result const sessions = projects.flatMap(p => p.sessions) const periodCost = projects.reduce((s, p) => s + p.totalCostUSD, 0) diff --git a/src/providers/grok.ts b/src/providers/grok.ts index 1c292441..4724a8af 100644 --- a/src/providers/grok.ts +++ b/src/providers/grok.ts @@ -3,7 +3,7 @@ import { basename, dirname, join } from 'path' import { homedir } from 'os' import { readSessionFile } from '../fs-utils.js' -import { calculateCost, getShortModelName } from '../models.js' +import { calculateCost, getModelCosts, getShortModelName } from '../models.js' import { extractBashCommands } from '../bash-utils.js' import type { ProbeRoot, Provider, SessionSource, SessionParser, ParsedProviderCall } from './types.js' @@ -12,14 +12,15 @@ import type { ProbeRoot, Provider, SessionSource, SessionParser, ParsedProviderC // or ~/.grok. Each session dir holds summary.json, signals.json, and the ACP // log updates.jsonl. // -// Grok does NOT record billable input/output tokens. signals.json carries -// `contextTokensUsed` (current context fill) and updates.jsonl carries a running -// `_meta.totalTokens` per streamed chunk; there is no per-call input/output -// split. We reconstruct an ESTIMATE from the per-turn totalTokens curve. Agentic -// turns re-send the growing context every call, and that re-sent context is -// cached server-side, so we bill the unique context (summed per compaction segment) as fresh input, -// the re-sent remainder as cache reads, and the per-turn growth as output. Cost -// is flagged estimated; grok-build is priced via its grok-build-0.1 alias. +// Newer Grok CLI versions append a `turn_completed` update with provider-recorded +// input/output/cache/reasoning usage. That record is authoritative: cached reads +// are part of input, and reasoning is a subset of output. Cache creation is +// treated as another input subset by analogy because the record exposes no +// separate fresh-input field; any per-record violation is clamped before pricing. +// Older sessions only carry +// `signals.json.contextTokensUsed` and the running `_meta.totalTokens` curve; for +// those we retain the old compaction-aware estimate and mark its cost estimated. +// `costUsdTicks` is deliberately ignored because its scale is not documented. const toolNameMap: Record = { bash: 'Bash', @@ -80,28 +81,136 @@ function safeDecode(name: string): string { } } -// updates.jsonl is one ACP JSON-RPC notification per line; streamed chunks carry -// params._meta.{totalTokens, promptId}. totalTokens is the running context size, -// so grouping by promptId (one per turn) gives each turn's first/last value. +// updates.jsonl is one ACP JSON-RPC notification per line. Streamed chunks carry +// params._meta.{totalTokens, promptId}; completed turns carry snake_case +// params.update.{prompt_id, usage}. type GrokUpdate = { params?: { - _meta?: { totalTokens?: number; promptId?: string } - update?: { sessionUpdate?: string; title?: string; rawInput?: { command?: unknown; subagent_type?: unknown } } + _meta?: { totalTokens?: unknown; promptId?: unknown } + update?: { + sessionUpdate?: unknown + prompt_id?: unknown + usage?: unknown + title?: unknown + rawInput?: { command?: unknown; subagent_type?: unknown } + } } } -// Single pass over updates.jsonl: per-turn totalTokens for the cost estimate, -// plus the real tool calls (each tool_call's title -> a tool, and -// run_terminal_command's rawInput.command -> shell commands). -function parseUpdates(updates: string): { +type GrokUsageValues = { + inputTokens: number + outputTokens: number + cacheReadTokens: number + cacheCreationTokens: number + reasoningTokens: number +} + +type GrokAuthoritativeUsage = GrokUsageValues & { + modelUsage: Map +} + +type GrokTokenTotals = { input: number cacheRead: number output: number + cacheCreation: number + reasoning: number +} + +function emptyTokenTotals(): GrokTokenTotals { + return { input: 0, cacheRead: 0, output: 0, cacheCreation: 0, reasoning: 0 } +} + +const authoritativeTokenFields = [ + 'inputTokens', + 'outputTokens', + 'cachedReadTokens', + 'cacheCreationTokens', + 'reasoningTokens', +] as const + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +// JSONL is third-party input. Keep the check local to this provider so bad +// usage fields become absent rather than leaking NaN, negative tokens, or a +// throwing arithmetic operation into the session aggregate. +function finiteNonNegative(value: unknown): number | undefined { + if (typeof value !== 'number' || !Number.isFinite(value) || value < 0) return undefined + // Token counts this large are not meaningful in a session and can overflow + // when summed or priced. Capping preserves the non-negative finite invariant. + return Math.min(value, Number.MAX_SAFE_INTEGER) +} + +function addTokenCounts(left: number, right: number): number { + return Math.min(Number.MAX_SAFE_INTEGER, left + right) +} + +function readUsageNumber(usage: Record, field: string): number | undefined { + return finiteNonNegative(usage[field]) +} + +function readModelUsage(usage: Record): Map { + const modelUsage = usage['modelUsage'] + const result = new Map() + if (!isRecord(modelUsage)) return result + + for (const [modelId, rawModelUsage] of Object.entries(modelUsage)) { + if (!modelId || !isRecord(rawModelUsage)) continue + const values = authoritativeTokenFields.map((field) => finiteNonNegative(rawModelUsage[field])) + if (!values.some((value) => value !== undefined)) continue + result.set(modelId, { + inputTokens: values[0] ?? 0, + outputTokens: values[1] ?? 0, + cacheReadTokens: values[2] ?? 0, + cacheCreationTokens: values[3] ?? 0, + reasoningTokens: values[4] ?? 0, + }) + } + return result +} + +function parseAuthoritativeUsage(raw: unknown): GrokAuthoritativeUsage | null { + if (!isRecord(raw)) return null + + const values = authoritativeTokenFields.map((field) => readUsageNumber(raw, field)) + const modelUsage = readModelUsage(raw) + return { + inputTokens: values[0] ?? 0, + outputTokens: values[1] ?? 0, + cacheReadTokens: values[2] ?? 0, + cacheCreationTokens: values[3] ?? 0, + reasoningTokens: values[4] ?? 0, + modelUsage, + } +} + +function chooseAuthoritativeModel(modelIds: string[], existingModel: string): string { + // modelUsage is the best attribution signal, but it may contain a newer + // provider id that this checkout cannot price yet (for example + // `grok-4.6-build`). Prefer an actual model id when it prices; otherwise keep + // the existing summary/signals id when that one prices, avoiding a truthful + // but $0 row. If neither prices, retain the actual model id for attribution. + const pricedActualModel = modelIds.find((modelId) => getModelCosts(modelId) !== null) + if (pricedActualModel) return pricedActualModel + if (getModelCosts(existingModel) !== null) return existingModel + return modelIds[0] ?? existingModel +} + +// Single pass over updates.jsonl: retain the old per-turn totalTokens estimate, +// the deduplicated authoritative turn records, and the real tool calls. +function parseUpdates(updates: string): { + usage: GrokTokenTotals + modelIds: string[] + authoritative: boolean + hasUncompletedTurn: boolean tools: string[] bashCommands: string[] subagentTypes: string[] } { const turns = new Map() + const completedUsages = new Map() const tools: string[] = [] const bashCommands: string[] = [] const subagentTypes: string[] = [] @@ -111,6 +220,7 @@ function parseUpdates(updates: string): { let prevTotal = -1 let segmentPeak = 0 let inputFresh = 0 + let completedWithoutPromptId = 0 for (const line of updates.split('\n')) { if (!line.trim()) continue @@ -122,16 +232,16 @@ function parseUpdates(updates: string): { } if (!params) continue - const total = params._meta?.totalTokens - if (typeof total === 'number') { + const total = finiteNonNegative(params._meta?.totalTokens) + if (total !== undefined) { if (prevTotal >= 0 && total < prevTotal * 0.5) { - inputFresh += segmentPeak // close the segment a compaction just ended + inputFresh = addTokenCounts(inputFresh, segmentPeak) // close the segment a compaction just ended segmentPeak = 0 } if (total > segmentPeak) segmentPeak = total prevTotal = total - const promptId = params._meta?.promptId + const promptId = typeof params._meta?.promptId === 'string' ? params._meta.promptId : undefined if (promptId) { const turn = turns.get(promptId) if (!turn) turns.set(promptId, { first: total, last: total }) @@ -140,6 +250,18 @@ function parseUpdates(updates: string): { } const update = params.update + if (update?.sessionUpdate === 'turn_completed') { + const usage = parseAuthoritativeUsage(update.usage) + if (usage) { + const promptId = typeof update.prompt_id === 'string' && update.prompt_id.length > 0 + ? update.prompt_id + : `turn_completed:${completedWithoutPromptId++}` + // Re-emitted turn_completed notifications are cumulative updates for + // the same turn. Last write wins so they cannot double count. + completedUsages.set(promptId, usage) + } + } + if (update?.sessionUpdate === 'tool_call' && typeof update.title === 'string') { tools.push(toolNameMap[update.title] ?? update.title) if (update.title === 'run_terminal_command' && typeof update.rawInput?.command === 'string') { @@ -151,17 +273,101 @@ function parseUpdates(updates: string): { } } - inputFresh += segmentPeak // close the final segment + inputFresh = addTokenCounts(inputFresh, segmentPeak) // close the final segment let sumFirst = 0 let output = 0 for (const { first, last } of turns.values()) { - sumFirst += first - output += Math.max(0, last - first) + sumFirst = addTokenCounts(sumFirst, first) + output = addTokenCounts(output, Math.max(0, last - first)) } // Fresh input (summed segment peaks) is billed once; the rest of the per-turn // re-sends are cache reads (Grok caches them, even though it reports nothing). - const cacheRead = Math.max(0, sumFirst - inputFresh) - return { input: inputFresh, cacheRead, output, tools, bashCommands, subagentTypes } + const estimated = { + input: inputFresh, + cacheRead: Math.max(0, sumFirst - inputFresh), + output, + } + + const usageTotals = emptyTokenTotals() + const modelIds: string[] = [] + const seenModelIds = new Set() + for (const usage of completedUsages.values()) { + addUsageToTotals(usageTotals, usage) + for (const modelId of usage.modelUsage.keys()) { + if (seenModelIds.has(modelId)) continue + seenModelIds.add(modelId) + modelIds.push(modelId) + } + } + + // Decide from the final, prompt-deduplicated records. A positive modelUsage + // entry is attribution metadata only; it is not a substitute for the + // top-level accounting fields. This also prevents a superseded positive + // record from suppressing the streaming fallback. + const hasPositiveCompletedUsage = [...completedUsages.values()].some(hasPositiveTopLevelUsage) + if (!hasPositiveCompletedUsage || !hasPositiveTotals(usageTotals)) { + // Older Grok CLI versions have no completed usage record. Keep the + // heuristic only here; blending it into a real record would reintroduce the + // large over-count this parser is fixing. A record that only has modelUsage, + // or whose final deduplicated values are empty, is treated the same way. + return { + usage: { input: estimated.input, cacheRead: estimated.cacheRead, output: estimated.output, cacheCreation: 0, reasoning: 0 }, + modelIds: [], + authoritative: false, + hasUncompletedTurn: false, + tools, + bashCommands, + subagentTypes, + } + } + + const hasUncompletedTurn = [...turns.keys()].some(promptId => !completedUsages.has(promptId)) + + // calculateCost follows the cache-exclusive input convention used by the + // other real-usage providers. Each record is decomposed before its totals + // are added, so an inconsistent record cannot consume another record's fresh + // input budget. + return { + usage: usageTotals, + modelIds, + authoritative: true, + hasUncompletedTurn, + tools, + bashCommands, + subagentTypes, + } +} + +function hasPositiveTopLevelUsage(usage: GrokAuthoritativeUsage): boolean { + return usage.inputTokens > 0 + || usage.outputTokens > 0 + || usage.cacheReadTokens > 0 + || usage.cacheCreationTokens > 0 + || usage.reasoningTokens > 0 +} + +function addUsageToTotals(totals: GrokTokenTotals, usage: GrokUsageValues): void { + // `cacheCreationTokens` is treated as an input subset by analogy. Clamp the + // exclusive portion per record before summing the session. Reasoning is + // reported inside outputTokens by Grok, so clamp it to that same record's + // output before the session totals are accumulated. + const reasoningTokens = Math.min(usage.reasoningTokens, usage.outputTokens) + totals.input = addTokenCounts( + totals.input, + Math.max(0, usage.inputTokens - usage.cacheReadTokens - usage.cacheCreationTokens), + ) + totals.cacheRead = addTokenCounts(totals.cacheRead, usage.cacheReadTokens) + totals.output = addTokenCounts(totals.output, usage.outputTokens) + totals.cacheCreation = addTokenCounts(totals.cacheCreation, usage.cacheCreationTokens) + totals.reasoning = addTokenCounts(totals.reasoning, reasoningTokens) +} + +function hasPositiveTotals(totals: GrokTokenTotals): boolean { + return totals.input > 0 + || totals.cacheRead > 0 + || totals.output > 0 + || totals.cacheCreation > 0 + || totals.reasoning > 0 } function createParser(source: SessionSource, seenKeys: Set): SessionParser { @@ -172,37 +378,64 @@ function createParser(source: SessionSource, seenKeys: Set): SessionPars const updates = await readSessionFile(source.path) if (!summary || updates === null) return - const { input, cacheRead, output, tools, bashCommands, subagentTypes } = parseUpdates(updates) - if (input === 0 && output === 0) return - const signals = await readJson(join(dir, 'signals.json')) - const model = + const existingModel = summary.current_model_id ?? signals?.primaryModelId ?? signals?.modelsUsed?.[0] ?? 'grok-build' + const parsed = parseUpdates(updates) + if (!hasPositiveTotals(parsed.usage)) return + const timestamp = summary.updated_at ?? summary.last_active_at ?? summary.created_at ?? '' const sessionId = summary.info?.id ?? basename(dir) - const dedupKey = `${source.provider}:${dir}:${timestamp}:${sessionId}` - if (seenKeys.has(dedupKey)) return - seenKeys.add(dedupKey) + // Multi-model attribution is deliberately out of scope: modelUsage may + // help choose a priced attribution id, but top-level totals remain the + // accounting source and one session uses one model's rate. + const model = parsed.authoritative ? chooseAuthoritativeModel(parsed.modelIds, existingModel) : existingModel + const baseDedupKey = `${source.provider}:${dir}:${timestamp}:${sessionId}` + if (seenKeys.has(baseDedupKey)) return + seenKeys.add(baseDedupKey) + + // `addUsageToTotals` clamps reasoning per authoritative record before + // summing, so the aggregate preserves this identity as well. + const reasoningTokens = parsed.usage.reasoning yield { provider: source.provider, model, - inputTokens: input, - outputTokens: output, - cacheCreationInputTokens: 0, - cacheReadInputTokens: cacheRead, - cachedInputTokens: cacheRead, - reasoningTokens: 0, + inputTokens: parsed.usage.input, + // Grok reports reasoning INSIDE outputTokens, but the repo contract is + // the opposite: ParsedProviderCall.reasoningTokens is exclusive of + // outputTokens, and every consumer sums the two (parser.ts's + // cachedCallToApiCall for cost, modelBreakdown for tokens, and the + // models/audit reports). tests/providers/kiro.test.ts states it + // outright. So split it here rather than special-casing grok in five + // downstream places: subtracting reasoning makes `output + reasoning` + // reconstruct exactly the number Grok reported. + outputTokens: parsed.usage.output - reasoningTokens, + cacheCreationInputTokens: parsed.usage.cacheCreation, + cacheReadInputTokens: parsed.usage.cacheRead, + cachedInputTokens: parsed.usage.cacheRead, + reasoningTokens, webSearchRequests: 0, - costUSD: calculateCost(model, input, output, 0, cacheRead, 0), - costIsEstimated: true, - tools, - bashCommands, - subagentTypes, + // Authoritative token counts are measured even though CodeBurn applies + // its own pricing table; only the legacy context-curve path is an + // estimate. The full provider output is priced once here, which is + // what the downstream `output + reasoning` recompute reproduces. + costUSD: calculateCost( + model, + parsed.usage.input, + parsed.usage.output, + parsed.usage.cacheCreation, + parsed.usage.cacheRead, + 0, + ), + costIsEstimated: !parsed.authoritative || parsed.hasUncompletedTurn, + tools: parsed.tools, + bashCommands: parsed.bashCommands, + subagentTypes: parsed.subagentTypes, timestamp, speed: 'standard', - deduplicationKey: dedupKey, + deduplicationKey: baseDedupKey, userMessage: summary.session_summary ?? summary.generated_title ?? '', sessionId, project: source.project, diff --git a/src/session-cache.ts b/src/session-cache.ts index 7fcb23a0..760984cc 100644 --- a/src/session-cache.ts +++ b/src/session-cache.ts @@ -283,7 +283,10 @@ export const PROVIDER_PARSE_VERSIONS: Record = { // 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', - grok: 'estimated-cost-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. + grok: 'authoritative-usage-v4', hermes: 'reasoning-output-accounting-v1-est-cost', 'lingtai-tui': 'token-ledger-registry-activity-v3', 'ibm-bob': 'worktree-project-grouping-v1', diff --git a/src/usage-aggregator.ts b/src/usage-aggregator.ts index 4350413b..82aeb732 100644 --- a/src/usage-aggregator.ts +++ b/src/usage-aggregator.ts @@ -935,7 +935,7 @@ export async function buildMenubarPayloadForRange(periodInfo: PeriodInfo, opts: } })() - const optimize = opts.optimize === false ? null : await scanAndDetect(scanProjects, scanRange, opts.provider) + const optimize = opts.optimize === false ? null : await scanAndDetect(scanProjects, scanRange) const granularRange = opts.daysSelection?.range ?? scanRange const granularHistory = opts.timeline === false ? undefined : buildGranularHistory(scanProjects, granularRange) return buildMenubarPayload(currentData, providers, optimize, dailyHistory, retryTax, routingWaste, breakdowns, claudeConfigs, granularHistory) diff --git a/tests/daily-cache-grok-rederivation.test.ts b/tests/daily-cache-grok-rederivation.test.ts new file mode 100644 index 00000000..1e5b0a80 --- /dev/null +++ b/tests/daily-cache-grok-rederivation.test.ts @@ -0,0 +1,100 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest' +import { mkdir, readFile, rm, writeFile } from 'fs/promises' +import { join } from 'path' +import { tmpdir } from 'os' + +import { + currentTzKey, + ensureCacheHydrated, + toDateString, + type DailyEntry, +} from '../src/daily-cache.js' + +const PRE_FIX_DAILY_VERSION = 18 +const cacheRoot = join(tmpdir(), `codeburn-grok-daily-${process.pid}-${Date.now()}`) + +function day(date: string, cost: number): DailyEntry { + return { + date, + cost, + savingsUSD: 0, + calls: 1, + sessions: 1, + inputTokens: 100, + outputTokens: 20, + cacheReadTokens: 30, + cacheWriteTokens: 0, + editTurns: 0, + oneShotTurns: 0, + models: { + 'Grok Build': { + calls: 1, + cost, + savingsUSD: 0, + inputTokens: 100, + outputTokens: 20, + cacheReadTokens: 30, + cacheWriteTokens: 0, + }, + }, + categories: {}, + providers: { + grok: { + calls: 1, + cost, + savingsUSD: 0, + sessions: 1, + inputTokens: 100, + outputTokens: 20, + cacheReadTokens: 30, + cacheWriteTokens: 0, + }, + }, + } +} + +beforeEach(async () => { + process.env['CODEBURN_CACHE_DIR'] = cacheRoot + await rm(cacheRoot, { recursive: true, force: true }) + await mkdir(cacheRoot, { recursive: true }) +}) + +afterEach(async () => { + await rm(cacheRoot, { recursive: true, force: true }) +}) + +describe('Grok daily-cache accounting rederivation', () => { + it('re-derives a v18 Grok day while preserving the old cache as the baseline', async () => { + 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.v${PRE_FIX_DAILY_VERSION}.json`) + const oldCache = { + version: PRE_FIX_DAILY_VERSION, + savingsConfigHash: 'cfg', + tzKey: currentTzKey(), + lastComputedDate: yesterday, + days: [day(date, 99)], + complete: true, + watermarkTrusted: true, + } + await writeFile(oldPath, JSON.stringify(oldCache)) + + let parseCount = 0 + const corrected = day(date, 2) + const hydrated = await ensureCacheHydrated( + async () => { + parseCount++ + return [] + }, + () => [corrected], + 'cfg', + () => true, + ) + + const refreshedDay = hydrated.days.find(entry => entry.date === date) + expect(parseCount).toBe(1) + expect(refreshedDay?.providers.grok?.cost).toBe(2) + expect(refreshedDay?.cost).toBe(2) + expect(JSON.parse(await readFile(oldPath, 'utf8'))).toEqual(oldCache) + }) +}) diff --git a/tests/grok-parser-pipeline.test.ts b/tests/grok-parser-pipeline.test.ts new file mode 100644 index 00000000..f3f5df58 --- /dev/null +++ b/tests/grok-parser-pipeline.test.ts @@ -0,0 +1,266 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest' +import { mkdir, rm, writeFile } from 'fs/promises' +import { join } from 'path' + +import { calculateCost } from '../src/models.js' +import { clearSessionCache, parseAllSessions } from '../src/parser.js' + +// The exported Grok provider resolves GROK_HOME when its singleton is created, +// before the test body runs. Set the root during module hoisting, then re-assert +// the call-time cache/env values in beforeEach after env-isolation runs. +const testRoot = vi.hoisted(() => { + const root = `${process.env['TMPDIR'] || '/tmp'}/grok-pipeline-${process.pid}-${Date.now()}` + process.env['GROK_HOME'] = `${root}/grok` + return root +}) + +const GROK_HOME = join(testRoot, 'grok') +const CACHE_DIR = join(testRoot, 'cache') + +type UsageOptions = { + input: number + output: number + cacheRead?: number + cacheCreation?: number + reasoning?: number + model?: string + modelUsage?: Record> +} + +type StreamingTurn = { + promptId: string + totals: number[] +} + +type CompletedTurn = { + promptId?: string + usage: Record +} + +function usage(opts: UsageOptions): Record { + const cacheRead = opts.cacheRead ?? 0 + const cacheCreation = opts.cacheCreation ?? 0 + const reasoning = opts.reasoning ?? 0 + const model = opts.model ?? 'grok-build' + const singleModel = { + inputTokens: opts.input, + outputTokens: opts.output, + cachedReadTokens: cacheRead, + cacheCreationTokens: cacheCreation, + reasoningTokens: reasoning, + } + return { + inputTokens: opts.input, + outputTokens: opts.output, + cachedReadTokens: cacheRead, + cacheCreationTokens: cacheCreation, + reasoningTokens: reasoning, + modelUsage: opts.modelUsage ?? { [model]: singleModel }, + } +} + +async function writeSession( + record: Record, + uuid = '019edf9c-0000-7000-8000-000000000101', + options: { turns?: StreamingTurn[]; completedTurns?: CompletedTurn[] } = {}, +): Promise { + const cwd = '/Users/test/grok-pipeline' + const dir = join(GROK_HOME, 'sessions', '%2FUsers%2Ftest%2Fgrok-pipeline', uuid) + await mkdir(dir, { recursive: true }) + await writeFile(join(dir, 'summary.json'), JSON.stringify({ + info: { id: uuid, cwd }, + created_at: '2026-08-17T09:00:00.000Z', + updated_at: '2026-08-17T09:05:00.000Z', + current_model_id: 'grok-build', + session_summary: 'pipeline regression', + })) + await writeFile(join(dir, 'signals.json'), JSON.stringify({ + primaryModelId: 'grok-build', + modelsUsed: ['grok-build'], + })) + const completedTurns = options.completedTurns ?? [{ promptId: 'pipeline-turn', usage: record }] + const lines: Record[] = [] + for (const turn of options.turns ?? []) { + for (const totalTokens of turn.totals) { + lines.push({ + method: 'session/update', + params: { + sessionId: uuid, + _meta: { eventId: `stream-${turn.promptId}-${totalTokens}`, totalTokens, promptId: turn.promptId }, + }, + }) + } + } + for (const [index, completed] of completedTurns.entries()) { + lines.push({ + method: 'session/update', + params: { + sessionId: uuid, + update: { + sessionUpdate: 'turn_completed', + ...(completed.promptId !== undefined ? { prompt_id: completed.promptId } : {}), + usage: completed.usage, + }, + _meta: { eventId: `completed-${index}` }, + }, + }) + } + await writeFile(join(dir, 'updates.jsonl'), lines.map(line => JSON.stringify(line)).join('\n') + '\n') +} + +async function parseGrokSessions() { + const projects = await parseAllSessions(undefined, 'grok') + return projects.flatMap(project => project.sessions) +} + +beforeEach(async () => { + clearSessionCache() + await rm(testRoot, { recursive: true, force: true }) + process.env['GROK_HOME'] = GROK_HOME + process.env['CODEBURN_CACHE_DIR'] = CACHE_DIR +}) + +afterEach(async () => { + clearSessionCache() + await rm(testRoot, { recursive: true, force: true }) +}) + +describe('Grok parser through the session-cache pipeline', () => { + it('keeps reasoning inside output pricing on both cold and warm parses', async () => { + await writeSession(usage({ input: 1000, output: 200, cacheRead: 500, cacheCreation: 100, reasoning: 150 })) + + const cold = (await parseGrokSessions())[0]! + const coldCall = cold.turns[0]!.assistantCalls[0]! + clearSessionCache() + const warm = (await parseGrokSessions())[0]! + const warmCall = warm.turns[0]!.assistantCalls[0]! + const expected = calculateCost('grok-build', 400, 200, 100, 500, 0) + + expect(cold.apiCalls).toBe(1) + expect(coldCall.costUSD).toBeCloseTo(expected, 12) + expect(warm.apiCalls).toBe(1) + expect(warmCall.costUSD).toBeCloseTo(expected, 12) + expect(warmCall.costUSD).not.toBeCloseTo(calculateCost('grok-build', 400, 350, 100, 500, 0), 12) + + // Cost is only half of it: `models` and the audit report sum + // outputTokens + reasoningTokens for the token column. Emitting the + // provider's cache-inclusive output verbatim inflated that column by the + // reasoning tokens even once the cost was right, so pin the split and the + // sum the reports actually render. + const breakdown = Object.values(cold.modelBreakdown)[0]! + expect(breakdown.tokens.outputTokens).toBe(50) // 200 reported - 150 reasoning + expect(breakdown.tokens.reasoningTokens).toBe(150) + expect(breakdown.tokens.outputTokens + breakdown.tokens.reasoningTokens).toBe(200) + }) + + it('keeps one session call and uses top-level totals for a multi-model record', async () => { + await writeSession(usage({ + input: 3000, + output: 300, + cacheRead: 600, + cacheCreation: 100, + reasoning: 30, + modelUsage: { + 'grok-4.6-build': { + inputTokens: 2000, + outputTokens: 200, + cachedReadTokens: 500, + cacheCreationTokens: 100, + reasoningTokens: 20, + }, + 'grok-latest': { + inputTokens: 1000, + outputTokens: 100, + cachedReadTokens: 100, + cacheCreationTokens: 0, + reasoningTokens: 10, + }, + }, + }), '019edf9c-0000-7000-8000-000000000102') + + const cold = (await parseGrokSessions())[0]! + const coldCalls = cold.turns.flatMap(turn => turn.assistantCalls) + const expected = calculateCost('grok-latest', 2300, 300, 100, 600, 0) + + expect(cold.turns).toHaveLength(1) + expect(cold.apiCalls).toBe(1) + expect(coldCalls.map(call => call.model)).toEqual(['grok-latest']) + expect(coldCalls.map(call => call.usage.inputTokens)).toEqual([2300]) + expect(coldCalls[0]!.usage.outputTokens + coldCalls[0]!.usage.reasoningTokens).toBe(300) + expect(cold.totalCostUSD).toBeCloseTo(expected, 12) + + clearSessionCache() + const warm = (await parseGrokSessions())[0]! + expect(warm.turns).toHaveLength(1) + expect(warm.apiCalls).toBe(1) + expect(warm.totalCostUSD).toBeCloseTo(expected, 12) + }) + + it('falls back to the streaming estimate when usage exists only under modelUsage', async () => { + await writeSession({ + modelUsage: { + 'grok-4.6-build': { + inputTokens: 1000, + outputTokens: 100, + }, + }, + }, '019edf9c-0000-7000-8000-000000000103', { + turns: [{ promptId: 'legacy-turn', totals: [1000, 1200] }], + }) + + const sessions = await parseGrokSessions() + expect(sessions).toHaveLength(1) + const call = sessions[0]!.turns[0]!.assistantCalls[0]! + expect(call.isEstimated).toBe(true) + expect(call.usage.outputTokens).toBe(200) + }) + + it('uses the final deduplicated record when deciding whether to estimate', async () => { + await writeSession({}, '019edf9c-0000-7000-8000-000000000104', { + turns: [{ promptId: 'superseded-turn', totals: [1000, 1200] }], + completedTurns: [ + { promptId: 'superseded-turn', usage: usage({ input: 1000, output: 100 }) }, + { promptId: 'superseded-turn', usage: {} }, + ], + }) + + const sessions = await parseGrokSessions() + expect(sessions).toHaveLength(1) + const call = sessions[0]!.turns[0]!.assistantCalls[0]! + expect(call.isEstimated).toBe(true) + expect(call.usage.outputTokens).toBe(200) + }) + + it('marks a mixed authoritative session estimated when a streamed turn has no record', async () => { + await writeSession(usage({ input: 1000, output: 100 }), '019edf9c-0000-7000-8000-000000000105', { + turns: [ + { promptId: 'pre-upgrade-turn', totals: [1000, 1400] }, + { promptId: 'authoritative-turn', totals: [1400, 1600] }, + ], + completedTurns: [{ promptId: 'authoritative-turn', usage: usage({ input: 800, output: 80 }) }], + }) + + const sessions = await parseGrokSessions() + expect(sessions).toHaveLength(1) + const call = sessions[0]!.turns[0]!.assistantCalls[0]! + expect(call.isEstimated).toBe(true) + expect(call.usage.inputTokens).toBe(800) + expect(call.usage.outputTokens + call.usage.reasoningTokens).toBe(80) + }) + + it('clamps reasoning to reported output before the real pipeline prices the call', async () => { + await writeSession(usage({ input: 1000, output: 100, cacheRead: 500, cacheCreation: 100, reasoning: 250 }), '019edf9c-0000-7000-8000-000000000106') + + const sessions = await parseGrokSessions() + const call = sessions[0]!.turns[0]!.assistantCalls[0]! + const expected = calculateCost('grok-build', 400, 100, 100, 500, 0) + expect(call.usage.outputTokens).toBe(0) + expect(call.usage.reasoningTokens).toBe(100) + expect(call.usage.outputTokens + call.usage.reasoningTokens).toBe(100) + expect(call.costUSD).toBeCloseTo(expected, 12) + + clearSessionCache() + const warmCall = (await parseGrokSessions())[0]!.turns[0]!.assistantCalls[0]! + expect(warmCall.costUSD).toBeCloseTo(expected, 12) + }) +}) diff --git a/tests/models-report.test.ts b/tests/models-report.test.ts index 8808ca58..33317fd8 100644 --- a/tests/models-report.test.ts +++ b/tests/models-report.test.ts @@ -1,9 +1,6 @@ -import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises' -import { tmpdir } from 'node:os' -import { join } from 'node:path' import { spawnSync } from 'node:child_process' -import { describe, it, expect, vi } from 'vitest' +import { describe, it, expect } from 'vitest' import chalk from 'chalk' import stripAnsi from 'strip-ansi' @@ -716,66 +713,6 @@ describe('renderCsv', () => { }) describe('models CLI breakdown flags', () => { - vi.setConfig({ testTimeout: 30_000 }) - - it('filters the models report to unpriced rows', async () => { - const home = await mkdtemp(join(tmpdir(), 'codeburn-models-unpriced-')) - try { - const projectDir = join(home, '.claude', 'projects', 'models-unpriced') - await mkdir(projectDir, { recursive: true }) - await writeFile(join(projectDir, 'session.jsonl'), [ - JSON.stringify({ - type: 'user', - sessionId: 'models-unpriced-session', - timestamp: '2026-05-09T00:00:00.000Z', - cwd: '/tmp/models-unpriced', - message: { role: 'user', content: 'Use one priced and one unpriced model.' }, - }), - JSON.stringify({ - type: 'assistant', - sessionId: 'models-unpriced-session', - timestamp: '2026-05-09T00:01:00.000Z', - cwd: '/tmp/models-unpriced', - message: { - id: 'priced', - type: 'message', - role: 'assistant', - model: 'claude-sonnet-4-6', - content: [{ type: 'text', text: 'priced' }], - usage: { input_tokens: 1000, output_tokens: 100, cache_read_input_tokens: 0, cache_creation_input_tokens: 0 }, - }, - }), - JSON.stringify({ - type: 'assistant', - sessionId: 'models-unpriced-session', - timestamp: '2026-05-09T00:02:00.000Z', - cwd: '/tmp/models-unpriced', - message: { - id: 'unpriced', - type: 'message', - role: 'assistant', - model: 'zz-unpriced-frontier-model', - content: [{ type: 'text', text: 'unpriced' }], - usage: { input_tokens: 2000, output_tokens: 200, cache_read_input_tokens: 0, cache_creation_input_tokens: 0 }, - }, - }), - ].join('\n') + '\n') - - const res = spawnSync( - process.execPath, - ['--import', 'tsx', 'src/cli.ts', 'models', '--unpriced', '--from', '2026-05-09', '--to', '2026-05-09', '--provider', 'claude', '--format', 'json'], - { cwd: process.cwd(), env: { ...process.env, HOME: home, CLAUDE_CONFIG_DIR: join(home, '.claude'), CODEBURN_CACHE_DIR: join(home, '.cache', 'codeburn'), TZ: 'UTC' }, encoding: 'utf-8', timeout: 30_000 }, - ) - - expect(res.status, `stdout: ${res.stdout}\nstderr: ${res.stderr}`).toBe(0) - const rows = JSON.parse(res.stdout) as Array<{ model: string; calls: number }> - expect(rows.map(row => row.model)).toEqual(['zz-unpriced-frontier-model']) - expect(rows[0]?.calls).toBe(1) - } finally { - await rm(home, { recursive: true, force: true }) - } - }) - it('rejects --by-task and --by-agent together with a clear error and exit 1', () => { const res = spawnSync( process.execPath, diff --git a/tests/models.test.ts b/tests/models.test.ts index 3e2b1655..e8910e37 100644 --- a/tests/models.test.ts +++ b/tests/models.test.ts @@ -505,7 +505,7 @@ describe('Cursor model variants resolve to pricing', () => { // Sonnet family ['claude-4-sonnet', 'claude-sonnet-4'], ['claude-4-sonnet-1m', 'claude-sonnet-4'], - ['claude-4-sonnet-thinking', 'claude-sonnet-4'], + ['claude-4-sonnet-thinking', 'claude-sonnet-4-5'], ['claude-4.5-sonnet', 'claude-sonnet-4-5'], ['claude-4.5-sonnet-thinking', 'claude-sonnet-4-5'], ['claude-4.6-sonnet', 'claude-sonnet-4-6'], @@ -558,14 +558,6 @@ describe('Cursor model variants resolve to pricing', () => { expect(costs!.outputCostPerToken).toBe(expected!.outputCostPerToken) }) } - - // Regression for #912: Cursor's unversioned `claude-4-sonnet-thinking` - // slug is the thinking variant of Sonnet 4, not Sonnet 4.5. The two models - // currently share a price, so the display name pins the canonical identity - // independently of today's pricing coincidence. - it('keeps claude-4-sonnet-thinking in the Sonnet 4 model family', () => { - expect(getShortModelName('claude-4-sonnet-thinking')).toBe('Sonnet 4') - }) }) describe('Cursor house model pricing', () => { diff --git a/tests/optimize-fs.test.ts b/tests/optimize-fs.test.ts index 7f9d76ea..2364e08a 100644 --- a/tests/optimize-fs.test.ts +++ b/tests/optimize-fs.test.ts @@ -1,5 +1,4 @@ -import { describe, it, expect, afterAll, afterEach, beforeEach, vi } from 'vitest' -import { Writable } from 'node:stream' +import { describe, it, expect, afterAll, beforeEach, vi } from 'vitest' import { mkdtempSync, rmSync, mkdirSync, writeFileSync, utimesSync } from 'fs' import { tmpdir } from 'os' import { join } from 'path' @@ -30,8 +29,6 @@ import { estimateContextBudget, discoverProjectCwd, } from '../src/context-budget.js' -import type { ProjectSummary } from '../src/types.js' -import { runOptimizeApply } from '../src/act/optimize-apply.js' // ============================================================================ // Helpers for filesystem fixtures @@ -374,94 +371,6 @@ describe('scanAndDetect', () => { expect(result.healthGrade).toBe('A') expect(result.costRate).toBe(0) }) - - // The session scan only ever reads Claude Code transcripts, so under a - // non-Claude --provider it used to report Claude-derived findings beside a - // header scoped to the other provider - e.g. `optimize --provider codex` - // printing a read/edit ratio counted from Claude sessions. - describe('provider scoping', () => { - // These fixtures live in the shared fake home, so they have to come back - // out: later suites in this file assert on an otherwise empty ~/.claude. - const CLAUDE_DIR = join(FAKE_HOME_FOR_MOCK, '.claude') - afterEach(() => { - for (const sub of ['projects', 'skills']) { - rmSync(join(CLAUDE_DIR, sub), { recursive: true, force: true }) - } - }) - - function claudeSessionWithEditHeavyTurns(): void { - const projectDir = join(CLAUDE_DIR, 'projects', 'provider-scope') - mkdirSync(projectDir, { recursive: true }) - const now = new Date().toISOString() - const entry = (name: string, file: string) => JSON.stringify({ - type: 'assistant', timestamp: now, - message: { content: [{ type: 'tool_use', name, input: { file_path: file } }] }, - }) - const lines = [entry('Read', '/src/a.ts')] - for (let i = 0; i < 12; i++) lines.push(entry('Edit', `/src/f${i}.ts`)) - writeFileSync(join(projectDir, 'session.jsonl'), lines.join('\n')) - } - - // scanAndDetect memoises on (provider, range, project fingerprint) for 60s, - // and the cache is module-level, so tests that differ only in what is on - // disk would serve each other's results. `seed` moves the fingerprint so - // each case scans for real. - function projectFixture(seed: number): ProjectSummary { - return { - project: 'provider-scope', - projectPath: '/tmp/provider-scope', - sessions: [], - totalCostUSD: 1, - totalApiCalls: 13 + seed, - } as unknown as ProjectSummary - } - - it('reports transcript-derived findings when scoped to claude', async () => { - claudeSessionWithEditHeavyTurns() - const result = await scanAndDetect([projectFixture(1)], undefined, 'claude') - expect(result.findings.map(f => f.id)).toContain('read-edit-ratio') - }) - - it('omits transcript-derived findings when scoped to another provider', async () => { - claudeSessionWithEditHeavyTurns() - mkdirSync(join(CLAUDE_DIR, 'skills', 'never-invoked'), { recursive: true }) - writeFileSync(join(CLAUDE_DIR, 'skills', 'never-invoked', 'SKILL.md'), '# skill\n') - - const result = await scanAndDetect([projectFixture(2)], undefined, 'codex') - const ids = result.findings.map(f => f.id) - - expect(ids).not.toContain('read-edit-ratio') - // An unmeasured skill must not be reported as an unused one: the scan - // returns nothing under this filter, which is not evidence of disuse. - expect(ids).not.toContain('unused-skills') - }) - - // The apply path reaches scanAndDetect through its own entry point, so it - // needs its own guard: `unused-skills` is appliable, and its plan moves - // directories out of ~/.claude/skills. Reporting a Codex-labelled finding - // is a wrong number; offering to archive every skill off one is a wrong - // number with side effects. - async function applyDryRun(provider: string): Promise { - const chunks: string[] = [] - const output = new Writable({ write(c, _e, cb) { chunks.push(String(c)); cb() } }) - const errorOutput = new Writable({ write(_c, _e, cb) { cb() } }) - await runOptimizeApply([projectFixture(3)], undefined, { provider, dryRun: true, output, errorOutput }) - return chunks.join('') - } - - it('plans no applies from Claude findings when scoped to another provider', async () => { - claudeSessionWithEditHeavyTurns() - mkdirSync(join(CLAUDE_DIR, 'skills', 'never-invoked'), { recursive: true }) - writeFileSync(join(CLAUDE_DIR, 'skills', 'never-invoked', 'SKILL.md'), '# skill\n') - - const codex = await applyDryRun('codex') - expect(codex).toContain('No appliable config-class fixes') - expect(codex).not.toContain('never-invoked') - - const claude = await applyDryRun('claude') - expect(claude).toContain('never-invoked') - }) - }) }) // ============================================================================ diff --git a/tests/optimize.test.ts b/tests/optimize.test.ts index bc72dd88..6be47a2a 100644 --- a/tests/optimize.test.ts +++ b/tests/optimize.test.ts @@ -52,6 +52,7 @@ function projectWithSessions(costs: number[], project = 'app'): ProjectSummary { totalCostUSD: cost, totalInputTokens: tokens, totalOutputTokens: tokens, + totalReasoningTokens: 0, totalCacheReadTokens: 0, totalCacheWriteTokens: 0, apiCalls: 1, @@ -105,6 +106,7 @@ function contextSession( totalCostUSD: 1, totalInputTokens: 0, totalOutputTokens: 0, + totalReasoningTokens: 0, totalCacheReadTokens: 0, totalCacheWriteTokens: 0, apiCalls: 1, @@ -365,6 +367,18 @@ describe('detectContextBloat', () => { expect(detectContextBloat([project])).toBeNull() }) + it('counts reasoning with output when measuring context pressure', () => { + const project = projectWithContextSessions([ + contextSession(0, { + totalInputTokens: 100_000, + totalOutputTokens: 3_500, + totalReasoningTokens: 2_000, + }), + ]) + + expect(detectContextBloat([project])).toBeNull() + }) + it('discounts cache reads when estimating context pressure', () => { const project = projectWithContextSessions([ contextSession(0, { diff --git a/tests/providers/grok.test.ts b/tests/providers/grok.test.ts index 4fcac7a0..c032bffd 100644 --- a/tests/providers/grok.test.ts +++ b/tests/providers/grok.test.ts @@ -4,6 +4,7 @@ import { join } from 'path' import { tmpdir } from 'os' import { createGrokProvider } from '../../src/providers/grok.js' +import { calculateCost } from '../../src/models.js' import type { ParsedProviderCall } from '../../src/providers/types.js' let tmpDir: string @@ -24,6 +25,7 @@ async function writeSession(opts: { cwd?: string model?: string turns?: Array<{ promptId: string; totals: number[] }> + completedTurns?: Array<{ promptId?: string; usage: unknown }> toolCalls?: Array<{ title: string; rawInput: Record }> toolsUsed?: string[] } = {}) { @@ -72,6 +74,21 @@ async function writeSession(opts: { })) } } + for (const completed of opts.completedTurns ?? []) { + lines.push(JSON.stringify({ + timestamp: 1786724773, + method: '_x.ai/session/update', + params: { + sessionId: uuid, + update: { + sessionUpdate: 'turn_completed', + ...(completed.promptId === undefined ? {} : { prompt_id: completed.promptId }), + usage: completed.usage, + }, + _meta: { eventId: 'event-1', agentTimestampMs: 1786724773589 }, + }, + })) + } for (const tc of opts.toolCalls ?? [ { title: 'read_file', rawInput: { target_directory: '.' } }, { title: 'grep', rawInput: { pattern: 'x' } }, @@ -89,6 +106,47 @@ async function writeSession(opts: { return { dir, uuid } } +function authoritativeUsage(opts: { + input?: number + output?: number + cacheRead?: number + cacheCreation?: number + reasoning?: number + model?: string + modelUsage?: Record> +} = {}): Record { + const input = opts.input ?? 1000 + const output = opts.output ?? 100 + const cacheRead = opts.cacheRead ?? 0 + const cacheCreation = opts.cacheCreation ?? 0 + const reasoning = opts.reasoning ?? 0 + const model = opts.model ?? 'grok-4.6-build' + const singleModelUsage = { + inputTokens: input, + outputTokens: output, + totalTokens: input + output, + cachedReadTokens: cacheRead, + cacheCreationTokens: cacheCreation, + reasoningTokens: reasoning, + modelCalls: 1, + apiDurationMs: 1000, + costUsdTicks: 125117780000, + } + return { + inputTokens: input, + outputTokens: output, + totalTokens: input + output, + cachedReadTokens: cacheRead, + cacheCreationTokens: cacheCreation, + reasoningTokens: reasoning, + modelCalls: 1, + apiDurationMs: 1000, + costUsdTicks: 125117780000, + modelUsage: opts.modelUsage ?? { [model]: singleModelUsage }, + numTurns: 1, + } +} + describe('grok provider - discovery', () => { it('discovers each session dir and derives project from cwd', async () => { await writeSession({ cwd: '/Users/test/myproject' }) @@ -123,7 +181,7 @@ describe('grok provider - parsing', () => { return calls } - it('emits one estimated call per session from the totalTokens curve', async () => { + it('emits one estimated call per session from the totalTokens fallback curve', async () => { await writeSession() const calls = await parse() expect(calls).toHaveLength(1) @@ -144,6 +202,245 @@ describe('grok provider - parsing', () => { expect(call.deduplicationKey).toContain('grok:') }) + it('uses one turn_completed usage record as authoritative and splits cache subsets from input', async () => { + await writeSession({ + turns: [], + completedTurns: [{ + promptId: 'real-prompt-1', + usage: authoritativeUsage({ + input: 12851663, + output: 36633, + cacheRead: 12092032, + cacheCreation: 0, + reasoning: 29077, + }), + }], + }) + + const calls = await parse() + expect(calls).toHaveLength(1) + const call = calls[0]! + expect(call.model).toBe('grok-build') + expect(call.inputTokens).toBe(759631) // 12851663 - 12092032 - 0 + expect(call.cacheReadInputTokens).toBe(12092032) + expect(call.cacheCreationInputTokens).toBe(0) + // Grok reports reasoning inside outputTokens; the repo contract wants them + // split, so output is emitted exclusive of reasoning and the two sum back + // to the 36633 the record reported. + expect(call.outputTokens).toBe(7556) // 36633 - 29077 + expect(call.reasoningTokens).toBe(29077) + expect(call.outputTokens + call.reasoningTokens).toBe(36633) + expect(call.costIsEstimated).toBe(false) + expect(call.costUSD).toBe(calculateCost('grok-build', 759631, 36633, 0, 12092032, 0)) + }) + + it('sums distinct turn_completed prompt ids exactly once each', async () => { + await writeSession({ + turns: [], + completedTurns: [ + { promptId: 'p1', usage: authoritativeUsage({ input: 1000, output: 100, cacheRead: 600, cacheCreation: 50, reasoning: 10 }) }, + { promptId: 'p2', usage: authoritativeUsage({ input: 2000, output: 200, cacheRead: 1000, cacheCreation: 100, reasoning: 20 }) }, + ], + }) + + const [call] = await parse() + expect(call).toMatchObject({ + inputTokens: 1250, + cacheReadInputTokens: 1600, + cacheCreationInputTokens: 150, + outputTokens: 270, // 300 reported - 30 reasoning + reasoningTokens: 30, + }) + }) + + it('keeps one authoritative call and uses top-level totals for multi-model usage', async () => { + await writeSession({ + turns: [], + completedTurns: [{ + promptId: 'multi-model', + usage: authoritativeUsage({ + input: 3000, + output: 300, + cacheRead: 600, + cacheCreation: 100, + reasoning: 30, + modelUsage: { + 'grok-build-0.1': { + inputTokens: 1000, + outputTokens: 100, + cachedReadTokens: 100, + cacheCreationTokens: 0, + reasoningTokens: 10, + }, + 'grok-latest': { + inputTokens: 2000, + outputTokens: 200, + cachedReadTokens: 500, + cacheCreationTokens: 100, + reasoningTokens: 20, + }, + }, + }), + }], + }) + + const calls = await parse() + expect(calls).toHaveLength(1) + expect(calls[0]).toMatchObject({ + model: 'grok-build-0.1', + inputTokens: 2300, + outputTokens: 270, + reasoningTokens: 30, + costUSD: calculateCost('grok-build-0.1', 2300, 300, 100, 600, 0), + }) + expect(calls[0]!.outputTokens + calls[0]!.reasoningTokens).toBe(300) + expect(calls[0]!.turnId).toBeUndefined() + }) + + it('uses the last turn_completed record for a duplicate prompt id', async () => { + await writeSession({ + turns: [], + completedTurns: [ + { promptId: 'same-prompt', usage: authoritativeUsage({ input: 500, output: 50, cacheRead: 100, reasoning: 5 }) }, + { promptId: 'same-prompt', usage: authoritativeUsage({ input: 800, output: 80, cacheRead: 200, cacheCreation: 25, reasoning: 8 }) }, + ], + }) + + const [call] = await parse() + expect(call).toMatchObject({ + inputTokens: 575, + cacheReadInputTokens: 200, + cacheCreationInputTokens: 25, + outputTokens: 72, // 80 reported - 8 reasoning + reasoningTokens: 8, + }) + }) + + it('uses unique fallback keys when completed records omit prompt_id', async () => { + await writeSession({ + turns: [], + completedTurns: [ + { usage: authoritativeUsage({ input: 100, output: 10 }) }, + { usage: authoritativeUsage({ input: 200, output: 20 }) }, + ], + }) + + const [call] = await parse() + expect(call).toMatchObject({ inputTokens: 300, outputTokens: 30, reasoningTokens: 0 }) + }) + + it('ignores a still-streaming turn but marks mixed coverage estimated', async () => { + await writeSession({ + turns: [{ promptId: 'still-streaming', totals: [10000, 15000] }], + completedTurns: [{ promptId: 'completed', usage: authoritativeUsage({ input: 900, output: 90, cacheRead: 300, reasoning: 20 }) }], + }) + + const [call] = await parse() + expect(call).toMatchObject({ + inputTokens: 600, + cacheReadInputTokens: 300, + outputTokens: 70, // 90 reported - 20 reasoning + reasoningTokens: 20, + costIsEstimated: true, + }) + }) + + it('treats malformed authoritative fields as absent without throwing or corrupting totals', async () => { + await writeSession({ + turns: [], + completedTurns: [{ + promptId: 'malformed', + usage: { + inputTokens: -1, + outputTokens: 4, + totalTokens: 'not-a-number', + cachedReadTokens: Number.NaN, + cacheCreationTokens: 'not-a-number', + reasoningTokens: -2, + modelUsage: {}, + }, + }], + }) + + const [call] = await parse() + expect(call).toBeDefined() + expect(call!.inputTokens).toBe(0) + expect(call!.outputTokens).toBe(4) + expect(call!.cacheReadInputTokens).toBe(0) + expect(call!.cacheCreationInputTokens).toBe(0) + expect(call!.reasoningTokens).toBe(0) + expect(Number.isFinite(call!.costUSD)).toBe(true) + expect(call!.costUSD).toBeGreaterThanOrEqual(0) + }) + + it('keeps the heuristic when a completed record reports all-zero usage', async () => { + await writeSession({ + turns: [ + { promptId: 'streaming-1', totals: [20000, 25000] }, + { promptId: 'streaming-2', totals: [30000, 35000] }, + ], + completedTurns: [{ + promptId: 'zero-usage', + usage: authoritativeUsage({ input: 0, output: 0, cacheRead: 0, cacheCreation: 0, reasoning: 0 }), + }], + }) + + const [call] = await parse() + expect(call).toBeDefined() + expect(call!.inputTokens).toBe(35000) + expect(call!.cacheReadInputTokens).toBe(15000) + expect(call!.outputTokens).toBe(10000) + expect(call!.costIsEstimated).toBe(true) + }) + + it('clamps cache-exclusive input per completed record before summing', async () => { + await writeSession({ + turns: [], + completedTurns: [ + { promptId: 'inconsistent', usage: authoritativeUsage({ input: 100, output: 10, cacheRead: 80, cacheCreation: 50 }) }, + { promptId: 'consistent', usage: authoritativeUsage({ input: 100, output: 20 }) }, + ], + }) + + const [call] = await parse() + expect(call).toMatchObject({ + inputTokens: 100, + cacheReadInputTokens: 80, + cacheCreationInputTokens: 50, + outputTokens: 30, + }) + }) + + it('does not add reasoning tokens on top of provider-reported output for cost', async () => { + await writeSession({ + turns: [], + model: 'grok-build', + completedTurns: [{ + promptId: 'reasoning-subset', + usage: authoritativeUsage({ + input: 1000, + output: 200, + cacheRead: 500, + cacheCreation: 100, + reasoning: 150, + model: 'grok-build', + }), + }], + }) + + const [call] = await parse() + expect(call).toBeDefined() + expect(call!.inputTokens).toBe(400) + // Output is emitted exclusive of reasoning, and the two sum to the 200 the + // record reported. The cost prices that full 200 once - the downstream + // `outputTokens + reasoningTokens` recompute lands on the same number. + expect(call!.outputTokens).toBe(50) // 200 - 150 + expect(call!.reasoningTokens).toBe(150) + expect(call!.outputTokens + call!.reasoningTokens).toBe(200) + expect(call!.costUSD).toBe(calculateCost('grok-build', 400, 200, 100, 500, 0)) + expect(call!.costUSD).not.toBe(calculateCost('grok-build', 400, 350, 100, 500, 0)) + }) + it('skips a session with no token growth', async () => { await writeSession({ turns: [{ promptId: 'p1', totals: [0, 0] }] }) expect(await parse()).toHaveLength(0) From a32761880f9f155e35d3d2b350f6e23dcf1d12cd Mon Sep 17 00:00:00 2001 From: ozymandiashh <234437643+ozymandiashh@users.noreply.github.com> Date: Tue, 18 Aug 2026 05:45:45 +0300 Subject: [PATCH 30/85] fix(sqlite): survive a read-only database parent instead of reporting no sessions `openDatabase` opens provider databases with `readOnly: true`, which is not enough for a WAL-mode database: SQLite has to create `-shm` and an empty `-wal` in the database's own directory unless they already exist. Two things follow, and both are real. The open writes. On a writable directory it succeeds and leaves two files behind in the user's provider directory, which is not what "CodeBurn only reads your session logs" implies. On a non-writable parent it fails outright with "attempt to write a readonly database". It is conditional on -shm being absent, which is exactly the state after the tool exits cleanly, so the symptom is intermittent: a provider's spend disappears whenever that tool is not running. Both discovery sites swallowed it with a bare `catch { continue }`, so the provider reported zero sessions with nothing on stderr - indistinguishable from the tool not being installed. It covers cursor, cursor-agent, opencode, goose, warp, kilo-code, zerostack and the copilot agent-traces DB. The direct open stays the fast path and is unchanged when it succeeds: no stat, no permission probe. Only when SQLite reports SQLITE_READONLY does the fallback run, copying the database and its -wal/-shm siblings into the CodeBurn cache directory and opening the copy there. The copy is fingerprinted the same way session-cache fingerprints a SQLite source, so an unchanged database is not copied twice, and there is one bounded entry per source path. The discovery sites now tell SQLITE_READONLY apart from ENOENT and emit one notice per source path rather than per session, matching what the parse-time paths already do. This is not specific to any one sandbox: it applies to a database on read-only media, a restrictive-permissions setup, and both the Flatpak and snap confinements. The snap was narrowed to a read-only personal-files plug in #978 and is likely affected; I have no snap install to confirm that on. --- CHANGELOG.md | 1 + src/providers/cursor.ts | 14 +- src/providers/sqlite-session-parser.ts | 14 +- src/sqlite.ts | 239 ++++++++++++++++++++++++- tests/sqlite-readonly-parent.test.ts | 207 +++++++++++++++++++++ 5 files changed, 468 insertions(+), 7 deletions(-) create mode 100644 tests/sqlite-readonly-parent.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 9242c070..1c6cdd65 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,7 @@ ## Unreleased ### Changed +- **SQLite providers now survive read-only database parents.** CodeBurn keeps the existing read-only open as the fast path, then fingerprints and caches the database plus `-wal`/`-shm` siblings only when SQLite reports that the source directory cannot create its sidecars. The cache has one bounded entry per source path and reuses it while the main-plus-WAL fingerprint is unchanged; the original provider database is never opened writable or modified. Read-only discovery failures are identified separately from missing databases and produce one rate-limited stderr notice. - **Codex rollouts parse across worker threads too, and the workload gate now takes bytes or files.** Codex is the bigger half of a real cold parse — a 4 GB rollout corpus against 1.8 GB of Claude sessions — and it was still decoding one file at a time. A whole-file rollout decode now runs on the same pool, against an empty dedup set, and comes back with the calls, the dedup keys it claimed, and the codex-cache entry it would have written; the parent installs all three in the serial loop's order, so `codex-results.json` and every payload come out byte-identical to a serial run. Cross-file state stays where it was: a forked rollout replaying its parent's token_count history collides on the parent's keys and is re-parsed in-process, and no worker ever touches the cache module's per-directory state. Files the Codex cache can serve exactly or resume into from a byte offset never reach a worker — they read a few KB and the resume state belongs to the parent. The workload gate is now pending BYTES alone (200 MB), not file count: 250 pending files holding under a megabyte between them spawned threads that made the run ~5% slower, while a few hundred huge rollouts were being turned away. The count takes `max(pendingFiles / 50, pendingBytes / 200 MB)`, and the per-thread memory budget is derived per parse as `clamp(256 MB, 2 × average pending file + 128 MB, 1 GB)` rather than a flat 256 MB — a 260 MB rollout peaks near 430 MB in its worker and scales linearly with the pool, so the flat figure over-subscribed exactly the workload this adds. The decision is per provider, and at most one pool is alive at a time. - **A large cold Claude parse now runs across worker threads.** Reading, decoding and line-parsing a session JSONL is per-file work that never touches anything shared, so it moves onto `worker_threads`; each worker ships its parsed turns back as a JSON string and the parent installs them in the exact order the serial loop would. Everything with cross-file state — the streaming-message dedup, canonical project paths, spawn links, PR correlation, progress saves — stays on the main thread, and a file whose message ids were already claimed by an earlier file (or whose worker failed) is simply re-parsed in-process, so the session cache and every payload are identical either way. On a 6 GB corpus a cold `status` drops from 27.5s to 14.8s with peak RSS up 2.27 GB → 2.52 GB. Threads only engage for a genuinely large cold parse: never with under 200 MB behind the pending whole-file re-parses, 2 or fewer cores, or under 4 GB of available memory — so warm and incremental runs are untouched and spawn nothing. Otherwise the count is `min(cores - 1, min(0.25 × available, 2 GB) / 256 MB, pendingFiles / 50)`, where available is `process.availableMemory()` (cgroup-aware in containers) rather than free memory, which on macOS reports free pages and would switch the feature on and off between runs. `CODEBURN_PARSE_WORKERS=0` forces the serial parse and `CODEBURN_PARSE_WORKERS=N` forces N (capped at the core count), both bypassing every gate; `CODEBURN_VERBOSE=1` prints the resolved count and why. - **A warm launch rewrites only the month that changed, and a ranged query reads only the months it can report on.** Per-provider shards still meant one appended session republished that provider's entire history — 95 MB for Claude on a 6 GB corpus. Each provider's shard is now split again by the UTC month of the cached session's FIRST turn, a bucket that never moves as a session grows, so an append rewrites one month. Every shard records the newest month it holds, which lets `--period today/week` skip the shards that cannot contribute a turn to the range; the skipped months stay on disk untouched across the save, and providers whose cache is the only surviving record (durable) or whose parse fingerprint moved are always read in full. Remaining shards are read concurrently. Existing v8 and v7 caches are re-laid-out losslessly on first load and the old layout removed once the new one is published: nothing re-parses. diff --git a/src/providers/cursor.ts b/src/providers/cursor.ts index 290f615a..b38aee54 100644 --- a/src/providers/cursor.ts +++ b/src/providers/cursor.ts @@ -5,7 +5,16 @@ import { homedir } from 'os' import { calculateCost } from '../models.js' import { extractBashCommands } from '../bash-utils.js' import { readCachedResults, writeCachedResults } from '../cursor-cache.js' -import { isSqliteAvailable, isSqliteBusyError, getSqliteLoadError, openDatabase, blobToText, type SqliteDatabase } from '../sqlite.js' +import { + isSqliteAvailable, + isSqliteBusyError, + getSqliteLoadError, + openDatabase, + blobToText, + isSqliteReadonlyError, + warnSqliteReadonlyOnce, + type SqliteDatabase, +} from '../sqlite.js' import { estimateTokensFromChars } from '../token-estimate.js' import type { DateRange } from '../types.js' import type { ProbeRoot, Provider, SessionSource, SessionParser, ParsedProviderCall } from './types.js' @@ -188,7 +197,8 @@ function loadWorkspaceMap(workspaceStorageDir: string): WorkspaceMapping { let db: SqliteDatabase try { db = openDatabase(wsDbPath) - } catch { + } catch (err) { + if (isSqliteReadonlyError(err)) warnSqliteReadonlyOnce(wsDbPath) continue } try { diff --git a/src/providers/sqlite-session-parser.ts b/src/providers/sqlite-session-parser.ts index b1e962f0..925a942b 100644 --- a/src/providers/sqlite-session-parser.ts +++ b/src/providers/sqlite-session-parser.ts @@ -2,7 +2,16 @@ import { readdir } from 'fs/promises' import { join } from 'path' import { calculateCost } from '../models.js' -import { isSqliteAvailable, getSqliteLoadError, openDatabase, blobToText, isSqliteBusyError, type SqliteDatabase } from '../sqlite.js' +import { + isSqliteAvailable, + getSqliteLoadError, + openDatabase, + blobToText, + isSqliteBusyError, + isSqliteReadonlyError, + warnSqliteReadonlyOnce, + type SqliteDatabase, +} from '../sqlite.js' import { buildAssistantCall, parseTimestamp, sanitize, type MessageData, type PartData } from './session-message.js' import type { SessionSource, @@ -310,7 +319,8 @@ export async function discoverSqliteSessions( let db: SqliteDatabase try { db = openDatabase(dbPath) - } catch { + } catch (err) { + if (isSqliteReadonlyError(err)) warnSqliteReadonlyOnce(dbPath) continue } diff --git a/src/sqlite.ts b/src/sqlite.ts index 3fb3c6a8..9576bc78 100644 --- a/src/sqlite.ts +++ b/src/sqlite.ts @@ -1,4 +1,9 @@ import { createRequire } from 'node:module' +import { copyFileSync, existsSync, mkdirSync, readFileSync, renameSync, statSync, unlinkSync, writeFileSync } from 'node:fs' +import { createHash, randomBytes } from 'node:crypto' +import { join } from 'node:path' + +import { getCodeburnCacheDir } from './cache-dir.js' /// Thin SQLite read-only wrapper over Node's built-in `node:sqlite` module (stable in /// Node 24, experimental in Node 22 / 23). Replaces the earlier `better-sqlite3` binding @@ -14,12 +19,14 @@ export type SqliteDatabase = { close(): void } -type DatabaseSyncCtor = new (path: string, options?: { readOnly?: boolean }) => { +type DatabaseSyncInstance = { prepare(sql: string): { all(...params: unknown[]): Row[] } exec?(sql: string): void close(): void } +type DatabaseSyncCtor = new (path: string, options?: { readOnly?: boolean }) => DatabaseSyncInstance + let DatabaseSync: DatabaseSyncCtor | null = null let loadAttempted = false let loadError: string | null = null @@ -116,12 +123,219 @@ export function isSqliteBusyError(err: unknown): boolean { ) } +/// SQLite reports SQLITE_READONLY_DIRECTORY as ERR_SQLITE_ERROR with an extended +/// result code on the Node 22 builds CodeBurn supports. Keep the base-code check +/// so this also covers SQLITE_READONLY and its other extended variants, while +/// leaving ENOENT/SQLITE_CANTOPEN distinguishable to callers. +export function isSqliteReadonlyError(err: unknown): boolean { + const e = err as { code?: unknown; errcode?: unknown; errstr?: unknown; message?: unknown } | null + const code = typeof e?.code === 'string' ? e.code : '' + const errcode = typeof e?.errcode === 'number' ? e.errcode : null + const message = [ + typeof e?.message === 'string' ? e.message : '', + typeof e?.errstr === 'string' ? e.errstr : '', + ].join(' ') + + return ( + (errcode !== null && (errcode & 0xff) === 8) || + /SQLITE_READONLY|attempt to write a readonly database|readonly database|read-only database/i.test(`${code} ${message}`) + ) +} + +type DatabaseFingerprint = { + dev: number + ino: number + mtimeMs: number + sizeBytes: number +} + +type CachedDatabaseMetadata = { + version: number + sourcePath: string + fingerprint: DatabaseFingerprint +} + +const SQLITE_CACHE_VERSION = 1 +const warnedReadonlyDatabases = new Set() + +/// A read-only SQLite connection can still need sidecar files. This notice is +/// intentionally once per source path: a provider may discover many sessions +/// from the same database, and the fallback is already doing the useful work. +export function warnSqliteReadonlyOnce(path: string): void { + if (warnedReadonlyDatabases.has(path)) return + warnedReadonlyDatabases.add(path) + process.stderr.write( + `codeburn: SQLite database ${path} is in a read-only directory and needs sidecar files; using a cache copy when necessary. ` + + 'The original database is not modified.\n', + ) +} + +function errorCode(err: unknown): string | undefined { + if (typeof err !== 'object' || err === null || !('code' in err)) return undefined + const code = err.code + return typeof code === 'string' ? code : undefined +} + +/// This deliberately mirrors fingerprintSqliteFile/fingerprintFile in +/// session-cache.ts. openDatabase is synchronous, so the fallback uses the +/// synchronous fs APIs only after the direct open has already failed; the +/// ordinary successful open remains probe-free. +function fingerprintDatabase(path: string): DatabaseFingerprint { + const main = statSync(path) + let wal: ReturnType | null = null + try { + wal = statSync(path + '-wal') + } catch (err) { + if (errorCode(err) !== 'ENOENT') throw err + } + return { + dev: main.dev, + ino: main.ino, + mtimeMs: wal ? Math.max(main.mtimeMs, wal.mtimeMs) : main.mtimeMs, + sizeBytes: main.size + (wal?.size ?? 0), + } +} + +function sameFingerprint(a: DatabaseFingerprint, b: DatabaseFingerprint): boolean { + return ( + a.dev === b.dev && + a.ino === b.ino && + a.mtimeMs === b.mtimeMs && + a.sizeBytes === b.sizeBytes + ) +} + +function isDatabaseFingerprint(value: unknown): value is DatabaseFingerprint { + if (typeof value !== 'object' || value === null) return false + const candidate = value as Partial + return ( + typeof candidate.dev === 'number' && + typeof candidate.ino === 'number' && + typeof candidate.mtimeMs === 'number' && + typeof candidate.sizeBytes === 'number' + ) +} + +function readCachedMetadata(path: string): CachedDatabaseMetadata | null { + try { + const parsed: unknown = JSON.parse(readFileSync(path, 'utf8')) + if (typeof parsed !== 'object' || parsed === null) return null + const candidate = parsed as { version?: unknown; sourcePath?: unknown; fingerprint?: unknown } + if ( + candidate.version !== SQLITE_CACHE_VERSION || + typeof candidate.sourcePath !== 'string' || + !isDatabaseFingerprint(candidate.fingerprint) + ) return null + return { version: SQLITE_CACHE_VERSION, sourcePath: candidate.sourcePath, fingerprint: candidate.fingerprint } + } catch { + return null + } +} + +function unlinkIfPresent(path: string): void { + try { + unlinkSync(path) + } catch (err) { + if (errorCode(err) !== 'ENOENT') throw err + } +} + +function copyOptionalFile(sourcePath: string, destinationPath: string): boolean { + try { + copyFileSync(sourcePath, destinationPath) + return true + } catch (err) { + if (errorCode(err) === 'ENOENT') return false + throw err + } +} + +function readOnlyCachePath(sourcePath: string, fingerprint: DatabaseFingerprint): string { + const cacheDir = join(getCodeburnCacheDir(), 'sqlite-ro') + mkdirSync(cacheDir, { recursive: true, mode: 0o700 }) + + const sourceKey = createHash('sha256').update(sourcePath, 'utf8').digest('hex') + const cachePath = join(cacheDir, `${sourceKey}.db`) + const metadataPath = `${cachePath}.json` + const cached = readCachedMetadata(metadataPath) + if ( + existsSync(cachePath) && + cached?.sourcePath === sourcePath && + sameFingerprint(cached.fingerprint, fingerprint) + ) { + return cachePath + } + + const tempBase = `${cachePath}.tmp-${process.pid}-${randomBytes(8).toString('hex')}` + const tempWal = tempBase + '-wal' + const tempShm = tempBase + '-shm' + const tempMetadata = `${metadataPath}.tmp-${process.pid}-${randomBytes(8).toString('hex')}` + + try { + copyFileSync(sourcePath, tempBase) + const copiedWal = copyOptionalFile(sourcePath + '-wal', tempWal) + const copiedShm = copyOptionalFile(sourcePath + '-shm', tempShm) + + // Do not publish a cache made from a moving database. A live WAL writer will + // normally make the direct open succeed once its sidecars exist; this check + // covers the narrow race where the source changes during the copy fallback. + if (!sameFingerprint(fingerprintDatabase(sourcePath), fingerprint)) { + throw new Error('SQLite database changed while preparing its read-only cache copy') + } + + unlinkIfPresent(cachePath) + unlinkIfPresent(cachePath + '-wal') + unlinkIfPresent(cachePath + '-shm') + renameSync(tempBase, cachePath) + if (copiedWal) renameSync(tempWal, cachePath + '-wal') + if (copiedShm) renameSync(tempShm, cachePath + '-shm') + + const metadata: { version: number; sourcePath: string; fingerprint: DatabaseFingerprint } = { + version: SQLITE_CACHE_VERSION, + sourcePath, + fingerprint, + } + writeFileSync(tempMetadata, JSON.stringify(metadata), { encoding: 'utf8', mode: 0o600 }) + renameSync(tempMetadata, metadataPath) + return cachePath + } finally { + unlinkIfPresent(tempBase) + unlinkIfPresent(tempWal) + unlinkIfPresent(tempShm) + unlinkIfPresent(tempMetadata) + } +} + +function openReadonlyCache(path: string, originalError: unknown): DatabaseSyncInstance { + let fingerprint: DatabaseFingerprint + try { + fingerprint = fingerprintDatabase(path) + } catch { + // Preserve the original SQLite error when the source disappeared or became + // inaccessible between the failed query and the fallback probe. + throw originalError + } + const cachedPath = readOnlyCachePath(path, fingerprint) + const Driver = DatabaseSync + if (Driver === null) throw new Error(getSqliteLoadError()) + return new Driver(cachedPath, { readOnly: true }) +} + export function openDatabase(path: string): SqliteDatabase { if (!loadDriver() || DatabaseSync === null) { throw new Error(getSqliteLoadError()) } - const db = new DatabaseSync(path, { readOnly: true }) + let db: DatabaseSyncInstance + let fallbackUsed = false + try { + db = new DatabaseSync(path, { readOnly: true }) + } catch (err) { + if (!isSqliteReadonlyError(err)) throw err + fallbackUsed = true + warnSqliteReadonlyOnce(path) + db = openReadonlyCache(path, err) + } try { db.exec?.('PRAGMA busy_timeout = 1000') } catch { @@ -130,7 +344,26 @@ export function openDatabase(path: string): SqliteDatabase { return { query(sql: string, params: unknown[] = []): T[] { - return db.prepare(sql).all(...params) as T[] + try { + return db.prepare(sql).all(...params) as T[] + } catch (err) { + if (!isSqliteReadonlyError(err)) throw err + if (fallbackUsed) throw err + fallbackUsed = true + warnSqliteReadonlyOnce(path) + try { + db.close() + } catch { + // The failed connection may already have been closed by node:sqlite. + } + db = openReadonlyCache(path, err) + try { + db.exec?.('PRAGMA busy_timeout = 1000') + } catch { + // Best effort, matching the direct-open path above. + } + return db.prepare(sql).all(...params) as T[] + } }, close() { db.close() diff --git a/tests/sqlite-readonly-parent.test.ts b/tests/sqlite-readonly-parent.test.ts new file mode 100644 index 00000000..13a71af3 --- /dev/null +++ b/tests/sqlite-readonly-parent.test.ts @@ -0,0 +1,207 @@ +import { chmodSync, existsSync, readdirSync, statSync } from 'node:fs' +import { mkdtemp, rm } from 'node:fs/promises' +import { createRequire } from 'node:module' +import { join } from 'node:path' +import { tmpdir } from 'node:os' + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +import { + isSqliteReadonlyError, + openDatabase, +} from '../src/sqlite.js' +import { + discoverSqliteSessions, + type SqliteProviderConfig, +} from '../src/providers/sqlite-session-parser.js' + +const requireForTest = createRequire(import.meta.url) + +type NativeDatabase = { + exec(sql: string): void + prepare(sql: string): { run(...params: unknown[]): void; all(...params: unknown[]): unknown[] } + close(): void +} + +type NativeDatabaseCtor = new (path: string) => NativeDatabase + +const { DatabaseSync: NativeDatabase } = requireForTest('node:sqlite') as { + DatabaseSync: NativeDatabaseCtor +} + +let sourceRoot: string +let cacheRoot: string +let previousCacheDir: string | undefined +const openWriters: NativeDatabase[] = [] + +beforeEach(async () => { + sourceRoot = await mkdtemp(join(tmpdir(), 'codeburn-sqlite-source-')) + cacheRoot = await mkdtemp(join(tmpdir(), 'codeburn-sqlite-cache-')) + previousCacheDir = process.env['CODEBURN_CACHE_DIR'] + process.env['CODEBURN_CACHE_DIR'] = cacheRoot +}) + +afterEach(async () => { + chmodSync(sourceRoot, 0o755) + for (const writer of openWriters.splice(0)) writer.close() + await rm(sourceRoot, { recursive: true, force: true }) + await rm(cacheRoot, { recursive: true, force: true }) + if (previousCacheDir === undefined) delete process.env['CODEBURN_CACHE_DIR'] + else process.env['CODEBURN_CACHE_DIR'] = previousCacheDir +}) + +function createClosedWalDatabase(dbPath: string): void { + const db = new NativeDatabase(dbPath) + db.exec('PRAGMA journal_mode=WAL') + db.exec('CREATE TABLE values_table (c INTEGER)') + db.prepare('INSERT INTO values_table (c) VALUES (?)').run(1) + db.close() +} + +function createOpenWalDatabase(dbPath: string): NativeDatabase { + const db = new NativeDatabase(dbPath) + db.exec('PRAGMA journal_mode=WAL') + db.exec('CREATE TABLE values_table (c INTEGER)') + db.prepare('INSERT INTO values_table (c) VALUES (?)').run(1) + expect(existsSync(dbPath + '-wal')).toBe(true) + expect(existsSync(dbPath + '-shm')).toBe(true) + openWriters.push(db) + return db +} + +function createDiscoveryDatabase(dbPath: string): void { + const db = new NativeDatabase(dbPath) + db.exec('PRAGMA journal_mode=WAL') + db.exec(` + CREATE TABLE session ( + id TEXT PRIMARY KEY, + directory TEXT, + title TEXT, + time_created INTEGER, + parent_id TEXT, + time_archived INTEGER + ) + `) + db.exec('CREATE TABLE message (id TEXT PRIMARY KEY, session_id TEXT, time_created INTEGER, data BLOB)') + db.exec('CREATE TABLE part (id INTEGER PRIMARY KEY, message_id TEXT, session_id TEXT, data BLOB)') + db.prepare( + 'INSERT INTO session (id, directory, title, time_created, parent_id, time_archived) VALUES (?, ?, ?, ?, ?, ?)', + ).run('session-1', '/tmp/project', 'Read-only fixture', Date.now(), null, null) + db.close() +} + +function makeSourceParentReadOnly(skip: (reason?: string) => void): boolean { + chmodSync(sourceRoot, 0o555) + const mode = statSync(sourceRoot).mode & 0o777 + if ((mode & 0o222) !== 0) { + skip(`SKIP: chmod 0555 did not make the fixture parent non-writable (mode ${mode.toString(8)})`) + return false + } + return true +} + +function cachedDatabaseFiles(): string[] { + try { + return readdirSync(join(cacheRoot, 'sqlite-ro')).filter(name => name.endsWith('.db')) + } catch { + return [] + } +} + +function readValue(dbPath: string): number { + const db = openDatabase(dbPath) + try { + const rows = db.query<{ c: number }>('SELECT c FROM values_table') + return rows[0]?.c ?? -1 + } finally { + db.close() + } +} + +describe('SQLite read-only parent fallback', () => { + it('keeps the existing writable-parent open behaviour', () => { + const dbPath = join(sourceRoot, 'state.vscdb') + createClosedWalDatabase(dbPath) + expect(existsSync(dbPath + '-wal')).toBe(false) + expect(existsSync(dbPath + '-shm')).toBe(false) + + expect(readValue(dbPath)).toBe(1) + + expect(existsSync(dbPath + '-wal')).toBe(true) + expect(existsSync(dbPath + '-shm')).toBe(true) + expect(cachedDatabaseFiles()).toEqual([]) + }) + + it('reads a WAL database when the parent is read-only and sidecars are absent', ({ skip }) => { + const dbPath = join(sourceRoot, 'state.vscdb') + createClosedWalDatabase(dbPath) + expect(existsSync(dbPath + '-wal')).toBe(false) + expect(existsSync(dbPath + '-shm')).toBe(false) + if (!makeSourceParentReadOnly(skip)) return + + expect(readValue(dbPath)).toBe(1) + + expect(existsSync(dbPath + '-wal')).toBe(false) + expect(existsSync(dbPath + '-shm')).toBe(false) + expect(cachedDatabaseFiles()).toHaveLength(1) + }) + + it('opens directly when a read-only parent already has WAL sidecars', ({ skip }) => { + const dbPath = join(sourceRoot, 'state.vscdb') + createOpenWalDatabase(dbPath) + if (!makeSourceParentReadOnly(skip)) return + + expect(readValue(dbPath)).toBe(1) + expect(cachedDatabaseFiles()).toEqual([]) + }) + + it('reuses an unchanged fallback copy instead of copying the database again', ({ skip }) => { + const dbPath = join(sourceRoot, 'state.vscdb') + createClosedWalDatabase(dbPath) + if (!makeSourceParentReadOnly(skip)) return + + expect(readValue(dbPath)).toBe(1) + const cachedPath = join(cacheRoot, 'sqlite-ro', cachedDatabaseFiles()[0]!) + const firstMtime = statSync(cachedPath).mtimeMs + expect(readValue(dbPath)).toBe(1) + expect(statSync(cachedPath).mtimeMs).toBe(firstMtime) + }) + + it('keeps a genuinely missing database distinguishable from SQLITE_READONLY', () => { + let thrown: unknown + try { + openDatabase(join(sourceRoot, 'missing.vscdb')) + } catch (err) { + thrown = err + } + + expect(thrown).toBeDefined() + expect(isSqliteReadonlyError(thrown)).toBe(false) + expect(thrown).toMatchObject({ errcode: 14, message: 'unable to open database file' }) + }) + + it('surfaces one read-only notice in SQLite discovery and still finds the session', async ({ skip }) => { + const dbPath = join(sourceRoot, 'state.db') + createDiscoveryDatabase(dbPath) + if (!makeSourceParentReadOnly(skip)) return + + const config: SqliteProviderConfig = { + providerName: 'opencode', + displayName: 'OpenCode', + dbDir: sourceRoot, + dbFilePrefix: 'state', + } + const stderr = vi.spyOn(process.stderr, 'write').mockReturnValue(true) + try { + const sessions = await discoverSqliteSessions(config) + expect(sessions).toHaveLength(1) + expect(sessions[0]?.path).toBe(`${dbPath}:session-1`) + expect(stderr.mock.calls.filter(([chunk]) => String(chunk).includes('read-only directory'))).toHaveLength(1) + + await discoverSqliteSessions(config) + expect(stderr.mock.calls.filter(([chunk]) => String(chunk).includes('read-only directory'))).toHaveLength(1) + } finally { + stderr.mockRestore() + } + }) +}) From 525b3c1d71742cec360c241aef2d8695d83832d3 Mon Sep 17 00:00:00 2001 From: ozymandiashh <234437643+ozymandiashh@users.noreply.github.com> Date: Tue, 18 Aug 2026 05:49:16 +0300 Subject: [PATCH 31/85] fix(dash): keep the disambiguator visible, make the bootstrap safe by construction Three follow-ups from an adversarial pass over this branch. The title cap was sized against the wrong number. 80 code points was chosen "for both the max-w-40 legend and the tooltip", but max-w-40 is 160px and the legend renders at text-[10px], which shows roughly 32 characters. Everything past that is clipped -- and that is exactly where the short session id, the provider and every collision-tier suffix lived. Two sessions in one repository whose AI titles share a 32-character prefix rendered as the same legend entry, which is worse than main and is the scenario #997 is about. The label now leads with the disambiguator so it is always inside the visible width, and both the legend and the tooltip carry title= so the full label is reachable on hover. injectDashboardBootstrap was not safe by construction. Extracting the helper fixed the $-substitution problem but left the security-critical '<' escaping at the call site 94 lines away, and the new test called the helper with raw JSON.stringify output -- so deleting that escape left every test green while the served page became injectable through any project, device or model name. Nothing in tests/ asserted that escaping at all. The escaping moves inside the helper, with a test that pushes through a payload value. preferredSessionTitle picked alphabetically, not most recently. types.ts documents title as the last ai-title entry, so when one session id yields two summaries the legend could show the superseded one. It now picks the greatest lastTimestamp, keeping the alphabetical order only to break exact ties so the result stays deterministic. Entries are also ordered by key before the collision tiers run, so the same corpus cannot emit a different label set depending on input order. --- CHANGELOG.md | 2 +- dash/src/components/UsageChart.tsx | 9 +++- src/granular-history.ts | 66 +++++++++++++++++++----- src/web-dashboard.ts | 13 +++-- tests/granular-history.test.ts | 82 +++++++++++++++++++++--------- tests/web-dashboard.test.ts | 28 ++++++++-- 6 files changed, 151 insertions(+), 49 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5e3f4a53..50a89a52 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,7 +15,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 -- **The session chart legend now leads with the session title instead of the project path.** Every series in a monorepo shared the same project prefix, so the only thing separating them was a truncated hex fragment — and per-application cost attribution is the main reason to open that chart. `SessionSummary.title` is already parsed and already rendered in the Context tab; the legend now prefers it, keeps the short session id as the disambiguator for sessions that share a title, and falls back to the previous project-based label when a session never produced one. Titles come from transcripts, so they are stripped of ANSI and control characters and capped before they reach either the legend or the tooltip. (#997) +- **The session chart legend now leads with a visible session disambiguator and title instead of the project path.** Every series in a monorepo shared the same project prefix, so the only thing separating them was a truncated hex fragment — and per-application cost attribution is the main reason to open that chart. `SessionSummary.title` is already parsed and already rendered in the Context tab; the legend now puts the short session id first, prefers the title, and falls back to the previous project-based label when a session never produced one. Titles come from transcripts, so they are stripped of ANSI and control characters and capped before they reach either the legend or the tooltip. (#997) - **Bash command splitting was quadratic on long whitespace-heavy commands.** The separator regex retried its leading `\s*` from every offset; matching the separator alone and widening over whitespace by hand makes cold parse ~24% and warm ~40% faster on large corpora, output unchanged. - **Cold parse no longer retains full message bodies through cached previews.** `flatSlice` skipped its Buffer round-trip for strings already within the bound, but provider adapters pre-truncate user-message previews with `.slice(0, 500)` before the cache-site call — those pre-sliced views are still V8 SlicedStrings pinning their large parent, so the retention that OOM'd cold parses of large histories survived. The round-trip now always runs. - **Kiro sessions carry the real `projectPath`** (CLI meta.cwd, v2 `workspacePaths[0]`, workspace sessions' `workspaceDirectory`), so git-repo attribution can resolve them; previously they were attribution-blind. Bumps the kiro parse version, so the first run after upgrade re-parses kiro history once, and kiro sessions in linked git worktrees now group under the main repo. diff --git a/dash/src/components/UsageChart.tsx b/dash/src/components/UsageChart.tsx index 926409e8..d3c6b17d 100644 --- a/dash/src/components/UsageChart.tsx +++ b/dash/src/components/UsageChart.tsx @@ -33,7 +33,12 @@ function makeTooltip(labels: Record, fmt: (n: number) => string, {items.slice(0, 6).map((p: any) => (
- {labels[String(p.dataKey)] ?? String(p.dataKey)} + + {labels[String(p.dataKey)] ?? String(p.dataKey)} + {fmt(p.value)}
))} @@ -172,7 +177,7 @@ function GranularLines({ {series.map(item => ( - {item.label} + {item.label} ))} diff --git a/src/granular-history.ts b/src/granular-history.ts index 7d75e0bb..75b6e328 100644 --- a/src/granular-history.ts +++ b/src/granular-history.ts @@ -7,9 +7,9 @@ const ONE_HOUR = 60 const ONE_DAY = 24 * 60 const MINUTE_MS = 60 * 1000 const MAX_SERIES_PER_METRIC = 6 -// Keep metadata bounded for both the max-w-40 legend and the tooltip: 80 -// characters preserves a useful title without letting the parser's 200-char -// transcript cap dominate either UI surface. +// Keep metadata bounded for the legend and tooltip: 80 characters preserves a +// useful title without letting the parser's 200-char transcript cap dominate +// either UI surface. const MAX_SESSION_TITLE_LENGTH = 80 export type GranularSeries = { @@ -47,12 +47,17 @@ type RawBucket = { sessions: Map } +type SessionTitleCandidate = { + title: string + lastTimestamp: string +} + type SessionLabelInfo = { provider: string projectPath: string projectNames: Set sessionId: string - titleCandidates: Set + titleCandidates: Map } type SessionLabelEntry = { @@ -134,27 +139,54 @@ function cleanSessionTitle(title: string | undefined): string | undefined { return Array.from(cleaned).slice(0, MAX_SESSION_TITLE_LENGTH).join('').trimEnd() || undefined } +// SessionSummary.lastTimestamp is normally an ISO timestamp, but fixtures and +// older cache entries can be incomplete. Valid timestamps win over invalid +// ones; two invalid values are an exact tie and are resolved alphabetically by +// the caller. +function compareTimestamps(a: string, b: string): number { + const aMs = Date.parse(a) + const bMs = Date.parse(b) + const aValid = Number.isFinite(aMs) + const bValid = Number.isFinite(bMs) + if (aValid && bValid) return aMs - bMs + if (aValid) return 1 + if (bValid) return -1 + return 0 +} + function preferredProjectName(projectNames: Set): string { return [...projectNames].sort()[0] ?? 'Unknown project' } -function preferredSessionTitle(titleCandidates: Set): string | undefined { - return [...titleCandidates] - .map(cleanSessionTitle) - .filter((title): title is string => title !== undefined) - .sort()[0] +function preferredSessionTitle(titleCandidates: Map): string | undefined { + const cleaned = [...titleCandidates.values()] + .map(candidate => { + const title = cleanSessionTitle(candidate.title) + return title === undefined ? undefined : { title, lastTimestamp: candidate.lastTimestamp } + }) + .filter((candidate): candidate is SessionTitleCandidate => candidate !== undefined) + + cleaned.sort((a, b) => { + const timestampOrder = compareTimestamps(b.lastTimestamp, a.lastTimestamp) + if (timestampOrder !== 0) return timestampOrder + return a.title < b.title ? -1 : a.title > b.title ? 1 : 0 + }) + return cleaned[0]?.title } function buildSessionLabels(inputs: 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: `${sessionLabel} · ${shortSessionId(info.sessionId)} (${info.provider})`, + baseLabel: `${shortSessionId(info.sessionId)} (${info.provider}) · ${sessionLabel}`, } - }) + }).sort((a, b) => a.key < b.key ? -1 : a.key > b.key ? 1 : 0) const byBaseLabel = new Map() for (const entry of entries) { const group = byBaseLabel.get(entry.baseLabel) ?? [] @@ -323,10 +355,18 @@ export function buildGranularHistory( projectPath: project.projectPath, projectNames: new Set(), sessionId: session.sessionId, - titleCandidates: new Set(), + titleCandidates: new Map(), } labelInfo.projectNames.add(projectName) - if (session.title !== undefined) labelInfo.titleCandidates.add(session.title) + if (session.title !== undefined) { + const existingTitle = labelInfo.titleCandidates.get(session.title) + if (!existingTitle || compareTimestamps(session.lastTimestamp, existingTitle.lastTimestamp) > 0) { + labelInfo.titleCandidates.set(session.title, { + title: session.title, + lastTimestamp: session.lastTimestamp, + }) + } + } sessionLabelInputs.set(sessionKey, labelInfo) callCount++ } diff --git a/src/web-dashboard.ts b/src/web-dashboard.ts index 400e7111..39416d7b 100644 --- a/src/web-dashboard.ts +++ b/src/web-dashboard.ts @@ -90,8 +90,13 @@ function openBrowser(url: string): void { } } -export function injectDashboardBootstrap(html: string, json: string): string { - return html.replace('\n \n ' - const injected = injectDashboardBootstrap(html, json) + const injected = injectDashboardBootstrap(html, payload) - expect(injected).toContain(`window.__CODEBURN_BOOTSTRAP__=${json}`) + expect(injected).toContain(`window.__CODEBURN_BOOTSTRAP__=${JSON.stringify(payload)}`) expect(injected).toContain(`"name":"${payloadValue}"`) }) + + it('escapes script-closing payload values and preserves the served bootstrap payload', () => { + const hostileName = '' + const payload = { + devices: [{ + id: 'local', + name: hostileName, + payload: { current: { topProjects: [{ name: hostileName }] } }, + }], + } + const html = '' + + const servedHtml = injectDashboardBootstrap(html, payload) + const marker = 'window.__CODEBURN_BOOTSTRAP__=' + const start = servedHtml.indexOf(marker) + marker.length + const end = servedHtml.indexOf('', start) + const serialized = servedHtml.slice(start, end) + + expect(serialized).not.toContain('') + expect(JSON.parse(serialized)).toEqual(payload) + }) }) // Regression guard for the original bug: a bad `period` query used to hit From e2007c5e2fcdc88bdabab7ccff1ea86e4ee057c8 Mon Sep 17 00:00:00 2001 From: iamtoruk Date: Tue, 18 Aug 2026 02:10:24 -0700 Subject: [PATCH 32/85] test(dashboard): wait for the Optimize scan on real event-loop turns, not fake-timer hops The Optimize scan does real fs I/O (readdir/stat) that only resolves on a real event-loop turn, but the wait loop counted 20 vi.advanceTimersByTimeAsync hops under full fake timers, which flush fake timers + microtasks but never give real I/O a chance to complete. Under load that read a stale "Scanning Today..." frame. Scope fake timers to just what the 60s auto-refresh interval needs, leave setImmediate/Date real, and wait on a real wall-clock deadline instead of a fixed hop count. --- tests/dashboard.test.ts | 19 ++++++++++++++++--- 1 file changed, 16 insertions(+), 3 deletions(-) diff --git a/tests/dashboard.test.ts b/tests/dashboard.test.ts index 9879c8a1..76107e6b 100644 --- a/tests/dashboard.test.ts +++ b/tests/dashboard.test.ts @@ -665,7 +665,12 @@ describe('InteractiveDashboard refresh', () => { }) it('keeps Optimize mounted without a loading frame when auto-refresh fires', async () => { - vi.useFakeTimers() + // The Optimize scan (`o`) does real fs I/O (readdir/stat) that only + // resolves on a real event-loop turn. Leave setImmediate/nextTick/Date + // real (Date stays real so the wait loop below can use a genuine + // wall-clock deadline) and fake only what the 60s auto-refresh + // interval needs. + vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout', 'setInterval', 'clearInterval'] }) const stdin = new PassThrough() as PassThrough & NodeJS.ReadStream const stdout = new PassThrough() as PassThrough & NodeJS.WriteStream stdin.isTTY = true @@ -712,7 +717,15 @@ describe('InteractiveDashboard refresh', () => { expect(activityHeader.indexOf('turns') + 'turns'.length).toBe(activityRow.indexOf('12') + '12'.length) expect(activityHeader.indexOf('1-shot') + '1-shot'.length).toBe(activityRow.indexOf('50%') + '50%'.length) stdin.write('o') - for (let i = 0; i < 20 && !frames.some(frame => frame.includes('Token estimates are approximate.')); i++) { + // The scan does real fs work, so wait on real event-loop turns + // (setImmediate is left un-faked above) rather than counting fake-timer + // hops, bounded by a real wall-clock deadline. + const realDeadline = Date.now() + 10_000 + while (!frames.some(frame => frame.includes('Token estimates are approximate.'))) { + if (Date.now() > realDeadline) { + throw new Error('Timed out waiting for the Optimize scan to render "Token estimates are approximate."') + } + await new Promise(resolve => setImmediate(resolve)) await vi.advanceTimersByTimeAsync(50) } const beforeRefresh = frames.filter(frame => frame.trim()).at(-1) ?? '' @@ -731,5 +744,5 @@ describe('InteractiveDashboard refresh', () => { expect(frame).not.toContain('Loading Today') expect(frame).not.toContain('Scanning Today') - }) + }, 30_000) }) From 7c54cf85c2ec201084171e6c97bc2b0078678105 Mon Sep 17 00:00:00 2001 From: iamtoruk Date: Tue, 18 Aug 2026 02:26:57 -0700 Subject: [PATCH 33/85] optimize: classify findings as fix/nudge/keep and mark measured vs estimated MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every finding now resolves to a class (apply-able fix, habit nudge, or informational keep) and a basis (measured from provider-counted usage, or estimated from a schema/heuristic model), both from one table next to the FindingId union. The class follows the plan layer: an id is 'fix' only when buildPlan routes it, and an instance drops to 'nudge' when it lacks the payload or cause its builder needs. CLI, TUI and the desktop app group findings under Fix now / Habits / FYI with continuous numbering; the CLI header reports 'N measured · M estimated' in place of the blanket 'Estimates only.' footer. The JSON report gains class + basis per finding and summary.measuredSavingsUSD; existing fields are unchanged. The menubar's top three follow the same order, since every surface reads the sorted findings list. Sessions whose cost the provider never reported leave the cost-outliers peer math; when nothing else is priced the comparison falls back to them and the finding reports itself as estimated instead of disappearing. --- app/renderer/App.test.tsx | 2 +- app/renderer/lib/types.ts | 5 + app/renderer/sections/Optimize.test.tsx | 21 ++- app/renderer/sections/Optimize.tsx | 16 ++- app/renderer/styles/plain.css | 3 + src/act/plans.ts | 2 +- src/dashboard.tsx | 20 ++- src/optimize.ts | 164 ++++++++++++++++++++++-- tests/dashboard.test.ts | 6 +- tests/optimize-apply.test.ts | 78 +++++++++++ tests/optimize.test.ts | 63 +++++++++ 11 files changed, 352 insertions(+), 28 deletions(-) diff --git a/app/renderer/App.test.tsx b/app/renderer/App.test.tsx index dbf5e8ff..2832cb4d 100644 --- a/app/renderer/App.test.tsx +++ b/app/renderer/App.test.tsx @@ -138,7 +138,7 @@ function installDefaultMocks() { summary: { healthScore: 100, healthGrade: 'A', findingCount: 0, periodCostUSD: 0, sessions: 0, calls: 0, potentialSavingsTokens: 0, potentialSavingsCostUSD: 0, - potentialSavingsPercent: 0, costRateUSD: 0, + potentialSavingsPercent: 0, costRateUSD: 0, measuredSavingsUSD: 0, }, findings: [], }) diff --git a/app/renderer/lib/types.ts b/app/renderer/lib/types.ts index 25394087..d1f61a79 100644 --- a/app/renderer/lib/types.ts +++ b/app/renderer/lib/types.ts @@ -407,6 +407,8 @@ export type WasteAction = | { type: 'command'; label: string; text: string } | { type: 'file-content'; label: string; path: string; content: string } +export type FindingClass = 'fix' | 'nudge' | 'keep' + export type OptimizeJsonReport = { period: { label: string; start: string | null; end: string | null } summary: { @@ -420,6 +422,7 @@ export type OptimizeJsonReport = { potentialSavingsCostUSD: number potentialSavingsPercent: number | null costRateUSD: number + measuredSavingsUSD: number } findings: Array<{ id: string @@ -429,6 +432,8 @@ export type OptimizeJsonReport = { trend: 'active' | 'improving' | null tokensSaved: number estimatedSavingsUSD: number + class: FindingClass + basis: 'measured' | 'estimated' fix: WasteAction }> } diff --git a/app/renderer/sections/Optimize.test.tsx b/app/renderer/sections/Optimize.test.tsx index 5219afaf..03b922b5 100644 --- a/app/renderer/sections/Optimize.test.tsx +++ b/app/renderer/sections/Optimize.test.tsx @@ -48,23 +48,26 @@ function makeOptimizeReport(): OptimizeJsonReport { healthScore: 72, healthGrade: 'C', findingCount: 3, periodCostUSD: 612.48, sessions: 88, calls: 1220, potentialSavingsTokens: 184_000, potentialSavingsCostUSD: 94.4, potentialSavingsPercent: 15.4, costRateUSD: 0.0005, + measuredSavingsUSD: 27.8, }, findings: [ { - id: 'cost-outliers', title: 'Opus is doing your small talk', + id: 'unused-mcp', title: 'Opus is doing your small talk', explanation: 'Small conversational requests are running on an expensive model.', severity: 'high', trend: 'active', tokensSaved: 18_200, estimatedSavingsUSD: 9.1, + class: 'fix', basis: 'estimated', fix: { type: 'paste', label: 'Paste into CLAUDE.md', text: 'Use Sonnet for routine questions.', destination: 'claude-md' }, }, { - id: 'context-heavy-sessions', title: 'Cache hit is low in agentseal-dash', + id: 'cost-outliers', title: 'Cache hit is low in agentseal-dash', explanation: 'Repeated context is not being served from cache.', severity: 'medium', - trend: null, tokensSaved: 17_400, estimatedSavingsUSD: 8.7, + trend: null, tokensSaved: 17_400, estimatedSavingsUSD: 8.7, class: 'nudge', basis: 'measured', fix: { type: 'command', label: 'Run this command', text: 'codeburn cache inspect' }, }, { - id: 'warmup-heavy', title: 'Batch tiny requests', explanation: 'Many short sessions repeat setup work.', + id: 'context-heavy-sessions', title: 'Batch tiny requests', explanation: 'Many short sessions repeat setup work.', severity: 'low', trend: 'improving', tokensSaved: 4_800, estimatedSavingsUSD: 2.4, + class: 'keep', basis: 'measured', fix: { type: 'file-content', label: 'Create configuration', path: '~/.codeburn/config.json', content: '{"batch":true}' }, }, ], @@ -121,6 +124,14 @@ describe('Optimize', () => { Object.defineProperty(navigator, 'clipboard', { configurable: true, value: { writeText } }) }) + it('groups Waste findings under the fix / habits / FYI headers in order', async () => { + render() + + await screen.findByText('Opus is doing your small talk') + const groups = document.querySelectorAll('.opt-group') + expect([...groups].map(g => g.textContent)).toEqual(['Fix now (apply-able)', 'Habits', 'FYI']) + }) + it('renders tabs and actionable Waste findings with impact, savings, explanation, and copy-paste fix', async () => { render() @@ -130,7 +141,7 @@ describe('Optimize', () => { expect(screen.getByText('Medium')).toHaveClass('opt-impact-medium') expect(screen.getByText('Low')).toHaveClass('opt-impact-low') expect(screen.getByText('$9.10')).toHaveClass('opt-finding-savings') - expect(screen.getByText('18.2K tokens')).toBeInTheDocument() + expect(screen.getByText('18.2K tokens · estimated')).toBeInTheDocument() expect(screen.getByRole('tab', { name: 'Waste $94.40' })).toBeInTheDocument() expect(screen.getByRole('tab', { name: 'Reverts $107.00' })).toBeInTheDocument() expect(screen.getByRole('tab', { name: 'Abandoned $65.40' })).toBeInTheDocument() diff --git a/app/renderer/sections/Optimize.tsx b/app/renderer/sections/Optimize.tsx index 03d8674b..e0a6b347 100644 --- a/app/renderer/sections/Optimize.tsx +++ b/app/renderer/sections/Optimize.tsx @@ -9,7 +9,7 @@ import { StaleBanner } from '../components/StaleBanner' import { type Polled, usePolled } from '../hooks/usePolled' import { formatCompact, formatUsd } from '../lib/format' import { codeburn } from '../lib/ipc' -import type { DateRange, MenubarPayload, OptimizeJsonReport, Period, SessionYieldJson, WasteAction, YieldJsonReport } from '../lib/types' +import type { DateRange, FindingClass, MenubarPayload, OptimizeJsonReport, Period, SessionYieldJson, WasteAction, YieldJsonReport } from '../lib/types' type OptimizeTab = 'waste' | 'reverts' | 'abandoned' | 'fixes' @@ -114,6 +114,12 @@ const IMPACT_ICON: Record<'high' | 'medium' | 'low', string> = { low: '↓', } +const CLASS_HEADERS: Record = { + fix: 'Fix now (apply-able)', + nudge: 'Habits', + keep: 'FYI', +} + function actionText(fix: WasteAction): string { return fix.type === 'file-content' ? fix.content : fix.text } @@ -132,10 +138,14 @@ function ActionableFindingRows({ findings }: { findings: OptimizeFinding[] }) { return (
- {findings.map(finding => { + {findings.map((finding, i) => { const expanded = expandedId === finding.id + // Findings arrive class-sorted from the CLI, so a header goes in + // wherever the class changes. + const showHeader = finding.class !== findings[i - 1]?.class return ( + {showHeader &&
{CLASS_HEADERS[finding.class]}
} {expanded && ( diff --git a/app/renderer/styles/plain.css b/app/renderer/styles/plain.css index f82172b9..ad1c10ee 100644 --- a/app/renderer/styles/plain.css +++ b/app/renderer/styles/plain.css @@ -640,6 +640,9 @@ td:first-child { font-size: var(--fs-body); font-weight: var(--fw-body); } .opt-waste { min-width: 0; } .opt-summary { padding: 0 0 10px; color: var(--mut); font-size: 11.5px; font-variant-numeric: tabular-nums; } .opt-findings { display: grid; min-width: 0; } +.opt-group { padding: 11px 0 5px; color: var(--mut2); font-size: 10px; font-weight: 600; letter-spacing: 0.06em; text-transform: uppercase; } +.opt-group:first-child { padding-top: 0; } +.opt-group + .opt-finding { border-top: 0; } .opt-finding { display: grid; align-items: center; column-gap: 12px; min-height: 43px; border-top: 1px solid var(--line2); } .opt-finding:first-child { border-top: 0; } .opt-finding-legacy { grid-template-columns: 28px minmax(0, 1fr) 104px 86px; } diff --git a/src/act/plans.ts b/src/act/plans.ts index b3ee4e39..308d2733 100644 --- a/src/act/plans.ts +++ b/src/act/plans.ts @@ -9,6 +9,7 @@ import { ALWAYSLOAD_STARTUP_CAP_SECONDS, ENABLE_TOOL_SEARCH_VAR, parseVersion, + SHELL_PROFILE_SCOPE, versionPredates, } from '../optimize.js' import type { WasteFinding } from '../optimize.js' @@ -386,7 +387,6 @@ const NEXT_SESSION_NOTE = 'takes effect on the next session (this config is read // findDeferralEnvSetting (src/optimize.ts) reports shell-profile hits with // exactly this scope string; the plan layer keys its refusal on it. -const SHELL_PROFILE_SCOPE = 'shell profile' const SHELL_TOOL_SEARCH_LINE = new RegExp(`^\\s*(?:export\\s+)?${ENABLE_TOOL_SEARCH_VAR}\\s*=.*$`, 'm') diff --git a/src/dashboard.tsx b/src/dashboard.tsx index 9cc3db50..d89e6e4b 100644 --- a/src/dashboard.tsx +++ b/src/dashboard.tsx @@ -1,6 +1,6 @@ import { homedir } from 'os' -import React, { useState, useCallback, useEffect, useLayoutEffect, useMemo, useRef } from 'react' +import React, { Fragment, useState, useCallback, useEffect, useLayoutEffect, useMemo, useRef } from 'react' import { render, Box, Text, measureElement, useInput, useApp, useWindowSize, type DOMElement } from 'ink' import { CATEGORY_LABELS, type DateRange, type ProjectSummary, type TaskCategory } from './types.js' import { formatCost, formatTokens, markEstimated, carriedCostNote } from './format.js' @@ -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 { scanAndDetect, type WasteFinding, type WasteAction, type OptimizeResult } from './optimize.js' +import { CLASS_HEADERS, findingBasis, findingClass, scanAndDetect, type FindingClass, type WasteFinding, type WasteAction, type OptimizeResult } from './optimize.js' import { aggregateFileChurn, buildCoachingNotes, computePricingCoverage, medianTimeToFirstEditMs, scanUserCorrections, worstOneShotCategory, type ReworkedFile } from './workflow-insights.js' import { estimateContextBudget, type ContextBudget } from './context-budget.js' import { dateKey } from './day-aggregator.js' @@ -1079,7 +1079,7 @@ function FindingPanel({ index, finding, costRate, width }: { index: number; find {trendBadge && {trendBadge}} {finding.explanation} - Savings: ~{formatTokens(finding.tokensSaved)} tokens (~{formatCost(costSaved)}) + Savings: ~{formatTokens(finding.tokensSaved)} tokens (~{formatCost(costSaved)}) {findingBasis(finding)} @@ -1119,8 +1119,18 @@ function OptimizeView({ findings, costRate, projects, label, width, healthScore, Showing {start + 1}–{end} of {total} · j/k to scroll )} - {visible.map((f, i) => )} - Token estimates are approximate. + {visible.map((f, i) => { + // Findings arrive class-sorted, so a header goes in wherever the class + // changes (including the top of the window after paging). + const cls = findingClass(f) + const previous: FindingClass | null = i > 0 ? findingClass(visible[i - 1]!) : null + return ( + + {cls !== previous && {CLASS_HEADERS[cls]}} + + + ) + })} ) } diff --git a/src/optimize.ts b/src/optimize.ts index 190a8a8b..29b0c754 100644 --- a/src/optimize.ts +++ b/src/optimize.ts @@ -264,6 +264,113 @@ export type FindingId = | 'unused-skills' | 'unused-commands' +/// How a finding is meant to be acted on: +/// - `fix` CodeBurn can write the change itself (`codeburn optimize --apply`) +/// - `nudge` behavioural, the user changes a habit +/// - `keep` informational; the cost may well be justified +export type FindingClass = 'fix' | 'nudge' | 'keep' + +/// Where a finding's `tokensSaved` number comes from: +/// - `measured` summed from provider-counted usage on the parsed calls +/// - `estimated` a schema/heuristic model (per-tool sizes, recovery fractions) +/// A detector that mixes the two counts as `estimated`. +export type FindingBasis = 'measured' | 'estimated' + +/// Static class per finding id. `fix` entries are exactly the ids `buildPlan` +/// (src/act/plans.ts) routes to a plan builder; tests assert the two lists +/// stay equal. Instances that lack the payload their builder needs fall back +/// to `nudge` via `findingClass`. +export const FINDING_CLASS: Record = { + 'read-edit-ratio': 'fix', // CLAUDE.md rule block + 'build-folder-reads': 'fix', // CLAUDE.md rule block + 'redundant-rereads': 'nudge', + 'warmup-heavy': 'nudge', + 'unused-mcp': 'fix', + 'mcp-low-coverage': 'fix', + 'mcp-project-scope': 'fix', + 'mcp-deferral-off': 'fix', + 'mcp-alwaysload-hygiene': 'fix', + 'mcp-defer-threshold': 'fix', + 'retry-heavy-capabilities': 'nudge', + 'low-worth-sessions': 'nudge', + 'context-heavy-sessions': 'keep', // context-heavy work is often load-bearing + 'cost-outliers': 'nudge', + 'claude-md-too-long': 'nudge', // trimming is a judgement call, not a rule block + 'bash-output-cap': 'fix', + 'unused-agents': 'fix', + 'unused-skills': 'fix', + 'unused-commands': 'fix', +} + +/// Ids whose plan is built from the `apply` payload: without it the plan +/// builder returns null, so the finding is only a nudge. +const CLASS_NEEDS_APPLY: ReadonlySet = new Set([ + 'unused-mcp', + 'mcp-low-coverage', + 'mcp-project-scope', + 'mcp-deferral-off', + 'mcp-alwaysload-hygiene', + 'mcp-defer-threshold', + 'unused-agents', + 'unused-skills', + 'unused-commands', +]) + +/// Static basis per finding id. Only the two session-level detectors sum +/// provider-counted tokens end to end; everything else multiplies a modelled +/// per-unit size or a recovery fraction. +export const FINDING_BASIS: Record = { + 'read-edit-ratio': 'estimated', // reads x AVG_TOKENS_PER_READ + 'build-folder-reads': 'estimated', // reads x AVG_TOKENS_PER_READ + 'redundant-rereads': 'estimated', // reads x AVG_TOKENS_PER_READ + 'warmup-heavy': 'estimated', // observed median minus a modelled baseline + 'unused-mcp': 'estimated', // tools x TOKENS_PER_MCP_TOOL x sessions + 'mcp-low-coverage': 'estimated', // schema-size model, only capped by observed cache tokens + 'mcp-project-scope': 'estimated', // same schema-size model + 'mcp-deferral-off': 'estimated', // schema-size model x affected sessions + 'mcp-alwaysload-hygiene': 'estimated', // tools x TOKENS_PER_MCP_TOOL x loaded sessions + 'mcp-defer-threshold': 'estimated', // definition-size model x sessions + 'retry-heavy-capabilities': 'estimated', // real turn tokens x recovery fraction + 'low-worth-sessions': 'estimated', // real session tokens x recovery fraction + 'context-heavy-sessions': 'measured', // counted input/cache tokens above the target ratio + 'cost-outliers': 'measured', // counted session tokens above the peer average + 'claude-md-too-long': 'estimated', // lines x CLAUDEMD_TOKENS_PER_LINE + 'bash-output-cap': 'estimated', // chars x BASH_TOKENS_PER_CHAR + 'unused-agents': 'estimated', // count x TOKENS_PER_AGENT_DEF + 'unused-skills': 'estimated', // count x TOKENS_PER_SKILL_DEF + 'unused-commands': 'estimated', // count x TOKENS_PER_COMMAND_DEF +} + +/// Scope label for a setting that lives in ~/.zshrc / ~/.bashrc. Plans never +/// rewrite shell profiles, they only report them. +export const SHELL_PROFILE_SCOPE = 'shell profile' + +export function findingClass(f: WasteFinding): FindingClass { + const base = FINDING_CLASS[f.id] + if (base !== 'fix') return base + if (CLASS_NEEDS_APPLY.has(f.id) && !f.apply) return 'nudge' + const apply = f.apply + if ((apply?.kind === 'defer-enable' || apply?.kind === 'defer-threshold') && apply.settingScope === SHELL_PROFILE_SCOPE) { + return 'nudge' + } + // Of the deferral causes only these two have a plan; the rest are manual + // advice (Vertex policy, an outdated Claude Code, an unverified proxy). + if (apply?.kind === 'defer-enable' && apply.cause !== 'env-false' && apply.cause !== 'proxy-verified') return 'nudge' + return 'fix' +} + +export function findingBasis(f: WasteFinding): FindingBasis { + return f.basis ?? FINDING_BASIS[f.id] +} + +const CLASS_ORDER: Record = { fix: 0, nudge: 1, keep: 2 } + +export const CLASS_HEADERS: Record = { + fix: 'Fix now (apply-able) — codeburn optimize --apply', + nudge: 'Habits', + keep: 'FYI', +} + // Cause taxonomy for defer-enable plans (mcp-deferral-off findings). // 'proxy-verified' is never produced by the detector today: it is reserved // for the #614 part-3 proxy verifier, which upgrades 'proxy-unknown' once a @@ -303,6 +410,9 @@ export type WasteFinding = { fix: WasteAction trend?: Trend apply?: FindingApply + /// Set only when a detector's basis varies per run (see detectSessionOutliers); + /// otherwise `FINDING_BASIS[id]` applies. Read through `findingBasis`. + basis?: FindingBasis } export type OptimizeResult = { @@ -330,6 +440,9 @@ export type OptimizeJsonReport = { potentialSavingsCostUSD: number potentialSavingsPercent: number | null costRateUSD: number + /// Portion of `potentialSavingsCostUSD` coming from `measured`-basis + /// findings. The total keeps its old meaning: measured plus estimated. + measuredSavingsUSD: number } findings: Array<{ id: FindingId @@ -339,6 +452,8 @@ export type OptimizeJsonReport = { trend: Trend | null tokensSaved: number estimatedSavingsUSD: number + class: FindingClass + basis: FindingBasis fix: WasteAction }> /// Files most reworked by edit-family calls, relative to project root (top 15). @@ -1716,7 +1831,7 @@ export function findDeferralEnvSetting( const content = readSessionFileSync(path) if (content === null) continue const match = content.match(linePattern) - if (match) return { value: match[1]!, scope: 'shell profile', path } + if (match) return { value: match[1]!, scope: SHELL_PROFILE_SCOPE, path } } return null } @@ -2814,9 +2929,17 @@ export function detectSessionOutliers(projects: ProjectSummary[], excludedSessio } const outliers: Outlier[] = [] + // Modelled costs (Kiro, Cursor, some Cline sessions) are not comparable + // against provider-reported ones, so they leave the peer math. Providers + // that only ever estimate would lose the finding entirely, so those fall + // back to the full set and the finding reports itself as estimated. + let usedEstimatedCosts = false for (const project of projects) { - const sessions = project.sessions.filter(s => s.totalCostUSD > 0) + const costed = project.sessions.filter(s => s.totalCostUSD > 0) + const exact = costed.filter(s => (s.totalEstimatedCostUSD ?? 0) === 0) + const sessions = exact.length >= MIN_SESSIONS_FOR_OUTLIER ? exact : costed + const fellBack = sessions.length > exact.length if (sessions.length < MIN_SESSIONS_FOR_OUTLIER) continue const totalCost = sessions.reduce((sum, s) => sum + s.totalCostUSD, 0) @@ -2835,6 +2958,7 @@ export function detectSessionOutliers(projects: ProjectSummary[], excludedSessio // "tighter constraint" advice here. if (excludedSessionIds?.has(session.sessionId)) continue + if (fellBack) usedEstimatedCosts = true outliers.push({ project: project.project, sessionId: session.sessionId, @@ -2864,6 +2988,7 @@ export function detectSessionOutliers(projects: ProjectSummary[], excludedSessio explanation: `Sessions costing more than ${SESSION_OUTLIER_MULTIPLIER}x their peer-session average in the same project: ${list}${extra}. These usually come from broad prompts, runaway loops, or context-heavy work that should be split into smaller sessions.`, impact: outliers.length >= 3 || totalExcessCost >= 10 ? 'high' : 'medium', tokensSaved, + ...(usedEstimatedCosts ? { basis: 'estimated' as const } : {}), fix: { type: 'paste', destination: 'session-opener', @@ -3080,7 +3205,10 @@ export async function scanAndDetect( : [] for (const f of ghostResults) if (f) findings.push(f) + // Urgency first, then class: every surface lists the apply-able fixes + // before the habit nudges, and orders by urgency inside each group. findings.sort((a, b) => urgencyScore(b) - urgencyScore(a)) + findings.sort((a, b) => CLASS_ORDER[findingClass(a)] - CLASS_ORDER[findingClass(b)]) const { score, grade } = computeHealth(findings) const modelRecommendations: ModelDefaultRecommendation[] = [] @@ -3164,7 +3292,7 @@ function renderFinding(n: number, f: WasteFinding, costRate: number): string[] { lines.push('') lines.push(wrap(f.explanation, PANEL_WIDTH - 4, ' ')) lines.push('') - lines.push(chalk.hex(GOLD)(` Potential savings: ${savings}`)) + lines.push(chalk.hex(GOLD)(` Potential savings: ${savings}`) + chalk.dim(` ${findingBasis(f)}`)) lines.push('') // Destination header — issue #277. Tells the user where each suggestion @@ -3207,7 +3335,7 @@ function renderWorkflowSection(reworkedFiles: ReworkedFile[], coachingNotes: str return lines } -function renderOptimize( +export function renderOptimize( findings: WasteFinding[], costRate: number, periodLabel: string, @@ -3228,12 +3356,16 @@ function renderOptimize( lines.push(chalk.hex(DIM)(' ' + SEP.repeat(PANEL_WIDTH))) const issueSuffix = findings.length > 0 ? `, ${findings.length} issue${findings.length > 1 ? 's' : ''}` : '' + const measured = findings.filter(f => findingBasis(f) === 'measured').length lines.push(' ' + [ `${sessionCount} sessions`, `${callCount.toLocaleString()} calls`, chalk.hex(GOLD)(formatCost(periodCost)), `Health: ${chalk.bold.hex(GRADE_COLORS[healthGrade])(healthGrade)}${chalk.dim(` (${healthScore}/100${issueSuffix})`)}`, ].join(chalk.hex(DIM)(' '))) + if (findings.length > 0) { + lines.push(chalk.dim(` ${measured} measured · ${findings.length - measured} estimated`)) + } if (appliedHeader) lines.push(' ' + chalk.hex(GREEN)(appliedHeader)) lines.push('') @@ -3257,15 +3389,22 @@ function renderOptimize( lines.push(chalk.hex(GREEN)(` Potential savings: ~${formatTokens(totalTokens)} tokens${costText}`)) lines.push('') - for (let i = 0; i < findings.length; i++) { - const f = findings[i]! - const appliedOn = previouslyApplied?.[f.id] - const shown = appliedOn ? { ...f, title: `${f.title} (previously applied ${appliedOn}, re-flagged)` } : f - lines.push(...renderFinding(i + 1, shown, costRate)) + // One block per class, in fix -> nudge -> keep order; numbering runs + // continuously across the blocks so `--only` picks stay unambiguous. + let n = 0 + for (const cls of ['fix', 'nudge', 'keep'] as const) { + const group = findings.filter(f => findingClass(f) === cls) + if (group.length === 0) continue + lines.push(chalk.bold.hex(ORANGE)(` ${CLASS_HEADERS[cls]}`)) + lines.push('') + 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(chalk.hex(DIM)(' ' + SEP.repeat(PANEL_WIDTH))) - lines.push(chalk.dim(' Estimates only.')) lines.push('') lines.push(...renderWorkflowSection(reworkedFiles, coachingNotes)) @@ -3365,6 +3504,9 @@ export function buildOptimizeJsonReport( potentialSavingsCostUSD, potentialSavingsPercent, costRateUSD: result.costRate, + measuredSavingsUSD: result.findings + .filter(f => findingBasis(f) === 'measured') + .reduce((s, f) => s + f.tokensSaved * result.costRate, 0), }, findings: result.findings.map(f => ({ id: f.id, @@ -3374,6 +3516,8 @@ export function buildOptimizeJsonReport( trend: f.trend ?? null, tokensSaved: f.tokensSaved, estimatedSavingsUSD: f.tokensSaved * result.costRate, + class: findingClass(f), + basis: findingBasis(f), fix: f.fix, })), ...buildWorkflowReport(projects), diff --git a/tests/dashboard.test.ts b/tests/dashboard.test.ts index 9879c8a1..1fe52c1e 100644 --- a/tests/dashboard.test.ts +++ b/tests/dashboard.test.ts @@ -712,12 +712,12 @@ describe('InteractiveDashboard refresh', () => { expect(activityHeader.indexOf('turns') + 'turns'.length).toBe(activityRow.indexOf('12') + '12'.length) expect(activityHeader.indexOf('1-shot') + '1-shot'.length).toBe(activityRow.indexOf('50%') + '50%'.length) stdin.write('o') - for (let i = 0; i < 20 && !frames.some(frame => frame.includes('Token estimates are approximate.')); i++) { + for (let i = 0; i < 20 && !frames.some(frame => frame.includes('Savings: ~')); i++) { await vi.advanceTimersByTimeAsync(50) } const beforeRefresh = frames.filter(frame => frame.trim()).at(-1) ?? '' expect(beforeRefresh).toContain('CodeBurn Optimize') - expect(beforeRefresh).toContain('Token estimates are approximate.') + expect(beforeRefresh).toContain('CodeBurn Optimize') frames.length = 0 await vi.advanceTimersByTimeAsync(60_000) @@ -726,7 +726,7 @@ describe('InteractiveDashboard refresh', () => { const frame = frames.filter(value => value.trim()).at(-1) ?? beforeRefresh expect(frame).toBe(beforeRefresh) expect(frame).toContain('CodeBurn Optimize') - expect(frame).toContain('Token estimates are approximate.') + expect(frame).toContain('CodeBurn Optimize') expect(frame).toContain('b back') expect(frame).not.toContain('Loading Today') expect(frame).not.toContain('Scanning Today') diff --git a/tests/optimize-apply.test.ts b/tests/optimize-apply.test.ts index 2b582284..3a2569b2 100644 --- a/tests/optimize-apply.test.ts +++ b/tests/optimize-apply.test.ts @@ -12,6 +12,9 @@ import { runAction } from '../src/act/apply.js' import { undoAction } from '../src/act/undo.js' import { readRecords, shortId } from '../src/act/journal.js' import { + FINDING_BASIS, + FINDING_CLASS, + findingClass, detectBloatedClaudeMd, detectDuplicateReads, detectJunkReads, @@ -653,3 +656,78 @@ describe('stale-plan detection', () => { expect(await readFile(p, 'utf-8')).toBe('overwritten') }) }) + +describe('finding class', () => { + it('covers every finding id with a class and a basis', () => { + expect(Object.keys(FINDING_BASIS).sort()).toEqual(Object.keys(FINDING_CLASS).sort()) + }) + + it("classes a finding 'fix' exactly when a plan can be built for it", async () => { + const fx = await makeFixture() + const claudeJson = join(fx.home, '.claude.json') + await writeFile(claudeJson, JSON.stringify({ mcpServers: { srv: { command: 's' } } }, null, 2) + '\n') + const settings = join(fx.project, '.claude', 'settings.json') + await mkdir(join(fx.project, '.claude'), { recursive: true }) + await writeFile(settings, JSON.stringify({ env: { ENABLE_TOOL_SEARCH: 'auto' } }, null, 2) + '\n') + const mcpJson = join(fx.project, '.mcp.json') + await writeFile(mcpJson, JSON.stringify({ mcpServers: { pinned: { command: 'x', alwaysLoad: true } } }, null, 2) + '\n') + await mkdir(join(fx.home, '.claude', 'skills', 'ghost'), { recursive: true }) + await mkdir(join(fx.home, '.claude', 'agents'), { recursive: true }) + await mkdir(join(fx.home, '.claude', 'commands'), { recursive: true }) + await writeFile(join(fx.home, '.claude', 'agents', 'ghost.md'), 'x') + await writeFile(join(fx.home, '.claude', 'commands', 'ghost.md'), 'x') + + const CLAUDE_MD_FIX: WasteAction = { type: 'paste', destination: 'claude-md', label: '', text: 'rule' } + const SHELL_FIX: WasteAction = { type: 'paste', destination: 'shell-config', label: '', text: 'export X=1' } + const PROMPT_FIX: WasteAction = { type: 'paste', destination: 'prompt', label: '', text: 'ask' } + const OPENER_FIX: WasteAction = { type: 'paste', destination: 'session-opener', label: '', text: 'o' } + + // One representative finding per id, carrying the payload its plan + // builder needs. Ids without a builder get a plain prompt fix. + const representatives: Record = { + 'read-edit-ratio': makeFinding('read-edit-ratio', CLAUDE_MD_FIX), + 'build-folder-reads': makeFinding('build-folder-reads', CLAUDE_MD_FIX), + 'redundant-rereads': makeFinding('redundant-rereads', PROMPT_FIX), + 'warmup-heavy': makeFinding('warmup-heavy', SHELL_FIX), + 'unused-mcp': makeFinding('unused-mcp', CMD_FIX, { kind: 'mcp-remove', servers: ['srv'] }), + 'mcp-low-coverage': makeFinding('mcp-low-coverage', CMD_FIX, { kind: 'mcp-remove', servers: ['srv'] }), + 'mcp-project-scope': makeFinding('mcp-project-scope', PROMPT_FIX, { + kind: 'mcp-project-scope', + servers: [{ server: 'srv', keepProjects: [fx.project], removeProjects: [] }], + }), + 'mcp-deferral-off': makeFinding('mcp-deferral-off', CMD_FIX, { + kind: 'defer-enable', cause: 'env-false', settingPath: settings, settingScope: 'project settings', value: 'false', + }), + 'mcp-alwaysload-hygiene': makeFinding('mcp-alwaysload-hygiene', PROMPT_FIX, { + kind: 'defer-alwaysload', + servers: [{ server: 'pinned', paths: [mcpJson] }], + }), + 'mcp-defer-threshold': makeFinding('mcp-defer-threshold', PROMPT_FIX, { + kind: 'defer-threshold', settingPath: settings, settingScope: 'project settings', + value: 'auto', recommendedPercent: 2, removeOverride: false, + }), + 'retry-heavy-capabilities': makeFinding('retry-heavy-capabilities', PROMPT_FIX), + 'low-worth-sessions': makeFinding('low-worth-sessions', OPENER_FIX), + 'context-heavy-sessions': makeFinding('context-heavy-sessions', OPENER_FIX), + 'cost-outliers': makeFinding('cost-outliers', OPENER_FIX), + 'claude-md-too-long': makeFinding('claude-md-too-long', PROMPT_FIX), + 'bash-output-cap': makeFinding('bash-output-cap', SHELL_FIX), + 'unused-agents': makeFinding('unused-agents', CMD_FIX, { kind: 'archive', names: ['ghost'] }), + 'unused-skills': makeFinding('unused-skills', CMD_FIX, { kind: 'archive', names: ['ghost'] }), + 'unused-commands': makeFinding('unused-commands', CMD_FIX, { kind: 'archive', names: ['ghost'] }), + } + + const planCtx: PlanContext = { homeDir: fx.home, cwd: fx.project, shell: '/bin/zsh', claudeVersion: () => '2.1.130' } + for (const finding of Object.values(representatives)) { + const hasPlan = planFor(finding, planCtx) !== null + expect([finding.id, hasPlan]).toEqual([finding.id, findingClass(finding) === 'fix']) + } + }) + + it("drops to 'nudge' when the instance lacks the payload its plan needs", () => { + const finding = makeFinding('mcp-deferral-off', { type: 'paste', destination: 'shell-config', label: '', text: 'x' }) + expect(FINDING_CLASS['mcp-deferral-off']).toBe('fix') + expect(findingClass(finding)).toBe('nudge') + expect(planFor(finding)).toBeNull() + }) +}) diff --git a/tests/optimize.test.ts b/tests/optimize.test.ts index bc72dd88..96ba9655 100644 --- a/tests/optimize.test.ts +++ b/tests/optimize.test.ts @@ -26,6 +26,9 @@ import { computeHealth, computeTrend, buildOptimizeJsonReport, + renderOptimize, + findingBasis, + type FindingId, type ToolCall, type ApiCallMeta, type WasteFinding, @@ -1004,6 +1007,29 @@ describe('detectSessionOutliers', () => { expect(finding!.tokensSaved).toBeGreaterThan(0) }) + it('keeps estimated-cost sessions out of the peer math', () => { + const project = projectWithSessions([1, 1, 1, 10]) + // The expensive session is priced from modelled tokens, so it is not + // comparable against the provider-reported peers and never gets flagged. + project.sessions[3]!.totalEstimatedCostUSD = project.sessions[3]!.totalCostUSD + expect(detectSessionOutliers([project])).toBeNull() + }) + + it('falls back to estimated costs when nothing else is priced, and says so', () => { + const project = projectWithSessions([1, 1, 1, 10]) + for (const s of project.sessions) s.totalEstimatedCostUSD = s.totalCostUSD + const finding = detectSessionOutliers([project]) + expect(finding).not.toBeNull() + expect(finding!.basis).toBe('estimated') + expect(findingBasis(finding!)).toBe('estimated') + }) + + it('reports measured basis when every peer cost is provider-reported', () => { + const finding = detectSessionOutliers([projectWithSessions([1, 1, 1, 10])]) + expect(finding!.basis).toBeUndefined() + expect(findingBasis(finding!)).toBe('measured') + }) + it('ignores tiny absolute-cost outliers', () => { expect(detectSessionOutliers([projectWithSessions([0.01, 0.01, 0.01, 0.2])])).toBeNull() }) @@ -1240,6 +1266,7 @@ describe('buildOptimizeJsonReport', () => { healthGrade: 'C', findings: [ { + id: 'claude-md-too-long', title: 'Trim stale context', explanation: 'Old instructions are loaded every turn.', impact: 'medium', @@ -1283,12 +1310,15 @@ describe('buildOptimizeJsonReport', () => { potentialSavingsPercent: 20, costRateUSD: 0.00002, }) + expect(report.summary.measuredSavingsUSD).toBe(0) expect(report.findings[0]).toMatchObject({ title: 'Trim stale context', severity: 'medium', trend: 'active', tokensSaved: 50_000, estimatedSavingsUSD: 1, + class: 'nudge', + basis: 'estimated', fix: { type: 'paste', destination: 'claude-md', @@ -1296,3 +1326,36 @@ describe('buildOptimizeJsonReport', () => { }) }) }) + +describe('renderOptimize grouping', () => { + const plain = (s: string): string => s.replace(/\[[0-9;]*m/g, '') + + function finding(id: FindingId, title: string): WasteFinding { + return { + id, + title, + explanation: 'why', + impact: 'medium', + tokensSaved: 1000, + fix: { type: 'paste', destination: 'prompt', label: 'ask', text: 'ask' }, + } + } + + it('groups findings under fix / habits / FYI with continuous numbering and a basis split', () => { + const findings = [ + finding('bash-output-cap', 'Cap bash output'), + finding('claude-md-too-long', 'Trim CLAUDE.md'), + finding('context-heavy-sessions', 'Context-heavy sessions'), + ] + const out = plain(renderOptimize(findings, 0.00001, '7 Days', 10, 5, 100, 80, 'B', [], [])) + + const headers = ['Fix now (apply-able)', 'Habits', 'FYI'].map(h => out.indexOf(h)) + expect(headers.every(i => i >= 0)).toBe(true) + expect(headers).toEqual([...headers].sort((a, b) => a - b)) + expect(out).toContain('1. Cap bash output') + expect(out).toContain('2. Trim CLAUDE.md') + expect(out).toContain('3. Context-heavy sessions') + expect(out).toContain('1 measured · 2 estimated') + expect(out).not.toContain('Estimates only.') + }) +}) From 6267c49c25d961bfa4814f888b53f8745da7d151 Mon Sep 17 00:00:00 2001 From: iamtoruk Date: Tue, 18 Aug 2026 02:27:01 -0700 Subject: [PATCH 34/85] docs: document optimize classes, provenance, and what --apply writes Adds docs/optimize.md (what optimize scans, the three classes, the exact files --apply may touch plus undo, measured vs estimated, the health grade bands, the --yes CLAUDE.md guardrail), links it from the README waste section, and corrects the detector count in docs/architecture.md (14 -> 19). --- CHANGELOG.md | 3 ++ README.md | 6 +++ docs/architecture.md | 2 +- docs/optimize.md | 104 +++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 114 insertions(+), 1 deletion(-) create mode 100644 docs/optimize.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 9ee86d04..a53889d8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,9 @@ ## Unreleased +### Added +- **Optimize findings say what to do with them and where their number came from.** Every finding now carries a class and a basis, and every surface groups by it: `Fix now (apply-able)` for findings `codeburn optimize --apply` can write itself, `Habits` for the behavioural ones, `FYI` for informational ones whose cost may be justified. A finding only counts as apply-able when a plan can actually be built for that instance, so an `mcp-deferral-off` caused by Vertex policy or a shell-profile override is grouped as a habit rather than promising a fix that does not exist. Alongside it, each finding is marked `measured` (summed from provider-counted usage) or `estimated` (a schema-size or recovery-fraction model), with the split reported in the header as `N measured · M estimated` in place of the blanket "Estimates only." footer. Sessions whose cost the provider never reported are kept out of the `cost-outliers` peer comparison, and a provider that only ever estimates gets the finding marked `estimated` rather than dropped. `--format json` gains `class` and `basis` per finding plus `summary.measuredSavingsUSD` (existing fields unchanged), and the new `docs/optimize.md` covers what is scanned, exactly what `--apply` may write, and how to read the health grade. + ### Added (CLI) - **DeepSeek Harness (`dsh`) is now a supported provider.** Reads DeepSeek's open-source agent harness from `~/.dsh/sessions` (`DSH_HOME` relocates the root), both the default zstd logs and the uncompressed `session.jsonl` variant. A `.zstd` log is a concatenation of independent zstd frames, one per write batch, so it is decoded frame by frame behind a structural frame scan and a torn trailing frame from a crashed writer is ignored rather than failing the file (needs Node 22.15+ for `zlib` zstd; below that dsh is skipped with a notice instead of counted as $0). One call per `(turn, step)`, with the step's final `assistant/message` usage superseding the streamed `assistant/chunk` sample of the same call rather than adding to it, the model taken from the message that served the step, and reasoning tokens billed at the output rate. DSH records tokens but no cost, so calls are priced from the shared tables. The events a forked session replays from its parent are skipped, since codeburn already counts the parent's own log. The session format is pinned at version 0 upstream with no compatibility implied, so a log stamped with any other version is skipped with a notice instead of read under today's assumptions. diff --git a/README.md b/README.md index ee283d92..47a1166b 100644 --- a/README.md +++ b/README.md @@ -167,6 +167,12 @@ codeburn optimize --format json # setup health + findings as JSON - Possibly low-worth expensive sessions with no edit turns or repeated retries when no `git`/`gh` delivery command is observed +Findings are grouped into three classes: **Fix now** (CodeBurn can apply it for you), **Habits** +(you change how you drive the next session), and **FYI** (informational, the cost may be justified). +Each one says whether its savings number is `measured` from provider-counted usage or `estimated` +from a model. See [docs/optimize.md](docs/optimize.md) for what is scanned, what `--apply` may write, +and how to read the health grade. + Each finding shows the estimated token and dollar savings plus a ready-to-paste fix: a `CLAUDE.md` line, an environment variable, or a `mv` command to archive unused items. Findings are ranked by urgency (impact weighted against observed waste) and rolled up into an A to F setup health grade. Repeat runs classify each finding as new, improving, or resolved against a 48-hour recent window. You can also open it inline from the dashboard: press `o` when a finding count appears in the status bar, `b` to return. diff --git a/docs/architecture.md b/docs/architecture.md index 4125ae13..85a24839 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -144,7 +144,7 @@ All three use atomic write (temp file + `rename`) and write with mode `0o600`. A ### Optimize Detectors -`src/optimize.ts` exports 14 detectors. Each returns a `WasteFinding | null`. They are composed by `runOptimize()` which collects findings, ranks them by impact, and returns them with `WasteAction` objects (paste-to-CLAUDE.md, paste-to-session-opener, prompt-now, edit shell config). +`src/optimize.ts` exports 19 detectors. Each returns a `WasteFinding | null`. They are composed by `runOptimize()` which collects findings, ranks them by impact, and returns them with `WasteAction` objects (paste-to-CLAUDE.md, paste-to-session-opener, prompt-now, edit shell config). | Detector | Line | What it catches | |---|---|---| diff --git a/docs/optimize.md b/docs/optimize.md new file mode 100644 index 00000000..87e59d5b --- /dev/null +++ b/docs/optimize.md @@ -0,0 +1,104 @@ +# optimize + +`codeburn optimize` scans your Claude Code sessions and your `~/.claude/` setup, reports what is +costing tokens without earning them, and grades the setup A to F. + +## What it scans + +- **Session transcripts** for the selected period: tool calls, per-call token usage, turn retries, + per-session cost. This is where re-reads, junk directory reads, low read:edit ratios, warmup + overhead, retries, and expensive or context-heavy sessions come from. +- **Your configuration**: `~/.claude.json`, user and project `settings.json` / `settings.local.json`, + `.mcp.json`, `CLAUDE.md` (including `@`-imports), and the `skills/`, `agents/`, `commands/` + directories. This is where unused MCP servers, MCP deferral gaps, ghost skills/agents/commands, + the bash output cap, and oversized `CLAUDE.md` files come from. + +Nothing is written during a scan. Only `--apply` writes. + +## The three classes + +Every finding carries a `class`, and both the CLI and the apps group by it: + +| Class | Header | Meaning | +|---|---|---| +| `fix` | Fix now (apply-able) | CodeBurn can make this change for you: `codeburn optimize --apply` | +| `nudge` | Habits | Behavioural. Nothing to edit; the fix is how you drive the next session | +| `keep` | FYI | Informational. The cost may well be justified; decide for yourself | + +A finding is `fix` only when a plan can actually be built for that instance. The same detector can +report a `fix` in one run and a `nudge` in another: `mcp-deferral-off` is appliable when the cause is +an `ENABLE_TOOL_SEARCH` override in a settings file, but manual when the cause is Vertex AI policy, +an outdated Claude Code, or an override that lives in your shell profile. + +## What `--apply` may write + +`--apply` builds a plan per finding, shows you the exact files it will touch, and asks before +writing. `--dry-run` prints the plan and stops. + +| Finding | File it edits | +|---|---| +| `unused-mcp`, `mcp-low-coverage` | `~/.claude.json`, project `.mcp.json` / `settings.json` (removes the server entry) | +| `mcp-project-scope` | moves a global server entry into the keeper project's `.mcp.json` | +| `mcp-deferral-off` | the settings file carrying the `ENABLE_TOOL_SEARCH` override | +| `mcp-alwaysload-hygiene` | the config files carrying `"alwaysLoad": true` | +| `mcp-defer-threshold` | the settings file carrying the `auto:N` threshold | +| `unused-agents`, `unused-skills`, `unused-commands` | moves the files into `~/.claude//.archived/` | +| `bash-output-cap` | appends a marker block to `~/.zshrc` / `~/.bashrc` | +| `read-edit-ratio`, `build-folder-reads` | appends a marker block to the current project's `CLAUDE.md` | + +Every write is backed up and journaled first: + +```bash +codeburn act list # every change CodeBurn has made +codeburn act undo # restore the original files +codeburn act undo --last +``` + +Undo refuses if a file changed after the apply, unless you pass `--force`. + +### The `--yes` CLAUDE.md guardrail + +`--apply --yes` skips the prompt for every plan except `CLAUDE.md` rule blocks. Those land in the +`CLAUDE.md` of whatever directory you happen to be in, so a blanket `--yes` from an unrelated +directory would write advice into the wrong project. To apply one anyway, use the interactive picker +or name it explicitly: + +```bash +codeburn optimize --apply --only read-edit-ratio +``` + +## measured vs estimated + +Each finding also carries a `basis`, printed next to its savings and summarised in the header as +`N measured · M estimated`: + +- **measured** — the token number is summed from provider-counted usage on your own calls. Today + that is `context-heavy-sessions` and `cost-outliers`. +- **estimated** — the token number comes from a model: a per-tool schema size, a per-line `CLAUDE.md` + cost, an average read size, a recovery fraction applied to real turn tokens. A detector that mixes + counted tokens with a model counts as estimated. + +Sessions whose cost the provider never reported (Kiro, Cursor, some Cline sessions price from +modelled token counts) are kept out of the `cost-outliers` peer comparison, so a modelled cost is +never called an outlier against provider-reported ones. When a provider only ever estimates, the +comparison falls back to those sessions and the finding reports itself as `estimated`. + +In `--format json`, `summary.measuredSavingsUSD` is the share of `summary.potentialSavingsCostUSD` +that comes from measured findings. + +## Reading the health grade + +Health starts at 100 and loses points per finding: 15 for a high-impact one, 7 for medium, 3 for low. +The total penalty is capped at 80, so a long tail of small findings cannot sink the score to zero on +its own. The grade is a band over that score: + +| Grade | Score | +|---|---| +| A | 90-100 | +| B | 75-89 | +| C | 55-74 | +| D | 30-54 | +| F | below 30 | + +The grade rates your setup, not your spending: an expensive month with a clean configuration still +scores an A. From 566090980118d5648ca239dc9c7fb74faa925b0c Mon Sep 17 00:00:00 2001 From: iamtoruk Date: Tue, 18 Aug 2026 02:39:50 -0700 Subject: [PATCH 35/85] optimize: per-group subtotals in every finding render Each class header now carries its own token/dollar subtotal and finding count, so the apply-able slice is never mistaken for the whole board; the headline savings line names that slice explicitly. CLI and TUI share one classHeaderLine helper, the desktop app reads the same numbers from the new summary.byClass in --format json (add-only; the three subtotals sum to findingCount and potentialSavingsTokens). Also scopes the SHELL_PROFILE_SCOPE comment to what is actually true: the MCP deferral plans refuse to rewrite a shell profile, but bash-output-cap appends its own marker block to one. --- app/renderer/App.test.tsx | 5 +++ app/renderer/lib/types.ts | 1 + app/renderer/sections/Optimize.test.tsx | 11 +++++- app/renderer/sections/Optimize.tsx | 10 ++++-- src/dashboard.tsx | 5 +-- src/optimize.ts | 45 ++++++++++++++++++++++--- tests/optimize.test.ts | 17 +++++++++- 7 files changed, 82 insertions(+), 12 deletions(-) diff --git a/app/renderer/App.test.tsx b/app/renderer/App.test.tsx index 2832cb4d..d29b4c43 100644 --- a/app/renderer/App.test.tsx +++ b/app/renderer/App.test.tsx @@ -139,6 +139,11 @@ function installDefaultMocks() { healthScore: 100, healthGrade: 'A', findingCount: 0, periodCostUSD: 0, sessions: 0, calls: 0, potentialSavingsTokens: 0, potentialSavingsCostUSD: 0, potentialSavingsPercent: 0, costRateUSD: 0, measuredSavingsUSD: 0, + byClass: { + fix: { tokensSaved: 0, savingsUSD: 0, count: 0 }, + nudge: { tokensSaved: 0, savingsUSD: 0, count: 0 }, + keep: { tokensSaved: 0, savingsUSD: 0, count: 0 }, + }, }, findings: [], }) diff --git a/app/renderer/lib/types.ts b/app/renderer/lib/types.ts index d1f61a79..8cc1fd0a 100644 --- a/app/renderer/lib/types.ts +++ b/app/renderer/lib/types.ts @@ -423,6 +423,7 @@ export type OptimizeJsonReport = { potentialSavingsPercent: number | null costRateUSD: number measuredSavingsUSD: number + byClass: Record } findings: Array<{ id: string diff --git a/app/renderer/sections/Optimize.test.tsx b/app/renderer/sections/Optimize.test.tsx index 03b922b5..a01ed0e7 100644 --- a/app/renderer/sections/Optimize.test.tsx +++ b/app/renderer/sections/Optimize.test.tsx @@ -49,6 +49,11 @@ function makeOptimizeReport(): OptimizeJsonReport { sessions: 88, calls: 1220, potentialSavingsTokens: 184_000, potentialSavingsCostUSD: 94.4, potentialSavingsPercent: 15.4, costRateUSD: 0.0005, measuredSavingsUSD: 27.8, + byClass: { + fix: { tokensSaved: 18_200, savingsUSD: 9.1, count: 1 }, + nudge: { tokensSaved: 17_400, savingsUSD: 8.7, count: 1 }, + keep: { tokensSaved: 4_800, savingsUSD: 2.4, count: 1 }, + }, }, findings: [ { @@ -129,7 +134,11 @@ describe('Optimize', () => { await screen.findByText('Opus is doing your small talk') const groups = document.querySelectorAll('.opt-group') - expect([...groups].map(g => g.textContent)).toEqual(['Fix now (apply-able)', 'Habits', 'FYI']) + expect([...groups].map(g => g.textContent)).toEqual([ + 'Fix now (apply-able) · 18.2K tokens · $9.10 · 1 finding', + 'Habits · 17.4K tokens · $8.70 · 1 finding', + 'FYI · 4.8K tokens · $2.40 · 1 finding', + ]) }) it('renders tabs and actionable Waste findings with impact, savings, explanation, and copy-paste fix', async () => { diff --git a/app/renderer/sections/Optimize.tsx b/app/renderer/sections/Optimize.tsx index e0a6b347..0d6ea911 100644 --- a/app/renderer/sections/Optimize.tsx +++ b/app/renderer/sections/Optimize.tsx @@ -101,7 +101,7 @@ function WasteRows({ report }: { report: Polled }) {
{report.data.summary.findingCount.toLocaleString('en-US')} findings · {formatUsd(report.data.summary.potentialSavingsCostUSD)} potential · health {report.data.summary.healthScore}/100
- +
) } @@ -124,7 +124,7 @@ function actionText(fix: WasteAction): string { return fix.type === 'file-content' ? fix.content : fix.text } -function ActionableFindingRows({ findings }: { findings: OptimizeFinding[] }) { +function ActionableFindingRows({ findings, byClass }: { findings: OptimizeFinding[]; byClass: OptimizeJsonReport['summary']['byClass'] }) { const [expandedId, setExpandedId] = useState(null) const [copiedId, setCopiedId] = useState(null) @@ -145,7 +145,11 @@ function ActionableFindingRows({ findings }: { findings: OptimizeFinding[] }) { const showHeader = finding.class !== findings[i - 1]?.class return ( - {showHeader &&
{CLASS_HEADERS[finding.class]}
} + {showHeader && ( +
+ {CLASS_HEADERS[finding.class]} · {formatCompact(byClass[finding.class].tokensSaved)} tokens · {formatUsd(byClass[finding.class].savingsUSD)} · {byClass[finding.class].count} {byClass[finding.class].count === 1 ? 'finding' : 'findings'} +
+ )} + ) + })} + + {preview && ( +
+
{preview.title}
+
{preview.body}
+
+ )} + + ) +} + +function previewFor( + id: Provider, + providers: Provider[], + costs: Record, + total: number, + currency: CurrencyState, +): { title: string; body: string } { + const meta = ALL_PROVIDERS.find(p => p.id === id)! + if (id === 'all') { + if (providers.length === 0) return { title: 'No tools detected yet', body: 'Run one of the supported tools once, then refresh.' } + return { + title: `${formatCurrency(total, currency)} today across ${plural(providers.length, 'tool')}`, + body: providers.map(p => `${PROVIDER_LABELS[p]} ${formatCompactCurrency(costs[p] ?? 0, currency)}`).join(' · '), + } + } + if (!providers.includes(id)) { + return { title: `${meta.label} not detected on this machine`, body: `CodeBurn watches ${meta.source}.` } + } + const cost = costs[id] ?? 0 + const share = total > 0 ? Math.round((cost / total) * 100) : 0 + return { + title: `${meta.label} · ${formatCurrency(cost, currency)} today`, + body: cost > 0 ? `${share}% of today's spend · click to filter every view` : 'No spend yet today · click to filter every view', + } +} diff --git a/windows/src/components/CollapsibleSection.tsx b/windows/src/components/CollapsibleSection.tsx new file mode 100644 index 00000000..ea4a42d8 --- /dev/null +++ b/windows/src/components/CollapsibleSection.tsx @@ -0,0 +1,44 @@ +import { useState, type ReactNode } from 'react' +import { ChevronRight } from './Icons' + +/// The macOS CollapsibleSection shell: 3px brand dot + caption, trailing column headers, +/// a chevron that rotates 90 degrees when open. Same component for Activity and Models so +/// the two headers can never drift apart again. + +type Props = { + caption: string + columns?: Array<{ label: string; width: number }> + defaultExpanded?: boolean + children: ReactNode +} + +export function CollapsibleSection({ caption, columns = [], defaultExpanded = true, children }: Props) { + const [expanded, setExpanded] = useState(defaultExpanded) + return ( +
+ + {expanded &&
{children}
} +
+ ) +} + +export function SectionCaption({ text, muted = true }: { text: string; muted?: boolean }) { + return ( + + + {text} + + ) +} diff --git a/windows/src/components/DropMenu.tsx b/windows/src/components/DropMenu.tsx new file mode 100644 index 00000000..00588629 --- /dev/null +++ b/windows/src/components/DropMenu.tsx @@ -0,0 +1,93 @@ +import { useEffect, useRef, useState, type ReactNode } from 'react' +import { CheckIcon } from './Icons' + +/// A bordered footer button that opens a small menu above itself, standing in for the +/// macOS `Menu` with `.bordered` style. Closes on outside click, Escape, or selection. + +export type MenuItem = { + id: string + label: string + checked?: boolean + disabled?: boolean + danger?: boolean + separatorBefore?: boolean +} + +type Props = { + label: ReactNode + title?: string + items: MenuItem[] + onSelect: (id: string) => void + align?: 'left' | 'right' + className?: string + /// Optional read-only footer line under the items (version, last update). + footnote?: string + /// Lay the items out in a grid (used for the 17-currency picker) instead of one column. + columns?: number +} + +export function DropMenu({ label, title, items, onSelect, align = 'left', className = '', footnote, columns = 1 }: Props) { + const [open, setOpen] = useState(false) + const rootRef = useRef(null) + + useEffect(() => { + if (!open) return + const onDown = (e: MouseEvent) => { + if (rootRef.current && !rootRef.current.contains(e.target as Node)) setOpen(false) + } + const onKey = (e: KeyboardEvent) => { + if (e.key === 'Escape') { + e.stopPropagation() + setOpen(false) + } + } + document.addEventListener('mousedown', onDown) + document.addEventListener('keydown', onKey, true) + return () => { + document.removeEventListener('mousedown', onDown) + document.removeEventListener('keydown', onKey, true) + } + }, [open]) + + return ( +
+ + {open && ( +
1 ? 'dropmenu-grid' : ''}`} + role="menu" + style={columns > 1 ? { gridTemplateColumns: `repeat(${columns}, minmax(0, 1fr))` } : undefined} + > + {items.map(item => ( +
+ {item.separatorBefore &&
} + +
+ ))} + {footnote &&
{footnote}
} +
+ )} +
+ ) +} diff --git a/windows/src/components/EmptyProviderState.tsx b/windows/src/components/EmptyProviderState.tsx new file mode 100644 index 00000000..e0843807 --- /dev/null +++ b/windows/src/components/EmptyProviderState.tsx @@ -0,0 +1,19 @@ +import type { Provider } from './AgentTabStrip' +import { PROVIDER_LABELS } from './AgentTabStrip' +import type { Period } from './PeriodTabs' +import { PERIOD_PHRASES } from './PeriodTabs' +import { TrayIcon } from './Icons' + +type Props = { + provider: Provider + period: Period +} + +export function EmptyProviderState({ provider, period }: Props) { + return ( +
+ +
No {PROVIDER_LABELS[provider]} data for {PERIOD_PHRASES[period]}
+
+ ) +} diff --git a/windows/src/components/ErrorToast.tsx b/windows/src/components/ErrorToast.tsx new file mode 100644 index 00000000..7855f369 --- /dev/null +++ b/windows/src/components/ErrorToast.tsx @@ -0,0 +1,29 @@ +import { useEffect, useRef } from 'react' +import { XIcon } from './Icons' + +const AUTO_DISMISS_MS = 8_000 + +type Props = { + message: string + onDismiss: () => void +} + +export function ErrorToast({ message, onDismiss }: Props) { + // Keep the latest handler in a ref so re-renders of the parent do not restart the timer. + const dismissRef = useRef(onDismiss) + dismissRef.current = onDismiss + + useEffect(() => { + const id = setTimeout(() => dismissRef.current(), AUTO_DISMISS_MS) + return () => clearTimeout(id) + }, [message]) + + return ( +
+ {message} + +
+ ) +} diff --git a/windows/src/components/FindingsSection.tsx b/windows/src/components/FindingsSection.tsx new file mode 100644 index 00000000..3745e021 --- /dev/null +++ b/windows/src/components/FindingsSection.tsx @@ -0,0 +1,72 @@ +import { useState } from 'react' +import type { MenubarPayload } from '../lib/payload' +import type { CurrencyState } from '../lib/currency' +import { computeTipGroups, type TipGroup } from '../lib/tips' +import { plural } from '../lib/currency' +import { ArrowForward, ArrowUpRightCircleIcon, BulbIcon, CheckCircleIcon, ChevronRight, WarningIcon } from './Icons' + +type Props = { + payload: MenubarPayload + currency: CurrencyState + onOpenTerminal: (args: string[]) => void +} + +export function FindingsSection({ payload, currency, onOpenTerminal }: Props) { + const [expanded, setExpanded] = useState(true) + const groups = computeTipGroups(payload, currency) + const totalSignals = groups.reduce((s, g) => s + g.items.length, 0) + if (totalSignals === 0) return null + + return ( +
+
+ + + {expanded && ( +
+ {groups.map(g => g.items.length > 0 && )} + {payload.optimize.findingCount > 0 && ( + + )} +
+ )} +
+
+ ) +} + +function GroupIcon({ icon }: { icon: string }) { + if (icon === 'check') return + if (icon === 'up') return + return +} + +function TipsGroupView({ group }: { group: TipGroup }) { + return ( +
+
+ + {group.label} +
+ {group.items.map((item, i) => ( +
+ + {item.text} + {item.trailing && {item.trailing}} +
+ ))} +
+ ) +} diff --git a/windows/src/components/FooterBar.tsx b/windows/src/components/FooterBar.tsx new file mode 100644 index 00000000..68490006 --- /dev/null +++ b/windows/src/components/FooterBar.tsx @@ -0,0 +1,81 @@ +import type { CurrencyState } from '../lib/currency' +import { CURRENCY_CODES } from '../lib/currency' +import { DropMenu } from './DropMenu' +import { CoinIcon, DownloadIcon, EllipsisIcon, RefreshIcon, TerminalIcon } from './Icons' + +type Props = { + currency: CurrencyState + onCurrency: (code: string) => void + loading: boolean + onRefresh: () => void + onExport: (format: 'csv' | 'json') => void + onOpenReport: () => void + onToggleTheme: () => void + onQuit: () => void + themeLabel: string + footnote: string + trayBadge: boolean + onToggleTrayBadge: () => void + onOpenSettings: () => void + settingsOpen: boolean +} + +export function FooterBar({ + currency, onCurrency, loading, onRefresh, onExport, onOpenReport, onToggleTheme, onQuit, themeLabel, footnote, + trayBadge, onToggleTrayBadge, onOpenSettings, settingsOpen, +}: Props) { + return ( +
+ {currency.code}} + items={CURRENCY_CODES.map(c => ({ id: c, label: c, checked: c === currency.code }))} + columns={3} + onSelect={onCurrency} + /> + + Export} + items={[ + { id: 'csv', label: 'CSV (folder)' }, + { id: 'json', label: 'JSON' }, + ]} + onSelect={id => onExport(id as 'csv' | 'json')} + /> + + + } + className="dropmenu-more" + items={[ + { id: 'settings', label: settingsOpen ? 'Back to overview' : 'Settings…' }, + { id: 'badge', label: "Show today's cost in tray", checked: trayBadge, separatorBefore: true }, + { id: 'theme', label: themeLabel }, + { id: 'quit', label: 'Quit CodeBurn', separatorBefore: true }, + ]} + footnote={footnote} + onSelect={id => { + if (id === 'settings') onOpenSettings() + if (id === 'badge') onToggleTrayBadge() + if (id === 'theme') onToggleTheme() + if (id === 'quit') onQuit() + }} + /> +
+ ) +} diff --git a/windows/src/components/ForecastInsight.tsx b/windows/src/components/ForecastInsight.tsx new file mode 100644 index 00000000..3ac30329 --- /dev/null +++ b/windows/src/components/ForecastInsight.tsx @@ -0,0 +1,59 @@ +import type { DailyEntry } from '../lib/payload' +import type { CurrencyState } from '../lib/currency' +import { formatCurrency, formatCompactCurrency } from '../lib/currency' +import { computeHistoryStats } from '../lib/history' +import { ArrowUpRight, ArrowDownRight } from './Icons' + +const WEEK_DAYS = 7 + +type Props = { + days: DailyEntry[] + currency: CurrencyState +} + +export function ForecastInsight({ days, currency }: Props) { + const s = computeHistoryStats(days) + const prevDelta = s.previousMonthTotal && s.previousMonthTotal > 0 + ? ((s.monthProjection - s.previousMonthTotal) / s.previousMonthTotal) * 100 + : null + + return ( +
+
+
+
Month-to-date
+
{formatCurrency(s.monthToDate, currency)}
+
+
+
On pace for
+
{formatCurrency(s.monthProjection, currency)}
+
+
+ +
+
+
Avg/day (this wk)
+
{formatCompactCurrency(s.weekTotal / WEEK_DAYS, currency)}
+
+
+
Yesterday
+
{formatCompactCurrency(s.yesterday, currency)}
+
+
+
Last 7d
+
{formatCompactCurrency(s.weekTotal, currency)}
+
+
+ + {prevDelta !== null && s.previousMonthTotal !== null && ( +
+ {prevDelta >= 0 ? : } + + {prevDelta >= 0 ? '+' : ''}{Math.round(prevDelta)}% vs last month + ({formatCompactCurrency(s.previousMonthTotal, currency)}) + +
+ )} +
+ ) +} diff --git a/windows/src/components/HeroSection.tsx b/windows/src/components/HeroSection.tsx new file mode 100644 index 00000000..706a9991 --- /dev/null +++ b/windows/src/components/HeroSection.tsx @@ -0,0 +1,43 @@ +import type { MenubarPayload } from '../lib/payload' +import type { CurrencyState } from '../lib/currency' +import { formatCurrency, plural } from '../lib/currency' +import { prettyDate, todayKey } from '../lib/dates' +import { SectionCaption } from './CollapsibleSection' + +type Props = { + payload: MenubarPayload | null + currency: CurrencyState + periodLabel: string + isToday: boolean +} + +export function HeroSection({ payload, currency, periodLabel, isToday }: Props) { + const todayLabel = prettyDate(todayKey()) + const caption = isToday ? `Today · ${todayLabel}` : (payload?.current.label || periodLabel) + + return ( +
+ +
+ {payload ? ( +
{formatCurrency(payload.current.cost, currency)}
+ ) : ( +
+ )} +
+ {payload ? ( + <> + {payload.current.calls.toLocaleString()} {payload.current.calls === 1 ? 'call' : 'calls'} + {plural(payload.current.sessions, 'session')} + + ) : ( + <> + + + + )} +
+
+
+ ) +} diff --git a/windows/src/components/Icons.tsx b/windows/src/components/Icons.tsx new file mode 100644 index 00000000..ae433822 --- /dev/null +++ b/windows/src/components/Icons.tsx @@ -0,0 +1,195 @@ +import type { SVGProps } from 'react' + +/// Small stroke icons standing in for the SF Symbols the macOS app uses. All are drawn on +/// a 16x16 grid at 1.5px stroke so they sit on the same optical baseline as 11px text. + +type IconProps = SVGProps & { size?: number } + +function Svg({ size = 12, children, ...rest }: IconProps) { + return ( + + ) +} + +export const FLAME_PATH = 'M8 1.5c.4 2.2 1.9 3.3 3.1 4.6C12.4 7.5 13 8.9 13 10.3 13 13 10.8 15 8 15s-5-2-5-4.7c0-1.6.7-2.8 1.5-3.7.2 1 .8 1.8 1.6 2.2C6 7 6.6 4.5 8 1.5z' + +export function FlameIcon({ filled = false, ...p }: IconProps & { filled?: boolean }) { + return +} + +export function ChevronRight(p: IconProps) { + return +} + +export function ChevronDown(p: IconProps) { + return +} + +export function RefreshIcon(p: IconProps) { + return ( + + + + + ) +} + +export function DownloadIcon(p: IconProps) { + return ( + + + + + ) +} + +export function TerminalIcon(p: IconProps) { + return ( + + + + + ) +} + +export function CoinIcon(p: IconProps) { + return ( + + + + + ) +} + +export function StarIcon(p: IconProps) { + return ( + + + + ) +} + +export function XIcon(p: IconProps) { + return +} + +export function BulbIcon(p: IconProps) { + return ( + + + + + ) +} + +export function CheckCircleIcon(p: IconProps) { + return ( + + + + + ) +} + +export function ArrowUpRightCircleIcon(p: IconProps) { + return ( + + + + + ) +} + +export function WarningIcon({ filled = true, ...p }: IconProps & { filled?: boolean }) { + return ( + + + + + ) +} + +export function ArrowUpRight(p: IconProps) { + return +} + +export function ArrowDownRight(p: IconProps) { + return +} + +export function ArrowForward(p: IconProps) { + return +} + +export function KeySlashIcon(p: IconProps) { + return ( + + + + + ) +} + +export function PersonDashedIcon(p: IconProps) { + return ( + + + + + + ) +} + +export function TrayIcon(p: IconProps) { + return ( + + + + + ) +} + +export function EllipsisIcon(p: IconProps) { + return ( + + + + + + ) +} + +export function CheckIcon(p: IconProps) { + return +} + +export function SunMoonIcon(p: IconProps) { + return ( + + + + + ) +} + +export function PowerIcon(p: IconProps) { + return ( + + + + + ) +} diff --git a/windows/src/components/InsightPills.tsx b/windows/src/components/InsightPills.tsx new file mode 100644 index 00000000..abd71a5d --- /dev/null +++ b/windows/src/components/InsightPills.tsx @@ -0,0 +1,41 @@ +export type InsightMode = 'plan' | 'trend' | 'forecast' | 'pulse' | 'stats' + +export const INSIGHT_LABELS: Record = { + plan: 'Plan', + trend: 'Trend', + forecast: 'Forecast', + pulse: 'Pulse', + stats: 'Stats', +} + +/// Same order as the macOS InsightMode enum: Plan first when it is visible. +export const INSIGHT_ORDER: InsightMode[] = ['plan', 'trend', 'forecast', 'pulse', 'stats'] + +export function isInsightMode(value: string | null): value is InsightMode { + return value !== null && value in INSIGHT_LABELS +} + +type Props = { + selected: InsightMode + onSelect: (m: InsightMode) => void + modes: InsightMode[] +} + +export function InsightPills({ selected, onSelect, modes }: Props) { + return ( +
+ {modes.map(m => ( + + ))} +
+ ) +} diff --git a/windows/src/components/LoadingOverlay.tsx b/windows/src/components/LoadingOverlay.tsx new file mode 100644 index 00000000..3d81276c --- /dev/null +++ b/windows/src/components/LoadingOverlay.tsx @@ -0,0 +1,44 @@ +import { FLAME_PATH } from './Icons' + +/// The macOS BurnLoadingOverlay: a blurred sheet over the scroll area with a flame that +/// fills bottom-to-top on a 1.4s loop while a soft glow pulses behind it. + +type Props = { periodLabel: string } + +export function LoadingOverlay({ periodLabel }: Props) { + return ( +
+
+ +
Loading {periodLabel}…
+
+
+ ) +} + +export function BurnFlame({ size = 64 }: { size?: number }) { + return ( +
+ + + +
+ ) +} diff --git a/windows/src/components/ModelsSection.tsx b/windows/src/components/ModelsSection.tsx new file mode 100644 index 00000000..9e61bf09 --- /dev/null +++ b/windows/src/components/ModelsSection.tsx @@ -0,0 +1,47 @@ +import type { Model } from '../lib/payload' +import type { CurrencyState } from '../lib/currency' +import { formatCompactCurrency, formatTokens } from '../lib/currency' +import { CollapsibleSection } from './CollapsibleSection' +import { FixedBar, COL_COST, COL_COUNT } from './ActivitySection' + +type Props = { + models: Model[] + inputTokens: number + outputTokens: number + cacheHitPercent: number + currency: CurrencyState +} + +export function ModelsSection({ models, inputTokens, outputTokens, cacheHitPercent, currency }: Props) { + if (models.length === 0) return null + const maxCost = Math.max(...models.map(m => m.cost), 0.01) + + return ( + + {models.map(m => ( +
+ + {m.name} + {formatCompactCurrency(m.cost, currency)} + {m.calls} +
+ ))} + {(inputTokens > 0 || outputTokens > 0) && ( +
+ Tokens + {formatTokens(inputTokens)} in + · + {formatTokens(outputTokens)} out + · + {Math.round(cacheHitPercent)}% cache hit +
+ )} +
+ ) +} diff --git a/windows/src/components/NoDataState.tsx b/windows/src/components/NoDataState.tsx new file mode 100644 index 00000000..cfd31a36 --- /dev/null +++ b/windows/src/components/NoDataState.tsx @@ -0,0 +1,35 @@ +/// First-run copy for a machine where the CLI ran fine but found no sessions. Paths are +/// shown the way the reader's own OS spells them. + +import { homePath } from '../lib/platform' + +const SOURCES: Array<{ path: string | null; tool: string }> = [ + { path: homePath('.claude', 'projects'), tool: 'Claude Code' }, + { path: homePath('.codex', 'sessions'), tool: 'Codex CLI' }, + { path: null, tool: 'Cursor local database' }, + { path: null, tool: 'GitHub Copilot session events' }, + { path: homePath('.local', 'share', 'opencode'), tool: 'OpenCode' }, + { path: homePath('.pi'), tool: 'Pi' }, +] + +export function NoDataState({ onRefresh }: { onRefresh: () => void }) { + return ( +
+

No session data yet

+

+ CodeBurn reads local session logs written by your AI coding tools. None of the + supported tools have recorded a session on this machine yet. +

+

Watched locations

+
    + {SOURCES.map(s => ( +
  • + {s.path ? <>{s.path} {s.tool} : s.tool} +
  • + ))} +
+

Run one of those tools for a session, then refresh.

+ +
+ ) +} diff --git a/windows/src/components/PeriodTabs.tsx b/windows/src/components/PeriodTabs.tsx new file mode 100644 index 00000000..38d3cce9 --- /dev/null +++ b/windows/src/components/PeriodTabs.tsx @@ -0,0 +1,41 @@ +export type Period = 'today' | 'week' | '30days' | 'month' | 'all' + +export const PERIOD_LABELS: Record = { + today: 'Today', week: '7 Days', '30days': '30 Days', month: 'Month', all: 'All', +} + +/// Short phrase used in sentences ("Sessions (7 days)", "No Claude data for this month"). +export const PERIOD_PHRASES: Record = { + today: 'today', + week: 'the last 7 days', + '30days': 'the last 30 days', + month: 'this month', + all: 'all time', +} + +const PERIODS = Object.keys(PERIOD_LABELS) as Period[] + +type Props = { + selected: Period + onSelect: (p: Period) => void +} + +export function PeriodTabs({ selected, onSelect }: Props) { + return ( +
+ +
+ ) +} diff --git a/windows/src/components/PlanInsight.tsx b/windows/src/components/PlanInsight.tsx new file mode 100644 index 00000000..76788e1f --- /dev/null +++ b/windows/src/components/PlanInsight.tsx @@ -0,0 +1,147 @@ +import { useEffect, useState } from 'react' +import { invoke } from '@tauri-apps/api/core' +import type { MenubarPayload } from '../lib/payload' +import type { CurrencyState } from '../lib/currency' +import { formatCompactCurrency, formatTokens, plural } from '../lib/currency' +import { relativeFuture } from '../lib/dates' +import type { PlanUsage, PlanWindow } from '../lib/plan' +import { projectWindow, earliestReset } from '../lib/plan' +import { BulbIcon, ChevronRight, KeySlashIcon, PersonDashedIcon, WarningIcon, ArrowUpRight } from './Icons' + +/// Sonnet-weighted approximation the mac app uses to turn a dollar saving into tokens. +const USD_PER_MILLION_EFFECTIVE_TOKENS = 9 +const MILLION = 1_000_000 +const PLAN_REFRESH_MS = 5 * 60_000 + +type LoadState = + | { kind: 'idle' } + | { kind: 'loading' } + | { kind: 'loaded'; usage: Extract } + | { kind: 'no_credentials' } + | { kind: 'failed'; message: string } + +type Props = { + payload: MenubarPayload | null + currency: CurrencyState + onOpenTerminal: (args: string[]) => void + onConnectClaude: () => void +} + +export function PlanInsight({ payload, currency, onOpenTerminal, onConnectClaude }: Props) { + const [state, setState] = useState({ kind: 'idle' }) + const [now, setNow] = useState(() => new Date()) + + const load = async () => { + setState(prev => (prev.kind === 'loaded' ? prev : { kind: 'loading' })) + try { + const usage = await invoke('plan_usage') + if (usage.state === 'ok') setState({ kind: 'loaded', usage }) + else if (usage.state === 'no_credentials') setState({ kind: 'no_credentials' }) + else setState({ kind: 'failed', message: usage.message }) + } catch (err) { + setState({ kind: 'failed', message: err instanceof Error ? err.message : String(err) }) + } + setNow(new Date()) + } + + useEffect(() => { + load() + const id = setInterval(load, PLAN_REFRESH_MS) + return () => clearInterval(id) + }, []) + + switch (state.kind) { + case 'idle': + case 'loading': + return ( +
+ +
Loading your plan...
+
Reading Claude Code credentials from this machine.
+
+ ) + case 'no_credentials': + return ( +
+ +
No Claude subscription connected
+
Click Connect to sign in with Claude in a terminal, then return here.
+
+ + +
+
+ ) + case 'failed': + return ( +
+ +
Couldn't load plan data
+
{state.message}
+
+ + +
+
+ ) + case 'loaded': { + const { usage } = state + const reset = earliestReset(usage.windows) + return ( +
+
+ {usage.tier} + {reset && Resets {relativeFuture(reset, now)}} +
+
+ {usage.windows.map(w => )} +
+ {payload && payload.optimize.findingCount > 0 && payload.optimize.savingsUSD > 0 && ( + + )} +
+ ) + } + } +} + +function UtilizationRow({ window, now }: { window: PlanWindow; now: Date }) { + const projection = projectWindow(window, now) + const clamped = Math.min(Math.max(window.percent, 0), 100) + const marker = projection ? Math.min(Math.max(projection.percent, 0), 100) : null + + let caption: string | null = null + if (projection) { + const pct = Math.round(projection.percent) + if (projection.source === 'historical') caption = `Based on last cycle: ${pct}%` + else if (projection.willOverflow && projection.hitsLimitAt) caption = `On pace: ${pct}% at reset · hits 100% ${relativeFuture(projection.hitsLimitAt, now)}` + else caption = `On pace: ${pct}% at reset` + } + + return ( +
+
+ {window.label} + {Math.round(clamped)}% +
+
+
+ {marker !== null &&
} +
+ {caption && ( +
+ {projection?.willOverflow ? : } + {caption} +
+ )} +
+ ) +} diff --git a/windows/src/components/PulseInsight.tsx b/windows/src/components/PulseInsight.tsx new file mode 100644 index 00000000..1668e8c8 --- /dev/null +++ b/windows/src/components/PulseInsight.tsx @@ -0,0 +1,32 @@ +import type { MenubarPayload } from '../lib/payload' +import type { CurrencyState } from '../lib/currency' +import { formatCompactCurrency } from '../lib/currency' + +type Props = { + payload: MenubarPayload + currency: CurrencyState +} + +export function PulseInsight({ payload, currency }: Props) { + const { cacheHitPercent, oneShotRate, cost, sessions } = payload.current + const cacheText = cacheHitPercent <= 0 ? '-' : `${Math.round(cacheHitPercent)}%` + const oneShotText = oneShotRate == null ? '-' : `${Math.round(oneShotRate * 100)}%` + const costPerSession = sessions > 0 ? formatCompactCurrency(cost / sessions, currency) : '-' + + return ( +
+
+
Cache hit
+
{cacheText}
+
+
+
1-shot
+
{oneShotText}
+
+
+
Cost / session
+
{costPerSession}
+
+
+ ) +} diff --git a/windows/src/components/SettingsPanel.tsx b/windows/src/components/SettingsPanel.tsx new file mode 100644 index 00000000..00f8b04b --- /dev/null +++ b/windows/src/components/SettingsPanel.tsx @@ -0,0 +1,156 @@ +import { useEffect, useState, type ReactNode } from 'react' +import { invoke } from '@tauri-apps/api/core' +import type { CurrencyState } from '../lib/currency' +import { CURRENCY_CODES } from '../lib/currency' +import { homePath } from '../lib/platform' +import type { CliStatus } from './SetupState' +import { DropMenu } from './DropMenu' +import { ChevronDown, ChevronRight } from './Icons' + +/// Preferences that have no home in the popover proper. Deliberately small: the mac app has +/// no settings window at all, so everything here is a Windows/Linux need (login item, tray +/// text) or a convenience the footer already offers in a smaller form. + +export type ThemeChoice = 'system' | 'light' | 'dark' + +const GITHUB_URL = 'https://github.com/getagentseal/codeburn' + +type Props = { + onBack: () => void + version: string + currency: CurrencyState + onCurrency: (code: string) => void + themeChoice: ThemeChoice + onThemeChoice: (t: ThemeChoice) => void + trayBadge: boolean + onTrayBadge: (on: boolean) => void + cliStatus: CliStatus | null + onCheckCli: () => void + onProbeCli: () => void + cliChecking: boolean + onQuit: () => void +} + +export function SettingsPanel({ + onBack, version, currency, onCurrency, themeChoice, onThemeChoice, trayBadge, onTrayBadge, + cliStatus, onCheckCli, onProbeCli, cliChecking, onQuit, +}: Props) { + const [loginItem, setLoginItem] = useState(null) + const [loginError, setLoginError] = useState(null) + + useEffect(() => { + invoke('launch_at_login').then(setLoginItem).catch(() => setLoginItem(false)) + if (!cliStatus) onProbeCli() + // Probe once when the panel opens; cliStatus arriving later must not re-trigger it. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []) + + const toggleLogin = async () => { + if (loginItem === null) return + setLoginError(null) + try { + setLoginItem(await invoke('set_launch_at_login', { enabled: !loginItem })) + } catch (err) { + setLoginError(err instanceof Error ? err.message : String(err)) + } + } + + return ( +
+
+ + Settings +
+ +
+
General
+ + + + {loginError &&
{loginError}
} + + onTrayBadge(!trayBadge)} /> + +
+ +
+
Appearance
+ +
+ {(['system', 'light', 'dark'] as ThemeChoice[]).map(t => ( + + ))} +
+
+ + {currency.code}} + items={CURRENCY_CODES.map(c => ({ id: c, label: c, checked: c === currency.code }))} + columns={3} + align="right" + onSelect={onCurrency} + /> + +
+ +
+
Data source
+ + + +
+ +
+
About
+ +
GitHub + + + + +
+
+ ) +} + +function Row({ label, hint, children }: { label: string; hint?: string; children: ReactNode }) { + return ( +
+
+
{label}
+ {hint &&
{hint}
} +
+
{children}
+
+ ) +} + +function Toggle({ on, disabled = false, onToggle }: { on: boolean; disabled?: boolean; onToggle: () => void }) { + return ( + + ) +} diff --git a/windows/src/components/SetupState.tsx b/windows/src/components/SetupState.tsx new file mode 100644 index 00000000..9c392e93 --- /dev/null +++ b/windows/src/components/SetupState.tsx @@ -0,0 +1,71 @@ +import { useState } from 'react' +import { BurnFlame } from './LoadingOverlay' +import { WarningIcon } from './Icons' + +/// Shown instead of the data views when the CLI is missing or too old. This is what a +/// brand-new Windows user sees, so it has to explain the one thing they need to do. + +export type CliStatus = { + found: boolean + program: string + version: string | null + min_version: string + compatible: boolean + error: string | null +} + +const INSTALL_COMMAND = 'npm install -g codeburn' + +type Props = { + status: CliStatus + checking: boolean + onCheckAgain: () => void +} + +export function SetupState({ status, checking, onCheckAgain }: Props) { + const [copied, setCopied] = useState(false) + const outdated = status.found && !status.compatible + + const copy = async () => { + try { + await navigator.clipboard.writeText(INSTALL_COMMAND) + setCopied(true) + setTimeout(() => setCopied(false), 1500) + } catch { + setCopied(false) + } + } + + return ( +
+ +

+ {outdated ? 'Update the CodeBurn CLI' : 'Install the CodeBurn CLI'} +

+

+ {outdated + ? `This app needs codeburn ${status.min_version} or newer; version ${status.version} was found.` + : 'The tray app reads everything through the codeburn command line tool, which is not installed on this machine yet.'} +

+
+ {INSTALL_COMMAND} + +
+

+ Requires Node.js 22 or newer. After installing, click Check again; no restart needed. +

+
+ +
+ {status.error && ( +
+ Details +
{status.error}
+
Looked for: {status.program}
+
+ )} +
+ ) +} diff --git a/windows/src/components/StarBanner.tsx b/windows/src/components/StarBanner.tsx new file mode 100644 index 00000000..ca359deb --- /dev/null +++ b/windows/src/components/StarBanner.tsx @@ -0,0 +1,29 @@ +import { useState } from 'react' +import { readSetting, writeSetting } from '../lib/settings' +import { StarIcon, XIcon } from './Icons' + +const GITHUB_URL = 'https://github.com/getagentseal/codeburn' + +export function StarBanner() { + const [dismissed, setDismissed] = useState(() => readSetting('starBannerDismissed') === 'true') + if (dismissed) return null + + const dismiss = () => { + writeSetting('starBannerDismissed', 'true') + setDismissed(true) + } + + return ( + + ) +} diff --git a/windows/src/components/StatsInsight.tsx b/windows/src/components/StatsInsight.tsx new file mode 100644 index 00000000..8ba13f5e --- /dev/null +++ b/windows/src/components/StatsInsight.tsx @@ -0,0 +1,63 @@ +import type { MenubarPayload } from '../lib/payload' +import type { CurrencyState } from '../lib/currency' +import { formatCurrency, formatCompactCurrency, plural } from '../lib/currency' +import { daysInMonth, monthDay } from '../lib/dates' +import { computeHistoryStats } from '../lib/history' +import type { Period } from './PeriodTabs' + +type Props = { + payload: MenubarPayload + currency: CurrencyState + period: Period +} + +const PERIOD_SUFFIX: Record = { + today: 'today', + week: '(7 days)', + '30days': '(30 days)', + month: '(month)', + all: '(all time)', +} + +export function StatsInsight({ payload, currency, period }: Props) { + const s = computeHistoryStats(payload.history.daily) + const suffix = PERIOD_SUFFIX[period] + + return ( +
+
+
+ + + + +
+
+ + + 0 ? plural(s.currentStreak, 'day') : '-'} /> + 0 ? plural(s.longestStreak, 'day') : '-'} /> +
+
+ {s.trackedDays > 0 && ( +
+ + Tracked spend (last {plural(s.trackedDays, 'day')}) + + + {formatCurrency(s.trackedTotal, currency)} + +
+ )} +
+ ) +} + +function StatRow({ label, value }: { label: string; value: string }) { + return ( +
+
{label}
+
{value}
+
+ ) +} diff --git a/windows/src/components/TrendInsight.tsx b/windows/src/components/TrendInsight.tsx new file mode 100644 index 00000000..9a8e1d85 --- /dev/null +++ b/windows/src/components/TrendInsight.tsx @@ -0,0 +1,153 @@ +import { useState } from 'react' +import type { DailyEntry, DailyModel } from '../lib/payload' +import type { CurrencyState } from '../lib/currency' +import { formatCompactCurrency, formatCurrency, formatTokens } from '../lib/currency' +import { todayKey, formatDateKey, addDays, startOfDay, prettyDate, shortDate } from '../lib/dates' +import { ArrowUpRight, ArrowDownRight } from './Icons' + +/// 19 columns of 13px bars with 4px gaps = 319px, the widest chart that fits the 332px +/// content width of a 360px popover (mirrors mac trendDays / trendBarWidth / trendBarGap). +export const TREND_DAYS = 19 +const MAX_TOOLTIP_MODELS = 4 +const MIN_BAR_PCT = 2 + +type TrendBar = { + date: string + cost: number + tokens: number + isToday: boolean + topModels: DailyModel[] +} + +function buildBars(days: DailyEntry[]): TrendBar[] { + const byDate = new Map(days.map(d => [d.date, d])) + const today = startOfDay(new Date()) + const tk = todayKey() + const bars: TrendBar[] = [] + for (let i = TREND_DAYS - 1; i >= 0; i--) { + const key = formatDateKey(addDays(today, -i)) + const entry = byDate.get(key) + bars.push({ + date: key, + cost: entry?.cost ?? 0, + tokens: (entry?.inputTokens ?? 0) + (entry?.outputTokens ?? 0), + isToday: key === tk, + topModels: entry?.topModels ?? [], + }) + } + return bars +} + +function computeDelta(bars: TrendBar[], allDays: DailyEntry[]): number | null { + const thisTotal = bars.reduce((s, b) => s + b.cost, 0) + const today = startOfDay(new Date()) + const priorStart = formatDateKey(addDays(today, -(2 * TREND_DAYS - 1))) + const thisStart = formatDateKey(addDays(today, -(TREND_DAYS - 1))) + const priorTotal = allDays + .filter(d => d.date >= priorStart && d.date < thisStart) + .reduce((s, d) => s + d.cost, 0) + if (priorTotal <= 0) return null + return ((thisTotal - priorTotal) / priorTotal) * 100 +} + +type Props = { + days: DailyEntry[] + currency: CurrencyState +} + +export function TrendInsight({ days, currency }: Props) { + const [hoveredIdx, setHoveredIdx] = useState(null) + const bars = buildBars(days) + const totalTokens = bars.reduce((s, b) => s + b.tokens, 0) + const useTokens = totalTokens > 0 + const metric = (b: TrendBar) => useTokens ? b.tokens : b.cost + const maxVal = Math.max(...bars.map(metric), 0.01) + const avgVal = bars.reduce((s, b) => s + metric(b), 0) / bars.length + const totalCost = bars.reduce((s, b) => s + b.cost, 0) + const peak = bars.filter(b => metric(b) > 0).sort((a, b) => metric(b) - metric(a))[0] + const yd = formatDateKey(addDays(startOfDay(new Date()), -1)) + const yesterday = bars.find(b => b.date === yd) + const delta = computeDelta(bars, days) + + const fmtVal = (v: number) => useTokens ? `${formatTokens(v)} tok` : formatCompactCurrency(v, currency) + const heroText = useTokens ? `${formatTokens(totalTokens)} tokens` : formatCurrency(totalCost, currency) + const hovered = hoveredIdx !== null ? bars[hoveredIdx] : null + + return ( +
+
+
+
Last {TREND_DAYS} days
+
{heroText}
+
+ {delta !== null && ( +
+ {delta >= 0 ? : } + {delta >= 0 ? '+' : ''}{Math.round(delta)}% vs prior {TREND_DAYS}d +
+ )} +
+ +
setHoveredIdx(null)}> +
+ {bars.map((bar, i) => { + const val = metric(bar) + const pct = (val / maxVal) * 100 + const cls = [ + 'trend-bar', + bar.isToday ? 'trend-bar-today' : '', + val <= 0 ? 'trend-bar-empty' : '', + hoveredIdx === i ? 'trend-bar-hovered' : '', + ].join(' ') + return ( +
setHoveredIdx(i)} + > +
+
+ ) + })} +
+
+ {hovered && ( +
+
+ {prettyDate(hovered.date)} + {fmtVal(metric(hovered))} +
+ {hovered.topModels.slice(0, MAX_TOOLTIP_MODELS).map(m => ( +
+ + {m.name} + {formatTokens(m.inputTokens + m.outputTokens)} tok + ({formatTokens(m.inputTokens)}/{formatTokens(m.outputTokens)}) +
+ ))} +
+ )} +
+ +
+
+
Avg/day
+
{fmtVal(avgVal)}
+
+
+
Peak
+
+ {peak ? `${fmtVal(metric(peak))} on ${shortDate(peak.date)}` : '-'} +
+
+
+
Yesterday
+
{yesterday ? fmtVal(metric(yesterday)) : '-'}
+
+
+
+ ) +} diff --git a/windows/src/lib/cache.ts b/windows/src/lib/cache.ts new file mode 100644 index 00000000..77cb04aa --- /dev/null +++ b/windows/src/lib/cache.ts @@ -0,0 +1,43 @@ +/// Per (period, provider) payload cache. Entries are served instantly on tab switches and +/// refreshed in the background (stale-while-revalidate); `age` lets the caller decide +/// whether a background refresh is due. + +interface CacheEntry { + data: T + ts: number +} + +export class PayloadCache { + private store = new Map>() + private flights = new Set() + + private key(period: string, provider: string): string { + return `${period}:${provider}` + } + + get(period: string, provider: string): T | null { + return this.store.get(this.key(period, provider))?.data ?? null + } + + /// Milliseconds since the entry was stored, or Infinity when absent. + age(period: string, provider: string): number { + const entry = this.store.get(this.key(period, provider)) + return entry ? Date.now() - entry.ts : Number.POSITIVE_INFINITY + } + + set(period: string, provider: string, data: T): void { + this.store.set(this.key(period, provider), { data, ts: Date.now() }) + } + + isInFlight(period: string, provider: string): boolean { + return this.flights.has(this.key(period, provider)) + } + + markInFlight(period: string, provider: string): void { + this.flights.add(this.key(period, provider)) + } + + clearInFlight(period: string, provider: string): void { + this.flights.delete(this.key(period, provider)) + } +} diff --git a/windows/src/lib/currency.ts b/windows/src/lib/currency.ts new file mode 100644 index 00000000..c334af90 --- /dev/null +++ b/windows/src/lib/currency.ts @@ -0,0 +1,70 @@ +/// Currency formatting that mirrors the macOS app's Double.asCurrency / asCompactCurrency. +/// The Rust backend hands us { code, symbol, rate } so the frontend stays dumb about FX -- +/// it just multiplies and renders. + +export type CurrencyState = { + code: string + symbol: string + rate: number +} + +export const USD: CurrencyState = { code: 'USD', symbol: '$', rate: 1 } + +export const CURRENCY_CODES = [ + 'USD', 'GBP', 'EUR', 'AUD', 'CAD', 'NZD', 'JPY', 'CHF', 'INR', + 'BRL', 'SEK', 'SGD', 'HKD', 'KRW', 'MXN', 'ZAR', 'DKK', +] as const + +const SUB_CENT = 0.005 + +/// Wider format with thousands separators. Used for the hero value. +export function formatCurrency(usdAmount: number, currency: CurrencyState): string { + const converted = usdAmount * currency.rate + const parts = converted.toFixed(2).split('.') + const whole = parts[0].replace(/\B(?=(\d{3})+(?!\d))/g, ',') + return `${currency.symbol}${whole}.${parts[1]}` +} + +/// Compact form (no thousands separators) used in dense tables where the monospace font +/// already gives visual grouping. +export function formatCompactCurrency(usdAmount: number, currency: CurrencyState): string { + const converted = usdAmount * currency.rate + return `${currency.symbol}${converted.toFixed(2)}` +} + +/// For savings and other tiny amounts: never print a misleading "$0.00". +export function formatSmallCurrency(usdAmount: number, currency: CurrencyState): string { + const converted = usdAmount * currency.rate + if (converted > 0 && converted < SUB_CENT) return `<${currency.symbol}0.01` + return formatCompactCurrency(usdAmount, currency) +} + +/// Token compaction shared by every surface (the mac app rounds K to whole numbers). +export function formatTokens(n: number): string { + if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M` + if (n >= 1_000) return `${(n / 1_000).toFixed(0)}K` + return `${Math.round(n)}` +} + +const BADGE_THOUSAND = 1_000 +const BADGE_MILLION = 1_000_000 + +/// The spend string drawn into the tray icon. Budget is a 16px-wide pixel grid, so at most +/// four glyph slots: "$9.5", "$87", "142", "1.2K", "12K", "0.1M". The `$` only fits when +/// there are two digits or fewer, and only USD has a glyph in the icon font. +export function trayBadgeText(usdAmount: number, currency: CurrencyState): string { + const v = Math.max(0, usdAmount * currency.rate) + const symbol = currency.code === 'USD' ? '$' : '' + // Thresholds sit at the rounding boundary of the format above them, so "9.96" becomes + // "$10" rather than "$10.0" and "999.7" becomes "1.0K" rather than "1000". + if (v < 9.95) return `${symbol}${v.toFixed(1)}` + if (v < 99.5) return `${symbol}${Math.round(v)}` + if (v < 999.5) return `${Math.round(v)}` + if (v < 9_950) return `${(v / BADGE_THOUSAND).toFixed(1)}K` + if (v < 999_500) return `${Math.round(v / BADGE_THOUSAND)}K` + return `${(v / BADGE_MILLION).toFixed(1)}M` +} + +export function plural(n: number, singular: string, pluralForm = `${singular}s`): string { + return `${n} ${n === 1 ? singular : pluralForm}` +} diff --git a/windows/src/lib/dates.ts b/windows/src/lib/dates.ts new file mode 100644 index 00000000..273afbdc --- /dev/null +++ b/windows/src/lib/dates.ts @@ -0,0 +1,89 @@ +/// All calendar math is in the machine's local time zone. The CLI buckets `history.daily` +/// by local date, so "today" here must be the same local day or the trend chart and the +/// hero disagree around midnight. + +const DAY_NAMES = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'] +const MONTH_NAMES = [ + 'Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', + 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec', +] + +export const MS_PER_DAY = 86_400_000 + +function pad2(n: number): string { + return n < 10 ? `0${n}` : String(n) +} + +export function todayKey(): string { + return formatDateKey(new Date()) +} + +export function formatDateKey(d: Date): string { + return `${d.getFullYear()}-${pad2(d.getMonth() + 1)}-${pad2(d.getDate())}` +} + +export function parseDateKey(ymd: string): Date { + const [y, m, d] = ymd.split('-').map(Number) + return new Date(y, m - 1, d) +} + +export function addDays(d: Date, n: number): Date { + const r = new Date(d.getTime()) + r.setDate(r.getDate() + n) + return r +} + +export function startOfDay(d: Date): Date { + return new Date(d.getFullYear(), d.getMonth(), d.getDate()) +} + +export function prettyDate(ymd: string): string { + const dt = parseDateKey(ymd) + return `${DAY_NAMES[dt.getDay()]} ${MONTH_NAMES[dt.getMonth()]} ${dt.getDate()}` +} + +export function monthDay(ymd: string): string { + const dt = parseDateKey(ymd) + return `${MONTH_NAMES[dt.getMonth()]} ${dt.getDate()}` +} + +export function shortDate(ymd: string): string { + const parts = ymd.split('-') + return `${parts[1]}/${parts[2]}` +} + +export function firstOfMonth(d: Date): Date { + return new Date(d.getFullYear(), d.getMonth(), 1) +} + +export function daysInMonth(d: Date): number { + return new Date(d.getFullYear(), d.getMonth() + 1, 0).getDate() +} + +export function dayOfMonth(d: Date): number { + return d.getDate() +} + +export function previousMonthRange(d: Date): { first: string; last: string } { + const first = new Date(d.getFullYear(), d.getMonth() - 1, 1) + const last = new Date(d.getFullYear(), d.getMonth(), 0) + return { first: formatDateKey(first), last: formatDateKey(last) } +} + +/// "in 42m", "in 3h", "in 2d", or "now". +export function relativeFuture(target: Date, now = new Date()): string { + const secs = (target.getTime() - now.getTime()) / 1000 + if (secs <= 0) return 'now' + if (secs < 3600) return `in ${Math.ceil(secs / 60)}m` + if (secs < 86_400) return `in ${Math.ceil(secs / 3600)}h` + return `in ${Math.ceil(secs / 86_400)}d` +} + +/// "just now", "2 min ago", "1 h ago". +export function relativePast(target: Date, now = new Date()): string { + const secs = Math.max(0, (now.getTime() - target.getTime()) / 1000) + if (secs < 45) return 'just now' + if (secs < 3600) return `${Math.round(secs / 60)} min ago` + if (secs < 86_400) return `${Math.round(secs / 3600)} h ago` + return `${Math.round(secs / 86_400)} d ago` +} diff --git a/windows/src/lib/history.ts b/windows/src/lib/history.ts new file mode 100644 index 00000000..ef532bf5 --- /dev/null +++ b/windows/src/lib/history.ts @@ -0,0 +1,99 @@ +import type { DailyEntry } from './payload' +import { + formatDateKey, addDays, startOfDay, firstOfMonth, daysInMonth, dayOfMonth, + previousMonthRange, parseDateKey, MS_PER_DAY, +} from './dates' + +/// Derived numbers over `history.daily` that several insights share (Trend, Forecast, +/// Stats, Tips). One implementation so the streak in Tips and the streak in Stats agree. + +const MAX_STREAK_LOOKBACK_DAYS = 400 +const WEEK_DAYS = 7 + +export type HistoryStats = { + weekTotal: number + priorWeekTotal: number + weekDelta: number | null + yesterday: number + monthToDate: number + monthProjection: number + previousMonthTotal: number | null + activeDaysThisMonth: number + currentStreak: number + longestStreak: number + peak: DailyEntry | null + trackedTotal: number + trackedDays: number +} + +export function computeHistoryStats(history: DailyEntry[], now = new Date()): HistoryStats { + const today = startOfDay(now) + const costByDate = new Map(history.map(d => [d.date, d.cost])) + const sum = (from: string, to: string) => + history.filter(d => d.date >= from && d.date <= to).reduce((s, d) => s + d.cost, 0) + + const todayKey = formatDateKey(today) + const weekStart = formatDateKey(addDays(today, -(WEEK_DAYS - 1))) + const priorWeekStart = formatDateKey(addDays(today, -(2 * WEEK_DAYS - 1))) + const priorWeekEnd = formatDateKey(addDays(today, -WEEK_DAYS)) + const weekTotal = sum(weekStart, todayKey) + const priorWeekTotal = sum(priorWeekStart, priorWeekEnd) + const weekDelta = priorWeekTotal > 0 ? ((weekTotal - priorWeekTotal) / priorWeekTotal) * 100 : null + + const yesterday = costByDate.get(formatDateKey(addDays(today, -1))) ?? 0 + + const fomKey = formatDateKey(firstOfMonth(now)) + const monthToDate = sum(fomKey, todayKey) + const dom = dayOfMonth(now) + const monthProjection = dom > 0 ? (monthToDate / dom) * daysInMonth(now) : 0 + const prev = previousMonthRange(now) + const prevEntries = history.filter(d => d.date >= prev.first && d.date <= prev.last) + const previousMonthTotal = prevEntries.length > 0 ? prevEntries.reduce((s, d) => s + d.cost, 0) : null + + const activeDaysThisMonth = history.filter(d => d.date >= fomKey && d.cost > 0).length + + let currentStreak = 0 + for (let i = 0; i < MAX_STREAK_LOOKBACK_DAYS; i++) { + if ((costByDate.get(formatDateKey(addDays(today, -i))) ?? 0) > 0) currentStreak++ + else break + } + + let longestStreak = 0 + if (history.length > 0) { + const first = parseDateKey([...history].sort((a, b) => a.date.localeCompare(b.date))[0].date) + const totalDays = Math.min( + MAX_STREAK_LOOKBACK_DAYS, + Math.round((today.getTime() - first.getTime()) / MS_PER_DAY) + 1, + ) + const start = addDays(today, -(totalDays - 1)) + let running = 0 + for (let i = 0; i < totalDays; i++) { + if ((costByDate.get(formatDateKey(addDays(start, i))) ?? 0) > 0) { + running++ + longestStreak = Math.max(longestStreak, running) + } else { + running = 0 + } + } + } + + const peak = history.reduce( + (best, d) => (!best || d.cost > best.cost) ? d : best, null, + ) + + return { + weekTotal, + priorWeekTotal, + weekDelta, + yesterday, + monthToDate, + monthProjection, + previousMonthTotal, + activeDaysThisMonth, + currentStreak, + longestStreak, + peak: peak && peak.cost > 0 ? peak : null, + trackedTotal: history.reduce((s, d) => s + d.cost, 0), + trackedDays: history.length, + } +} diff --git a/windows/src/lib/payload.ts b/windows/src/lib/payload.ts new file mode 100644 index 00000000..e4d34755 --- /dev/null +++ b/windows/src/lib/payload.ts @@ -0,0 +1,57 @@ +/// Shape of the JSON returned by `codeburn status --format menubar-json`. Kept in sync with +/// `src/menubar-json.ts` (CLI) and `mac/Sources/CodeBurnMenubar/Data/MenubarPayload.swift` +/// (macOS app). Any field change there must land here too or the frontend silently drops it. +export type MenubarPayload = { + generated: string + current: { + label: string + cost: number + calls: number + sessions: number + oneShotRate: number | null + inputTokens: number + outputTokens: number + cacheHitPercent: number + topActivities: Activity[] + topModels: Model[] + providers: Record + } + optimize: { + findingCount: number + savingsUSD: number + topFindings: Array<{ title: string; impact: 'high' | 'medium' | 'low'; savingsUSD: number }> + } + history: { daily: DailyEntry[] } +} + +export type Activity = { + name: string + cost: number + turns: number + oneShotRate: number | null +} + +export type Model = { + name: string + cost: number + calls: number +} + +export type DailyModel = { + name: string + cost: number + calls: number + inputTokens: number + outputTokens: number +} + +export type DailyEntry = { + date: string + cost: number + calls: number + inputTokens: number + outputTokens: number + cacheReadTokens: number + cacheWriteTokens: number + topModels?: DailyModel[] +} diff --git a/windows/src/lib/plan.ts b/windows/src/lib/plan.ts new file mode 100644 index 00000000..72ccc6ce --- /dev/null +++ b/windows/src/lib/plan.ts @@ -0,0 +1,73 @@ +/// Claude subscription usage as returned by the Rust `plan_usage` command, plus the +/// projection math from the macOS PlanInsight so both apps draw the same marker. + +export type PlanWindow = { + key: 'five_hour' | 'seven_day' | 'seven_day_opus' | 'seven_day_sonnet' | string + label: string + percent: number + resets_at: string | null + previous_final: number | null +} + +export type PlanUsage = + | { state: 'ok'; tier: string; raw_tier: string | null; windows: PlanWindow[]; fetched_at: string } + | { state: 'no_credentials' } + | { state: 'failed'; message: string } + +export type PlanProjection = { + percent: number + willOverflow: boolean + hitsLimitAt: Date | null + source: 'linear' | 'historical' +} + +const FIVE_HOUR_SECONDS = 5 * 3600 +const SEVEN_DAY_SECONDS = 7 * 86_400 +/// Below this fraction of the window the linear extrapolation is noise; fall back to +/// last cycle's final reading instead. +const FRESH_WINDOW_THRESHOLD = 0.05 +const FULL_PERCENT = 100 + +function windowSeconds(key: string): number { + return key === 'five_hour' ? FIVE_HOUR_SECONDS : SEVEN_DAY_SECONDS +} + +export function projectWindow(window: PlanWindow, now = new Date()): PlanProjection | null { + if (!window.resets_at) return null + const resetsAt = new Date(window.resets_at) + if (Number.isNaN(resetsAt.getTime())) return null + const seconds = windowSeconds(window.key) + const windowStart = resetsAt.getTime() / 1000 - seconds + const elapsed = now.getTime() / 1000 - windowStart + const elapsedFraction = elapsed / seconds + + if (elapsedFraction > FRESH_WINDOW_THRESHOLD && window.percent > 0) { + const projected = window.percent / elapsedFraction + let hitsLimitAt: Date | null = null + if (projected > FULL_PERCENT && window.percent < FULL_PERCENT) { + const percentPerSecond = window.percent / elapsed + if (percentPerSecond > 0) { + hitsLimitAt = new Date(now.getTime() + ((FULL_PERCENT - window.percent) / percentPerSecond) * 1000) + } + } + return { percent: projected, willOverflow: projected > FULL_PERCENT, hitsLimitAt, source: 'linear' } + } + + if (window.previous_final != null) { + return { + percent: window.previous_final, + willOverflow: window.previous_final > FULL_PERCENT, + hitsLimitAt: null, + source: 'historical', + } + } + return null +} + +export function earliestReset(windows: PlanWindow[]): Date | null { + const dates = windows + .map(w => (w.resets_at ? new Date(w.resets_at) : null)) + .filter((d): d is Date => d !== null && !Number.isNaN(d.getTime())) + if (dates.length === 0) return null + return dates.reduce((a, b) => (a < b ? a : b)) +} diff --git a/windows/src/lib/platform.ts b/windows/src/lib/platform.ts new file mode 100644 index 00000000..a7a1733f --- /dev/null +++ b/windows/src/lib/platform.ts @@ -0,0 +1,10 @@ +/// Paths shown in copy use the reader's own OS spelling. + +export const IS_WINDOWS = navigator.userAgent.includes('Windows') + +const HOME = IS_WINDOWS ? '%USERPROFILE%' : '~' +const SEP = IS_WINDOWS ? '\\' : '/' + +export function homePath(...parts: string[]): string { + return [HOME, ...parts].join(SEP) +} diff --git a/windows/src/lib/settings.ts b/windows/src/lib/settings.ts new file mode 100644 index 00000000..8aa4d2e1 --- /dev/null +++ b/windows/src/lib/settings.ts @@ -0,0 +1,42 @@ +/// The few preferences that live in the webview (everything the CLI also needs, like the +/// currency, lives in ~/.config/codeburn/config.json via the Rust side). + +const KEYS = { + theme: 'codeburn.theme', + insight: 'codeburn.insight', + starBannerDismissed: 'codeburn.starBannerDismissed', + trayBadge: 'codeburn.trayBadge', +} as const + +type Key = keyof typeof KEYS + +export function readSetting(key: Key): string | null { + try { + return localStorage.getItem(KEYS[key]) + } catch { + return null + } +} + +export function writeSetting(key: Key, value: string | null): void { + try { + if (value === null) localStorage.removeItem(KEYS[key]) + else localStorage.setItem(KEYS[key], value) + } catch { + // Storage can be unavailable in a locked-down webview; preferences are best-effort. + } +} + +export type Theme = 'light' | 'dark' + +export function currentTheme(): Theme { + const stamped = document.documentElement.getAttribute('data-theme') + if (stamped === 'dark' || stamped === 'light') return stamped + return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light' +} + +export function applyTheme(theme: Theme | null): void { + if (theme) document.documentElement.setAttribute('data-theme', theme) + else document.documentElement.removeAttribute('data-theme') + writeSetting('theme', theme) +} diff --git a/windows/src/lib/tips.ts b/windows/src/lib/tips.ts new file mode 100644 index 00000000..d80d9c1b --- /dev/null +++ b/windows/src/lib/tips.ts @@ -0,0 +1,48 @@ +import type { MenubarPayload } from './payload' +import type { CurrencyState } from './currency' +import { formatCompactCurrency, formatSmallCurrency } from './currency' +import { computeHistoryStats } from './history' + +export type TipItem = { text: string; trailing: string | null } +export type TipGroup = { label: string; icon: string; items: TipItem[] } + +const CACHE_HIT_GOOD = 80 +const CACHE_HIT_LOW = 50 +const ONESHOT_GOOD = 0.75 +const ONESHOT_LOW = 0.5 +const SPEND_DOWN_THRESHOLD = -10 +const SPEND_UP_THRESHOLD = 25 +const STREAK_MILESTONE = 5 +const MONTH_GROWTH_WARNING = 1.3 +const TOP_FINDINGS_COUNT = 3 + +export function computeTipGroups(payload: MenubarPayload, currency: CurrencyState): TipGroup[] { + const stats = computeHistoryStats(payload.history.daily) + const { cacheHitPercent, oneShotRate } = payload.current + + const wins: TipItem[] = [] + if (cacheHitPercent >= CACHE_HIT_GOOD) wins.push({ text: `Cache hit at ${Math.round(cacheHitPercent)}% - most prompts reuse cache`, trailing: null }) + if (oneShotRate != null && oneShotRate >= ONESHOT_GOOD) wins.push({ text: `${Math.round(oneShotRate * 100)}% one-shot - edits landing first try`, trailing: null }) + if (stats.weekDelta != null && stats.weekDelta < SPEND_DOWN_THRESHOLD) wins.push({ text: `Spend down ${Math.round(Math.abs(stats.weekDelta))}% vs last 7 days`, trailing: null }) + if (stats.currentStreak >= STREAK_MILESTONE) wins.push({ text: `${stats.currentStreak}-day usage streak`, trailing: null }) + + const improvements: TipItem[] = payload.optimize.topFindings.slice(0, TOP_FINDINGS_COUNT).map(f => ({ + text: f.title, + trailing: formatSmallCurrency(f.savingsUSD, currency), + })) + + const risks: TipItem[] = [] + if (stats.weekDelta != null && stats.weekDelta > SPEND_UP_THRESHOLD) risks.push({ text: `Spend up ${Math.round(stats.weekDelta)}% vs prior 7 days`, trailing: null }) + if (cacheHitPercent > 0 && cacheHitPercent < CACHE_HIT_LOW) risks.push({ text: `Cache hit only ${Math.round(cacheHitPercent)}% - paying for cold prompts`, trailing: null }) + if (oneShotRate != null && oneShotRate < ONESHOT_LOW) risks.push({ text: `${Math.round(oneShotRate * 100)}% one-shot - lots of iteration`, trailing: null }) + if (stats.previousMonthTotal != null && stats.previousMonthTotal > 0 && stats.monthProjection > stats.previousMonthTotal * MONTH_GROWTH_WARNING) { + const pct = Math.round(((stats.monthProjection - stats.previousMonthTotal) / stats.previousMonthTotal) * 100) + risks.push({ text: `On pace for ${formatCompactCurrency(stats.monthProjection, currency)} this month (+${pct}% vs last)`, trailing: null }) + } + + return [ + { label: "What's working", icon: 'check', items: wins }, + { label: 'What to improve', icon: 'up', items: improvements }, + { label: 'Risks', icon: 'warn', items: risks }, + ] +} diff --git a/windows/src/main.tsx b/windows/src/main.tsx new file mode 100644 index 00000000..5dd018a7 --- /dev/null +++ b/windows/src/main.tsx @@ -0,0 +1,10 @@ +import React from 'react' +import ReactDOM from 'react-dom/client' +import { App } from './App' +import './styles.css' + +ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render( + + + +) diff --git a/windows/src/styles.css b/windows/src/styles.css new file mode 100644 index 00000000..c61da8b6 --- /dev/null +++ b/windows/src/styles.css @@ -0,0 +1,1153 @@ +/* CodeBurn tray popover. Values mirror mac/Sources/CodeBurnMenubar (Theme.swift + views): + translucent system label colours, 360x660 popover, 14px gutters, 11-13px type scale, + brand accent #C9521D used identically in both themes. */ + +:root { + --brand-accent: #C9521D; + --brand-accent-dark: #E8774A; + --ember-deep: #8B3E13; + --ember-glow: #F0A070; + + /* Light: warm popover material over the desktop. */ + --surface: rgba(250, 247, 243, 0.94); + --surface-solid: #FAF7F3; + --surface-window: #FFFFFF; + --label: rgba(0, 0, 0, 0.85); + --label-2: rgba(0, 0, 0, 0.50); + --label-3: rgba(0, 0, 0, 0.28); + --separator: rgba(0, 0, 0, 0.10); + --separator-soft: rgba(0, 0, 0, 0.05); + --fill-06: rgba(0, 0, 0, 0.06); + --fill-08: rgba(0, 0, 0, 0.08); + --fill-10: rgba(0, 0, 0, 0.10); + --fill-12: rgba(0, 0, 0, 0.12); + --fill-15: rgba(0, 0, 0, 0.15); + --fill-18: rgba(0, 0, 0, 0.18); + --fill-50: rgba(0, 0, 0, 0.45); + --tooltip-bg: #1C1816; + --tooltip-fg: rgba(255, 255, 255, 0.92); + --tooltip-fg-2: rgba(255, 255, 255, 0.72); + --tooltip-fg-3: rgba(255, 255, 255, 0.52); + --tooltip-border: rgba(255, 255, 255, 0.12); + --overlay-material: rgba(250, 247, 243, 0.55); + --control-bg: rgba(255, 255, 255, 0.85); + --control-border: rgba(0, 0, 0, 0.09); + --control-hover: rgba(0, 0, 0, 0.05); + --menu-bg: rgba(252, 250, 247, 0.98); + --menu-shadow: 0 8px 28px rgba(0, 0, 0, 0.18), 0 1px 3px rgba(0, 0, 0, 0.10); + --icon-contrast: #FFFFFF; + --danger: #C83F2C; + + --font-sans: 'Segoe UI Variable Text', 'Segoe UI', -apple-system, system-ui, sans-serif; + --font-display: 'Segoe UI Variable Display', 'Segoe UI', -apple-system, system-ui, sans-serif; + --font-mono: 'Cascadia Mono', 'Cascadia Code', Consolas, 'SF Mono', Menlo, monospace; + + --gutter: 14px; + --popover-radius: 10px; + --radius-bar: 2px; + --radius-util: 3px; + --radius-segment: 5px; + --radius-pill: 6px; + --radius-track: 7px; + --radius-card: 8px; + --scroll-height: 520px; + + --ease-ui: cubic-bezier(0.2, 0.7, 0.2, 1); +} + +@media (prefers-color-scheme: dark) { + :root:not([data-theme="light"]) { + --surface: rgba(28, 24, 22, 0.92); + --surface-solid: #1C1816; + --surface-window: #2A2320; + --label: rgba(255, 255, 255, 0.86); + --label-2: rgba(255, 255, 255, 0.55); + --label-3: rgba(255, 255, 255, 0.28); + --separator: rgba(255, 255, 255, 0.10); + --separator-soft: rgba(255, 255, 255, 0.05); + --fill-06: rgba(255, 255, 255, 0.06); + --fill-08: rgba(255, 255, 255, 0.08); + --fill-10: rgba(255, 255, 255, 0.10); + --fill-12: rgba(255, 255, 255, 0.12); + --fill-15: rgba(255, 255, 255, 0.15); + --fill-18: rgba(255, 255, 255, 0.18); + --fill-50: rgba(255, 255, 255, 0.45); + --tooltip-bg: #FAF7F3; + --tooltip-fg: rgba(0, 0, 0, 0.9); + --tooltip-fg-2: rgba(0, 0, 0, 0.7); + --tooltip-fg-3: rgba(0, 0, 0, 0.5); + --tooltip-border: rgba(0, 0, 0, 0.12); + --overlay-material: rgba(28, 24, 22, 0.55); + --control-bg: rgba(255, 255, 255, 0.08); + --control-border: rgba(255, 255, 255, 0.10); + --control-hover: rgba(255, 255, 255, 0.10); + --menu-bg: rgba(40, 35, 32, 0.98); + --menu-shadow: 0 8px 28px rgba(0, 0, 0, 0.5), 0 1px 3px rgba(0, 0, 0, 0.3); + --icon-contrast: #1C1816; + color-scheme: dark; + } +} + +:root[data-theme="dark"] { + --surface: rgba(28, 24, 22, 0.92); + --surface-solid: #1C1816; + --surface-window: #2A2320; + --label: rgba(255, 255, 255, 0.86); + --label-2: rgba(255, 255, 255, 0.55); + --label-3: rgba(255, 255, 255, 0.28); + --separator: rgba(255, 255, 255, 0.10); + --separator-soft: rgba(255, 255, 255, 0.05); + --fill-06: rgba(255, 255, 255, 0.06); + --fill-08: rgba(255, 255, 255, 0.08); + --fill-10: rgba(255, 255, 255, 0.10); + --fill-12: rgba(255, 255, 255, 0.12); + --fill-15: rgba(255, 255, 255, 0.15); + --fill-18: rgba(255, 255, 255, 0.18); + --fill-50: rgba(255, 255, 255, 0.45); + --tooltip-bg: #FAF7F3; + --tooltip-fg: rgba(0, 0, 0, 0.9); + --tooltip-fg-2: rgba(0, 0, 0, 0.7); + --tooltip-fg-3: rgba(0, 0, 0, 0.5); + --tooltip-border: rgba(0, 0, 0, 0.12); + --overlay-material: rgba(28, 24, 22, 0.55); + --control-bg: rgba(255, 255, 255, 0.08); + --control-border: rgba(255, 255, 255, 0.10); + --control-hover: rgba(255, 255, 255, 0.10); + --menu-bg: rgba(40, 35, 32, 0.98); + --menu-shadow: 0 8px 28px rgba(0, 0, 0, 0.5), 0 1px 3px rgba(0, 0, 0, 0.3); + --icon-contrast: #1C1816; + color-scheme: dark; +} + +* { box-sizing: border-box; } + +html, body, #root { + margin: 0; + padding: 0; + height: 100%; + background: transparent; + color: var(--label); + font-family: var(--font-sans); + font-size: 11.5px; + line-height: 1.3; + -webkit-font-smoothing: antialiased; + text-rendering: optimizeLegibility; + overflow: hidden; + user-select: none; + cursor: default; +} + +button { + font: inherit; + color: inherit; + cursor: default; +} +button:focus-visible, a:focus-visible, summary:focus-visible { + outline: 2px solid var(--brand-accent); + outline-offset: 1px; + border-radius: 4px; +} +a { color: inherit; } +code { font-family: var(--font-mono); } + +@media (prefers-reduced-motion: reduce) { + *, *::before, *::after { animation-duration: 0.01ms !important; transition-duration: 0.01ms !important; } +} + +/* ---- shell ---------------------------------------------------------------------- */ + +.popover { + display: flex; + flex-direction: column; + height: 100vh; + overflow: hidden; + border-radius: var(--popover-radius); + background: var(--surface); + box-shadow: inset 0 0 0 0.5px var(--separator); +} + +.header { + display: flex; + flex-direction: column; + gap: 1px; + padding: 10px var(--gutter) 8px; + border-bottom: 1px solid var(--separator); +} +.brand { font-size: 13px; font-weight: 600; letter-spacing: -0.15px; line-height: 16px; } +.brand-primary { color: var(--label); } +.brand-accent { color: var(--brand-accent); } +.subhead { font-size: 10.5px; color: var(--label-2); } + +.main-content { + position: relative; + flex: 1; + min-height: 0; + overflow-y: auto; + scrollbar-width: none; +} +.main-content::-webkit-scrollbar { display: none; } + +/* ---- agent tabs ---------------------------------------------------------------- */ + +.agent-tabs-wrap { + position: relative; + z-index: 40; + border-bottom: 1px solid var(--separator); +} +.agent-tabs { + display: flex; + gap: 5px; + padding: 8px 12px; + overflow-x: auto; + scrollbar-width: none; + mask-image: linear-gradient(90deg, #000 calc(100% - 18px), transparent); + -webkit-mask-image: linear-gradient(90deg, #000 calc(100% - 18px), transparent); +} +.agent-tabs::-webkit-scrollbar { display: none; } +.tab-muted, .tab-muted:hover { background: transparent; color: var(--label-3); box-shadow: inset 0 0 0 1px var(--fill-08); } +.tab-preview { + position: absolute; + left: 12px; + right: 12px; + top: calc(100% + 4px); + padding: 8px 10px; + border-radius: var(--radius-card); + background: var(--tooltip-bg); + color: var(--tooltip-fg); + box-shadow: 0 4px 20px rgba(0, 0, 0, 0.35), inset 0 0 0 0.5px var(--tooltip-border); + pointer-events: none; + animation: fadeIn 120ms ease-out; +} +.tab-preview-title { font-size: 11px; font-weight: 600; } +.tab-preview-body { margin-top: 2px; font-size: 10px; color: var(--tooltip-fg-2); font-variant-numeric: tabular-nums; } +.tab { + display: inline-flex; + align-items: baseline; + gap: 5px; + flex: 0 0 auto; + border: 0; + padding: 4px 10px; + border-radius: var(--radius-pill); + background: var(--fill-08); + color: var(--label-2); + font-size: 11.5px; + font-weight: 500; + letter-spacing: -0.05px; + line-height: 14px; + transition: background 120ms var(--ease-ui), color 120ms var(--ease-ui); +} +.tab:hover { background: var(--fill-12); } +.tab-active, .tab-active:hover { background: var(--brand-accent); color: #fff; } +.tab-cost { + font-family: var(--font-mono); + font-size: 10.5px; + font-weight: 500; + letter-spacing: -0.2px; + color: var(--label-2); +} +.tab-active .tab-cost { color: rgba(255, 255, 255, 0.8); } + +/* ---- hero ------------------------------------------------------------------------ */ + +.hero { + display: flex; + flex-direction: column; + gap: 8px; + padding: 10px var(--gutter) 12px; + border-bottom: 1px solid var(--separator-soft); +} +.hero-row { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 12px; +} +.hero-amount { + font-family: var(--font-display); + font-size: 32px; + font-weight: 600; + line-height: 36px; + letter-spacing: -1px; + font-variant-numeric: tabular-nums; + background: linear-gradient(180deg, var(--brand-accent), var(--ember-deep)); + -webkit-background-clip: text; + background-clip: text; + color: transparent; +} +.hero-meta { + display: flex; + flex-direction: column; + align-items: flex-end; + gap: 2px; + font-variant-numeric: tabular-nums; +} +.hero-calls { font-size: 11px; color: var(--label-2); } +.hero-sessions { font-size: 10.5px; color: var(--label-3); } + +.hero-skeleton, .hero-skeleton-line { + display: block; + border-radius: 4px; + background: linear-gradient(90deg, var(--fill-06) 25%, var(--fill-12) 50%, var(--fill-06) 75%); + background-size: 200% 100%; + animation: shimmer 1.4s linear infinite; +} +.hero-skeleton { width: 128px; height: 30px; margin: 3px 0; -webkit-background-clip: border-box; background-clip: border-box; color: transparent; } +.hero-skeleton-line { width: 54px; height: 10px; } +.hero-skeleton-line.short { width: 64px; } +@keyframes shimmer { to { background-position: -200% 0; } } + +/* ---- section caption ----------------------------------------------------------- */ + +.section-caption { + display: inline-flex; + align-items: center; + gap: 5px; + font-size: 11.5px; + font-weight: 500; + letter-spacing: -0.1px; + color: var(--label-2); +} +.section-caption-strong { color: var(--label); } +.section-dot { + width: 3px; + height: 3px; + border-radius: 50%; + background: var(--brand-accent); + opacity: 0.7; + flex-shrink: 0; +} + +/* ---- period segmented control -------------------------------------------------- */ + +.period-wrap { + padding: 6px 12px 10px; + border-bottom: 1px solid var(--separator-soft); +} +.period-tabs { + display: flex; + gap: 1px; + padding: 2px; + border-radius: var(--radius-track); + background: var(--fill-08); +} +.period { + flex: 1; + border: 0; + background: transparent; + padding: 4px 0; + border-radius: var(--radius-segment); + font-size: 11px; + font-weight: 500; + line-height: 14px; + color: var(--label-2); + transition: background 120ms var(--ease-ui), color 120ms var(--ease-ui), box-shadow 120ms var(--ease-ui); +} +.period:hover { color: var(--label); } +.period-active, .period-active:hover { + background: var(--surface-window); + color: var(--label); + box-shadow: 0 0.5px 2px rgba(0, 0, 0, 0.10); +} + +/* ---- insight area -------------------------------------------------------------- */ + +.insight-area { + display: flex; + flex-direction: column; + gap: 10px; + padding: 10px var(--gutter); + border-bottom: 1px solid var(--separator-soft); + position: relative; + z-index: 10; +} +.insight-pills { display: flex; gap: 4px; } +.insight-pill { + border: 0; + padding: 4px 10px; + border-radius: var(--radius-pill); + background: var(--fill-10); + color: var(--label-2); + font-size: 11px; + font-weight: 500; + line-height: 14px; + transition: background 120ms var(--ease-ui), color 120ms var(--ease-ui); +} +.insight-pill:hover { background: var(--fill-15); } +.insight-pill-active, .insight-pill-active:hover { background: var(--brand-accent); color: #fff; } + +.insight-header { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 10px; +} +.insight-sublabel { font-size: 10px; font-weight: 500; color: var(--label-3); } +.insight-hero { + margin-top: 1px; + font-family: var(--font-display); + font-size: 18px; + font-weight: 600; + line-height: 22px; + font-variant-numeric: tabular-nums; + color: var(--label); +} +.delta-badge { + display: inline-flex; + align-items: center; + gap: 3px; + font-size: 10.5px; + font-variant-numeric: tabular-nums; + color: var(--brand-accent); + white-space: nowrap; +} +.delta-badge-block { margin-top: 0; } + +/* Trend */ +.trend-insight, .forecast-insight, .stats-insight, .plan-insight { + display: flex; + flex-direction: column; + gap: 10px; +} +.trend-chart { position: relative; height: 90px; } +.trend-bars { + display: flex; + align-items: flex-end; + gap: 4px; + height: 100%; +} +.trend-bar-col { + display: flex; + flex-direction: column; + justify-content: flex-end; + width: 13px; + height: 100%; +} +.trend-bar { + width: 100%; + min-height: 2px; + border-radius: var(--radius-bar); + background: var(--brand-accent); + opacity: 0.55; + transform-origin: bottom center; + transition: transform 120ms ease-out, opacity 120ms ease-out, box-shadow 120ms ease-out; +} +.trend-bar-today { opacity: 1; } +.trend-bar-empty { background: var(--label); opacity: 0.15; } +.trend-bar-hovered { + opacity: 0.85; + transform: scaleX(1.08); + box-shadow: inset 0 0 0 1px rgba(201, 82, 29, 0.9); +} +.trend-bar-today.trend-bar-hovered { opacity: 1; } +.trend-avg-line { + position: absolute; + left: 0; + right: 0; + border-top: 1px dashed var(--fill-50); + pointer-events: none; +} + +.bar-tooltip { + position: absolute; + left: 0; + right: 0; + top: calc(100% + 6px); + z-index: 20; + padding: 11px; + border-radius: var(--radius-card); + background: var(--tooltip-bg); + color: var(--tooltip-fg); + box-shadow: 0 4px 20px rgba(0, 0, 0, 0.35), inset 0 0 0 0.5px var(--tooltip-border); + pointer-events: none; + animation: fadeIn 120ms ease-out; +} +.bar-tooltip-header { + display: flex; + justify-content: space-between; + align-items: baseline; + font-size: 11px; + font-weight: 600; + margin-bottom: 5px; +} +.bar-tooltip-value { color: var(--brand-accent-dark); font-family: var(--font-mono); font-size: 10.5px; font-weight: 600; } +.bar-tooltip-model { + display: flex; + align-items: center; + gap: 6px; + padding: 1.5px 0; + font-size: 10px; + font-weight: 500; +} +.bar-tooltip-dot { width: 4px; height: 4px; border-radius: 50%; background: var(--brand-accent); opacity: 0.7; flex-shrink: 0; } +.bar-tooltip-name { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.bar-tooltip-tokens { font-family: var(--font-mono); font-size: 9.5px; color: var(--tooltip-fg-2); } +.bar-tooltip-split { font-family: var(--font-mono); font-size: 9px; color: var(--tooltip-fg-3); } + +.mini-stats { display: flex; gap: 14px; } +.mini-stat { flex: 1; min-width: 0; } +.mini-stat-label { font-size: 9.5px; font-weight: 500; color: var(--label-3); } +.mini-stat-value { + margin-top: 1px; + font-size: 11.5px; + font-weight: 600; + font-variant-numeric: tabular-nums; + color: var(--label); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +/* Forecast */ +.forecast-mtd { + margin-top: 2px; + font-family: var(--font-display); + font-size: 22px; + font-weight: 600; + line-height: 26px; + font-variant-numeric: tabular-nums; + color: var(--brand-accent); +} +.forecast-right { text-align: right; } +.forecast-projection { + margin-top: 2px; + font-size: 16px; + font-weight: 600; + line-height: 20px; + font-variant-numeric: tabular-nums; + color: var(--label); +} + +/* Pulse */ +.pulse-tiles { display: flex; gap: 10px; } +.pulse-tile { + flex: 1; + min-width: 0; + padding: 8px 10px; + border-radius: var(--radius-pill); + background: var(--fill-06); +} +.pulse-label { font-size: 10px; font-weight: 500; color: var(--label-3); } +.pulse-value { + margin-top: 3px; + font-family: var(--font-display); + font-size: 18px; + font-weight: 600; + line-height: 22px; + font-variant-numeric: tabular-nums; + color: var(--label-2); +} +.pulse-value-accent { color: var(--brand-accent); } + +/* Stats */ +.stats-grid { display: flex; gap: 14px; } +.stats-col { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 8px; } +.stat-row-label { font-size: 9.5px; font-weight: 500; color: var(--label-3); } +.stat-row-value { + margin-top: 1px; + font-size: 12px; + font-weight: 600; + font-variant-numeric: tabular-nums; + color: var(--label); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.stats-lifetime { + display: flex; + justify-content: space-between; + align-items: baseline; + padding-top: 10px; + border-top: 1px solid var(--separator-soft); +} +.stats-lifetime-label { font-size: 10.5px; font-weight: 500; color: var(--label-3); } +.stats-lifetime-value { + font-family: var(--font-display); + font-size: 13px; + font-weight: 600; + font-variant-numeric: tabular-nums; + color: var(--brand-accent); +} + +/* Plan */ +.plan-header { display: flex; align-items: baseline; justify-content: space-between; } +.plan-tier { font-size: 13px; font-weight: 600; color: var(--brand-accent); } +.plan-reset { font-size: 10.5px; color: var(--label-2); } +.plan-rows { display: flex; flex-direction: column; gap: 8px; } +.util-row { display: flex; flex-direction: column; gap: 3px; } +.util-row-head { display: flex; align-items: baseline; justify-content: space-between; } +.util-label { font-size: 11px; font-weight: 500; color: var(--label-2); } +.util-percent { + font-family: var(--font-mono); + font-size: 11px; + font-weight: 600; + font-variant-numeric: tabular-nums; + color: var(--brand-accent); +} +.util-bar { + position: relative; + height: 6px; + border-radius: var(--radius-util); + background: var(--fill-12); + overflow: hidden; +} +.util-bar-fill { + height: 100%; + border-radius: var(--radius-util); + background: var(--brand-accent); + transition: width 400ms var(--ease-ui); +} +.util-bar-marker { + position: absolute; + top: 0; + bottom: 0; + width: 1.5px; + background: var(--label); + opacity: 0.55; +} +.util-caption { + display: flex; + align-items: center; + gap: 3px; + font-size: 9.5px; + font-weight: 500; + color: var(--label-3); +} +.util-caption-warn { color: var(--brand-accent); } +.savings-badge { + display: flex; + align-items: center; + gap: 6px; + width: 100%; + margin-top: 2px; + padding: 7px 10px; + border: 0; + border-radius: var(--radius-pill); + background: rgba(201, 82, 29, 0.10); + color: var(--label); + font-size: 11px; + font-weight: 500; + text-align: left; + transition: background 120ms var(--ease-ui); +} +.savings-badge:hover { background: rgba(201, 82, 29, 0.16); } +.savings-badge-icon { color: var(--brand-accent); flex-shrink: 0; } +.savings-badge span { flex: 1; } +.savings-badge-chevron { color: var(--label-3); } + +.plan-state { + display: flex; + flex-direction: column; + align-items: center; + gap: 8px; + padding: 14px 8px; + text-align: center; +} +.plan-state-icon { color: var(--label-3); } +.plan-state-icon-accent { color: var(--brand-accent); } +.plan-state-title { font-size: 12px; font-weight: 600; color: var(--label); } +.plan-state-title-muted { font-size: 11.5px; font-weight: 500; color: var(--label-2); } +.plan-state-note { max-width: 260px; font-size: 10.5px; color: var(--label-2); } +.plan-state-error { + max-width: 280px; + font-size: 10px; + color: var(--label-3); + display: -webkit-box; + -webkit-line-clamp: 3; + -webkit-box-orient: vertical; + overflow: hidden; +} +.plan-actions { display: flex; gap: 8px; margin-top: 2px; } + +/* ---- collapsible sections (Activity / Models) ---------------------------------- */ + +.collapsible { + display: flex; + flex-direction: column; + gap: 7px; + padding: 11px var(--gutter); + border-bottom: 1px solid var(--separator-soft); +} +.collapsible-header { + display: flex; + align-items: center; + gap: 8px; + width: 100%; + border: 0; + padding: 0; + background: transparent; + color: var(--label-2); + text-align: left; +} +.collapsible-spacer { flex: 1; } +.col-header { + font-size: 10px; + font-weight: 500; + letter-spacing: -0.05px; + color: var(--label-3); + text-align: right; +} +.chevron { + color: var(--label-2); + opacity: 0.55; + transition: transform 180ms ease-in-out; + flex-shrink: 0; + margin-left: 2px; +} +.chevron-open { transform: rotate(90deg); } +.collapsible-body { + display: flex; + flex-direction: column; + gap: 7px; + animation: fadeIn 180ms ease-in-out; +} + +.data-row { + display: flex; + align-items: center; + gap: 8px; + padding: 1px 2px; + font-variant-numeric: tabular-nums; +} +.fixed-bar { + position: relative; + display: block; + width: 56px; + height: 6px; + flex-shrink: 0; + border-radius: var(--radius-bar); + background: var(--fill-15); + overflow: hidden; +} +.fixed-bar-fill { display: block; height: 100%; border-radius: var(--radius-bar); background: var(--brand-accent); } +.row-name { + flex: 1; + min-width: 0; + font-size: 12.5px; + font-weight: 500; + color: var(--label); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.row-cost { + font-family: var(--font-mono); + font-size: 12px; + font-weight: 500; + letter-spacing: -0.2px; + text-align: right; + color: var(--label); +} +.row-count { font-size: 11px; text-align: right; color: var(--label-2); } +.row-oneshot { font-size: 10.5px; text-align: right; color: var(--label-2); } + +.tokens-line { + display: flex; + align-items: baseline; + gap: 4px; + padding-top: 5px; + font-size: 10.5px; + font-variant-numeric: tabular-nums; +} +.tokens-label { color: var(--label-3); } +.tokens-value { color: var(--label-2); } +.tokens-sep { color: var(--label-3); } + +/* ---- tips card ------------------------------------------------------------------ */ + +.findings-wrap { padding: 8px var(--gutter); } +.findings-card { + display: flex; + flex-direction: column; + gap: 8px; + padding: 12px; + border-radius: var(--radius-card); + background: var(--fill-06); +} +.findings-header { + display: flex; + align-items: baseline; + justify-content: space-between; + width: 100%; + border: 0; + padding: 0; + background: transparent; + color: var(--label); + text-align: left; +} +.findings-header-left { display: inline-flex; align-items: center; gap: 6px; } +.findings-icon { color: var(--brand-accent); } +.findings-title { font-size: 12.5px; font-weight: 600; } +.findings-header-right { display: inline-flex; align-items: center; gap: 6px; color: var(--label-2); } +.findings-count { font-size: 10.5px; } +.findings-body { display: flex; flex-direction: column; gap: 10px; animation: fadeIn 180ms ease-in-out; } +.tips-group { display: flex; flex-direction: column; gap: 5px; } +.tips-group-header { + display: flex; + align-items: center; + gap: 5px; + font-size: 10.5px; + font-weight: 600; + letter-spacing: 0.4px; + text-transform: uppercase; + color: var(--brand-accent); +} +.tips-item { + display: flex; + align-items: baseline; + gap: 6px; + padding: 0; + font-size: 11.5px; + color: var(--label); +} +.tips-bullet { + width: 3px; + height: 3px; + border-radius: 50%; + background: var(--brand-accent); + flex-shrink: 0; + transform: translateY(-2px); +} +.tips-text { flex: 1; min-width: 0; } +.tips-trailing { + font-family: var(--font-mono); + font-size: 11px; + font-weight: 500; + letter-spacing: -0.2px; + color: var(--label-2); +} +.findings-open-optimize { + display: inline-flex; + align-items: center; + gap: 4px; + align-self: flex-start; + border: 0; + padding: 0; + background: transparent; + color: var(--brand-accent); + font-size: 11.5px; + font-weight: 600; +} +.findings-open-optimize:hover { text-decoration: underline; } + +/* ---- empty / no data / setup ------------------------------------------------- */ + +.empty-provider { + display: flex; + flex-direction: column; + align-items: center; + gap: 10px; + padding: 60px 0; +} +.empty-provider-icon { color: var(--label-3); } +.empty-provider-text { font-size: 12px; font-weight: 500; color: var(--label-2); text-align: center; } + +.no-data { + padding: 10px var(--gutter) 14px; + color: var(--label-2); + font-size: 11.5px; + line-height: 1.5; +} +.no-data-title { margin: 0 0 6px; font-size: 12.5px; font-weight: 600; color: var(--label); } +.no-data p { margin: 0 0 8px; } +.no-data-sub { font-weight: 600; color: var(--label); margin-bottom: 4px; } +.no-data ul { margin: 0 0 10px; padding-left: 16px; } +.no-data li { margin-bottom: 3px; } +.no-data code { + font-size: 10.5px; + padding: 1px 4px; + border-radius: 3px; + background: var(--fill-06); + color: var(--label); + user-select: text; +} +.no-data-tool { color: var(--label-3); } + +.setup { + display: flex; + flex-direction: column; + align-items: center; + gap: 10px; + padding: 36px 24px; + text-align: center; +} +.setup-title { margin: 4px 0 0; font-size: 14px; font-weight: 600; color: var(--label); } +.setup-copy { margin: 0; max-width: 280px; font-size: 11.5px; line-height: 1.5; color: var(--label-2); } +.setup-copy-muted { color: var(--label-3); font-size: 10.5px; } +.setup-command { + display: flex; + align-items: center; + gap: 8px; + padding: 6px 6px 6px 12px; + border-radius: var(--radius-card); + background: var(--fill-06); +} +.setup-command code { font-size: 12px; color: var(--label); user-select: text; } +.setup-actions { display: flex; gap: 8px; margin-top: 4px; } +.setup-details { font-size: 10.5px; color: var(--label-2); max-width: 300px; } +.setup-details summary { display: inline-flex; align-items: center; gap: 4px; list-style: none; color: var(--label-3); } +.setup-details summary::-webkit-details-marker { display: none; } +.setup-error { margin-top: 6px; text-align: left; font-family: var(--font-mono); font-size: 10px; word-break: break-word; user-select: text; } +.setup-error-muted { margin-top: 4px; text-align: left; font-size: 10px; color: var(--label-3); word-break: break-all; } + +/* ---- settings ------------------------------------------------------------------- */ + +.settings { display: flex; flex-direction: column; padding: 8px 0 14px; } +.settings-head { + display: flex; + align-items: center; + gap: 8px; + padding: 2px var(--gutter) 10px; +} +.settings-title { font-size: 13px; font-weight: 600; color: var(--label); } +.settings-group { + display: flex; + flex-direction: column; + padding: 8px var(--gutter) 6px; + border-top: 1px solid var(--separator-soft); +} +.settings-group-label { + font-size: 10px; + font-weight: 600; + letter-spacing: 0.4px; + text-transform: uppercase; + color: var(--label-3); + margin-bottom: 4px; +} +.settings-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + padding: 7px 0; +} +.settings-row-text { flex: 1; min-width: 0; } +.settings-row-label { font-size: 12px; font-weight: 500; color: var(--label); } +.settings-row-hint { margin-top: 1px; font-size: 10.5px; line-height: 1.35; color: var(--label-2); word-break: break-word; } +.settings-row-control { flex-shrink: 0; display: flex; align-items: center; } +.settings-error { font-size: 10.5px; color: var(--danger); padding-bottom: 6px; } +.settings a.btn { text-decoration: none; } + +.toggle { + position: relative; + width: 34px; + height: 20px; + padding: 0; + border: 0; + border-radius: 10px; + background: var(--fill-18); + transition: background 160ms var(--ease-ui); +} +.toggle:disabled { opacity: 0.5; } +.toggle-on { background: var(--brand-accent); } +.toggle-knob { + position: absolute; + top: 2px; + left: 2px; + width: 16px; + height: 16px; + border-radius: 50%; + background: #fff; + box-shadow: 0 1px 2px rgba(0, 0, 0, 0.25); + transition: transform 160ms var(--ease-ui); +} +.toggle-on .toggle-knob { transform: translateX(14px); } + +.segmented { + display: flex; + gap: 1px; + padding: 2px; + border-radius: var(--radius-track); + background: var(--fill-08); +} +.segment { + border: 0; + background: transparent; + padding: 3px 9px; + border-radius: var(--radius-segment); + font-size: 11px; + font-weight: 500; + color: var(--label-2); +} +.segment-active { background: var(--surface-window); color: var(--label); box-shadow: 0 0.5px 2px rgba(0, 0, 0, 0.10); } + +/* ---- loading overlay ---------------------------------------------------------- */ + +.loading-overlay { + position: absolute; + inset: 0; + z-index: 30; + display: flex; + align-items: center; + justify-content: center; + background: var(--overlay-material); + backdrop-filter: blur(18px) saturate(160%); + -webkit-backdrop-filter: blur(18px) saturate(160%); + animation: fadeIn 200ms ease-in-out; +} +.loading-content { display: flex; flex-direction: column; align-items: center; gap: 14px; } +.loading-text { font-size: 11.5px; font-weight: 500; color: var(--label-2); } + +.burn-flame { position: relative; } +.burn-flame svg { position: absolute; inset: 0; } +.burn-flame-glow path { fill: var(--ember-glow); } +.burn-flame-glow { animation: burnGlow 0.9s ease-in-out infinite alternate; } +.burn-flame-outline path { fill: none; stroke: var(--brand-accent); stroke-width: 0.9; opacity: 0.25; } +.burn-clip-rect { animation: burnFill 1.4s ease-in-out infinite alternate; } +@keyframes burnGlow { + from { opacity: 0.20; filter: blur(6px); } + to { opacity: 0.55; filter: blur(14px); } +} +@keyframes burnFill { + from { y: 16px; height: 0px; } + to { y: 0px; height: 16px; } +} +@keyframes fadeIn { from { opacity: 0; } to { opacity: 1; } } + +/* ---- footer ---------------------------------------------------------------------- */ + +.footer { + display: flex; + align-items: center; + gap: 6px; + padding: 8px 12px; + border-top: 1px solid var(--separator); +} +.footer-spacer { flex: 1; } + +.btn { + display: inline-flex; + align-items: center; + gap: 5px; + height: 24px; + padding: 0 9px; + border: 1px solid var(--control-border); + border-radius: 6px; + background: var(--control-bg); + color: var(--label); + font-size: 11px; + font-weight: 500; + line-height: 1; + white-space: nowrap; + box-shadow: 0 0.5px 1px rgba(0, 0, 0, 0.06); + transition: background 120ms var(--ease-ui), border-color 120ms var(--ease-ui); +} +.btn:hover:not(:disabled) { background: var(--control-hover); } +.btn:disabled { opacity: 0.55; } +.btn-pressed { background: var(--control-hover); } +.btn-icon { padding: 0 6px; } +.btn-spinning svg { animation: spin 0.9s linear infinite; } +@keyframes spin { to { transform: rotate(360deg); } } +.btn-prominent, .btn-prominent:hover:not(:disabled) { + background: var(--brand-accent); + border-color: transparent; + color: #fff; + font-weight: 600; +} +.btn-prominent:hover:not(:disabled) { background: #B84A1A; } +.btn-prominent:active { background: var(--ember-deep); } + +.dropmenu { position: relative; } +.dropmenu-panel { + position: absolute; + bottom: calc(100% + 6px); + min-width: 132px; + max-height: 300px; + overflow-y: auto; + scrollbar-width: none; + padding: 4px; + border-radius: 8px; + background: var(--menu-bg); + box-shadow: var(--menu-shadow), inset 0 0 0 0.5px var(--separator); + z-index: 100; + animation: menuIn 120ms var(--ease-ui); +} +.dropmenu-panel::-webkit-scrollbar { display: none; } +.dropmenu-grid { display: grid; gap: 1px 2px; min-width: 204px; } +.dropmenu-grid .dropmenu-item { padding-right: 6px; } +.dropmenu-item-wrap { min-width: 0; } +.dropmenu-left { left: 0; } +.dropmenu-right { right: 0; } +@keyframes menuIn { from { opacity: 0; transform: translateY(4px); } to { opacity: 1; transform: none; } } +.dropmenu-item { + display: flex; + align-items: center; + gap: 4px; + width: 100%; + height: 24px; + padding: 0 8px 0 4px; + border: 0; + border-radius: 5px; + background: transparent; + color: var(--label); + font-size: 11.5px; + text-align: left; +} +.dropmenu-item:hover:not(:disabled) { background: var(--brand-accent); color: #fff; } +.dropmenu-item:disabled { color: var(--label-3); } +.dropmenu-check { display: inline-flex; width: 14px; justify-content: center; flex-shrink: 0; } +.dropmenu-label { flex: 1; white-space: nowrap; } +.dropmenu-sep { height: 1px; margin: 4px 6px; background: var(--separator); } +.dropmenu-footnote { + padding: 6px 8px 3px; + font-size: 10px; + color: var(--label-3); + white-space: nowrap; + border-top: 1px solid var(--separator-soft); + margin-top: 4px; +} +.dropmenu-more .btn { padding: 0 6px; } + +/* ---- star banner --------------------------------------------------------------- */ + +.star-banner { + display: flex; + align-items: center; + gap: 8px; + padding: 6px 12px; + background: rgba(201, 82, 29, 0.08); + box-shadow: inset 0 0.5px 0 var(--fill-18); +} +.star-banner-icon { color: var(--brand-accent); flex-shrink: 0; } +.star-banner-link { font-size: 10.5px; font-weight: 500; color: var(--label); text-decoration: none; } +.star-banner-cta { color: var(--brand-accent); text-decoration: underline; } +.star-banner-spacer { flex: 1; } +.star-banner-close { + display: inline-flex; + padding: 4px; + border: 0; + border-radius: 4px; + background: transparent; + color: var(--label-2); +} +.star-banner-close:hover { background: var(--fill-08); } + +/* ---- error toast --------------------------------------------------------------- */ + +.error-toast { + position: absolute; + left: 12px; + right: 12px; + bottom: 74px; + z-index: 200; + display: flex; + align-items: flex-start; + gap: 8px; + padding: 8px 8px 8px 10px; + border-radius: 8px; + background: var(--tooltip-bg); + color: var(--tooltip-fg); + font-size: 11px; + line-height: 1.4; + box-shadow: 0 4px 20px rgba(0, 0, 0, 0.35), inset 0 0 0 0.5px var(--tooltip-border); + animation: menuIn 160ms var(--ease-ui); +} +.error-toast-text { flex: 1; word-break: break-word; user-select: text; } +.error-toast-close { + display: inline-flex; + padding: 3px; + border: 0; + border-radius: 4px; + background: transparent; + color: var(--tooltip-fg-2); +} +.error-toast-close:hover { background: rgba(127, 127, 127, 0.2); } diff --git a/windows/tokens.json b/windows/tokens.json new file mode 100644 index 00000000..2d07b130 --- /dev/null +++ b/windows/tokens.json @@ -0,0 +1,75 @@ +{ + "version": 1, + "_comment": "Canonical design tokens for CodeBurn. The Swift mac/ app reads this at build time to populate Theme.swift; the windows/ Tauri frontend imports it as CSS custom properties. Side-by-side screenshots on macOS and Linux should read as the same product.", + "color": { + "brand": { + "accent": "#C9521D", + "accentDark": "#E8774A", + "emberDeep": "#8B3E13", + "emberGlow": "#F0A070", + "bright": "#FF7A2A" + }, + "surface": { + "light": "#FAF7F3", + "dark": "#1C1816", + "elevated": "#FFFFFF", + "elevatedDark": "#2A2320" + }, + "text": { + "primary": "#1C1816", + "primaryDark": "#FAF7F3", + "secondary": "#6E5D53", + "secondaryDark": "#B5A49A", + "tertiary": "#A0897D", + "tertiaryDark": "#8A7A70" + }, + "categorical": { + "claude": "#C9521D", + "cursor": "#4A7D5C", + "codex": "#5C7CA3", + "pi": "#8B5A9C", + "copilot":"#B8944C" + } + }, + "type": { + "family": { + "sans": "Inter, 'Segoe UI Variable', 'SF Pro Text', system-ui, sans-serif", + "mono": "'JetBrains Mono', 'SF Mono', 'Cascadia Code', Menlo, monospace", + "rounded": "'SF Pro Rounded', Inter, system-ui, sans-serif" + }, + "scale": { + "hint": 9.5, + "caption": 10.5, + "body": 11.5, + "label": 12.5, + "heading": 16, + "hero": 32 + }, + "weight": { + "regular": 400, + "medium": 500, + "semibold": 600 + } + }, + "spacing": { + "xs": 2, + "sm": 6, + "md": 10, + "lg": 14, + "xl": 20 + }, + "radius": { + "sm": 3, + "md": 6, + "lg": 8, + "pill": 999 + }, + "layout": { + "popoverWidth": 360, + "popoverHeight": 660, + "activityBarWidth": 56, + "trendBarWidth": 13, + "trendBarGap": 4, + "trendChartHeight": 90 + } +} diff --git a/windows/tsconfig.json b/windows/tsconfig.json new file mode 100644 index 00000000..f09bae3e --- /dev/null +++ b/windows/tsconfig.json @@ -0,0 +1,24 @@ +{ + "compilerOptions": { + "target": "ES2022", + "useDefineForClassFields": true, + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "module": "ESNext", + "skipLibCheck": true, + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "resolveJsonModule": true, + "isolatedModules": true, + "moduleDetection": "force", + "noEmit": true, + "jsx": "react-jsx", + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true, + "allowSyntheticDefaultImports": true, + "esModuleInterop": true + }, + "include": ["src"], + "exclude": ["node_modules", "dist", "src-tauri"] +} diff --git a/windows/vite.config.ts b/windows/vite.config.ts new file mode 100644 index 00000000..ecb12221 --- /dev/null +++ b/windows/vite.config.ts @@ -0,0 +1,29 @@ +import { defineConfig } from 'vite' +import react from '@vitejs/plugin-react' + +// Tauri expects a fixed dev-server port so the Rust webview can connect reliably. The +// `@tauri-apps/plugin-*` runtime expects these HMR + strictPort settings to mirror what +// `tauri dev` spawns; tweaking them breaks the IPC bridge on first boot. +const TAURI_DEV_PORT = 1420 + +export default defineConfig(async () => ({ + plugins: [react()], + clearScreen: false, + server: { + port: TAURI_DEV_PORT, + strictPort: true, + host: process.env.TAURI_DEV_HOST || false, + hmr: process.env.TAURI_DEV_HOST + ? { protocol: 'ws', host: process.env.TAURI_DEV_HOST, port: 1421 } + : undefined, + watch: { + ignored: ['**/src-tauri/**'], + }, + }, + envPrefix: ['VITE_', 'TAURI_ENV_*'], + build: { + target: process.env.TAURI_ENV_PLATFORM === 'windows' ? 'chrome105' : 'safari13', + minify: !process.env.TAURI_ENV_DEBUG ? 'esbuild' : false, + sourcemap: !!process.env.TAURI_ENV_DEBUG, + }, +})) From 7e57fb8d4f3f7ca72abcb33ccdf913ab726011e6 Mon Sep 17 00:00:00 2001 From: iamtoruk Date: Tue, 18 Aug 2026 04:10:07 -0700 Subject: [PATCH 42/85] windows: fix the security and correctness findings from the import audit Process spawning (Windows searches the current directory before PATH): - reg.exe and cmd.exe are now spawned by absolute System32 path via cli::system_command, which also carries the CREATE_NO_WINDOW flag the three previous copies each set by hand. The tray badge re-ran `reg query` on every refresh, so this was the most reliably reachable planted-binary path. - "Connect Claude" resolves the claude binary itself instead of handing a bare name to the console shell. - CLI lookup ignores empty and relative PATH entries. `;;` or a trailing `;` used to yield PathBuf::from("").join("codeburn.cmd"), a current-directory lookup, at startup. The filter lives in one place (find_in_dirs) that every search - codeburn, claude, and the Linux terminal probe - goes through. - The Linux terminal path re-validates the whole command against the argument allowlist before joining it into the string `bash -lc` parses; anything that fails falls through to the argv-only detached spawn. CLI version gate: - MIN_CLI_VERSION moves from 0.7.0 to 0.9.9, the first release accepting `status --format menubar-json --no-optimize` (every quiet refresh passes it) and emitting all the payload fields the popover reads. - The gate is probed on mount, before the first fetch. It previously only ran when a fetch failed with the literal "CLI not found", so an old CLI produced a payload whose missing history.daily threw and blanked the popover. - A successful fetch no longer flips an incompatible CLI to compatible, and the settings panel no longer probes on its own - App owns the verdict, so a transient probe failure there cannot drop a working app onto the setup screen. - The payload reads App makes are optional now, so a surprising payload lands on an empty state rather than a blank window. Refresh cadence, mirroring mac RefreshCadence.swift: 60s with optimize findings while the popover is visible, 120s today/all without them while it is hidden, and an immediate refresh on show when the visible key is stale. Every hide path in lib.rs now goes through mark_hidden so the debounce stamp and the frontend signal cannot drift. Previously it was 60s with optimize regardless of visibility - about 2880 CLI spawns a day. Claude quota (plan.rs) stops calling the token refresh endpoint. Claude's refresh token is single-use and rotates, so spending it invalidated the token Claude Code itself holds and broke the user's login. Parity with ClaudeCredentialStore.refreshAfter401: re-read Claude's own store for a token it has already rotated, and report a transient failure when there is not one yet. Smaller: - Snapshot writes refuse a symlinked target and are 0600 on unix, mirroring mac SafeFile; no home directory now means no snapshots rather than a file dropped in whatever directory the tray was launched from. - The Windows config lock keeps its file handle open, so the stale sweep can only ever unlink a lock whose owner is gone. The doc comment now says what the lock actually buys (the CLI never takes it). - "updated Xs ago" is only stamped by a fetch of the key on screen. - External links go through tauri-plugin-opener instead of target=_blank. - The tray badge font is loaded once into a OnceLock instead of read and parsed on every render. - autostart's shared import and constant are cfg-gated so clippy is clean on every OS. Adds unit tests for the PATH filter and the version gate. --- windows/src-tauri/src/autostart.rs | 11 +- windows/src-tauri/src/cli.rs | 196 +++++++++++++++++------ windows/src-tauri/src/config.rs | 34 +++- windows/src-tauri/src/lib.rs | 15 +- windows/src-tauri/src/plan.rs | 113 +++++++------ windows/src-tauri/src/tray_badge.rs | 18 ++- windows/src/App.tsx | 93 +++++++---- windows/src/components/SettingsPanel.tsx | 12 +- windows/src/components/StarBanner.tsx | 5 +- windows/src/styles.css | 12 +- 10 files changed, 338 insertions(+), 171 deletions(-) diff --git a/windows/src-tauri/src/autostart.rs b/windows/src-tauri/src/autostart.rs index b1a1306d..f563d429 100644 --- a/windows/src-tauri/src/autostart.rs +++ b/windows/src-tauri/src/autostart.rs @@ -2,20 +2,21 @@ //! executable. Linux: an XDG autostart .desktop file. No extra crates; both are a few //! lines of `reg` / plain file IO. -use anyhow::{anyhow, Context, Result}; +use anyhow::{anyhow, Result}; +#[cfg(any(target_os = "windows", target_os = "linux"))] +use anyhow::Context; +#[cfg(any(target_os = "windows", target_os = "linux"))] const APP_NAME: &str = "CodeBurn"; #[cfg(target_os = "windows")] const RUN_KEY: &str = r"HKCU\Software\Microsoft\Windows\CurrentVersion\Run"; +/// Absolute `reg.exe` out of System32 -- see `cli::system_command`. #[cfg(target_os = "windows")] fn reg(args: &[&str]) -> Result { - use std::os::windows::process::CommandExt; - const CREATE_NO_WINDOW: u32 = 0x08000000; - std::process::Command::new("reg") + crate::cli::system_command("reg.exe") .args(args) - .creation_flags(CREATE_NO_WINDOW) .output() .with_context(|| "failed to run reg.exe") } diff --git a/windows/src-tauri/src/cli.rs b/windows/src-tauri/src/cli.rs index 84f2b358..53435547 100644 --- a/windows/src-tauri/src/cli.rs +++ b/windows/src-tauri/src/cli.rs @@ -18,14 +18,21 @@ const MAX_STDERR_BYTES: usize = 256 * 1024; const FETCH_TIMEOUT_SECS: u64 = 60; const VERSION_TIMEOUT_SECS: u64 = 20; -/// Oldest CLI that emits the `menubar-json` shape this app renders (history.daily with -/// per-day model breakdown, providers map). Older CLIs get the setup screen instead of a -/// half-rendered popover. -pub const MIN_CLI_VERSION: (u32, u32, u32) = (0, 7, 0); +/// Oldest CLI this app can talk to. 0.9.9 is the first release whose +/// `status --format menubar-json` accepts `--no-optimize`, which every quiet background +/// refresh passes; it also emits all the payload fields the popover reads +/// (`current.providers`, `current.cacheHitPercent`, `history.daily[].topModels`). Older CLIs +/// get the setup screen instead of a half-rendered popover or a stream of spawn failures. +pub const MIN_CLI_VERSION: (u32, u32, u32) = (0, 9, 9); #[cfg(windows)] const WINDOWS_CLI_NAMES: [&str; 2] = ["codeburn.cmd", "codeburn.exe"]; +#[cfg(windows)] +const CLAUDE_NAMES: [&str; 2] = ["claude.cmd", "claude.exe"]; +#[cfg(not(windows))] +const CLAUDE_NAMES: [&str; 1] = ["claude"]; + /// Alphanumerics plus `._/-` and space, with `\`, `:`, `(`, `)` also allowed on Windows /// so a user-supplied `CODEBURN_BIN` path like `C:\Users\...\codeburn.cmd` is accepted. /// None of these are shell metacharacters in a direct-argv spawn (we never invoke `sh -c`). @@ -247,14 +254,32 @@ pub fn parse_version(text: &str) -> Option<(u32, u32, u32)> { /// changed the user's PATH, so we also read the live PATH from the registry on Windows and /// probe the standard npm / node install prefixes. fn locate_cli() -> Option { + find_in_search_dirs(&candidate_names()) +} + +/// Same search for Claude Code's own binary, so "Connect Claude" spawns an absolute path +/// instead of letting the console shell resolve a bare `claude`. +fn locate_claude() -> Option { + find_in_search_dirs(&CLAUDE_NAMES) +} + +fn find_in_search_dirs(names: &[&str]) -> Option { let mut dirs: Vec = Vec::new(); if let Some(path) = env::var_os("PATH") { dirs.extend(env::split_paths(&path)); } dirs.extend(extra_search_dirs()); + find_in_dirs(&dirs, names) +} - for dir in dirs { - for name in candidate_names() { +/// The absolute-only filter is the security boundary, so it lives here where every search +/// goes through it. `env::split_paths` yields an empty `PathBuf` for `;;` or a trailing `;`, +/// and the registry PATH can hold relative entries too; `PathBuf::from("").join("codeburn.cmd")` +/// resolves against the current directory, which for a tray app launched at login is +/// whatever Explorer handed it. A binary planted there must never win. +fn find_in_dirs(dirs: &[PathBuf], names: &[&str]) -> Option { + for dir in dirs.iter().filter(|d| d.is_absolute()) { + for name in names { let candidate = dir.join(name); if candidate.is_file() { return Some(candidate.to_string_lossy().into_owned()); @@ -313,21 +338,42 @@ fn extra_search_dirs() -> Vec { out } +/// Windows' `CreateProcess` searches the current directory before `PATH`, so spawning +/// `reg` or `cmd` by bare name lets anything dropped next to the app impersonate a system +/// tool -- and the tray badge re-runs `reg query` every refresh. Always spawn the real one +/// out of `%SystemRoot%\System32`, falling back to the documented default when the +/// environment variable is missing or relative. +#[cfg(windows)] +pub fn system32_path(exe: &str) -> PathBuf { + let root = env::var_os("SystemRoot") + .map(PathBuf::from) + .filter(|p| p.is_absolute()) + .unwrap_or_else(|| PathBuf::from(r"C:\Windows")); + root.join("System32").join(exe) +} + +/// `system32_path` plus the CREATE_NO_WINDOW flag every one of these callers wants. +#[cfg(windows)] +pub fn system_command(exe: &str) -> std::process::Command { + use std::os::windows::process::CommandExt; + const CREATE_NO_WINDOW: u32 = 0x08000000; + let mut cmd = std::process::Command::new(system32_path(exe)); + cmd.creation_flags(CREATE_NO_WINDOW); + cmd +} + /// Reads the user and machine PATH values from the registry via `reg.exe` so a PATH edit /// made after this process started (npm install adds `%APPDATA%\npm`) is still honoured. #[cfg(windows)] fn registry_path_dirs() -> Vec { - use std::os::windows::process::CommandExt; - const CREATE_NO_WINDOW: u32 = 0x08000000; let mut out = Vec::new(); let keys = [ r"HKCU\Environment", r"HKLM\SYSTEM\CurrentControlSet\Control\Session Manager\Environment", ]; for key in keys { - let output = std::process::Command::new("reg") + let output = system_command("reg.exe") .args(["query", key, "/v", "Path"]) - .creation_flags(CREATE_NO_WINDOW) .output(); let Ok(output) = output else { continue }; let text = String::from_utf8_lossy(&output.stdout); @@ -391,11 +437,14 @@ pub fn spawn_in_terminal(app: &AppHandle, subcommand: &[&str]) -> Result<()> { } /// The Plan view's "Connect Claude" runs Claude Code's own login flow, not codeburn. The -/// bare name is deliberate: on Windows the console shell resolves `claude.exe` (native -/// installer) or `claude.cmd` (npm) through PATHEXT. +/// binary is located up front rather than handed to the console shell as a bare name, so +/// the same absolute-directory rule that protects the codeburn lookup applies here too. pub fn spawn_claude_login(app: &AppHandle) -> Result<()> { + let program = locate_claude().ok_or_else(|| { + anyhow!("Claude Code was not found on this machine. Install it, then try again.") + })?; let cli = CodeburnCli { - program: "claude".to_string(), + program, extra_args: vec![], }; spawn_program_in_terminal(app, &cli, &["login"]) @@ -408,27 +457,32 @@ fn spawn_program_in_terminal(_app: &AppHandle, cli: &CodeburnCli, subcommand: &[ #[cfg(target_os = "linux")] { - let terminals: [&[&str]; 4] = [ - &["x-terminal-emulator", "-e"], - &["gnome-terminal", "--", "bash", "-lc"], - &["konsole", "-e"], - &["xterm", "-e"], - ]; - for term in &terminals { - let program = term[0]; - let extras = &term[1..]; - if which::which(program).is_ok() { - let mut command_parts: Vec = vec![cli.program.clone()]; - command_parts.extend(cli.extra_args.clone()); - command_parts.extend(subcommand.iter().map(|s| s.to_string())); - // gnome-terminal wants the whole command as a single argv after `--` - // followed by `bash -lc`. The allowlist guarantees no quoting is needed. - let composite = command_parts.join(" "); - let mut cmd = std::process::Command::new(program); - cmd.args(extras); - cmd.arg(&composite); - cmd.spawn().with_context(|| format!("failed to launch {}", program))?; - return Ok(()); + let mut command_parts: Vec = vec![cli.program.clone()]; + command_parts.extend(cli.extra_args.clone()); + command_parts.extend(subcommand.iter().map(|s| s.to_string())); + // Terminal emulators take the command as one string that a shell then parses + // (gnome-terminal explicitly hands it to `bash -lc`). `cli.program` reaches here + // from PATH resolution, not only from the allowlisted CODEBURN_BIN, so re-check + // every part before joining; anything a shell could reinterpret skips the terminal + // and goes through the argv-only detached spawn below. + if command_parts.iter().all(|p| is_safe_arg(p)) { + let composite = command_parts.join(" "); + let terminals: [&[&str]; 4] = [ + &["x-terminal-emulator", "-e"], + &["gnome-terminal", "--", "bash", "-lc"], + &["konsole", "-e"], + &["xterm", "-e"], + ]; + for term in &terminals { + let program = term[0]; + let extras = &term[1..]; + if which::which(program).is_ok() { + let mut cmd = std::process::Command::new(program); + cmd.args(extras); + cmd.arg(&composite); + cmd.spawn().with_context(|| format!("failed to launch {}", program))?; + return Ok(()); + } } } // Fallback: run detached, output lost -- better than silently doing nothing. @@ -448,14 +502,16 @@ fn spawn_program_in_terminal(_app: &AppHandle, cli: &CodeburnCli, subcommand: &[ // explicit empty title. `/K` keeps the console open for non-interactive commands // (export) so the user can read where the file went; the TUI (report/optimize) // owns the window until the user quits it either way. - let is_codeburn = cli.program.starts_with("codeburn"); - let program = if std::path::Path::new(&cli.program).is_absolute() || !is_codeburn { - cli.program.clone() - } else { + // Only the unresolved default name is worth a second lookup; anything else is + // either already absolute or a CODEBURN_BIN the user chose. + let program = if cli.program == default_program_name() { locate_cli().unwrap_or_else(|| cli.program.clone()) + } else { + cli.program.clone() }; - let mut cmd = std::process::Command::new("cmd"); - cmd.arg("/C").arg("start").arg("").arg("cmd").arg("/K").arg(&program); + let cmd_exe = system32_path("cmd.exe"); + let mut cmd = system_command("cmd.exe"); + cmd.arg("/C").arg("start").arg("").arg(&cmd_exe).arg("/K").arg(&program); for a in &cli.extra_args { cmd.arg(a); } @@ -488,12 +544,58 @@ mod which { pub fn which(program: &str) -> Result { let path = env::var_os("PATH").ok_or(())?; - for dir in env::split_paths(&path) { - let candidate = dir.join(program); - if candidate.is_file() { - return Ok(candidate); - } - } - Err(()) + let dirs: Vec = env::split_paths(&path).collect(); + super::find_in_dirs(&dirs, &[program]).map(PathBuf::from).ok_or(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// The `;;` / trailing-`;` case: an empty PATH entry must not turn into a + /// current-directory lookup, which is how a planted binary would win at login. + #[test] + fn find_in_dirs_skips_empty_and_relative_entries() { + let dir = std::env::temp_dir(); + let name = "codeburn-menubar-locate-probe"; + let planted = dir.join(name); + std::fs::write(&planted, b"probe").unwrap(); + + // Empty and relative entries are ignored even though the file is reachable + // through them once the process CWD is the temp dir. + let unsafe_dirs = vec![PathBuf::from(""), PathBuf::from("."), PathBuf::from("..")]; + assert_eq!(find_in_dirs(&unsafe_dirs, &[name]), None); + + // The same name behind an absolute entry is found. + let found = find_in_dirs(std::slice::from_ref(&dir), &[name]).expect("absolute entry should match"); + assert!(PathBuf::from(&found).is_absolute()); + assert!(found.ends_with(name)); + + // An unsafe entry ahead of a good one cannot shadow it. + let mixed = vec![PathBuf::from(""), dir.clone()]; + assert_eq!(find_in_dirs(&mixed, &[name]), Some(found)); + + std::fs::remove_file(&planted).ok(); + } + + #[test] + fn parse_version_reads_bare_and_prefixed_output() { + assert_eq!(parse_version("0.9.9"), Some((0, 9, 9))); + assert_eq!(parse_version("codeburn 0.9.20\n"), Some((0, 9, 20))); + assert_eq!(parse_version("1.0"), Some((1, 0, 0))); + assert_eq!(parse_version("0.10.0-beta.1"), Some((0, 10, 0))); + assert_eq!(parse_version("no version here"), None); + } + + /// The gate is a plain tuple compare, so the only thing worth pinning is that the + /// versions on either side of MIN_CLI_VERSION land on the right side of it. + #[test] + fn version_gate_rejects_only_older_clis() { + assert_eq!(MIN_CLI_VERSION, (0, 9, 9)); + assert!(parse_version("0.9.8").unwrap() < MIN_CLI_VERSION); + assert!(parse_version("0.9.9").unwrap() >= MIN_CLI_VERSION); + assert!(parse_version("0.9.20").unwrap() >= MIN_CLI_VERSION); + assert!(parse_version("0.10.0").unwrap() >= MIN_CLI_VERSION); } } diff --git a/windows/src-tauri/src/config.rs b/windows/src-tauri/src/config.rs index 29787ff1..3ec8c0ab 100644 --- a/windows/src-tauri/src/config.rs +++ b/windows/src-tauri/src/config.rs @@ -91,6 +91,8 @@ mod unix_lock { .create(true) .read(true) .write(true) + // A lock file only ever needs to exist; its contents are irrelevant. + .truncate(false) .open(super::lock_path()) .with_context(|| "failed to open config lock")?; @@ -107,9 +109,18 @@ mod unix_lock { } } -/// Windows has no flock; a create-new lock file gives the same mutual exclusion against -/// the CLI's own writer. Stale locks (crash mid-write) are ignored once older than -/// STALE_LOCK_SECS so a single crash can never wedge currency changes forever. +/// Windows has no flock; a create-new lock file is the closest equivalent. +/// +/// What this actually buys: mutual exclusion between writers that take this lock, which +/// today means only other instances of this app. The codeburn CLI writes `config.json` +/// without taking it, so a concurrent CLI write still races us -- the rename below keeps the +/// file from ever being torn, but a simultaneous CLI edit can still be the one that wins. +/// +/// A lock file left behind by a crash is treated as abandoned once it is older than +/// STALE_LOCK_SECS (three orders of magnitude longer than the read-modify-rename it guards), +/// so one crash cannot wedge currency changes forever. Upgrading to `LockFileEx`, which the +/// OS releases on process death and needs no staleness heuristic, only becomes worth it if +/// the CLI ever starts taking the lock too. #[cfg(windows)] mod windows_lock { use std::fs; @@ -124,10 +135,15 @@ mod windows_lock { pub struct Guard { path: PathBuf, + /// Kept open for the lifetime of the guard: Windows will not unlink a file that is + /// still open, so holding the handle is what stops the stale sweep below from ever + /// deleting a lock whose owner is alive. + file: Option, } impl Drop for Guard { fn drop(&mut self) { + self.file.take(); let _ = fs::remove_file(&self.path); } } @@ -136,10 +152,16 @@ mod windows_lock { let path = super::lock_path(); for _ in 0..MAX_RETRIES { match fs::OpenOptions::new().write(true).create_new(true).open(&path) { - Ok(_) => return Ok(Guard { path }), + Ok(file) => { + return Ok(Guard { + path, + file: Some(file), + }) + } Err(err) if err.kind() == std::io::ErrorKind::AlreadyExists => { - if is_stale(&path) { - let _ = fs::remove_file(&path); + // Only proceed on a successful unlink: a live holder still has the file + // open, so this fails for anything but an abandoned lock. + if is_stale(&path) && fs::remove_file(&path).is_ok() { continue; } sleep(RETRY_INTERVAL); diff --git a/windows/src-tauri/src/lib.rs b/windows/src-tauri/src/lib.rs index d040f399..6064a916 100644 --- a/windows/src-tauri/src/lib.rs +++ b/windows/src-tauri/src/lib.rs @@ -83,10 +83,11 @@ pub fn run() { WindowEvent::CloseRequested { api, .. } => { api.prevent_close(); let _ = window.hide(); + mark_hidden(window.app_handle()); } WindowEvent::Focused(false) => { - LAST_HIDDEN_MS.store(now_ms(), Ordering::Relaxed); let _ = window.hide(); + mark_hidden(window.app_handle()); } _ => {} } @@ -274,13 +275,21 @@ fn now_ms() -> i64 { .as_millis() as i64 } +/// Every path that hides the popover goes through here, so the debounce stamp and the +/// frontend's visibility signal can never drift apart. The frontend drops to its idle +/// refresh cadence on `codeburn://hidden` and comes back on `codeburn://shown`. +fn mark_hidden(app: &AppHandle) { + LAST_HIDDEN_MS.store(now_ms(), Ordering::Relaxed); + let _ = app.emit("codeburn://hidden", ()); +} + fn toggle_popover(app: &AppHandle, anchor: Option<(i32, i32)>) { let Some(window) = app.get_webview_window(POPOVER_LABEL) else { return; }; if window.is_visible().unwrap_or(false) { - LAST_HIDDEN_MS.store(now_ms(), Ordering::Relaxed); let _ = window.hide(); + mark_hidden(app); return; } let last = LAST_HIDDEN_MS.load(Ordering::Relaxed); @@ -434,8 +443,8 @@ mod commands { #[tauri::command] pub fn hide_popover(app: AppHandle) { if let Some(window) = app.get_webview_window(POPOVER_LABEL) { - super::LAST_HIDDEN_MS.store(super::now_ms(), std::sync::atomic::Ordering::Relaxed); let _ = window.hide(); + super::mark_hidden(&app); } } diff --git a/windows/src-tauri/src/plan.rs b/windows/src-tauri/src/plan.rs index 165c1b33..98fbbff2 100644 --- a/windows/src-tauri/src/plan.rs +++ b/windows/src-tauri/src/plan.rs @@ -1,18 +1,17 @@ //! Claude subscription usage (the "Plan" insight). Mirrors the macOS SubscriptionClient: -//! read Claude Code's OAuth credentials, call the usage endpoint, refresh once on 401, and -//! keep a rolling snapshot file so a freshly reset window can still show last cycle's final. +//! read Claude Code's OAuth credentials, call the usage endpoint, adopt a token Claude Code +//! has already rotated on 401 (never spending the shared refresh token), and keep a rolling +//! snapshot file so a freshly reset window can still show last cycle's final. use std::fs; use std::path::PathBuf; use std::time::{Duration, SystemTime, UNIX_EPOCH}; -use anyhow::{anyhow, bail, Context, Result}; +use anyhow::{bail, Context, Result}; use serde::{Deserialize, Serialize}; use tokio::sync::Mutex; const CREDENTIALS_RELATIVE_PATH: &str = ".claude/.credentials.json"; -const OAUTH_CLIENT_ID: &str = "9d1c250a-e61b-44d9-88ed-5944d1962f5e"; -const REFRESH_URL: &str = "https://platform.claude.com/v1/oauth/token"; const USAGE_URL: &str = "https://api.anthropic.com/api/oauth/usage"; const BETA_HEADER: &str = "oauth-2025-04-20"; const USER_AGENT: &str = "claude-code/2.1.0"; @@ -78,24 +77,28 @@ impl PlanClient { let response = match fetch_usage(&creds.access_token).await { Ok(r) => r, + // Parity with mac/Sources/CodeBurnMenubar/Data/ClaudeCredentialStore.swift + // (`refreshAfter401`): Claude's refresh token is single-use and rotates, so + // spending it here would invalidate the token Claude Code itself is holding and + // break the user's `claude` login. Instead re-read Claude's own store for a + // token it has already rotated; if there is nothing fresher yet, report a + // transient failure and let the next refresh pick it up. Err(FetchError::Unauthorized) => { - let Some(refresh) = creds.refresh_token.as_deref().filter(|t| !t.is_empty()) else { + let rotated = load_credentials() + .ok() + .flatten() + .map(|c| c.access_token) + .filter(|t| *t != creds.access_token); + let Some(token) = rotated else { return Ok(PlanUsage::Failed { - message: "Claude session expired and no refresh token is available. Run `claude login`.".into(), + message: "Claude is refreshing its session. This clears itself once Claude Code renews the token; run `claude login` if it persists.".into(), }); }; - match refresh_access_token(refresh).await { - Ok(token) => match fetch_usage(&token).await { - Ok(r) => r, - Err(err) => { - return Ok(PlanUsage::Failed { - message: err.to_string(), - }) - } - }, + match fetch_usage(&token).await { + Ok(r) => r, Err(err) => { return Ok(PlanUsage::Failed { - message: format!("Token refresh failed: {err}"), + message: err.to_string(), }) } } @@ -144,7 +147,6 @@ impl PlanClient { struct StoredCredentials { access_token: String, - refresh_token: Option, rate_limit_tier: Option, } @@ -158,8 +160,6 @@ struct CredentialsRoot { struct OAuthBlock { #[serde(rename = "accessToken")] access_token: Option, - #[serde(rename = "refreshToken")] - refresh_token: Option, #[serde(rename = "rateLimitTier")] rate_limit_tier: Option, } @@ -195,7 +195,6 @@ fn load_credentials() -> Result> { } Ok(Some(StoredCredentials { access_token: token, - refresh_token: oauth.refresh_token, rate_limit_tier: oauth.rate_limit_tier, })) } @@ -253,11 +252,6 @@ struct Window { resets_at: Option, } -#[derive(Debug, Deserialize)] -struct TokenRefreshResponse { - access_token: String, -} - #[derive(Debug, thiserror::Error)] enum FetchError { #[error("Claude session is no longer authorized")] @@ -301,27 +295,6 @@ async fn fetch_usage(token: &str) -> Result { .map_err(|e| FetchError::Other(format!("Decode failed: {e}"))) } -async fn refresh_access_token(refresh_token: &str) -> Result { - let response = client() - .map_err(|e| anyhow!(e.to_string()))? - .post(REFRESH_URL) - .header("Accept", "application/json") - .form(&[ - ("grant_type", "refresh_token"), - ("refresh_token", refresh_token), - ("client_id", OAUTH_CLIENT_ID), - ]) - .send() - .await?; - let status = response.status(); - if !status.is_success() { - let body = response.text().await.unwrap_or_default(); - bail!("{} {}", status.as_u16(), truncate(&body, 200)); - } - let decoded: TokenRefreshResponse = response.json().await?; - Ok(decoded.access_token) -} - fn truncate(text: &str, max: usize) -> String { let mut out: String = text.chars().take(max).collect(); if text.chars().count() > max { @@ -345,31 +318,53 @@ struct Snapshot { effective_tokens: Option, } -fn snapshots_path() -> PathBuf { - let dir = std::env::var_os("CODEBURN_CACHE_DIR") +/// None when there is no cache dir and no home dir: writing the snapshot store into the +/// process's current directory would scatter a file wherever the tray happened to be +/// launched from, so we simply skip snapshots instead. +fn snapshots_path() -> Option { + std::env::var_os("CODEBURN_CACHE_DIR") .map(PathBuf::from) .or_else(|| dirs::home_dir().map(|h| h.join(".cache").join("codeburn"))) - .unwrap_or_else(|| PathBuf::from(".")); - dir.join(SNAPSHOT_FILENAME) + .map(|dir| dir.join(SNAPSHOT_FILENAME)) } fn load_snapshots() -> Vec { - fs::read(snapshots_path()) - .ok() + snapshots_path() + .and_then(|p| fs::read(p).ok()) .and_then(|bytes| serde_json::from_slice(&bytes).ok()) .unwrap_or_default() } +/// Mirrors `mac/Sources/CodeBurnMenubar/Security/SafeFile.swift`: write to a temp file with +/// owner-only permissions and rename over the target, and refuse a target that has been +/// replaced by a symlink pointing somewhere else. fn save_snapshots(all: &[Snapshot]) { - let path = snapshots_path(); + let Some(path) = snapshots_path() else { return }; + if let Ok(meta) = fs::symlink_metadata(&path) { + if meta.file_type().is_symlink() { + return; + } + } if let Some(parent) = path.parent() { let _ = fs::create_dir_all(parent); } - if let Ok(bytes) = serde_json::to_vec_pretty(all) { - let tmp = path.with_extension("tmp"); - if fs::write(&tmp, bytes).is_ok() { - let _ = fs::rename(&tmp, &path); - } + let Ok(bytes) = serde_json::to_vec_pretty(all) else { return }; + let tmp = path.with_extension("tmp"); + let mut options = fs::OpenOptions::new(); + options.write(true).create(true).truncate(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + options.mode(0o600); + } + let Ok(mut file) = options.open(&tmp) else { return }; + use std::io::Write; + if file.write_all(&bytes).is_ok() && file.flush().is_ok() { + drop(file); + let _ = fs::rename(&tmp, &path); + } else { + drop(file); + let _ = fs::remove_file(&tmp); } } diff --git a/windows/src-tauri/src/tray_badge.rs b/windows/src-tauri/src/tray_badge.rs index a372b858..5411d59f 100644 --- a/windows/src-tauri/src/tray_badge.rs +++ b/windows/src-tauri/src/tray_badge.rs @@ -80,6 +80,13 @@ const FONT_MAX_FRACTION: f32 = 0.95; const FONT_MIN_FRACTION: f32 = 0.5; const FONT_STEP_PX: f32 = 0.5; +/// Read and parsed once: the badge re-renders on every refresh and the font file does not +/// change under a running session. +fn font() -> Option<&'static fontdue::Font> { + static FONT: std::sync::OnceLock> = std::sync::OnceLock::new(); + FONT.get_or_init(load_font).as_ref() +} + fn load_font() -> Option { for path in FONT_CANDIDATES { if let Ok(bytes) = std::fs::read(path) { @@ -111,12 +118,12 @@ fn layout(font: &fontdue::Font, text: &str, px: f32) -> (Vec, f32, i32, } fn render_with_font(text: &str, size: u32, color: [u8; 3]) -> Option> { - let font = load_font()?; + let font = font()?; let limit = size as f32; let mut px = limit * FONT_MAX_FRACTION; let mut chosen = None; while px >= limit * FONT_MIN_FRACTION { - let (glyphs, width, top, bottom) = layout(&font, text, px); + let (glyphs, width, top, bottom) = layout(font, text, px); let height = (top - bottom) as f32; if width <= limit && height <= limit { chosen = Some((glyphs, width, top, bottom)); @@ -219,16 +226,15 @@ pub fn small_icon_size() -> u32 { /// with the taskbar, not the popover. #[cfg(target_os = "windows")] pub fn taskbar_is_dark() -> bool { - use std::os::windows::process::CommandExt; - const CREATE_NO_WINDOW: u32 = 0x08000000; - let output = std::process::Command::new("reg") + // Absolute `reg.exe` out of System32 -- this runs on every badge refresh, so a bare + // name here would be the single most reliably triggered planted-binary path. + let output = crate::cli::system_command("reg.exe") .args([ "query", r"HKCU\Software\Microsoft\Windows\CurrentVersion\Themes\Personalize", "/v", "SystemUsesLightTheme", ]) - .creation_flags(CREATE_NO_WINDOW) .output(); match output { Ok(out) => { diff --git a/windows/src/App.tsx b/windows/src/App.tsx index 4a4408f8..af923314 100644 --- a/windows/src/App.tsx +++ b/windows/src/App.tsx @@ -33,10 +33,14 @@ import { SettingsPanel, type ThemeChoice } from './components/SettingsPanel' const payloadCache = new PayloadCache() -/// Background cadence. Every tick refreshes today/all (tray tooltip, provider badges) and -/// the visible period/provider; entries younger than STALE_MS are left alone when the -/// popover is re-opened. -const REFRESH_INTERVAL_MS = 60_000 +/// Background cadence, mirroring mac/Sources/CodeBurnMenubar/RefreshCadence.swift: every +/// fetch is a full Node process, so the popover being closed has to cost less than it being +/// open. Visible, a tick refreshes today/all plus the selected period/provider with optimize +/// findings; hidden, a slower tick refreshes only today/all and skips optimize, since the +/// tray badge and tooltip are the only things anyone can see. Entries younger than STALE_MS +/// are left alone when the popover is re-opened. +const REFRESH_ACTIVE_MS = 60_000 +const REFRESH_IDLE_MS = 120_000 const STALE_MS = 60_000 type FetchOptions = { @@ -63,6 +67,8 @@ export function App() { const [theme, setTheme] = useState(() => currentTheme()) const [trayBadge, setTrayBadge] = useState(() => readSetting('trayBadge') !== 'off') const [showSettings, setShowSettings] = useState(false) + // The window starts hidden and is shown by a tray click, which emits `codeburn://shown`. + const [popoverVisible, setPopoverVisible] = useState(false) const [themeChoice, setThemeChoice] = useState(() => { const saved = readSetting('theme') return saved === 'dark' || saved === 'light' ? saved : 'system' @@ -88,10 +94,13 @@ export function App() { if (previous) json.optimize = previous.optimize } payloadCache.set(p, prov, json) - if (isSelected()) setPayload(json) + if (isSelected()) { + setPayload(json) + // "updated Xs ago" describes what the user is looking at, so only a fetch of the + // visible key may stamp it - a background today/all tick must not. + setLastUpdated(new Date()) + } if (p === 'today' && prov === 'all') setTodayPayload(json) - setLastUpdated(new Date()) - setCliStatus(prev => (prev && !(prev.found && prev.compatible) ? { ...prev, found: true, compatible: true, error: null } : prev)) } catch (err) { const message = err instanceof Error ? err.message : String(err) if (message.includes('CLI not found')) { @@ -114,17 +123,9 @@ export function App() { await fetchKey(p, prov, opts) }, [fetchKey]) - const probeCli = useCallback(async () => { - setCliChecking(true) - try { - setCliStatus(await invoke('cli_status')) - } catch { - // Leave the status unknown; the data views already tell the user if fetches fail. - } finally { - setCliChecking(false) - } - }, []) - + /// The single source of truth for the CLI gate. Nothing else writes a "compatible" + /// verdict: a payload that happens to parse does not prove the CLI is new enough, and a + /// probe from the settings panel must not be able to invent one either. const checkCli = useCallback(async () => { setCliChecking(true) try { @@ -140,33 +141,50 @@ export function App() { } }, [refreshAll]) + const cliReady = cliStatus !== null && cliStatus.found && cliStatus.compatible + + // Probe the gate before the first fetch: an old CLI emits a payload missing fields the + // popover reads, which used to blank the whole window instead of showing the setup screen. useEffect(() => { invoke('app_version').then(setVersion).catch(() => {}) - refreshAll({ includeOptimize: true, showOverlay: true }) - const id = setInterval(() => refreshAll({ includeOptimize: true, showOverlay: false }), REFRESH_INTERVAL_MS) + checkCli() + // Startup only; checkCli is re-run from the setup screen and settings on demand. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []) + + useEffect(() => { + if (!cliReady) return + const tick = popoverVisible + ? () => refreshAll({ includeOptimize: true, showOverlay: false }) + : () => fetchKey('today', 'all', { includeOptimize: false, showOverlay: false }) + const id = setInterval(tick, popoverVisible ? REFRESH_ACTIVE_MS : REFRESH_IDLE_MS) return () => clearInterval(id) - }, [refreshAll]) + }, [cliReady, popoverVisible, refreshAll, fetchKey]) useEffect(() => { const cached = payloadCache.get(period, provider) setPayload(cached) + if (!cliReady) return if (!cached) { fetchKey(period, provider, { includeOptimize: true, showOverlay: true }) } else if (payloadCache.age(period, provider) > STALE_MS) { fetchKey(period, provider, { includeOptimize: true, showOverlay: false }) } - }, [period, provider, fetchKey]) + }, [period, provider, cliReady, fetchKey]) useEffect(() => { const unlistenRefresh = listen('codeburn://refresh', () => refreshAll({ includeOptimize: true, showOverlay: true })) const unlistenShown = listen('codeburn://shown', () => { + setPopoverVisible(true) const { period: p, provider: prov } = selection.current if (payloadCache.age(p, prov) > STALE_MS) refreshAll({ includeOptimize: true, showOverlay: false }) }) + const unlistenHidden = listen('codeburn://hidden', () => setPopoverVisible(false)) const unlistenTheme = listen('codeburn://toggle-theme', () => toggleTheme()) return () => { unlistenRefresh.then(fn => fn()) unlistenShown.then(fn => fn()) + unlistenHidden.then(fn => fn()) unlistenTheme.then(fn => fn()) } }, [refreshAll]) @@ -189,16 +207,18 @@ export function App() { return () => window.removeEventListener('keydown', onKey) }, []) - useEffect(() => { - if (!todayPayload) return - const text = `CodeBurn · ${formatCurrency(todayPayload.current.cost, currency)} today` - invoke('set_tray_tooltip', { text }).catch(() => {}) - }, [todayPayload, currency]) + const todayCost = todayPayload?.current?.cost ?? null useEffect(() => { - const text = trayBadge && todayPayload ? trayBadgeText(todayPayload.current.cost, currency) : null + if (todayCost === null) return + const text = `CodeBurn · ${formatCurrency(todayCost, currency)} today` + invoke('set_tray_tooltip', { text }).catch(() => {}) + }, [todayCost, currency]) + + useEffect(() => { + const text = trayBadge && todayCost !== null ? trayBadgeText(todayCost, currency) : null invoke('set_tray_badge', { text }).catch(err => setError(`Tray badge: ${String(err)}`)) - }, [todayPayload, currency, trayBadge]) + }, [todayCost, currency, trayBadge]) const chooseTheme = (choice: ThemeChoice) => { @@ -245,9 +265,13 @@ export function App() { const activeInsight = visibleModes.includes(insight) ? insight : 'trend' const cliBlocked = cliStatus !== null && (!cliStatus.found || !cliStatus.compatible) - const isFilteredEmpty = payload !== null && provider !== 'all' && payload.current.cost <= 0 && payload.current.calls === 0 + // The version gate above is what keeps these fields present; the optional reads are the + // backstop that turns a surprising payload into an empty state rather than a blank window. + const isFilteredEmpty = payload !== null && provider !== 'all' + && (payload.current?.cost ?? 0) <= 0 && (payload.current?.calls ?? 0) === 0 const neverAnyData = payload !== null && provider === 'all' - && payload.current.calls === 0 && payload.current.sessions === 0 && payload.history.daily.length === 0 + && (payload.current?.calls ?? 0) === 0 && (payload.current?.sessions ?? 0) === 0 + && (payload.history?.daily?.length ?? 0) === 0 const footnote = [version ? `CodeBurn v${version}` : 'CodeBurn', lastUpdated ? `updated ${relativePast(lastUpdated)}` : null] .filter(Boolean) @@ -280,7 +304,6 @@ export function App() { onTrayBadge={setTrayBadgePref} cliStatus={cliStatus} onCheckCli={checkCli} - onProbeCli={probeCli} cliChecking={cliChecking} onQuit={() => invoke('quit_app').catch(() => {})} /> @@ -302,12 +325,12 @@ export function App() { {activeInsight === 'plan' && ( )} - {activeInsight === 'trend' && } - {activeInsight === 'forecast' && } + {activeInsight === 'trend' && } + {activeInsight === 'forecast' && } {activeInsight === 'pulse' && payload && } {activeInsight === 'stats' && payload && }
- {payload && ( + {payload?.current && ( <> void cliStatus: CliStatus | null onCheckCli: () => void - onProbeCli: () => void cliChecking: boolean onQuit: () => void } export function SettingsPanel({ onBack, version, currency, onCurrency, themeChoice, onThemeChoice, trayBadge, onTrayBadge, - cliStatus, onCheckCli, onProbeCli, cliChecking, onQuit, + cliStatus, onCheckCli, cliChecking, onQuit, }: Props) { const [loginItem, setLoginItem] = useState(null) const [loginError, setLoginError] = useState(null) + // No CLI probe here: App owns the gate and probes it on mount, so the panel only ever + // displays what that probe found. Its own probe could otherwise fail transiently and drop + // a working app onto the setup screen. useEffect(() => { invoke('launch_at_login').then(setLoginItem).catch(() => setLoginItem(false)) - if (!cliStatus) onProbeCli() - // Probe once when the panel opens; cliStatus arriving later must not re-trigger it. - // eslint-disable-next-line react-hooks/exhaustive-deps }, []) const toggleLogin = async () => { @@ -118,7 +118,7 @@ export function SettingsPanel({
About
- GitHub + diff --git a/windows/src/components/StarBanner.tsx b/windows/src/components/StarBanner.tsx index ca359deb..3b28676d 100644 --- a/windows/src/components/StarBanner.tsx +++ b/windows/src/components/StarBanner.tsx @@ -1,4 +1,5 @@ import { useState } from 'react' +import { openUrl } from '@tauri-apps/plugin-opener' import { readSetting, writeSetting } from '../lib/settings' import { StarIcon, XIcon } from './Icons' @@ -16,10 +17,10 @@ export function StarBanner() { return (
- +
- macOS Menubar
+ Menubar
CodeBurn macOS menubar
- codeburn menubar + codeburn menubar
+ Download the CodeBurn Windows menubar
@@ -107,7 +108,7 @@ Also runs via `bunx codeburn` or `pnpm dlx codeburn`, or `brew install codeburn` codeburn menubar ``` -On Linux, a GNOME Shell extension gives the same panel view; see [Linux (GNOME)](#linux-gnome). +On Windows the same view is a tray app; see [Windows](#windows). On Linux, a GNOME Shell extension gives it in the top panel; see [Linux (GNOME)](#linux-gnome). Requires **Node.js 22.13+** and at least one supported tool with session data on disk. For Cursor and OpenCode, `better-sqlite3` installs automatically. @@ -288,6 +289,8 @@ Pairing is PIN-authorized and stays on your local network. You can also discover ## Menu bar +### macOS + ```bash codeburn menubar ``` @@ -328,6 +331,14 @@ defaults write org.agentseal.codeburn-menubar CodeBurnPreferredTerminal -string Allowed values are `terminal` (macOS Terminal.app, the default) and `iterm2`. Anything else falls back to `terminal`. Only terminals that can script a command into a live window are offered; if the chosen app is missing or fails to accept the command, CodeBurn tries Terminal.app and then runs the command in the background, logging each step to Console.app. Takes effect on the next launch of a command, no relaunch needed. +### Windows + +Windows gets the same ambient view from the system tray. Download the `.msi` from the [latest Windows Menubar release](https://github.com/getagentseal/codeburn/releases/tag/windows-v0.9.20) and run it; `codeburn menubar` on Windows just points you at that page. + +Today's spend sits in the tray as a number beside the flame icon (turn it off in Settings, and the tooltip always carries it). Click for the same popover the macOS app shows: agent tabs, period switcher, Trend, Forecast, Pulse, Stats and Plan insights, activity and model breakdowns, optimize findings, and CSV/JSON export. Settings covers launch at login, the tray number, theme, and currency. It refreshes every 60 seconds while the popover is open and every 2 minutes while it is closed. + +The tray app reads everything through the CLI, so install that first (`npm install -g codeburn`) — it needs **codeburn 0.9.9 or newer**, and shows a setup screen with the install command until it finds one. Source and build instructions are in [`windows/`](windows/) ([windows/DEVELOPMENT.md](windows/DEVELOPMENT.md)). The `.msi` is unsigned for now, so SmartScreen prompts on first run. + ### Linux (GNOME) Linux gets the same ambient view through a GNOME Shell extension (GNOME 45+): spend in the top panel, period switcher, compact mode, and daily budget alerts. It lives in [`gnome/`](gnome/): @@ -338,7 +349,7 @@ git clone https://github.com/getagentseal/codeburn && cd codeburn/gnome gnome-extensions enable codeburn@codeburn.dev ``` -See [gnome/README.md](gnome/README.md) for settings and development notes. On Windows, `codeburn web` is the always-on view for now. +See [gnome/README.md](gnome/README.md) for settings and development notes. The Tauri tray app in `windows/` also builds and runs on Linux, but it is experimental and unreleased there — the GNOME extension is the supported Linux surface. ## CodeBurn in your agent (MCP) diff --git a/docs/architecture.md b/docs/architecture.md index 1e5cb985..3b949bb4 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -4,27 +4,27 @@ A map of the codebase. Read this once before opening a non-trivial PR. ## Three Surfaces -CodeBurn is one Node.js CLI plus two GUI clients that shell out to it. +CodeBurn is one Node.js CLI plus three ambient GUI clients that shell out to it. ``` -+----------------------+ +-----------------+ -| mac/ (Swift) | ---> | | -+----------------------+ | src/cli.ts | -| gnome/ (JavaScript) | ---> | (the CLI) | -+----------------------+ | | - | status | - | --format | - | menubar-json | - +-----------------+ - | - v - +----------------------------+ - | session files on disk | - | (JSONL, SQLite, protobuf) | - +----------------------------+ ++---------------------------+ +-----------------+ +| mac/ (Swift) | ---> | | ++---------------------------+ | src/cli.ts | +| windows/ (Rust + React) | ---> | (the CLI) | ++---------------------------+ | | +| gnome/ (JavaScript) | ---> | status | ++---------------------------+ | --format | + | menubar-json | + +-----------------+ + | + v + +----------------------------+ + | session files on disk | + | (JSONL, SQLite, protobuf) | + +----------------------------+ ``` -The macOS menubar (`mac/`) and the GNOME extension (`gnome/`) both invoke `codeburn status --format menubar-json --period

` and parse the JSON. They do not share code with the CLI; they only depend on its output contract. +The macOS menubar (`mac/`), the Windows tray app (`windows/`), and the GNOME extension (`gnome/`) all invoke `codeburn status --format menubar-json --period

` and parse the JSON. They do not share code with the CLI; they only depend on its output contract. ## CLI (`src/`) @@ -217,6 +217,20 @@ Tests live in `mac/Tests/CodeBurnMenubarTests/` (currently `CapacityEstimatorTes The build artifact is a zipped `.app` bundle produced by `mac/Scripts/package-app.sh`. See `RELEASING.md` for how the GitHub Actions workflow uses it. +## Windows Menubar (`windows/`) + +Tauri 2 app: a Rust binary (`windows/src-tauri/`) owning the tray and the process spawning, plus a React + TypeScript popover (`windows/src/`) rendered in a WebView2 window. Design tokens come from `windows/tokens.json`, the same file `mac/` reads at build time, so both products render as one. + +- `src-tauri/src/lib.rs` builds the tray, positions the popover against the taskbar edge, and registers the `#[tauri::command]` surface the frontend calls. +- `src-tauri/src/cli.rs` resolves and spawns the CLI. Only absolute `PATH` directories are searched (an empty entry from `;;` would otherwise resolve against the current directory), `CODEBURN_BIN` is allowlisted, and Windows system tools are spawned by absolute `%SystemRoot%\System32` path because `CreateProcess` searches the current directory first. `MIN_CLI_VERSION` gates the whole app; below it the popover shows a setup screen. +- `src-tauri/src/plan.rs` ports the Claude quota view. Like the macOS `ClaudeCredentialStore`, it never spends Claude's single-use refresh token; on a 401 it re-reads Claude's own credential file for a token Claude Code has already rotated. +- `src-tauri/src/tray_badge.rs` renders today's spend into a second tray icon, since Windows has no menubar title. +- `src/App.tsx` owns the payload cache, the CLI gate, and the refresh cadence, which follows popover visibility the way `mac/`'s `RefreshCadence.swift` does. + +`cargo test` covers the PATH filter and the version gate. `windows/DEVELOPMENT.md` has the build, security, and release details; CI is `.github/workflows/windows-menubar-ci.yml` and releases go out on `windows-v*` tags. + +The Linux (ksni) paths in the same crate are kept compiling but are experimental and unreleased; `gnome/` is the shipping Linux surface. + ## GNOME Extension (`gnome/`) Plain JavaScript, no bundler. Targets GNOME Shell 45-50 (`metadata.json`). diff --git a/src/menubar-installer.ts b/src/menubar-installer.ts index b43b136f..3f22bef5 100644 --- a/src/menubar-installer.ts +++ b/src/menubar-installer.ts @@ -22,6 +22,9 @@ const EXPECTED_BUNDLE_ID = 'org.agentseal.codeburn-menubar' const VERSIONED_ASSET_PATTERN = /^CodeBurnMenubar-v.+\.zip$/ const APP_PROCESS_NAME = 'CodeBurnMenubar' const SUPPORTED_OS = 'darwin' +/// The Windows tray app (windows/) is released as an .msi under its own tag, so this command +/// points at it rather than pretending to install anything. +const WINDOWS_RELEASE_PAGE = 'https://github.com/getagentseal/codeburn/releases?q=windows-v' const MIN_MACOS_MAJOR = 14 const PERSISTED_CLI_PATH = join(homedir(), 'Library', 'Application Support', 'CodeBurn', 'codeburn-cli-path.v1') const PERSISTENT_CLI_REQUIRED_MESSAGE = @@ -184,6 +187,12 @@ async function exists(path: string): Promise { } async function ensureSupportedPlatform(): Promise { + if (platform() === 'win32') { + throw new Error( + 'The Windows menubar ships as an installer, not through this command. ' + + `Download the .msi from ${WINDOWS_RELEASE_PAGE} and run it.`, + ) + } if (platform() !== SUPPORTED_OS) { throw new Error(`The menubar app is macOS only (detected: ${platform()}).`) } From 30037b331f98191af5fedfd461c2441dd492e23f Mon Sep 17 00:00:00 2001 From: iamtoruk Date: Tue, 18 Aug 2026 04:32:20 -0700 Subject: [PATCH 45/85] test: raise vitest testTimeout to 30s The 5s default failed three unrelated real-I/O tests on the CI Node-22 runner in two days (dashboard optimize scan, codex resume differential, OTel DB prune) purely from runner load. Hung tests still fail at 30s. --- vitest.config.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/vitest.config.ts b/vitest.config.ts index b56c0158..0fa9f58b 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -6,5 +6,8 @@ export default defineConfig({ // session-discovery env vars (CLAUDE_CONFIG_DIRS, HOME, XDG_*, every // provider-specific *_HOME) don't bleed real local data into fixtures. setupFiles: ['./tests/setup/env-isolation.ts'], + // Real-I/O tests (session parses, sqlite fixtures, worker pools) exceed the + // 5s default under CI runner load; a hung test still fails at 30s. + testTimeout: 30_000, }, }) From 76c63cbfd13d35374ddbefb43382ad1e2281c336 Mon Sep 17 00:00:00 2001 From: iamtoruk Date: Tue, 18 Aug 2026 04:37:27 -0700 Subject: [PATCH 46/85] windows: cfg-gate the tray badge so the Linux build has no dead code The spend badge is a second tray icon carrying the number as its bitmap, which only the Tauri tray backend provides; Linux runs its own SNI tray and has no equivalent. The module was compiled there anyway, so every item in it - and the two tray ids in lib.rs - tripped dead_code under clippy -D warnings on the ubuntu leg. - `mod tray_badge` and TRAY_ID / BADGE_TRAY_ID are now cfg(not(linux)), so the code is absent on Linux rather than present and unused. - `set_tray_badge` reports the badge as unsupported on Linux instead of returning a success that never happened. - The frontend hides the control wherever it is unsupported, behind TRAY_BADGE_SUPPORTED in lib/platform.ts - one constant, three call sites (the settings row, the footer menu item, and the effect that would otherwise invoke the command). - AppState's `linux_tray` field is dropped: it was written and never read, which is the same lint one file over. init_tray_linux already owns the handle. Checked rather than reasoned: `cargo clippy --all-targets -- -D warnings` passes on macOS, and a scratch copy of the crate with the linux/macos cfg arms swapped (so a normal macOS clippy selects everything a Linux build would keep, and drops everything it would drop) also passes. Reinstating the ungated `mod tray_badge` in that copy reproduces the exact CI failure, so the check is real. It stubs tray_linux.rs, whose ksni and png deps do not build here - that file is still only covered by CI's ubuntu leg. --- windows/DEVELOPMENT.md | 6 ++++ windows/src-tauri/src/lib.rs | 36 +++++++++++++----------- windows/src/App.tsx | 4 ++- windows/src/components/FooterBar.tsx | 5 +++- windows/src/components/SettingsPanel.tsx | 10 ++++--- windows/src/lib/platform.ts | 6 ++++ 6 files changed, 44 insertions(+), 23 deletions(-) diff --git a/windows/DEVELOPMENT.md b/windows/DEVELOPMENT.md index fee5a3d2..9df090a5 100644 --- a/windows/DEVELOPMENT.md +++ b/windows/DEVELOPMENT.md @@ -8,6 +8,12 @@ Linux (ksni / AppIndicator) support is compiled and kept working for dev, but it **experimental and unreleased** - Linux users should use the GNOME extension in `../gnome/`. The releases this repo cuts from here are Windows only. +Not everything crosses over: the spend badge is a second tray icon carrying the number as its +bitmap, which only the Windows notification area provides. `tray_badge` is compiled out on +Linux, the `set_tray_badge` command reports it as unsupported there, and the frontend hides +the control behind `TRAY_BADGE_SUPPORTED` in `src/lib/platform.ts`. Anything else that is +Windows-only must be cfg-gated the same way, or the ubuntu leg of CI fails on dead code. + ## Architecture ``` diff --git a/windows/src-tauri/src/lib.rs b/windows/src-tauri/src/lib.rs index 6064a916..64e67111 100644 --- a/windows/src-tauri/src/lib.rs +++ b/windows/src-tauri/src/lib.rs @@ -3,6 +3,10 @@ mod cli; mod config; mod fx; mod plan; +/// The spend-in-the-tray badge is a second tray icon, which only the Tauri tray backend +/// provides; Linux runs its own SNI tray (`tray_linux`) and has no equivalent, so the +/// whole module is compiled out there rather than sitting unused. +#[cfg(not(target_os = "linux"))] mod tray_badge; #[cfg(target_os = "linux")] mod tray_linux; @@ -26,9 +30,11 @@ use crate::cli::CodeburnCli; use crate::config::CurrencyConfig; use crate::fx::FxCache; +#[cfg(not(target_os = "linux"))] const TRAY_ID: &str = "codeburn-tray"; /// Second tray icon that carries today's spend as text, sitting next to the logo. The -/// closest Windows and Linux panels get to the macOS menubar title. +/// closest the Windows notification area gets to the macOS menubar title. +#[cfg(not(target_os = "linux"))] const BADGE_TRAY_ID: &str = "codeburn-badge"; const POPOVER_LABEL: &str = "popover"; @@ -41,8 +47,6 @@ pub struct AppState { pub config: Mutex, pub fx: FxCache, pub plan: plan::PlanClient, - #[cfg(target_os = "linux")] - pub linux_tray: tray_linux::LinuxTrayHandle, } #[cfg_attr(mobile, tauri::mobile_entry_point)] @@ -51,24 +55,18 @@ pub fn run() { .plugin(tauri_plugin_shell::init()) .plugin(tauri_plugin_opener::init()) .setup(|app| { - #[cfg(target_os = "linux")] - let linux_tray = tray_linux::LinuxTrayHandle::empty(); - - let state = AppState { + app.manage(AppState { cli: Mutex::new(CodeburnCli::resolve()), config: Mutex::new(CurrencyConfig::load_or_default()), fx: FxCache::new(), plan: plan::PlanClient::new(), - #[cfg(target_os = "linux")] - linux_tray: linux_tray.clone(), - }; - app.manage(state); + }); #[cfg(not(target_os = "linux"))] build_tray_tauri(app.handle())?; #[cfg(target_os = "linux")] - init_tray_linux(app.handle().clone(), linux_tray); + init_tray_linux(app.handle().clone(), tray_linux::LinuxTrayHandle::empty()); if let Some(window) = app.get_webview_window(POPOVER_LABEL) { let _ = window.hide(); @@ -466,6 +464,14 @@ mod commands { /// `text` is a short spend string ("$87", "142", "1.2K"); `None` hides the badge icon. #[tauri::command] pub fn set_tray_badge(app: AppHandle, text: Option) -> Result<(), String> { + #[cfg(target_os = "linux")] + { + // Unreachable from the UI: the frontend hides the control wherever the badge is + // unsupported (lib/platform.ts). Saying so beats reporting a success that never + // happened. + let _ = (app, text); + Err("the tray spend badge needs a second tray icon, which the Linux SNI tray does not provide".to_string()) + } #[cfg(not(target_os = "linux"))] { let Some(badge) = app.tray_by_id(super::BADGE_TRAY_ID) else { @@ -487,12 +493,8 @@ mod commands { badge.set_visible(false).map_err(|e| e.to_string())?; } } + Ok(()) } - #[cfg(target_os = "linux")] - { - let _ = (app, text); - } - Ok(()) } #[tauri::command] diff --git a/windows/src/App.tsx b/windows/src/App.tsx index af923314..77281597 100644 --- a/windows/src/App.tsx +++ b/windows/src/App.tsx @@ -8,6 +8,7 @@ import { USD, formatCurrency, trayBadgeText } from './lib/currency' import { PayloadCache } from './lib/cache' import { relativePast } from './lib/dates' import { applyTheme, currentTheme, readSetting, writeSetting } from './lib/settings' +import { TRAY_BADGE_SUPPORTED } from './lib/platform' import { AgentTabStrip, detectedProviders } from './components/AgentTabStrip' import type { Provider } from './components/AgentTabStrip' import { ModelsSection } from './components/ModelsSection' @@ -65,7 +66,7 @@ export function App() { const [version, setVersion] = useState('') const [lastUpdated, setLastUpdated] = useState(null) const [theme, setTheme] = useState(() => currentTheme()) - const [trayBadge, setTrayBadge] = useState(() => readSetting('trayBadge') !== 'off') + const [trayBadge, setTrayBadge] = useState(() => TRAY_BADGE_SUPPORTED && readSetting('trayBadge') !== 'off') const [showSettings, setShowSettings] = useState(false) // The window starts hidden and is shown by a tray click, which emits `codeburn://shown`. const [popoverVisible, setPopoverVisible] = useState(false) @@ -216,6 +217,7 @@ export function App() { }, [todayCost, currency]) useEffect(() => { + if (!TRAY_BADGE_SUPPORTED) return const text = trayBadge && todayCost !== null ? trayBadgeText(todayCost, currency) : null invoke('set_tray_badge', { text }).catch(err => setError(`Tray badge: ${String(err)}`)) }, [todayCost, currency, trayBadge]) diff --git a/windows/src/components/FooterBar.tsx b/windows/src/components/FooterBar.tsx index 68490006..6ec88ed1 100644 --- a/windows/src/components/FooterBar.tsx +++ b/windows/src/components/FooterBar.tsx @@ -1,5 +1,6 @@ import type { CurrencyState } from '../lib/currency' import { CURRENCY_CODES } from '../lib/currency' +import { TRAY_BADGE_SUPPORTED } from '../lib/platform' import { DropMenu } from './DropMenu' import { CoinIcon, DownloadIcon, EllipsisIcon, RefreshIcon, TerminalIcon } from './Icons' @@ -64,7 +65,9 @@ export function FooterBar({ className="dropmenu-more" items={[ { id: 'settings', label: settingsOpen ? 'Back to overview' : 'Settings…' }, - { id: 'badge', label: "Show today's cost in tray", checked: trayBadge, separatorBefore: true }, + ...(TRAY_BADGE_SUPPORTED + ? [{ id: 'badge', label: "Show today's cost in tray", checked: trayBadge, separatorBefore: true }] + : []), { id: 'theme', label: themeLabel }, { id: 'quit', label: 'Quit CodeBurn', separatorBefore: true }, ]} diff --git a/windows/src/components/SettingsPanel.tsx b/windows/src/components/SettingsPanel.tsx index 766e81ca..5e9e3fae 100644 --- a/windows/src/components/SettingsPanel.tsx +++ b/windows/src/components/SettingsPanel.tsx @@ -3,7 +3,7 @@ import { invoke } from '@tauri-apps/api/core' import { openUrl } from '@tauri-apps/plugin-opener' import type { CurrencyState } from '../lib/currency' import { CURRENCY_CODES } from '../lib/currency' -import { homePath } from '../lib/platform' +import { homePath, TRAY_BADGE_SUPPORTED } from '../lib/platform' import type { CliStatus } from './SetupState' import { DropMenu } from './DropMenu' import { ChevronDown, ChevronRight } from './Icons' @@ -70,9 +70,11 @@ export function SettingsPanel({ {loginError &&

{loginError}
} - - onTrayBadge(!trayBadge)} /> - + {TRAY_BADGE_SUPPORTED && ( + + onTrayBadge(!trayBadge)} /> + + )}
diff --git a/windows/src/lib/platform.ts b/windows/src/lib/platform.ts index a7a1733f..538d9f3f 100644 --- a/windows/src/lib/platform.ts +++ b/windows/src/lib/platform.ts @@ -8,3 +8,9 @@ const SEP = IS_WINDOWS ? '\\' : '/' export function homePath(...parts: string[]): string { return [HOME, ...parts].join(SEP) } + +/// Today's spend in the tray is a second tray icon carrying the number as its bitmap. Only +/// the Windows notification area gives us one; the Linux SNI tray has no equivalent, and +/// macOS ships the Swift menubar instead. Where this is false the control is hidden and the +/// Rust command is never called. +export const TRAY_BADGE_SUPPORTED = IS_WINDOWS From 527e58078d40fb636f55a5e3e30210759fb136b1 Mon Sep 17 00:00:00 2001 From: iamtoruk Date: Tue, 18 Aug 2026 06:55:09 -0700 Subject: [PATCH 47/85] menubar: install and launch the Windows tray app from codeburn menubar Shares the mac release resolution behind a per-platform spec (tag prefix, asset name, error text), so the Windows path reuses the pinned-version URL, the release-API fallback scan, the retrying download and the sha256 verify unchanged. Windows then runs msiexec out of %SystemRoot%\System32 with /i /passive /norestart, treats 3010 and 1602 as non-failures, and launches the exe named by the product's Uninstall registry key. --- CHANGELOG.md | 3 +- README.md | 10 +- src/main.ts | 7 +- src/menubar-installer.ts | 247 +++++++++++++++++--- tests/menubar-installer-windows.test.ts | 299 ++++++++++++++++++++++++ windows/DEVELOPMENT.md | 6 + 6 files changed, 538 insertions(+), 34 deletions(-) create mode 100644 tests/menubar-installer-windows.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 2e5f9394..6e38464a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,7 +8,8 @@ - **Optimize findings say what to do with them and where their number came from.** Every finding now carries a class and a basis, and every surface groups by it: `Fix now (apply-able)` for findings `codeburn optimize --apply` can write itself, `Habits` for the behavioural ones, `FYI` for informational ones whose cost may be justified. A finding only counts as apply-able when a plan can actually be built for that instance, so an `mcp-deferral-off` caused by Vertex policy or a shell-profile override is grouped as a habit rather than promising a fix that does not exist. Alongside it, each finding is marked `measured` (summed from provider-counted usage) or `estimated` (a schema-size or recovery-fraction model), with the split reported in the header as `N measured · M estimated` in place of the blanket "Estimates only." footer. Sessions whose cost the provider never reported are kept out of the `cost-outliers` peer comparison, and a provider that only ever estimates gets the finding marked `estimated` rather than dropped. `--format json` gains `class` and `basis` per finding plus `summary.measuredSavingsUSD` (existing fields unchanged), and the new `docs/optimize.md` covers what is scanned, exactly what `--apply` may write, and how to read the health grade. ### Added (Windows) -- **A menubar app for Windows.** `windows/` is a Tauri 2 tray app — Rust binary, React popover — that puts today's spend in the notification area and mirrors the macOS menubar screen for screen: agent tabs, period switcher, Trend, Forecast, Pulse, Stats and Plan insights, activity and model breakdowns, optimize findings, CSV/JSON export, launch at login, currency, and theme. Windows has no menubar title, so the number lives in a second tray icon rendered from the system font at the panel's native icon size (Settings can turn it off; the tooltip always carries it). It reads everything through the CLI like the macOS and GNOME clients do, and gates on **codeburn 0.9.9 or newer** — the first release accepting `status --format menubar-json --no-optimize` — showing a setup screen with the install command until it finds one. Refresh follows popover visibility the way the macOS app does: 60 s with optimize findings while open, 2 minutes for today's total while closed, and immediately on open when what you are looking at has gone stale. The Claude quota view never spends Claude's single-use refresh token; on a 401 it re-reads Claude Code's own credential file for a token it has already rotated, matching the macOS client. Ships as an unsigned `.msi` from the `windows-v*` tag; `codeburn menubar` on Windows points at that release page. The same crate still builds and runs a tray on Linux, but that stays experimental and unreleased — `gnome/` is the supported Linux surface. +- **`codeburn menubar` installs and launches the tray app on Windows.** The same command that installs the macOS menubar now does the Windows one, through the same pinned-release path: it resolves `windows-v`, falls back to a scan of the newest `windows-v*` release carrying both assets when that tag has none, downloads the `.msi` with the same retry and backoff, and verifies its sha256 before anything executes it — a mismatch aborts without ever handing the file to the installer. It then runs `msiexec` out of `%SystemRoot%\System32` (never a bare name, so nothing dropped next to the CLI can impersonate it) with `/i /passive /norestart`, treats exit 3010 as installed-pending-restart and 1602 as a cancelled install rather than failures, and launches the exe named by the product's Uninstall registry key. An already-installed matching version skips the download and just launches; `--force` reinstalls. +- **A menubar app for Windows.** `windows/` is a Tauri 2 tray app — Rust binary, React popover — that puts today's spend in the notification area and mirrors the macOS menubar screen for screen: agent tabs, period switcher, Trend, Forecast, Pulse, Stats and Plan insights, activity and model breakdowns, optimize findings, CSV/JSON export, launch at login, currency, and theme. Windows has no menubar title, so the number lives in a second tray icon rendered from the system font at the panel's native icon size (Settings can turn it off; the tooltip always carries it). It reads everything through the CLI like the macOS and GNOME clients do, and gates on **codeburn 0.9.9 or newer** — the first release accepting `status --format menubar-json --no-optimize` — showing a setup screen with the install command until it finds one. Refresh follows popover visibility the way the macOS app does: 60 s with optimize findings while open, 2 minutes for today's total while closed, and immediately on open when what you are looking at has gone stale. The Claude quota view never spends Claude's single-use refresh token; on a 401 it re-reads Claude Code's own credential file for a token it has already rotated, matching the macOS client. Ships as an unsigned `.msi` from the `windows-v*` tag, which `codeburn menubar` now installs for you. The same crate still builds and runs a tray on Linux, but that stays experimental and unreleased — `gnome/` is the supported Linux surface. ### Added (CLI) - **DeepSeek Harness (`dsh`) is now a supported provider.** Reads DeepSeek's open-source agent harness from `~/.dsh/sessions` (`DSH_HOME` relocates the root), both the default zstd logs and the uncompressed `session.jsonl` variant. A `.zstd` log is a concatenation of independent zstd frames, one per write batch, so it is decoded frame by frame behind a structural frame scan and a torn trailing frame from a crashed writer is ignored rather than failing the file (needs Node 22.15+ for `zlib` zstd; below that dsh is skipped with a notice instead of counted as $0). One call per `(turn, step)`, with the step's final `assistant/message` usage superseding the streamed `assistant/chunk` sample of the same call rather than adding to it, the model taken from the message that served the step, and reasoning tokens billed at the output rate. DSH records tokens but no cost, so calls are priced from the shared tables. The events a forked session replays from its parent are skipped, since codeburn already counts the parent's own log. The session format is pinned at version 0 upstream with no compatibility implied, so a log stamped with any other version is skipped with a notice instead of read under today's assumptions. diff --git a/README.md b/README.md index fc9f760a..d587b3ef 100644 --- a/README.md +++ b/README.md @@ -108,7 +108,7 @@ Also runs via `bunx codeburn` or `pnpm dlx codeburn`, or `brew install codeburn` codeburn menubar ``` -On Windows the same view is a tray app; see [Windows](#windows). On Linux, a GNOME Shell extension gives it in the top panel; see [Linux (GNOME)](#linux-gnome). +The same command installs the tray app on Windows; see [Windows](#windows). On Linux, a GNOME Shell extension gives it in the top panel; see [Linux (GNOME)](#linux-gnome). Requires **Node.js 22.13+** and at least one supported tool with session data on disk. For Cursor and OpenCode, `better-sqlite3` installs automatically. @@ -333,7 +333,13 @@ Allowed values are `terminal` (macOS Terminal.app, the default) and `iterm2`. An ### Windows -Windows gets the same ambient view from the system tray. Download the `.msi` from the [latest Windows Menubar release](https://github.com/getagentseal/codeburn/releases/tag/windows-v0.9.20) and run it; `codeburn menubar` on Windows just points you at that page. +Windows gets the same ambient view from the system tray, from the same one command: + +```powershell +codeburn menubar +``` + +It downloads the `.msi` for your CLI version, verifies its sha256, runs it through `msiexec /passive`, and launches the tray app. Re-run with `--force` to reinstall; an already-installed matching version is just launched. You can also download the `.msi` yourself from the [latest Windows Menubar release](https://github.com/getagentseal/codeburn/releases/tag/windows-v0.9.20). Today's spend sits in the tray as a number beside the flame icon (turn it off in Settings, and the tooltip always carries it). Click for the same popover the macOS app shows: agent tabs, period switcher, Trend, Forecast, Pulse, Stats and Plan insights, activity and model breakdowns, optimize findings, and CSV/JSON export. Settings covers launch at login, the tray number, theme, and currency. It refreshes every 60 seconds while the popover is open and every 2 minutes while it is closed. diff --git a/src/main.ts b/src/main.ts index e5246df5..3ed919d8 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1311,12 +1311,13 @@ program program .command('menubar') - .description('Install and launch the macOS menubar app (one command, no clone)') - .option('--force', 'Reinstall even if an older copy is already in ~/Applications') + .description('Install and launch the menubar app on macOS and Windows (one command, no clone)') + .option('--force', 'Reinstall even if a copy is already installed') .action(async (opts: { force?: boolean }) => { try { const result = await installMenubarApp({ force: opts.force, cliVersion: version }) - console.log(`\n Ready. ${result.installedPath}\n`) + // A cancelled Windows installer leaves nothing to point at. + if (result.installedPath) console.log(`\n Ready. ${result.installedPath}\n`) } catch (err) { const message = err instanceof Error ? err.message : String(err) console.error(`\n Menubar install failed: ${message}\n`) diff --git a/src/menubar-installer.ts b/src/menubar-installer.ts index 3f22bef5..62494349 100644 --- a/src/menubar-installer.ts +++ b/src/menubar-installer.ts @@ -8,6 +8,7 @@ import { pipeline } from 'node:stream/promises' import { Readable } from 'node:stream' import { ProxyAgent, fetch as undiciFetch } from 'undici' +import { getCodeburnCacheDir } from './cache-dir.js' import { buildPersistentCodeburnLookupPath, resolvePersistentCodeburnPathFromWhichOutput, @@ -22,9 +23,11 @@ const EXPECTED_BUNDLE_ID = 'org.agentseal.codeburn-menubar' const VERSIONED_ASSET_PATTERN = /^CodeBurnMenubar-v.+\.zip$/ const APP_PROCESS_NAME = 'CodeBurnMenubar' const SUPPORTED_OS = 'darwin' -/// The Windows tray app (windows/) is released as an .msi under its own tag, so this command -/// points at it rather than pretending to install anything. -const WINDOWS_RELEASE_PAGE = 'https://github.com/getagentseal/codeburn/releases?q=windows-v' +/// The Windows tray app (windows/) ships as an .msi under its own `windows-v*` tag. GitHub +/// rewrites the spaces in the bundle name to dots when it stores the asset, so both the asset +/// name and its download URL carry `CodeBurn.Menubar_...`. +const WINDOWS_PRODUCT_NAME = 'CodeBurn Menubar' +const WINDOWS_ASSET_PATTERN = /^CodeBurn\.Menubar_.+_x64_en-US\.msi$/ const MIN_MACOS_MAJOR = 14 const PERSISTED_CLI_PATH = join(homedir(), 'Library', 'Application Support', 'CodeBurn', 'codeburn-cli-path.v1') const PERSISTENT_CLI_REQUIRED_MESSAGE = @@ -34,8 +37,45 @@ export type InstallResult = { installedPath: string; launched: boolean } export type ReleaseAsset = { name: string; browser_download_url: string } export type ReleaseResponse = { tag_name: string; assets: ReleaseAsset[] } +/// `zip` is the platform's primary asset: the mac bundle zip, or the Windows .msi. export type ResolvedAssets = { release: ReleaseResponse; zip: ReleaseAsset; checksum: ReleaseAsset } -export type InstallOptions = { force?: boolean; cliVersion?: string } +export type InstallOptions = { + force?: boolean + cliVersion?: string + platform?: string + windows?: WindowsInstallHooks +} + +/// What differs per platform between the mac and Windows installs: which release tag holds the +/// build, and which asset in it is the installable. Everything downstream - versioned URL first, +/// release-API scan as fallback, retrying download, checksum verify - is shared. +export type ReleaseSpec = { + tagPrefix: string + assetPattern: RegExp + assetName: (version: string) => string + missingAsset: (tag: string) => string + noRelease: string +} + +const MAC_RELEASE: ReleaseSpec = { + tagPrefix: 'mac-v', + assetPattern: VERSIONED_ASSET_PATTERN, + assetName: version => `CodeBurnMenubar-v${version}.zip`, + missingAsset: tag => + `No ${APP_BUNDLE_NAME} versioned zip found in release ${tag}. ` + + `Check https://github.com/getagentseal/codeburn/releases.`, + noRelease: 'No mac-v* release with a CodeBurnMenubar-v*.zip and checksum was found.', +} + +export const WINDOWS_RELEASE: ReleaseSpec = { + tagPrefix: 'windows-v', + assetPattern: WINDOWS_ASSET_PATTERN, + assetName: version => `CodeBurn.Menubar_${version}_x64_en-US.msi`, + missingAsset: tag => + `No ${WINDOWS_PRODUCT_NAME} .msi found in release ${tag}. ` + + `Check https://github.com/getagentseal/codeburn/releases.`, + noRelease: 'No windows-v* release with a CodeBurn.Menubar_*.msi and checksum was found.', +} type ProxyEnv = Partial> type FetchOptions = Parameters[1] type HeaderGetter = { get(name: string): string | null } @@ -50,6 +90,10 @@ type FetchLikeResponse = { text(): Promise } type FetchImpl = (url: string, options?: FetchOptions) => Promise +/// The release-API lookup reads JSON instead of streaming a body, so it takes its own narrow +/// response shape rather than widening FetchLikeResponse for every asset download fake. +export type ReleaseApiFetch = (url: string, options?: FetchOptions) => + Promise<{ ok: boolean; status: number; headers: HeaderGetter; json(): Promise }> /// Release-asset delivery (github.com -> Azure blob) occasionally returns a transient 5xx or /// drops the socket. Three attempts with a short exponential backoff (0.5s, then 1s) rides out @@ -99,14 +143,9 @@ function fetchWithProxy(url: string, options: FetchOptions = {}) { return undiciFetch(url, dispatcher ? { ...options, dispatcher } : options) } -export function resolveMenubarReleaseAssets(release: ReleaseResponse): ResolvedAssets { - const zip = release.assets.find(a => VERSIONED_ASSET_PATTERN.test(a.name)) - if (!zip) { - throw new Error( - `No ${APP_BUNDLE_NAME} versioned zip found in release ${release.tag_name}. ` + - `Check https://github.com/getagentseal/codeburn/releases.` - ) - } +export function resolveMenubarReleaseAssets(release: ReleaseResponse, spec: ReleaseSpec = MAC_RELEASE): ResolvedAssets { + const zip = release.assets.find(a => spec.assetPattern.test(a.name)) + if (!zip) throw new Error(spec.missingAsset(release.tag_name)) const checksum = release.assets.find(a => a.name === `${zip.name}.sha256`) if (!checksum) { throw new Error(`Missing checksum asset ${zip.name}.sha256 in release ${release.tag_name}.`) @@ -114,28 +153,28 @@ export function resolveMenubarReleaseAssets(release: ReleaseResponse): ResolvedA return { release, zip, checksum } } -export function resolveLatestMenubarReleaseAssets(releases: ReleaseResponse[]): ResolvedAssets { +export function resolveLatestMenubarReleaseAssets(releases: ReleaseResponse[], spec: ReleaseSpec = MAC_RELEASE): ResolvedAssets { for (const release of releases) { - if (!release.tag_name.startsWith('mac-v')) continue + if (!release.tag_name.startsWith(spec.tagPrefix)) continue try { - return resolveMenubarReleaseAssets(release) + return resolveMenubarReleaseAssets(release, spec) } catch { continue } } - throw new Error('No mac-v* release with a CodeBurnMenubar-v*.zip and checksum was found.') + throw new Error(spec.noRelease) } function normalizeCliVersion(cliVersion: string): string { return cliVersion.trim().replace(/^v/, '') } -export function resolveVersionedMenubarReleaseAssets(cliVersion: string): ResolvedAssets { +export function resolveVersionedMenubarReleaseAssets(cliVersion: string, spec: ReleaseSpec = MAC_RELEASE): ResolvedAssets { const version = normalizeCliVersion(cliVersion) if (!version) throw new Error('Cannot resolve CodeBurn Menubar release without a CLI version.') - const tagName = `mac-v${version}` - const zipName = `CodeBurnMenubar-v${version}.zip` + const tagName = `${spec.tagPrefix}${version}` + const zipName = spec.assetName(version) const checksumName = `${zipName}.sha256` const releaseBase = `${RELEASE_DOWNLOAD_BASE}/${tagName}` const zip = { name: zipName, browser_download_url: `${releaseBase}/${zipName}` } @@ -187,12 +226,6 @@ async function exists(path: string): Promise { } async function ensureSupportedPlatform(): Promise { - if (platform() === 'win32') { - throw new Error( - 'The Windows menubar ships as an installer, not through this command. ' + - `Download the .msi from ${WINDOWS_RELEASE_PAGE} and run it.`, - ) - } if (platform() !== SUPPORTED_OS) { throw new Error(`The menubar app is macOS only (detected: ${platform()}).`) } @@ -216,8 +249,8 @@ async function sysProductVersion(): Promise { }) } -async function fetchLatestReleaseAssets(): Promise { - const response = await fetchWithProxy(RELEASE_API, { +async function fetchLatestReleaseAssets(spec: ReleaseSpec = MAC_RELEASE, fetchImpl?: ReleaseApiFetch): Promise { + const response = await (fetchImpl ?? fetchWithProxy)(RELEASE_API, { headers: { 'User-Agent': 'codeburn-menubar-installer', Accept: 'application/vnd.github+json', @@ -227,7 +260,7 @@ async function fetchLatestReleaseAssets(): Promise { throw new HttpStatusError(formatGitHubReleaseLookupError(response.status, response.headers), response.status) } const body = await response.json() as ReleaseResponse[] - return resolveLatestMenubarReleaseAssets(body) + return resolveLatestMenubarReleaseAssets(body, spec) } /// 5xx means "GitHub/the CDN is unhappy right now" and is worth another attempt. 4xx is not: @@ -482,7 +515,165 @@ async function killRunningApp(): Promise { } } +/// Windows mirror of the mac install below: pin the release to the CLI's own version, fall back +/// to the newest windows-v* release, verify the sha256 before anything executes the file, hand +/// the .msi to msiexec, then launch what it installed. +const WINDOWS_UNINSTALL_KEYS = [ + 'HKLM\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Uninstall', + 'HKLM\\SOFTWARE\\WOW6432Node\\Microsoft\\Windows\\CurrentVersion\\Uninstall', + 'HKCU\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Uninstall', +] +/// 3010 is "installed, reboot to finish"; 1602 is the user closing the UAC/installer prompt. +const MSI_EXIT_REBOOT_REQUIRED = 3010 +const MSI_EXIT_USER_CANCEL = 1602 + +export type WindowsInstallHooks = { + fetchOptions?: AssetFetchOptions + apiFetch?: ReleaseApiFetch + runInstaller?: (exe: string, args: string[]) => Promise + queryRegistry?: () => Promise + launch?: (exePath: string) => void + log?: (message: string) => void + stagingDir?: string + env?: NodeJS.ProcessEnv +} + +export type InstalledWindowsMenubar = { version: string; exePath: string } + +/// Windows' `CreateProcess` searches the current directory before `PATH`, so spawning `msiexec` +/// or `reg` by bare name lets anything dropped next to the CLI impersonate a system tool. Same +/// rule the tray app follows (windows/src-tauri/src/cli.rs: system32_path). +export function resolveSystem32Path(exe: string, env: NodeJS.ProcessEnv = process.env): string { + const root = env.SystemRoot + const base = root && /^[a-zA-Z]:[\\/]/.test(root) ? root.replace(/[\\/]+$/, '') : 'C:\\Windows' + return `${base}\\System32\\${exe}` +} + +/// Reads `reg query ... /s` output, which prints one blank-line separated block per subkey. +export function parseInstalledWindowsMenubar(regOutput: string): InstalledWindowsMenubar | undefined { + for (const block of regOutput.split(/\r?\n\s*\r?\n/)) { + const values = new Map() + for (const line of block.split(/\r?\n/)) { + const match = /^\s+(.+?)\s{4}REG_\w+\s{4}(.*)$/.exec(line) + if (match) values.set(match[1]!.trim(), match[2]!.trim()) + } + if (values.get('DisplayName') !== WINDOWS_PRODUCT_NAME) continue + const location = values.get('InstallLocation') + // DisplayIcon is `[,]` and points at the installed binary when there is no + // InstallLocation to join onto. + const icon = values.get('DisplayIcon')?.split(',')[0]?.trim() + const exePath = location + ? `${location.replace(/[\\/]+$/, '')}\\${WINDOWS_PRODUCT_NAME}.exe` + : icon + if (!exePath) continue + return { version: values.get('DisplayVersion') ?? '', exePath } + } + return undefined +} + +async function queryWindowsUninstallRegistry(env: NodeJS.ProcessEnv): Promise { + const reg = resolveSystem32Path('reg.exe', env) + // reg exits non-zero for a hive the machine does not have; an empty block is the right answer. + const outputs = await Promise.all( + WINDOWS_UNINSTALL_KEYS.map(key => captureCommand(reg, ['query', key, '/s']).catch(() => '')), + ) + return outputs.join('\n\n') +} + +async function runMsiexec(exe: string, args: string[]): Promise { + return new Promise((resolve, reject) => { + const proc = spawn(exe, args, { stdio: 'inherit' }) + proc.on('error', reject) + proc.on('close', code => resolve(code ?? 1)) + }) +} + +function launchWindowsApp(exePath: string): void { + const proc = spawn(exePath, [], { detached: true, stdio: 'ignore' }) + proc.on('error', err => console.error(`Could not launch ${exePath}: ${err.message}`)) + proc.unref() +} + +async function stageWindowsInstaller( + assets: ResolvedAssets, + stagingDir: string, + hooks: WindowsInstallHooks, + log: (message: string) => void, +): Promise { + const { zip: msi, checksum } = assets + const msiPath = join(stagingDir, msi.name) + log(`Downloading ${msi.name}...`) + await downloadToFile(msi.browser_download_url, msiPath, hooks.fetchOptions) + log('Verifying checksum...') + await verifyChecksum(msiPath, checksum.browser_download_url, hooks.fetchOptions) + return msiPath +} + +async function installWindowsMenubarApp(options: InstallOptions): Promise { + const hooks = options.windows ?? {} + const log = hooks.log ?? console.log + const env = hooks.env ?? process.env + const queryRegistry = hooks.queryRegistry ?? (() => queryWindowsUninstallRegistry(env)) + const launch = hooks.launch ?? launchWindowsApp + const cliVersion = options.cliVersion ? normalizeCliVersion(options.cliVersion) : '' + + const installed = parseInstalledWindowsMenubar(await queryRegistry()) + if (installed && !options.force && (!cliVersion || installed.version === cliVersion)) { + launch(installed.exePath) + log('Launched CodeBurn Menubar.') + return { installedPath: installed.exePath, launched: true } + } + + let assets: ResolvedAssets + if (cliVersion) { + log(`Resolving CodeBurn Menubar v${cliVersion}...`) + assets = resolveVersionedMenubarReleaseAssets(cliVersion, WINDOWS_RELEASE) + } else { + log('Looking up the latest CodeBurn Menubar release...') + assets = await fetchLatestReleaseAssets(WINDOWS_RELEASE, hooks.apiFetch) + } + + const stagingDir = hooks.stagingDir ?? await (async () => { + await mkdir(getCodeburnCacheDir(), { recursive: true }) + return mkdtemp(join(getCodeburnCacheDir(), 'menubar-')) + })() + try { + let msiPath: string + try { + msiPath = await stageWindowsInstaller(assets, stagingDir, hooks, log) + } catch (err) { + if (!cliVersion || !isMissingDirectAssetError(err)) throw err + log(`CodeBurn Menubar v${cliVersion} assets were not found. Looking up the latest CodeBurn Menubar release...`) + assets = await fetchLatestReleaseAssets(WINDOWS_RELEASE, hooks.apiFetch) + msiPath = await stageWindowsInstaller(assets, stagingDir, hooks, log) + } + + log('Installing...') + const msiexec = resolveSystem32Path('msiexec.exe', env) + const exitCode = await (hooks.runInstaller ?? runMsiexec)(msiexec, ['/i', msiPath, '/passive', '/norestart']) + if (exitCode === MSI_EXIT_USER_CANCEL) { + log('Installation was cancelled; nothing was installed.') + return { installedPath: '', launched: false } + } + if (exitCode !== 0 && exitCode !== MSI_EXIT_REBOOT_REQUIRED) { + throw new Error(`msiexec exited with ${exitCode} while installing ${assets.zip.name}.`) + } + if (exitCode === MSI_EXIT_REBOOT_REQUIRED) log('Windows wants a restart to finish the install.') + + const nowInstalled = parseInstalledWindowsMenubar(await queryRegistry()) + if (!nowInstalled) { + throw new Error('CodeBurn Menubar installed, but it was not found in the uninstall registry; start it from the Start menu.') + } + launch(nowInstalled.exePath) + log('Launched CodeBurn Menubar.') + return { installedPath: nowInstalled.exePath, launched: true } + } finally { + if (!hooks.stagingDir) await rm(stagingDir, { recursive: true, force: true }) + } +} + export async function installMenubarApp(options: InstallOptions = {}): Promise { + if ((options.platform ?? platform()) === 'win32') return installWindowsMenubarApp(options) await ensureSupportedPlatform() await persistCodeburnPath() diff --git a/tests/menubar-installer-windows.test.ts b/tests/menubar-installer-windows.test.ts new file mode 100644 index 00000000..0ac4c55e --- /dev/null +++ b/tests/menubar-installer-windows.test.ts @@ -0,0 +1,299 @@ +import { createHash } from 'node:crypto' +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { + WINDOWS_RELEASE, + installMenubarApp, + parseInstalledWindowsMenubar, + resolveLatestMenubarReleaseAssets, + resolveSystem32Path, + resolveVersionedMenubarReleaseAssets, + type ReleaseResponse, +} from '../src/menubar-installer.js' + +function asset(name: string) { + return { name, browser_download_url: `https://example.test/${name}` } +} + +const MSI_URL = + 'https://github.com/getagentseal/codeburn/releases/download/windows-v0.9.20/CodeBurn.Menubar_0.9.20_x64_en-US.msi' +const MSI_BYTES = 'msi-bytes' + +function sha256(text: string): string { + return createHash('sha256').update(Buffer.from(text)).digest('hex') +} + +function httpResponse(status: number, body?: string) { + return { + ok: status >= 200 && status < 300, + status, + headers: { get: () => null }, + body: body === undefined ? null : new Response(body).body, + text: async () => body ?? '', + } +} + +/** reg query /s output, one blank-line separated block per subkey. */ +function regBlock(values: Record, key = '{9c1e2f0a-0000-0000-0000-000000000001}'): string { + const lines = Object.entries(values).map(([name, value]) => ` ${name} REG_SZ ${value}`) + return [ + 'HKEY_LOCAL_MACHINE\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\{other}', + ' DisplayName REG_SZ Some Other App', + '', + `HKEY_LOCAL_MACHINE\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\${key}`, + ...lines, + '', + ].join('\r\n') +} + +const INSTALLED_0_9_20 = regBlock({ + DisplayName: 'CodeBurn Menubar', + DisplayVersion: '0.9.20', + InstallLocation: 'C:\\Program Files\\CodeBurn Menubar\\', + Publisher: 'AgentSeal', +}) + +describe('windows release asset resolution', () => { + it('builds direct release asset URLs from the CLI version', () => { + const resolved = resolveVersionedMenubarReleaseAssets('0.9.20', WINDOWS_RELEASE) + + expect(resolved.release.tag_name).toBe('windows-v0.9.20') + expect(resolved.zip.name).toBe('CodeBurn.Menubar_0.9.20_x64_en-US.msi') + expect(resolved.zip.browser_download_url).toBe(MSI_URL) + expect(resolved.checksum.browser_download_url).toBe(`${MSI_URL}.sha256`) + }) + + it('normalizes a leading v', () => { + expect(resolveVersionedMenubarReleaseAssets('v0.9.20', WINDOWS_RELEASE).release.tag_name).toBe('windows-v0.9.20') + }) + + it('scans for the newest windows-v release that has both assets', () => { + const releases: ReleaseResponse[] = [ + { tag_name: 'mac-v0.9.20', assets: [asset('CodeBurnMenubar-v0.9.20.zip'), asset('CodeBurnMenubar-v0.9.20.zip.sha256')] }, + { tag_name: 'windows-v0.9.21', assets: [asset('CodeBurn.Menubar_0.9.21_x64_en-US.msi')] }, + { + tag_name: 'windows-v0.9.20', + assets: [asset('CodeBurn.Menubar_0.9.20_x64_en-US.msi'), asset('CodeBurn.Menubar_0.9.20_x64_en-US.msi.sha256')], + }, + ] + + const resolved = resolveLatestMenubarReleaseAssets(releases, WINDOWS_RELEASE) + + expect(resolved.release.tag_name).toBe('windows-v0.9.20') + expect(resolved.zip.name).toBe('CodeBurn.Menubar_0.9.20_x64_en-US.msi') + }) + + it('reports when no windows release carries both assets', () => { + expect(() => resolveLatestMenubarReleaseAssets([{ tag_name: 'v0.9.20', assets: [] }], WINDOWS_RELEASE)) + .toThrow(/No windows-v\* release/) + }) +}) + +describe('resolveSystem32Path', () => { + it('uses an absolute SystemRoot', () => { + expect(resolveSystem32Path('msiexec.exe', { SystemRoot: 'D:\\Windows' })).toBe('D:\\Windows\\System32\\msiexec.exe') + }) + + it('falls back to the documented default when SystemRoot is missing or relative', () => { + expect(resolveSystem32Path('reg.exe', {})).toBe('C:\\Windows\\System32\\reg.exe') + expect(resolveSystem32Path('reg.exe', { SystemRoot: 'Windows' })).toBe('C:\\Windows\\System32\\reg.exe') + }) +}) + +describe('parseInstalledWindowsMenubar', () => { + it('reads the version and joins the exe onto InstallLocation', () => { + expect(parseInstalledWindowsMenubar(INSTALLED_0_9_20)).toEqual({ + version: '0.9.20', + exePath: 'C:\\Program Files\\CodeBurn Menubar\\CodeBurn Menubar.exe', + }) + }) + + it('falls back to DisplayIcon when there is no InstallLocation', () => { + const output = regBlock({ + DisplayName: 'CodeBurn Menubar', + DisplayVersion: '0.9.20', + DisplayIcon: 'C:\\Program Files\\CodeBurn Menubar\\CodeBurn Menubar.exe,0', + }) + + expect(parseInstalledWindowsMenubar(output)?.exePath).toBe('C:\\Program Files\\CodeBurn Menubar\\CodeBurn Menubar.exe') + }) + + it('returns undefined when the product is not installed', () => { + expect(parseInstalledWindowsMenubar(regBlock({ DisplayName: 'Something Else', DisplayVersion: '1.0' }))).toBeUndefined() + }) +}) + +describe('installMenubarApp on windows', () => { + let sandbox: string + let logs: string[] + let launched: string[] + let installerCalls: Array<{ exe: string; args: string[] }> + + function hooks(overrides: Record = {}) { + return { + stagingDir: sandbox, + env: { SystemRoot: 'C:\\Windows' }, + log: (message: string) => { logs.push(message) }, + launch: (exePath: string) => { launched.push(exePath) }, + queryRegistry: async () => INSTALLED_0_9_20, + runInstaller: async (exe: string, args: string[]) => { installerCalls.push({ exe, args }); return 0 }, + fetchOptions: { + sleep: async () => {}, + log: (message: string) => { logs.push(message) }, + fetchImpl: async (url: string) => httpResponse(200, url.endsWith('.sha256') + ? `${sha256(MSI_BYTES)} CodeBurn.Menubar_0.9.20_x64_en-US.msi` + : MSI_BYTES), + }, + ...overrides, + } + } + + beforeEach(async () => { + sandbox = await mkdtemp(join(tmpdir(), 'menubar-windows-')) + logs = [] + launched = [] + installerCalls = [] + }) + + afterEach(async () => { + await rm(sandbox, { recursive: true, force: true }) + }) + + it('skips the download and just launches when the pinned version is already installed', async () => { + let fetches = 0 + const result = await installMenubarApp({ + platform: 'win32', + cliVersion: '0.9.20', + windows: hooks({ fetchOptions: { fetchImpl: async () => { fetches++; return httpResponse(500) } } }), + }) + + expect(fetches).toBe(0) + expect(installerCalls).toEqual([]) + expect(launched).toEqual(['C:\\Program Files\\CodeBurn Menubar\\CodeBurn Menubar.exe']) + expect(result).toEqual({ installedPath: 'C:\\Program Files\\CodeBurn Menubar\\CodeBurn Menubar.exe', launched: true }) + }) + + it('downloads, verifies, runs msiexec from System32 and launches the installed app', async () => { + let queries = 0 + const result = await installMenubarApp({ + platform: 'win32', + cliVersion: '0.9.20', + windows: hooks({ + queryRegistry: async () => (queries++ === 0 ? '' : INSTALLED_0_9_20), + }), + }) + + expect(installerCalls).toEqual([{ + exe: 'C:\\Windows\\System32\\msiexec.exe', + args: ['/i', join(sandbox, 'CodeBurn.Menubar_0.9.20_x64_en-US.msi'), '/passive', '/norestart'], + }]) + expect(launched).toEqual(['C:\\Program Files\\CodeBurn Menubar\\CodeBurn Menubar.exe']) + expect(result.launched).toBe(true) + expect(logs).toContain('Downloading CodeBurn.Menubar_0.9.20_x64_en-US.msi...') + expect(logs).toContain('Verifying checksum...') + expect(logs).toContain('Installing...') + expect(logs).toContain('Launched CodeBurn Menubar.') + }) + + it('reinstalls the same version when --force is passed', async () => { + await installMenubarApp({ platform: 'win32', cliVersion: '0.9.20', force: true, windows: hooks() }) + + expect(installerCalls).toHaveLength(1) + }) + + it('aborts on a checksum mismatch without running the installer', async () => { + await expect(installMenubarApp({ + platform: 'win32', + cliVersion: '0.9.20', + windows: hooks({ + queryRegistry: async () => '', + fetchOptions: { + sleep: async () => {}, + log: () => {}, + fetchImpl: async (url: string) => + httpResponse(200, url.endsWith('.sha256') ? `${sha256('other-bytes')} x.msi` : MSI_BYTES), + }, + }), + })).rejects.toThrow(/Checksum mismatch/) + + expect(installerCalls).toEqual([]) + expect(launched).toEqual([]) + }) + + it('treats 3010 as installed and says a restart is pending', async () => { + let queries = 0 + const result = await installMenubarApp({ + platform: 'win32', + cliVersion: '0.9.20', + windows: hooks({ + queryRegistry: async () => (queries++ === 0 ? '' : INSTALLED_0_9_20), + runInstaller: async (exe: string, args: string[]) => { installerCalls.push({ exe, args }); return 3010 }, + }), + }) + + expect(result.launched).toBe(true) + expect(logs.some(line => line.includes('restart'))).toBe(true) + }) + + it('treats 1602 as a cancelled install: no launch, no error', async () => { + const result = await installMenubarApp({ + platform: 'win32', + cliVersion: '0.9.20', + windows: hooks({ + queryRegistry: async () => '', + runInstaller: async () => 1602, + }), + }) + + expect(result).toEqual({ installedPath: '', launched: false }) + expect(launched).toEqual([]) + expect(logs.some(line => line.includes('cancelled'))).toBe(true) + }) + + it('fails with the exit code for any other msiexec failure', async () => { + await expect(installMenubarApp({ + platform: 'win32', + cliVersion: '0.9.20', + windows: hooks({ queryRegistry: async () => '', runInstaller: async () => 1603 }), + })).rejects.toThrow(/msiexec exited with 1603/) + + expect(launched).toEqual([]) + }) + + it('falls back to the release API when the pinned assets are missing', async () => { + let queries = 0 + const requested: string[] = [] + const latest: ReleaseResponse[] = [{ + tag_name: 'windows-v0.9.19', + assets: [ + { name: 'CodeBurn.Menubar_0.9.19_x64_en-US.msi', browser_download_url: 'https://example.test/msi' }, + { name: 'CodeBurn.Menubar_0.9.19_x64_en-US.msi.sha256', browser_download_url: 'https://example.test/msi.sha256' }, + ], + }] + + const result = await installMenubarApp({ + platform: 'win32', + cliVersion: '0.9.20', + windows: hooks({ + queryRegistry: async () => (queries++ === 0 ? '' : INSTALLED_0_9_20), + apiFetch: async () => ({ ok: true, status: 200, headers: { get: () => null }, json: async () => latest }), + fetchOptions: { + sleep: async () => {}, + log: () => {}, + fetchImpl: async (url: string) => { + requested.push(url) + if (url.startsWith(MSI_URL)) return httpResponse(404) + return httpResponse(200, url.endsWith('.sha256') ? `${sha256(MSI_BYTES)} msi` : MSI_BYTES) + }, + }, + }), + }) + + expect(requested[0]).toBe(MSI_URL) + expect(requested).toContain('https://example.test/msi') + expect(installerCalls[0]?.args[1]).toBe(join(sandbox, 'CodeBurn.Menubar_0.9.19_x64_en-US.msi')) + expect(result.launched).toBe(true) + }) +}) diff --git a/windows/DEVELOPMENT.md b/windows/DEVELOPMENT.md index 9df090a5..e59a7392 100644 --- a/windows/DEVELOPMENT.md +++ b/windows/DEVELOPMENT.md @@ -174,6 +174,12 @@ npm run tauri build `.github/workflows/release-menubar-windows.yml`; publishes the `.msi` (plus its sha256) to a "Windows Menubar vX" release. Unsigned for now, so Windows SmartScreen prompts on first run until a signing cert is in place. +- `codeburn menubar` installs from those assets (`src/menubar-installer.ts`): it pins the tag to + the CLI's own version (`windows-v`), falls back to a scan of the newest `windows-v*` + release carrying both assets, verifies the sha256 before anything executes the file, then runs + `%SystemRoot%\System32\msiexec.exe /i /passive /norestart` and launches the exe named by + the product's Uninstall registry key. Renaming the bundle or the MSI asset breaks that lookup — + `WINDOWS_RELEASE` and `WINDOWS_PRODUCT_NAME` in the installer have to move with it. ## Pending work From 185d6b3b3150de3f323a8cfbaee377bab3c0a4e7 Mon Sep 17 00:00:00 2001 From: iamtoruk Date: Tue, 18 Aug 2026 07:48:03 -0700 Subject: [PATCH 48/85] mac: register the login item with SMAppService The menubar told System Events to make its login item, so macOS asked for Automation access on first launch. SMAppService.mainApp does it in-process with no Automation grant. No AppleScript fallback: a failure must not bring the prompt back. Package floor is macOS 14, so the 13+ API needs no availability guard. Fixes #1026 --- CHANGELOG.md | 1 + mac/Sources/CodeBurnMenubar/CodeBurnApp.swift | 30 +++++-------------- 2 files changed, 9 insertions(+), 22 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2e5f9394..66b04297 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,6 +23,7 @@ - **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) ### Fixed (Desktop & Menubar) +- **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) - **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 diff --git a/mac/Sources/CodeBurnMenubar/CodeBurnApp.swift b/mac/Sources/CodeBurnMenubar/CodeBurnApp.swift index 63144f76..b0400d6e 100644 --- a/mac/Sources/CodeBurnMenubar/CodeBurnApp.swift +++ b/mac/Sources/CodeBurnMenubar/CodeBurnApp.swift @@ -2,6 +2,7 @@ import Foundation import SwiftUI import AppKit import Observation +import ServiceManagement private let refreshIntervalSeconds: UInt64 = 30 private let forceRefreshWatchdogSeconds: TimeInterval = 90 @@ -281,34 +282,19 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate, NSM let key = "codeburn.loginItemRegistered" guard !UserDefaults.standard.bool(forKey: key) else { return } - let appPath = Bundle.main.bundlePath - let script = "tell application \"System Events\" to make login item at end with properties {path:\(appleScriptStringLiteral(appPath)), hidden:false}" - - let process = Process() - process.launchPath = "/usr/bin/osascript" - process.arguments = ["-e", script] - process.standardOutput = FileHandle.nullDevice - process.standardError = FileHandle.nullDevice - + // Registers in-process. The old path told System Events to make the login + // item, which made macOS ask for Automation access on first launch (#1026). + // No AppleScript fallback: a failure here must not bring that prompt back. do { - try process.run() - process.waitUntilExit() - if process.terminationStatus == 0 { - UserDefaults.standard.set(true, forKey: key) + if SMAppService.mainApp.status != .enabled { + try SMAppService.mainApp.register() } + UserDefaults.standard.set(true, forKey: key) } catch { - NSLog("CodeBurn: Login item registration failed: \(error)") + NSLog("CodeBurn: login item registration failed: \(error.localizedDescription)") } } - private func appleScriptStringLiteral(_ value: String) -> String { - var escaped = value.replacingOccurrences(of: "\\", with: "\\\\") - escaped = escaped.replacingOccurrences(of: "\"", with: "\\\"") - escaped = escaped.replacingOccurrences(of: "\r", with: "") - escaped = escaped.replacingOccurrences(of: "\n", with: "") - return "\"\(escaped)\"" - } - private var lastRefreshTime: Date = .distantPast /// Anchors the shallow provider-root snapshot only after a complete usage /// refresh succeeds. It sits beside the cadence anchor so a failed fetch From 117aa833cc8e5eb646f9dbddc7d29cd22ba557ce Mon Sep 17 00:00:00 2001 From: iamtoruk Date: Tue, 18 Aug 2026 08:22:33 -0700 Subject: [PATCH 49/85] fix(optimize): scope the apply-able subtotal to the local MCP subset A mixed local + claude.ai connector finding is class `fix`, but `--apply` only mutates the local servers. classTotals now credits the `fix` group with `applyTokensSaved` when present, so the "Fix now (apply-able)" subtotal, the "apply-able: ~$X" headline and `summary.byClass.fix` (CLI, TUI and desktop all read these) describe what apply can actually recover. The finding keeps the whole opportunity in its own `tokensSaved`. Also fixes the desktop connector fixture, which predated the class/basis fields, and adds class-level coverage: connector-only findings resolve to `nudge` (no apply payload), a local server named like a connector stays manual-only, and local-only findings keep their full subtotal. --- app/renderer/sections/Optimize.test.tsx | 3 ++ src/optimize.ts | 14 ++++-- tests/mcp-coverage.test.ts | 57 +++++++++++++++++++++++++ 3 files changed, 71 insertions(+), 3 deletions(-) diff --git a/app/renderer/sections/Optimize.test.tsx b/app/renderer/sections/Optimize.test.tsx index 663084a3..70013522 100644 --- a/app/renderer/sections/Optimize.test.tsx +++ b/app/renderer/sections/Optimize.test.tsx @@ -214,11 +214,14 @@ describe('Optimize', () => { id: 'mcp-low-coverage', title: 'Underused claude.ai connector', explanation: 'The connector loads unused tools.', severity: 'medium', trend: null, tokensSaved: 2_000, estimatedSavingsUSD: 1, + // Connector-only: no appliable plan, so the finding is a nudge. + class: 'nudge', basis: 'estimated', fix: { type: 'paste', destination: 'manual', label: 'Manage the connector where it loads:', text: 'Open /mcp and disable claude.ai Google Calendar.', }, }) + report.summary.byClass.nudge = { tokensSaved: 19_400, savingsUSD: 9.7, count: 2 } getOptimizeReport.mockResolvedValue(report) render() const row = await screen.findByRole('button', { name: /Underused claude.ai connector/ }) diff --git a/src/optimize.ts b/src/optimize.ts index ea740cb8..631ba38f 100644 --- a/src/optimize.ts +++ b/src/optimize.ts @@ -403,9 +403,17 @@ export function classTotals(findings: WasteFinding[], costRate: number): Record< keep: { tokensSaved: 0, savingsUSD: 0, count: 0 }, } for (const f of findings) { - const t = totals[findingClass(f)] - t.tokensSaved += f.tokensSaved - t.savingsUSD += f.tokensSaved * costRate + const cls = findingClass(f) + // A `fix` whose plan owns only part of its estimate (a mixed local + + // claude.ai connector MCP finding) contributes only the apply-able + // subset, so this subtotal and the "apply-able" headline never promise + // what `--apply` cannot recover. The finding keeps the whole + // opportunity in its own `tokensSaved`, so the fix subtotal can be + // smaller than the findings listed under it. + const tokens = cls === 'fix' ? f.applyTokensSaved ?? f.tokensSaved : f.tokensSaved + const t = totals[cls] + t.tokensSaved += tokens + t.savingsUSD += tokens * costRate t.count++ } return totals diff --git a/tests/mcp-coverage.test.ts b/tests/mcp-coverage.test.ts index 77301bc5..0dc7f36c 100644 --- a/tests/mcp-coverage.test.ts +++ b/tests/mcp-coverage.test.ts @@ -3,6 +3,8 @@ import { describe, it, expect, vi } from 'vitest' import { aggregateMcpCoverage, buildOptimizeJsonReport, + classTotals, + findingClass, detectMcpProfileAdvisor, detectMcpToolCoverage, estimateMcpSchemaCost, @@ -925,3 +927,58 @@ describe('detectMcpProfileAdvisor', () => { expect(detectMcpProfileAdvisor(projects, coverage)).toBeNull() }) }) + +// --------------------------------------------------------------------------- +// Connector findings under the fix/nudge/keep classification (#1019) +// --------------------------------------------------------------------------- + +describe('connector findings and finding class', () => { + const inventoryFor = (servers: string[]) => servers.flatMap(server => + Array.from({ length: 20 }, (_, i) => `mcp__${server}__t${i}`), + ) + const twoSessions = (servers: string[]) => ['a', 'b'].map(sessionId => makeSession({ + sessionId, + inventory: inventoryFor(servers), + turns: [makeTurn([makeCall({ cacheCreation: 50_000 })])], + })) + + it('classifies a connector-only finding as a nudge, since nothing is appliable', () => { + const finding = detectMcpToolCoverage([project(twoSessions(['claude_ai_Gmail']))]) + + expect(finding).not.toBeNull() + expect(finding!.apply).toBeUndefined() + expect(findingClass(finding!)).toBe('nudge') + expect(finding!.tokensSaved).toBeGreaterThan(0) + // Never lands in the "apply-able" subtotal. + expect(classTotals([finding!], 0.00002).fix).toEqual({ tokensSaved: 0, savingsUSD: 0, count: 0 }) + }) + + it('counts only the local subset of a mixed finding towards the apply-able subtotal', () => { + const finding = detectMcpToolCoverage([project(twoSessions(['filesystem', 'claude_ai_Slack']))]) + + expect(finding).not.toBeNull() + expect(findingClass(finding!)).toBe('fix') + expect(finding).toMatchObject({ tokensSaved: 40_000, applyTokensSaved: 20_000 }) + expect(classTotals([finding!], 0.00002).fix).toEqual({ tokensSaved: 20_000, savingsUSD: 0.4, count: 1 }) + }) + + it("leaves a local-only finding's subtotal at its full estimate", () => { + const finding = detectMcpToolCoverage([project(twoSessions(['filesystem']))]) + + expect(finding).not.toBeNull() + expect(finding!.applyTokensSaved).toBeUndefined() + expect(classTotals([finding!], 0.00002).fix.tokensSaved).toBe(finding!.tokensSaved) + }) + + it('treats a local server named like a connector namespace as manual only', () => { + // Known limitation: the namespace prefix is the only connector signal in + // the transcript, so a local server literally named claude_ai_* gets the + // manual /mcp guidance rather than a remove command. Conservative by + // design: CodeBurn never emits a command that could hit a connector. + const finding = detectMcpToolCoverage([project(twoSessions(['claude_ai_homegrown']))]) + + expect(finding!.fix.type).toBe('paste') + expect(finding!.apply).toBeUndefined() + expect(findingClass(finding!)).toBe('nudge') + }) +}) From cddf5d5a53a4b34fc2cb2f4401a2b60c14fd6085 Mon Sep 17 00:00:00 2001 From: iamtoruk Date: Tue, 18 Aug 2026 08:27:22 -0700 Subject: [PATCH 50/85] test(optimize): pin per-session MCP schema charging without connectors The rewritten cost pass charges each session only for the schemas that session loaded, which changes local-only estimates too (on a real corpus the mcp-low-coverage estimate roughly halves). Pin it so the change is deliberate rather than a side effect of the connector split. --- tests/mcp-coverage.test.ts | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/tests/mcp-coverage.test.ts b/tests/mcp-coverage.test.ts index 0dc7f36c..49d94cd6 100644 --- a/tests/mcp-coverage.test.ts +++ b/tests/mcp-coverage.test.ts @@ -970,6 +970,25 @@ describe('connector findings and finding class', () => { expect(classTotals([finding!], 0.00002).fix.tokensSaved).toBe(finding!.tokensSaved) }) + it('charges each session only for the local schemas it actually loaded', () => { + // Same per-session scoping the connector split relies on, with no + // connector in play: two flagged local servers in disjoint sessions are + // charged one schema each, not both schemas everywhere. + const sessions = ['filesystem', 'playwright'].flatMap(server => + ['a', 'b'].map(suffix => makeSession({ + sessionId: `${server}-${suffix}`, + inventory: inventoryFor([server]), + turns: [makeTurn([makeCall({ cacheCreation: 50_000 })])], + })), + ) + + const finding = detectMcpToolCoverage([project(sessions)]) + + expect(finding).toMatchObject({ tokensSaved: 40_000 }) + expect(finding!.applyTokensSaved).toBeUndefined() + expect(classTotals([finding!], 0.00002).fix.tokensSaved).toBe(40_000) + }) + it('treats a local server named like a connector namespace as manual only', () => { // Known limitation: the namespace prefix is the only connector signal in // the transcript, so a local server literally named claude_ai_* gets the From c07a5a795ca47f7df3cfdf7aa8679f9606ebec17 Mon Sep 17 00:00:00 2001 From: iamtoruk Date: Tue, 18 Aug 2026 08:29:43 -0700 Subject: [PATCH 51/85] changelog: own the connector split and the per-session MCP cost model (#975) --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index f1d398ed..560a772a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -28,6 +28,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 +- **`optimize` no longer offers `claude mcp remove` for claude.ai connectors, and its MCP schema-cost estimate is per session.** `claude_ai_*` namespaces are claude.ai connectors managed through `/mcp` or claude.ai Settings, not local MCP servers; low-coverage findings now render them as a manual follow-up and build `--apply` plans only for exact local server names found in readable MCP config, so mixed findings remove only the local subset and the "apply-able" subtotal counts only that subset. The same change replaces the old global schema-cost cap with per-session, per-server proportional attribution — a more accurate model that lowers `mcp-low-coverage` estimates for everyone, connectors or not (on a large corpus roughly by half). (#975, #991) - **Bash command splitting was quadratic on long whitespace-heavy commands.** The separator regex retried its leading `\s*` from every offset; matching the separator alone and widening over whitespace by hand makes cold parse ~24% and warm ~40% faster on large corpora, output unchanged. - **Cold parse no longer retains full message bodies through cached previews.** `flatSlice` skipped its Buffer round-trip for strings already within the bound, but provider adapters pre-truncate user-message previews with `.slice(0, 500)` before the cache-site call — those pre-sliced views are still V8 SlicedStrings pinning their large parent, so the retention that OOM'd cold parses of large histories survived. The round-trip now always runs. - **Kiro sessions carry the real `projectPath`** (CLI meta.cwd, v2 `workspacePaths[0]`, workspace sessions' `workspaceDirectory`), so git-repo attribution can resolve them; previously they were attribution-blind. Bumps the kiro parse version, so the first run after upgrade re-parses kiro history once, and kiro sessions in linked git worktrees now group under the main repo. From d5d42e4d266779cd59beab79c16f102565c67f41 Mon Sep 17 00:00:00 2001 From: iamtoruk Date: Tue, 18 Aug 2026 08:39:53 -0700 Subject: [PATCH 52/85] fix(optimize): disambiguate connector namespaces against local MCP config The prefix alone could not tell a claude.ai connector from a local MCP server that happens to be named claude_ai_*, so the latter lost its removal command and its apply plan. A namespace is now a connector only when no readable local config claims that exact name; localMcpServerNames supplies the set from the same files the remove plan edits (settings.json, .mcp.json, and ~/.claude.json top-level plus per-project mcpServers). A prefixed name that local config does own stays local: normal removal command, apply plan, class fix, full savings attribution. Because the transcript still cannot rule out a same-name connector, the finding adds a manual note about it instead of asserting the server is one. Config that cannot be read contributes no names, which leaves every prefixed namespace on the conservative connector path. --- CHANGELOG.md | 2 +- src/optimize.ts | 65 +++++++++++++++++++++++++++++++------- tests/mcp-coverage.test.ts | 61 ++++++++++++++++++++++++++++++++--- tests/optimize-fs.test.ts | 27 ++++++++++++++++ 4 files changed, 138 insertions(+), 17 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 560a772a..c444ad37 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -28,7 +28,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 -- **`optimize` no longer offers `claude mcp remove` for claude.ai connectors, and its MCP schema-cost estimate is per session.** `claude_ai_*` namespaces are claude.ai connectors managed through `/mcp` or claude.ai Settings, not local MCP servers; low-coverage findings now render them as a manual follow-up and build `--apply` plans only for exact local server names found in readable MCP config, so mixed findings remove only the local subset and the "apply-able" subtotal counts only that subset. The same change replaces the old global schema-cost cap with per-session, per-server proportional attribution — a more accurate model that lowers `mcp-low-coverage` estimates for everyone, connectors or not (on a large corpus roughly by half). (#975, #991) +- **`optimize` no longer offers `claude mcp remove` for claude.ai connectors, and its MCP schema-cost estimate is per session.** A `claude_ai_*` namespace that no readable local MCP config claims is a claude.ai connector, managed through `/mcp` or claude.ai Settings rather than as a local MCP server (a local server that carries the prefix keeps its removal command and gains a same-name connector note); low-coverage findings now render them as a manual follow-up and build `--apply` plans only for exact local server names found in readable MCP config, so mixed findings remove only the local subset and the "apply-able" subtotal counts only that subset. The same change replaces the old global schema-cost cap with per-session, per-server proportional attribution — a more accurate model that lowers `mcp-low-coverage` estimates for everyone, connectors or not (on a large corpus roughly by half). (#975, #991) - **Bash command splitting was quadratic on long whitespace-heavy commands.** The separator regex retried its leading `\s*` from every offset; matching the separator alone and widening over whitespace by hand makes cold parse ~24% and warm ~40% faster on large corpora, output unchanged. - **Cold parse no longer retains full message bodies through cached previews.** `flatSlice` skipped its Buffer round-trip for strings already within the bound, but provider adapters pre-truncate user-message previews with `.slice(0, 500)` before the cache-site call — those pre-sliced views are still V8 SlicedStrings pinning their large parent, so the retention that OOM'd cold parses of large histories survived. The round-trip now always runs. - **Kiro sessions carry the real `projectPath`** (CLI meta.cwd, v2 `workspacePaths[0]`, workspace sessions' `workspaceDirectory`), so git-repo attribution can resolve them; previously they were attribution-blind. Bumps the kiro parse version, so the first run after upgrade re-parses kiro history once, and kiro sessions in linked git worktrees now group under the main repo. diff --git a/src/optimize.ts b/src/optimize.ts index 631ba38f..7984800a 100644 --- a/src/optimize.ts +++ b/src/optimize.ts @@ -923,6 +923,27 @@ export function loadMcpConfigs(projectCwds: Iterable, homeDir = homedir( return servers } +/// Server names owned by readable local MCP config, normalized the way +/// transcript namespaces are (":" -> "_"). `loadMcpConfigs` covers +/// settings.json and .mcp.json; `~/.claude.json` adds the top-level and +/// per-project `mcpServers` containers the remove plan also edits. +/// +/// A `claude_ai_*` namespace listed here is a local server that happens to +/// carry the connector prefix, not a claude.ai connector. Config we cannot +/// read simply contributes no names, which leaves those namespaces on the +/// conservative connector path. +export function localMcpServerNames(projectCwds: Iterable, homeDir = homedir()): Set { + const names = new Set(loadMcpConfigs(projectCwds, homeDir).keys()) + const userJson = readJsonFile(join(homeDir, '.claude.json')) + const projects = (userJson?.['projects'] ?? {}) as Record + const containers = [userJson?.['mcpServers'], ...Object.values(projects).map(entry => entry?.mcpServers)] + for (const container of containers) { + if (!container || typeof container !== 'object') continue + for (const name of Object.keys(container)) names.add(name.replace(/:/g, '_')) + } + return names +} + // ============================================================================ // Detectors // ============================================================================ @@ -1342,6 +1363,7 @@ function estimateMcpSchemaCostAttributed( export function detectMcpToolCoverage( projects: ProjectSummary[], coverage = aggregateMcpCoverage(projects), + localServerNames: ReadonlySet = new Set(), ): WasteFinding | null { if (coverage.length === 0) return null @@ -1360,6 +1382,10 @@ export function detectMcpToolCoverage( const flaggedServers: string[] = [] const localServers: string[] = [] const connectorServers: string[] = [] + // Local, but named like a connector: the transcript cannot tell the two + // apart, so the removal targets the config entry and the guidance warns + // about a possible same-name connector instead of asserting one. + const ambiguousServers: string[] = [] for (const c of flagged) { unusedToolsByServer[c.server] = c.unusedTools @@ -1368,9 +1394,10 @@ export function detectMcpToolCoverage( lines.push( `${c.server}: ${c.toolsInvoked}/${c.toolsAvailable} tools used (${pct}% coverage) across ${c.loadedSessions} session${c.loadedSessions === 1 ? '' : 's'}`, ) - if (c.server.startsWith('claude_ai_')) { + if (c.server.startsWith('claude_ai_') && !localServerNames.has(c.server)) { connectorServers.push(c.server) } else { + if (c.server.startsWith('claude_ai_')) ambiguousServers.push(c.server) localServers.push(c.server) removeCommands.push(`claude mcp remove '${c.server}'`) } @@ -1396,10 +1423,12 @@ export function detectMcpToolCoverage( ? 'high' : 'medium' // `claude_ai_*` is Claude Code's transcript namespace for server-side - // claude.ai connectors. Those connectors are not local mcpServers entries, - // so `claude mcp remove` and the file-editing apply plan cannot own them. + // claude.ai connectors, which are not local mcpServers entries, so + // `claude mcp remove` and the file-editing apply plan cannot own them -- + // unless readable local config claims the exact name (`ambiguousServers`). // Coverage is aggregate here; project-level config attribution is deliberately // out of scope, hence the instruction to inspect /mcp per affected project. + const one = connectorServers.length === 1 const connectorLabels = connectorServers.map(server => `claude.ai ${server.slice('claude_ai_'.length).replaceAll('_', ' ')}`, ) @@ -1407,14 +1436,28 @@ export function detectMcpToolCoverage( `${connectorLabels[index]} (${server})`, ) const connectorGuidance = connectorServers.length > 0 - ? ` ${connectorEvidence.join(', ')} ${connectorServers.length === 1 ? 'is a claude.ai connector namespace' : 'are claude.ai connector namespaces'}, separate from any similarly named local MCP server. Transcript inventory is aggregated across the selected projects; use /mcp in each project where ${connectorServers.length === 1 ? 'it loads' : 'they load'}, or manage ${connectorServers.length === 1 ? 'it' : 'them'} in claude.ai Settings > Connectors.` + ? ` ${connectorEvidence.join(', ')} ${one ? 'is a claude.ai connector namespace' : 'are claude.ai connector namespaces'}, separate from any similarly named local MCP server. Transcript inventory is aggregated across the selected projects; use /mcp in each project where ${one ? 'it loads' : 'they load'}, or manage ${one ? 'it' : 'them'} in claude.ai Settings > Connectors.` : '' - const connectorAction = connectorServers.length > 0 + const oneAmbiguous = ambiguousServers.length === 1 + const ambiguousNote = ambiguousServers.length > 0 + ? `If you also use ${oneAmbiguous ? 'a claude.ai connector' : 'claude.ai connectors'} named ${ambiguousServers.join(', ')}, manage ${oneAmbiguous ? 'it' : 'them'} with /mcp or in claude.ai Settings > Connectors.` + : '' + const ambiguousGuidance = ambiguousServers.length > 0 + ? ` ${ambiguousServers.join(', ')} ${oneAmbiguous ? 'is a local MCP config entry whose name matches' : 'are local MCP config entries whose names match'} the claude.ai connector namespace, so the removal below edits local config only. ${ambiguousNote}` + : '' + const connectorText = [ + connectorServers.length > 0 + ? `Open /mcp in each affected project and disable ${connectorLabels.join(', ')}, or manage ${one ? 'it' : 'them'} in claude.ai Settings > Connectors.` + : '', + ambiguousNote, + ].filter(Boolean).join(' ') + const connectorAction = connectorText ? { - label: connectorServers.length === 1 - ? 'Manage the underused claude.ai connector where it loads:' - : 'Manage the underused claude.ai connectors where they load:', - text: `Open /mcp in each affected project and disable ${connectorLabels.join(', ')}, or manage ${connectorServers.length === 1 ? 'it' : 'them'} in claude.ai Settings > Connectors.`, + label: connectorServers.length === 0 + ? 'Check for a same-name claude.ai connector:' + : one ? 'Manage the underused claude.ai connector where it loads:' + : 'Manage the underused claude.ai connectors where they load:', + text: connectorText, } : undefined const fix: WasteAction = localServers.length > 0 @@ -1438,7 +1481,7 @@ export function detectMcpToolCoverage( explanation: `Schema for unused tools is loaded into the system prompt every session and ` + `carried in the cached prefix on every turn. ` + - `${lines.join('; ')}.${connectorGuidance}`, + `${lines.join('; ')}.${connectorGuidance}${ambiguousGuidance}`, impact, tokensSaved, ...(applyTokensSaved !== undefined ? { applyTokensSaved } : {}), @@ -3472,7 +3515,7 @@ export async function scanAndDetect( claudeOnly(() => detectJunkReads(toolCalls, dateRange)), claudeOnly(() => detectDuplicateReads(toolCalls, dateRange)), claudeOnly(() => detectUnusedMcp(toolCalls, projects, projectCwds, mcpCoverage)), - () => detectMcpToolCoverage(projects, mcpCoverage), + () => detectMcpToolCoverage(projects, mcpCoverage, localMcpServerNames(projectCwds)), () => detectMcpProfileAdvisor(projects, mcpCoverage), // mcp-deferral-gaps family (#614): detection only, no apply plans yet. claudeOnly(() => detectMcpDeferralOff(toolCalls, projects, projectCwds, apiCalls)), diff --git a/tests/mcp-coverage.test.ts b/tests/mcp-coverage.test.ts index 49d94cd6..e173f008 100644 --- a/tests/mcp-coverage.test.ts +++ b/tests/mcp-coverage.test.ts @@ -989,15 +989,66 @@ describe('connector findings and finding class', () => { expect(classTotals([finding!], 0.00002).fix.tokensSaved).toBe(40_000) }) - it('treats a local server named like a connector namespace as manual only', () => { - // Known limitation: the namespace prefix is the only connector signal in - // the transcript, so a local server literally named claude_ai_* gets the - // manual /mcp guidance rather than a remove command. Conservative by - // design: CodeBurn never emits a command that could hit a connector. + it('treats a claude_ai_* name owned by local config as a local server', () => { + const finding = detectMcpToolCoverage( + [project(twoSessions(['claude_ai_homegrown']))], + undefined, + new Set(['claude_ai_homegrown']), + ) + + expect(finding!.fix).toEqual({ + type: 'command', + label: 'Remove the underused local server, or trim its tools in your MCP config:', + text: "claude mcp remove 'claude_ai_homegrown'", + }) + expect(finding!.apply).toEqual({ kind: 'mcp-remove', servers: ['claude_ai_homegrown'] }) + expect(findingClass(finding!)).toBe('fix') + // Local config owns the name, so the finding must not claim it is a connector. + expect(finding!.explanation).not.toContain('is a claude.ai connector namespace') + // ...but the transcript cannot rule out a same-name connector. + expect(finding!.explanation).toContain('If you also use a claude.ai connector named claude_ai_homegrown') + expect(finding!.manualFollowUp?.label).toBe('Check for a same-name claude.ai connector:') + // The whole estimate is appliable: nothing is reserved for a connector. + expect(finding!.applyTokensSaved).toBeUndefined() + expect(classTotals([finding!], 0.00002).fix.tokensSaved).toBe(finding!.tokensSaved) + }) + + it('keeps a claude_ai_* name absent from local config a connector', () => { + const finding = detectMcpToolCoverage( + [project(twoSessions(['claude_ai_Gmail']))], + undefined, + new Set(['filesystem', 'playwright']), + ) + + expect(finding!.fix.type).toBe('paste') + expect(finding!.apply).toBeUndefined() + expect(findingClass(finding!)).toBe('nudge') + expect(finding!.explanation).toContain('is a claude.ai connector namespace') + }) + + it('falls back to prefix-only when no local config could be read', () => { + // Unreadable or absent config contributes no names, which leaves every + // claude_ai_* namespace on the conservative connector path. const finding = detectMcpToolCoverage([project(twoSessions(['claude_ai_homegrown']))]) expect(finding!.fix.type).toBe('paste') expect(finding!.apply).toBeUndefined() expect(findingClass(finding!)).toBe('nudge') }) + + it('applies the local entry and notes the connector when a name collides', () => { + const finding = detectMcpToolCoverage( + [project(twoSessions(['filesystem', 'claude_ai_Slack']))], + undefined, + new Set(['filesystem', 'claude_ai_Slack']), + ) + + // Both are local: the removal owns both entries and nothing is deferred. + expect(finding!.apply).toEqual({ kind: 'mcp-remove', servers: ['filesystem', 'claude_ai_Slack'] }) + expect(finding!.applyTokensSaved).toBeUndefined() + expect(classTotals([finding!], 0.00002).fix.tokensSaved).toBe(40_000) + expect(finding!.explanation).not.toContain('is a claude.ai connector namespace') + expect(finding!.manualFollowUp?.text) + .toBe('If you also use a claude.ai connector named claude_ai_Slack, manage it with /mcp or in claude.ai Settings > Connectors.') + }) }) diff --git a/tests/optimize-fs.test.ts b/tests/optimize-fs.test.ts index 83b2f8e5..ff72a9b5 100644 --- a/tests/optimize-fs.test.ts +++ b/tests/optimize-fs.test.ts @@ -22,6 +22,7 @@ import { detectBashBloat, detectGhostCommands, loadMcpConfigs, + localMcpServerNames, scanJsonlFile, scanAndDetect, detectRecurringContext, @@ -176,6 +177,32 @@ describe('loadMcpConfigs', () => { }) }) +describe('localMcpServerNames', () => { + it('adds the ~/.claude.json top-level and per-project servers to the config names', () => { + const root = makeFixtureRoot() + const projectDir = join(root, 'myapp') + mkdirSync(projectDir, { recursive: true }) + writeFile(join(projectDir, '.mcp.json'), JSON.stringify({ mcpServers: { fromMcpJson: {} } })) + writeFile(join(FAKE_HOME_FOR_MOCK, '.claude.json'), JSON.stringify({ + mcpServers: { 'claude_ai_homegrown': {}, 'plugin:ctx:ctx': {} }, + projects: { [projectDir]: { mcpServers: { scoped: {} } } }, + })) + + const names = localMcpServerNames([projectDir]) + + expect([...names].sort()).toEqual(['claude_ai_homegrown', 'fromMcpJson', 'plugin_ctx_ctx', 'scoped']) + }) + + it('contributes no names when ~/.claude.json cannot be parsed', () => { + const root = makeFixtureRoot() + const projectDir = join(root, 'myapp') + mkdirSync(projectDir, { recursive: true }) + writeFile(join(FAKE_HOME_FOR_MOCK, '.claude.json'), '{ not valid json') + + expect(localMcpServerNames([projectDir]).size).toBe(0) + }) +}) + describe('detectUnusedMcp', () => { it('flags servers configured but never called', () => { const root = makeFixtureRoot() From 2d35c8fa242574a073a60f1ea4c8d48c8851ce3f Mon Sep 17 00:00:00 2001 From: iamtoruk Date: Tue, 18 Aug 2026 08:52:43 -0700 Subject: [PATCH 53/85] test(parser): cover durable retention through a month-scoped refresh --- tests/parser.test.ts | 39 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 39 insertions(+) diff --git a/tests/parser.test.ts b/tests/parser.test.ts index 6582a97c..44f9db73 100644 --- a/tests/parser.test.ts +++ b/tests/parser.test.ts @@ -416,6 +416,45 @@ describe('(e) 90-day age-out for durable providers', () => { expect.soft(cache3.providers['test-synthetic']?.files[synthFile]).toBeUndefined() }) + it('keeps a discovered 91-day source through a month-scoped refresh', async () => { + const synthFile = join(tmpHome, 'synth-scoped.txt') + await writeFile(synthFile, 'placeholder') + + const ts91dAgo = new Date(Date.now() - 91 * 24 * 60 * 60 * 1000).toISOString() + + _synthDurable = true + _synthSources = [{ path: synthFile, project: 'test', provider: 'test-synthetic' }] + _synthYields = [{ + provider: 'test-synthetic', model: 'gpt-4o', + inputTokens: 10, outputTokens: 8, + cacheCreationInputTokens: 0, cacheReadInputTokens: 0, + cachedInputTokens: 0, reasoningTokens: 0, webSearchRequests: 0, + costUSD: 0.002, tools: [], bashCommands: [], + timestamp: ts91dAgo, + speed: 'standard', + deduplicationKey: 'synth-age-out-91d-scoped', + userMessage: 'old', sessionId: 'synth-old-scoped', + }] + + expect.soft(totalOutput(await parseAllSessions(undefined, 'test-synthetic'))).toBe(8) + + // A today-ranged refresh loads under a month scope that excludes the entry's + // shard. Durable providers are never scoped, so the age-out still sees the + // entry as discovered and the save must carry its month across intact. + clearSessionCache() + const today = new Date() + const start = new Date(today); start.setHours(0, 0, 0, 0) + const end = new Date(today); end.setHours(23, 59, 59, 999) + expect.soft(totalOutput(await parseAllSessions({ start, end }, 'test-synthetic'))).toBe(0) + + clearSessionCache() + expect.soft(totalOutput(await parseAllSessions(undefined, 'test-synthetic'))).toBe(8) + expect.soft(_synthParseCalls).toBe(1) + + const cache = await loadCache() + expect.soft(cache.providers['test-synthetic']?.files[synthFile]).toBeDefined() + }) + it('retains an orphaned cache entry whose newest call is 89 days old', async () => { const synthFile = join(tmpHome, 'synth-retain.txt') await writeFile(synthFile, 'placeholder') From 8ecd14ccdf593ba9e2e2f73849ff0b8c9365bbdd Mon Sep 17 00:00:00 2001 From: iamtoruk Date: Tue, 18 Aug 2026 08:59:51 -0700 Subject: [PATCH 54/85] changelog: sidechains leave the optimize session population (#974) --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index c444ad37..b6ad4d90 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -28,6 +28,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 +- **`optimize` no longer treats subagent transcripts as your sessions.** Claude Code writes each subagent's transcript to its own `subagents/agent-*.jsonl` file with `isSidechain: true` on every entry, and optimize counted each one as a user-started session. That inflated the session count in the header and fed the session-level detectors a population that fails their tests by construction: a sidechain is handed a large context and returns a short answer (context-heavy), and it never commits or opens a PR because its parent does (low-worth). The session count, the low-worth / context-heavy / cost-outlier / capability-reliability detectors, the coaching notes, the file-churn table, and the model-default recommendation now all run on user-started sessions only, and the raw read/edit, junk-read and duplicate-read detectors skip calls made inside a sidechain transcript. Classification is sticky across the whole file, so calls that appear before the first marked entry are reclassified too, and `isSidechain` now survives the compact parser's 32 KB large-line path and warm-cache range rebuilds. Nothing is deleted from spend: sidechain tokens, calls and cost stay in every total, in `status`, and in the configuration-overhead findings, and the optimize result cache keys on sidechain identity so a run cannot be served a pre-fix result. Absent markers still read as user-started, so no cache re-parse is needed. (#974) - **`optimize` no longer offers `claude mcp remove` for claude.ai connectors, and its MCP schema-cost estimate is per session.** A `claude_ai_*` namespace that no readable local MCP config claims is a claude.ai connector, managed through `/mcp` or claude.ai Settings rather than as a local MCP server (a local server that carries the prefix keeps its removal command and gains a same-name connector note); low-coverage findings now render them as a manual follow-up and build `--apply` plans only for exact local server names found in readable MCP config, so mixed findings remove only the local subset and the "apply-able" subtotal counts only that subset. The same change replaces the old global schema-cost cap with per-session, per-server proportional attribution — a more accurate model that lowers `mcp-low-coverage` estimates for everyone, connectors or not (on a large corpus roughly by half). (#975, #991) - **Bash command splitting was quadratic on long whitespace-heavy commands.** The separator regex retried its leading `\s*` from every offset; matching the separator alone and widening over whitespace by hand makes cold parse ~24% and warm ~40% faster on large corpora, output unchanged. - **Cold parse no longer retains full message bodies through cached previews.** `flatSlice` skipped its Buffer round-trip for strings already within the bound, but provider adapters pre-truncate user-message previews with `.slice(0, 500)` before the cache-site call — those pre-sliced views are still V8 SlicedStrings pinning their large parent, so the retention that OOM'd cold parses of large histories survived. The round-trip now always runs. From 595225da34229c71b94e0e4a9bfb1b1d71700b5b Mon Sep 17 00:00:00 2001 From: iamtoruk Date: Tue, 18 Aug 2026 09:27:21 -0700 Subject: [PATCH 55/85] changelog: note the one-time lifetime jump when retained history reappears (#987) --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5e3dfbad..7663da93 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -28,7 +28,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 -- **Old durable sources remain visible while they still exist.** The 90-day session-cache age-out now applies only after a durable source disappears from discovery, so an unchanged older Copilot source keeps reporting usage and reuses its persisted fingerprint instead of being reparsed and immediately discarded. (#987) +- **Old durable sources remain visible while they still exist.** The 90-day session-cache age-out now applies only after a durable source disappears from discovery, so an unchanged older Copilot source keeps reporting usage and reuses its persisted fingerprint instead of being reparsed and immediately discarded. (#987) On long-lived machines this makes previously dropped history reappear, so lifetime totals can jump once after upgrading. - **`optimize` no longer offers `claude mcp remove` for claude.ai connectors, and its MCP schema-cost estimate is per session.** A `claude_ai_*` namespace that no readable local MCP config claims is a claude.ai connector, managed through `/mcp` or claude.ai Settings rather than as a local MCP server (a local server that carries the prefix keeps its removal command and gains a same-name connector note); low-coverage findings now render them as a manual follow-up and build `--apply` plans only for exact local server names found in readable MCP config, so mixed findings remove only the local subset and the "apply-able" subtotal counts only that subset. The same change replaces the old global schema-cost cap with per-session, per-server proportional attribution — a more accurate model that lowers `mcp-low-coverage` estimates for everyone, connectors or not (on a large corpus roughly by half). (#975, #991) - **Bash command splitting was quadratic on long whitespace-heavy commands.** The separator regex retried its leading `\s*` from every offset; matching the separator alone and widening over whitespace by hand makes cold parse ~24% and warm ~40% faster on large corpora, output unchanged. - **Cold parse no longer retains full message bodies through cached previews.** `flatSlice` skipped its Buffer round-trip for strings already within the bound, but provider adapters pre-truncate user-message previews with `.slice(0, 500)` before the cache-site call — those pre-sliced views are still V8 SlicedStrings pinning their large parent, so the retention that OOM'd cold parses of large histories survived. The round-trip now always runs. From 29b531fced1d1b61ff133412aeedfb8c849d1add Mon Sep 17 00:00:00 2001 From: iamtoruk Date: Tue, 18 Aug 2026 09:38:11 -0700 Subject: [PATCH 56/85] optimize: keep sidechain tool calls in the junk-read and read:edit signals Only duplicate-reads has a structural reason to skip them: a subagent starts on a fresh context, so re-reading what its parent read is a necessary read, not a repeat. Reading node_modules or editing without reading is the same waste whoever does it, and the CLAUDE.md rule both findings suggest binds subagents too - filtering them there discarded most of the evidence on a subagent-heavy corpus. --- CHANGELOG.md | 2 +- README.md | 10 ++++++---- src/optimize.ts | 6 ++++-- tests/optimize-fs.test.ts | 21 ++++++++++++--------- 4 files changed, 23 insertions(+), 16 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b6ad4d90..9a999aaf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -28,7 +28,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 -- **`optimize` no longer treats subagent transcripts as your sessions.** Claude Code writes each subagent's transcript to its own `subagents/agent-*.jsonl` file with `isSidechain: true` on every entry, and optimize counted each one as a user-started session. That inflated the session count in the header and fed the session-level detectors a population that fails their tests by construction: a sidechain is handed a large context and returns a short answer (context-heavy), and it never commits or opens a PR because its parent does (low-worth). The session count, the low-worth / context-heavy / cost-outlier / capability-reliability detectors, the coaching notes, the file-churn table, and the model-default recommendation now all run on user-started sessions only, and the raw read/edit, junk-read and duplicate-read detectors skip calls made inside a sidechain transcript. Classification is sticky across the whole file, so calls that appear before the first marked entry are reclassified too, and `isSidechain` now survives the compact parser's 32 KB large-line path and warm-cache range rebuilds. Nothing is deleted from spend: sidechain tokens, calls and cost stay in every total, in `status`, and in the configuration-overhead findings, and the optimize result cache keys on sidechain identity so a run cannot be served a pre-fix result. Absent markers still read as user-started, so no cache re-parse is needed. (#974) +- **`optimize` no longer treats subagent transcripts as your sessions.** Claude Code writes each subagent's transcript to its own `subagents/agent-*.jsonl` file with `isSidechain: true` on every entry, and optimize counted each one as a user-started session. That inflated the session count in the header and fed the session-level detectors a population that fails their tests by construction: a sidechain is handed a large context and returns a short answer (context-heavy), and it never commits or opens a PR because its parent does (low-worth). Excluded from sidechains now: the header session count, the `low-worth-sessions`, `context-bloat`, `cost-outliers` and `capability-reliability` detectors, the coaching notes, the file-churn table, the median time-to-first-edit, the worst one-shot category, and the model-default recommendation - plus `duplicate-reads`, because a subagent starts on a fresh context and re-reading what its parent read is a necessary read, not a repeat. Everything else keeps the full population: `build-folder-reads` and `read-edit-ratio` still count calls made inside a sidechain, since reading `node_modules` or editing without reading is the same waste whoever does it and the `CLAUDE.md` rule they suggest binds subagents too, and so do the MCP, cache-bloat, ghost-command and configuration-overhead findings. Classification is sticky across the whole file, so calls that appear before the first marked entry are reclassified too, and `isSidechain` now survives the compact parser's 32 KB large-line path and warm-cache range rebuilds. Nothing is deleted from spend: sidechain tokens, calls and cost stay in every total and in `status`, and the optimize result cache keys on sidechain identity so a run cannot be served a pre-fix result. Absent markers still read as user-started, so no cache re-parse is needed. (#974) - **`optimize` no longer offers `claude mcp remove` for claude.ai connectors, and its MCP schema-cost estimate is per session.** A `claude_ai_*` namespace that no readable local MCP config claims is a claude.ai connector, managed through `/mcp` or claude.ai Settings rather than as a local MCP server (a local server that carries the prefix keeps its removal command and gains a same-name connector note); low-coverage findings now render them as a manual follow-up and build `--apply` plans only for exact local server names found in readable MCP config, so mixed findings remove only the local subset and the "apply-able" subtotal counts only that subset. The same change replaces the old global schema-cost cap with per-session, per-server proportional attribution — a more accurate model that lowers `mcp-low-coverage` estimates for everyone, connectors or not (on a large corpus roughly by half). (#975, #991) - **Bash command splitting was quadratic on long whitespace-heavy commands.** The separator regex retried its leading `\s*` from every offset; matching the separator alone and widening over whitespace by hand makes cold parse ~24% and warm ~40% faster on large corpora, output unchanged. - **Cold parse no longer retains full message bodies through cached previews.** `flatSlice` skipped its Buffer round-trip for strings already within the bound, but provider adapters pre-truncate user-message previews with `.slice(0, 500)` before the cache-site call — those pre-sliced views are still V8 SlicedStrings pinning their large parent, so the retention that OOM'd cold parses of large histories survived. The round-trip now always runs. diff --git a/README.md b/README.md index 26a7e67c..e8059461 100644 --- a/README.md +++ b/README.md @@ -157,11 +157,13 @@ codeburn optimize --format json # setup health + findings as JSON `codeburn optimize` scans your sessions and your `~/.claude/` setup for waste patterns: -For Claude Code, the optimize session count, behavioral findings, coaching, and -model-default recommendations use user-started (main) sessions. Subagent +For Claude Code, the optimize session count, the per-session findings, coaching, +and model-default recommendations use user-started (main) sessions. Subagent sidechain transcripts are excluded from that population because their delegated -context and delivery behavior are structurally different; their tokens, calls, -and cost still count in all spend totals and configuration-overhead findings. +context and delivery behavior are structurally different, and so is the re-read +finding, since a subagent starts on a fresh context. Findings about how Claude +uses tools (junk reads, read:edit ratio) and every spend, MCP, and +configuration-overhead finding keep counting them. - Files Claude re-reads across sessions (same content, same context, over and over) - Low Read:Edit ratio (editing without reading leads to retries and wasted tokens) diff --git a/src/optimize.ts b/src/optimize.ts index 2cd7d2eb..5e3ff1ee 100644 --- a/src/optimize.ts +++ b/src/optimize.ts @@ -958,7 +958,6 @@ export function localMcpServerNames(projectCwds: Iterable, homeDir = hom // ============================================================================ export function detectJunkReads(calls: ToolCall[], dateRange?: DateRange): WasteFinding | null { - calls = calls.filter(call => call.isSidechain !== true) const dirCounts = new Map() let totalJunkReads = 0 let recentJunkReads = 0 @@ -1009,6 +1008,10 @@ export function detectJunkReads(calls: ToolCall[], dateRange?: DateRange): Waste } export function detectDuplicateReads(calls: ToolCall[], dateRange?: DateRange): WasteFinding | null { + // A sidechain re-reading what its parent read is not a repeat: a subagent + // starts on a fresh context and has to read it. Junk reads and the + // read:edit ratio keep the full call population - that waste is waste + // whoever does it, and the CLAUDE.md rule they suggest binds subagents too. calls = calls.filter(call => call.isSidechain !== true) const sessionFiles = new Map>() @@ -2618,7 +2621,6 @@ export const EDIT_TOOL_NAMES = new Set(['Edit', 'Write', 'FileEditTool', 'FileWr export const BASH_TOOL_NAMES = new Set(['Bash', 'BashTool', 'PowerShellTool']) export function detectLowReadEditRatio(calls: ToolCall[]): WasteFinding | null { - calls = calls.filter(call => call.isSidechain !== true) let reads = 0 let edits = 0 let recentEdits = 0 diff --git a/tests/optimize-fs.test.ts b/tests/optimize-fs.test.ts index 86b1cb13..60b64b4f 100644 --- a/tests/optimize-fs.test.ts +++ b/tests/optimize-fs.test.ts @@ -380,22 +380,25 @@ describe('scanJsonlFile', () => { expect(result.userMessages).toEqual(['delegate this']) }) - it('excludes marked sidechain calls from raw human-behavior detectors', () => { + it('keeps sidechain calls out of duplicate reads but in junk reads and the read:edit ratio', () => { + const sidechain = { sessionId: 'agent-reviewer', project: 'p1', isSidechain: true } const editCalls = Array.from({ length: 10 }, (_, index) => ({ - name: 'Edit', input: { file_path: `/src/${index}.ts` }, - sessionId: 'agent-reviewer', project: 'p1', isSidechain: true, + name: 'Edit', input: { file_path: `/src/${index}.ts` }, ...sidechain, })) const junkReads = Array.from({ length: 6 }, () => ({ - name: 'Read', input: { file_path: '/app/node_modules/pkg/index.js' }, - sessionId: 'agent-reviewer', project: 'p1', isSidechain: true, + name: 'Read', input: { file_path: '/app/node_modules/pkg/index.js' }, ...sidechain, })) const repeatReads = Array.from({ length: 6 }, () => ({ - name: 'Read', input: { file_path: '/app/src/a.ts' }, - sessionId: 'agent-reviewer', project: 'p1', isSidechain: true, + name: 'Read', input: { file_path: '/app/src/a.ts' }, ...sidechain, })) - expect(detectLowReadEditRatio(editCalls)).toBeNull() - expect(detectJunkReads(junkReads)).toBeNull() + // A subagent editing without reading, or reading into node_modules, is the + // same waste as the parent doing it, and the CLAUDE.md rule both suggest + // binds subagents too - so the full call population feeds them. + expect(detectLowReadEditRatio(editCalls)?.id).toBe('read-edit-ratio') + expect(detectJunkReads(junkReads)?.id).toBe('build-folder-reads') + // A re-read is only waste when the context already held the file; a + // sidechain starts fresh and has to read it. expect(detectDuplicateReads(repeatReads)).toBeNull() }) From 6b427d72cee4118db488096a8edb3bb5d7524dbb Mon Sep 17 00:00:00 2001 From: iamtoruk Date: Tue, 18 Aug 2026 09:58:25 -0700 Subject: [PATCH 57/85] fix(models): honest unpriced guidance, shared ID sanitizing, readable narrow warning The unpriced-models pointer told every user to run `codeburn model-alias`. A subscription or flat-rate model is correctly $0, and mapping it onto another model's per-token rate invents spend that was never billed (#968), so the hint now states the condition instead of the instruction. `sanitizeModelForDisplay` guarded only the `--unpriced` path, leaving every other command and format rendering provider-supplied IDs unfiltered. It moves to the `formatModel` closures in models-report and audit-report, the two sites every renderer routes through, covering the raw-ID fallback as well. The `--unpriced` override stays: it bypasses that path deliberately, because `model-alias` keys on the raw ID, not the friendly name. README says so. Below 45 columns of panel the dashboard warning dropped its marker and count and printed a bare command, so a narrow terminal gave no signal that anything was wrong. `! N: codeburn models --unpriced` is 31 characters and fits. --- CHANGELOG.md | 1 + README.md | 3 +-- src/audit-report.ts | 6 ++++-- src/dashboard.tsx | 2 +- src/main.ts | 6 +++++- src/models-report.ts | 5 ++++- tests/cli-models-unpriced.test.ts | 6 +++++- tests/dashboard.test.ts | 6 +++--- 8 files changed, 24 insertions(+), 11 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7f91d77c..03a484be 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -29,6 +29,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 +- **The unpriced-models warning in the dashboard is now readable at every terminal width.** It lived in a fixed-width panel with an inline model list and a fix command, so it clipped mid-name at 80 columns and clipped *earlier* at 200, where the three-column layout narrows each panel - neither the affected models nor a runnable command survived. The panel line is now a pointer, `! N unpriced: codeburn models --unpriced` (shortened to `! N: codeburn models --unpriced` below 45 columns of panel), and the model list moves to that command's plain output, which is full width, copyable, and lists every model rather than the first two. The command's hint no longer reads as an unconditional instruction to alias: a subscription or flat-rate model is correctly $0, and mapping it onto another model's per-token rate would invent spend that was never billed. Provider-supplied model IDs are now stripped of terminal control characters in every human-readable report rather than only on the unpriced path, and `--unpriced` shows raw IDs instead of friendly names because `model-alias` keys on the raw ID. (#969) - **`codeburn models --unpriced --top N` returned nothing for a `--top N` smaller than the number of priced models.** `--top` is applied inside `aggregateModels`, before the unpriced filter, on rows sorted cost-first — and unpriced rows are $0 on both, so they sorted last and the slice removed exactly the rows the flag exists to show. A user with unpriced models was told they had none. The slice now runs after the filter — and after ranking, because unpriced rows tie at $0 on both keys, so slicing them in aggregate order kept whichever models happened to appear earliest in the transcript rather than the largest. The order now matches the one the unpriced-models warning shows. (#969) - **Old durable sources remain visible while they still exist.** The 90-day session-cache age-out now applies only after a durable source disappears from discovery, so an unchanged older Copilot source keeps reporting usage and reuses its persisted fingerprint instead of being reparsed and immediately discarded. (#987) On long-lived machines this makes previously dropped history reappear, so lifetime totals can jump once after upgrading. - **`optimize` no longer treats subagent transcripts as your sessions.** Claude Code writes each subagent's transcript to its own `subagents/agent-*.jsonl` file with `isSidechain: true` on every entry, and optimize counted each one as a user-started session. That inflated the session count in the header and fed the session-level detectors a population that fails their tests by construction: a sidechain is handed a large context and returns a short answer (context-heavy), and it never commits or opens a PR because its parent does (low-worth). Excluded from sidechains now: the header session count, the `low-worth-sessions`, `context-bloat`, `cost-outliers` and `capability-reliability` detectors, the coaching notes, the file-churn table, the median time-to-first-edit, the worst one-shot category, and the model-default recommendation - plus `duplicate-reads`, because a subagent starts on a fresh context and re-reading what its parent read is a necessary read, not a repeat. Everything else keeps the full population: `build-folder-reads` and `read-edit-ratio` still count calls made inside a sidechain, since reading `node_modules` or editing without reading is the same waste whoever does it and the `CLAUDE.md` rule they suggest binds subagents too, and so do the MCP, cache-bloat, ghost-command and configuration-overhead findings. Classification is sticky across the whole file, so calls that appear before the first marked entry are reclassified too, and `isSidechain` now survives the compact parser's 32 KB large-line path and warm-cache range rebuilds. Nothing is deleted from spend: sidechain tokens, calls and cost stay in every total and in `status`, and the optimize result cache keys on sidechain identity so a run cannot be served a pre-fix result. Absent markers still read as user-started, so no cache re-parse is needed. (#974) diff --git a/README.md b/README.md index 25ef9901..b04e01db 100644 --- a/README.md +++ b/README.md @@ -516,11 +516,10 @@ Sync sends token counts, costs, models, and projects, never prompts or code. Thi | `codeburn models --by-task` | Break each model into per-task-type rows | | `codeburn models --by-agent` | Break each model into per-agent rows: which agent drove which model's spend (`(main)` covers non-agent sessions; `--min-cost 0` shows sub-cent agents) | | `codeburn models --top 10` | Only the 10 most expensive models | -| `codeburn models --unpriced` | Only models with usage that currently price at $0 — the copyable form of the unpriced-models warning | +| `codeburn models --unpriced` | Only models with usage that currently price at $0 — the copyable form of the unpriced-models warning. Shows raw model IDs (not friendly names) so they can be pasted into `model-alias`; JSON keeps them exact | | `codeburn models --format markdown` | Emit a paste-friendly markdown table | | `codeburn models --task feature` | Filter to feature-development work | | `codeburn models --provider claude` | Filter to a single provider | -| `codeburn models --unpriced` | List models counted at $0 because pricing is unknown; JSON preserves exact raw IDs for `model-alias` | Left/right arrow keys switch between Today, 7 Days, 30 Days, Month, 6 Months, and Lifetime (use `--from` / `--to` for an exact historical window). Up/down scroll the full dashboard one line, Page Up/Page Down move one screen, and Home/End jump to either end. The main Daily Activity panel shows at least 10 dates from scrollable full history: use `j`/`k` to move one day, Shift+Space/Space to page, and `g`/`G` to jump to either end. Panels flow in the same order across three columns at maximum width, two at medium width, and one when narrow. In the three-column layout, all panels widen equally by one character for every three additional terminal columns until the dashboard reaches the lesser of 256 characters or the widest renderable source row. Press `q` to quit, `1` `2` `3` `4` `5` `6` as period shortcuts, `c` to open model comparison, or `o` to open optimize. Today, 7 Days, and concrete-day views refresh in place at most once per minute by default (`--refresh 0` to disable) without changing the active view or scroll position. The heavier aggregate views remain static between deliberate navigation changes. The dashboard also shows average cost per session and the five most expensive sessions across all projects. diff --git a/src/audit-report.ts b/src/audit-report.ts index a133dcf4..7a40c5c7 100644 --- a/src/audit-report.ts +++ b/src/audit-report.ts @@ -1,4 +1,4 @@ -import { getModelCosts, type ModelCosts } from './models.js' +import { 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' @@ -111,7 +111,9 @@ export async function aggregateAudit(projects: ProjectSummary[]): Promise p.modelDisplayName(m) : (m: string) => m, + formatModel: p + ? (m: string) => sanitizeModelForDisplay(p.modelDisplayName(m)) + : sanitizeModelForDisplay, } providerCache.set(name, entry) return entry diff --git a/src/dashboard.tsx b/src/dashboard.tsx index f750bcf6..7eeeaf35 100644 --- a/src/dashboard.tsx +++ b/src/dashboard.tsx @@ -655,7 +655,7 @@ function ModelBreakdown({ projects, pw, bw }: { projects: ProjectSummary[]; pw: {unpriced.length > 0 && ( {pw <= 44 - ? 'codeburn models --unpriced' + ? `! ${unpriced.length}: codeburn models --unpriced` : `! ${unpriced.length} unpriced: codeburn models --unpriced`} )} diff --git a/src/main.ts b/src/main.ts index ca6e2600..fdaea87d 100644 --- a/src/main.ts +++ b/src/main.ts @@ -2148,6 +2148,8 @@ program : 'No model usage found for the selected period.\n') return } + // The friendly name is useless for `model-alias`, which keys on the raw ID. + // Sanitized because this bypasses the shared display path in models-report. const renderRows = opts.unpriced && fmt !== 'json' ? rows.map(row => ({ ...row, modelDisplayName: sanitizeModelForDisplay(row.model) })) : rows @@ -2159,7 +2161,9 @@ program process.stdout.write(renderMarkdown(renderRows, { byTask: !!opts.byTask, byAgent: !!opts.byAgent, showTotals: opts.totals !== false }) + '\n') } else if (fmt === 'table') { process.stdout.write(renderTable(renderRows, { byTask: !!opts.byTask, byAgent: !!opts.byAgent, showTotals: opts.totals !== false }) + '\n') - if (opts.unpriced) process.stdout.write('Fix: codeburn model-alias "" \n') + // Never advise aliasing unconditionally: a subscription or flat-rate model + // is correctly $0, and mapping it onto another model's rate invents spend. + if (opts.unpriced) process.stdout.write('If a model is billed per token, map it with: codeburn model-alias "" . Subscription or flat-rate models are correctly $0.\n') } else { process.stderr.write(`codeburn: unknown --format "${opts.format}". Choose table, markdown, json, or csv.\n`) process.exit(1) diff --git a/src/models-report.ts b/src/models-report.ts index aaeb2235..66938b6f 100644 --- a/src/models-report.ts +++ b/src/models-report.ts @@ -3,6 +3,7 @@ import stripAnsi from 'strip-ansi' import { codexCredits } from './codex-credits.js' import { formatCost, formatTokens } from './format.js' +import { sanitizeModelForDisplay } from './models.js' import { getProvider } from './providers/index.js' import { CATEGORY_LABELS, type ProjectSummary, type TaskCategory } from './types.js' @@ -153,7 +154,9 @@ export async function aggregateModels(projects: ProjectSummary[], opts: Aggregat const p = await getProvider(name) const entry = { displayName: p?.displayName ?? name, - formatModel: p ? (m: string) => p.modelDisplayName(m) : (m: string) => m, + formatModel: p + ? (m: string) => sanitizeModelForDisplay(p.modelDisplayName(m)) + : sanitizeModelForDisplay, } providerCache.set(name, entry) return entry diff --git a/tests/cli-models-unpriced.test.ts b/tests/cli-models-unpriced.test.ts index 397e1e0e..eb93fe90 100644 --- a/tests/cli-models-unpriced.test.ts +++ b/tests/cli-models-unpriced.test.ts @@ -113,7 +113,11 @@ describe('codeburn models --unpriced public CLI', () => { expect(result.stdout).toContain('acme/unknown-alpha-969') expect(result.stdout).toContain('acme/unknown-beta-969') expect(result.stdout).not.toContain('claude-opus-4-6') - expect(result.stdout).toContain('codeburn model-alias "" ') + expect(result.stdout).toContain('If a model is billed per token, map it with: codeburn model-alias "" ') + // #968: aliasing a subscription-billed model fabricates spend, so the + // hint must never read as an unconditional instruction. + expect(result.stdout).toContain('Subscription or flat-rate models are correctly $0.') + expect(result.stdout).not.toContain('Fix: codeburn model-alias') }) }) diff --git a/tests/dashboard.test.ts b/tests/dashboard.test.ts index cb86c8ff..f4892286 100644 --- a/tests/dashboard.test.ts +++ b/tests/dashboard.test.ts @@ -396,9 +396,9 @@ describe('interactive terminal rendering', () => { }) it.each([ - { columns: 42, expected: 'codeburn models --unpriced' }, - { columns: 43, expected: 'codeburn models --unpriced' }, - { columns: 44, expected: 'codeburn models --unpriced' }, + { columns: 42, expected: '! 10: codeburn models --unpriced' }, + { columns: 43, expected: '! 10: codeburn models --unpriced' }, + { columns: 44, expected: '! 10: codeburn models --unpriced' }, { columns: 80, expected: '! 10 unpriced: codeburn models --unpriced' }, ])('shows an actionable unpriced-model command in a real $columns-column Ink frame', async ({ columns, expected }) => { const stdin = new PassThrough() as PassThrough & NodeJS.ReadStream From 267749b112147e672be1411394aa0bf70e01e2cd Mon Sep 17 00:00:00 2001 From: iamtoruk Date: Tue, 18 Aug 2026 10:18:03 -0700 Subject: [PATCH 58/85] fix(sqlite): fall back for a read-only parent that reports SQLITE_CANTOPEN A read-only parent with a -wal but no -shm fails as SQLITE_CANTOPEN (14), not SQLITE_READONLY (8), so the fallback never ran and the un-checkpointed rows in the -wal stayed invisible. openReadonlyCache already re-throws the original error when the database itself is missing, which is the other CANTOPEN, so widening the trigger keeps that case distinguishable. Also stops copying the source -shm: SQLite rebuilds the wal-index from the -wal in the writable cache directory, so the copy is dead weight. --- src/sqlite.ts | 22 +++++++++++------- tests/sqlite-readonly-parent.test.ts | 34 +++++++++++++++++++++++++++- 2 files changed, 47 insertions(+), 9 deletions(-) diff --git a/src/sqlite.ts b/src/sqlite.ts index 9576bc78..42b5f4a1 100644 --- a/src/sqlite.ts +++ b/src/sqlite.ts @@ -142,6 +142,16 @@ export function isSqliteReadonlyError(err: unknown): boolean { ) } +/// A read-only parent reports SQLITE_READONLY_DIRECTORY when it must create the +/// sidecars from scratch, but SQLITE_CANTOPEN when a `-wal` is present and the +/// `-shm` it needs to index it is not. openReadonlyCache re-throws the original +/// error when the database itself is missing, which is the other CANTOPEN. +function isSqliteSidecarError(err: unknown): boolean { + if (isSqliteReadonlyError(err)) return true + const errcode = (err as { errcode?: unknown } | null)?.errcode + return typeof errcode === 'number' && (errcode & 0xff) === 14 +} + type DatabaseFingerprint = { dev: number ino: number @@ -268,13 +278,11 @@ function readOnlyCachePath(sourcePath: string, fingerprint: DatabaseFingerprint) const tempBase = `${cachePath}.tmp-${process.pid}-${randomBytes(8).toString('hex')}` const tempWal = tempBase + '-wal' - const tempShm = tempBase + '-shm' const tempMetadata = `${metadataPath}.tmp-${process.pid}-${randomBytes(8).toString('hex')}` try { copyFileSync(sourcePath, tempBase) const copiedWal = copyOptionalFile(sourcePath + '-wal', tempWal) - const copiedShm = copyOptionalFile(sourcePath + '-shm', tempShm) // Do not publish a cache made from a moving database. A live WAL writer will // normally make the direct open succeed once its sidecars exist; this check @@ -288,7 +296,6 @@ function readOnlyCachePath(sourcePath: string, fingerprint: DatabaseFingerprint) unlinkIfPresent(cachePath + '-shm') renameSync(tempBase, cachePath) if (copiedWal) renameSync(tempWal, cachePath + '-wal') - if (copiedShm) renameSync(tempShm, cachePath + '-shm') const metadata: { version: number; sourcePath: string; fingerprint: DatabaseFingerprint } = { version: SQLITE_CACHE_VERSION, @@ -301,7 +308,6 @@ function readOnlyCachePath(sourcePath: string, fingerprint: DatabaseFingerprint) } finally { unlinkIfPresent(tempBase) unlinkIfPresent(tempWal) - unlinkIfPresent(tempShm) unlinkIfPresent(tempMetadata) } } @@ -331,10 +337,10 @@ export function openDatabase(path: string): SqliteDatabase { try { db = new DatabaseSync(path, { readOnly: true }) } catch (err) { - if (!isSqliteReadonlyError(err)) throw err + if (!isSqliteSidecarError(err)) throw err fallbackUsed = true - warnSqliteReadonlyOnce(path) db = openReadonlyCache(path, err) + warnSqliteReadonlyOnce(path) } try { db.exec?.('PRAGMA busy_timeout = 1000') @@ -347,16 +353,16 @@ export function openDatabase(path: string): SqliteDatabase { try { return db.prepare(sql).all(...params) as T[] } catch (err) { - if (!isSqliteReadonlyError(err)) throw err + if (!isSqliteSidecarError(err)) throw err if (fallbackUsed) throw err fallbackUsed = true - warnSqliteReadonlyOnce(path) try { db.close() } catch { // The failed connection may already have been closed by node:sqlite. } db = openReadonlyCache(path, err) + warnSqliteReadonlyOnce(path) try { db.exec?.('PRAGMA busy_timeout = 1000') } catch { diff --git a/tests/sqlite-readonly-parent.test.ts b/tests/sqlite-readonly-parent.test.ts index 13a71af3..fc1882d5 100644 --- a/tests/sqlite-readonly-parent.test.ts +++ b/tests/sqlite-readonly-parent.test.ts @@ -1,4 +1,4 @@ -import { chmodSync, existsSync, readdirSync, statSync } from 'node:fs' +import { chmodSync, copyFileSync, existsSync, mkdirSync, readdirSync, statSync } from 'node:fs' import { mkdtemp, rm } from 'node:fs/promises' import { createRequire } from 'node:module' import { join } from 'node:path' @@ -155,6 +155,38 @@ describe('SQLite read-only parent fallback', () => { expect(cachedDatabaseFiles()).toEqual([]) }) + it('reads un-checkpointed WAL rows when the parent is read-only and the -shm is absent', ({ skip }) => { + const originDir = join(sourceRoot, 'origin') + mkdirSync(originDir) + const originPath = join(originDir, 'state.vscdb') + const writer = new NativeDatabase(originPath) + writer.exec('PRAGMA journal_mode=WAL') + writer.exec('CREATE TABLE values_table (c INTEGER)') + writer.prepare('INSERT INTO values_table (c) VALUES (?)').run(1) + writer.exec('PRAGMA wal_checkpoint(TRUNCATE)') + writer.exec('PRAGMA wal_autocheckpoint=0') + writer.prepare('INSERT INTO values_table (c) VALUES (?)').run(2) + + // A database copied off a live source (snapshot, rsync, unclean unmount) keeps + // its -wal but not its -shm. SQLite reports that as SQLITE_CANTOPEN, not + // SQLITE_READONLY, and the un-checkpointed row lives only in the -wal. + const dbPath = join(sourceRoot, 'state.vscdb') + copyFileSync(originPath, dbPath) + copyFileSync(originPath + '-wal', dbPath + '-wal') + writer.close() + expect(existsSync(dbPath + '-shm')).toBe(false) + if (!makeSourceParentReadOnly(skip)) return + + const db = openDatabase(dbPath) + try { + expect(db.query<{ c: number }>('SELECT c FROM values_table ORDER BY c')).toEqual([{ c: 1 }, { c: 2 }]) + } finally { + db.close() + } + expect(existsSync(dbPath + '-shm')).toBe(false) + expect(cachedDatabaseFiles()).toHaveLength(1) + }) + it('reuses an unchanged fallback copy instead of copying the database again', ({ skip }) => { const dbPath = join(sourceRoot, 'state.vscdb') createClosedWalDatabase(dbPath) From 7bb4e7f8e1856b3ea6594b29ae68ecf6fd5bdcf6 Mon Sep 17 00:00:00 2001 From: iamtoruk Date: Tue, 18 Aug 2026 10:22:36 -0700 Subject: [PATCH 59/85] docs(grok): state the mixed-session drop and the global daily re-derivation plainly The daily-cache re-derivation test seeded v18, a version that only ever existed as an unreleased draft of this change. Seed the shipped v17 so the test models the 17 -> 19 upgrade path users actually hit, and rename it: the bump re-derives every day for every provider, not just Grok, because the daily cache has no per-provider invalidation. The Grok day stays as the fixture since Grok is what the bump exists to correct. The changelog entry now says outright that Grok totals change materially on upgrade (150K -> 96.3M cache-read tokens on a 568-session corpus), that a turn without a turn_completed record inside an otherwise-covered session is dropped rather than estimated, and that the one-time daily re-derivation reads the warm session cache and keeps the superseded file. The context-bloat denominator fix moves to Fixed and names the providers it corrects. docs/providers/grok.md gets the same undercount warning in the token model and a matching entry under Quirks. --- CHANGELOG.md | 3 ++- docs/providers/grok.md | 3 ++- src/daily-cache.ts | 15 +++++++++++---- ... => daily-cache-version-rederivation.test.ts} | 16 ++++++++++++---- 4 files changed, 27 insertions(+), 10 deletions(-) rename tests/{daily-cache-grok-rederivation.test.ts => daily-cache-version-rederivation.test.ts} (72%) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5af53793..6f83b6a3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,7 +16,7 @@ - **DeepSeek Harness (`dsh`) is now a supported provider.** Reads DeepSeek's open-source agent harness from `~/.dsh/sessions` (`DSH_HOME` relocates the root), both the default zstd logs and the uncompressed `session.jsonl` variant. A `.zstd` log is a concatenation of independent zstd frames, one per write batch, so it is decoded frame by frame behind a structural frame scan and a torn trailing frame from a crashed writer is ignored rather than failing the file (needs Node 22.15+ for `zlib` zstd; below that dsh is skipped with a notice instead of counted as $0). One call per `(turn, step)`, with the step's final `assistant/message` usage superseding the streamed `assistant/chunk` sample of the same call rather than adding to it, the model taken from the message that served the step, and reasoning tokens billed at the output rate. DSH records tokens but no cost, so calls are priced from the shared tables. The events a forked session replays from its parent are skipped, since codeburn already counts the parent's own log. The session format is pinned at version 0 upstream with no compatibility implied, so a log stamped with any other version is skipped with a notice instead of read under today's assumptions. ### Changed -- **Grok Build now uses the CLI's authoritative completed-turn usage.** `turn_completed.usage` records are deduplicated by prompt and emitted as one session-level call from verified top-level totals; `modelUsage` only selects a priced attribution id, so multi-model rate attribution remains deliberately out of scope. Reasoning is clamped to reported output before the exclusive output/reasoning split, and the old context-size heuristic remains for sessions without usable top-level usage. In mixed sessions, uncovered pre-upgrade turns are dropped and the row is marked estimated rather than claiming full provider coverage; already-cached Grok sessions re-parse once. (#998) +- **Grok Build now reads the CLI's own completed-turn usage instead of estimating it.** Usage comes from the `turn_completed.usage` records Grok CLI already writes into `updates.jsonl` (`inputTokens`, `outputTokens`, `cachedReadTokens`, `cacheCreationTokens`, `reasoningTokens`), deduplicated by `prompt_id` and emitted as one session-level call from the top-level totals. The previous parser reconstructed an estimate from the running `_meta.totalTokens` context counter, so **existing Grok totals will change materially on upgrade** - on one real 568-session corpus cache-read went from 150K to 96.3M tokens, total tokens from 20.0M to 113.9M, and cost from $36.98 to $56.79. Cache read and cache creation are subsets of input and reasoning is a subset of output, so reasoning is clamped to the record's reported output and split back out to match this repo's exclusive-reasoning contract. `modelUsage` only selects a priced attribution id; multi-model rate attribution stays out of scope, so one session is priced at one model's rate. `costUsdTicks` is ignored because its scale is undocumented. Sessions with no usable record - older CLI versions - keep the old context-curve heuristic and stay flagged estimated. **In a session that has at least one `turn_completed` record, turns without one are not counted at all** (their tokens are dropped rather than estimated), and the session is marked estimated instead of claiming full provider coverage. Cached Grok sessions re-parse once. The daily cache re-derives once on first run after upgrade: this is a global re-derivation of every day and every provider, since the daily cache has no per-provider invalidation, but it reads the warm session cache rather than re-parsing transcripts, so it costs seconds (~3s on the corpus above), and the superseded cache file is retained on disk as the baseline for days no source can still re-derive. (#998) - **Codex rollouts parse across worker threads too, and the workload gate now takes bytes or files.** Codex is the bigger half of a real cold parse — a 4 GB rollout corpus against 1.8 GB of Claude sessions — and it was still decoding one file at a time. A whole-file rollout decode now runs on the same pool, against an empty dedup set, and comes back with the calls, the dedup keys it claimed, and the codex-cache entry it would have written; the parent installs all three in the serial loop's order, so `codex-results.json` and every payload come out byte-identical to a serial run. Cross-file state stays where it was: a forked rollout replaying its parent's token_count history collides on the parent's keys and is re-parsed in-process, and no worker ever touches the cache module's per-directory state. Files the Codex cache can serve exactly or resume into from a byte offset never reach a worker — they read a few KB and the resume state belongs to the parent. The workload gate is now pending BYTES alone (200 MB), not file count: 250 pending files holding under a megabyte between them spawned threads that made the run ~5% slower, while a few hundred huge rollouts were being turned away. The count takes `max(pendingFiles / 50, pendingBytes / 200 MB)`, and the per-thread memory budget is derived per parse as `clamp(256 MB, 2 × average pending file + 128 MB, 1 GB)` rather than a flat 256 MB — a 260 MB rollout peaks near 430 MB in its worker and scales linearly with the pool, so the flat figure over-subscribed exactly the workload this adds. The decision is per provider, and at most one pool is alive at a time. - **A large cold Claude parse now runs across worker threads.** Reading, decoding and line-parsing a session JSONL is per-file work that never touches anything shared, so it moves onto `worker_threads`; each worker ships its parsed turns back as a JSON string and the parent installs them in the exact order the serial loop would. Everything with cross-file state — the streaming-message dedup, canonical project paths, spawn links, PR correlation, progress saves — stays on the main thread, and a file whose message ids were already claimed by an earlier file (or whose worker failed) is simply re-parsed in-process, so the session cache and every payload are identical either way. On a 6 GB corpus a cold `status` drops from 27.5s to 14.8s with peak RSS up 2.27 GB → 2.52 GB. Threads only engage for a genuinely large cold parse: never with under 200 MB behind the pending whole-file re-parses, 2 or fewer cores, or under 4 GB of available memory — so warm and incremental runs are untouched and spawn nothing. Otherwise the count is `min(cores - 1, min(0.25 × available, 2 GB) / 256 MB, pendingFiles / 50)`, where available is `process.availableMemory()` (cgroup-aware in containers) rather than free memory, which on macOS reports free pages and would switch the feature on and off between runs. `CODEBURN_PARSE_WORKERS=0` forces the serial parse and `CODEBURN_PARSE_WORKERS=N` forces N (capped at the core count), both bypassing every gate; `CODEBURN_VERBOSE=1` prints the resolved count and why. - **A warm launch rewrites only the month that changed, and a ranged query reads only the months it can report on.** Per-provider shards still meant one appended session republished that provider's entire history — 95 MB for Claude on a 6 GB corpus. Each provider's shard is now split again by the UTC month of the cached session's FIRST turn, a bucket that never moves as a session grows, so an append rewrites one month. Every shard records the newest month it holds, which lets `--period today/week` skip the shards that cannot contribute a turn to the range; the skipped months stay on disk untouched across the save, and providers whose cache is the only surviving record (durable) or whose parse fingerprint moved are always read in full. Remaining shards are read concurrently. Existing v8 and v7 caches are re-laid-out losslessly on first load and the old layout removed once the new one is published: nothing re-parses. @@ -30,6 +30,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 +- **Context-bloat detection now counts reasoning tokens as generated output.** `detectContextBloat` divided context by `totalOutputTokens` alone, but reasoning is stored beside output rather than inside it, so for every reasoning-bearing provider the detector saw a fraction of the tokens actually generated and invented findings - a session whose real ratio was 20:1, under the 25:1 threshold, was reported as 133:1 and "high impact". It now uses the same `output + reasoning` sum the reports use, which corrects grok, codex, kiro, hermes, qwen and cursor-agent alike. - **The unpriced-models warning in the dashboard is now readable at every terminal width.** It lived in a fixed-width panel with an inline model list and a fix command, so it clipped mid-name at 80 columns and clipped *earlier* at 200, where the three-column layout narrows each panel - neither the affected models nor a runnable command survived. The panel line is now a pointer, `! N unpriced: codeburn models --unpriced` (shortened to `! N: codeburn models --unpriced` below 45 columns of panel), and the model list moves to that command's plain output, which is full width, copyable, and lists every model rather than the first two. The command's hint no longer reads as an unconditional instruction to alias: a subscription or flat-rate model is correctly $0, and mapping it onto another model's per-token rate would invent spend that was never billed. Provider-supplied model IDs are now stripped of terminal control characters in every human-readable report rather than only on the unpriced path, and `--unpriced` shows raw IDs instead of friendly names because `model-alias` keys on the raw ID. (#969) - **`codeburn models --unpriced --top N` returned nothing for a `--top N` smaller than the number of priced models.** `--top` is applied inside `aggregateModels`, before the unpriced filter, on rows sorted cost-first — and unpriced rows are $0 on both, so they sorted last and the slice removed exactly the rows the flag exists to show. A user with unpriced models was told they had none. The slice now runs after the filter — and after ranking, because unpriced rows tie at $0 on both keys, so slicing them in aggregate order kept whichever models happened to appear earliest in the transcript rather than the largest. The order now matches the one the unpriced-models warning shows. (#969) - **Old durable sources remain visible while they still exist.** The 90-day session-cache age-out now applies only after a durable source disappears from discovery, so an unchanged older Copilot source keeps reporting usage and reuses its persisted fingerprint instead of being reparsed and immediately discarded. (#987) On long-lived machines this makes previously dropped history reappear, so lifetime totals can jump once after upgrading. diff --git a/docs/providers/grok.md b/docs/providers/grok.md index ab874200..56adec69 100644 --- a/docs/providers/grok.md +++ b/docs/providers/grok.md @@ -21,7 +21,7 @@ JSON + JSONL. `summary.json` holds the session id, cwd, timestamps, and `current **Estimated fallback.** Older sessions without any valid `turn_completed.usage` record use the running context fill (`totalTokens` per chunk) and the existing compaction-aware per-turn curve. That path remains flagged `costIsEstimated`; a completed record is never blended with the heuristic. -If a session spans a CLI upgrade and has both completed records and streamed turns without a matching record, the all-or-nothing authoritative path keeps the recorded totals, drops the uncovered turns, and marks the emitted row `costIsEstimated: true`. A later parse can fill an open turn once it writes a record; pre-upgrade turns that never do are dropped. +**Mixed sessions undercount.** The choice between the two paths is per session, not per turn. If a session has at least one usable `turn_completed` record, the whole session is billed from the summed records and any turn WITHOUT a record contributes nothing at all - its tokens are dropped, not estimated, so such a session reads low. The row is marked `costIsEstimated: true` rather than claiming full provider coverage. This is deliberate: blending the heuristic into real records would reintroduce the roughly 5x output over-count this parser exists to remove. It happens when a session straddles a CLI upgrade or a run dies before writing its last record; an open turn is filled by a later parse once it writes one, pre-upgrade turns never are. Measured on a 568-session corpus, 1 turn out of 566 was uncovered. ## Pricing @@ -38,6 +38,7 @@ Per `grok:::`. ## Quirks - **Two token paths.** Completed turns carry provider usage; sessions from older CLI versions have only the context curve and therefore remain estimates (likely an upper bound, since re-sent context is cached server-side and not exposed in those files). +- **A turn with no `turn_completed` record is dropped inside an otherwise-covered session** (see Token model). The session still reports, marked estimated, but reads low by those turns. - **No bash-command capture.** Tool names come from `signals.toolsUsed`; per-command bash text is not extracted, so `bashCommands` is empty. - **Whole-session timestamp.** Spend is attributed to `updated_at`, since the context curve is cumulative. - **Subscription vs API.** Grok Build runs via either a metered xAI API account (tiered) or a SuperGrok subscription; the session files do not record which. diff --git a/src/daily-cache.ts b/src/daily-cache.ts index b122e27e..91dc9b10 100644 --- a/src/daily-cache.ts +++ b/src/daily-cache.ts @@ -8,10 +8,17 @@ import type { DateRange, ProjectSummary } from './types.js' // Bumped to 19: Grok authoritative usage now keeps one session-level rollup // from top-level totals, clamps reasoning to reported output, and labels mixed -// authoritative/heuristic coverage. Days already finalized at v18 contain the -// per-model split from the previous draft, so raising MIN_SUPPORTED_VERSION -// forces a one-time re-derivation; v14 carry-forward keeps source-less history -// intact. +// authoritative/heuristic coverage. Every day finalized under the previous +// accounting carries the old Grok totals, and the daily cache has no +// per-provider invalidation, so raising MIN_SUPPORTED_VERSION is the only +// lever: it forces a one-time re-derivation of ALL days, for every provider, +// not just Grok. That pass reads the warm session cache (CACHE_VERSION is +// unchanged and only PROVIDER_PARSE_VERSIONS.grok moved), so it costs seconds +// rather than a full re-parse, and adoptOlderDailyCaches keeps the superseded +// file as the baseline for days no source can still re-derive. +// +// The shipped predecessor is v17; v18 was an unreleased draft of this change +// and only exists in pre-release checkouts. 19 clears both. // // Bumped to 17: copilot CLI sessions were misclassified as VS Code transcripts // (#944), so days finalized at v16 or earlier carry output-only copilot costs — diff --git a/tests/daily-cache-grok-rederivation.test.ts b/tests/daily-cache-version-rederivation.test.ts similarity index 72% rename from tests/daily-cache-grok-rederivation.test.ts rename to tests/daily-cache-version-rederivation.test.ts index 1e5b0a80..bbb9cc95 100644 --- a/tests/daily-cache-grok-rederivation.test.ts +++ b/tests/daily-cache-version-rederivation.test.ts @@ -10,8 +10,12 @@ import { type DailyEntry, } from '../src/daily-cache.js' -const PRE_FIX_DAILY_VERSION = 18 -const cacheRoot = join(tmpdir(), `codeburn-grok-daily-${process.pid}-${Date.now()}`) +// The last SHIPPED daily-cache version before the Grok accounting change, so +// this models the real 17 -> 19 upgrade path users hit. (v18 existed only as an +// unreleased draft.) Anything below MIN_SUPPORTED_VERSION is untrusted, which +// is what makes the re-derivation global rather than Grok-scoped. +const PRE_FIX_DAILY_VERSION = 17 +const cacheRoot = join(tmpdir(), `codeburn-daily-rederive-${process.pid}-${Date.now()}`) function day(date: string, cost: number): DailyEntry { return { @@ -63,8 +67,12 @@ afterEach(async () => { await rm(cacheRoot, { recursive: true, force: true }) }) -describe('Grok daily-cache accounting rederivation', () => { - it('re-derives a v18 Grok day while preserving the old cache as the baseline', async () => { +// Raising MIN_SUPPORTED_VERSION re-derives EVERY day from EVERY provider, not +// only Grok - the daily cache has no per-provider invalidation. A Grok day is +// used here because Grok is the provider whose totals the bump exists to +// correct; the mechanism under test is version-wide. +describe('daily-cache re-derivation on a DAILY_CACHE_VERSION bump', () => { + it('re-derives a day from a below-minimum v17 cache while preserving the old file', async () => { 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.v${PRE_FIX_DAILY_VERSION}.json`) From ada9382833247cbb5f20e4dc4ff8afd8161615d7 Mon Sep 17 00:00:00 2001 From: iamtoruk Date: Tue, 18 Aug 2026 10:26:06 -0700 Subject: [PATCH 60/85] test(grok): pin the unpriced-model branch instead of inheriting it from the snapshot Both multi-model tests assert that chooseAuthoritativeModel skips a modelUsage id it cannot price and falls back to a priced one. They got that "cannot price" from the bundled LiteLLM snapshot happening not to carry grok-4.6-build, so `npm run build` - which re-fetches the snapshot - flipped both assertions the moment an xai/grok-4.6 entry appeared upstream and the prefix match started pricing the id. Stub getModelCosts for that one id instead. The reporter's real ids stay in the fixtures, so the tests still document the #998 case, and calculateCost is left alone: module-internal calls are not intercepted, so cost assertions keep pricing off the real tables. Verified by re-running both files against a refreshed snapshot that does carry xai/grok-4.6; 26/26 pass where they previously failed. --- tests/grok-parser-pipeline.test.ts | 13 +++++++++++++ tests/providers/grok.test.ts | 15 ++++++++++++++- 2 files changed, 27 insertions(+), 1 deletion(-) diff --git a/tests/grok-parser-pipeline.test.ts b/tests/grok-parser-pipeline.test.ts index f3f5df58..d2264d8c 100644 --- a/tests/grok-parser-pipeline.test.ts +++ b/tests/grok-parser-pipeline.test.ts @@ -5,6 +5,19 @@ import { join } from 'path' import { calculateCost } from '../src/models.js' import { clearSessionCache, parseAllSessions } from '../src/parser.js' +// `chooseAuthoritativeModel` branches on whether a modelUsage id resolves to a +// price, so pin the reporter's real id from #998 as unpriced here rather than +// letting the bundled LiteLLM snapshot decide it: xAI pricing landing upstream +// would otherwise silently flip these assertions. Only this lookup is stubbed, +// so `calculateCost` still prices off the real tables. +vi.mock('../src/models.js', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + getModelCosts: (model: string) => (model === 'grok-4.6-build' ? null : actual.getModelCosts(model)), + } +}) + // The exported Grok provider resolves GROK_HOME when its singleton is created, // before the test body runs. Set the root during module hoisting, then re-assert // the call-time cache/env values in beforeEach after env-isolation runs. diff --git a/tests/providers/grok.test.ts b/tests/providers/grok.test.ts index c032bffd..24585e29 100644 --- a/tests/providers/grok.test.ts +++ b/tests/providers/grok.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect, beforeEach, afterEach } from 'vitest' +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest' import { mkdtemp, mkdir, writeFile, rm } from 'fs/promises' import { join } from 'path' import { tmpdir } from 'os' @@ -7,6 +7,19 @@ import { createGrokProvider } from '../../src/providers/grok.js' import { calculateCost } from '../../src/models.js' import type { ParsedProviderCall } from '../../src/providers/types.js' +// `chooseAuthoritativeModel` branches on whether a modelUsage id resolves to a +// price, so pin the reporter's real id from #998 as unpriced here rather than +// letting the bundled LiteLLM snapshot decide it: xAI pricing landing upstream +// would otherwise silently flip these assertions. Only this lookup is stubbed, +// so `calculateCost` still prices off the real tables. +vi.mock('../../src/models.js', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + getModelCosts: (model: string) => (model === 'grok-4.6-build' ? null : actual.getModelCosts(model)), + } +}) + let tmpDir: string beforeEach(async () => { From 9bfe9cc4929bedd606cecfcf44de09e04f8d962c Mon Sep 17 00:00:00 2001 From: iamtoruk Date: Tue, 18 Aug 2026 10:28:31 -0700 Subject: [PATCH 61/85] fix(sqlite): read a read-only parent in place when there is no WAL to lose Four things the copy fallback got wrong. A database whose -wal is absent or empty has no un-checkpointed frames, so there is nothing to go stale and nothing worth copying: immutable=1 opens the source in place and SQLite skips the -shm it cannot create. The copy is now taken only when a non-empty -wal exists, which is the case where dropping it would lose rows. A copy is published under a name carrying its fingerprint, so refreshing one never has to unlink a file another process may still hold open, which Windows does not allow. The -wal is published before the database so a reader can never see the database without the sidecar holding its newest rows, and losing a publish race to an identical copy is not an error. That removes the metadata sidecar: the name is the fingerprint. Superseded copies are evicted rather than overwritten -- the one in use plus at most one predecessor, and anything untouched for a day, which is also what a source path that no longer exists looks like. Reuse touches the copy, so its mtime is last use. A cache directory that cannot be written no longer fails the same way the bug did. It emits the once-per-database notice naming the database and the reason before the database is skipped, instead of going quiet. --- CHANGELOG.md | 2 +- src/sqlite.ts | 207 +++++++++++++++++---------- tests/sqlite-readonly-parent.test.ts | 139 ++++++++++++++---- 3 files changed, 248 insertions(+), 100 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 99cb7fe8..e0bbf7bb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,7 +16,7 @@ - **DeepSeek Harness (`dsh`) is now a supported provider.** Reads DeepSeek's open-source agent harness from `~/.dsh/sessions` (`DSH_HOME` relocates the root), both the default zstd logs and the uncompressed `session.jsonl` variant. A `.zstd` log is a concatenation of independent zstd frames, one per write batch, so it is decoded frame by frame behind a structural frame scan and a torn trailing frame from a crashed writer is ignored rather than failing the file (needs Node 22.15+ for `zlib` zstd; below that dsh is skipped with a notice instead of counted as $0). One call per `(turn, step)`, with the step's final `assistant/message` usage superseding the streamed `assistant/chunk` sample of the same call rather than adding to it, the model taken from the message that served the step, and reasoning tokens billed at the output rate. DSH records tokens but no cost, so calls are priced from the shared tables. The events a forked session replays from its parent are skipped, since codeburn already counts the parent's own log. The session format is pinned at version 0 upstream with no compatibility implied, so a log stamped with any other version is skipped with a notice instead of read under today's assumptions. ### Changed -- **SQLite providers now survive read-only database parents.** CodeBurn keeps the existing read-only open as the fast path, then fingerprints and caches the database plus `-wal`/`-shm` siblings only when SQLite reports that the source directory cannot create its sidecars. The cache has one bounded entry per source path and reuses it while the main-plus-WAL fingerprint is unchanged; the original provider database is never opened writable or modified. Read-only discovery failures are identified separately from missing databases and produce one rate-limited stderr notice. +- **SQLite providers now survive read-only database parents.** A read-only SQLite open is not read-only on disk: on a WAL database SQLite must create `-shm` and `-wal` in the database's own directory, so a source on read-only media, under restrictive permissions, or inside a Flatpak/snap confinement failed with `attempt to write a readonly database` (or `unable to open database file` when a `-wal` was present without its `-shm`), and both discovery sites swallowed it — the provider read as "not installed" rather than as an error. That covers cursor, cursor-agent, opencode, goose, warp, kilo-code, zerostack and the copilot agent-traces database. The direct open stays the fast path and is byte-identical when it succeeds. When it fails for want of sidecars: a database with no WAL frames to lose is opened in place with `immutable=1`, which costs nothing and cannot go stale; a database with a non-empty `-wal` is copied with its `-wal` into the CodeBurn cache and read there, so its un-checkpointed rows are never silently dropped. The copy costs one database's worth of disk and is taken once per change — it is keyed by the main-plus-WAL fingerprint, published under a fingerprint-stamped name so a refresh never overwrites a copy another process is reading, and superseded copies are evicted once a day has passed without a read, keeping at most one predecessor. If the cache itself cannot be written, the database is skipped with a notice naming it and the reason rather than in silence. The original provider database is never opened writable or modified. - **Codex rollouts parse across worker threads too, and the workload gate now takes bytes or files.** Codex is the bigger half of a real cold parse — a 4 GB rollout corpus against 1.8 GB of Claude sessions — and it was still decoding one file at a time. A whole-file rollout decode now runs on the same pool, against an empty dedup set, and comes back with the calls, the dedup keys it claimed, and the codex-cache entry it would have written; the parent installs all three in the serial loop's order, so `codex-results.json` and every payload come out byte-identical to a serial run. Cross-file state stays where it was: a forked rollout replaying its parent's token_count history collides on the parent's keys and is re-parsed in-process, and no worker ever touches the cache module's per-directory state. Files the Codex cache can serve exactly or resume into from a byte offset never reach a worker — they read a few KB and the resume state belongs to the parent. The workload gate is now pending BYTES alone (200 MB), not file count: 250 pending files holding under a megabyte between them spawned threads that made the run ~5% slower, while a few hundred huge rollouts were being turned away. The count takes `max(pendingFiles / 50, pendingBytes / 200 MB)`, and the per-thread memory budget is derived per parse as `clamp(256 MB, 2 × average pending file + 128 MB, 1 GB)` rather than a flat 256 MB — a 260 MB rollout peaks near 430 MB in its worker and scales linearly with the pool, so the flat figure over-subscribed exactly the workload this adds. The decision is per provider, and at most one pool is alive at a time. - **A large cold Claude parse now runs across worker threads.** Reading, decoding and line-parsing a session JSONL is per-file work that never touches anything shared, so it moves onto `worker_threads`; each worker ships its parsed turns back as a JSON string and the parent installs them in the exact order the serial loop would. Everything with cross-file state — the streaming-message dedup, canonical project paths, spawn links, PR correlation, progress saves — stays on the main thread, and a file whose message ids were already claimed by an earlier file (or whose worker failed) is simply re-parsed in-process, so the session cache and every payload are identical either way. On a 6 GB corpus a cold `status` drops from 27.5s to 14.8s with peak RSS up 2.27 GB → 2.52 GB. Threads only engage for a genuinely large cold parse: never with under 200 MB behind the pending whole-file re-parses, 2 or fewer cores, or under 4 GB of available memory — so warm and incremental runs are untouched and spawn nothing. Otherwise the count is `min(cores - 1, min(0.25 × available, 2 GB) / 256 MB, pendingFiles / 50)`, where available is `process.availableMemory()` (cgroup-aware in containers) rather than free memory, which on macOS reports free pages and would switch the feature on and off between runs. `CODEBURN_PARSE_WORKERS=0` forces the serial parse and `CODEBURN_PARSE_WORKERS=N` forces N (capped at the core count), both bypassing every gate; `CODEBURN_VERBOSE=1` prints the resolved count and why. - **A warm launch rewrites only the month that changed, and a ranged query reads only the months it can report on.** Per-provider shards still meant one appended session republished that provider's entire history — 95 MB for Claude on a 6 GB corpus. Each provider's shard is now split again by the UTC month of the cached session's FIRST turn, a bucket that never moves as a session grows, so an append rewrites one month. Every shard records the newest month it holds, which lets `--period today/week` skip the shards that cannot contribute a turn to the range; the skipped months stay on disk untouched across the save, and providers whose cache is the only surviving record (durable) or whose parse fingerprint moved are always read in full. Remaining shards are read concurrently. Existing v8 and v7 caches are re-laid-out losslessly on first load and the old layout removed once the new one is published: nothing re-parses. diff --git a/src/sqlite.ts b/src/sqlite.ts index 42b5f4a1..935a42f5 100644 --- a/src/sqlite.ts +++ b/src/sqlite.ts @@ -1,7 +1,8 @@ import { createRequire } from 'node:module' -import { copyFileSync, existsSync, mkdirSync, readFileSync, renameSync, statSync, unlinkSync, writeFileSync } from 'node:fs' +import { copyFileSync, existsSync, mkdirSync, readdirSync, renameSync, statSync, unlinkSync, utimesSync } from 'node:fs' import { createHash, randomBytes } from 'node:crypto' import { join } from 'node:path' +import { pathToFileURL } from 'node:url' import { getCodeburnCacheDir } from './cache-dir.js' @@ -157,24 +158,27 @@ type DatabaseFingerprint = { ino: number mtimeMs: number sizeBytes: number + walBytes: number } -type CachedDatabaseMetadata = { - version: number - sourcePath: string - fingerprint: DatabaseFingerprint +/// A superseded copy is dropped once it has gone this long without being used. +/// The delay is what keeps a concurrent reader of the previous copy from having +/// its file yanked out from under it. +const CACHE_ENTRY_MAX_AGE_MS = 24 * 60 * 60 * 1000 +const warnedDatabases = new Set() + +/// One notice per source path per run: a provider may discover many sessions +/// from the same database, and the first notice already says what happened. +function warnSqliteOnce(path: string, message: string): void { + if (warnedDatabases.has(path)) return + warnedDatabases.add(path) + process.stderr.write(message) } -const SQLITE_CACHE_VERSION = 1 -const warnedReadonlyDatabases = new Set() - -/// A read-only SQLite connection can still need sidecar files. This notice is -/// intentionally once per source path: a provider may discover many sessions -/// from the same database, and the fallback is already doing the useful work. +/// A read-only SQLite connection can still need sidecar files. export function warnSqliteReadonlyOnce(path: string): void { - if (warnedReadonlyDatabases.has(path)) return - warnedReadonlyDatabases.add(path) - process.stderr.write( + warnSqliteOnce( + path, `codeburn: SQLite database ${path} is in a read-only directory and needs sidecar files; using a cache copy when necessary. ` + 'The original database is not modified.\n', ) @@ -186,6 +190,10 @@ function errorCode(err: unknown): string | undefined { return typeof code === 'string' ? code : undefined } +function describeError(err: unknown): string { + return err instanceof Error ? err.message : String(err) +} + /// This deliberately mirrors fingerprintSqliteFile/fingerprintFile in /// session-cache.ts. openDatabase is synchronous, so the fallback uses the /// synchronous fs APIs only after the direct open has already failed; the @@ -203,6 +211,7 @@ function fingerprintDatabase(path: string): DatabaseFingerprint { ino: main.ino, mtimeMs: wal ? Math.max(main.mtimeMs, wal.mtimeMs) : main.mtimeMs, sizeBytes: main.size + (wal?.size ?? 0), + walBytes: wal?.size ?? 0, } } @@ -211,42 +220,17 @@ function sameFingerprint(a: DatabaseFingerprint, b: DatabaseFingerprint): boolea a.dev === b.dev && a.ino === b.ino && a.mtimeMs === b.mtimeMs && - a.sizeBytes === b.sizeBytes + a.sizeBytes === b.sizeBytes && + a.walBytes === b.walBytes ) } -function isDatabaseFingerprint(value: unknown): value is DatabaseFingerprint { - if (typeof value !== 'object' || value === null) return false - const candidate = value as Partial - return ( - typeof candidate.dev === 'number' && - typeof candidate.ino === 'number' && - typeof candidate.mtimeMs === 'number' && - typeof candidate.sizeBytes === 'number' - ) -} - -function readCachedMetadata(path: string): CachedDatabaseMetadata | null { - try { - const parsed: unknown = JSON.parse(readFileSync(path, 'utf8')) - if (typeof parsed !== 'object' || parsed === null) return null - const candidate = parsed as { version?: unknown; sourcePath?: unknown; fingerprint?: unknown } - if ( - candidate.version !== SQLITE_CACHE_VERSION || - typeof candidate.sourcePath !== 'string' || - !isDatabaseFingerprint(candidate.fingerprint) - ) return null - return { version: SQLITE_CACHE_VERSION, sourcePath: candidate.sourcePath, fingerprint: candidate.fingerprint } - } catch { - return null - } -} - -function unlinkIfPresent(path: string): void { +function unlinkQuietly(path: string): void { try { unlinkSync(path) - } catch (err) { - if (errorCode(err) !== 'ENOENT') throw err + } catch { + // Already gone, or still held open by another CodeBurn on Windows. Either + // way the next run's eviction pass gets another chance at it. } } @@ -260,26 +244,85 @@ function copyOptionalFile(sourcePath: string, destinationPath: string): boolean } } +function sourceKeyOf(sourcePath: string): string { + return createHash('sha256').update(sourcePath, 'utf8').digest('hex').slice(0, 32) +} + +/// The copy is named after the source it came from AND the fingerprint it was +/// taken at, so a refresh publishes a new file rather than overwriting one that +/// another process may still have open. +function cacheEntryName(sourceKey: string, fingerprint: DatabaseFingerprint): string { + const parts = `${fingerprint.dev}:${fingerprint.ino}:${fingerprint.mtimeMs}:${fingerprint.sizeBytes}:${fingerprint.walBytes}` + return `${sourceKey}.${createHash('sha256').update(parts).digest('hex').slice(0, 16)}.db` +} + +function dropCopy(cacheDir: string, name: string): void { + unlinkQuietly(join(cacheDir, name)) + unlinkQuietly(join(cacheDir, name + '-wal')) + unlinkQuietly(join(cacheDir, name + '-shm')) +} + +/// Superseded copies are cleaned up here rather than by overwriting them: keep +/// the one in use plus at most one predecessor, and drop anything untouched for +/// a day, which is also what a source path that no longer exists looks like. +/// Reuse touches the copy, so its mtime is last-use rather than copy time. +function evictSupersededCopies(cacheDir: string, sourceKey: string, keepName: string): void { + let names: string[] + try { + names = readdirSync(cacheDir) + } catch { + return + } + const now = Date.now() + const superseded: { name: string, mtimeMs: number }[] = [] + for (const name of names) { + if (!name.endsWith('.db') || name === keepName) continue + let mtimeMs: number + try { + mtimeMs = statSync(join(cacheDir, name)).mtimeMs + } catch { + continue + } + if (name.startsWith(`${sourceKey}.`)) superseded.push({ name, mtimeMs }) + else if (now - mtimeMs > CACHE_ENTRY_MAX_AGE_MS) dropCopy(cacheDir, name) + } + superseded.sort((a, b) => b.mtimeMs - a.mtimeMs) + for (const [index, entry] of superseded.entries()) { + if (index > 0 || now - entry.mtimeMs > CACHE_ENTRY_MAX_AGE_MS) dropCopy(cacheDir, entry.name) + } +} + +/// A concurrent CodeBurn may have published the same copy first. The name is +/// the fingerprint, so the content is identical by construction and losing that +/// race is not an error. +function publish(tempPath: string, finalPath: string): void { + try { + renameSync(tempPath, finalPath) + } catch (err) { + if (!existsSync(finalPath)) throw err + } +} + function readOnlyCachePath(sourcePath: string, fingerprint: DatabaseFingerprint): string { const cacheDir = join(getCodeburnCacheDir(), 'sqlite-ro') mkdirSync(cacheDir, { recursive: true, mode: 0o700 }) - const sourceKey = createHash('sha256').update(sourcePath, 'utf8').digest('hex') - const cachePath = join(cacheDir, `${sourceKey}.db`) - const metadataPath = `${cachePath}.json` - const cached = readCachedMetadata(metadataPath) - if ( - existsSync(cachePath) && - cached?.sourcePath === sourcePath && - sameFingerprint(cached.fingerprint, fingerprint) - ) { + const sourceKey = sourceKeyOf(sourcePath) + const name = cacheEntryName(sourceKey, fingerprint) + const cachePath = join(cacheDir, name) + if (existsSync(cachePath)) { + const now = new Date() + try { + utimesSync(cachePath, now, now) + } catch { + // mtime is only the eviction clock; a copy we cannot touch still reads. + } + evictSupersededCopies(cacheDir, sourceKey, name) return cachePath } const tempBase = `${cachePath}.tmp-${process.pid}-${randomBytes(8).toString('hex')}` const tempWal = tempBase + '-wal' - const tempMetadata = `${metadataPath}.tmp-${process.pid}-${randomBytes(8).toString('hex')}` - try { copyFileSync(sourcePath, tempBase) const copiedWal = copyOptionalFile(sourcePath + '-wal', tempWal) @@ -291,28 +334,22 @@ function readOnlyCachePath(sourcePath: string, fingerprint: DatabaseFingerprint) throw new Error('SQLite database changed while preparing its read-only cache copy') } - unlinkIfPresent(cachePath) - unlinkIfPresent(cachePath + '-wal') - unlinkIfPresent(cachePath + '-shm') - renameSync(tempBase, cachePath) - if (copiedWal) renameSync(tempWal, cachePath + '-wal') - - const metadata: { version: number; sourcePath: string; fingerprint: DatabaseFingerprint } = { - version: SQLITE_CACHE_VERSION, - sourcePath, - fingerprint, - } - writeFileSync(tempMetadata, JSON.stringify(metadata), { encoding: 'utf8', mode: 0o600 }) - renameSync(tempMetadata, metadataPath) + // The -wal goes first: a reader that can see the database must never find it + // without the sidecar holding its most recent rows. + if (copiedWal) publish(tempWal, cachePath + '-wal') + publish(tempBase, cachePath) + evictSupersededCopies(cacheDir, sourceKey, name) return cachePath } finally { - unlinkIfPresent(tempBase) - unlinkIfPresent(tempWal) - unlinkIfPresent(tempMetadata) + unlinkQuietly(tempBase) + unlinkQuietly(tempWal) } } function openReadonlyCache(path: string, originalError: unknown): DatabaseSyncInstance { + const Driver = DatabaseSync + if (Driver === null) throw new Error(getSqliteLoadError()) + let fingerprint: DatabaseFingerprint try { fingerprint = fingerprintDatabase(path) @@ -321,9 +358,29 @@ function openReadonlyCache(path: string, originalError: unknown): DatabaseSyncIn // inaccessible between the failed query and the fallback probe. throw originalError } - const cachedPath = readOnlyCachePath(path, fingerprint) - const Driver = DatabaseSync - if (Driver === null) throw new Error(getSqliteLoadError()) + + // An absent or empty -wal holds no frames, so there is nothing to go stale and + // nothing worth copying: immutable lets SQLite skip the -shm it cannot create + // and read the source in place. + if (fingerprint.walBytes === 0) { + try { + return new Driver(`${pathToFileURL(path).href}?immutable=1`, { readOnly: true }) + } catch { + // Older node:sqlite builds may not enable URI filenames. Copy instead. + } + } + + let cachedPath: string + try { + cachedPath = readOnlyCachePath(path, fingerprint) + } catch (err) { + warnSqliteOnce( + path, + `codeburn: SQLite database ${path} is in a read-only directory and its cache copy could not be written ` + + `(${describeError(err)}); skipping this database.\n`, + ) + throw originalError + } return new Driver(cachedPath, { readOnly: true }) } diff --git a/tests/sqlite-readonly-parent.test.ts b/tests/sqlite-readonly-parent.test.ts index fc1882d5..1fc5ffd8 100644 --- a/tests/sqlite-readonly-parent.test.ts +++ b/tests/sqlite-readonly-parent.test.ts @@ -1,4 +1,4 @@ -import { chmodSync, copyFileSync, existsSync, mkdirSync, readdirSync, statSync } from 'node:fs' +import { chmodSync, copyFileSync, existsSync, mkdirSync, readdirSync, statSync, utimesSync, writeFileSync } from 'node:fs' import { mkdtemp, rm } from 'node:fs/promises' import { createRequire } from 'node:module' import { join } from 'node:path' @@ -43,6 +43,7 @@ beforeEach(async () => { afterEach(async () => { chmodSync(sourceRoot, 0o755) + chmodSync(cacheRoot, 0o755) for (const writer of openWriters.splice(0)) writer.close() await rm(sourceRoot, { recursive: true, force: true }) await rm(cacheRoot, { recursive: true, force: true }) @@ -69,6 +70,27 @@ function createOpenWalDatabase(dbPath: string): NativeDatabase { return db } +/// A database plus a non-empty -wal and no -shm: what a snapshot, an rsync or an +/// unclean unmount of a live source leaves behind. The second row exists only in +/// the -wal, so dropping it would be silent data loss rather than an error. +function writeUncheckpointedWalDatabase(dbPath: string): void { + const originDir = join(sourceRoot, `origin-${readdirSync(sourceRoot).length}`) + mkdirSync(originDir) + const originPath = join(originDir, 'state.vscdb') + const writer = new NativeDatabase(originPath) + writer.exec('PRAGMA journal_mode=WAL') + writer.exec('CREATE TABLE values_table (c INTEGER)') + writer.prepare('INSERT INTO values_table (c) VALUES (?)').run(1) + writer.exec('PRAGMA wal_checkpoint(TRUNCATE)') + writer.exec('PRAGMA wal_autocheckpoint=0') + writer.prepare('INSERT INTO values_table (c) VALUES (?)').run(2) + copyFileSync(originPath, dbPath) + copyFileSync(originPath + '-wal', dbPath + '-wal') + writer.close() + expect(statSync(dbPath + '-wal').size).toBeGreaterThan(0) + expect(existsSync(dbPath + '-shm')).toBe(false) +} + function createDiscoveryDatabase(dbPath: string): void { const db = new NativeDatabase(dbPath) db.exec('PRAGMA journal_mode=WAL') @@ -100,6 +122,10 @@ function makeSourceParentReadOnly(skip: (reason?: string) => void): boolean { return true } +function makeSourceParentWritable(): void { + chmodSync(sourceRoot, 0o755) +} + function cachedDatabaseFiles(): string[] { try { return readdirSync(join(cacheRoot, 'sqlite-ro')).filter(name => name.endsWith('.db')) @@ -132,7 +158,7 @@ describe('SQLite read-only parent fallback', () => { expect(cachedDatabaseFiles()).toEqual([]) }) - it('reads a WAL database when the parent is read-only and sidecars are absent', ({ skip }) => { + it('reads a read-only parent with no -wal in place, without copying it', ({ skip }) => { const dbPath = join(sourceRoot, 'state.vscdb') createClosedWalDatabase(dbPath) expect(existsSync(dbPath + '-wal')).toBe(false) @@ -143,7 +169,9 @@ describe('SQLite read-only parent fallback', () => { expect(existsSync(dbPath + '-wal')).toBe(false) expect(existsSync(dbPath + '-shm')).toBe(false) - expect(cachedDatabaseFiles()).toHaveLength(1) + // No WAL frames exist, so immutable reads the source in place: nothing to go + // stale, nothing to copy. + expect(cachedDatabaseFiles()).toEqual([]) }) it('opens directly when a read-only parent already has WAL sidecars', ({ skip }) => { @@ -156,25 +184,10 @@ describe('SQLite read-only parent fallback', () => { }) it('reads un-checkpointed WAL rows when the parent is read-only and the -shm is absent', ({ skip }) => { - const originDir = join(sourceRoot, 'origin') - mkdirSync(originDir) - const originPath = join(originDir, 'state.vscdb') - const writer = new NativeDatabase(originPath) - writer.exec('PRAGMA journal_mode=WAL') - writer.exec('CREATE TABLE values_table (c INTEGER)') - writer.prepare('INSERT INTO values_table (c) VALUES (?)').run(1) - writer.exec('PRAGMA wal_checkpoint(TRUNCATE)') - writer.exec('PRAGMA wal_autocheckpoint=0') - writer.prepare('INSERT INTO values_table (c) VALUES (?)').run(2) - - // A database copied off a live source (snapshot, rsync, unclean unmount) keeps - // its -wal but not its -shm. SQLite reports that as SQLITE_CANTOPEN, not + // SQLite reports a -wal without its -shm as SQLITE_CANTOPEN, not // SQLITE_READONLY, and the un-checkpointed row lives only in the -wal. const dbPath = join(sourceRoot, 'state.vscdb') - copyFileSync(originPath, dbPath) - copyFileSync(originPath + '-wal', dbPath + '-wal') - writer.close() - expect(existsSync(dbPath + '-shm')).toBe(false) + writeUncheckpointedWalDatabase(dbPath) if (!makeSourceParentReadOnly(skip)) return const db = openDatabase(dbPath) @@ -189,14 +202,92 @@ describe('SQLite read-only parent fallback', () => { it('reuses an unchanged fallback copy instead of copying the database again', ({ skip }) => { const dbPath = join(sourceRoot, 'state.vscdb') - createClosedWalDatabase(dbPath) + writeUncheckpointedWalDatabase(dbPath) if (!makeSourceParentReadOnly(skip)) return expect(readValue(dbPath)).toBe(1) - const cachedPath = join(cacheRoot, 'sqlite-ro', cachedDatabaseFiles()[0]!) - const firstMtime = statSync(cachedPath).mtimeMs + const first = cachedDatabaseFiles() + expect(first).toHaveLength(1) + const cachedPath = join(cacheRoot, 'sqlite-ro', first[0]!) + const firstIno = statSync(cachedPath).ino expect(readValue(dbPath)).toBe(1) - expect(statSync(cachedPath).mtimeMs).toBe(firstMtime) + expect(cachedDatabaseFiles()).toEqual(first) + expect(statSync(cachedPath).ino).toBe(firstIno) + }) + + it('publishes a refreshed copy beside the old one and keeps at most one predecessor', ({ skip }) => { + const dbPath = join(sourceRoot, 'state.vscdb') + writeUncheckpointedWalDatabase(dbPath) + if (!makeSourceParentReadOnly(skip)) return + expect(readValue(dbPath)).toBe(1) + const [first] = cachedDatabaseFiles() + const firstIno = statSync(join(cacheRoot, 'sqlite-ro', first!)).ino + + // A changed source must not overwrite the copy a concurrent reader may still + // have open: Windows cannot unlink it, and the name is the fingerprint. + makeSourceParentWritable() + writeUncheckpointedWalDatabase(dbPath) + if (!makeSourceParentReadOnly(skip)) return + expect(readValue(dbPath)).toBe(1) + const second = cachedDatabaseFiles() + expect(second).toHaveLength(2) + expect(second).toContain(first) + expect(statSync(join(cacheRoot, 'sqlite-ro', first!)).ino).toBe(firstIno) + + makeSourceParentWritable() + writeUncheckpointedWalDatabase(dbPath) + if (!makeSourceParentReadOnly(skip)) return + expect(readValue(dbPath)).toBe(1) + const third = cachedDatabaseFiles() + expect(third).toHaveLength(2) + expect(third).not.toContain(first) + }) + + it('evicts a copy left untouched for a day, including one whose source is gone', ({ skip }) => { + const dbPath = join(sourceRoot, 'state.vscdb') + writeUncheckpointedWalDatabase(dbPath) + if (!makeSourceParentReadOnly(skip)) return + expect(readValue(dbPath)).toBe(1) + const cacheDir = join(cacheRoot, 'sqlite-ro') + const predecessor = join(cacheDir, cachedDatabaseFiles()[0]!) + + // A copy of a database that no longer exists is simply one nothing touches. + const orphan = join(cacheDir, `${'0'.repeat(32)}.deadbeefdeadbeef.db`) + writeFileSync(orphan, 'orphan') + const aDayAndAnHourAgo = new Date(Date.now() - 25 * 60 * 60 * 1000) + utimesSync(orphan, aDayAndAnHourAgo, aDayAndAnHourAgo) + + makeSourceParentWritable() + writeUncheckpointedWalDatabase(dbPath) + if (!makeSourceParentReadOnly(skip)) return + expect(readValue(dbPath)).toBe(1) + expect(existsSync(orphan)).toBe(false) + expect(existsSync(predecessor)).toBe(true) + + // A day without a read and the superseded copy goes too. + utimesSync(predecessor, aDayAndAnHourAgo, aDayAndAnHourAgo) + expect(readValue(dbPath)).toBe(1) + + expect(existsSync(predecessor)).toBe(false) + expect(cachedDatabaseFiles()).toHaveLength(1) + }) + + it('says so instead of going quiet when the cache copy cannot be written', ({ skip }) => { + const dbPath = join(sourceRoot, 'state.vscdb') + writeUncheckpointedWalDatabase(dbPath) + if (!makeSourceParentReadOnly(skip)) return + chmodSync(cacheRoot, 0o555) + + const stderr = vi.spyOn(process.stderr, 'write').mockReturnValue(true) + try { + expect(() => readValue(dbPath)).toThrow() + const notices = stderr.mock.calls.filter(([chunk]) => String(chunk).includes('cache copy could not be written')) + expect(notices).toHaveLength(1) + expect(String(notices[0]?.[0])).toContain(dbPath) + } finally { + stderr.mockRestore() + chmodSync(cacheRoot, 0o755) + } }) it('keeps a genuinely missing database distinguishable from SQLITE_READONLY', () => { From 60feaa8651b4e8a8a66597d4fce49c9ec7bd1b9b Mon Sep 17 00:00:00 2001 From: iamtoruk Date: Tue, 18 Aug 2026 10:38:01 -0700 Subject: [PATCH 62/85] fix(sqlite): only reach for an immutable URI where node:sqlite honours one node:sqlite enables SQLITE_OPEN_URI from Node 22.15 on. Below that -- 22.13 is the package floor -- a `file:...` location is a literal filename, so the immutable open failed as CANTOPEN and the copy quietly stood in for it. That was the right outcome by accident; the test asserted the newer behaviour and failed on the floor. The support question is now asked once per process, with an in-memory URI that touches no filesystem whichever answer comes back, and the immutable open is attempted only when the answer is yes. The test asks the same question rather than skipping, so both CI lines assert something: rows are correct either way, in place where URI filenames work and from a copy where they do not. --- src/sqlite.ts | 27 +++++++++++++++++++++++++-- tests/sqlite-readonly-parent.test.ts | 10 ++++++---- 2 files changed, 31 insertions(+), 6 deletions(-) diff --git a/src/sqlite.ts b/src/sqlite.ts index 935a42f5..7c107c0d 100644 --- a/src/sqlite.ts +++ b/src/sqlite.ts @@ -153,6 +153,29 @@ function isSqliteSidecarError(err: unknown): boolean { return typeof errcode === 'number' && (errcode & 0xff) === 14 } +let uriFilenamesSupported: boolean | null = null + +/// node:sqlite only enables SQLITE_OPEN_URI from Node 22.15 on (measured: 22.13 +/// and 22.14 fail, 22.15 and later work). Below that a `file:...` location is +/// taken literally and fails as CANTOPEN, so the immutable open is not attempted +/// there. The probe is an in-memory URI rather than a version comparison: it +/// answers the question directly and touches no filesystem either way. +export function sqliteSupportsUriFilenames(): boolean { + if (uriFilenamesSupported !== null) return uriFilenamesSupported + uriFilenamesSupported = false + const Driver = loadDriver() ? DatabaseSync : null + if (Driver !== null) { + try { + new Driver('file:codeburn-uri-probe?mode=memory', { readOnly: true }).close() + uriFilenamesSupported = true + } catch { + // An older build: locations are plain paths, and the copy fallback covers + // exactly the case the immutable open would have. + } + } + return uriFilenamesSupported +} + type DatabaseFingerprint = { dev: number ino: number @@ -362,11 +385,11 @@ function openReadonlyCache(path: string, originalError: unknown): DatabaseSyncIn // An absent or empty -wal holds no frames, so there is nothing to go stale and // nothing worth copying: immutable lets SQLite skip the -shm it cannot create // and read the source in place. - if (fingerprint.walBytes === 0) { + if (fingerprint.walBytes === 0 && sqliteSupportsUriFilenames()) { try { return new Driver(`${pathToFileURL(path).href}?immutable=1`, { readOnly: true }) } catch { - // Older node:sqlite builds may not enable URI filenames. Copy instead. + // Understood but refused: the copy covers it. } } diff --git a/tests/sqlite-readonly-parent.test.ts b/tests/sqlite-readonly-parent.test.ts index 1fc5ffd8..c227a1c7 100644 --- a/tests/sqlite-readonly-parent.test.ts +++ b/tests/sqlite-readonly-parent.test.ts @@ -9,6 +9,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { isSqliteReadonlyError, openDatabase, + sqliteSupportsUriFilenames, } from '../src/sqlite.js' import { discoverSqliteSessions, @@ -158,7 +159,7 @@ describe('SQLite read-only parent fallback', () => { expect(cachedDatabaseFiles()).toEqual([]) }) - it('reads a read-only parent with no -wal in place, without copying it', ({ skip }) => { + it('reads a read-only parent with no -wal, in place where it can and by copy where it cannot', ({ skip }) => { const dbPath = join(sourceRoot, 'state.vscdb') createClosedWalDatabase(dbPath) expect(existsSync(dbPath + '-wal')).toBe(false) @@ -169,9 +170,10 @@ describe('SQLite read-only parent fallback', () => { expect(existsSync(dbPath + '-wal')).toBe(false) expect(existsSync(dbPath + '-shm')).toBe(false) - // No WAL frames exist, so immutable reads the source in place: nothing to go - // stale, nothing to copy. - expect(cachedDatabaseFiles()).toEqual([]) + // No WAL frames exist, so there is nothing to go stale and nothing worth + // copying: immutable reads the source in place. node:sqlite only honours + // URI filenames on newer builds, and on the 22.13 floor the copy stands in. + expect(cachedDatabaseFiles()).toHaveLength(sqliteSupportsUriFilenames() ? 0 : 1) }) it('opens directly when a read-only parent already has WAL sidecars', ({ skip }) => { From 087656baccf7b610d7f0add758ad964151c78ab9 Mon Sep 17 00:00:00 2001 From: iamtoruk Date: Tue, 18 Aug 2026 11:59:21 -0700 Subject: [PATCH 63/85] cache: CODEBURN_CACHE_SCOPE=all forces a full shard read The month-scoped load a ranged query takes is a behaviour change on a warm cache with no way back except deleting it. Drop the scope in loadCache, the one place every caller (including the resident serve) routes through, so a suspect scoped read can be compared against a full one in place. Read policy only: deliberately not in PROVIDER_ENV_VARS, so setting or unsetting it invalidates nothing. --- CHANGELOG.md | 2 ++ docs/architecture.md | 2 ++ src/session-cache.ts | 7 +++++++ tests/session-cache-shards.test.ts | 18 ++++++++++++++++++ 4 files changed, 29 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3f623e3f..7173565b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,8 @@ - **Applied fixes get re-measured on every `optimize` run, and told plainly whether they worked.** After `codeburn optimize --apply`, every still-applied fix comes back in an `Applied fixes` section on subsequent `codeburn optimize` runs, carrying the verdict `act report` already computes from the same reconciliation: `worked` (at least 70% of its window-scaled estimate realized), `partial` (something, but under that), `no-effect` (no measured reduction, printed with the exact `codeburn act undo ` that puts it back), or `measuring` for anything younger than the 3-day measurement window. The numbers are measured — provider-counted usage over the post-apply window — not re-estimated. `--apply` now says when the re-measure will happen, `--format json` gains `appliedFixes[]` (add-only), and the same section appears in the dashboard TUI and the desktop app. New `codeburn optimize --auto-revert` undoes the fixes that measured no reduction at all through the same code path as `codeburn act undo`; it never touches `partial` or still-measuring fixes, and never auto-reverts a `CLAUDE.md` rule (it prints the undo command instead), matching the `--yes` guardrail. - **Optimize findings say what to do with them and where their number came from.** Every finding now carries a class and a basis, and every surface groups by it: `Fix now (apply-able)` for findings `codeburn optimize --apply` can write itself, `Habits` for the behavioural ones, `FYI` for informational ones whose cost may be justified. A finding only counts as apply-able when a plan can actually be built for that instance, so an `mcp-deferral-off` caused by Vertex policy or a shell-profile override is grouped as a habit rather than promising a fix that does not exist. Alongside it, each finding is marked `measured` (summed from provider-counted usage) or `estimated` (a schema-size or recovery-fraction model), with the split reported in the header as `N measured · M estimated` in place of the blanket "Estimates only." footer. Sessions whose cost the provider never reported are kept out of the `cost-outliers` peer comparison, and a provider that only ever estimates gets the finding marked `estimated` rather than dropped. `--format json` gains `class` and `basis` per finding plus `summary.measuredSavingsUSD` (existing fields unchanged), and the new `docs/optimize.md` covers what is scanned, exactly what `--apply` may write, and how to read the health grade. +- **`CODEBURN_CACHE_SCOPE=all` forces a full session-cache read.** A ranged query reads only the month shards that can contribute a turn to it, which is a real behaviour change on a warm cache; this is the escape hatch for the case where a number looks wrong and you want to know whether the scoped read is why. Set it and every load ignores its scope and reads every shard, one-shot runs and the resident `codeburn serve` alike. It is a read policy, not an input to any cache fingerprint: setting or unsetting it re-parses nothing and invalidates nothing. + ### Added (Windows) - **`codeburn menubar` installs and launches the tray app on Windows.** The same command that installs the macOS menubar now does the Windows one, through the same pinned-release path: it resolves `windows-v`, falls back to a scan of the newest `windows-v*` release carrying both assets when that tag has none, downloads the `.msi` with the same retry and backoff, and verifies its sha256 before anything executes it — a mismatch aborts without ever handing the file to the installer. It then runs `msiexec` out of `%SystemRoot%\System32` (never a bare name, so nothing dropped next to the CLI can impersonate it) with `/i /passive /norestart`, treats exit 3010 as installed-pending-restart and 1602 as a cancelled install rather than failures, and launches the exe named by the product's Uninstall registry key. An already-installed matching version skips the download and just launches; `--force` reinstalls. - **A menubar app for Windows.** `windows/` is a Tauri 2 tray app — Rust binary, React popover — that puts today's spend in the notification area and mirrors the macOS menubar screen for screen: agent tabs, period switcher, Trend, Forecast, Pulse, Stats and Plan insights, activity and model breakdowns, optimize findings, CSV/JSON export, launch at login, currency, and theme. Windows has no menubar title, so the number lives in a second tray icon rendered from the system font at the panel's native icon size (Settings can turn it off; the tooltip always carries it). It reads everything through the CLI like the macOS and GNOME clients do, and gates on **codeburn 0.9.9 or newer** — the first release accepting `status --format menubar-json --no-optimize` — showing a setup screen with the install command until it finds one. Refresh follows popover visibility the way the macOS app does: 60 s with optimize findings while open, 2 minutes for today's total while closed, and immediately on open when what you are looking at has gone stale. The Claude quota view never spends Claude's single-use refresh token; on a 401 it re-reads Claude Code's own credential file for a token it has already rotated, matching the macOS client. Ships as an unsigned `.msi` from the `windows-v*` tag, which `codeburn menubar` now installs for you. The same crate still builds and runs a tray on Linux, but that stays experimental and unreleased — `gnome/` is the supported Linux surface. diff --git a/docs/architecture.md b/docs/architecture.md index 3b949bb4..5ec39e28 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -142,6 +142,8 @@ Three caches under `~/.cache/codeburn/` (override with `CODEBURN_CACHE_DIR`): All three use atomic write (temp file + `rename`) and write with mode `0o600`. All three carry a numeric `version` field; bumping it forces a recompute next run. +The session cache (`src/session-cache.ts`) sits beside them as a directory of per-provider-month shards. A date-ranged query reads only the shards whose months can contribute a turn to that range; `CODEBURN_CACHE_SCOPE=all` turns that off and reads every shard, whatever the range. It is a read policy only — it is not part of any provider's env fingerprint, so setting or unsetting it never invalidates the cache. + ### Optimize Detectors `src/optimize.ts` exports 20 detectors. Each returns a `WasteFinding | null`. They are composed by `runOptimize()` which collects findings, ranks them by impact, and returns them with `WasteAction` objects (paste-to-CLAUDE.md, paste-to-session-opener, prompt-now, edit shell config). diff --git a/src/session-cache.ts b/src/session-cache.ts index 3bb37344..c4d0f9e0 100644 --- a/src/session-cache.ts +++ b/src/session-cache.ts @@ -792,8 +792,15 @@ async function loadShard(path: string): Promise | nul * full: the first because its cache is the only surviving record of pruned * usage, the second because a fingerprint change discards the whole section and * must see every entry it is discarding. + * + * `CODEBURN_CACHE_SCOPE=all` is the escape hatch: it drops the scope here, at + * the one place every caller routes through, so a suspect scoped read can be + * compared against a full one without a rebuild. It is a READ policy and + * deliberately not part of any env fingerprint (PROVIDER_ENV_VARS) — setting or + * unsetting it must never invalidate a cache, only change how much of it is read. */ export async function loadCache(scope?: CacheLoadScope): Promise { + if (process.env['CODEBURN_CACHE_SCOPE'] === 'all') scope = undefined const dir = sessionCacheDir() const envelope = await readEnvelope(dir) if (!envelope) return afterMissingShardCache() diff --git a/tests/session-cache-shards.test.ts b/tests/session-cache-shards.test.ts index ff53c9b6..60ef069b 100644 --- a/tests/session-cache-shards.test.ts +++ b/tests/session-cache-shards.test.ts @@ -516,6 +516,24 @@ describe('scoped load', () => { .toEqual(['/live/apr.jsonl', '/live/jun.jsonl', '/live/mar.jsonl']) }) + it('CODEBURN_CACHE_SCOPE=all reads every month and memoizes as unscoped', async () => { + await seedThreeMonths() + clearLoadCacheMemo() + const unscoped = await loadCache() + + clearLoadCacheMemo() + process.env['CODEBURN_CACHE_SCOPE'] = 'all' + try { + const forced = await loadCache(juneScope) + expect(forced).toEqual(unscoped) + // Memoized as a full load, so a resident serve reuses it for any range. + delete process.env['CODEBURN_CACHE_SCOPE'] + expect(await loadCache(juneScope)).toBe(forced) + } finally { + delete process.env['CODEBURN_CACHE_SCOPE'] + } + }) + it('never scopes a provider whose fingerprint moved, or a durable one', async () => { const cache: SessionCache = { version: CACHE_VERSION, From 39075edd502bc4568c8ab664202eb212bb492bc9 Mon Sep 17 00:00:00 2001 From: iamtoruk Date: Tue, 18 Aug 2026 12:00:29 -0700 Subject: [PATCH 64/85] parser: keep promptSource on lines over 32 KB parseLargeJsonl dropped promptSource for exactly the lines SDK-generated prompts live on, so the recurring-context detector regex-scanned the ends of the raw line for it. Add the field to LARGE_ROOT_FIELDS (tiny scalar, add-only, isSidechain already there) and delete the workaround: it read only 2 KB from each end, so a flag further in was missed. No cache change: optimize scans the raw JSONL each run, so promptSource never has to persist on CachedFile. Fixes #1030. With #994 this closes #1023. --- CHANGELOG.md | 2 +- src/optimize.ts | 22 ++++++---------------- src/parser.ts | 4 +++- tests/optimize-fs.test.ts | 8 +++----- tests/parser-large-json-scanner.test.ts | 18 ++++++++++++++++++ 5 files changed, 31 insertions(+), 23 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7173565b..19269865 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,7 @@ ### Added - **`codeburn models --unpriced`.** The dashboard warns about models that price at $0 and points at `codeburn model-alias`, but the list itself was hard to get out of the TUI. This filters the plain-stdout `models` report to exactly those rows, reusing `findUnpricedModels` so local, free, aliased and price-overridden models are treated the same way the warning treats them, and defaulting that mode's min-cost to 0 so $0 rows are not pre-filtered away. Thanks @kocaemre. (#969) -- **`optimize` spots the same long block pasted at the start of many sessions.** The new `recurring-context` detector groups sessions by their opening block — normalized for whitespace and ANSI, hashed over the first 2 KB — and reports a block of at least 1.5 KB that opens 5 or more sessions, with the top three by tokens, their session counts and the project each is confined to. It is a habit, not an apply-able fix: CodeBurn will not move your own text into `CLAUDE.md` for you, so the finding asks Claude to give the block a permanent home (a `CLAUDE.md` rule, or a file read on demand) and hand back a one-line pointer to open sessions with instead. Savings count the repeats only, never the first paste, and are marked `estimated`: provider usage is counted per API call, where the pasted block is mixed in with the system prompt, tool schemas and `CLAUDE.md`, so the block is sized from its own bytes. Injected system reminders and slash-command wrappers are not pastes and are skipped, and neither is a prompt a program wrote — an SDK session or a subagent task — read from the entry's flags, or off the ends of the raw line when the entry is too large for the parser to keep them. The opening block comes from the session scan that already runs, so nothing extra is read from disk. +- **`optimize` spots the same long block pasted at the start of many sessions.** The new `recurring-context` detector groups sessions by their opening block — normalized for whitespace and ANSI, hashed over the first 2 KB — and reports a block of at least 1.5 KB that opens 5 or more sessions, with the top three by tokens, their session counts and the project each is confined to. It is a habit, not an apply-able fix: CodeBurn will not move your own text into `CLAUDE.md` for you, so the finding asks Claude to give the block a permanent home (a `CLAUDE.md` rule, or a file read on demand) and hand back a one-line pointer to open sessions with instead. Savings count the repeats only, never the first paste, and are marked `estimated`: provider usage is counted per API call, where the pasted block is mixed in with the system prompt, tool schemas and `CLAUDE.md`, so the block is sized from its own bytes. Injected system reminders and slash-command wrappers are not pastes and are skipped, and neither is a prompt a program wrote — an SDK session or a subagent task — read from the entry's flags, which survive the parser's large-line path. The opening block comes from the session scan that already runs, so nothing extra is read from disk. - **Applied fixes get re-measured on every `optimize` run, and told plainly whether they worked.** After `codeburn optimize --apply`, every still-applied fix comes back in an `Applied fixes` section on subsequent `codeburn optimize` runs, carrying the verdict `act report` already computes from the same reconciliation: `worked` (at least 70% of its window-scaled estimate realized), `partial` (something, but under that), `no-effect` (no measured reduction, printed with the exact `codeburn act undo ` that puts it back), or `measuring` for anything younger than the 3-day measurement window. The numbers are measured — provider-counted usage over the post-apply window — not re-estimated. `--apply` now says when the re-measure will happen, `--format json` gains `appliedFixes[]` (add-only), and the same section appears in the dashboard TUI and the desktop app. New `codeburn optimize --auto-revert` undoes the fixes that measured no reduction at all through the same code path as `codeburn act undo`; it never touches `partial` or still-measuring fixes, and never auto-reverts a `CLAUDE.md` rule (it prints the undo command instead), matching the `--yes` guardrail. - **Optimize findings say what to do with them and where their number came from.** Every finding now carries a class and a basis, and every surface groups by it: `Fix now (apply-able)` for findings `codeburn optimize --apply` can write itself, `Habits` for the behavioural ones, `FYI` for informational ones whose cost may be justified. A finding only counts as apply-able when a plan can actually be built for that instance, so an `mcp-deferral-off` caused by Vertex policy or a shell-profile override is grouped as a habit rather than promising a fix that does not exist. Alongside it, each finding is marked `measured` (summed from provider-counted usage) or `estimated` (a schema-size or recovery-fraction model), with the split reported in the header as `N measured · M estimated` in place of the blanket "Estimates only." footer. Sessions whose cost the provider never reported are kept out of the `cost-outliers` peer comparison, and a provider that only ever estimates gets the finding marked `estimated` rather than dropped. `--format json` gains `class` and `basis` per finding plus `summary.measuredSavingsUSD` (existing fields unchanged), and the new `docs/optimize.md` covers what is scanned, exactly what `--apply` may write, and how to read the health grade. diff --git a/src/optimize.ts b/src/optimize.ts index 425792e8..2ecd348b 100644 --- a/src/optimize.ts +++ b/src/optimize.ts @@ -663,22 +663,12 @@ function normalizeOpener(text: string): string { return stripAnsi(text).replace(/\s+/g, ' ').trim() } -const MACHINE_PROMPT_HEAD_BYTES = 2048 -const MACHINE_PROMPT_PATTERN = /"promptSource"\s*:\s*"sdk"|"isSidechain"\s*:\s*true/ - /// True when a program wrote this prompt rather than a person pasting it: an /// SDK caller, or a parent agent writing a subagent's task. Either repeats by -/// design and has no home in CLAUDE.md. A user entry over the parser's -/// large-line threshold — routine for generated prompts — comes back without -/// its root flags, so those are read off the raw line instead: the ends of it, -/// since the fields sit either side of the message that made the line large. -function isMachineWrittenPrompt(entry: Record, line: string | Buffer): boolean { - if (entry['promptSource'] === 'sdk' || entry['isSidechain'] === true) return true - const edge = (start: number, end: number): string => - typeof line === 'string' ? line.slice(start, end) : line.subarray(start, end).toString('utf-8') - const head = edge(0, MACHINE_PROMPT_HEAD_BYTES) - const tail = edge(Math.max(MACHINE_PROMPT_HEAD_BYTES, line.length - MACHINE_PROMPT_HEAD_BYTES), line.length) - return MACHINE_PROMPT_PATTERN.test(head) || MACHINE_PROMPT_PATTERN.test(tail) +/// design and has no home in CLAUDE.md. Both flags survive the parser's +/// large-line path, which is where generated prompts routinely land. +function isMachineWrittenPrompt(entry: Record): boolean { + return entry['promptSource'] === 'sdk' || entry['isSidechain'] === true } /// A session's opening block, or null when it is too small to matter or is @@ -768,7 +758,7 @@ export async function scanJsonlFile( userMessages.push(msgContent.slice(0, OPTIMIZE_TEXT_CAP)) if (!sawUserText) { sawUserText = true - const opener = isMachineWrittenPrompt(entry, line) ? null : toSessionOpener(msgContent, project) + const opener = isMachineWrittenPrompt(entry) ? null : toSessionOpener(msgContent, project) if (opener) openers.push(opener) } } else if (Array.isArray(msgContent)) { @@ -781,7 +771,7 @@ export async function scanJsonlFile( remaining -= text.length if (!sawUserText) { sawUserText = true - const opener = isMachineWrittenPrompt(entry, line) ? null : toSessionOpener(block.text, project) + const opener = isMachineWrittenPrompt(entry) ? null : toSessionOpener(block.text, project) if (opener) openers.push(opener) } } diff --git a/src/parser.ts b/src/parser.ts index 6b8a75fc..d67aaab4 100644 --- a/src/parser.ts +++ b/src/parser.ts @@ -557,7 +557,7 @@ function extractObjectFields( return captured } -const LARGE_ROOT_FIELDS = ['type', 'timestamp', 'sessionId', 'cwd', 'gitBranch', 'attachment', 'message', 'isSidechain'] as const +const LARGE_ROOT_FIELDS = ['type', 'timestamp', 'sessionId', 'cwd', 'gitBranch', 'attachment', 'message', 'isSidechain', 'promptSource'] as const const LARGE_ASSISTANT_MESSAGE_FIELDS = ['model', 'usage', 'id', 'content'] as const function parseLargeJsonl(line: string | Buffer): JournalEntry | null { @@ -578,10 +578,12 @@ function parseLargeJsonl(line: string | Buffer): JournalEntry | null { const sessionId = readJsonString(source, root['sessionId']) const cwd = readJsonString(source, root['cwd']) const gitBranch = readJsonString(source, root['gitBranch']) + const promptSource = readJsonString(source, root['promptSource']) if (timestamp !== undefined) entry.timestamp = timestamp if (sessionId !== undefined) entry.sessionId = sessionId if (cwd !== undefined) entry.cwd = cwd if (gitBranch !== undefined) entry.gitBranch = gitBranch + if (promptSource !== undefined) entry.promptSource = promptSource const addedNames = extractLargeAddedNames(source, root['attachment']) if (addedNames.length > 0) { ;(entry as Record)['attachment'] = { type: 'deferred_tools_delta', addedNames } diff --git a/tests/optimize-fs.test.ts b/tests/optimize-fs.test.ts index 60b64b4f..96110ba5 100644 --- a/tests/optimize-fs.test.ts +++ b/tests/optimize-fs.test.ts @@ -551,17 +551,15 @@ describe('detectRecurringContext', () => { expect(detectRecurringContext(openers)).toBeNull() }) - // Over 32 KB the JSONL parser returns a reduced entry without the root - // flags, so the markers have to be read off the raw line. - it('skips machine-written prompts too large for the parser to keep flags on', async () => { + // Over 32 KB the JSONL parser returns a reduced entry; the root flags are + // part of that reduction, so the markers survive. + it('skips machine-written prompts on lines too large for a full parse', async () => { const root = makeFixtureRoot() const now = new Date().toISOString() const huge = BRIEF + 'x'.repeat(40_000) const openers: SessionOpener[] = [] for (let i = 0; i < 6; i++) { const filePath = join(root, `huge-${i}.jsonl`) - // Field order matters: the flags land past the head, behind the very - // message that made the line large. writeFile(filePath, JSON.stringify({ isSidechain: false, type: 'user', message: { content: huge }, timestamp: now, promptSource: 'sdk', })) diff --git a/tests/parser-large-json-scanner.test.ts b/tests/parser-large-json-scanner.test.ts index 00ebe5de..d483eeee 100644 --- a/tests/parser-large-json-scanner.test.ts +++ b/tests/parser-large-json-scanner.test.ts @@ -44,7 +44,25 @@ function largeAssistantLine(): string { }) } +// The fields sit either side of the message that makes the line large, which +// is where a generated prompt puts them in the wild. +function largeMachineWrittenLine(): string { + return JSON.stringify({ + isSidechain: true, + type: 'user', + message: { role: 'user', content: 'brief ' + 'x'.repeat(40_000) }, + timestamp: '2026-05-01T00:00:00Z', + promptSource: 'sdk', + }) +} + describe('large JSONL compact scanner', () => { + it('keeps the flags marking a program-written prompt', () => { + const parsed = parseJsonlLine(largeMachineWrittenLine()) + expect(parsed?.promptSource).toBe('sdk') + expect(parsed?.isSidechain).toBe(true) + }) + it('extracts user text from array content without full JSON.parse', () => { const parsed = parseJsonlLine(largeUserLine()) expect(parsed?.type).toBe('user') From 37796a568e83376d0b1a6121f461b5ebb2fa781b Mon Sep 17 00:00:00 2001 From: iamtoruk Date: Tue, 18 Aug 2026 12:01:12 -0700 Subject: [PATCH 65/85] models: distinguish grok-4.5-build from grok-4.5 in reports Reports bucket rows by model id and label them afterwards, so the two ids collapsing onto one display name printed what looked like the same row twice with different numbers. Give the variant its own SHORT_NAMES entry, which the longest-first match picks over the grok-4.5 prefix. Display only: ids are untouched, so nothing re-parses and no cost moves. Fixed in the shared table rather than the grok provider so the menubar and model-breakdown, which call getShortModelName directly, get it too. Fixes #1029. --- CHANGELOG.md | 1 + src/models.ts | 4 ++++ tests/providers/grok.test.ts | 7 +++++++ 3 files changed, 12 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 19269865..04390407 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -33,6 +33,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 +- **`models` and `audit` no longer show two identical `Grok 4.5` rows.** `grok-4.5-build` — the Grok Build harness's variant id — fell into the `grok-4.5` display entry by prefix, and since rows bucket by model id, not display name, the two came out as visually identical rows with different numbers. The variant now shows as `Grok 4.5 (build)`. Display only: no id is rewritten and no cost moves. (#1029) - **The session chart legend now leads with a visible session disambiguator and title instead of the project path.** Every series in a monorepo shared the same project prefix, so the only thing separating them was a truncated hex fragment — and per-application cost attribution is the main reason to open that chart. `SessionSummary.title` is already parsed and already rendered in the Context tab; the legend now puts the short session id first, prefers the title, and falls back to the previous project-based label when a session never produced one. Titles come from transcripts, so they are stripped of ANSI and control characters and capped before they reach either the legend or the tooltip. (#997) - **Context-bloat detection now counts reasoning tokens as generated output.** `detectContextBloat` divided context by `totalOutputTokens` alone, but reasoning is stored beside output rather than inside it, so for every reasoning-bearing provider the detector saw a fraction of the tokens actually generated and invented findings - a session whose real ratio was 20:1, under the 25:1 threshold, was reported as 133:1 and "high impact". It now uses the same `output + reasoning` sum the reports use, which corrects grok, codex, kiro, hermes, qwen and cursor-agent alike. - **The unpriced-models warning in the dashboard is now readable at every terminal width.** It lived in a fixed-width panel with an inline model list and a fix command, so it clipped mid-name at 80 columns and clipped *earlier* at 200, where the three-column layout narrows each panel - neither the affected models nor a runnable command survived. The panel line is now a pointer, `! N unpriced: codeburn models --unpriced` (shortened to `! N: codeburn models --unpriced` below 45 columns of panel), and the model list moves to that command's plain output, which is full width, copyable, and lists every model rather than the first two. The command's hint no longer reads as an unconditional instruction to alias: a subscription or flat-rate model is correctly $0, and mapping it onto another model's per-token rate would invent spend that was never billed. Provider-supplied model IDs are now stripped of terminal control characters in every human-readable report rather than only on the unpriced path, and `--unpriced` shows raw IDs instead of friendly names because `model-alias` keys on the raw ID. (#969) diff --git a/src/models.ts b/src/models.ts index dbe1c49d..c0e6746f 100644 --- a/src/models.ts +++ b/src/models.ts @@ -947,6 +947,10 @@ const SHORT_NAMES: Record = { // The Grok Build harness reports the model it runs (`grok-4.5`), so this is // the model's own name; `grok-build*` ids still resolve to "Grok Build". 'grok-4.5': 'Grok 4.5', + // The harness also reports a `-build` variant of that model. It is a distinct + // id and reports bucket by id, so without its own entry the prefix match gave + // it the same name as `grok-4.5` and the report showed two identical rows. + 'grok-4.5-build': 'Grok 4.5 (build)', // ClinePass routes models as `cline-pass/`; getShortModelName's path // fallback strips the prefix and re-resolves the bare slug through this // table, the same way it handles `accounts/fireworks/models/`. diff --git a/tests/providers/grok.test.ts b/tests/providers/grok.test.ts index 24585e29..eba7dc2b 100644 --- a/tests/providers/grok.test.ts +++ b/tests/providers/grok.test.ts @@ -490,6 +490,13 @@ describe('grok provider - display names', () => { expect(provider.modelDisplayName('grok-build')).toBe('Grok Build') }) + // Two distinct ids, so two rows; identical names made them look like one row + // printed twice (#1029). + it('distinguishes the build variant of a model from the model itself', () => { + expect(provider.modelDisplayName('grok-4.5')).toBe('Grok 4.5') + expect(provider.modelDisplayName('grok-4.5-build')).toBe('Grok 4.5 (build)') + }) + it('normalizes tool names', () => { expect(provider.toolDisplayName('run_terminal_command')).toBe('Bash') expect(provider.toolDisplayName('mystery_tool')).toBe('mystery_tool') From 74d718fe033c0d9d34bb25bebc9daeade7e74ec8 Mon Sep 17 00:00:00 2001 From: iamtoruk Date: Tue, 18 Aug 2026 12:14:55 -0700 Subject: [PATCH 66/85] ci: verify the 0.9.20 upgrade path on every platform Everyone upgrading from the last published CLI crosses the session-cache v7 -> v9 re-layout (#1005/#1007) and the daily-cache v17 -> v19 re-derivation (#1015) on their first run. That path was covered by unit tests on one platform against caches the tests wrote themselves. `npm run verify:upgrade` (and the matching matrix job over {ubuntu, windows, macos} x node {22.13.0, 22}) instead installs the real codeburn@0.9.20 into an isolated global prefix, points it at a generated seven-provider corpus in a HOME whose path contains a space, and lets it write a genuine session-cache.v7.json + daily-cache.v17.json. This build then runs against that same cache dir, installed the same way, so dist/parse-worker.js has to resolve from an entry point outside the checkout. It asserts the v7 file is retired, the v9 envelope and shards publish, the daily history does not shrink, and per-provider calls/tokens/cost match the baseline exactly for claude, codex, gemini, kiro and cursor. grok is reported rather than asserted (its accounting changed in #1015) and dsh is required to be new. It also smoke-tests serve --stdio against the one-shot payloads, pins CODEBURN_PARSE_WORKERS to 0 and 3 and requires identical shards and payloads either way, and checks that a second run re-parses nothing. --- .github/workflows/upgrade-path.yml | 53 ++++ package.json | 1 + scripts/upgrade-path/compare.mjs | 130 ++++++++++ scripts/upgrade-path/gen-corpus.mjs | 377 ++++++++++++++++++++++++++++ scripts/upgrade-path/run.mjs | 343 +++++++++++++++++++++++++ 5 files changed, 904 insertions(+) create mode 100644 .github/workflows/upgrade-path.yml create mode 100644 scripts/upgrade-path/compare.mjs create mode 100644 scripts/upgrade-path/gen-corpus.mjs create mode 100644 scripts/upgrade-path/run.mjs diff --git a/.github/workflows/upgrade-path.yml b/.github/workflows/upgrade-path.yml new file mode 100644 index 00000000..4791d6a8 --- /dev/null +++ b/.github/workflows/upgrade-path.yml @@ -0,0 +1,53 @@ +name: Upgrade path + +# Every existing user upgrading from the last published CLI (0.9.20) crosses the +# session-cache v7 -> v9 re-layout and the daily-cache v17 -> v19 re-derivation on +# their first run. Unit tests cover the migration in isolation on one platform; this +# job proves it against a cache that the REAL 0.9.20 binary wrote, on all three +# platforms, at both the package floor and the newest 22.x. +on: + pull_request: + paths: + - 'src/**' + - 'scripts/upgrade-path/**' + - '.github/workflows/upgrade-path.yml' + workflow_dispatch: + +jobs: + upgrade-path: + runs-on: ${{ matrix.os }} + timeout-minutes: 45 + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, windows-latest, macos-latest] + # Package floor, and the newest 22.x. The floor matters here: node:zlib + # gained zstd in 22.15, so dsh degrades below it (the corpus writes the + # uncompressed dsh variant so both legs still count the same numbers). + node-version: [22.13.0, 22] + + steps: + - uses: actions/checkout@v6 + - uses: actions/setup-node@v4 + with: + node-version: ${{ matrix.node-version }} + cache: npm + - run: npm ci + # dist/cli.js + dist/parse-worker.js. The dash bundle is not exercised by + # any command this job runs, so the full `npm run build` is not paid for. + - run: npm run build:cli + + - name: Upgrade path from codeburn@0.9.20 + run: npm run verify:upgrade + env: + # Under runner.temp so the artifact step below can find it. The space + # is deliberate: a real Windows HOME almost always has one. + UPGRADE_PATH_WORK: ${{ runner.temp }}/codeburn upgrade path + + - name: Upload payloads on failure + if: failure() + uses: actions/upload-artifact@v4 + with: + name: upgrade-path-${{ matrix.os }}-node${{ matrix.node-version }} + path: ${{ runner.temp }}/codeburn upgrade path/payloads + if-no-files-found: ignore diff --git a/package.json b/package.json index c1288196..0e12120b 100644 --- a/package.json +++ b/package.json @@ -21,6 +21,7 @@ "test": "vitest run tests --exclude \"tests/cache-refresh-lock*\"", "test:locks": "vitest run tests/cache-refresh-lock.test.ts tests/cache-refresh-lock-corrupt-body.test.ts tests/cache-refresh-lock-process.test.ts --poolOptions.forks.singleFork=true", "test:watch": "vitest tests --exclude \"tests/cache-refresh-lock*\"", + "verify:upgrade": "node scripts/upgrade-path/run.mjs", "prepublishOnly": "npm run build" }, "keywords": [ diff --git a/scripts/upgrade-path/compare.mjs b/scripts/upgrade-path/compare.mjs new file mode 100644 index 00000000..0d0ef49a --- /dev/null +++ b/scripts/upgrade-path/compare.mjs @@ -0,0 +1,130 @@ +// Per-provider payload parity between the published CLI and this build. +// +// node scripts/upgrade-path/compare.mjs +// +// Each dir holds the payloads run.mjs captured: `export.json` (per-call records, +// the token/call source) and `menubar.json` (the unrounded per-provider cost). +// Prints one row per provider and exits non-zero on a diff that is not expected. +// +// Expectations, and why: +// claude, codex, gemini, kiro, cursor parse identically either side of the +// upgrade. Calls and every token field must match EXACTLY; cost is allowed +// COST_TOLERANCE of drift because the two binaries carry different bundled +// LiteLLM price snapshots and only agree when the shared pricing cache in +// CODEBURN_CACHE_DIR is warm (which it is, unless the runner is offline). +// grok changed by design in #1015: usage now comes from the CLI's own +// turn_completed records instead of a context-curve estimate. The change +// is REPORTED, never asserted — not even directionally. On real corpora +// the changelog documents totals rising, but that is a property of real +// Grok sessions, and the direction here would only reflect how the +// generator happened to size its synthetic context curve against its +// synthetic usage records. Both sides must still count the same SESSIONS, +// 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. +const EXACT = ['claude', 'codex', 'gemini', 'kiro', 'cursor'] +const CHANGED_BY_DESIGN = ['grok'] +const NEW_IN_THIS_RELEASE = ['dsh'] + +const COST_TOLERANCE = 0.005 // 0.5% relative + +import { readFileSync } from 'node:fs' +import { join } from 'node:path' + +const [baseDir, upDir] = process.argv.slice(2) +if (!baseDir || !upDir) { + console.error('usage: compare.mjs ') + process.exit(2) +} + +const TOKEN_FIELDS = ['inputTokens', 'outputTokens', 'reasoningTokens', 'cacheWriteTokens', 'cacheReadTokens'] + +function load(dir) { + const exported = JSON.parse(readFileSync(join(dir, 'export.json'), 'utf8')) + const menubar = JSON.parse(readFileSync(join(dir, 'menubar.json'), 'utf8')) + const byProvider = {} + for (const r of exported.records ?? []) { + const p = r.provider || 'unknown' + const acc = (byProvider[p] ??= { calls: 0, cost: 0, ...Object.fromEntries(TOKEN_FIELDS.map(f => [f, 0])) }) + acc.calls++ + acc.cost += r.cost ?? 0 + for (const f of TOKEN_FIELDS) acc[f] += r[f] ?? 0 + } + // Prefer the unrounded cost, keyed by the provider's internal id. + // `providerDetails` is the only place that pairing exists — the sibling + // `providers` map is keyed by lowercased display name. The per-record sum + // above stands in when a binary predates providerDetails; it is rounded per + // record, so it is the coarser of the two. + for (const d of menubar.current?.providerDetails ?? []) { + if (byProvider[d.id]) byProvider[d.id].cost = d.cost + } + return byProvider +} + +const base = load(baseDir) +const up = load(upDir) +const providers = [...new Set([...Object.keys(base), ...Object.keys(up)])].sort() + +const failures = [] +const notes = [] +const rows = [] + +const relDiff = (a, b) => (a === 0 && b === 0 ? 0 : Math.abs(b - a) / Math.max(Math.abs(a), Math.abs(b))) +const fmt = n => (Number.isInteger(n) ? String(n) : n.toFixed(6)) + +for (const name of providers) { + const b = base[name] + const u = up[name] + let verdict + + if (NEW_IN_THIS_RELEASE.includes(name)) { + if (b) failures.push(`${name}: expected to be absent from the 0.9.20 baseline, but it reported ${b.calls} calls`) + else if (!u || u.calls === 0) failures.push(`${name}: new in this release but the upgraded run reported nothing`) + verdict = 'new (expected)' + } else if (!b || !u) { + failures.push(`${name}: present in ${b ? 'baseline' : 'upgraded'} only`) + verdict = 'MISSING' + } else if (CHANGED_BY_DESIGN.includes(name)) { + const bt = TOKEN_FIELDS.reduce((s, f) => s + b[f], 0) + const ut = TOKEN_FIELDS.reduce((s, f) => s + u[f], 0) + if (b.calls !== u.calls) failures.push(`${name}: usage accounting changed in #1015, but the session/call COUNT should not have: ${b.calls} != ${u.calls}`) + verdict = 'changed by design' + notes.push(`${name}: tokens ${bt} -> ${ut}, cost ${fmt(b.cost)} -> ${fmt(u.cost)} (#1015, expected; magnitude here is a property of the fixture, not evidence)`) + } else { + const diffs = [] + 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 (!EXACT.includes(name)) { + notes.push(`${name}: no expectation declared in compare.mjs; ${diffs.length ? diffs.join(', ') : 'identical'}`) + verdict = diffs.length ? 'differs (unclassified)' : 'identical' + } else if (diffs.length) { + failures.push(`${name}: ${diffs.join(', ')}`) + verdict = 'DIFFERS' + } else { + verdict = costDrift === 0 ? 'identical' : `identical (cost ${(costDrift * 100).toFixed(3)}% drift)` + } + } + + rows.push({ + provider: name, + calls: `${b?.calls ?? '-'} -> ${u?.calls ?? '-'}`, + tokens: `${b ? TOKEN_FIELDS.reduce((s, f) => s + b[f], 0) : '-'} -> ${u ? TOKEN_FIELDS.reduce((s, f) => s + u[f], 0) : '-'}`, + cost: `${b ? fmt(b.cost) : '-'} -> ${u ? fmt(u.cost) : '-'}`, + verdict, + }) +} + +const cols = ['provider', 'calls', 'tokens', 'cost', 'verdict'] +const width = Object.fromEntries(cols.map(c => [c, Math.max(c.length, ...rows.map(r => r[c].length))])) +const line = r => cols.map(c => String(r[c]).padEnd(width[c])).join(' ') +console.log('') +console.log(line(Object.fromEntries(cols.map(c => [c, c.toUpperCase()])))) +console.log(cols.map(c => '-'.repeat(width[c])).join(' ')) +for (const r of rows) console.log(line(r)) +console.log('') +for (const n of notes) console.log(`note: ${n}`) +for (const f of failures) console.log(`FAIL: ${f}`) +console.log(failures.length ? `\nparity: ${failures.length} unexpected difference(s)` : '\nparity: ok') +process.exit(failures.length ? 1 : 0) diff --git a/scripts/upgrade-path/gen-corpus.mjs b/scripts/upgrade-path/gen-corpus.mjs new file mode 100644 index 00000000..a4ccbdb6 --- /dev/null +++ b/scripts/upgrade-path/gen-corpus.mjs @@ -0,0 +1,377 @@ +// Deterministic multi-provider fixture corpus for the upgrade-path check. +// +// node scripts/upgrade-path/gen-corpus.mjs +// +// Lays sessions out at each provider's DEFAULT path under , so the run +// only has to set HOME/USERPROFILE and no per-provider override var. Everything +// is seeded off a fixed constant: two invocations against the same day produce +// byte-identical files, which is what makes the worker-determinism and +// 0.9.20-vs-main payload comparisons meaningful. +// +// Day anchoring is the one thing that moves: sessions are dated relative to +// today so they land inside the daily cache's backfill window. That is fine — +// every comparison this corpus feeds happens inside a single run. + +import { mkdirSync, writeFileSync, rmSync, existsSync } from 'node:fs' +import { join } from 'node:path' +import { createRequire } from 'node:module' + +const require_ = createRequire(import.meta.url) + +const HOME = process.argv[2] +if (!HOME) { + console.error('usage: gen-corpus.mjs ') + process.exit(2) +} + +// mulberry32 — same seed, same corpus. +let seedState = 0x9e3779b9 +function rnd() { + seedState = (seedState + 0x6d2b79f5) | 0 + let t = seedState + t = Math.imul(t ^ (t >>> 15), t | 1) + t ^= t + Math.imul(t ^ (t >>> 7), t | 61) + return ((t ^ (t >>> 14)) >>> 0) / 4294967296 +} +const pick = arr => arr[Math.floor(rnd() * arr.length)] +const between = (lo, hi) => lo + Math.floor(rnd() * (hi - lo)) + +// Day 0 = 92 days ago (UTC midnight); the corpus spans day 0..91. +const DAY_MS = 86_400_000 +const SPAN_DAYS = 92 +const day0 = Math.floor(Date.now() / DAY_MS) * DAY_MS - (SPAN_DAYS - 1) * DAY_MS +const at = (day, hour, min = 0, sec = 0) => + new Date(day0 + day * DAY_MS + hour * 3_600_000 + min * 60_000 + sec * 1000) +const iso = d => d.toISOString() + +const write = (path, body) => { + mkdirSync(join(path, '..'), { recursive: true }) + writeFileSync(path, body) +} +const writeLines = (path, lines) => write(path, lines.join('\n') + '\n') + +const PROJECTS = ['/work/api-gateway', '/work/billing', '/work/web app', '/work/infra'] + +// ── claude ─────────────────────────────────────────────────────────────────── +// 204 transcripts across the span: 200 plain, 2 parent/sidechain pairs. One +// plain transcript carries a single line over 32 KB (the large-line scanner +// path); the parent/sidechain pairs exercise the v7 spawn-link capture that the +// migration has to carry forward. + +const CLAUDE_MODELS = ['claude-sonnet-4-5', 'claude-opus-4-8', 'claude-haiku-4-5'] + +function claudeUser(sessionId, ts, cwd, text) { + return JSON.stringify({ type: 'user', sessionId, timestamp: iso(ts), cwd, gitBranch: 'main', message: { role: 'user', content: text } }) +} + +function claudeAssistant(sessionId, ts, cwd, msgId, model, usage, content) { + return JSON.stringify({ + type: 'assistant', sessionId, timestamp: iso(ts), cwd, gitBranch: 'main', + message: { id: msgId, type: 'message', role: 'assistant', model, content, usage }, + }) +} + +function claudeSession(sessionId, day, cwd, turns, opts = {}) { + const lines = [] + for (let t = 0; t < turns; t++) { + const ts = at(day, 9 + (t % 8), (t * 7) % 60) + lines.push(claudeUser(sessionId, ts, cwd, `task ${t} for ${sessionId}`)) + const content = [ + { type: 'text', text: `step ${t}` }, + { type: 'tool_use', id: `tu-${sessionId}-${t}`, name: t % 3 === 0 ? 'Edit' : 'Read', input: { file_path: `${cwd}/src/f${t}.ts` } }, + ] + // One line north of 32 KB, on the file the caller asked for it on. + if (opts.hugeLineAtTurn === t) content.push({ type: 'text', text: 'y'.repeat(40 * 1024) }) + lines.push(claudeAssistant(sessionId, at(day, 9 + (t % 8), (t * 7) % 60, 30), cwd, `msg-${sessionId}-${t}`, pick(CLAUDE_MODELS), { + input_tokens: between(400, 4000), + output_tokens: between(40, 900), + cache_read_input_tokens: between(0, 20000), + cache_creation_input_tokens: between(0, 3000), + }, content)) + } + return lines +} + +function genClaude() { + const projectsDir = join(HOME, '.claude', 'projects') + let files = 0 + for (let i = 0; i < 200; i++) { + const cwd = PROJECTS[i % PROJECTS.length] + const day = (i * 7) % SPAN_DAYS + const sid = `c-${String(i).padStart(4, '0')}` + const dirName = cwd.replace(/[/ ]/g, '-') + writeLines(join(projectsDir, dirName, `${sid}.jsonl`), claudeSession(sid, day, cwd, between(4, 14), i === 137 ? { hugeLineAtTurn: 2 } : {})) + files++ + } + + // Two parent transcripts, each spawning one subagent whose transcript lives + // under /subagents/agent-.jsonl and is marked isSidechain. + for (let p = 0; p < 2; p++) { + const cwd = PROJECTS[p] + const dirName = cwd.replace(/[/ ]/g, '-') + const parent = `p-000${p}` + const agent = `a-000${p}` + const day = 40 + p * 10 + const parentLines = claudeSession(parent, day, cwd, 5) + parentLines.push(JSON.stringify({ + type: 'assistant', sessionId: parent, timestamp: iso(at(day, 12)), cwd, + message: { id: `m-spawn-${p}`, type: 'message', role: 'assistant', model: 'claude-sonnet-4-5', content: [{ type: 'tool_use', id: `toolu_spawn_${p}`, name: 'Agent', input: {} }], usage: { input_tokens: 120, output_tokens: 30 } }, + })) + parentLines.push(JSON.stringify({ + type: 'user', sessionId: parent, timestamp: iso(at(day, 12, 1)), cwd, + message: { role: 'user', content: [{ type: 'tool_result', tool_use_id: `toolu_spawn_${p}`, content: 'subagent done' }] }, + toolUseResult: { status: 'completed', agentId: agent, content: 'subagent done' }, + })) + parentLines.push(JSON.stringify({ type: 'pr-link', sessionId: parent, timestamp: iso(at(day, 12, 2)), cwd, prUrl: `https://github.com/acme/repo/pull/${100 + p}` })) + writeLines(join(projectsDir, dirName, `${parent}.jsonl`), parentLines) + files++ + + const side = [] + for (let t = 0; t < 4; t++) { + side.push(JSON.stringify({ type: 'user', isSidechain: true, sessionId: parent, agentId: agent, timestamp: iso(at(day, 12, 3 + t)), cwd, message: { role: 'user', content: `sub task ${t}` } })) + side.push(JSON.stringify({ + type: 'assistant', isSidechain: true, sessionId: parent, agentId: agent, timestamp: iso(at(day, 12, 3 + t, 20)), cwd, + message: { id: `sub-${p}-${t}`, type: 'message', role: 'assistant', model: 'claude-opus-4-8', content: [{ type: 'text', text: 'ok' }], usage: { input_tokens: between(800, 2000), output_tokens: between(100, 400), cache_read_input_tokens: between(0, 5000) } }, + })) + } + writeLines(join(projectsDir, dirName, parent, 'subagents', `agent-${agent}.jsonl`), side) + write(join(projectsDir, dirName, parent, 'subagents', `agent-${agent}.meta.json`), JSON.stringify({ agentType: 'reviewer' })) + files++ + } + return files +} + +// ── codex ──────────────────────────────────────────────────────────────────── +// token_count carries a CUMULATIVE total_token_usage; the parser diffs +// consecutive events, so the running totals below must only ever grow. + +function genCodex() { + const root = join(HOME, '.codex', 'sessions') + let files = 0 + for (let i = 0; i < 24; i++) { + const day = (i * 4) % SPAN_DAYS + const d = new Date(day0 + day * DAY_MS) + const cwd = PROJECTS[i % PROJECTS.length] + const sid = `codex-${String(i).padStart(3, '0')}` + const lines = [JSON.stringify({ type: 'session_meta', timestamp: iso(at(day, 10)), payload: { cwd, originator: 'codex-cli', session_id: sid, model: 'gpt-5.3-codex' } })] + const total = { input_tokens: 0, cached_input_tokens: 0, output_tokens: 0, reasoning_output_tokens: 0, total_tokens: 0 } + for (let t = 0; t < between(3, 9); t++) { + const ts = iso(at(day, 10, t * 5)) + const last = { input_tokens: between(500, 6000), cached_input_tokens: between(0, 2000), output_tokens: between(50, 800), reasoning_output_tokens: between(0, 300), total_tokens: 0 } + last.total_tokens = last.input_tokens + last.output_tokens + for (const k of Object.keys(total)) total[k] += last[k] + lines.push(JSON.stringify({ type: 'event_msg', timestamp: ts, payload: { type: 'task_started' } })) + lines.push(JSON.stringify({ type: 'response_item', timestamp: ts, payload: { type: 'message', role: 'user', content: [{ type: 'input_text', text: `task ${t}` }] } })) + lines.push(JSON.stringify({ type: 'response_item', timestamp: ts, payload: { type: 'function_call', name: 'shell', call_id: `c${t}`, arguments: JSON.stringify({ command: 'ls' }) } })) + lines.push(JSON.stringify({ type: 'response_item', timestamp: ts, payload: { type: 'function_call_output', call_id: `c${t}` } })) + lines.push(JSON.stringify({ type: 'response_item', timestamp: ts, payload: { type: 'message', role: 'assistant', content: [{ type: 'output_text', text: 'done' }] } })) + lines.push(JSON.stringify({ type: 'event_msg', timestamp: ts, payload: { type: 'token_count', info: { last_token_usage: last, total_token_usage: { ...total } } } })) + lines.push(JSON.stringify({ type: 'event_msg', timestamp: ts, payload: { type: 'task_complete', duration_ms: 4000 } })) + } + const dir = join(root, String(d.getUTCFullYear()), String(d.getUTCMonth() + 1).padStart(2, '0'), String(d.getUTCDate()).padStart(2, '0')) + writeLines(join(dir, `rollout-${sid}.jsonl`), lines) + files++ + } + return files +} + +// ── gemini ─────────────────────────────────────────────────────────────────── + +function genGemini() { + const messages = [] + for (let t = 0; t < 12; t++) { + messages.push({ id: `u${t}`, timestamp: iso(at(60, 9, t * 3)), type: 'user', content: `inspect ${t}` }) + messages.push({ + id: `g${t}`, timestamp: iso(at(60, 9, t * 3, 20)), type: 'gemini', content: 'reading files', + model: 'gemini-3.1-pro-preview', + tokens: { input: between(200, 3000), cached: between(0, 1000), output: between(30, 400), thoughts: between(0, 200) }, + toolCalls: [{ id: `t${t}`, name: 'read_file', args: { path: 'src/index.ts' } }], + }) + } + write(join(HOME, '.gemini', 'tmp', 'api-gateway', 'chats', 'session-upgrade-1.json'), + JSON.stringify({ sessionId: 'gemini-session-1', startTime: iso(at(60, 9)), messages })) + return 1 +} + +// ── kiro ───────────────────────────────────────────────────────────────────── + +function genKiro() { + const dir = join(HOME, '.kiro', 'sessions', 'cli') + const id = 'kiro-upgrade-1' + const lines = [] + for (let t = 0; t < 6; t++) { + lines.push(JSON.stringify({ kind: 'Prompt', data: { content: [{ kind: 'text', data: `add feature ${t}` }] } })) + lines.push(JSON.stringify({ kind: 'AssistantMessage', data: { content: [{ kind: 'text', data: `Done — added feature ${t} and its tests.` }] } })) + } + writeLines(join(dir, `${id}.jsonl`), lines) + write(join(dir, `${id}.json`), JSON.stringify({ + session_id: id, cwd: '/work/billing', + created_at: iso(at(70, 10)), updated_at: iso(at(70, 11)), + session_state: { + rts_model_state: { model_info: { model_id: 'auto' } }, + conversation_metadata: { user_turn_metadatas: [{ end_timestamp: iso(at(70, 11)), metering_usage: [] }] }, + }, + })) + return 2 +} + +// ── dsh ────────────────────────────────────────────────────────────────────── +// Written UNCOMPRESSED on purpose: node:zlib gained zstd in 22.15 and the +// package floor is 22.13, so the .zstd variant would silently drop out of the +// floor matrix leg and the two legs would not be comparable. + +function genDsh() { + const cwd = '/work/api-gateway' + const encoded = `--${cwd.replace(/[/\\]/g, '-')}--` + const dir = join(HOME, '.dsh', 'sessions', encoded, 'session-upgrade-0001') + const lines = [ + JSON.stringify({ type: 'session', version: 0, id: 'session-upgrade-0001', createdAt: at(75, 10).getTime(), cwd, delegationDepth: 0, agentPreset: 'cordis' }), + JSON.stringify({ type: 'request/header', seq: 1, time: at(75, 10).getTime(), data: { header: { config: { provider: 'deepseek-official', model: 'deepseek-v3.2', reasoningEffort: 'max', maxTokens: 256000 } } } }), + ] + let seq = 2 + for (let turn = 1; turn <= 8; turn++) { + const base = at(75, 10, turn * 5).getTime() + lines.push(JSON.stringify({ type: 'turn/start', seq: seq++, time: base, data: { turn } })) + lines.push(JSON.stringify({ type: 'user/message', seq: seq++, time: base + 100, data: { content: [{ type: 'text', text: `build ${turn}` }], source: { kind: 'user' }, role: 'user', id: `msg-${turn}` } })) + lines.push(JSON.stringify({ type: 'tool/call', seq: seq++, time: base + 200, data: { turn, step: 1, callId: `call_${turn}`, name: 'bash', arguments: JSON.stringify({ command: 'git status' }) } })) + lines.push(JSON.stringify({ + type: 'assistant/message', seq: seq++, time: base + 900, + data: { turn, step: 1, message: { role: 'assistant', content: [{ type: 'text', text: 'done' }] }, usage: { inputTokens: between(2000, 20000), outputTokens: between(100, 900), cacheReadTokens: between(0, 5000), reasoningTokens: between(0, 600) } }, + })) + } + writeLines(join(dir, 'session.jsonl'), lines) + return 1 +} + +// ── grok ───────────────────────────────────────────────────────────────────── +// Uses the authoritative `turn_completed.usage` records that #1015 switched to. + +function genGrok() { + const cwd = '/work/infra' + const root = join(HOME, '.grok', 'sessions', encodeURIComponent(cwd)) + let files = 0 + for (let i = 0; i < 3; i++) { + const id = `019edf9c-0000-7000-8000-00000000000${i + 1}` + const day = 80 + i + const dir = join(root, id) + write(join(dir, 'summary.json'), JSON.stringify({ + info: { id, cwd }, created_at: iso(at(day, 11)), updated_at: iso(at(day, 12)), last_active_at: iso(at(day, 12)), + num_messages: 12, current_model_id: 'grok-build', session_summary: 'repo work', generated_title: 'repo work', + })) + write(join(dir, 'signals.json'), JSON.stringify({ + primaryModelId: 'grok-build', modelsUsed: ['grok-build'], toolsUsed: ['read_file', 'grep'], + contextTokensUsed: 40000, contextWindowTokens: 512000, + })) + const updates = [] + let running = 0 + for (let t = 0; t < 5; t++) { + // Streamed chunk carrying the running context counter. This is all the + // published CLI can see, and what it estimates from; main ignores it in + // favour of the turn_completed record below. Both are present in a real + // session, so the corpus carries both and the two versions have something + // to disagree about. + running += between(3000, 12000) + updates.push(JSON.stringify({ + timestamp: iso(at(day, 11, t * 5)), method: 'session/update', + params: { sessionId: id, update: { sessionUpdate: 'agent_message_chunk', content: { type: 'text', text: `chunk ${t}` } }, _meta: { totalTokens: running, promptId: `p${t}`, updateType: 'AgentMessageChunk', modelId: 'grok-build' } }, + })) + const usage = { + inputTokens: between(1000, 9000), outputTokens: between(80, 700), totalTokens: 0, + cachedReadTokens: between(0, 40000), cacheCreationTokens: between(0, 2000), reasoningTokens: between(0, 300), + modelCalls: 1, apiDurationMs: 1000, costUsdTicks: 125117780000, numTurns: 1, + } + usage.totalTokens = usage.inputTokens + usage.outputTokens + usage.modelUsage = { 'grok-4.6-build': { ...usage } } + updates.push(JSON.stringify({ + timestamp: Math.floor(at(day, 11, t * 5).getTime() / 1000), method: '_x.ai/session/update', + params: { sessionId: id, update: { sessionUpdate: 'turn_completed', prompt_id: `p${t}`, usage }, _meta: { eventId: `event-${t}`, agentTimestampMs: at(day, 11, t * 5).getTime() } }, + })) + } + writeLines(join(dir, 'updates.jsonl'), updates) + files += 3 + } + return files +} + +// ── cursor (WAL-mode SQLite) ───────────────────────────────────────────────── +// Left with an un-checkpointed -wal sidecar and NO -shm: that is what a live +// Cursor database looks like on disk, and it is the shape that made +// read-only opens fail before #1017. + +function genCursor() { + let DatabaseSync + try { ({ DatabaseSync } = require_('node:sqlite')) } catch { return 0 } + + const userDir = process.platform === 'darwin' + ? join(HOME, 'Library', 'Application Support', 'Cursor', 'User') + : process.platform === 'win32' + ? join(HOME, 'AppData', 'Roaming', 'Cursor', 'User') + : join(HOME, '.config', 'Cursor', 'User') + + const globalDir = join(userDir, 'globalStorage') + mkdirSync(globalDir, { recursive: true }) + const dbPath = join(globalDir, 'state.vscdb') + for (const suffix of ['', '-wal', '-shm']) rmSync(dbPath + suffix, { force: true }) + const db = new DatabaseSync(dbPath) + db.exec('PRAGMA journal_mode=WAL') + db.exec('CREATE TABLE cursorDiskKV (key TEXT PRIMARY KEY, value BLOB)') + db.exec('CREATE TABLE ItemTable (key TEXT UNIQUE, value BLOB)') + const ins = db.prepare('INSERT INTO cursorDiskKV (key, value) VALUES (?, ?)') + + const composers = [] + for (let c = 0; c < 6; c++) { + const composerId = `composer-${c}` + composers.push({ composerId, name: `session-${c}`, unifiedMode: 'agent' }) + ins.run(`composerData:${composerId}`, JSON.stringify({ + promptTokenBreakdown: { totalUsedTokens: between(10000, 90000) }, + createdAt: at(85, 9 + c).getTime(), + })) + for (let b = 0; b < 8; b++) { + const createdAt = iso(at(85, 9 + c, b * 4)) + ins.run(`bubbleId:${composerId}:u${b}`, JSON.stringify({ type: 1, conversationId: composerId, createdAt, text: `ask ${b}`, codeBlocks: '[]' })) + ins.run(`bubbleId:${composerId}:a${b}`, JSON.stringify({ + type: 2, conversationId: composerId, createdAt, text: `reply ${b}`, codeBlocks: '[]', + tokenCount: { inputTokens: between(300, 4000), outputTokens: between(40, 500) }, + modelInfo: { modelName: 'claude-4.6-sonnet' }, + requestId: `req-${c}-${b}`, + })) + } + } + // Checkpoint what is written so far, then stop auto-checkpointing and append + // more: the tail rows live only in the -wal the copy below carries. + db.exec('PRAGMA wal_checkpoint(TRUNCATE)') + db.exec('PRAGMA wal_autocheckpoint=0') + ins.run('composerData:composer-tail', JSON.stringify({ promptTokenBreakdown: { totalUsedTokens: 12345 }, createdAt: at(86, 9).getTime() })) + ins.run('bubbleId:composer-tail:a0', JSON.stringify({ + type: 2, conversationId: 'composer-tail', createdAt: iso(at(86, 9)), text: 'tail reply', codeBlocks: '[]', + tokenCount: { inputTokens: 2222, outputTokens: 333 }, modelInfo: { modelName: 'claude-4.6-sonnet' }, + })) + composers.push({ composerId: 'composer-tail', name: 'session-tail', unifiedMode: 'agent' }) + db.close() + + // Per-workspace DB naming the composers, plus the workspace.json that gives + // the project its name. + const wsDir = join(userDir, 'workspaceStorage', 'ws0000000000000000000000000000000') + mkdirSync(wsDir, { recursive: true }) + for (const suffix of ['', '-wal', '-shm']) rmSync(join(wsDir, 'state.vscdb' + suffix), { force: true }) + const wsDb = new DatabaseSync(join(wsDir, 'state.vscdb')) + wsDb.exec('CREATE TABLE ItemTable (key TEXT UNIQUE, value BLOB)') + wsDb.prepare('INSERT INTO ItemTable (key, value) VALUES (?, ?)').run('composer.composerData', JSON.stringify({ allComposers: composers })) + wsDb.close() + write(join(wsDir, 'workspace.json'), JSON.stringify({ folder: 'file:///work/billing' })) + + return existsSync(dbPath + '-wal') ? 3 : 2 +} + +// ── run ────────────────────────────────────────────────────────────────────── + +const counts = { + claude: genClaude(), + codex: genCodex(), + gemini: genGemini(), + kiro: genKiro(), + dsh: genDsh(), + grok: genGrok(), + cursor: genCursor(), +} +console.log(JSON.stringify(counts)) diff --git a/scripts/upgrade-path/run.mjs b/scripts/upgrade-path/run.mjs new file mode 100644 index 00000000..66f2d687 --- /dev/null +++ b/scripts/upgrade-path/run.mjs @@ -0,0 +1,343 @@ +// Upgrade-path verification: prove that a cache written by the last PUBLISHED +// CLI survives this build's first run, on this platform, with this Node. +// +// npm run verify:upgrade +// +// What it does, in order: +// 1. generates a deterministic multi-provider corpus into an isolated HOME +// (whose path contains a space, because a real Windows HOME usually does) +// 2. installs codeburn@0.9.20 into an isolated global prefix and runs it, +// producing a genuine session-cache.v7 + daily-cache.v17 +// 3. installs THIS build the same way and runs it against the SAME cache dir, +// through the npm bin shim rather than `node dist/cli.js`, so +// dist/parse-worker.js has to resolve from a symlinked entry point +// 4. asserts the migration landed and compares payloads per provider +// 5. serve --stdio smoke, worker determinism, warm-run stability +// +// Env: UPGRADE_PATH_WORK (work dir), UPGRADE_PATH_OLD (published version to +// upgrade from), UPGRADE_PATH_KEEP=1 to leave the work dir behind. + +import { spawnSync, spawn } from 'node:child_process' +import { mkdirSync, rmSync, existsSync, readdirSync, statSync, readFileSync, writeFileSync, copyFileSync } from 'node:fs' +import { join, dirname } from 'node:path' +import { fileURLToPath } from 'node:url' +import { tmpdir } from 'node:os' + +const HERE = dirname(fileURLToPath(import.meta.url)) +const REPO = join(HERE, '..', '..') +const OLD_VERSION = process.env['UPGRADE_PATH_OLD'] || '0.9.20' +const WORK = process.env['UPGRADE_PATH_WORK'] || join(tmpdir(), 'codeburn upgrade path') + +// The published binary's cache versions. If a future baseline writes something +// else these two are the knobs to move, and the assertions below will say so. +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.v19.json' + +const HOME = join(WORK, 'user home') +const PAYLOADS = join(WORK, 'payloads') +const CACHES = join(WORK, 'caches') +const npmCmd = process.platform === 'win32' ? 'npm.cmd' : 'npm' + +let failures = 0 +let skipped = 0 +const step = msg => console.log(`\n=== ${msg}`) +const ok = msg => console.log(` ok ${msg}`) +const fail = msg => { failures++; console.log(` FAIL ${msg}`) } +const skip = msg => { skipped++; console.log(` skip ${msg}`) } +const check = (cond, msg) => (cond ? ok(msg) : fail(msg)) + +function run(cmd, args, opts = {}) { + const r = spawnSync(cmd, args, { encoding: 'utf8', maxBuffer: 1 << 29, shell: false, ...opts }) + if (r.error) throw new Error(`${cmd} ${args.join(' ')}: ${r.error.message}`) + return r +} + +// Node refuses to spawn a .cmd/.bat without a shell, and a shell spawn quotes +// nothing for you — so on Windows anything holding a space (every path here +// does, by design) has to be quoted by hand. +const quoteForShell = s => (process.platform === 'win32' && /[\s&|^]/.test(s) ? `"${s}"` : s) +function runShell(cmd, args, opts = {}) { + if (process.platform !== 'win32') return run(cmd, args, opts) + return run(quoteForShell(cmd), args.map(quoteForShell), { ...opts, shell: true }) +} + +function cliEnv(cacheDir, extra = {}) { + // Deliberately minimal: no provider override vars, so every provider resolves + // its own default path under the isolated HOME. APPDATA/LOCALAPPDATA are + // pinned under it too — several providers read them on Windows, and inheriting + // the runner's would let real (or leftover) data into the comparison. + const passthrough = {} + for (const k of ['PATH', 'PATHEXT', 'SystemRoot', 'ComSpec', 'windir', 'TEMP', 'TMP', 'NUMBER_OF_PROCESSORS']) { + if (process.env[k] !== undefined) passthrough[k] = process.env[k] + } + return { + ...passthrough, + HOME, USERPROFILE: HOME, TZ: 'UTC', CODEBURN_CACHE_DIR: cacheDir, + APPDATA: join(HOME, 'AppData', 'Roaming'), LOCALAPPDATA: join(HOME, 'AppData', 'Local'), + ...extra, + } +} + +function cli(bin, args, cacheDir, extra = {}) { + const r = run(bin.cmd, [...bin.args, ...args], { env: cliEnv(cacheDir, extra), cwd: WORK }) + if (r.status !== 0) throw new Error(`${args.join(' ')} exited ${r.status}\n${r.stderr?.slice(0, 4000)}`) + return r.stdout +} + +// Install into an isolated global prefix, so the CLI runs from a location that +// has nothing to do with this checkout — which is what makes dist/parse-worker.js +// resolution worth testing. On POSIX npm's bin is a symlink INTO the package and +// we drive that directly. On Windows it is a .cmd shim, which Node will not +// spawn without a shell; the payload captures go through the installed +// dist/cli.js there and the shim itself is smoke-tested once, separately. +function installGlobal(prefix, spec) { + const r = runShell(npmCmd, ['install', '-g', '--prefix', prefix, spec, '--no-audit', '--no-fund', '--loglevel', 'error'], { cwd: WORK }) + if (r.status !== 0) throw new Error(`npm install -g ${spec} exited ${r.status}\n${r.stdout}\n${r.stderr}`) + const symlink = join(prefix, 'bin', 'codeburn') + if (existsSync(symlink)) return { cmd: process.execPath, args: [symlink], shim: null } + const winCmd = join(prefix, 'codeburn.cmd') + const entry = join(prefix, 'node_modules', 'codeburn', 'dist', 'cli.js') + if (existsSync(entry)) return { cmd: process.execPath, args: [entry], shim: existsSync(winCmd) ? winCmd : null } + throw new Error(`no codeburn bin under ${prefix}`) +} + +// The npm shim, exercised once. Needs a shell on Windows, so nothing with a +// space in it is passed through here. +function checkShim(bin, cacheDir) { + if (!bin.shim) { ok("CLI invoked through npm's bin symlink"); return } + const r = runShell(bin.shim, ['--version'], { env: cliEnv(cacheDir), cwd: WORK }) + check(r.status === 0 && r.stdout.trim().length > 0, `npm .cmd shim runs: ${r.stdout.trim() || r.stderr?.slice(0, 200)}`) +} + +// Captured payloads. `export` is the token/call source, `menubar-json` the +// unrounded per-provider cost; both are stable given a fixed corpus. --period all +// so the whole three-month corpus is in scope on both sides. +function capture(bin, cacheDir, outDir, extra = {}) { + mkdirSync(outDir, { recursive: true }) + cli(bin, ['export', '--format', 'json', '--from', '2000-01-01', '--to', '2999-12-31', '-o', join(outDir, 'export.json')], cacheDir, extra) + const menubar = cli(bin, ['status', '--format', 'menubar-json', '--period', 'all', '--no-optimize', '--no-timeline'], cacheDir, extra) + writeFileSync(join(outDir, 'menubar.json'), menubar) + const status = cli(bin, ['status', '--format', 'json', '--period', 'all'], cacheDir, extra) + writeFileSync(join(outDir, 'status.json'), status) + return { menubar: JSON.parse(menubar), status: JSON.parse(status) } +} + +// The one field that moves between two runs of the same payload. +const stripGenerated = obj => JSON.parse(JSON.stringify(obj, (k, v) => (k.startsWith('generated') ? undefined : v))) + +// Shards carry real stat data and are published under a random filename, so +// "identical" means identical after normalizing both away. +function shardSnapshot(cacheDir) { + const dir = join(cacheDir, NEW_SESSION_CACHE_DIR) + if (!existsSync(dir)) return null + const out = {} + for (const name of readdirSync(dir).sort()) { + if (name === 'envelope.json') continue + const body = JSON.parse(readFileSync(join(dir, name), 'utf8')) + for (const entry of Object.values(body)) { + if (entry && typeof entry === 'object' && entry.fingerprint) { + delete entry.fingerprint.dev + delete entry.fingerprint.ino + delete entry.fingerprint.mtimeMs + } + } + // Key the bucket off the shard's provider.month prefix, dropping the nonce. + out[name.replace(/\.[0-9a-f]{16}\.json$/, '')] = sortDeep(body) + } + return out +} + +function sortDeep(v) { + if (Array.isArray(v)) return v.map(sortDeep) + if (v && typeof v === 'object') return Object.fromEntries(Object.keys(v).sort().map(k => [k, sortDeep(v[k])])) + return v +} + +function shardMtimes(cacheDir) { + const dir = join(cacheDir, NEW_SESSION_CACHE_DIR) + return Object.fromEntries(readdirSync(dir).sort().map(n => [n, statSync(join(dir, n)).mtimeMs])) +} + +// ── 1. corpus ──────────────────────────────────────────────────────────────── + +step(`work dir: ${WORK}`) +try { rmSync(WORK, { recursive: true, force: true }) } catch (err) { console.log(` note could not clear the work dir (${err.code}); reusing it`) } +mkdirSync(HOME, { recursive: true }) +mkdirSync(PAYLOADS, { recursive: true }) + +const gen = run(process.execPath, [join(HERE, 'gen-corpus.mjs'), HOME]) +if (gen.status !== 0) { console.log(gen.stderr); process.exit(1) } +ok(`corpus generated: ${gen.stdout.trim()}`) + +const upgradeCache = join(CACHES, 'upgrade') +mkdirSync(upgradeCache, { recursive: true }) + +// ── 2. baseline: the last published CLI ────────────────────────────────────── + +step(`baseline: codeburn@${OLD_VERSION}`) +const oldBin = installGlobal(join(WORK, 'old'), `codeburn@${OLD_VERSION}`) +const oldVersion = cli(oldBin, ['--version'], upgradeCache).trim() +check(oldVersion === OLD_VERSION, `installed baseline reports ${oldVersion}`) + +const baseline = capture(oldBin, upgradeCache, join(PAYLOADS, 'baseline')) +check(baseline.menubar.current.calls > 0, `baseline counted ${baseline.menubar.current.calls} calls across ${baseline.menubar.current.sessions} sessions`) +check(existsSync(join(upgradeCache, OLD_SESSION_CACHE)), `${OLD_VERSION} wrote ${OLD_SESSION_CACHE}`) +check(existsSync(join(upgradeCache, OLD_DAILY_CACHE)), `${OLD_VERSION} wrote ${OLD_DAILY_CACHE}`) + +// ── 3. upgrade: this build, same cache dir, through the npm bin shim ───────── + +step('upgrade: this build against the same cache dir') +const pack = runShell(npmCmd, ['pack', '--ignore-scripts', '--pack-destination', WORK, '--loglevel', 'error'], { cwd: REPO }) +if (pack.status !== 0) { console.log(pack.stdout, pack.stderr); process.exit(1) } +const tarball = join(WORK, pack.stdout.trim().split('\n').pop().trim()) +const newBin = installGlobal(join(WORK, 'new'), tarball) +ok(`this build installed as ${newBin.args[0] ?? newBin.cmd}`) +checkShim(newBin, upgradeCache) + +const upgraded = capture(newBin, upgradeCache, join(PAYLOADS, 'upgraded')) + +check(!existsSync(join(upgradeCache, OLD_SESSION_CACHE)), `${OLD_SESSION_CACHE} removed after the re-layout`) +check(existsSync(join(upgradeCache, NEW_SESSION_CACHE_DIR)), `${NEW_SESSION_CACHE_DIR}/ present`) +check(existsSync(join(upgradeCache, NEW_SESSION_CACHE_DIR, 'envelope.json')), `${NEW_SESSION_CACHE_DIR}/envelope.json present`) +const envelope = JSON.parse(readFileSync(join(upgradeCache, NEW_SESSION_CACHE_DIR, 'envelope.json'), 'utf8')) +check(envelope.version === 9 && Object.keys(envelope.providers ?? {}).length > 0, + `envelope at version ${envelope.version} with ${Object.keys(envelope.providers ?? {}).length} providers`) +check(readdirSync(join(upgradeCache, NEW_SESSION_CACHE_DIR)).some(n => n !== 'envelope.json'), 'shards published alongside the envelope') +check(existsSync(join(upgradeCache, NEW_DAILY_CACHE)), `${NEW_DAILY_CACHE} re-derived`) +check(existsSync(join(upgradeCache, OLD_DAILY_CACHE)), `${OLD_DAILY_CACHE} kept as the carry-forward baseline`) +const oldDays = JSON.parse(readFileSync(join(upgradeCache, OLD_DAILY_CACHE), 'utf8')).days.length +const newDays = JSON.parse(readFileSync(join(upgradeCache, NEW_DAILY_CACHE), 'utf8')).days.length +check(newDays >= oldDays, `daily history did not shrink: ${oldDays} -> ${newDays} days`) + +// ── 4. payload parity ──────────────────────────────────────────────────────── + +step('payload parity vs the baseline') +const cmp = run(process.execPath, [join(HERE, 'compare.mjs'), join(PAYLOADS, 'baseline'), join(PAYLOADS, 'upgraded')], { stdio: 'inherit' }) +if (cmp.status !== 0) failures++ + +// ── 5. serve smoke ─────────────────────────────────────────────────────────── + +step('serve --stdio') +const serveFrames = await serveSmoke() +if (serveFrames) { + for (const [name, args] of [['menubar-json', ['status', '--format', 'menubar-json', '--period', 'all', '--no-optimize', '--no-timeline']], ['models', ['models', '--format', 'json']]]) { + const frame = serveFrames.get(name) + if (!frame?.ok) { fail(`serve returned no ok frame for ${name}: ${JSON.stringify(frame)}`); continue } + ok(`serve ok frame for ${name}`) + const oneShot = cli(newBin, args, upgradeCache) + const a = JSON.stringify(stripGenerated(JSON.parse(frame.output))) + const b = JSON.stringify(stripGenerated(JSON.parse(oneShot))) + check(a === b, `serve ${name} matches the one-shot payload (ignoring generated*)`) + } +} + +async function serveSmoke() { + const child = spawn(newBin.cmd, [...newBin.args, 'serve', '--stdio'], { env: cliEnv(upgradeCache), cwd: WORK, stdio: ['pipe', 'pipe', 'pipe'] }) + const frames = new Map() + let buf = '' + let ready = false + const done = new Promise(resolve => { + child.stdout.on('data', d => { + buf += d + let nl + while ((nl = buf.indexOf('\n')) >= 0) { + const line = buf.slice(0, nl); buf = buf.slice(nl + 1) + if (!line.trim()) continue + let msg + try { msg = JSON.parse(line) } catch { continue } + if (msg.ready) { ready = true; continue } + if (msg.progress !== undefined) continue + if (msg.id === 1) frames.set('menubar-json', msg) + if (msg.id === 2) frames.set('models', msg) + if (frames.size === 2) resolve() + } + }) + }) + child.stdin.write(JSON.stringify({ id: 1, args: ['status', '--format', 'menubar-json', '--period', 'all', '--no-optimize', '--no-timeline'] }) + '\n') + child.stdin.write(JSON.stringify({ id: 2, args: ['models', '--format', 'json'] }) + '\n') + const timeout = new Promise(r => setTimeout(() => r('timeout'), 240_000)) + if ((await Promise.race([done, timeout])) === 'timeout') { child.kill(); fail('serve did not answer both requests within 240s'); return null } + check(ready, 'serve announced itself with a ready frame') + + // Closing stdin is the documented shutdown: the child must exit on its own. + const exited = new Promise(r => child.once('exit', code => r(code))) + child.stdin.end() + const exitCode = await Promise.race([exited, new Promise(r => setTimeout(() => r('hung'), 30_000))]) + if (exitCode === 'hung') { child.kill(); fail('serve did not exit when stdin closed') } + else ok(`serve exited on stdin close (code ${exitCode})`) + return frames +} + +// ── 6. worker determinism ──────────────────────────────────────────────────── + +step('parse-worker determinism (CODEBURN_PARSE_WORKERS 0 vs 3)') +const serialCache = join(CACHES, 'workers-0') +const parallelCache = join(CACHES, 'workers-3') +for (const dir of [serialCache, parallelCache]) { + mkdirSync(dir, { recursive: true }) + // Seed the shared price table so the two runs cannot be priced differently by + // a cache expiring between them. Parsing is unaffected either way. + const priced = join(upgradeCache, 'litellm-pricing.json') + if (existsSync(priced)) copyFileSync(priced, join(dir, 'litellm-pricing.json')) +} +const serialOut = capture(newBin, serialCache, join(PAYLOADS, 'workers-0'), { CODEBURN_PARSE_WORKERS: '0', CODEBURN_VERBOSE: '1' }) +const parallelOut = capture(newBin, parallelCache, join(PAYLOADS, 'workers-3'), { CODEBURN_PARSE_WORKERS: '3', CODEBURN_VERBOSE: '1' }) +check(JSON.stringify(stripGenerated(serialOut.menubar)) === JSON.stringify(stripGenerated(parallelOut.menubar)), + 'menubar-json payload identical with and without workers') +const readExport = dir => stripGenerated(JSON.parse(readFileSync(join(PAYLOADS, dir, 'export.json'), 'utf8'))) +check(JSON.stringify(readExport('workers-0')) === JSON.stringify(readExport('workers-3')), + 'export payload identical with and without workers') +check(JSON.stringify(shardSnapshot(serialCache)) === JSON.stringify(shardSnapshot(parallelCache)), + 'shard bodies identical with and without workers (fingerprint stat data and shard nonces normalized)') + +// A forced pool that never actually spawned would make the check above vacuous. +const verbose = run(newBin.cmd, [...newBin.args, 'status', '--format', 'json', '--period', 'all'], { + env: cliEnv(join(CACHES, 'workers-probe'), { CODEBURN_PARSE_WORKERS: '3', CODEBURN_VERBOSE: '1' }), cwd: WORK, +}) +const decision = (verbose.stderr || '').split('\n').filter(l => l.includes('parse workers=')) +if (decision.length === 0) skip('no "parse workers=" line on stderr; cannot confirm the pool was forced') +else check(decision.some(l => /parse workers=[1-9]/.test(l)), `worker pool engaged: ${decision.map(l => l.trim()).join(' | ')}`) + +// ── 7. second run is warm ──────────────────────────────────────────────────── + +step('second run is warm') +const beforeBodies = shardSnapshot(upgradeCache) +const beforeMtimes = shardMtimes(upgradeCache) +const warm = capture(newBin, upgradeCache, join(PAYLOADS, 'warm')) + +// The direct no-re-parse signal: the worker gate prints how many whole-file +// re-parses are pending. On an unchanged corpus that must be zero for the two +// providers big enough to be gated. +const warmVerbose = run(newBin.cmd, [...newBin.args, 'status', '--format', 'menubar-json', '--period', 'all', '--no-optimize', '--no-timeline'], { + env: cliEnv(upgradeCache, { CODEBURN_VERBOSE: '1' }), cwd: WORK, +}) +const pending = (warmVerbose.stderr || '').split('\n').filter(l => l.includes('parse workers=')) +if (pending.length === 0) skip('no "parse workers=" line on a warm run; cannot confirm nothing re-parsed') +else check(pending.every(l => /0 pending files|no full parses pending/.test(l)), + `nothing re-parsed on the warm run: ${pending.map(l => l.replace(/^codeburn: /, '').trim()).join(' | ')}`) + +check(JSON.stringify(shardSnapshot(upgradeCache)) === JSON.stringify(beforeBodies), 'warm run left every shard body unchanged') +check(JSON.stringify(stripGenerated(warm.menubar)) === JSON.stringify(stripGenerated(upgraded.menubar)), + 'warm run reports the same payload as the run that migrated the cache') + +// Republication without a content change is wasted I/O, not a correctness +// problem, so it is reported rather than failed. It is real: a date-RANGED +// query (`status --format json`, the statusline/menubar fast path) currently +// republishes the month shards its range skipped, on every run, even when +// nothing changed — the identical-bodies check above is what proves the +// content survives it. +const afterMtimes = shardMtimes(upgradeCache) +const republished = Object.keys(afterMtimes).filter(n => n !== 'envelope.json' && beforeMtimes[n] !== afterMtimes[n]) +const retired = Object.keys(beforeMtimes).filter(n => n !== 'envelope.json' && !(n in afterMtimes)) +if (retired.length) console.log(` note ${retired.length} shard(s) republished under a new name with identical content: ${retired.join(', ')}`) +else if (republished.length) console.log(` note ${republished.length} shard(s) rewritten in place: ${republished.join(', ')}`) +else ok('no shard republished on an unchanged corpus') + +// ── done ───────────────────────────────────────────────────────────────────── + +console.log(`\n${failures ? `FAILED: ${failures} check(s)` : 'PASSED'}${skipped ? ` (${skipped} skipped)` : ''}`) +if (failures && process.env['UPGRADE_PATH_KEEP'] !== '0') console.log(`payloads left in: ${PAYLOADS}`) +else if (process.env['UPGRADE_PATH_KEEP'] !== '1') { try { rmSync(WORK, { recursive: true, force: true }) } catch { /* windows file locks */ } } +process.exit(failures ? 1 : 0) From b2e59c3a2f740248ec2376113498625d6d6cf14c Mon Sep 17 00:00:00 2001 From: iamtoruk Date: Tue, 18 Aug 2026 12:19:45 -0700 Subject: [PATCH 67/85] ci: fail the upgrade path when a partially aged-out day loses its slice Claude Code deletes its transcripts after ~30 days, so between one run and the next a day can go from fully sourced to PARTIALLY sourced. On such a day the daily cache re-derives a smaller slice from the surviving files and that slice REPLACES the baseline one rather than being unioned with it, so the aged-out portion is lost. A day that aged out completely is carried forward correctly, which is what makes this a hole in never-lose rather than a missing feature. The check ages the corpus the way retention does: two days keep a single anchoring transcript each, a third loses every one. It then compares the (date, provider) slices in daily-cache.v19.json against the ones the 0.9.20 baseline recorded in daily-cache.v17.json, requiring the partial days never to shrink and the fully sourceless day to come back exactly. This is a hard failure, not a note. It is expected to be red until the fix lands: on the generated corpus the two partial days currently lose 76.6% and 56.8% of their cost, while the fully sourceless control day returns to the cent. It runs last and on its own cache dir, so mutating the corpus cannot disturb the payload parity comparison. The shard-republication defect found earlier stays a note, now naming its pending follow-up issue. --- scripts/upgrade-path/run.mjs | 93 +++++++++++++++++++++++++++++++++--- 1 file changed, 86 insertions(+), 7 deletions(-) diff --git a/scripts/upgrade-path/run.mjs b/scripts/upgrade-path/run.mjs index 66f2d687..05878e1f 100644 --- a/scripts/upgrade-path/run.mjs +++ b/scripts/upgrade-path/run.mjs @@ -323,18 +323,97 @@ check(JSON.stringify(stripGenerated(warm.menubar)) === JSON.stringify(stripGener 'warm run reports the same payload as the run that migrated the cache') // Republication without a content change is wasted I/O, not a correctness -// problem, so it is reported rather than failed. It is real: a date-RANGED -// query (`status --format json`, the statusline/menubar fast path) currently -// republishes the month shards its range skipped, on every run, even when -// nothing changed — the identical-bodies check above is what proves the -// content survives it. +// problem, so it is reported rather than failed. It is real, and it has a +// follow-up issue: a date-RANGED query (`status --format json`, the +// statusline/menubar fast path) republishes the month shards its range skipped, +// on every run, even when nothing changed — partially defeating #1007's +// "a warm launch rewrites only the month that changed". The identical-bodies +// check above is what proves the content survives it. const afterMtimes = shardMtimes(upgradeCache) const republished = Object.keys(afterMtimes).filter(n => n !== 'envelope.json' && beforeMtimes[n] !== afterMtimes[n]) const retired = Object.keys(beforeMtimes).filter(n => n !== 'envelope.json' && !(n in afterMtimes)) -if (retired.length) console.log(` note ${retired.length} shard(s) republished under a new name with identical content: ${retired.join(', ')}`) -else if (republished.length) console.log(` note ${republished.length} shard(s) rewritten in place: ${republished.join(', ')}`) +const churnNote = 'known defect, follow-up issue pending: a date-ranged run republishes the month shards it skipped' +if (retired.length) console.log(` note ${retired.length} shard(s) republished under a new name with identical content (${churnNote}): ${retired.join(', ')}`) +else if (republished.length) console.log(` note ${republished.length} shard(s) rewritten in place (${churnNote}): ${republished.join(', ')}`) else ok('no shard republished on an unchanged corpus') +// ── 8. partial source aging (release blocker, track C) ─────────────────────── +// Claude Code deletes its transcripts after ~30 days, so between one run and the +// next a day can go from fully sourced to PARTIALLY sourced. The daily cache's +// never-lose contract says a schema bump re-derives what it can and carries +// forward what it cannot — but on a partially-sourced day the re-derivation +// produces a smaller slice from the surviving files and that slice REPLACES the +// baseline one instead of being unioned with it, so the aged-out portion is lost. +// A day that aged out completely is carried forward correctly, which is what +// makes the partial case a hole rather than a missing feature. +// +// Runs last, and on its own cache dir, so mutating the corpus cannot disturb the +// parity comparison above. + +step('never-lose across partial source aging') +const agingCache = join(CACHES, 'aging') +mkdirSync(agingCache, { recursive: true }) + +// A fresh 0.9.20 cache, taken while every transcript still exists. +capture(oldBin, agingCache, join(PAYLOADS, 'aging-baseline')) +const baseDaily = JSON.parse(readFileSync(join(agingCache, OLD_DAILY_CACHE), 'utf8')) +const sliceOf = (cache, date) => cache.days.find(d => d.date === date)?.providers?.claude + +// Group transcripts by the day their turns land on. Sidechain files are left out: +// deleting a parent's subagent transcript entangles this with spawn-link +// carry-forward, which is a different contract. +const projectsDir = join(HOME, '.claude', 'projects') +const byDay = new Map() +for (const rel of readdirSync(projectsDir, { recursive: true })) { + const relPath = String(rel) + if (!relPath.endsWith('.jsonl') || relPath.includes('subagents')) continue + const full = join(projectsDir, relPath) + const first = readFileSync(full, 'utf8').split('\n', 1)[0] + const date = JSON.parse(first).timestamp?.slice(0, 10) + if (!date || !sliceOf(baseDaily, date)) continue + if (!byDay.has(date)) byDay.set(date, []) + byDay.get(date).push(full) +} + +// Densest days first, oldest among equals — the ones a retention window reaches +// first, and the ones where losing the aged-out portion shows up largest. +const candidates = [...byDay.entries()].filter(([, files]) => files.length >= 2) + .sort((a, b) => (b[1].length - a[1].length) || a[0].localeCompare(b[0])) + +let aged = [] +if (candidates.length < 3) fail(`need 3 multi-transcript claude days to age out, found ${candidates.length}`) +else { + for (const [date, files] of candidates.slice(0, 2)) { + // Keep exactly one file, so the day is still sourced — just not fully. + for (const f of files.slice(1)) rmSync(f) + aged.push({ date, kind: 'partially sourceless', kept: 1, removed: files.length - 1 }) + } + const [goneDate, goneFiles] = candidates[2] + for (const f of goneFiles) rmSync(f) + aged.push({ date: goneDate, kind: 'fully sourceless', kept: 0, removed: goneFiles.length }) + for (const a of aged) ok(`${a.date}: ${a.kind} (removed ${a.removed} of ${a.removed + a.kept} transcripts)`) + + capture(newBin, agingCache, join(PAYLOADS, 'aging-upgraded')) + const upDaily = JSON.parse(readFileSync(join(agingCache, NEW_DAILY_CACHE), 'utf8')) + const usd = n => `$${n.toFixed(6)}` + + for (const a of aged) { + const b = sliceOf(baseDaily, a.date) + const u = sliceOf(upDaily, a.date) + if (!u) { fail(`${a.date} (${a.kind}): the claude slice is gone entirely; baseline had ${usd(b.cost)} over ${b.calls} calls`); continue } + // A fully sourceless day has nothing to re-derive, so it must come back + // EXACTLY. A partially sourceless one may legitimately grow (a re-parse + // under new accounting), but must never shrink. + const exact = a.kind === 'fully sourceless' + const costOk = exact ? Math.abs(u.cost - b.cost) < 1e-9 : u.cost >= b.cost - 1e-9 + const callsOk = exact ? u.calls === b.calls : u.calls >= b.calls + const loss = costOk && callsOk ? '' : + ` — LOST ${usd(b.cost - u.cost)} (${(100 * (b.cost - u.cost) / b.cost).toFixed(1)}%) and ${b.calls - u.calls} calls` + check(costOk && callsOk, + `${a.date} (${a.kind}): cost ${usd(b.cost)} -> ${usd(u.cost)}, calls ${b.calls} -> ${u.calls}${loss}`) + } +} + // ── done ───────────────────────────────────────────────────────────────────── console.log(`\n${failures ? `FAILED: ${failures} check(s)` : 'PASSED'}${skipped ? ` (${skipped} skipped)` : ''}`) From 34726fb63c6b005d13a442e5edce10bd36ef00b5 Mon Sep 17 00:00:00 2001 From: iamtoruk Date: Tue, 18 Aug 2026 12:20:34 -0700 Subject: [PATCH 68/85] ci(upgrade-path): reference #1032 in the shard-republish note --- scripts/upgrade-path/run.mjs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/upgrade-path/run.mjs b/scripts/upgrade-path/run.mjs index 05878e1f..3bd66395 100644 --- a/scripts/upgrade-path/run.mjs +++ b/scripts/upgrade-path/run.mjs @@ -332,7 +332,7 @@ check(JSON.stringify(stripGenerated(warm.menubar)) === JSON.stringify(stripGener const afterMtimes = shardMtimes(upgradeCache) const republished = Object.keys(afterMtimes).filter(n => n !== 'envelope.json' && beforeMtimes[n] !== afterMtimes[n]) const retired = Object.keys(beforeMtimes).filter(n => n !== 'envelope.json' && !(n in afterMtimes)) -const churnNote = 'known defect, follow-up issue pending: a date-ranged run republishes the month shards it skipped' +const churnNote = 'known defect, see #1032: a date-ranged run republishes the month shards it skipped' if (retired.length) console.log(` note ${retired.length} shard(s) republished under a new name with identical content (${churnNote}): ${retired.join(', ')}`) else if (republished.length) console.log(` note ${republished.length} shard(s) rewritten in place (${churnNote}): ${republished.join(', ')}`) else ok('no shard republished on an unchanged corpus') From e9d922ca2dc7402e7381cdb8cae1c4ebda2f1339 Mon Sep 17 00:00:00 2001 From: iamtoruk Date: Tue, 18 Aug 2026 12:23:29 -0700 Subject: [PATCH 69/85] fix(daily-cache): keep history for days whose transcripts partly aged out The never-lose contract carried a (day, provider) slice forward only when the re-derivation found nothing for it. Transcripts expire per file, so a mostly-aged-out day still gets a few turns from surviving later files: the fresh slice came back non-empty but truncated and replaced the full cached one (a real cache lost $2,765.75 / 19,209 calls / 520 sessions over 13 days on the 17 -> 19 upgrade). A fresh slice now replaces a settled baseline slice only when it carries at least as many calls. Comparison is on calls alone - cost and tokens are re-priced accounting on the same evidence, and session counts drift down on healthy days. Days inside a 7-day settle window stay authoritative. The tz-change re-derive gets the exact form of the rule: the subtraction residual is added on top of a data-carrying fresh slice instead of being dropped. The cross-file adoption union is unchanged. --- CHANGELOG.md | 1 + src/daily-cache.ts | 81 ++++++++++++++++++-- tests/daily-cache-carry-forward.test.ts | 99 +++++++++++++++++++++++++ 3 files changed, 175 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3f623e3f..f30151c0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -31,6 +31,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 +- **An upgrade no longer loses history for days whose transcripts have only PARTLY aged out.** The never-lose contract carried a cached (day, provider) slice forward only when the re-derivation found NOTHING for it, but transcripts expire per FILE rather than per day: on a day whose sources are mostly gone, a handful of turns from surviving later files still bucket onto it, so the fresh slice came back non-empty but truncated and REPLACED the full cached one. On a real cache upgrading from the last shipped daily-cache version, 2026-07-16 fell from $1,685.17 / 12,530 calls to $385.44 / 560 calls, and 13 days lost $2,765.75, 19,209 calls and 520 sessions in total. A fresh slice now replaces a settled baseline slice only when it carries at least as many CALLS - the same or more evidence; fewer calls means the source set demonstrably lost data, and the baseline is kept whole. The comparison is on calls alone: cost and tokens are re-priced accounting on the same evidence, which is exactly what a legitimate re-derivation changes (the Grok accounting fix keeps its per-day calls and is unaffected), and session counts drift down by a few on days whose sources are entirely intact. Days inside a 7-day settle window stay authoritative - their session files are still on disk, so a shrink there is a real change rather than expiry. The trade-off is deliberate and matches the direction this cache has always chosen: a future fix that legitimately REDUCES calls on a settled day keeps the older, higher value until that day is re-derived at an equal or greater call count. The timezone-change re-derive gets the exact form of the same rule - what the fresh parse can no longer explain under the old bucketing is added on top of the fresh slice instead of being dropped - and the cross-file adoption union is unchanged, where the newer schema still wins per (day, provider). - **The session chart legend now leads with a visible session disambiguator and title instead of the project path.** Every series in a monorepo shared the same project prefix, so the only thing separating them was a truncated hex fragment — and per-application cost attribution is the main reason to open that chart. `SessionSummary.title` is already parsed and already rendered in the Context tab; the legend now puts the short session id first, prefers the title, and falls back to the previous project-based label when a session never produced one. Titles come from transcripts, so they are stripped of ANSI and control characters and capped before they reach either the legend or the tooltip. (#997) - **Context-bloat detection now counts reasoning tokens as generated output.** `detectContextBloat` divided context by `totalOutputTokens` alone, but reasoning is stored beside output rather than inside it, so for every reasoning-bearing provider the detector saw a fraction of the tokens actually generated and invented findings - a session whose real ratio was 20:1, under the 25:1 threshold, was reported as 133:1 and "high impact". It now uses the same `output + reasoning` sum the reports use, which corrects grok, codex, kiro, hermes, qwen and cursor-agent alike. - **The unpriced-models warning in the dashboard is now readable at every terminal width.** It lived in a fixed-width panel with an inline model list and a fix command, so it clipped mid-name at 80 columns and clipped *earlier* at 200, where the three-column layout narrows each panel - neither the affected models nor a runnable command survived. The panel line is now a pointer, `! N unpriced: codeburn models --unpriced` (shortened to `! N: codeburn models --unpriced` below 45 columns of panel), and the model list moves to that command's plain output, which is full width, copyable, and lists every model rather than the first two. The command's hint no longer reads as an unconditional instruction to alias: a subscription or flat-rate model is correctly $0, and mapping it onto another model's per-token rate would invent spend that was never billed. Provider-supplied model IDs are now stripped of terminal control characters in every human-readable report rather than only on the unpriced path, and `--unpriced` shows raw IDs instead of friendly names because `model-alias` keys on the raw ID. (#969) diff --git a/src/daily-cache.ts b/src/daily-cache.ts index 91dc9b10..1eb19ce6 100644 --- a/src/daily-cache.ts +++ b/src/daily-cache.ts @@ -15,7 +15,11 @@ import type { DateRange, ProjectSummary } from './types.js' // not just Grok. That pass reads the warm session cache (CACHE_VERSION is // unchanged and only PROVIDER_PARSE_VERSIONS.grok moved), so it costs seconds // rather than a full re-parse, and adoptOlderDailyCaches keeps the superseded -// file as the baseline for days no source can still re-derive. +// file as the baseline for days no source can still re-derive. Days whose +// sources only PARTLY survive are held by the partial-survival guard in +// mergeDayEntries, which is what makes a global re-derive safe to force: on a +// real 108-day cache the 17 -> 19 pass moves Grok cost and tokens and nothing +// else - the day-by-day call counts come back identical. // // The shipped predecessor is v17; v18 was an unreleased draft of this change // and only exists in pre-release checkouts. 19 clears both. @@ -856,6 +860,52 @@ function hasPositiveDayContent(day: DailyEntry): boolean { return false } +/// PARTIAL SURVIVAL (the v14 never-lose contract, extended past all-or-nothing). +/// v14 protected a (date, provider) slice only when the fresh derivation found +/// NOTHING there. But transcripts age out per FILE, not per day: Claude Code +/// deletes them after ~30 days, and turn-anchored bucketing means a handful of +/// turns from surviving later files still land on a mostly-aged-out day. The +/// fresh slice is then non-empty but truncated, and replacing the baseline with +/// it silently deletes the rest (measured on a real cache upgrading 17 -> 19: +/// 2026-07-16 fell from $1,685.17 / 12,530 calls to $385.44 / 560 calls). +/// +/// So a fresh slice replaces a settled baseline slice only when it carries at +/// least as many CALLS — the same or more evidence. Fewer calls means the +/// source set demonstrably lost data, and the baseline is kept whole. +/// +/// Why calls and not sessions: session counts shrink routinely on days whose +/// sources are entirely intact (a session's turns re-attribute to a neighbouring +/// day), measured at 1-5 sessions on recent days whose call counts were +/// identical across the re-derivation. A sessions test would freeze stale slices +/// on healthy days. Why not cost/tokens: those are re-priced accounting on the +/// same evidence — exactly what a legitimate re-derivation changes (#1015 Grok +/// keeps its per-day calls and raises cost, and is unaffected by this guard). +/// +/// Why no source-set test instead: a day entry records no source files, counts +/// or fingerprints, and the session cache is keyed by file rather than by day, +/// so "were this day's sources all present?" cannot be answered from the cache. +/// The calls comparison is the available proxy. +/// +/// TRADE-OFF: a future fix that legitimately REDUCES calls on a settled day +/// (deduplication) is blocked, and that day keeps the older, higher value until +/// its slice is re-derived under an equal-or-greater call count. That is the +/// "estimate high, never lose" direction v14 already chose over silent loss. +/// +/// Recent days stay authoritative: within the settle window their session files +/// are still on disk, so a shrink there is a real change (the user deleted a +/// transcript), not aged-out sources. Seven days is far inside the ~30-day +/// retention floor of the shortest-lived source we know of, so any shrink older +/// than that is source loss with overwhelming likelihood. +const SETTLE_DAYS = 7 + +function settleCutoffDate(now: Date): string { + return toDateString(new Date(now.getFullYear(), now.getMonth(), now.getDate() - SETTLE_DAYS)) +} + +function isPartialSurvival(date: string, baseline: ProviderDaySlice, fresh: ProviderDaySlice, settleCutoff: string): boolean { + return date < settleCutoff && fresh.calls < baseline.calls +} + /// Index `freshUnderOldTz` (the same parse re-aggregated under the cache's OLD /// tzKey) by date then provider, so the merge can subtract exactly what the /// fresh parse still explains under the old bucketing. @@ -886,21 +936,34 @@ function buildTzSubtraction(days: DailyEntry[]): ReadonlyMap>, + /// Set ONLY by the complete-parse re-derive, where `primary` is a fresh + /// derivation from live sources and `secondary` is the cache baseline: a + /// primary slice with fewer calls there means sources aged out, so the + /// baseline wins on settled days (`isPartialSurvival`). The adoption union + /// leaves it off - both sides are cache generations there and the newer + /// schema deliberately wins per (date, provider). + guardPartialSurvival = false, ): DailyEntry[] { const byDate = new Map() + const settleCutoff = settleCutoffDate(new Date()) for (const day of primary) byDate.set(day.date, structuredClone(day)) for (const day of secondary) { const existing = byDate.get(day.date) @@ -927,7 +990,6 @@ export function mergeDayEntries( // day) still carry a real session count — worth preserving. if (!hasSliceData(slice) && !(slice.sessions ?? 0)) continue const existingSlice = Object.hasOwn(existing.providers, provider) ? existing.providers[provider] : undefined - if (existingSlice && hasSliceData(existingSlice)) continue let toAdd = slice let residual = false if (subtract) { @@ -943,6 +1005,13 @@ export function mergeDayEntries( residual = true } } + if (existingSlice && hasSliceData(existingSlice) && !residual) { + if (!guardPartialSurvival || !isPartialSurvival(day.date, slice, existingSlice, settleCutoff)) continue + // The baseline holds more evidence than the sources can still produce: + // swap the fresh slice back out for it (inverse of addSliceIntoDay, so + // the day's totals and nested maps stay reconciled with its slices). + subtractSliceFromDay(existing, provider, existingSlice) + } addSliceIntoDay(existing, provider, toAdd, residual) if (markSecondaryCarried) existing.carried = true } @@ -1105,7 +1174,7 @@ export async function ensureCacheHydrated( tzSubtraction = buildTzSubtraction(aggregateDaysInTz(wideProjects, c.tzKey)) } const merged = parseWasComplete - ? mergeDayEntries(freshDays, baseline, true, tzSubtraction) + ? mergeDayEntries(freshDays, baseline, true, tzSubtraction, true) : mergeDayEntries(baseline, freshDays, false) c = { version: DAILY_CACHE_VERSION, diff --git a/tests/daily-cache-carry-forward.test.ts b/tests/daily-cache-carry-forward.test.ts index a3056b80..78cfeab8 100644 --- a/tests/daily-cache-carry-forward.test.ts +++ b/tests/daily-cache-carry-forward.test.ts @@ -712,3 +712,102 @@ describe('adoption union across older cache files', () => { expect(hydrated.complete).toBe(true) }) }) + +describe('partial survival: a truncated fresh slice cannot delete a settled baseline', () => { + // The real 0.9.20 -> next upgrade loss: transcripts age out per FILE, so a + // mostly-forgotten day still gets a handful of turns from surviving later + // files. The fresh slice is non-empty but truncated, and before this guard it + // replaced the baseline outright ($1,685.17 / 12,530 calls -> $385.44 / 560). + const settled = daysAgoStr(33) + + it('keeps the baseline slice when the fresh derivation lost calls on a settled day', () => { + const fresh = day(settled, { claude: slice(385.44, 560, { sessions: 0, inputTokens: 10 }) }) + const baseline = day(settled, { claude: slice(1685.17, 12530, { sessions: 214, inputTokens: 500 }) }) + const merged = mergeDayEntries([fresh], [baseline], true, undefined, true) + const m = merged[0]! + expect(m.providers['claude']).toMatchObject({ cost: 1685.17, calls: 12530, sessions: 214 }) + // Day totals track the swap - they must equal the kept slice, not the sum. + expect(m.cost).toBeCloseTo(1685.17, 5) + expect(m.calls).toBe(12530) + expect(m.sessions).toBe(214) + expect(m.inputTokens).toBe(500) + expect(m.carried).toBe(true) + }) + + it('a truncated slice cannot take other providers down with it', () => { + const fresh = day(settled, { claude: slice(10, 5), codex: slice(7, 3) }) + const baseline = day(settled, { claude: slice(100, 50) }) + const m = mergeDayEntries([fresh], [baseline], true, undefined, true)[0]! + expect(m.providers['claude']).toMatchObject({ cost: 100, calls: 50 }) + expect(m.providers['codex']).toMatchObject({ cost: 7, calls: 3 }) + expect(m.cost).toBeCloseTo(107, 5) + expect(m.calls).toBe(53) + }) + + it('the fresh slice wins on equal calls at a different cost (the Grok re-pricing)', () => { + const fresh = day(settled, { grok: slice(11.89, 21, { sessions: 21, outputTokens: 900 }) }) + const baseline = day(settled, { grok: slice(3.29, 21, { sessions: 21, outputTokens: 200 }) }) + const m = mergeDayEntries([fresh], [baseline], true, undefined, true)[0]! + expect(m.providers['grok']).toMatchObject({ cost: 11.89, calls: 21, outputTokens: 900 }) + expect(m.cost).toBeCloseTo(11.89, 5) + expect(m.carried).toBeUndefined() + }) + + it('a fresh slice with FEWER sessions but equal calls still wins (sessions drift on healthy days)', () => { + const fresh = day(settled, { claude: slice(1021.11, 3295, { sessions: 71 }) }) + const baseline = day(settled, { claude: slice(1021.11, 3295, { sessions: 72 }) }) + const m = mergeDayEntries([fresh], [baseline], true, undefined, true)[0]! + expect(m.providers['claude']!.sessions).toBe(71) + }) + + it('recent days stay authoritative: a shrink inside the settle window is honored', () => { + const recent = daysAgoStr(2) + const fresh = day(recent, { claude: slice(20, 4) }) + const baseline = day(recent, { claude: slice(90, 40) }) + const m = mergeDayEntries([fresh], [baseline], true, undefined, true)[0]! + expect(m.providers['claude']).toMatchObject({ cost: 20, calls: 4 }) + expect(m.cost).toBe(20) + }) + + it('the adoption union is unguarded: the newer schema still wins per (date, provider)', () => { + const newer = day(settled, { claude: slice(50, 5) }) + const older = day(settled, { claude: slice(100, 10) }) + const m = mergeDayEntries([newer], [older], true)[0]! + expect(m.providers['claude']).toMatchObject({ cost: 50, calls: 5 }) + }) + + it('end-to-end: a version-bump re-derive whose transcripts aged out keeps the full day', async () => { + const cache: DailyCache = { + version: DAILY_CACHE_VERSION, + savingsConfigHash: 'cfg-A', + tzKey: currentTzKey(), + lastComputedDate: daysAgoStr(1), + days: [day(settled, { claude: slice(1685.17, 12530, { sessions: 214 }) })], + complete: false, // what a version bump / adoption leaves behind + } + await saveDailyCache(cache) + const truncated = [day(settled, { claude: slice(385.44, 560, { sessions: 0 }) })] + const out = await ensureCacheHydrated(noSessions, () => truncated, 'cfg-A') + const kept = out.days.find(d => d.date === settled)! + expect(kept.providers['claude']).toMatchObject({ cost: 1685.17, calls: 12530 }) + expect(kept.cost).toBeCloseTo(1685.17, 5) + // A --period all payload sums the kept slices, not the truncated ones. + const period = buildPeriodDataFromDays(out.days, 'all') + expect(period.cost).toBeCloseTo(1685.17, 5) + expect(period.calls).toBe(12530) + }) + + it('a fully sourceless day is still carried whole (v14 behavior, unchanged)', async () => { + const cache: DailyCache = { + version: DAILY_CACHE_VERSION, + savingsConfigHash: 'cfg-A', + tzKey: currentTzKey(), + lastComputedDate: daysAgoStr(1), + days: [day(settled, { claude: slice(230.06, 400) })], + complete: false, + } + await saveDailyCache(cache) + const out = await ensureCacheHydrated(noSessions, () => [], 'cfg-A') + expect(out.days[0]).toMatchObject({ date: settled, cost: 230.06, calls: 400, carried: true }) + }) +}) From ab98a04c51930b7fd75279263adcad8bc67dbf80 Mon Sep 17 00:00:00 2001 From: iamtoruk Date: Tue, 18 Aug 2026 12:29:07 -0700 Subject: [PATCH 70/85] fix(cache): stop republishing month shards a scoped run never read A date-ranged query loads only the months its range can report on, so a file in an out-of-range month has no visible cache entry: the reconcile re-parses it and writes the identical entry back, which marks a bucket this run never loaded dirty. The save then merged and republished that month under a fresh nonce name on every run, with byte-identical content. A merge into an unloaded month that neither adds, changes nor removes an entry now keeps the published shard. Fixes #1032 --- CHANGELOG.md | 1 + src/session-cache.ts | 9 +++++++++ tests/session-cache-shards.test.ts | 22 ++++++++++++++++++++++ 3 files changed, 32 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3f623e3f..c82301ab 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -31,6 +31,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 +- **A date-ranged run no longer republishes the month shards it never read.** A scoped load leaves an out-of-range month on disk, so the files it holds have no visible cache entry and the reconcile re-parses them — re-deriving the entry the shard already stores. That re-parse marked the unloaded month dirty, and the save merged and republished it under a fresh nonce name on every single run, byte-identical content and all, so a repeated `codeburn status --format json` churned old months (on a real corpus: claude/2026-03, cursor/2026-02 and warp/2026-03 renamed every run) and left the retired shards for the sweeper. A merge into an unloaded month that neither adds, changes nor removes an entry now keeps the published shard, so unchanged months keep their names and their bytes. (#1032) - **The session chart legend now leads with a visible session disambiguator and title instead of the project path.** Every series in a monorepo shared the same project prefix, so the only thing separating them was a truncated hex fragment — and per-application cost attribution is the main reason to open that chart. `SessionSummary.title` is already parsed and already rendered in the Context tab; the legend now puts the short session id first, prefers the title, and falls back to the previous project-based label when a session never produced one. Titles come from transcripts, so they are stripped of ANSI and control characters and capped before they reach either the legend or the tooltip. (#997) - **Context-bloat detection now counts reasoning tokens as generated output.** `detectContextBloat` divided context by `totalOutputTokens` alone, but reasoning is stored beside output rather than inside it, so for every reasoning-bearing provider the detector saw a fraction of the tokens actually generated and invented findings - a session whose real ratio was 20:1, under the 25:1 threshold, was reported as 133:1 and "high impact". It now uses the same `output + reasoning` sum the reports use, which corrects grok, codex, kiro, hermes, qwen and cursor-agent alike. - **The unpriced-models warning in the dashboard is now readable at every terminal width.** It lived in a fixed-width panel with an inline model list and a fix command, so it clipped mid-name at 80 columns and clipped *earlier* at 200, where the three-column layout narrows each panel - neither the affected models nor a runnable command survived. The panel line is now a pointer, `! N unpriced: codeburn models --unpriced` (shortened to `! N: codeburn models --unpriced` below 45 columns of panel), and the model list moves to that command's plain output, which is full width, copyable, and lists every model rather than the first two. The command's hint no longer reads as an unconditional instruction to alias: a subscription or flat-rate model is correctly $0, and mapping it onto another model's per-token rate would invent spend that was never billed. Provider-supplied model IDs are now stripped of terminal control characters in every human-readable report rather than only on the unpriced path, and `--unpriced` shows raw IDs instead of friendly names because `model-alias` keys on the raw ID. (#969) diff --git a/src/session-cache.ts b/src/session-cache.ts index 3bb37344..a17df0a8 100644 --- a/src/session-cache.ts +++ b/src/session-cache.ts @@ -1036,6 +1036,15 @@ export async function saveCache(cache: SessionCache, verifyStillOwner?: () => Pr const files = plan.groups.get(bucket)! const onDisk = from ? await loadShard(join(dir, from)) : null if (!onDisk) return writeShard(provider, bucket, files) + // A file whose month this run never loaded has no visible cache entry, so it + // looks uncached and is re-parsed into the same bucket — re-deriving the + // entry the shard already holds. Republishing then churns the shard's nonce + // name on every run for content that never changed (#1032), so a merge that + // neither adds, changes nor removes an entry keeps the published shard. + const adds = Object.entries(files).some(([path, file]) => + onDisk[path] === undefined || JSON.stringify(onDisk[path]) !== JSON.stringify(file)) + const removes = [...plan.moved].some(path => onDisk[path] !== undefined && files[path] === undefined) + if (!adds && !removes) return { name: from!, until: untilMonth(onDisk) } for (const path of plan.moved) delete onDisk[path] return writeShard(provider, bucket, { ...onDisk, ...files }) } diff --git a/tests/session-cache-shards.test.ts b/tests/session-cache-shards.test.ts index ff53c9b6..e965f181 100644 --- a/tests/session-cache-shards.test.ts +++ b/tests/session-cache-shards.test.ts @@ -499,6 +499,28 @@ describe('scoped load', () => { .toEqual(['/live/apr.jsonl', '/live/jun.jsonl', '/live/jun2.jsonl', '/live/mar.jsonl']) }) + it('keeps the unloaded month\'s shard name when a re-parse re-derives the same entry', async () => { + await seedThreeMonths() + // The March entry is invisible to a June-scoped run, so the reconcile + // re-parses that file and writes the identical entry straight back. Nothing + // changed, so the March shard must keep its name run after run (#1032). + const nameOf = async (): Promise => (await envelope()).providers['claude']!.shards['2026-03']!.name + const before = await nameOf() + for (let run = 0; run < 2; run++) { + clearLoadCacheMemo() + const scoped = await loadCache(juneScope) + scoped.providers['claude']!.files['/live/mar.jsonl'] = fileSpanning('2026-03-10T10:00:00Z') + markCacheDirty(scoped, 'claude', '/live/mar.jsonl') + await saveCache(scoped) + expect(await nameOf(), `March republished on run ${run + 1}`).toBe(before) + } + + clearLoadCacheMemo() + const full = await loadCache() + expect(Object.keys(full.providers['claude']!.files).sort()) + .toEqual(['/live/apr.jsonl', '/live/jun.jsonl', '/live/mar.jsonl']) + }) + it('merges rather than replaces when a re-parse lands in an unloaded month', async () => { await seedThreeMonths() clearLoadCacheMemo() From ad406cf10f9296d2867f2e418fa4081fece8c7bb Mon Sep 17 00:00:00 2001 From: Aditya Vikram Singh <247195684+avs-io@users.noreply.github.com> Date: Wed, 19 Aug 2026 13:48:50 +0530 Subject: [PATCH 71/85] fix(tui): coalesce resize bursts without dropping updates Hold Ink stdout columns/rows frozen during a SIGWINCH burst and emit one settled resize, then rerender. Do not intercept writes, so a mid-burst state update still paints even when net size is unchanged. Fixes #977. --- src/dashboard.tsx | 153 ++++++++++++++++++++++++--- tests/dashboard-resize.test.ts | 184 +++++++++++++++++++++++++++++++++ 2 files changed, 322 insertions(+), 15 deletions(-) create mode 100644 tests/dashboard-resize.test.ts diff --git a/src/dashboard.tsx b/src/dashboard.tsx index 7eeeaf35..35c6eeef 100644 --- a/src/dashboard.tsx +++ b/src/dashboard.tsx @@ -1,7 +1,8 @@ import { homedir } from 'os' +import { EventEmitter } from 'node:events' import React, { Fragment, useState, useCallback, useEffect, useLayoutEffect, useMemo, useRef } from 'react' -import { render, Box, Text, measureElement, useInput, useApp, useWindowSize, type DOMElement } from 'ink' +import { render, Box, Text, measureElement, useInput, useApp, useWindowSize, type DOMElement, type Instance, type RenderOptions } from 'ink' import { CATEGORY_LABELS, type DateRange, type ProjectSummary, type TaskCategory } from './types.js' import { formatCost, formatTokens, markEstimated, carriedCostNote } from './format.js' import { aggregateModelEfficiency } from './model-efficiency.js' @@ -31,6 +32,138 @@ export type DailyActivityRow = { export const DAILY_ACTIVITY_PAGE_SIZE = 10 export const INTERACTIVE_RENDER_OPTIONS = { alternateScreen: true } as const +export const RESIZE_DEBOUNCE_MS = 150 + +export type TerminalSize = { columns: number; rows: number } +export type DebouncedResizeStream = NodeJS.WriteStream & { + dispose(): void + onSettledResize(listener: (size: TerminalSize) => void): () => void +} + +function normalizeTerminalDimension(value: number | undefined, fallback: number): number { + return Number.isFinite(value) && value! > 0 ? Math.floor(value!) : fallback +} + +function terminalSizeOf(source: NodeJS.WriteStream): TerminalSize { + return { + columns: normalizeTerminalDimension(source.columns, 80), + rows: normalizeTerminalDimension(source.rows, 24), + } +} + +const RESIZE_LISTENER_METHODS = new Set([ + 'addListener', 'on', 'once', 'prependListener', 'prependOnceListener', 'off', 'removeListener', +]) + +export function createDebouncedResizeStream(source: NodeJS.WriteStream, delayMs: number): DebouncedResizeStream { + let resizeTimer: ReturnType | undefined + let disposed = false + let { columns, rows } = terminalSizeOf(source) + const resizeEvents = new EventEmitter() + const settledResizeListeners = new Set<(size: TerminalSize) => void>() + + const resize = () => { + if (disposed) return + if (resizeTimer) clearTimeout(resizeTimer) + resizeTimer = setTimeout(() => { + resizeTimer = undefined + if (disposed) return + const next = terminalSizeOf(source) + const changed = next.columns !== columns || next.rows !== rows + columns = next.columns + rows = next.rows + if (!changed) return + // Rerender first so the settled view owns the first paint at the new + // size; then notify Ink/useWindowSize. Writes are never intercepted, so + // a mid-burst state update still reaches the terminal even when net + // size is unchanged. + for (const listener of [...settledResizeListeners]) listener(next) + resizeEvents.emit('resize') + }, delayMs) + } + source.on('resize', resize) + + const dispose = () => { + if (disposed) return + disposed = true + source.off('resize', resize) + if (resizeTimer) clearTimeout(resizeTimer) + resizeTimer = undefined + resizeEvents.removeAllListeners() + settledResizeListeners.clear() + } + + const stream = new Proxy(source as DebouncedResizeStream, { + get(target, property) { + if (property === 'dispose') return dispose + if (property === 'onSettledResize') { + return (listener: (size: TerminalSize) => void) => { + if (disposed) return () => {} + settledResizeListeners.add(listener) + return () => settledResizeListeners.delete(listener) + } + } + if (property === 'columns') return columns + if (property === 'rows') return rows + if (RESIZE_LISTENER_METHODS.has(property)) { + return (event: string | symbol, ...args: unknown[]) => { + if (event === 'resize') { + if (!disposed) { + Reflect.apply(Reflect.get(resizeEvents, property) as (...args: unknown[]) => unknown, resizeEvents, [event, ...args]) + } + return stream + } + Reflect.apply(Reflect.get(target, property, target) as (...args: unknown[]) => unknown, target, [event, ...args]) + return stream + } + } + const value = Reflect.get(target, property, target) + return typeof value === 'function' ? value.bind(target) : value + }, + }) + return stream +} + +function DisposeOnUnmount({ dispose, children }: { dispose: () => void; children: React.ReactNode }) { + useLayoutEffect(() => dispose, [dispose]) + return children +} + +export type DebouncedInteractiveInstance = Instance & { + dispose(): void + stdout: DebouncedResizeStream +} + +export function renderDebouncedInteractive( + source: NodeJS.WriteStream, + view: (size: TerminalSize) => React.ReactElement, + options: Omit = INTERACTIVE_RENDER_OPTIONS, +): DebouncedInteractiveInstance { + const stdout = createDebouncedResizeStream(source, RESIZE_DEBOUNCE_MS) + let size = { columns: stdout.columns, rows: stdout.rows } + let unsubscribe = () => {} + let disposed = false + const dispose = () => { + if (disposed) return + disposed = true + unsubscribe() + stdout.dispose() + } + const dashboard = () => {view(size)} + let app: Instance + try { + app = render(dashboard(), { ...options, stdout }) + } catch (error) { + dispose() + throw error + } + unsubscribe = stdout.onSettledResize(nextSize => { + if (disposed) return + size = nextSize + app.rerender(dashboard()) + }) + return Object.assign(app, { dispose, stdout }) +} export function getDailyActivityPageSize(columnCount: 1 | 2 | 3, projectRows: number, activityRows: number, dayMode = false): number { if (dayMode) return 1 @@ -1738,23 +1871,13 @@ export async function renderDashboard(period: Period = 'week', provider: string const label = initialDay ? formatDayRangeLabel(initialDay) : customRangeLabel patchStdoutForWindows() if (isTTY) { - let windowColumns = process.stdout.columns - const dashboard = () => ( - - ) - const app = render( - dashboard(), - INTERACTIVE_RENDER_OPTIONS, - ) - const resize = () => { - windowColumns = process.stdout.columns - app.rerender(dashboard()) - } - process.stdout.prependListener('resize', resize) + const app = renderDebouncedInteractive(process.stdout, ({ columns }) => ( + + )) try { await app.waitUntilExit() } finally { - process.stdout.off('resize', resize) + app.dispose() } } else { const { unmount } = render(, { patchConsole: false }) diff --git a/tests/dashboard-resize.test.ts b/tests/dashboard-resize.test.ts new file mode 100644 index 00000000..a1eb80a6 --- /dev/null +++ b/tests/dashboard-resize.test.ts @@ -0,0 +1,184 @@ +import { readFileSync } from 'node:fs' +import { PassThrough } from 'node:stream' + +import React, { useEffect } from 'react' +import { Text } from 'ink' +import { afterEach, describe, expect, it, vi } from 'vitest' + +import { RESIZE_DEBOUNCE_MS, createDebouncedResizeStream, renderDebouncedInteractive } from '../src/dashboard.js' +import { stripSyncUpdateEscapes } from '../src/ink-win.js' + +function makeTerminal(columns = 100, rows = 24): PassThrough & NodeJS.WriteStream { + const terminal = new PassThrough() as PassThrough & NodeJS.WriteStream + terminal.isTTY = true + terminal.columns = columns + terminal.rows = rows + return terminal +} + +function paintedFrames(writes: string[]): string[] { + return writes + .map(chunk => stripSyncUpdateEscapes(chunk)) + .flatMap(chunk => chunk.match(/FRAME:[^\r\n]*/g) ?? []) +} + +describe('interactive dashboard resize stream', () => { + afterEach(() => vi.useRealTimers()) + + it('does not intercept writes or parse synchronized-update frames', () => { + const source = readFileSync(new URL('../src/dashboard.tsx', import.meta.url), 'utf8') + expect(source).not.toContain('suppressingFrame') + expect(source).not.toContain('capturingResizeWrites') + expect(source).not.toContain('finalFramePreamble') + expect(source).not.toContain('indexOf(BSU)') + expect(source).not.toContain('indexOf(ESU)') + expect(source).not.toContain('process.stdout.prependListener') + }) + + it('publishes one settled paint after a resize burst', async () => { + vi.useFakeTimers() + const terminal = makeTerminal() + const writes: string[] = [] + terminal.on('data', chunk => writes.push(String(chunk))) + const app = renderDebouncedInteractive(terminal, size => ( + React.createElement(Text, null, `FRAME:${size.columns}x${size.rows}`) + ), { + interactive: true, + patchConsole: false, + alternateScreen: true, + }) + await vi.advanceTimersByTimeAsync(100) + writes.length = 0 + + terminal.columns = 99 + terminal.emit('resize') + await vi.advanceTimersByTimeAsync(50) + terminal.columns = 98 + terminal.emit('resize') + await vi.advanceTimersByTimeAsync(50) + terminal.columns = 97 + terminal.rows = 30 + terminal.emit('resize') + + await vi.advanceTimersByTimeAsync(RESIZE_DEBOUNCE_MS + 100) + + const frames = paintedFrames(writes) + expect(frames.filter(frame => frame !== 'FRAME:97x30'), 'a resize burst must not paint intermediate sizes').toEqual([]) + expect(frames).toContain('FRAME:97x30') + + app.unmount() + app.dispose() + await vi.runAllTimersAsync() + await app.waitUntilExit() + }) + + it('paints a mid-burst state update when the burst nets to no size change', async () => { + vi.useFakeTimers() + const terminal = makeTerminal() + const writes: string[] = [] + terminal.on('data', chunk => writes.push(String(chunk))) + let updateVisibleState = () => {} + const StatefulProbe = ({ size }: { size: { columns: number; rows: number } }) => { + const [revision, setRevision] = React.useState(0) + updateVisibleState = () => setRevision(value => value + 1) + return React.createElement(Text, null, `FRAME:revision=${revision}:size=${size.columns}x${size.rows}`) + } + const app = renderDebouncedInteractive(terminal, size => React.createElement(StatefulProbe, { size }), { + interactive: true, + patchConsole: false, + alternateScreen: true, + }) + await vi.advanceTimersByTimeAsync(100) + writes.length = 0 + + terminal.columns = 80 + terminal.emit('resize') + updateVisibleState() + terminal.columns = 100 + terminal.emit('resize') + + await vi.advanceTimersByTimeAsync(RESIZE_DEBOUNCE_MS + 100) + + expect(paintedFrames(writes).some(frame => frame.includes('revision=1')), 'a mid-burst state update must reach the terminal even when net size is unchanged').toBe(true) + + app.unmount() + app.dispose() + await vi.runAllTimersAsync() + await app.waitUntilExit() + }) + + it('paints a state update after a spurious identical-dimension SIGWINCH', async () => { + vi.useFakeTimers() + const terminal = makeTerminal() + const writes: string[] = [] + terminal.on('data', chunk => writes.push(String(chunk))) + let updateVisibleState = () => {} + const StatefulProbe = ({ size }: { size: { columns: number; rows: number } }) => { + const [revision, setRevision] = React.useState(0) + updateVisibleState = () => setRevision(value => value + 1) + return React.createElement(Text, null, `FRAME:revision=${revision}:size=${size.columns}x${size.rows}`) + } + const app = renderDebouncedInteractive(terminal, size => React.createElement(StatefulProbe, { size }), { + interactive: true, + patchConsole: false, + alternateScreen: true, + }) + await vi.advanceTimersByTimeAsync(100) + writes.length = 0 + + terminal.emit('resize') + updateVisibleState() + + await vi.advanceTimersByTimeAsync(RESIZE_DEBOUNCE_MS + 100) + + expect(paintedFrames(writes).some(frame => frame.includes('revision=1')), 'a state update must still paint after a no-op SIGWINCH').toBe(true) + + app.unmount() + app.dispose() + await vi.runAllTimersAsync() + await app.waitUntilExit() + }) + + it('removes the source relay and cancels pending resize delivery on dispose', async () => { + vi.useFakeTimers() + const terminal = makeTerminal() + const renderedSizes: Array<{ columns: number; rows: number }> = [] + const Probe = ({ size }: { size: { columns: number; rows: number } }) => { + useEffect(() => { + renderedSizes.push(size) + }, [size]) + return React.createElement(Text, null, `FRAME:${size.columns}x${size.rows}`) + } + const app = renderDebouncedInteractive(terminal, size => React.createElement(Probe, { size }), { + interactive: true, + patchConsole: false, + }) + await vi.advanceTimersByTimeAsync(100) + renderedSizes.length = 0 + + terminal.columns = 90 + terminal.emit('resize') + app.unmount() + app.dispose() + await vi.runAllTimersAsync() + await app.waitUntilExit() + + await vi.advanceTimersByTimeAsync(RESIZE_DEBOUNCE_MS) + expect(renderedSizes).toEqual([]) + expect(terminal.listenerCount('resize')).toBe(0) + }) + + it('disposes a stream that never rendered', () => { + const terminal = makeTerminal() + const stdout = createDebouncedResizeStream(terminal, RESIZE_DEBOUNCE_MS) + expect(terminal.listenerCount('resize')).toBe(1) + stdout.dispose() + expect(terminal.listenerCount('resize')).toBe(0) + + const resize = vi.fn() + stdout.on('resize', resize) + terminal.emit('resize') + expect(resize).not.toHaveBeenCalled() + expect(terminal.listenerCount('resize')).toBe(0) + }) +}) From cdaa5b7ed37456063bdb5cf8e48deecffd752b5f Mon Sep 17 00:00:00 2001 From: Aditya Vikram Singh <247195684+avs-io@users.noreply.github.com> Date: Wed, 19 Aug 2026 13:39:51 +0530 Subject: [PATCH 72/85] fix(menubar): migrate Claude/Codex caches to namespaced Keychain Stop writing OAuth caches as Application Support JSON. Persist CodeBurn-owned items in Keychain, secure-read and migrate leftover 0644 files only after read-back verification, and keep Claude from storing a refresh token. --- mac/Sources/CodeBurnMenubar/AppStore.swift | 12 +- .../Data/ClaudeCredentialStore.swift | 241 +++++++++++-- .../Data/ClaudeSubscriptionService.swift | 2 +- .../Data/CodexCredentialStore.swift | 167 +++++++-- .../Data/CodexSubscriptionService.swift | 2 +- .../Security/KeychainCredentialCache.swift | 186 ++++++++++ .../CodeBurnMenubar/Security/SafeFile.swift | 66 ++++ .../CodeBurnMenubar/Views/SettingsView.swift | 2 +- .../CredentialKeychainCacheRedTests.swift | 149 ++++++++ .../CredentialKeychainContinuityTests.swift | 341 ++++++++++++++++++ 10 files changed, 1112 insertions(+), 56 deletions(-) create mode 100644 mac/Sources/CodeBurnMenubar/Security/KeychainCredentialCache.swift create mode 100644 mac/Tests/CodeBurnMenubarTests/CredentialKeychainCacheRedTests.swift create mode 100644 mac/Tests/CodeBurnMenubarTests/CredentialKeychainContinuityTests.swift diff --git a/mac/Sources/CodeBurnMenubar/AppStore.swift b/mac/Sources/CodeBurnMenubar/AppStore.swift index 005a7531..33ac8d12 100644 --- a/mac/Sources/CodeBurnMenubar/AppStore.swift +++ b/mac/Sources/CodeBurnMenubar/AppStore.swift @@ -1070,7 +1070,11 @@ final class AppStore { // result instead of re-populating the cleared state. claudeRefreshGen &+= 1 subscription = nil - subscriptionError = nil + if let result = ClaudeCredentialStore.lastCacheDeleteResult, !result.isSuccess { + subscriptionError = "Could not fully remove the local Claude credential cache." + } else { + subscriptionError = nil + } subscriptionLoadState = .notBootstrapped capacityEstimates = [:] Task.detached { await SubscriptionSnapshotStore.clearAll() } @@ -1134,7 +1138,11 @@ final class AppStore { CodexSubscriptionService.disconnect() codexRefreshGen &+= 1 codexUsage = nil - codexError = nil + if let result = CodexCredentialStore.lastCacheDeleteResult, !result.isSuccess { + codexError = "Could not fully remove the local Codex credential cache." + } else { + codexError = nil + } codexLoadState = .notBootstrapped NotificationCenter.default.post(name: .codeBurnSubscriptionDisconnected, object: nil) } diff --git a/mac/Sources/CodeBurnMenubar/Data/ClaudeCredentialStore.swift b/mac/Sources/CodeBurnMenubar/Data/ClaudeCredentialStore.swift index 002e861e..d9e691e6 100644 --- a/mac/Sources/CodeBurnMenubar/Data/ClaudeCredentialStore.swift +++ b/mac/Sources/CodeBurnMenubar/Data/ClaudeCredentialStore.swift @@ -16,9 +16,9 @@ import Security /// the refresh endpoint. If the CLI hasn't rotated yet we report a /// transient staleness (`sourceTokenStale`) and recover on its next use. /// -/// 3. **In-memory + file cache** so back-to-back reads in the same refresh +/// 3. **In-memory + Keychain cache** so back-to-back reads in the same refresh /// cycle don't re-hit the source, and we keep serving the last good token -/// across launches. +/// across launches without a plaintext Application Support file. enum ClaudeCredentialStore { private static let bootstrapCompletedKey = "codeburn.claude.bootstrapCompleted" private static let inMemoryTTL: TimeInterval = 5 * 60 @@ -28,12 +28,45 @@ enum ClaudeCredentialStore { private static let credentialsRelativePath = ".claude/.credentials.json" private static let maxCredentialBytes = 64 * 1024 - /// Legacy local cache file. New writes use the macOS Keychain; this path is + /// Legacy local cache file under Application Support. Migration to the + /// CodeBurn-namespaced Keychain item is staged behind an injectable seam. private static let cacheFilename = "claude-credentials.v1.json" + static let ourKeychainService = CodeBurnKeychainIdentity.claudeService + static let ourKeychainAccount = CodeBurnKeychainIdentity.account + private static let lock = NSLock() private nonisolated(unsafe) static var memoryCache: CachedRecord? + // MARK: - Injectable seams (tests + staged Keychain migration) + + /// Override Application Support root. Nil uses the real user domain. + nonisolated(unsafe) static var applicationSupportDirectoryOverride: URL? + /// Override home for Claude CLI credential discovery. Nil uses the real home. + nonisolated(unsafe) static var homeDirectoryOverride: URL? + /// Override defaults used for bootstrap flags. + nonisolated(unsafe) static var userDefaultsOverride: UserDefaults? + /// Keychain backend. Production uses Live; tests inject InMemory. + nonisolated(unsafe) static var keychainCache: any KeychainCredentialCaching = LiveKeychainCredentialCache() + + static func resetTestSeams() { + applicationSupportDirectoryOverride = nil + homeDirectoryOverride = nil + userDefaultsOverride = nil + keychainCache = LiveKeychainCredentialCache() + lastCacheDeleteResult = nil + lastLegacyCleanupFailed = false + lock.withLock { memoryCache = nil } + } + + private static var defaults: UserDefaults { + userDefaultsOverride ?? .standard + } + + private static var homeDirectory: URL { + homeDirectoryOverride ?? FileManager.default.homeDirectoryForCurrentUser + } + struct CachedRecord { let record: CredentialRecord let cachedAt: Date @@ -88,18 +121,37 @@ enum ClaudeCredentialStore { /// True once the user has explicitly connected (clicked Connect in the Plan /// tab AND we successfully read their credentials). Persists across launches. static var isBootstrapCompleted: Bool { - get { UserDefaults.standard.bool(forKey: bootstrapCompletedKey) } - set { UserDefaults.standard.set(newValue, forKey: bootstrapCompletedKey) } + get { defaults.bool(forKey: bootstrapCompletedKey) } + set { defaults.set(newValue, forKey: bootstrapCompletedKey) } } /// Reset bootstrap state. Used when the user explicitly wants to disconnect - /// or when the refresh token has been revoked terminally. - static func resetBootstrap() { + /// or when the refresh token has been revoked terminally. Deletion failures + /// are recorded on `lastCacheDeleteResult` — callers must not claim the + /// local copy is gone when `isSuccess` is false. + @discardableResult + static func resetBootstrap() -> CacheDeleteResult { lock.withLock { memoryCache = nil } - deleteOurCache() + let result = deleteOurCache() + lastCacheDeleteResult = result isBootstrapCompleted = false + return result } + /// Outcome of deleting CodeBurn-owned Claude cache material. + struct CacheDeleteResult: Equatable { + var keychainDeletedOrAbsent: Bool + var legacyDeletedOrAbsent: Bool + var isSuccess: Bool { keychainDeletedOrAbsent && legacyDeletedOrAbsent } + } + + /// Last disconnect/cleanup result. Nil until the first delete attempt. + nonisolated(unsafe) static var lastCacheDeleteResult: CacheDeleteResult? + + /// Last legacy-unlink failure after a verified Keychain write (cleanup retry signal). + nonisolated(unsafe) static var lastLegacyCleanupFailed = false + + // MARK: - Public API /// User-initiated entry point. Reads from Claude's source (PROMPTS for the @@ -190,7 +242,7 @@ enum ClaudeCredentialStore { } private static func readClaudeFile() throws -> CredentialRecord? { - let url = FileManager.default.homeDirectoryForCurrentUser.appendingPathComponent(credentialsRelativePath) + let url = homeDirectory.appendingPathComponent(credentialsRelativePath) guard FileManager.default.fileExists(atPath: url.path) else { return nil } let data = try SafeFile.read(from: url.path, maxBytes: maxCredentialBytes) return try parseClaudeBlob(data: sanitizeClaudeBlob(data)) @@ -305,37 +357,174 @@ enum ClaudeCredentialStore { } } - // MARK: - Local cache file (no keychain involvement) + // MARK: - Local cache (injectable Application Support + Keychain seam) - private static func cacheFileURL() -> URL { - let support = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first - ?? FileManager.default.homeDirectoryForCurrentUser.appendingPathComponent("Library/Application Support") + static func cacheFileURL() -> URL { + let support = applicationSupportDirectoryOverride + ?? FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first + ?? homeDirectory.appendingPathComponent("Library/Application Support") return support .appendingPathComponent("CodeBurn", isDirectory: true) .appendingPathComponent(cacheFilename) } + /// Production-used write path. Persists to the CodeBurn-namespaced Keychain + /// item. Claude never stores a refresh token in this cache. After a verified + /// Keychain read-back, any legacy JSON is unlinked (not "securely erased"). + static func writeOurCache(record: CredentialRecord) throws { + let persisted = encodePersisted(record) + let data = try JSONEncoder().encode(persisted) + try keychainCache.upsert( + service: ourKeychainService, + account: ourKeychainAccount, + data: data + ) + try verifyKeychainMatches(persisted) + tryUnlinkLegacyAfterVerifiedKeychain() + } + + /// Cache shape stored in Keychain — intentionally omits refreshToken. + struct PersistedCacheRecord: Codable, Equatable { + let accessToken: String + let expiresAt: Date? + let rateLimitTier: String? + } + + private static func encodePersisted(_ record: CredentialRecord) -> PersistedCacheRecord { + PersistedCacheRecord( + accessToken: record.accessToken, + expiresAt: record.expiresAt, + rateLimitTier: record.rateLimitTier + ) + } + + private static func decodePersisted(_ data: Data) -> CredentialRecord? { + if let persisted = try? JSONDecoder().decode(PersistedCacheRecord.self, from: data) { + return CredentialRecord( + accessToken: persisted.accessToken, + refreshToken: nil, + expiresAt: persisted.expiresAt, + rateLimitTier: persisted.rateLimitTier + ) + } + // Historical blobs may still include refreshToken; drop it on read. + if let legacy = try? JSONDecoder().decode(CredentialRecord.self, from: data) { + return CredentialRecord( + accessToken: legacy.accessToken, + refreshToken: nil, + expiresAt: legacy.expiresAt, + rateLimitTier: legacy.rateLimitTier + ) + } + return nil + } + + private static func verifyKeychainMatches(_ expected: PersistedCacheRecord) throws { + guard let data = try keychainCache.read(service: ourKeychainService, account: ourKeychainAccount), + let roundTrip = decodePersisted(data), + encodePersisted(roundTrip) == expected + else { + throw StoreError.keychainWriteFailed(-1) + } + } + private static func readOurCache() throws -> CredentialRecord? { + if let data = try keychainCache.read(service: ourKeychainService, account: ourKeychainAccount), + let record = decodePersisted(data) { + // Rewrite historical Claude blobs once without refreshToken. + let sanitized = encodePersisted(record) + if let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + object.keys.contains("refreshToken") { + try? writeOurCache(record: record) + } else { + tryUnlinkLegacyAfterVerifiedKeychain() + _ = sanitized + } + return record + } + + return try migrateLegacyFileIfPresent() + } + + /// Secure-read legacy JSON, upsert Keychain, verify read-back, then unlink. + private static func migrateLegacyFileIfPresent() throws -> CredentialRecord? { let url = cacheFileURL() guard FileManager.default.fileExists(atPath: url.path) else { return nil } - let data = try SafeFile.read(from: url.path, maxBytes: maxCredentialBytes) - guard let record = try? JSONDecoder().decode(CredentialRecord.self, from: data) else { return nil } - return record + + let data: Data + do { + data = try SafeFile.readAfterSecuringPermissions( + from: url.path, + maxBytes: maxCredentialBytes + ) + } catch { + // Symlink / ownership / chmod failures: leave the file alone. + return nil + } + + guard let decoded = try? JSONDecoder().decode(CredentialRecord.self, from: data) else { + // Invalid data stays in place at 0600; do not delete. + return nil + } + let migrated = CredentialRecord( + accessToken: decoded.accessToken, + refreshToken: nil, + expiresAt: decoded.expiresAt, + rateLimitTier: decoded.rateLimitTier + ) + + do { + try writeOurCache(record: migrated) + return migrated + } catch { + // Keychain write/read-back failure: leave repaired 0600 legacy file. + lastLegacyCleanupFailed = false + return migrated + } } - private static func writeOurCache(record: CredentialRecord) throws { - try writeOurFileCache(record: record) - } - - private static func writeOurFileCache(record: CredentialRecord) throws { + private static func tryUnlinkLegacyAfterVerifiedKeychain() { let url = cacheFileURL() - try FileManager.default.createDirectory(at: url.deletingLastPathComponent(), withIntermediateDirectories: true) - let data = try JSONEncoder().encode(record) - try data.write(to: url, options: [.atomic, .completeFileProtection]) + guard FileManager.default.fileExists(atPath: url.path) else { + lastLegacyCleanupFailed = false + return + } + do { + try FileManager.default.removeItem(at: url) + lastLegacyCleanupFailed = false + } catch { + // Retain valid Keychain item + 0600 file; surface for later retry. + lastLegacyCleanupFailed = true + } } - private static func deleteOurCache() { - try? FileManager.default.removeItem(at: cacheFileURL()) + @discardableResult + private static func deleteOurCache() -> CacheDeleteResult { + var keychainOK = true + do { + try keychainCache.delete(service: ourKeychainService, account: ourKeychainAccount) + } catch { + keychainOK = false + } + + var legacyOK = true + let url = cacheFileURL() + if FileManager.default.fileExists(atPath: url.path) { + do { + try FileManager.default.removeItem(at: url) + } catch { + legacyOK = false + } + } + return CacheDeleteResult( + keychainDeletedOrAbsent: keychainOK, + legacyDeletedOrAbsent: legacyOK + ) + } + + /// Clears only the in-memory TTL cache (simulates process restart in tests). + static func clearMemoryCacheForTesting() { + lock.withLock { memoryCache = nil } } private static func cacheInMemory(_ record: CredentialRecord) { diff --git a/mac/Sources/CodeBurnMenubar/Data/ClaudeSubscriptionService.swift b/mac/Sources/CodeBurnMenubar/Data/ClaudeSubscriptionService.swift index d2876d1b..d452b049 100644 --- a/mac/Sources/CodeBurnMenubar/Data/ClaudeSubscriptionService.swift +++ b/mac/Sources/CodeBurnMenubar/Data/ClaudeSubscriptionService.swift @@ -100,7 +100,7 @@ enum ClaudeSubscriptionService { /// Reset everything — used on user-initiated disconnect. static func disconnect() { - ClaudeCredentialStore.resetBootstrap() + _ = ClaudeCredentialStore.resetBootstrap() clearUsageBlock() } diff --git a/mac/Sources/CodeBurnMenubar/Data/CodexCredentialStore.swift b/mac/Sources/CodeBurnMenubar/Data/CodexCredentialStore.swift index 9f7ae18f..96bf71fd 100644 --- a/mac/Sources/CodeBurnMenubar/Data/CodexCredentialStore.swift +++ b/mac/Sources/CodeBurnMenubar/Data/CodexCredentialStore.swift @@ -5,7 +5,7 @@ import Security /// ClaudeCredentialStore but reads from ~/.codex/auth.json — Codex CLI /// already stores its tokens as plaintext JSON in the home directory, so /// no keychain prompt is involved on bootstrap. After the user clicks -/// Connect we cache a copy under ~/Library/Application Support/CodeBurn so +/// Connect we cache a CodeBurn-owned copy in the macOS Keychain so /// we keep using rotated tokens after refresh. enum CodexCredentialStore { private static let bootstrapCompletedKey = "codeburn.codex.bootstrapCompleted" @@ -23,9 +23,37 @@ enum CodexCredentialStore { private static let cacheFilename = "codex-credentials.v1.json" + static let ourKeychainService = CodeBurnKeychainIdentity.codexService + static let ourKeychainAccount = CodeBurnKeychainIdentity.account + private static let lock = NSLock() private nonisolated(unsafe) static var memoryCache: CachedRecord? + // MARK: - Injectable seams (tests + staged Keychain migration) + + nonisolated(unsafe) static var applicationSupportDirectoryOverride: URL? + nonisolated(unsafe) static var homeDirectoryOverride: URL? + nonisolated(unsafe) static var userDefaultsOverride: UserDefaults? + nonisolated(unsafe) static var keychainCache: any KeychainCredentialCaching = LiveKeychainCredentialCache() + + static func resetTestSeams() { + applicationSupportDirectoryOverride = nil + homeDirectoryOverride = nil + userDefaultsOverride = nil + keychainCache = LiveKeychainCredentialCache() + lastCacheDeleteResult = nil + lastLegacyCleanupFailed = false + lock.withLock { memoryCache = nil } + } + + private static var defaults: UserDefaults { + userDefaultsOverride ?? .standard + } + + private static var homeDirectory: URL { + homeDirectoryOverride ?? FileManager.default.homeDirectoryForCurrentUser + } + struct CachedRecord { let record: CredentialRecord let cachedAt: Date @@ -97,16 +125,28 @@ enum CodexCredentialStore { // MARK: - Bootstrap state static var isBootstrapCompleted: Bool { - get { UserDefaults.standard.bool(forKey: bootstrapCompletedKey) } - set { UserDefaults.standard.set(newValue, forKey: bootstrapCompletedKey) } + get { defaults.bool(forKey: bootstrapCompletedKey) } + set { defaults.set(newValue, forKey: bootstrapCompletedKey) } } - static func resetBootstrap() { + static func resetBootstrap() -> CacheDeleteResult { lock.withLock { memoryCache = nil } - deleteOurCache() + let result = deleteOurCache() + lastCacheDeleteResult = result isBootstrapCompleted = false + return result } + struct CacheDeleteResult: Equatable { + var keychainDeletedOrAbsent: Bool + var legacyDeletedOrAbsent: Bool + var isSuccess: Bool { keychainDeletedOrAbsent && legacyDeletedOrAbsent } + } + + nonisolated(unsafe) static var lastCacheDeleteResult: CacheDeleteResult? + nonisolated(unsafe) static var lastLegacyCleanupFailed = false + + // MARK: - Public API @discardableResult @@ -170,7 +210,7 @@ enum CodexCredentialStore { // MARK: - Bootstrap source: ~/.codex/auth.json private static func readCodexAuth() throws -> CredentialRecord { - let url = FileManager.default.homeDirectoryForCurrentUser.appendingPathComponent(codexAuthPath) + let url = homeDirectory.appendingPathComponent(codexAuthPath) guard FileManager.default.fileExists(atPath: url.path) else { throw StoreError.bootstrapNoSource } @@ -231,7 +271,7 @@ enum CodexCredentialStore { /// key (OPENAI_API_KEY, auth_mode, ...) and only rewrites the tokens dict and /// last_refresh. Keeps the CLI and the menubar on the same rotated grant. private static func writeBackToCodexAuth(record: CredentialRecord) { - let url = FileManager.default.homeDirectoryForCurrentUser.appendingPathComponent(codexAuthPath) + let url = homeDirectory.appendingPathComponent(codexAuthPath) var json: [String: Any] = [:] if let data = try? SafeFile.read(from: url.path, maxBytes: maxCredentialBytes), let existing = try? JSONSerialization.jsonObject(with: data) as? [String: Any] { @@ -250,40 +290,117 @@ enum CodexCredentialStore { guard let out = try? JSONSerialization.data(withJSONObject: json, options: [.prettyPrinted, .sortedKeys]) else { return } - try? out.write(to: url, options: .atomic) + try? SafeFile.write(out, to: url.path, mode: 0o600) } - // MARK: - Local cache file + // MARK: - Local cache (injectable Application Support + Keychain seam) - private static func cacheFileURL() -> URL { - let support = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first - ?? FileManager.default.homeDirectoryForCurrentUser.appendingPathComponent("Library/Application Support") + static func cacheFileURL() -> URL { + let support = applicationSupportDirectoryOverride + ?? FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first + ?? homeDirectory.appendingPathComponent("Library/Application Support") return support .appendingPathComponent("CodeBurn", isDirectory: true) .appendingPathComponent(cacheFilename) } + /// Production-used write path. Persists rotation fields to the CodeBurn + /// Keychain item. After verified read-back, unlinks any legacy JSON. + static func writeOurCache(record: CredentialRecord) throws { + let data = try JSONEncoder().encode(record) + try keychainCache.upsert( + service: ourKeychainService, + account: ourKeychainAccount, + data: data + ) + guard let readBack = try keychainCache.read(service: ourKeychainService, account: ourKeychainAccount), + let roundTrip = try? JSONDecoder().decode(CredentialRecord.self, from: readBack), + roundTrip.accessToken == record.accessToken, + roundTrip.refreshToken == record.refreshToken, + roundTrip.idToken == record.idToken, + roundTrip.accountId == record.accountId + else { + throw StoreError.fileWriteFailed("keychain read-back mismatch") + } + tryUnlinkLegacyAfterVerifiedKeychain() + } + private static func readOurCache() throws -> CredentialRecord? { + if let data = try keychainCache.read(service: ourKeychainService, account: ourKeychainAccount), + let record = try? JSONDecoder().decode(CredentialRecord.self, from: data) { + tryUnlinkLegacyAfterVerifiedKeychain() + return record + } + return try migrateLegacyFileIfPresent() + } + + private static func migrateLegacyFileIfPresent() throws -> CredentialRecord? { let url = cacheFileURL() guard FileManager.default.fileExists(atPath: url.path) else { return nil } - let data = try SafeFile.read(from: url.path, maxBytes: maxCredentialBytes) - guard let record = try? JSONDecoder().decode(CredentialRecord.self, from: data) else { return nil } - return record + + let data: Data + do { + data = try SafeFile.readAfterSecuringPermissions( + from: url.path, + maxBytes: maxCredentialBytes + ) + } catch { + return nil + } + + guard let decoded = try? JSONDecoder().decode(CredentialRecord.self, from: data) else { + return nil + } + + do { + try writeOurCache(record: decoded) + return decoded + } catch { + lastLegacyCleanupFailed = false + return decoded + } } - private static func writeOurCache(record: CredentialRecord) throws { - try writeOurFileCache(record: record) - } - - private static func writeOurFileCache(record: CredentialRecord) throws { + private static func tryUnlinkLegacyAfterVerifiedKeychain() { let url = cacheFileURL() - try FileManager.default.createDirectory(at: url.deletingLastPathComponent(), withIntermediateDirectories: true) - let data = try JSONEncoder().encode(record) - try data.write(to: url, options: [.atomic, .completeFileProtection]) + guard FileManager.default.fileExists(atPath: url.path) else { + lastLegacyCleanupFailed = false + return + } + do { + try FileManager.default.removeItem(at: url) + lastLegacyCleanupFailed = false + } catch { + lastLegacyCleanupFailed = true + } } - private static func deleteOurCache() { - try? FileManager.default.removeItem(at: cacheFileURL()) + @discardableResult + private static func deleteOurCache() -> CacheDeleteResult { + var keychainOK = true + do { + try keychainCache.delete(service: ourKeychainService, account: ourKeychainAccount) + } catch { + keychainOK = false + } + + var legacyOK = true + let url = cacheFileURL() + if FileManager.default.fileExists(atPath: url.path) { + do { + try FileManager.default.removeItem(at: url) + } catch { + legacyOK = false + } + } + return CacheDeleteResult( + keychainDeletedOrAbsent: keychainOK, + legacyDeletedOrAbsent: legacyOK + ) + } + + static func clearMemoryCacheForTesting() { + lock.withLock { memoryCache = nil } } private static func cacheInMemory(_ record: CredentialRecord) { diff --git a/mac/Sources/CodeBurnMenubar/Data/CodexSubscriptionService.swift b/mac/Sources/CodeBurnMenubar/Data/CodexSubscriptionService.swift index d25637c1..7fd5968b 100644 --- a/mac/Sources/CodeBurnMenubar/Data/CodexSubscriptionService.swift +++ b/mac/Sources/CodeBurnMenubar/Data/CodexSubscriptionService.swift @@ -79,7 +79,7 @@ enum CodexSubscriptionService { } static func disconnect() { - CodexCredentialStore.resetBootstrap() + _ = CodexCredentialStore.resetBootstrap() clearUsageBlock() } diff --git a/mac/Sources/CodeBurnMenubar/Security/KeychainCredentialCache.swift b/mac/Sources/CodeBurnMenubar/Security/KeychainCredentialCache.swift new file mode 100644 index 00000000..8ea079fa --- /dev/null +++ b/mac/Sources/CodeBurnMenubar/Security/KeychainCredentialCache.swift @@ -0,0 +1,186 @@ +import Foundation +import Security + +/// Serializes credential-store test harnesses that mutate process-wide seams. +enum CredentialStoreTestIsolation { + static let lock = NSLock() +} + +/// Narrow CodeBurn-owned Keychain cache over exact service/account pairs. +/// +/// Production uses `LiveKeychainCredentialCache`. Tests inject +/// `InMemoryKeychainCredentialCache` so the suite never touches the login +/// Keychain. Errors carry only operation, service, and OSStatus — never blob data. +protocol KeychainCredentialCaching: Sendable { + func read(service: String, account: String) throws -> Data? + func upsert(service: String, account: String, data: Data) throws + func delete(service: String, account: String) throws +} + +enum KeychainCredentialCacheError: Error, LocalizedError, Equatable { + case readFailed(service: String, status: OSStatus) + case writeFailed(service: String, status: OSStatus) + case deleteFailed(service: String, status: OSStatus) + + var errorDescription: String? { + switch self { + case let .readFailed(service, status): + return "Keychain read failed for \(service) (status \(status))." + case let .writeFailed(service, status): + return "Keychain write failed for \(service) (status \(status))." + case let .deleteFailed(service, status): + return "Keychain delete failed for \(service) (status \(status))." + } + } +} + +/// Published CodeBurn Keychain identities. Keep these exact — Electron contracts +/// on the Codex pair, and historical items use the same names. +enum CodeBurnKeychainIdentity { + static let claudeService = "org.agentseal.codeburn.menubar.claude.oauth.v1" + static let codexService = "org.agentseal.codeburn.menubar.codex.oauth.v1" + static let account = "default" +} + +struct LiveKeychainCredentialCache: KeychainCredentialCaching { + func read(service: String, account: String) throws -> Data? { + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: account, + kSecMatchLimit as String: kSecMatchLimitOne, + kSecReturnData as String: true, + ] + var result: CFTypeRef? + let status = SecItemCopyMatching(query as CFDictionary, &result) + if status == errSecItemNotFound { return nil } + guard status == errSecSuccess, let data = result as? Data else { + throw KeychainCredentialCacheError.readFailed(service: service, status: status) + } + return data + } + + func upsert(service: String, account: String, data: Data) throws { + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: account, + ] + let attributes: [String: Any] = [ + kSecValueData as String: data, + ] + let updateStatus = SecItemUpdate(query as CFDictionary, attributes as CFDictionary) + if updateStatus == errSecSuccess { return } + if updateStatus == errSecItemNotFound { + var add = query + add[kSecValueData as String] = data + let addStatus = SecItemAdd(add as CFDictionary, nil) + if addStatus == errSecSuccess { return } + if addStatus == errSecDuplicateItem { + let retry = SecItemUpdate(query as CFDictionary, attributes as CFDictionary) + guard retry == errSecSuccess else { + throw KeychainCredentialCacheError.writeFailed(service: service, status: retry) + } + return + } + throw KeychainCredentialCacheError.writeFailed(service: service, status: addStatus) + } + throw KeychainCredentialCacheError.writeFailed(service: service, status: updateStatus) + } + + func delete(service: String, account: String) throws { + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: account, + ] + let status = SecItemDelete(query as CFDictionary) + if status == errSecSuccess || status == errSecItemNotFound { return } + throw KeychainCredentialCacheError.deleteFailed(service: service, status: status) + } +} + +/// Process-local fake for tests. Never writes to the system Keychain. +final class InMemoryKeychainCredentialCache: KeychainCredentialCaching, @unchecked Sendable { + private let lock = NSLock() + private var items: [String: Data] = [:] + private(set) var upsertCount = 0 + private(set) var readCount = 0 + private(set) var deleteCount = 0 + + private func key(_ service: String, _ account: String) -> String { + "\(service)\u{1f}\(account)" + } + + func read(service: String, account: String) throws -> Data? { + lock.lock(); defer { lock.unlock() } + readCount += 1 + return items[key(service, account)] + } + + func upsert(service: String, account: String, data: Data) throws { + lock.lock(); defer { lock.unlock() } + upsertCount += 1 + items[key(service, account)] = data + } + + func delete(service: String, account: String) throws { + lock.lock(); defer { lock.unlock() } + deleteCount += 1 + items.removeValue(forKey: key(service, account)) + } + + func storedJSONObject(service: String, account: String) -> [String: Any]? { + lock.lock(); defer { lock.unlock() } + guard let data = items[key(service, account)] else { return nil } + return (try? JSONSerialization.jsonObject(with: data)) as? [String: Any] + } + + func storedKeys(service: String, account: String) -> [String]? { + storedJSONObject(service: service, account: account).map { Array($0.keys).sorted() } + } + + /// Snapshot for simulated process restart: keep Keychain bytes, drop nothing else. + func cloneStorage() -> InMemoryKeychainCredentialCache { + lock.lock(); defer { lock.unlock() } + let copy = InMemoryKeychainCredentialCache() + copy.items = items + return copy + } +} + +/// Test double that wraps another backend and can force upsert/read/delete failures. +final class ControllableKeychainCredentialCache: KeychainCredentialCaching, @unchecked Sendable { + private let inner: any KeychainCredentialCaching + var failUpsert = false + var failRead = false + var failDelete = false + var upsertStatus: OSStatus = -1 + var readStatus: OSStatus = -1 + var deleteStatus: OSStatus = -1 + + init(inner: any KeychainCredentialCaching) { + self.inner = inner + } + + func read(service: String, account: String) throws -> Data? { + if failRead { + throw KeychainCredentialCacheError.readFailed(service: service, status: readStatus) + } + return try inner.read(service: service, account: account) + } + + func upsert(service: String, account: String, data: Data) throws { + if failUpsert { + throw KeychainCredentialCacheError.writeFailed(service: service, status: upsertStatus) + } + try inner.upsert(service: service, account: account, data: data) + } + + func delete(service: String, account: String) throws { + if failDelete { + throw KeychainCredentialCacheError.deleteFailed(service: service, status: deleteStatus) + } + try inner.delete(service: service, account: account) + } +} diff --git a/mac/Sources/CodeBurnMenubar/Security/SafeFile.swift b/mac/Sources/CodeBurnMenubar/Security/SafeFile.swift index 3b3dea29..080682d0 100644 --- a/mac/Sources/CodeBurnMenubar/Security/SafeFile.swift +++ b/mac/Sources/CodeBurnMenubar/Security/SafeFile.swift @@ -101,6 +101,72 @@ enum SafeFile { return data } + enum SecureReadError: Swift.Error, Equatable { + case notRegularFile(String) + case wrongOwner(String) + case chmodFailed(String, Int32) + case modeVerifyFailed(String, mode_t) + } + + /// Legacy credential migration path: open with `O_NOFOLLOW`, refuse non-regular / + /// non-owned files, `fchmod(0600)` and verify mode, then read bounded bytes from + /// the same descriptor. Permissions are repaired before any secret byte is read. + static func readAfterSecuringPermissions( + from path: String, + maxBytes: Int = defaultReadLimit, + expectedOwner: uid_t = geteuid() + ) throws -> Data { + var linkInfo = stat() + guard lstat(path, &linkInfo) == 0 else { + throw Error.readFailed(path, errno) + } + if (linkInfo.st_mode & S_IFMT) == S_IFLNK { + throw Error.symlinkDetected(path) + } + guard (linkInfo.st_mode & S_IFMT) == S_IFREG else { + throw SecureReadError.notRegularFile(path) + } + guard linkInfo.st_uid == expectedOwner else { + throw SecureReadError.wrongOwner(path) + } + + let fd = Darwin.open(path, O_RDONLY | O_NOFOLLOW) + guard fd >= 0 else { + throw Error.readFailed(path, errno) + } + defer { Darwin.close(fd) } + + if fchmod(fd, 0o600) != 0 { + throw SecureReadError.chmodFailed(path, errno) + } + var verified = stat() + guard fstat(fd, &verified) == 0 else { + throw Error.readFailed(path, errno) + } + let mode = verified.st_mode & 0o777 + guard mode == 0o600 else { + throw SecureReadError.modeVerifyFailed(path, mode) + } + + let size = Int(verified.st_size) + if size > maxBytes { + throw Error.sizeLimitExceeded(path, size) + } + + var data = Data(count: max(size, 0)) + let readBytes: Int = data.withUnsafeMutableBytes { buffer -> Int in + guard let base = buffer.baseAddress else { return 0 } + return Darwin.read(fd, base, buffer.count) + } + guard readBytes >= 0 else { + throw Error.readFailed(path, errno) + } + if readBytes < data.count { + data = data.prefix(readBytes) + } + return data + } + /// Runs `body` while holding an exclusive POSIX advisory lock on `path`. The lock file is /// created if missing (with 0o600 permissions) and released on scope exit, so other /// codeburn processes (the CLI running in a terminal, say) block on the same file instead diff --git a/mac/Sources/CodeBurnMenubar/Views/SettingsView.swift b/mac/Sources/CodeBurnMenubar/Views/SettingsView.swift index e8c1f218..3ee25a50 100644 --- a/mac/Sources/CodeBurnMenubar/Views/SettingsView.swift +++ b/mac/Sources/CodeBurnMenubar/Views/SettingsView.swift @@ -508,7 +508,7 @@ private struct CodexSettingsTab: View { CodexConnectionRow() } Section { - Text("Codex live-quota tracking reads `~/.codex/auth.json` once on Connect, then keeps a local copy under Application Support so subsequent quota fetches don't re-read the original. Only ChatGPT-mode auth (Plus / Pro / Team / Business / Edu / Enterprise) is supported. API-key users are billed per request and have a different reporting surface. Credit-metered workspaces report no rate-limit windows, so their monthly credit allowance is shown instead.") + Text("Codex live-quota tracking reads `~/.codex/auth.json` once on Connect, then keeps a CodeBurn-owned copy in the macOS Keychain so subsequent quota fetches don't re-read the original. Only ChatGPT-mode auth (Plus / Pro / Team / Business / Edu / Enterprise) is supported. API-key users are billed per request and have a different reporting surface. Credit-metered workspaces report no rate-limit windows, so their monthly credit allowance is shown instead.") .font(.system(size: 11)) .foregroundStyle(.secondary) } header: { diff --git a/mac/Tests/CodeBurnMenubarTests/CredentialKeychainCacheRedTests.swift b/mac/Tests/CodeBurnMenubarTests/CredentialKeychainCacheRedTests.swift new file mode 100644 index 00000000..51646d9b --- /dev/null +++ b/mac/Tests/CodeBurnMenubarTests/CredentialKeychainCacheRedTests.swift @@ -0,0 +1,149 @@ +import Foundation +import Testing +@testable import CodeBurnMenubar + +/// Red receipt for 0B: current plaintext writers must fail these assertions. +/// Disposable sentinels only — never log or expect raw secret values in receipts. +@Suite("Credential Keychain cache red", .serialized) +struct CredentialKeychainCacheRedTests { + private let accessSentinel = "cb-red-access-sentinel" + private let refreshSentinel = "cb-red-refresh-sentinel" + private let idSentinel = "cb-red-id-sentinel" + private let accountSentinel = "cb-red-account-sentinel" + + private func withIsolatedSeams( + _ body: (URL, InMemoryKeychainCredentialCache) throws -> Void + ) throws { + CredentialStoreTestIsolation.lock.lock() + defer { CredentialStoreTestIsolation.lock.unlock() } + + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("codeburn-0b-red-\(UUID().uuidString)", isDirectory: true) + let support = root.appendingPathComponent("Application Support", isDirectory: true) + try FileManager.default.createDirectory(at: support, withIntermediateDirectories: true) + let suiteName = "codeburn.0b.red.\(UUID().uuidString)" + let defaults = UserDefaults(suiteName: suiteName)! + defaults.removePersistentDomain(forName: suiteName) + + let fakeKeychain = InMemoryKeychainCredentialCache() + ClaudeCredentialStore.resetTestSeams() + CodexCredentialStore.resetTestSeams() + ClaudeCredentialStore.applicationSupportDirectoryOverride = support + CodexCredentialStore.applicationSupportDirectoryOverride = support + ClaudeCredentialStore.userDefaultsOverride = defaults + CodexCredentialStore.userDefaultsOverride = defaults + ClaudeCredentialStore.keychainCache = fakeKeychain + CodexCredentialStore.keychainCache = fakeKeychain + + defer { + ClaudeCredentialStore.resetTestSeams() + CodexCredentialStore.resetTestSeams() + defaults.removePersistentDomain(forName: suiteName) + try? FileManager.default.removeItem(at: root) + } + + try body(support, fakeKeychain) + } + + private func posixMode(at url: URL) -> mode_t? { + var info = stat() + guard lstat(url.path, &info) == 0 else { return nil } + return info.st_mode & 0o777 + } + + private func jsonKeys(at url: URL) throws -> [String] { + let data = try Data(contentsOf: url) + let object = try #require(JSONSerialization.jsonObject(with: data) as? [String: Any]) + return object.keys.sorted() + } + + @Test("Claude writeOurCache leaves no JSON and stores Keychain payload without refreshToken") + func claudeWriteUsesKeychainWithoutRefreshToken() throws { + try withIsolatedSeams { support, fakeKeychain in + let record = ClaudeCredentialStore.CredentialRecord( + accessToken: accessSentinel, + refreshToken: refreshSentinel, + expiresAt: Date(timeIntervalSince1970: 1_900_000_000), + rateLimitTier: "default" + ) + + try ClaudeCredentialStore.writeOurCache(record: record) + + let legacyURL = ClaudeCredentialStore.cacheFileURL() + let legacyExists = FileManager.default.fileExists(atPath: legacyURL.path) + let mode = legacyExists ? posixMode(at: legacyURL) : nil + let fileKeys = legacyExists ? try jsonKeys(at: legacyURL) : [] + let keychainKeys = fakeKeychain.storedKeys( + service: ClaudeCredentialStore.ourKeychainService, + account: ClaudeCredentialStore.ourKeychainAccount + ) + let keychainObject = fakeKeychain.storedJSONObject( + service: ClaudeCredentialStore.ourKeychainService, + account: ClaudeCredentialStore.ourKeychainAccount + ) + let hasRefreshKey = keychainObject?.keys.contains("refreshToken") == true + let refreshValueMatches = (keychainObject?["refreshToken"] as? String) == refreshSentinel + + // Intended green behavior (must fail against current plaintext writer): + #expect(!legacyExists, "legacy Claude JSON must not be created under Application Support") + #expect(fakeKeychain.upsertCount >= 1, "fake Keychain must receive a Claude upsert") + #expect(keychainKeys != nil, "Claude Keychain payload must exist") + #expect(!(keychainKeys?.contains("refreshToken") ?? false), "Claude Keychain keys must omit refreshToken") + #expect(!hasRefreshKey && !refreshValueMatches, "Claude Keychain must not persist refreshToken") + + // Red diagnostic (keys + mode only; never fixture values): + if legacyExists { + Issue.record( + Comment(rawValue: "RED Claude legacy present mode=\(String(mode.map { String($0, radix: 8) } ?? "nil")) keys=\(fileKeys.joined(separator: ","))") + ) + } + if fakeKeychain.upsertCount == 0 { + Issue.record(Comment(rawValue: "RED Claude Keychain upsertCount=0")) + } + _ = support + } + } + + @Test("Codex writeOurCache leaves no JSON and stores Keychain rotation fields") + func codexWriteUsesKeychainWithRotationFields() throws { + try withIsolatedSeams { support, fakeKeychain in + let record = CodexCredentialStore.CredentialRecord( + accessToken: accessSentinel, + refreshToken: refreshSentinel, + idToken: idSentinel, + accountId: accountSentinel, + expiresAt: Date(timeIntervalSince1970: 1_900_000_100), + lastRefresh: Date(timeIntervalSince1970: 1_700_000_000) + ) + + try CodexCredentialStore.writeOurCache(record: record) + + let legacyURL = CodexCredentialStore.cacheFileURL() + let legacyExists = FileManager.default.fileExists(atPath: legacyURL.path) + let mode = legacyExists ? posixMode(at: legacyURL) : nil + let fileKeys = legacyExists ? try jsonKeys(at: legacyURL) : [] + let keychainKeys = fakeKeychain.storedKeys( + service: CodexCredentialStore.ourKeychainService, + account: CodexCredentialStore.ourKeychainAccount + ) + + let required = ["accessToken", "refreshToken", "idToken", "accountId", "lastRefresh"] + let missingRequired = required.filter { !(keychainKeys?.contains($0) ?? false) } + + #expect(!legacyExists, "legacy Codex JSON must not be created under Application Support") + #expect(fakeKeychain.upsertCount >= 1, "fake Keychain must receive a Codex upsert") + #expect(keychainKeys != nil, "Codex Keychain payload must exist") + #expect(missingRequired.isEmpty, "Codex Keychain must retain rotation fields") + + if legacyExists { + Issue.record( + Comment(rawValue: "RED Codex legacy present mode=\(String(mode.map { String($0, radix: 8) } ?? "nil")) keys=\(fileKeys.joined(separator: ","))") + ) + } + if fakeKeychain.upsertCount == 0 { + Issue.record(Comment(rawValue: "RED Codex Keychain upsertCount=0")) + } + _ = support + } + } +} diff --git a/mac/Tests/CodeBurnMenubarTests/CredentialKeychainContinuityTests.swift b/mac/Tests/CodeBurnMenubarTests/CredentialKeychainContinuityTests.swift new file mode 100644 index 00000000..00ffc36f --- /dev/null +++ b/mac/Tests/CodeBurnMenubarTests/CredentialKeychainContinuityTests.swift @@ -0,0 +1,341 @@ +import Foundation +import Testing +@testable import CodeBurnMenubar + +/// Implementation-continuity tests for option-3 evidence bar. +/// Uses only InMemory/Controllable Keychain backends and temp Application Support — +/// never the operator login Keychain or live credential files. +@Suite("Credential Keychain implementation continuity", .serialized) +struct CredentialKeychainContinuityTests { + private let accessSentinel = "cb-cont-access-sentinel" + private let refreshSentinel = "cb-cont-refresh-sentinel" + private let idSentinel = "cb-cont-id-sentinel" + private let accountSentinel = "cb-cont-account-sentinel" + + private struct Harness { + let root: URL + let support: URL + let defaults: UserDefaults + let suiteName: String + let fakeKeychain: InMemoryKeychainCredentialCache + } + + private func withHarness( + _ body: (Harness) throws -> Void + ) throws { + CredentialStoreTestIsolation.lock.lock() + defer { CredentialStoreTestIsolation.lock.unlock() } + + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("codeburn-0b-cont-\(UUID().uuidString)", isDirectory: true) + let support = root.appendingPathComponent("Application Support", isDirectory: true) + try FileManager.default.createDirectory(at: support, withIntermediateDirectories: true) + let suiteName = "codeburn.0b.cont.\(UUID().uuidString)" + let defaults = UserDefaults(suiteName: suiteName)! + defaults.removePersistentDomain(forName: suiteName) + let fake = InMemoryKeychainCredentialCache() + + ClaudeCredentialStore.resetTestSeams() + CodexCredentialStore.resetTestSeams() + ClaudeCredentialStore.applicationSupportDirectoryOverride = support + CodexCredentialStore.applicationSupportDirectoryOverride = support + ClaudeCredentialStore.homeDirectoryOverride = root + CodexCredentialStore.homeDirectoryOverride = root + ClaudeCredentialStore.userDefaultsOverride = defaults + CodexCredentialStore.userDefaultsOverride = defaults + ClaudeCredentialStore.keychainCache = fake + CodexCredentialStore.keychainCache = fake + + defer { + ClaudeCredentialStore.resetTestSeams() + CodexCredentialStore.resetTestSeams() + defaults.removePersistentDomain(forName: suiteName) + try? FileManager.default.removeItem(at: root) + } + + try body(Harness(root: root, support: support, defaults: defaults, suiteName: suiteName, fakeKeychain: fake)) + } + + private func posixMode(at url: URL) -> mode_t? { + var info = stat() + guard lstat(url.path, &info) == 0 else { return nil } + return info.st_mode & 0o777 + } + + private func writeLegacyClaude0644(record: ClaudeCredentialStore.CredentialRecord) throws { + let url = ClaudeCredentialStore.cacheFileURL() + try FileManager.default.createDirectory(at: url.deletingLastPathComponent(), withIntermediateDirectories: true) + let data = try JSONEncoder().encode(record) + try data.write(to: url) + try FileManager.default.setAttributes([.posixPermissions: 0o644], ofItemAtPath: url.path) + } + + private func writeLegacyCodex0644(record: CodexCredentialStore.CredentialRecord) throws { + let url = CodexCredentialStore.cacheFileURL() + try FileManager.default.createDirectory(at: url.deletingLastPathComponent(), withIntermediateDirectories: true) + let data = try JSONEncoder().encode(record) + try data.write(to: url) + try FileManager.default.setAttributes([.posixPermissions: 0o644], ofItemAtPath: url.path) + } + + // MARK: - Continuity lifecycle + + @Test("Claude write → simulated restart → read → update → delete") + func claudeWriteRestartReadUpdateDelete() throws { + try withHarness { harness in + let initial = ClaudeCredentialStore.CredentialRecord( + accessToken: accessSentinel, + refreshToken: refreshSentinel, + expiresAt: Date(timeIntervalSince1970: 1_900_000_000), + rateLimitTier: "default" + ) + try ClaudeCredentialStore.writeOurCache(record: initial) + ClaudeCredentialStore.isBootstrapCompleted = true + + #expect(!FileManager.default.fileExists(atPath: ClaudeCredentialStore.cacheFileURL().path)) + let keys = harness.fakeKeychain.storedKeys( + service: ClaudeCredentialStore.ourKeychainService, + account: ClaudeCredentialStore.ourKeychainAccount + ) + #expect(keys?.contains("refreshToken") != true) + #expect(keys?.contains("accessToken") == true) + + // Simulated process restart: drop memory, keep Keychain bytes. + ClaudeCredentialStore.clearMemoryCacheForTesting() + let afterRestart = try #require(try ClaudeCredentialStore.currentRecord()) + #expect(afterRestart.accessToken == accessSentinel) + #expect(afterRestart.refreshToken == nil) + + let updated = ClaudeCredentialStore.CredentialRecord( + accessToken: accessSentinel + "-rotated", + refreshToken: refreshSentinel, + expiresAt: Date(timeIntervalSince1970: 1_900_000_100), + rateLimitTier: "default" + ) + try ClaudeCredentialStore.writeOurCache(record: updated) + ClaudeCredentialStore.clearMemoryCacheForTesting() + let afterUpdate = try #require(try ClaudeCredentialStore.currentRecord()) + #expect(afterUpdate.accessToken == accessSentinel + "-rotated") + + let deleteResult = ClaudeCredentialStore.resetBootstrap() + #expect(deleteResult.isSuccess) + let afterDelete = try harness.fakeKeychain.read( + service: ClaudeCredentialStore.ourKeychainService, + account: ClaudeCredentialStore.ourKeychainAccount + ) + #expect(afterDelete == nil) + let afterDisconnect = try ClaudeCredentialStore.currentRecord() + #expect(afterDisconnect == nil) + } + } + + @Test("Codex write → simulated restart → read → update → delete") + func codexWriteRestartReadUpdateDelete() throws { + try withHarness { harness in + let initial = CodexCredentialStore.CredentialRecord( + accessToken: accessSentinel, + refreshToken: refreshSentinel, + idToken: idSentinel, + accountId: accountSentinel, + expiresAt: nil, + lastRefresh: Date(timeIntervalSince1970: 1_700_000_000) + ) + try CodexCredentialStore.writeOurCache(record: initial) + CodexCredentialStore.isBootstrapCompleted = true + + #expect(!FileManager.default.fileExists(atPath: CodexCredentialStore.cacheFileURL().path)) + let keys = harness.fakeKeychain.storedKeys( + service: CodexCredentialStore.ourKeychainService, + account: CodexCredentialStore.ourKeychainAccount + ) + for required in ["accessToken", "refreshToken", "idToken", "accountId", "lastRefresh"] { + #expect(keys?.contains(required) == true) + } + + CodexCredentialStore.clearMemoryCacheForTesting() + // Without ~/.codex/auth.json, currentRecord falls through to Keychain cache. + let afterRestart = try #require(try CodexCredentialStore.currentRecord()) + #expect(afterRestart.accessToken == accessSentinel) + #expect(afterRestart.refreshToken == refreshSentinel) + + let updated = CodexCredentialStore.CredentialRecord( + accessToken: accessSentinel + "-rotated", + refreshToken: refreshSentinel + "-rotated", + idToken: idSentinel, + accountId: accountSentinel, + expiresAt: nil, + lastRefresh: Date(timeIntervalSince1970: 1_700_000_800) + ) + try CodexCredentialStore.writeOurCache(record: updated) + CodexCredentialStore.clearMemoryCacheForTesting() + let afterUpdate = try #require(try CodexCredentialStore.currentRecord()) + #expect(afterUpdate.accessToken == accessSentinel + "-rotated") + #expect(afterUpdate.refreshToken == refreshSentinel + "-rotated") + + let deleteResult = CodexCredentialStore.resetBootstrap() + #expect(deleteResult.isSuccess) + let afterDisconnect = try CodexCredentialStore.currentRecord() + #expect(afterDisconnect == nil) + } + } + + // MARK: - Migration + + @Test("successful Claude legacy 0644 migration unlinks JSON and omits refreshToken") + func claudeSuccessfulLegacyMigration() throws { + try withHarness { harness in + let legacy = ClaudeCredentialStore.CredentialRecord( + accessToken: accessSentinel, + refreshToken: refreshSentinel, + expiresAt: Date(timeIntervalSince1970: 1_900_000_000), + rateLimitTier: "default" + ) + try writeLegacyClaude0644(record: legacy) + #expect(posixMode(at: ClaudeCredentialStore.cacheFileURL()) == 0o644) + + ClaudeCredentialStore.isBootstrapCompleted = true + let migrated = try #require(try ClaudeCredentialStore.currentRecord()) + #expect(migrated.accessToken == accessSentinel) + #expect(migrated.refreshToken == nil) + #expect(!FileManager.default.fileExists(atPath: ClaudeCredentialStore.cacheFileURL().path)) + #expect(harness.fakeKeychain.upsertCount >= 1) + let keys = harness.fakeKeychain.storedKeys( + service: ClaudeCredentialStore.ourKeychainService, + account: ClaudeCredentialStore.ourKeychainAccount + ) + #expect(keys?.contains("refreshToken") != true) + #expect(ClaudeCredentialStore.lastLegacyCleanupFailed == false) + } + } + + @Test("failed Claude Keychain upsert leaves secured legacy file") + func claudeFailedMigrationKeepsLegacy() throws { + try withHarness { harness in + let controllable = ControllableKeychainCredentialCache(inner: harness.fakeKeychain) + controllable.failUpsert = true + ClaudeCredentialStore.keychainCache = controllable + + let legacy = ClaudeCredentialStore.CredentialRecord( + accessToken: accessSentinel, + refreshToken: refreshSentinel, + expiresAt: Date(timeIntervalSince1970: 1_900_000_000), + rateLimitTier: "default" + ) + try writeLegacyClaude0644(record: legacy) + ClaudeCredentialStore.isBootstrapCompleted = true + + let record = try #require(try ClaudeCredentialStore.currentRecord()) + #expect(record.accessToken == accessSentinel) + #expect(FileManager.default.fileExists(atPath: ClaudeCredentialStore.cacheFileURL().path)) + #expect(posixMode(at: ClaudeCredentialStore.cacheFileURL()) == 0o600) + #expect(harness.fakeKeychain.upsertCount == 0) + } + } + + @Test("successful Codex legacy migration unlinks JSON and keeps rotation fields") + func codexSuccessfulLegacyMigration() throws { + try withHarness { harness in + let legacy = CodexCredentialStore.CredentialRecord( + accessToken: accessSentinel, + refreshToken: refreshSentinel, + idToken: idSentinel, + accountId: accountSentinel, + expiresAt: nil, + lastRefresh: Date(timeIntervalSince1970: 1_700_000_000) + ) + try writeLegacyCodex0644(record: legacy) + CodexCredentialStore.isBootstrapCompleted = true + + let migrated = try #require(try CodexCredentialStore.currentRecord()) + #expect(migrated.refreshToken == refreshSentinel) + #expect(!FileManager.default.fileExists(atPath: CodexCredentialStore.cacheFileURL().path)) + let keys = harness.fakeKeychain.storedKeys( + service: CodexCredentialStore.ourKeychainService, + account: CodexCredentialStore.ourKeychainAccount + ) + #expect(keys?.contains("refreshToken") == true) + #expect(keys?.contains("lastRefresh") == true) + } + } + + @Test("symlink legacy Claude file is refused and left in place") + func claudeSymlinkLegacyRefused() throws { + try withHarness { _ in + let codeburnDir = ClaudeCredentialStore.cacheFileURL().deletingLastPathComponent() + try FileManager.default.createDirectory(at: codeburnDir, withIntermediateDirectories: true) + let target = codeburnDir.appendingPathComponent("not-a-cred.txt") + try Data("x".utf8).write(to: target) + let link = ClaudeCredentialStore.cacheFileURL() + try FileManager.default.createSymbolicLink(at: link, withDestinationURL: target) + + ClaudeCredentialStore.isBootstrapCompleted = true + let afterSymlink = try ClaudeCredentialStore.currentRecord() + #expect(afterSymlink == nil) + #expect(FileManager.default.fileExists(atPath: link.path)) + } + } + + // MARK: - Disconnect / reinstall + + @Test("disconnect not-found is success; delete failure is observable") + func disconnectIdempotentAndPartialFailure() throws { + try withHarness { harness in + let empty = ClaudeCredentialStore.resetBootstrap() + #expect(empty.isSuccess) + + try ClaudeCredentialStore.writeOurCache(record: .init( + accessToken: accessSentinel, + refreshToken: refreshSentinel, + expiresAt: nil, + rateLimitTier: nil + )) + let controllable = ControllableKeychainCredentialCache(inner: harness.fakeKeychain) + controllable.failDelete = true + ClaudeCredentialStore.keychainCache = controllable + + let failed = ClaudeCredentialStore.resetBootstrap() + #expect(!failed.isSuccess) + #expect(failed.keychainDeletedOrAbsent == false) + #expect(ClaudeCredentialStore.lastCacheDeleteResult?.isSuccess == false) + } + } + + @Test("reinstall with empty Keychain and no legacy clears bootstrap on read") + func reinstallMissingCacheClearsBootstrap() throws { + try withHarness { _ in + ClaudeCredentialStore.isBootstrapCompleted = true + let missing = try ClaudeCredentialStore.currentRecord() + #expect(missing == nil) + #expect(ClaudeCredentialStore.isBootstrapCompleted == false) + } + } + + @Test("corrupt Keychain plus valid legacy repairs the CodeBurn item") + func corruptKeychainRepairedFromLegacy() throws { + try withHarness { harness in + try harness.fakeKeychain.upsert( + service: ClaudeCredentialStore.ourKeychainService, + account: ClaudeCredentialStore.ourKeychainAccount, + data: Data("%not-json%".utf8) + ) + let legacy = ClaudeCredentialStore.CredentialRecord( + accessToken: accessSentinel, + refreshToken: refreshSentinel, + expiresAt: Date(timeIntervalSince1970: 1_900_000_000), + rateLimitTier: "default" + ) + try writeLegacyClaude0644(record: legacy) + ClaudeCredentialStore.isBootstrapCompleted = true + + let repaired = try #require(try ClaudeCredentialStore.currentRecord()) + #expect(repaired.accessToken == accessSentinel) + #expect(repaired.refreshToken == nil) + #expect(!FileManager.default.fileExists(atPath: ClaudeCredentialStore.cacheFileURL().path)) + let object = harness.fakeKeychain.storedJSONObject( + service: ClaudeCredentialStore.ourKeychainService, + account: ClaudeCredentialStore.ourKeychainAccount + ) + #expect(object?.keys.contains("refreshToken") != true) + } + } +} From 252ea92d3b8a8fc3057fe835e74ff72d4f445fd3 Mon Sep 17 00:00:00 2001 From: Aditya Vikram Singh <247195684+avs-io@users.noreply.github.com> Date: Wed, 19 Aug 2026 20:02:44 +0530 Subject: [PATCH 73/85] fix(menubar): keep Keychain failure paths from leaving plaintext A valid Keychain item plus a leftover JSON used to skip chmod, so a failed unlink could leave 0644 secrets on disk. Failed Disconnect also cleared bootstrap and hid the retry. Repair leftover files to 0600, keep bootstrap when Keychain delete fails, revalidate the opened fd, and loop the secure read. --- mac/Sources/CodeBurnMenubar/AppStore.swift | 12 +++++++ .../Data/ClaudeCredentialStore.swift | 18 ++++++++-- .../Data/CodexCredentialStore.swift | 13 +++++-- .../CodeBurnMenubar/Security/SafeFile.swift | 35 ++++++++++++++----- .../CredentialKeychainContinuityTests.swift | 25 +++++++++++++ 5 files changed, 89 insertions(+), 14 deletions(-) diff --git a/mac/Sources/CodeBurnMenubar/AppStore.swift b/mac/Sources/CodeBurnMenubar/AppStore.swift index 33ac8d12..98bd91f8 100644 --- a/mac/Sources/CodeBurnMenubar/AppStore.swift +++ b/mac/Sources/CodeBurnMenubar/AppStore.swift @@ -1069,6 +1069,13 @@ final class AppStore { // resumes after this point detects the disconnect and discards its // result instead of re-populating the cleared state. claudeRefreshGen &+= 1 + if let result = ClaudeCredentialStore.lastCacheDeleteResult, !result.keychainDeletedOrAbsent { + // Keychain item still present — keep Connect/Disconnect on the + // connected path so the user can retry delete. + subscriptionError = "Could not fully remove the local Claude credential cache. Disconnect again to retry." + NotificationCenter.default.post(name: .codeBurnSubscriptionDisconnected, object: nil) + return + } subscription = nil if let result = ClaudeCredentialStore.lastCacheDeleteResult, !result.isSuccess { subscriptionError = "Could not fully remove the local Claude credential cache." @@ -1137,6 +1144,11 @@ final class AppStore { func disconnectCodex() { CodexSubscriptionService.disconnect() codexRefreshGen &+= 1 + if let result = CodexCredentialStore.lastCacheDeleteResult, !result.keychainDeletedOrAbsent { + codexError = "Could not fully remove the local Codex credential cache. Disconnect again to retry." + NotificationCenter.default.post(name: .codeBurnSubscriptionDisconnected, object: nil) + return + } codexUsage = nil if let result = CodexCredentialStore.lastCacheDeleteResult, !result.isSuccess { codexError = "Could not fully remove the local Codex credential cache." diff --git a/mac/Sources/CodeBurnMenubar/Data/ClaudeCredentialStore.swift b/mac/Sources/CodeBurnMenubar/Data/ClaudeCredentialStore.swift index d9e691e6..438dc397 100644 --- a/mac/Sources/CodeBurnMenubar/Data/ClaudeCredentialStore.swift +++ b/mac/Sources/CodeBurnMenubar/Data/ClaudeCredentialStore.swift @@ -56,6 +56,7 @@ enum ClaudeCredentialStore { keychainCache = LiveKeychainCredentialCache() lastCacheDeleteResult = nil lastLegacyCleanupFailed = false + unlinkLegacyOverride = nil lock.withLock { memoryCache = nil } } @@ -134,7 +135,11 @@ enum ClaudeCredentialStore { lock.withLock { memoryCache = nil } let result = deleteOurCache() lastCacheDeleteResult = result - isBootstrapCompleted = false + // A failed Keychain delete must not pretend the provider is disconnected. + // Clearing the flag hides Disconnect and orphans the remaining item. + if result.keychainDeletedOrAbsent { + isBootstrapCompleted = false + } return result } @@ -150,6 +155,8 @@ enum ClaudeCredentialStore { /// Last legacy-unlink failure after a verified Keychain write (cleanup retry signal). nonisolated(unsafe) static var lastLegacyCleanupFailed = false + /// Test seam: force legacy unlink to throw so we can assert the 0600 repair. + nonisolated(unsafe) static var unlinkLegacyOverride: ((URL) throws -> Void)? // MARK: - Public API @@ -490,11 +497,16 @@ enum ClaudeCredentialStore { return } do { - try FileManager.default.removeItem(at: url) + if let unlinkLegacyOverride { + try unlinkLegacyOverride(url) + } else { + try FileManager.default.removeItem(at: url) + } lastLegacyCleanupFailed = false } catch { - // Retain valid Keychain item + 0600 file; surface for later retry. lastLegacyCleanupFailed = true + // Verified Keychain item exists — leftover plaintext must not stay 0644. + try? FileManager.default.setAttributes([.posixPermissions: 0o600], ofItemAtPath: url.path) } } diff --git a/mac/Sources/CodeBurnMenubar/Data/CodexCredentialStore.swift b/mac/Sources/CodeBurnMenubar/Data/CodexCredentialStore.swift index 96bf71fd..8ab257e3 100644 --- a/mac/Sources/CodeBurnMenubar/Data/CodexCredentialStore.swift +++ b/mac/Sources/CodeBurnMenubar/Data/CodexCredentialStore.swift @@ -43,6 +43,7 @@ enum CodexCredentialStore { keychainCache = LiveKeychainCredentialCache() lastCacheDeleteResult = nil lastLegacyCleanupFailed = false + unlinkLegacyOverride = nil lock.withLock { memoryCache = nil } } @@ -133,7 +134,9 @@ enum CodexCredentialStore { lock.withLock { memoryCache = nil } let result = deleteOurCache() lastCacheDeleteResult = result - isBootstrapCompleted = false + if result.keychainDeletedOrAbsent { + isBootstrapCompleted = false + } return result } @@ -145,6 +148,7 @@ enum CodexCredentialStore { nonisolated(unsafe) static var lastCacheDeleteResult: CacheDeleteResult? nonisolated(unsafe) static var lastLegacyCleanupFailed = false + nonisolated(unsafe) static var unlinkLegacyOverride: ((URL) throws -> Void)? // MARK: - Public API @@ -368,10 +372,15 @@ enum CodexCredentialStore { return } do { - try FileManager.default.removeItem(at: url) + if let unlinkLegacyOverride { + try unlinkLegacyOverride(url) + } else { + try FileManager.default.removeItem(at: url) + } lastLegacyCleanupFailed = false } catch { lastLegacyCleanupFailed = true + try? FileManager.default.setAttributes([.posixPermissions: 0o600], ofItemAtPath: url.path) } } diff --git a/mac/Sources/CodeBurnMenubar/Security/SafeFile.swift b/mac/Sources/CodeBurnMenubar/Security/SafeFile.swift index 080682d0..6a300a7f 100644 --- a/mac/Sources/CodeBurnMenubar/Security/SafeFile.swift +++ b/mac/Sources/CodeBurnMenubar/Security/SafeFile.swift @@ -136,6 +136,17 @@ enum SafeFile { } defer { Darwin.close(fd) } + var opened = stat() + guard fstat(fd, &opened) == 0 else { + throw Error.readFailed(path, errno) + } + guard (opened.st_mode & S_IFMT) == S_IFREG else { + throw SecureReadError.notRegularFile(path) + } + guard opened.st_uid == expectedOwner else { + throw SecureReadError.wrongOwner(path) + } + if fchmod(fd, 0o600) != 0 { throw SecureReadError.chmodFailed(path, errno) } @@ -153,16 +164,22 @@ enum SafeFile { throw Error.sizeLimitExceeded(path, size) } - var data = Data(count: max(size, 0)) - let readBytes: Int = data.withUnsafeMutableBytes { buffer -> Int in - guard let base = buffer.baseAddress else { return 0 } - return Darwin.read(fd, base, buffer.count) + var data = Data() + data.reserveCapacity(max(size, 0)) + var chunk = [UInt8](repeating: 0, count: 4096) + while data.count < maxBytes { + let n = chunk.withUnsafeMutableBytes { buffer -> Int in + guard let base = buffer.baseAddress else { return 0 } + return Darwin.read(fd, base, buffer.count) + } + guard n >= 0 else { + throw Error.readFailed(path, errno) + } + if n == 0 { break } + data.append(contentsOf: chunk.prefix(n)) } - guard readBytes >= 0 else { - throw Error.readFailed(path, errno) - } - if readBytes < data.count { - data = data.prefix(readBytes) + if data.count > maxBytes { + throw Error.sizeLimitExceeded(path, data.count) } return data } diff --git a/mac/Tests/CodeBurnMenubarTests/CredentialKeychainContinuityTests.swift b/mac/Tests/CodeBurnMenubarTests/CredentialKeychainContinuityTests.swift index 00ffc36f..1b75a9e1 100644 --- a/mac/Tests/CodeBurnMenubarTests/CredentialKeychainContinuityTests.swift +++ b/mac/Tests/CodeBurnMenubarTests/CredentialKeychainContinuityTests.swift @@ -289,6 +289,7 @@ struct CredentialKeychainContinuityTests { expiresAt: nil, rateLimitTier: nil )) + ClaudeCredentialStore.isBootstrapCompleted = true let controllable = ControllableKeychainCredentialCache(inner: harness.fakeKeychain) controllable.failDelete = true ClaudeCredentialStore.keychainCache = controllable @@ -297,6 +298,30 @@ struct CredentialKeychainContinuityTests { #expect(!failed.isSuccess) #expect(failed.keychainDeletedOrAbsent == false) #expect(ClaudeCredentialStore.lastCacheDeleteResult?.isSuccess == false) + #expect(ClaudeCredentialStore.isBootstrapCompleted == true) + } + } + + @Test("failed unlink after verified Keychain repairs leftover JSON to 0600") + func failedUnlinkRepairsLegacyMode() throws { + try withHarness { _ in + let record = ClaudeCredentialStore.CredentialRecord( + accessToken: accessSentinel, + refreshToken: refreshSentinel, + expiresAt: Date(timeIntervalSince1970: 1_900_000_000), + rateLimitTier: "default" + ) + try ClaudeCredentialStore.writeOurCache(record: record) + try writeLegacyClaude0644(record: record) + ClaudeCredentialStore.isBootstrapCompleted = true + ClaudeCredentialStore.unlinkLegacyOverride = { _ in + throw POSIXError(.EPERM) + } + ClaudeCredentialStore.clearMemoryCacheForTesting() + _ = try ClaudeCredentialStore.currentRecord() + #expect(ClaudeCredentialStore.lastLegacyCleanupFailed == true) + #expect(FileManager.default.fileExists(atPath: ClaudeCredentialStore.cacheFileURL().path)) + #expect(posixMode(at: ClaudeCredentialStore.cacheFileURL()) == 0o600) } } From d3f86f5d16250d42c8c800bcb3393e334930da53 Mon Sep 17 00:00:00 2001 From: Aditya Vikram Singh <247195684+avs-io@users.noreply.github.com> Date: Wed, 19 Aug 2026 20:44:18 +0530 Subject: [PATCH 74/85] fix(models): alias bare MiMo 2.5 ids to the LiteLLM Xiaomi rows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Hermes and token-plan sessions store mimo-v2.5-pro. The snapshot row is xiaomi/mimo-v2.5-pro. Same class as the existing mimo-v2-flash alias. No invented rate. Looking up the display name on the stripped leaf before following a pricing alias, so cline-pass/mimo-v2.5-pro cannot recurse strip → alias → last-segment forever. --- src/models.ts | 16 ++++++++++++---- tests/models.test.ts | 2 ++ 2 files changed, 14 insertions(+), 4 deletions(-) diff --git a/src/models.ts b/src/models.ts index c0e6746f..1b910469 100644 --- a/src/models.ts +++ b/src/models.ts @@ -297,6 +297,10 @@ const BUILTIN_ALIASES: Record = { 'k3-agent': 'kimi-k3', 'k2d6-agent': 'kimi-k2p6', 'mimo-v2-flash': 'xiaomi/mimo-v2-flash', + // Hermes / Xiaomi token-plan sessions store the bare id. LiteLLM's row is + // namespaced. Same class as mimo-v2-flash above — do not invent a rate. + 'mimo-v2.5-pro': 'xiaomi/mimo-v2.5-pro', + 'mimo-v2.5': 'xiaomi/mimo-v2.5', 'kat-coder-pro-v1': 'kwaipilot/kat-coder-pro', // Cursor emits dot-version tier-last names plus tier/reasoning suffixes // that LiteLLM does not index (`-high`, `-low`, `-medium`, `-thinking`, @@ -983,13 +987,17 @@ function deriveClaudeShortName(canonical: string): string | undefined { export function getShortModelName(model: string): string { if (autoModelNames[model]) return autoModelNames[model] - const canonical = resolveAlias(getCanonicalName(model)) + const stripped = getCanonicalName(model) + // Pricing aliases may re-namespace a leaf (mimo-v2.5-pro → xiaomi/…). + // Display names live on the leaf. Look that up before following the alias + // or we recurse forever: strip → alias → last-segment → strip. + for (const [key, name] of SORTED_SHORT_NAMES) { + if (stripped === key || stripped.startsWith(key + '-')) return name + } + const canonical = resolveAlias(stripped) const claude = deriveClaudeShortName(canonical) if (claude) return claude for (const [key, name] of SORTED_SHORT_NAMES) { - // Match on a version boundary, not a bare prefix: an unlisted future minor - // (e.g. gpt-5.6) must NOT collapse into the base "gpt-5" entry — it should - // fall through to its raw id rather than show a wrong name/tier. if (canonical === key || canonical.startsWith(key + '-')) return name } // getCanonicalName only strips the leading provider prefix, so a raw diff --git a/tests/models.test.ts b/tests/models.test.ts index 3e2b1655..080dec4a 100644 --- a/tests/models.test.ts +++ b/tests/models.test.ts @@ -737,6 +737,8 @@ describe('provider pricing suffix variants', () => { describe('observed provider model aliases', () => { const cases: Array<[string, string]> = [ ['MiMo-V2-Flash', 'xiaomi/mimo-v2-flash'], + ['mimo-v2.5-pro', 'xiaomi/mimo-v2.5-pro'], + ['MiMo-v2.5-Pro', 'xiaomi/mimo-v2.5-pro'], ['KAT-Coder-Pro-V1', 'kwaipilot/kat-coder-pro'], // Kimi Code wires report bare `k3` in llm.request.model; it must price // through the kimi-k3 table entry, not fall through to $0. From 98d109d425f265b8f6f223d39a67b79a0be6302c Mon Sep 17 00:00:00 2001 From: Aditya Vikram Singh <247195684+avs-io@users.noreply.github.com> Date: Wed, 19 Aug 2026 20:56:07 +0530 Subject: [PATCH 75/85] fix(menubar): fail closed on leftover JSON and partial Disconnect Extra High MERGE AFTER FIX on 252ea92. Pathname chmod was unverified. Disconnect hid retry when only the legacy file survived. Secure read stopped at exactly maxBytes. Tighten leftovers via opened-fd fchmod+fstat. Keep bootstrap unless both Keychain and legacy deletes succeed. Read maxBytes+1 so growth past the limit is rejected. --- mac/Sources/CodeBurnMenubar/AppStore.swift | 8 ++-- .../Data/ClaudeCredentialStore.swift | 21 +++++++-- .../Data/CodexCredentialStore.swift | 20 ++++++-- .../CodeBurnMenubar/Security/SafeFile.swift | 41 ++++++++++++++++- .../CredentialKeychainContinuityTests.swift | 46 +++++++++++++++++++ 5 files changed, 123 insertions(+), 13 deletions(-) diff --git a/mac/Sources/CodeBurnMenubar/AppStore.swift b/mac/Sources/CodeBurnMenubar/AppStore.swift index 98bd91f8..2ca365f0 100644 --- a/mac/Sources/CodeBurnMenubar/AppStore.swift +++ b/mac/Sources/CodeBurnMenubar/AppStore.swift @@ -1069,9 +1069,9 @@ final class AppStore { // resumes after this point detects the disconnect and discards its // result instead of re-populating the cleared state. claudeRefreshGen &+= 1 - if let result = ClaudeCredentialStore.lastCacheDeleteResult, !result.keychainDeletedOrAbsent { - // Keychain item still present — keep Connect/Disconnect on the - // connected path so the user can retry delete. + if let result = ClaudeCredentialStore.lastCacheDeleteResult, !result.isSuccess { + // Any leftover Keychain item or plaintext must keep Disconnect + // so the user can retry delete. subscriptionError = "Could not fully remove the local Claude credential cache. Disconnect again to retry." NotificationCenter.default.post(name: .codeBurnSubscriptionDisconnected, object: nil) return @@ -1144,7 +1144,7 @@ final class AppStore { func disconnectCodex() { CodexSubscriptionService.disconnect() codexRefreshGen &+= 1 - if let result = CodexCredentialStore.lastCacheDeleteResult, !result.keychainDeletedOrAbsent { + if let result = CodexCredentialStore.lastCacheDeleteResult, !result.isSuccess { codexError = "Could not fully remove the local Codex credential cache. Disconnect again to retry." NotificationCenter.default.post(name: .codeBurnSubscriptionDisconnected, object: nil) return diff --git a/mac/Sources/CodeBurnMenubar/Data/ClaudeCredentialStore.swift b/mac/Sources/CodeBurnMenubar/Data/ClaudeCredentialStore.swift index 438dc397..5df6bbc8 100644 --- a/mac/Sources/CodeBurnMenubar/Data/ClaudeCredentialStore.swift +++ b/mac/Sources/CodeBurnMenubar/Data/ClaudeCredentialStore.swift @@ -57,6 +57,7 @@ enum ClaudeCredentialStore { lastCacheDeleteResult = nil lastLegacyCleanupFailed = false unlinkLegacyOverride = nil + tightenLegacyOverride = nil lock.withLock { memoryCache = nil } } @@ -137,7 +138,7 @@ enum ClaudeCredentialStore { lastCacheDeleteResult = result // A failed Keychain delete must not pretend the provider is disconnected. // Clearing the flag hides Disconnect and orphans the remaining item. - if result.keychainDeletedOrAbsent { + if result.isSuccess { isBootstrapCompleted = false } return result @@ -157,6 +158,7 @@ enum ClaudeCredentialStore { nonisolated(unsafe) static var lastLegacyCleanupFailed = false /// Test seam: force legacy unlink to throw so we can assert the 0600 repair. nonisolated(unsafe) static var unlinkLegacyOverride: ((URL) throws -> Void)? + nonisolated(unsafe) static var tightenLegacyOverride: ((URL) throws -> Void)? // MARK: - Public API @@ -505,8 +507,15 @@ enum ClaudeCredentialStore { lastLegacyCleanupFailed = false } catch { lastLegacyCleanupFailed = true - // Verified Keychain item exists — leftover plaintext must not stay 0644. - try? FileManager.default.setAttributes([.posixPermissions: 0o600], ofItemAtPath: url.path) + do { + if let tightenLegacyOverride { + try tightenLegacyOverride(url) + } else { + try SafeFile.tightenToOwnerReadWrite(at: url.path) + } + } catch { + // Still leftover, and 0600 is unproven. Retry signal stays set. + } } } @@ -523,7 +532,11 @@ enum ClaudeCredentialStore { let url = cacheFileURL() if FileManager.default.fileExists(atPath: url.path) { do { - try FileManager.default.removeItem(at: url) + if let unlinkLegacyOverride { + try unlinkLegacyOverride(url) + } else { + try FileManager.default.removeItem(at: url) + } } catch { legacyOK = false } diff --git a/mac/Sources/CodeBurnMenubar/Data/CodexCredentialStore.swift b/mac/Sources/CodeBurnMenubar/Data/CodexCredentialStore.swift index 8ab257e3..00385b05 100644 --- a/mac/Sources/CodeBurnMenubar/Data/CodexCredentialStore.swift +++ b/mac/Sources/CodeBurnMenubar/Data/CodexCredentialStore.swift @@ -44,6 +44,7 @@ enum CodexCredentialStore { lastCacheDeleteResult = nil lastLegacyCleanupFailed = false unlinkLegacyOverride = nil + tightenLegacyOverride = nil lock.withLock { memoryCache = nil } } @@ -134,7 +135,7 @@ enum CodexCredentialStore { lock.withLock { memoryCache = nil } let result = deleteOurCache() lastCacheDeleteResult = result - if result.keychainDeletedOrAbsent { + if result.isSuccess { isBootstrapCompleted = false } return result @@ -149,6 +150,7 @@ enum CodexCredentialStore { nonisolated(unsafe) static var lastCacheDeleteResult: CacheDeleteResult? nonisolated(unsafe) static var lastLegacyCleanupFailed = false nonisolated(unsafe) static var unlinkLegacyOverride: ((URL) throws -> Void)? + nonisolated(unsafe) static var tightenLegacyOverride: ((URL) throws -> Void)? // MARK: - Public API @@ -380,7 +382,15 @@ enum CodexCredentialStore { lastLegacyCleanupFailed = false } catch { lastLegacyCleanupFailed = true - try? FileManager.default.setAttributes([.posixPermissions: 0o600], ofItemAtPath: url.path) + do { + if let tightenLegacyOverride { + try tightenLegacyOverride(url) + } else { + try SafeFile.tightenToOwnerReadWrite(at: url.path) + } + } catch { + // Still leftover, and 0600 is unproven. Retry signal stays set. + } } } @@ -397,7 +407,11 @@ enum CodexCredentialStore { let url = cacheFileURL() if FileManager.default.fileExists(atPath: url.path) { do { - try FileManager.default.removeItem(at: url) + if let unlinkLegacyOverride { + try unlinkLegacyOverride(url) + } else { + try FileManager.default.removeItem(at: url) + } } catch { legacyOK = false } diff --git a/mac/Sources/CodeBurnMenubar/Security/SafeFile.swift b/mac/Sources/CodeBurnMenubar/Security/SafeFile.swift index 6a300a7f..9ff37fe4 100644 --- a/mac/Sources/CodeBurnMenubar/Security/SafeFile.swift +++ b/mac/Sources/CodeBurnMenubar/Security/SafeFile.swift @@ -21,6 +21,42 @@ enum SafeFile { /// from exhausting memory in the Swift process. static let defaultReadLimit = 8 * 1024 * 1024 + /// Open the existing regular file with O_NOFOLLOW, fchmod 0600, and + /// fstat-verify the mode. Used when leftover credential JSON cannot be + /// unlinked after a verified Keychain write. + static func tightenToOwnerReadWrite(at path: String) throws { + var linkInfo = stat() + guard lstat(path, &linkInfo) == 0 else { + throw Error.readFailed(path, errno) + } + if (linkInfo.st_mode & S_IFMT) == S_IFLNK { + throw Error.symlinkDetected(path) + } + let fd = Darwin.open(path, O_RDONLY | O_NOFOLLOW) + guard fd >= 0 else { + throw Error.readFailed(path, errno) + } + defer { Darwin.close(fd) } + var opened = stat() + guard fstat(fd, &opened) == 0 else { + throw Error.readFailed(path, errno) + } + guard (opened.st_mode & S_IFMT) == S_IFREG else { + throw SecureReadError.notRegularFile(path) + } + if fchmod(fd, 0o600) != 0 { + throw SecureReadError.chmodFailed(path, errno) + } + var verified = stat() + guard fstat(fd, &verified) == 0 else { + throw Error.readFailed(path, errno) + } + let mode = verified.st_mode & 0o777 + guard mode == 0o600 else { + throw SecureReadError.modeVerifyFailed(path, mode) + } + } + /// Refuses to follow symlinks and writes atomically via a tmp file + rename. `mode` is the /// final file permission (0o600 by default so cache files stay user-private). static func write(_ data: Data, to path: String, mode: mode_t = 0o600) throws { @@ -167,10 +203,11 @@ enum SafeFile { var data = Data() data.reserveCapacity(max(size, 0)) var chunk = [UInt8](repeating: 0, count: 4096) - while data.count < maxBytes { + let limit = maxBytes + 1 + while data.count < limit { let n = chunk.withUnsafeMutableBytes { buffer -> Int in guard let base = buffer.baseAddress else { return 0 } - return Darwin.read(fd, base, buffer.count) + return Darwin.read(fd, base, min(buffer.count, limit - data.count)) } guard n >= 0 else { throw Error.readFailed(path, errno) diff --git a/mac/Tests/CodeBurnMenubarTests/CredentialKeychainContinuityTests.swift b/mac/Tests/CodeBurnMenubarTests/CredentialKeychainContinuityTests.swift index 1b75a9e1..b4b8ebb6 100644 --- a/mac/Tests/CodeBurnMenubarTests/CredentialKeychainContinuityTests.swift +++ b/mac/Tests/CodeBurnMenubarTests/CredentialKeychainContinuityTests.swift @@ -325,6 +325,52 @@ struct CredentialKeychainContinuityTests { } } + @Test("failed tighten after failed unlink keeps leftover and retry signal") + func failedTightenLeavesRetrySignal() throws { + try withHarness { _ in + let record = ClaudeCredentialStore.CredentialRecord( + accessToken: accessSentinel, + refreshToken: refreshSentinel, + expiresAt: Date(timeIntervalSince1970: 1_900_000_000), + rateLimitTier: "default" + ) + try ClaudeCredentialStore.writeOurCache(record: record) + try writeLegacyClaude0644(record: record) + ClaudeCredentialStore.isBootstrapCompleted = true + ClaudeCredentialStore.unlinkLegacyOverride = { _ in throw POSIXError(.EPERM) } + ClaudeCredentialStore.tightenLegacyOverride = { _ in throw POSIXError(.EPERM) } + ClaudeCredentialStore.clearMemoryCacheForTesting() + _ = try ClaudeCredentialStore.currentRecord() + #expect(ClaudeCredentialStore.lastLegacyCleanupFailed == true) + #expect(FileManager.default.fileExists(atPath: ClaudeCredentialStore.cacheFileURL().path)) + } + } + + @Test("legacy-only disconnect failure keeps bootstrap so retry stays") + func legacyOnlyDisconnectKeepsBootstrap() throws { + try withHarness { _ in + try ClaudeCredentialStore.writeOurCache(record: .init( + accessToken: accessSentinel, + refreshToken: refreshSentinel, + expiresAt: nil, + rateLimitTier: nil + )) + try writeLegacyClaude0644(record: .init( + accessToken: accessSentinel, + refreshToken: refreshSentinel, + expiresAt: nil, + rateLimitTier: nil + )) + ClaudeCredentialStore.isBootstrapCompleted = true + ClaudeCredentialStore.unlinkLegacyOverride = { _ in throw POSIXError(.EPERM) } + let failed = ClaudeCredentialStore.resetBootstrap() + #expect(failed.keychainDeletedOrAbsent == true) + #expect(failed.legacyDeletedOrAbsent == false) + #expect(failed.isSuccess == false) + #expect(ClaudeCredentialStore.isBootstrapCompleted == true) + } + } + @Test("reinstall with empty Keychain and no legacy clears bootstrap on read") func reinstallMissingCacheClearsBootstrap() throws { try withHarness { _ in From d5ced78595d6196ef48d5af089c65f2f9ef8c30b Mon Sep 17 00:00:00 2001 From: Aditya Vikram Singh <247195684+avs-io@users.noreply.github.com> Date: Wed, 19 Aug 2026 21:10:33 +0530 Subject: [PATCH 76/85] fix(models): cycle-safe short names; keep user-alias display MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extra High MERGE AFTER FIX on d3f86f5. mimo-v2.5 aliased to xiaomi/mimo-v2.5 then last-segment recursed forever. Looking up SHORT_NAMES before resolveAlias also froze user remaps of known ids (gpt-4o still displayed as GPT-4o). Follow user aliases first. Break strip→alias→leaf cycles. Do not invent a Kimi rate. Do not paper over this with a mimo-v2.5 SHORT_NAMES row. --- src/models.ts | 60 +++++++++++++++++++++++++++++--------------- tests/models.test.ts | 16 ++++++++++++ 2 files changed, 56 insertions(+), 20 deletions(-) diff --git a/src/models.ts b/src/models.ts index 1b910469..907c2327 100644 --- a/src/models.ts +++ b/src/models.ts @@ -985,32 +985,52 @@ function deriveClaudeShortName(canonical: string): string | undefined { return `${CLAUDE_FAMILY[family]} ${major}${minor ? `.${minor}` : ''}` } -export function getShortModelName(model: string): string { - if (autoModelNames[model]) return autoModelNames[model] - const stripped = getCanonicalName(model) - // Pricing aliases may re-namespace a leaf (mimo-v2.5-pro → xiaomi/…). - // Display names live on the leaf. Look that up before following the alias - // or we recurse forever: strip → alias → last-segment → strip. - for (const [key, name] of SORTED_SHORT_NAMES) { - if (stripped === key || stripped.startsWith(key + '-')) return name - } - const canonical = resolveAlias(stripped) - const claude = deriveClaudeShortName(canonical) +function lookupShortName(id: string): string | undefined { + const claude = deriveClaudeShortName(id) if (claude) return claude for (const [key, name] of SORTED_SHORT_NAMES) { - if (canonical === key || canonical.startsWith(key + '-')) return name + if (id === key || id.startsWith(key + '-')) return name } - // getCanonicalName only strips the leading provider prefix, so a raw - // path-style id (e.g. accounts/fireworks/models/glm-5p2) still has slashes - // here. Take the last path segment and re-resolve it: the segment may itself - // be a known model slug (Fireworks fleet ids), earning a friendly name; a - // genuinely unmapped slug resolves to itself, preserving the raw-segment - // fallback for everything else. + return undefined +} + +export function getShortModelName(model: string, seen: Set = new Set()): string { + if (autoModelNames[model]) return autoModelNames[model] + if (seen.has(model)) { + const leaf = model.includes('/') ? model.slice(model.lastIndexOf('/') + 1) : model + return lookupShortName(leaf) ?? leaf + } + seen.add(model) + + // User aliases win over built-in display names. A remap of gpt-4o must + // show the target, not "GPT-4o". + if (Object.hasOwn(userAliases, model)) { + return getShortModelName(userAliases[model]!, seen) + } + + const stripped = getCanonicalName(model) + if (stripped !== model) { + if (Object.hasOwn(userAliases, stripped)) { + return getShortModelName(userAliases[stripped]!, seen) + } + const knownStripped = lookupShortName(stripped) + if (knownStripped && !Object.hasOwn(BUILTIN_ALIASES, stripped) && !Object.hasOwn(BUILTIN_ALIASES, stripped.toLowerCase())) { + return knownStripped + } + } + + const canonical = resolveAlias(stripped) + const known = lookupShortName(canonical) + if (known) return known + if (canonical.includes('/')) { const segment = canonical.slice(canonical.lastIndexOf('/') + 1) - return segment ? getShortModelName(segment) : canonical + if (!segment || seen.has(segment) || segment === stripped) { + return lookupShortName(segment) ?? segment + } + return getShortModelName(segment, seen) } - return canonical + return lookupShortName(canonical) ?? canonical } // Pricing is process-global state assembled at CLI startup from the cached diff --git a/tests/models.test.ts b/tests/models.test.ts index 080dec4a..2c81f74f 100644 --- a/tests/models.test.ts +++ b/tests/models.test.ts @@ -310,6 +310,13 @@ describe('user aliases via setModelAliases', () => { expect(getModelCosts('anthropic--claude-4.6-opus')).toEqual(getModelCosts('claude-sonnet-4-5')) }) + it('user alias whose source already has a short name displays the target', () => { + setModelAliases({ 'gpt-4o': 'claude-opus-4-6' }) + expect(getModelCosts('gpt-4o')).toEqual(getModelCosts('claude-opus-4-6')) + expect(getShortModelName('gpt-4o')).toBe('Opus 4.6') + setModelAliases({}) + }) + it('resetting aliases restores builtins', () => { setModelAliases({ 'anthropic--claude-4.6-opus': 'claude-sonnet-4-5' }) setModelAliases({}) @@ -739,6 +746,8 @@ describe('observed provider model aliases', () => { ['MiMo-V2-Flash', 'xiaomi/mimo-v2-flash'], ['mimo-v2.5-pro', 'xiaomi/mimo-v2.5-pro'], ['MiMo-v2.5-Pro', 'xiaomi/mimo-v2.5-pro'], + ['mimo-v2.5', 'xiaomi/mimo-v2.5'], + ['MiMo-v2.5', 'xiaomi/mimo-v2.5'], ['KAT-Coder-Pro-V1', 'kwaipilot/kat-coder-pro'], // Kimi Code wires report bare `k3` in llm.request.model; it must price // through the kimi-k3 table entry, not fall through to $0. @@ -760,6 +769,13 @@ describe('observed provider model aliases', () => { expect(getShortModelName('k3')).toBe('Kimi K3') }) + it('does not recurse on vendor-requalified MiMo aliases', () => { + expect(getShortModelName('mimo-v2.5')).toBe('mimo-v2.5') + expect(getShortModelName('MiMo-v2.5')).toBe('mimo-v2.5') + expect(getShortModelName('cline-pass/mimo-v2.5')).toBe('mimo-v2.5') + expect(getShortModelName('cline-pass/mimo-v2.5-pro')).toBe('MiMo v2.5 Pro') + }) + it('does not map dated Qwen3 Max to a reseller price without provider context', () => { expect(getModelCosts('qwen3-max-2026-01-23')).toBeNull() expect(calculateCost('qwen3-max-2026-01-23', 1_000_000, 1_000_000, 0, 0, 0)).toBe(0) From 827a41241cbf77ec8501ae19f9e3ec6b8d33148f Mon Sep 17 00:00:00 2001 From: Aditya Vikram Singh <247195684+avs-io@users.noreply.github.com> Date: Wed, 19 Aug 2026 21:27:13 +0530 Subject: [PATCH 77/85] fix(models): keep getShortModelName unary for Array.map CI typecheck failed: sessions-report maps getShortModelName, and the Extra High cycle Set was a second parameter. Array.map fed the index as `seen`. Cycle tracking stays on an internal helper. Display and alias behavior unchanged. No second Extra High. --- src/models.ts | 13 +++++++++---- tests/models.test.ts | 8 ++++++++ 2 files changed, 17 insertions(+), 4 deletions(-) diff --git a/src/models.ts b/src/models.ts index 907c2327..d429ab47 100644 --- a/src/models.ts +++ b/src/models.ts @@ -994,7 +994,12 @@ function lookupShortName(id: string): string | undefined { return undefined } -export function getShortModelName(model: string, seen: Set = new Set()): string { +// Public API stays unary so Array.map/forEach cannot feed index as cycle state. +export function getShortModelName(model: string): string { + return shortModelName(model, new Set()) +} + +function shortModelName(model: string, seen: Set): string { if (autoModelNames[model]) return autoModelNames[model] if (seen.has(model)) { const leaf = model.includes('/') ? model.slice(model.lastIndexOf('/') + 1) : model @@ -1005,13 +1010,13 @@ export function getShortModelName(model: string, seen: Set = new Set()): // User aliases win over built-in display names. A remap of gpt-4o must // show the target, not "GPT-4o". if (Object.hasOwn(userAliases, model)) { - return getShortModelName(userAliases[model]!, seen) + return shortModelName(userAliases[model]!, seen) } const stripped = getCanonicalName(model) if (stripped !== model) { if (Object.hasOwn(userAliases, stripped)) { - return getShortModelName(userAliases[stripped]!, seen) + return shortModelName(userAliases[stripped]!, seen) } const knownStripped = lookupShortName(stripped) if (knownStripped && !Object.hasOwn(BUILTIN_ALIASES, stripped) && !Object.hasOwn(BUILTIN_ALIASES, stripped.toLowerCase())) { @@ -1028,7 +1033,7 @@ export function getShortModelName(model: string, seen: Set = new Set()): if (!segment || seen.has(segment) || segment === stripped) { return lookupShortName(segment) ?? segment } - return getShortModelName(segment, seen) + return shortModelName(segment, seen) } return lookupShortName(canonical) ?? canonical } diff --git a/tests/models.test.ts b/tests/models.test.ts index 2c81f74f..44461252 100644 --- a/tests/models.test.ts +++ b/tests/models.test.ts @@ -776,6 +776,14 @@ describe('observed provider model aliases', () => { expect(getShortModelName('cline-pass/mimo-v2.5-pro')).toBe('MiMo v2.5 Pro') }) + it('stays unary so Array.map cannot feed the index as cycle state', () => { + expect(['mimo-v2.5', 'gpt-4o', 'cline-pass/mimo-v2.5-pro'].map(getShortModelName)).toEqual([ + 'mimo-v2.5', + 'GPT-4o', + 'MiMo v2.5 Pro', + ]) + }) + it('does not map dated Qwen3 Max to a reseller price without provider context', () => { expect(getModelCosts('qwen3-max-2026-01-23')).toBeNull() expect(calculateCost('qwen3-max-2026-01-23', 1_000_000, 1_000_000, 0, 0, 0)).toBe(0) From ef040a4c4d1a0fc11758a6b7a24a1726492bd7a6 Mon Sep 17 00:00:00 2001 From: iamtoruk Date: Wed, 19 Aug 2026 11:26:22 -0700 Subject: [PATCH 78/85] test(models): pin the shipped MiMo v2 Flash crash; name the base 2.5 row The `mimo-v2-flash -> xiaomi/mimo-v2-flash` alias shipped before this branch and already cycled through display-name resolution, so getShortModelName threw RangeError on every real MiMo v2 Flash session. The new cycle-safe resolver fixes it, but nothing pinned the ids that actually crashed in production: cover the four spellings found in a real session cache, including the unnamespaced `mimo/mimo-v2-flash`. Add the base `mimo-v2.5` display name so the row reads next to "MiMo v2.5 Pro" instead of showing a raw slug; SORTED_SHORT_NAMES is longest-first, so the Pro tier still wins its own entry. --- CHANGELOG.md | 1 + src/models.ts | 1 + tests/models.test.ts | 26 ++++++++++++++++++++++---- 3 files changed, 24 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9bee3bad..37e4ed57 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -33,6 +33,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 +- **MiMo sessions price from the LiteLLM Xiaomi rows, and MiMo v2 Flash no longer crashes the display path.** Hermes / Xiaomi token-plan sessions store the bare id (`mimo-v2.5-pro`, `mimo-v2.5`) while LiteLLM namespaces its row (`xiaomi/…`), so those models reported $0. They now alias to the existing snapshot rows — no invented rate, and `kimi-k3` still has none — which means a session Hermes left costless is priced from the shared tables and carries the estimated marker, exactly as `mimo-v2-flash` already did. The same change fixes a **pre-existing** crash that this alias did not introduce: the shipped `mimo-v2-flash -> xiaomi/mimo-v2-flash` alias already cycled through display-name resolution — strip the namespace, alias it back, take the leaf, repeat — so `getShortModelName` blew the stack on any real MiMo v2 Flash session and took every surface that names a model down with it, the `models` table included. Display-name resolution is now cycle-safe, and the base `mimo-v2.5` row is named rather than shown as a raw slug. - **A date-ranged run no longer republishes the month shards it never read.** A scoped load leaves an out-of-range month on disk, so the files it holds have no visible cache entry and the reconcile re-parses them — re-deriving the entry the shard already stores. That re-parse marked the unloaded month dirty, and the save merged and republished it under a fresh nonce name on every single run, byte-identical content and all, so a repeated `codeburn status --format json` churned old months (on a real corpus: claude/2026-03, cursor/2026-02 and warp/2026-03 renamed every run) and left the retired shards for the sweeper. A merge into an unloaded month that neither adds, changes nor removes an entry now keeps the published shard, so unchanged months keep their names and their bytes. (#1032) - **`models` and `audit` no longer show two identical `Grok 4.5` rows.** `grok-4.5-build` — the Grok Build harness's variant id — fell into the `grok-4.5` display entry by prefix, and since rows bucket by model id, not display name, the two came out as visually identical rows with different numbers. The variant now shows as `Grok 4.5 (build)`. Display only: no id is rewritten and no cost moves. (#1029) - **An upgrade no longer loses history for days whose transcripts have only PARTLY aged out.** The never-lose contract carried a cached (day, provider) slice forward only when the re-derivation found NOTHING for it, but transcripts expire per FILE rather than per day: on a day whose sources are mostly gone, a handful of turns from surviving later files still bucket onto it, so the fresh slice came back non-empty but truncated and REPLACED the full cached one. On a real cache upgrading from the last shipped daily-cache version, 2026-07-16 fell from $1,685.17 / 12,530 calls to $385.44 / 560 calls, and 13 days lost $2,765.75, 19,209 calls and 520 sessions in total. A fresh slice now replaces a settled baseline slice only when it carries at least as many CALLS - the same or more evidence; fewer calls means the source set demonstrably lost data, and the baseline is kept whole. The comparison is on calls alone: cost and tokens are re-priced accounting on the same evidence, which is exactly what a legitimate re-derivation changes (the Grok accounting fix keeps its per-day calls and is unaffected), and session counts drift down by a few on days whose sources are entirely intact. Days inside a 7-day settle window stay authoritative - their session files are still on disk, so a shrink there is a real change rather than expiry. The trade-off is deliberate and matches the direction this cache has always chosen: a future fix that legitimately REDUCES calls on a settled day keeps the older, higher value until that day is re-derived at an equal or greater call count. The timezone-change re-derive gets the exact form of the same rule - what the fresh parse can no longer explain under the old bucketing is added on top of the fresh slice instead of being dropped - and the cross-file adoption union is unchanged, where the newer schema still wins per (day, provider). diff --git a/src/models.ts b/src/models.ts index d429ab47..616185e4 100644 --- a/src/models.ts +++ b/src/models.ts @@ -960,6 +960,7 @@ const SHORT_NAMES: Record = { // table, the same way it handles `accounts/fireworks/models/`. 'qwen3.7-max': 'Qwen 3.7 Max', 'mimo-v2.5-pro': 'MiMo v2.5 Pro', + 'mimo-v2.5': 'MiMo v2.5', // Both spellings occur in the wild: OpenRouter gap-filled keys are lowercase // slugs while sessions report the capitalized name (see the case-insensitive // pricing index above). SHORT_NAMES matching is case-sensitive, so map both. diff --git a/tests/models.test.ts b/tests/models.test.ts index 44461252..dbaf0183 100644 --- a/tests/models.test.ts +++ b/tests/models.test.ts @@ -770,15 +770,33 @@ describe('observed provider model aliases', () => { }) it('does not recurse on vendor-requalified MiMo aliases', () => { - expect(getShortModelName('mimo-v2.5')).toBe('mimo-v2.5') - expect(getShortModelName('MiMo-v2.5')).toBe('mimo-v2.5') - expect(getShortModelName('cline-pass/mimo-v2.5')).toBe('mimo-v2.5') + expect(getShortModelName('mimo-v2.5')).toBe('MiMo v2.5') + expect(getShortModelName('MiMo-v2.5')).toBe('MiMo v2.5') + expect(getShortModelName('cline-pass/mimo-v2.5')).toBe('MiMo v2.5') expect(getShortModelName('cline-pass/mimo-v2.5-pro')).toBe('MiMo v2.5 Pro') }) + // The `mimo-v2-flash -> xiaomi/mimo-v2-flash` alias shipped before this + // change and already cycled: strip the namespace, alias it back, take the + // leaf, repeat. Every display surface (overview's model table included) + // threw RangeError on a real MiMo v2 Flash session. Pin the shipped ids. + it('resolves the already-shipped MiMo v2 Flash alias without blowing the stack', () => { + for (const id of ['mimo-v2-flash', 'MiMo-V2-Flash', 'cline-pass/mimo-v2-flash', 'mimo/mimo-v2-flash']) { + expect(() => getShortModelName(id)).not.toThrow() + expect(getShortModelName(id)).toBe('mimo-v2-flash') + expect(getModelCosts(id)).toEqual(getModelCosts('xiaomi/mimo-v2-flash')) + } + }) + + it('names the base MiMo 2.5 row without swallowing the Pro tier', () => { + expect(getShortModelName('mimo-v2.5')).toBe('MiMo v2.5') + expect(getShortModelName('mimo-v2.5-pro')).toBe('MiMo v2.5 Pro') + expect(getModelCosts('mimo-v2.5')).not.toEqual(getModelCosts('mimo-v2.5-pro')) + }) + it('stays unary so Array.map cannot feed the index as cycle state', () => { expect(['mimo-v2.5', 'gpt-4o', 'cline-pass/mimo-v2.5-pro'].map(getShortModelName)).toEqual([ - 'mimo-v2.5', + 'MiMo v2.5', 'GPT-4o', 'MiMo v2.5 Pro', ]) From 6bd86dbc366bcead7906f12adfabbefb4600dccb Mon Sep 17 00:00:00 2001 From: iamtoruk Date: Wed, 19 Aug 2026 11:29:14 -0700 Subject: [PATCH 79/85] ci: build, test and package the macOS menubar on every mac/ change release-menubar.yml only packages; the 170+ Swift tests gated nothing. --- .github/workflows/mac-menubar-ci.yml | 34 ++++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) create mode 100644 .github/workflows/mac-menubar-ci.yml diff --git a/.github/workflows/mac-menubar-ci.yml b/.github/workflows/mac-menubar-ci.yml new file mode 100644 index 00000000..f7d9cc1c --- /dev/null +++ b/.github/workflows/mac-menubar-ci.yml @@ -0,0 +1,34 @@ +name: macOS Menubar CI + +# The macOS menubar (mac/) ships from release-menubar.yml, which only packages the app. +# Its Swift test suite (170+ tests covering the credential stores, Keychain cache, +# serve connection, quota parsing) gated nothing until this workflow. Runs on every +# PR touching mac/** so a red test fails the PR, the same way tests.yml does for the CLI. +on: + push: + branches: [main] + paths: + - .github/workflows/mac-menubar-ci.yml + - mac/** + pull_request: + paths: + - .github/workflows/mac-menubar-ci.yml + - mac/** + +permissions: + contents: read + +jobs: + test: + runs-on: macos-latest + timeout-minutes: 30 + steps: + - uses: actions/checkout@v6 + - name: Swift toolchain + run: swift --version + - name: Build + run: swift build --package-path mac + - name: Test + run: swift test --package-path mac + - name: Package (same script the release uses) + run: mac/Scripts/package-app.sh ci-smoke From bb5b71dff1eb8be24c3b29769fe29fb922ffdc8e Mon Sep 17 00:00:00 2001 From: iamtoruk Date: Wed, 19 Aug 2026 11:33:13 -0700 Subject: [PATCH 80/85] feat(models): name the MiMo v2 Flash row It was the only MiMo row still rendering as its raw slug next to "MiMo v2.5" and "MiMo v2.5 Pro". SORTED_SHORT_NAMES is longest-first, so the two v2.5 entries keep their own labels. --- CHANGELOG.md | 2 +- src/models.ts | 1 + tests/models.test.ts | 2 +- 3 files changed, 3 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 37e4ed57..0479fe82 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -33,7 +33,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 -- **MiMo sessions price from the LiteLLM Xiaomi rows, and MiMo v2 Flash no longer crashes the display path.** Hermes / Xiaomi token-plan sessions store the bare id (`mimo-v2.5-pro`, `mimo-v2.5`) while LiteLLM namespaces its row (`xiaomi/…`), so those models reported $0. They now alias to the existing snapshot rows — no invented rate, and `kimi-k3` still has none — which means a session Hermes left costless is priced from the shared tables and carries the estimated marker, exactly as `mimo-v2-flash` already did. The same change fixes a **pre-existing** crash that this alias did not introduce: the shipped `mimo-v2-flash -> xiaomi/mimo-v2-flash` alias already cycled through display-name resolution — strip the namespace, alias it back, take the leaf, repeat — so `getShortModelName` blew the stack on any real MiMo v2 Flash session and took every surface that names a model down with it, the `models` table included. Display-name resolution is now cycle-safe, and the base `mimo-v2.5` row is named rather than shown as a raw slug. +- **MiMo sessions price from the LiteLLM Xiaomi rows, and MiMo v2 Flash no longer crashes the display path.** Hermes / Xiaomi token-plan sessions store the bare id (`mimo-v2.5-pro`, `mimo-v2.5`) while LiteLLM namespaces its row (`xiaomi/…`), so those models reported $0. They now alias to the existing snapshot rows — no invented rate, and `kimi-k3` still has none — which means a session Hermes left costless is priced from the shared tables and carries the estimated marker, exactly as `mimo-v2-flash` already did. The same change fixes a **pre-existing** crash that this alias did not introduce: the shipped `mimo-v2-flash -> xiaomi/mimo-v2-flash` alias already cycled through display-name resolution — strip the namespace, alias it back, take the leaf, repeat — so `getShortModelName` blew the stack on any real MiMo v2 Flash session and took every surface that names a model down with it, the `models` table included. Display-name resolution is now cycle-safe, and the `mimo-v2-flash` and `mimo-v2.5` rows are named rather than shown as raw slugs. - **A date-ranged run no longer republishes the month shards it never read.** A scoped load leaves an out-of-range month on disk, so the files it holds have no visible cache entry and the reconcile re-parses them — re-deriving the entry the shard already stores. That re-parse marked the unloaded month dirty, and the save merged and republished it under a fresh nonce name on every single run, byte-identical content and all, so a repeated `codeburn status --format json` churned old months (on a real corpus: claude/2026-03, cursor/2026-02 and warp/2026-03 renamed every run) and left the retired shards for the sweeper. A merge into an unloaded month that neither adds, changes nor removes an entry now keeps the published shard, so unchanged months keep their names and their bytes. (#1032) - **`models` and `audit` no longer show two identical `Grok 4.5` rows.** `grok-4.5-build` — the Grok Build harness's variant id — fell into the `grok-4.5` display entry by prefix, and since rows bucket by model id, not display name, the two came out as visually identical rows with different numbers. The variant now shows as `Grok 4.5 (build)`. Display only: no id is rewritten and no cost moves. (#1029) - **An upgrade no longer loses history for days whose transcripts have only PARTLY aged out.** The never-lose contract carried a cached (day, provider) slice forward only when the re-derivation found NOTHING for it, but transcripts expire per FILE rather than per day: on a day whose sources are mostly gone, a handful of turns from surviving later files still bucket onto it, so the fresh slice came back non-empty but truncated and REPLACED the full cached one. On a real cache upgrading from the last shipped daily-cache version, 2026-07-16 fell from $1,685.17 / 12,530 calls to $385.44 / 560 calls, and 13 days lost $2,765.75, 19,209 calls and 520 sessions in total. A fresh slice now replaces a settled baseline slice only when it carries at least as many CALLS - the same or more evidence; fewer calls means the source set demonstrably lost data, and the baseline is kept whole. The comparison is on calls alone: cost and tokens are re-priced accounting on the same evidence, which is exactly what a legitimate re-derivation changes (the Grok accounting fix keeps its per-day calls and is unaffected), and session counts drift down by a few on days whose sources are entirely intact. Days inside a 7-day settle window stay authoritative - their session files are still on disk, so a shrink there is a real change rather than expiry. The trade-off is deliberate and matches the direction this cache has always chosen: a future fix that legitimately REDUCES calls on a settled day keeps the older, higher value until that day is re-derived at an equal or greater call count. The timezone-change re-derive gets the exact form of the same rule - what the fresh parse can no longer explain under the old bucketing is added on top of the fresh slice instead of being dropped - and the cross-file adoption union is unchanged, where the newer schema still wins per (day, provider). diff --git a/src/models.ts b/src/models.ts index 616185e4..4413d084 100644 --- a/src/models.ts +++ b/src/models.ts @@ -961,6 +961,7 @@ const SHORT_NAMES: Record = { 'qwen3.7-max': 'Qwen 3.7 Max', 'mimo-v2.5-pro': 'MiMo v2.5 Pro', 'mimo-v2.5': 'MiMo v2.5', + 'mimo-v2-flash': 'MiMo v2 Flash', // Both spellings occur in the wild: OpenRouter gap-filled keys are lowercase // slugs while sessions report the capitalized name (see the case-insensitive // pricing index above). SHORT_NAMES matching is case-sensitive, so map both. diff --git a/tests/models.test.ts b/tests/models.test.ts index dbaf0183..6ec6d9d4 100644 --- a/tests/models.test.ts +++ b/tests/models.test.ts @@ -783,7 +783,7 @@ describe('observed provider model aliases', () => { it('resolves the already-shipped MiMo v2 Flash alias without blowing the stack', () => { for (const id of ['mimo-v2-flash', 'MiMo-V2-Flash', 'cline-pass/mimo-v2-flash', 'mimo/mimo-v2-flash']) { expect(() => getShortModelName(id)).not.toThrow() - expect(getShortModelName(id)).toBe('mimo-v2-flash') + expect(getShortModelName(id)).toBe('MiMo v2 Flash') expect(getModelCosts(id)).toEqual(getModelCosts('xiaomi/mimo-v2-flash')) } }) From e213e192b45308b3e917f9dd1aff09b63908fa5d Mon Sep 17 00:00:00 2001 From: iamtoruk Date: Wed, 19 Aug 2026 11:43:22 -0700 Subject: [PATCH 81/85] fix(menubar): never let a Keychain read raise UI on the refresh timer Cache reads run on the background quota timer, so they must not be able to put a panel on screen. Measured on macOS 15 against a throwaway keychain: with the keychain locked, SecItemCopyMatching blocks on an unlock panel even when the query carries kSecUseAuthenticationUI: ...Fail or a non-interactive LAContext. Both of those govern the data-protection keychain; unlocking a file-based keychain is something securityd drives itself. The only reliable suppression is not issuing the read, so check lock state first and report .unavailable instead. .unavailable is separate from readFailed on purpose: a locked keychain means "cannot look right now", not "the item is gone", and callers must not turn it into a disconnect. It also carries a readable errorDescription so a -25308 reaching the UI reads as "Keychain unavailable" rather than a struct dump. SecKeychainGetStatus is soft-deprecated with no replacement that reports file-keychain lock state; annotating the warning away only moves it to the call site, so it is left visible with a comment. Adds the first test that touches a real Keychain, against a throwaway service name no build reads, skipped when the host has no usable Keychain. --- .../Security/KeychainCredentialCache.swift | 64 +++++++++++- .../LiveKeychainCredentialCacheTests.swift | 97 +++++++++++++++++++ 2 files changed, 160 insertions(+), 1 deletion(-) create mode 100644 mac/Tests/CodeBurnMenubarTests/LiveKeychainCredentialCacheTests.swift diff --git a/mac/Sources/CodeBurnMenubar/Security/KeychainCredentialCache.swift b/mac/Sources/CodeBurnMenubar/Security/KeychainCredentialCache.swift index 8ea079fa..2b8f5d3c 100644 --- a/mac/Sources/CodeBurnMenubar/Security/KeychainCredentialCache.swift +++ b/mac/Sources/CodeBurnMenubar/Security/KeychainCredentialCache.swift @@ -1,4 +1,5 @@ import Foundation +import LocalAuthentication import Security /// Serializes credential-store test harnesses that mutate process-wide seams. @@ -21,6 +22,9 @@ enum KeychainCredentialCacheError: Error, LocalizedError, Equatable { case readFailed(service: String, status: OSStatus) case writeFailed(service: String, status: OSStatus) case deleteFailed(service: String, status: OSStatus) + /// Keychain is locked or consent was refused. Transient — callers keep the + /// last known token and must not treat it as "the item is gone". + case unavailable(service: String, status: OSStatus) var errorDescription: String? { switch self { @@ -30,12 +34,30 @@ enum KeychainCredentialCacheError: Error, LocalizedError, Equatable { return "Keychain write failed for \(service) (status \(status))." case let .deleteFailed(service, status): return "Keychain delete failed for \(service) (status \(status))." + case .unavailable: + return "Keychain unavailable — unlock your login keychain to refresh quota." } } + + /// Statuses that mean "we were not allowed to look right now", as opposed to + /// "the item does not exist". `errSecInteractionNotAllowed` (-25308) is what + /// a locked keychain returns once UI is suppressed. + static func isUnavailable(_ status: OSStatus) -> Bool { + status == errSecInteractionNotAllowed + || status == errSecAuthFailed + || status == errSecUserCanceled + || status == errSecInteractionRequired + } } /// Published CodeBurn Keychain identities. Keep these exact — Electron contracts -/// on the Codex pair, and historical items use the same names. +/// on the Codex pair (`app/electron/quota/codex.ts`), and installs going back to +/// May 2026 already hold items under these names. +/// +/// Deliberately NOT derived from `CFBundleIdentifier`: the Electron app hardcodes +/// the same strings, so a per-bundle suffix would break that contract. The +/// tradeoff is that a dev/beta build sharing this source shares the item — patch +/// these constants when running a second build alongside the release. enum CodeBurnKeychainIdentity { static let claudeService = "org.agentseal.codeburn.menubar.claude.oauth.v1" static let codexService = "org.agentseal.codeburn.menubar.codex.oauth.v1" @@ -43,17 +65,53 @@ enum CodeBurnKeychainIdentity { } struct LiveKeychainCredentialCache: KeychainCredentialCaching { + /// True when the default (login) keychain exists and is currently locked. + /// Returns false when the state cannot be determined, so an unexpected + /// failure degrades to "just try the read" rather than a hard outage. + /// + /// `SecKeychain*` is soft-deprecated with no replacement that reports + /// file-keychain lock state — `kSecUseDataProtectionKeychain` would move our + /// item to a different store and orphan every existing install. The + /// This is the one intentional deprecation warning in the file; annotating it + /// away only moves the warning to the call site, so it is left visible. + private func isDefaultKeychainLocked() -> Bool { + var status: SecKeychainStatus = 0 + guard SecKeychainGetStatus(nil, &status) == errSecSuccess else { return false } + return (status & SecKeychainStatus(kSecUnlockStateStatus)) == 0 + } + func read(service: String, account: String) throws -> Data? { + // Reads happen on the background refresh timer, so they must never be + // able to raise UI. Measured on macOS 15 against a locked test keychain: + // NEITHER `kSecUseAuthenticationUI: …Fail` NOR + // `LAContext.interactionNotAllowed` suppresses the unlock panel for a + // file-based keychain — both govern the data-protection keychain, while + // unlocking is a keychain-level operation securityd drives itself. The + // only thing that reliably avoids the panel is not issuing the read at + // all, so check lock state first. Same class of bug as the + // partition-list re-prompt in #490. + if isDefaultKeychainLocked() { + throw KeychainCredentialCacheError.unavailable( + service: service, status: errSecInteractionNotAllowed) + } + // Still pass a non-interactive context: it is the supported way to keep + // a data-protection-backed item from raising biometric/passcode UI. + let context = LAContext() + context.interactionNotAllowed = true let query: [String: Any] = [ kSecClass as String: kSecClassGenericPassword, kSecAttrService as String: service, kSecAttrAccount as String: account, kSecMatchLimit as String: kSecMatchLimitOne, kSecReturnData as String: true, + kSecUseAuthenticationContext as String: context, ] var result: CFTypeRef? let status = SecItemCopyMatching(query as CFDictionary, &result) if status == errSecItemNotFound { return nil } + if KeychainCredentialCacheError.isUnavailable(status) { + throw KeychainCredentialCacheError.unavailable(service: service, status: status) + } guard status == errSecSuccess, let data = result as? Data else { throw KeychainCredentialCacheError.readFailed(service: service, status: status) } @@ -165,6 +223,10 @@ final class ControllableKeychainCredentialCache: KeychainCredentialCaching, @unc func read(service: String, account: String) throws -> Data? { if failRead { + // Mirror the live adapter's mapping so tests exercise the same branch. + if KeychainCredentialCacheError.isUnavailable(readStatus) { + throw KeychainCredentialCacheError.unavailable(service: service, status: readStatus) + } throw KeychainCredentialCacheError.readFailed(service: service, status: readStatus) } return try inner.read(service: service, account: account) diff --git a/mac/Tests/CodeBurnMenubarTests/LiveKeychainCredentialCacheTests.swift b/mac/Tests/CodeBurnMenubarTests/LiveKeychainCredentialCacheTests.swift new file mode 100644 index 00000000..3eaf0eeb --- /dev/null +++ b/mac/Tests/CodeBurnMenubarTests/LiveKeychainCredentialCacheTests.swift @@ -0,0 +1,97 @@ +import Foundation +import Security +import Testing +@testable import CodeBurnMenubar + +/// The ONLY test that touches a real Keychain. Everything else in the suite runs +/// against `InMemoryKeychainCredentialCache`. +/// +/// It writes to a throwaway service name (`…menubar.selftest.oauth.v1`) that no +/// build ever reads, never touches the Claude/Codex production items, and deletes +/// what it created. If the login Keychain is locked or unavailable — headless CI, +/// SSH session, no login Keychain — the whole suite is SKIPPED rather than failed; +/// look for "live Keychain unavailable" in the output to tell a skip from a pass. +@Suite("Live Keychain adapter", .serialized) +struct LiveKeychainCredentialCacheTests { + private static let service = "org.agentseal.codeburn.menubar.selftest.oauth.v1" + private static let account = "selftest" + + /// True when this machine can round-trip a generic password right now. + private static let isAvailable: Bool = { + let live = LiveKeychainCredentialCache() + do { + try live.upsert(service: service, account: account, data: Data("probe".utf8)) + _ = try live.read(service: service, account: account) + try live.delete(service: service, account: account) + return true + } catch { + try? live.delete(service: service, account: account) + return false + } + }() + + @Test("live adapter round-trips write → read → update → delete") + func liveRoundTrip() throws { + guard Self.isAvailable else { + print("SKIP: live Keychain unavailable on this host") + return + } + let live = LiveKeychainCredentialCache() + defer { try? live.delete(service: Self.service, account: Self.account) } + + #expect(try live.read(service: Self.service, account: Self.account) == nil) + + try live.upsert(service: Self.service, account: Self.account, data: Data(#"{"v":1}"#.utf8)) + let first = try #require(try live.read(service: Self.service, account: Self.account)) + #expect(String(data: first, encoding: .utf8) == #"{"v":1}"#) + + // upsert must update in place, not duplicate. + try live.upsert(service: Self.service, account: Self.account, data: Data(#"{"v":2}"#.utf8)) + let second = try #require(try live.read(service: Self.service, account: Self.account)) + #expect(String(data: second, encoding: .utf8) == #"{"v":2}"#) + + try live.delete(service: Self.service, account: Self.account) + #expect(try live.read(service: Self.service, account: Self.account) == nil) + // Deleting an absent item is success, so disconnect stays idempotent. + #expect(throws: Never.self) { + try live.delete(service: Self.service, account: Self.account) + } + } + + /// Documents the measured behaviour that motivates the pre-flight lock check. + /// The locked-keychain case itself is deliberately NOT exercised at runtime: + /// reproducing it requires a keychain operation that raises a password panel + /// on the tester's screen. Measured once by hand on macOS 15 against a + /// throwaway keychain: with the keychain locked, `SecItemCopyMatching` blocks + /// on an unlock panel even when the query carries + /// `kSecUseAuthenticationUI: …Fail` or a non-interactive `LAContext` — both + /// govern the data-protection keychain, not file-keychain unlocking. Skipping + /// the read while locked is therefore the only reliable suppression. + @Test("unavailable statuses are classified as transient, not as a missing item") + func unavailableClassification() { + #expect(KeychainCredentialCacheError.isUnavailable(errSecInteractionNotAllowed)) + #expect(KeychainCredentialCacheError.isUnavailable(errSecAuthFailed)) + #expect(KeychainCredentialCacheError.isUnavailable(errSecUserCanceled)) + #expect(KeychainCredentialCacheError.isUnavailable(errSecInteractionRequired)) + // errSecItemNotFound is a real miss and must never be treated as transient. + #expect(!KeychainCredentialCacheError.isUnavailable(errSecItemNotFound)) + #expect(!KeychainCredentialCacheError.isUnavailable(errSecDecode)) + } + + @Test("live reads never block on an interactive prompt") + func liveReadIsNonInteractive() throws { + guard Self.isAvailable else { + print("SKIP: live Keychain unavailable on this host") + return + } + let live = LiveKeychainCredentialCache() + defer { try? live.delete(service: Self.service, account: Self.account) } + try live.upsert(service: Self.service, account: Self.account, data: Data("x".utf8)) + + // The read carries a non-interactive LAContext, so it either returns or + // fails fast. A prompt would park this call until a human dismissed it. + let start = Date() + _ = try? live.read(service: Self.service, account: Self.account) + #expect(Date().timeIntervalSince(start) < 5) + } +} From 0fd8419bfdea243a021803e2cb8beb286f599627 Mon Sep 17 00:00:00 2001 From: iamtoruk Date: Wed, 19 Aug 2026 11:43:32 -0700 Subject: [PATCH 82/85] fix(menubar): keep the newer credential copy and drop the sticky retry flag Three fixes in the store read path. A locked keychain no longer reads as a disconnect. currentRecord() treated any failure from readOurCache() as fatal, and a nil as "the item vanished", which cleared isBootstrapCompleted. .unavailable now falls back to the last known record and leaves the flag set. Recency. A Keychain hit always won and the legacy file was then unlinked, even when the file was newer. This service name has been in use since May 2026, so an upgrading install can hold a months-old item beside a file the pre-migration build wrote today; the older token won and the newer copy was deleted. Both stores now compare first (expiresAt for Claude, lastRefresh for Codex) and adopt the later one before anything is removed. Codex matters most here: serving a spent rotating refresh token ends in a terminal invalid_grant. lastLegacyCleanupFailed is gone. It was set on every cleanup path and read only by tests, never surfaced. The retry it was meant to signal already happens, because the unlink is attempted on every successful read. Also serializes migrate + unlink under the existing SafeFile.withExclusiveLock so two menubar instances cannot race on the same legacy file, and drops a leftover no-op local. --- .../Data/ClaudeCredentialStore.swift | 114 ++++++++----- .../Data/CodexCredentialStore.swift | 111 ++++++++----- .../CredentialKeychainContinuityTests.swift | 153 +++++++++++++++++- 3 files changed, 288 insertions(+), 90 deletions(-) diff --git a/mac/Sources/CodeBurnMenubar/Data/ClaudeCredentialStore.swift b/mac/Sources/CodeBurnMenubar/Data/ClaudeCredentialStore.swift index 5df6bbc8..a5a7e8f7 100644 --- a/mac/Sources/CodeBurnMenubar/Data/ClaudeCredentialStore.swift +++ b/mac/Sources/CodeBurnMenubar/Data/ClaudeCredentialStore.swift @@ -55,7 +55,6 @@ enum ClaudeCredentialStore { userDefaultsOverride = nil keychainCache = LiveKeychainCredentialCache() lastCacheDeleteResult = nil - lastLegacyCleanupFailed = false unlinkLegacyOverride = nil tightenLegacyOverride = nil lock.withLock { memoryCache = nil } @@ -154,8 +153,6 @@ enum ClaudeCredentialStore { /// Last disconnect/cleanup result. Nil until the first delete attempt. nonisolated(unsafe) static var lastCacheDeleteResult: CacheDeleteResult? - /// Last legacy-unlink failure after a verified Keychain write (cleanup retry signal). - nonisolated(unsafe) static var lastLegacyCleanupFailed = false /// Test seam: force legacy unlink to throw so we can assert the 0600 repair. nonisolated(unsafe) static var unlinkLegacyOverride: ((URL) throws -> Void)? nonisolated(unsafe) static var tightenLegacyOverride: ((URL) throws -> Void)? @@ -185,7 +182,19 @@ enum ClaudeCredentialStore { if let cached = lock.withLock({ memoryCache }), cached.isFresh { return cached.record } - if let stored = try readOurCache() { + let fetched: CredentialRecord? + do { + fetched = try readOurCache() + } catch let err as KeychainCredentialCacheError { + // A locked/denied keychain means "can't look right now", not "the + // item is gone". Serve the last known token and leave the bootstrap + // flag alone so we don't silently disconnect the user. + if case .unavailable = err { + return lock.withLock { memoryCache }?.record + } + throw err + } + if let stored = fetched { cacheInMemory(stored) return stored } @@ -437,17 +446,37 @@ enum ClaudeCredentialStore { } } + /// Serializes migrate + unlink across processes so two menubar instances (or the + /// CLI) cannot race on the same legacy file. Only `SafeFile.Error` from acquiring + /// the lock is tolerated — `readOurCacheLocked` never lets one escape, because + /// `readLegacyFile` swallows its own read failures. private static func readOurCache() throws -> CredentialRecord? { + do { + return try SafeFile.withExclusiveLock(at: cacheFileURL().path + ".lock") { + try readOurCacheLocked() + } + } catch is SafeFile.Error { + return try readOurCacheLocked() + } + } + + private static func readOurCacheLocked() throws -> CredentialRecord? { if let data = try keychainCache.read(service: ourKeychainService, account: ourKeychainAccount), let record = decodePersisted(data) { + // The Keychain item can predate the legacy file by a long way — this + // service name has been in use since May 2026, so an upgrading install + // can hold a months-old item beside a file the old build wrote today. + // Adopt whichever expires later before unlinking anything. + if let fresher = legacyRecordIfNewer(than: record) { + try? writeOurCache(record: fresher) + return fresher + } // Rewrite historical Claude blobs once without refreshToken. - let sanitized = encodePersisted(record) if let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any], object.keys.contains("refreshToken") { try? writeOurCache(record: record) } else { tryUnlinkLegacyAfterVerifiedKeychain() - _ = sanitized } return record } @@ -455,66 +484,63 @@ enum ClaudeCredentialStore { return try migrateLegacyFileIfPresent() } - /// Secure-read legacy JSON, upsert Keychain, verify read-back, then unlink. - private static func migrateLegacyFileIfPresent() throws -> CredentialRecord? { + /// Reads the legacy file only to compare recency. Returns it when it expires + /// strictly later than `record`; nil when absent, unreadable, or not newer. + private static func legacyRecordIfNewer(than record: CredentialRecord) -> CredentialRecord? { + guard let legacy = readLegacyFile() else { return nil } + guard let legacyExpiry = legacy.expiresAt else { return nil } + guard let currentExpiry = record.expiresAt else { return legacy } + return legacyExpiry > currentExpiry ? legacy : nil + } + + /// Secure-read + decode the legacy JSON, dropping any refreshToken it holds. + /// Returns nil on symlink / ownership / chmod / decode failure, leaving the + /// file in place. + private static func readLegacyFile() -> CredentialRecord? { let url = cacheFileURL() guard FileManager.default.fileExists(atPath: url.path) else { return nil } - - let data: Data - do { - data = try SafeFile.readAfterSecuringPermissions( - from: url.path, - maxBytes: maxCredentialBytes - ) - } catch { - // Symlink / ownership / chmod failures: leave the file alone. - return nil - } - + guard let data = try? SafeFile.readAfterSecuringPermissions( + from: url.path, + maxBytes: maxCredentialBytes + ) else { return nil } guard let decoded = try? JSONDecoder().decode(CredentialRecord.self, from: data) else { // Invalid data stays in place at 0600; do not delete. return nil } - let migrated = CredentialRecord( + return CredentialRecord( accessToken: decoded.accessToken, refreshToken: nil, expiresAt: decoded.expiresAt, rateLimitTier: decoded.rateLimitTier ) - - do { - try writeOurCache(record: migrated) - return migrated - } catch { - // Keychain write/read-back failure: leave repaired 0600 legacy file. - lastLegacyCleanupFailed = false - return migrated - } } + /// Secure-read legacy JSON, upsert Keychain, verify read-back, then unlink. + private static func migrateLegacyFileIfPresent() throws -> CredentialRecord? { + guard let migrated = readLegacyFile() else { return nil } + // Keychain write/read-back failure leaves the repaired 0600 legacy file + // in place; the next read retries the migration. + try? writeOurCache(record: migrated) + return migrated + } + + /// Unlink the redundant legacy JSON. Called on every successful cache read, + /// so a failure here is retried on the next read without a sticky flag. private static func tryUnlinkLegacyAfterVerifiedKeychain() { let url = cacheFileURL() - guard FileManager.default.fileExists(atPath: url.path) else { - lastLegacyCleanupFailed = false - return - } + guard FileManager.default.fileExists(atPath: url.path) else { return } do { if let unlinkLegacyOverride { try unlinkLegacyOverride(url) } else { try FileManager.default.removeItem(at: url) } - lastLegacyCleanupFailed = false } catch { - lastLegacyCleanupFailed = true - do { - if let tightenLegacyOverride { - try tightenLegacyOverride(url) - } else { - try SafeFile.tightenToOwnerReadWrite(at: url.path) - } - } catch { - // Still leftover, and 0600 is unproven. Retry signal stays set. + // Could not remove it — at least make sure it is not world-readable. + if let tightenLegacyOverride { + try? tightenLegacyOverride(url) + } else { + try? SafeFile.tightenToOwnerReadWrite(at: url.path) } } } diff --git a/mac/Sources/CodeBurnMenubar/Data/CodexCredentialStore.swift b/mac/Sources/CodeBurnMenubar/Data/CodexCredentialStore.swift index 00385b05..2d500cfa 100644 --- a/mac/Sources/CodeBurnMenubar/Data/CodexCredentialStore.swift +++ b/mac/Sources/CodeBurnMenubar/Data/CodexCredentialStore.swift @@ -42,7 +42,6 @@ enum CodexCredentialStore { userDefaultsOverride = nil keychainCache = LiveKeychainCredentialCache() lastCacheDeleteResult = nil - lastLegacyCleanupFailed = false unlinkLegacyOverride = nil tightenLegacyOverride = nil lock.withLock { memoryCache = nil } @@ -148,7 +147,6 @@ enum CodexCredentialStore { } nonisolated(unsafe) static var lastCacheDeleteResult: CacheDeleteResult? - nonisolated(unsafe) static var lastLegacyCleanupFailed = false nonisolated(unsafe) static var unlinkLegacyOverride: ((URL) throws -> Void)? nonisolated(unsafe) static var tightenLegacyOverride: ((URL) throws -> Void)? @@ -178,7 +176,18 @@ enum CodexCredentialStore { if let cached = lock.withLock({ memoryCache }), cached.isFresh { return cached.record } - if let stored = try readOurCache() { + let fetched: CredentialRecord? + do { + fetched = try readOurCache() + } catch let err as KeychainCredentialCacheError { + // Locked/denied keychain: serve the last known token rather than + // reporting the grant as missing. + if case .unavailable = err { + return lock.withLock { memoryCache }?.record + } + throw err + } + if let stored = fetched { cacheInMemory(stored) return stored } @@ -331,65 +340,83 @@ enum CodexCredentialStore { tryUnlinkLegacyAfterVerifiedKeychain() } + /// Serializes migrate + unlink across processes so two menubar instances (or the + /// CLI) cannot race on the same legacy file. Only `SafeFile.Error` from acquiring + /// the lock is tolerated — `readOurCacheLocked` never lets one escape, because + /// `readLegacyFile` swallows its own read failures. private static func readOurCache() throws -> CredentialRecord? { + do { + return try SafeFile.withExclusiveLock(at: cacheFileURL().path + ".lock") { + try readOurCacheLocked() + } + } catch is SafeFile.Error { + return try readOurCacheLocked() + } + } + + private static func readOurCacheLocked() throws -> CredentialRecord? { if let data = try keychainCache.read(service: ourKeychainService, account: ourKeychainAccount), let record = try? JSONDecoder().decode(CredentialRecord.self, from: data) { + // Only reached when auth.json is unreadable, but the Keychain item can + // still be older than the legacy file — and serving a spent rotating + // refresh token here ends in a terminal invalid_grant. Prefer the + // later `lastRefresh` before unlinking anything. + if let fresher = legacyRecordIfNewer(than: record) { + try? writeOurCache(record: fresher) + return fresher + } tryUnlinkLegacyAfterVerifiedKeychain() return record } return try migrateLegacyFileIfPresent() } - private static func migrateLegacyFileIfPresent() throws -> CredentialRecord? { - let url = cacheFileURL() - guard FileManager.default.fileExists(atPath: url.path) else { return nil } - - let data: Data - do { - data = try SafeFile.readAfterSecuringPermissions( - from: url.path, - maxBytes: maxCredentialBytes - ) - } catch { - return nil - } - - guard let decoded = try? JSONDecoder().decode(CredentialRecord.self, from: data) else { - return nil - } - - do { - try writeOurCache(record: decoded) - return decoded - } catch { - lastLegacyCleanupFailed = false - return decoded - } + /// Returns the legacy file's record when it refreshed strictly later than + /// `record`; nil when absent, unreadable, or not newer. + private static func legacyRecordIfNewer(than record: CredentialRecord) -> CredentialRecord? { + guard let legacy = readLegacyFile() else { return nil } + guard let legacyRefresh = legacy.lastRefresh else { return nil } + guard let currentRefresh = record.lastRefresh else { return legacy } + return legacyRefresh > currentRefresh ? legacy : nil } + /// Secure-read + decode the legacy JSON. Returns nil on symlink / ownership / + /// chmod / decode failure, leaving the file in place. + private static func readLegacyFile() -> CredentialRecord? { + let url = cacheFileURL() + guard FileManager.default.fileExists(atPath: url.path) else { return nil } + guard let data = try? SafeFile.readAfterSecuringPermissions( + from: url.path, + maxBytes: maxCredentialBytes + ) else { return nil } + return try? JSONDecoder().decode(CredentialRecord.self, from: data) + } + + private static func migrateLegacyFileIfPresent() throws -> CredentialRecord? { + guard let decoded = readLegacyFile() else { return nil } + // Keychain write/read-back failure leaves the repaired 0600 legacy file + // in place; the next read retries the migration. + try? writeOurCache(record: decoded) + return decoded + } + + /// Unlink the redundant legacy JSON. Called on every successful cache read, + /// so a failure here is retried on the next read without a sticky flag. private static func tryUnlinkLegacyAfterVerifiedKeychain() { let url = cacheFileURL() - guard FileManager.default.fileExists(atPath: url.path) else { - lastLegacyCleanupFailed = false - return - } + guard FileManager.default.fileExists(atPath: url.path) else { return } do { if let unlinkLegacyOverride { try unlinkLegacyOverride(url) } else { try FileManager.default.removeItem(at: url) } - lastLegacyCleanupFailed = false } catch { - lastLegacyCleanupFailed = true - do { - if let tightenLegacyOverride { - try tightenLegacyOverride(url) - } else { - try SafeFile.tightenToOwnerReadWrite(at: url.path) - } - } catch { - // Still leftover, and 0600 is unproven. Retry signal stays set. + // Could not remove it — at least make sure it is not world-readable. + if let tightenLegacyOverride { + try? tightenLegacyOverride(url) + } else { + try? SafeFile.tightenToOwnerReadWrite(at: url.path) } } } diff --git a/mac/Tests/CodeBurnMenubarTests/CredentialKeychainContinuityTests.swift b/mac/Tests/CodeBurnMenubarTests/CredentialKeychainContinuityTests.swift index b4b8ebb6..13385a94 100644 --- a/mac/Tests/CodeBurnMenubarTests/CredentialKeychainContinuityTests.swift +++ b/mac/Tests/CodeBurnMenubarTests/CredentialKeychainContinuityTests.swift @@ -204,7 +204,6 @@ struct CredentialKeychainContinuityTests { account: ClaudeCredentialStore.ourKeychainAccount ) #expect(keys?.contains("refreshToken") != true) - #expect(ClaudeCredentialStore.lastLegacyCleanupFailed == false) } } @@ -319,13 +318,18 @@ struct CredentialKeychainContinuityTests { } ClaudeCredentialStore.clearMemoryCacheForTesting() _ = try ClaudeCredentialStore.currentRecord() - #expect(ClaudeCredentialStore.lastLegacyCleanupFailed == true) #expect(FileManager.default.fileExists(atPath: ClaudeCredentialStore.cacheFileURL().path)) #expect(posixMode(at: ClaudeCredentialStore.cacheFileURL()) == 0o600) + + // No sticky flag: the next read retries the unlink on its own. + ClaudeCredentialStore.unlinkLegacyOverride = nil + ClaudeCredentialStore.clearMemoryCacheForTesting() + _ = try ClaudeCredentialStore.currentRecord() + #expect(!FileManager.default.fileExists(atPath: ClaudeCredentialStore.cacheFileURL().path)) } } - @Test("failed tighten after failed unlink keeps leftover and retry signal") + @Test("failed tighten after failed unlink keeps leftover in place") func failedTightenLeavesRetrySignal() throws { try withHarness { _ in let record = ClaudeCredentialStore.CredentialRecord( @@ -341,7 +345,6 @@ struct CredentialKeychainContinuityTests { ClaudeCredentialStore.tightenLegacyOverride = { _ in throw POSIXError(.EPERM) } ClaudeCredentialStore.clearMemoryCacheForTesting() _ = try ClaudeCredentialStore.currentRecord() - #expect(ClaudeCredentialStore.lastLegacyCleanupFailed == true) #expect(FileManager.default.fileExists(atPath: ClaudeCredentialStore.cacheFileURL().path)) } } @@ -381,6 +384,148 @@ struct CredentialKeychainContinuityTests { } } + // MARK: - Locked / denied Keychain + + @Test("unavailable Keychain read is a miss, not a disconnect") + func unavailableKeychainKeepsBootstrap() throws { + try withHarness { harness in + try ClaudeCredentialStore.writeOurCache(record: .init( + accessToken: accessSentinel, + refreshToken: refreshSentinel, + expiresAt: Date(timeIntervalSince1970: 1_900_000_000), + rateLimitTier: "default" + )) + ClaudeCredentialStore.isBootstrapCompleted = true + + let controllable = ControllableKeychainCredentialCache(inner: harness.fakeKeychain) + controllable.failRead = true + controllable.readStatus = errSecInteractionNotAllowed // -25308 + ClaudeCredentialStore.keychainCache = controllable + ClaudeCredentialStore.clearMemoryCacheForTesting() + + // Must not throw and must not clear bootstrap: a locked keychain is + // "can't look right now", not "the user disconnected". + #expect(try ClaudeCredentialStore.currentRecord() == nil) + #expect(ClaudeCredentialStore.isBootstrapCompleted == true) + } + } + + @Test("a genuine read failure still surfaces as an error") + func nonUnavailableReadStillThrows() throws { + try withHarness { harness in + ClaudeCredentialStore.isBootstrapCompleted = true + let controllable = ControllableKeychainCredentialCache(inner: harness.fakeKeychain) + controllable.failRead = true + controllable.readStatus = errSecDecode + ClaudeCredentialStore.keychainCache = controllable + ClaudeCredentialStore.clearMemoryCacheForTesting() + + #expect(throws: KeychainCredentialCacheError.self) { + _ = try ClaudeCredentialStore.currentRecord() + } + } + } + + @Test("Keychain errors render a readable message, not a struct dump") + func keychainErrorMessageIsReadable() { + let unavailable = KeychainCredentialCacheError.unavailable( + service: ClaudeCredentialStore.ourKeychainService, + status: errSecInteractionNotAllowed + ) + let text = unavailable.localizedDescription + #expect(text.contains("Keychain unavailable")) + // AppStore renders errors via localizedDescription; a struct dump would + // read "unavailable(service:" and leak the raw item name. + #expect(!text.contains("unavailable(service:")) + #expect(!text.contains(ClaudeCredentialStore.ourKeychainService)) + } + + // MARK: - Recency between Keychain item and legacy file + + @Test("newer legacy file beats an older Keychain item and is then unlinked") + func newerLegacyFileWins() throws { + try withHarness { harness in + // Keychain item from an old build: already expired. + try ClaudeCredentialStore.writeOurCache(record: .init( + accessToken: "cb-stale-keychain-token", + refreshToken: nil, + expiresAt: Date(timeIntervalSince1970: 1_700_000_000), + rateLimitTier: "default" + )) + // Legacy file written far later by the pre-migration build. + try writeLegacyClaude0644(record: .init( + accessToken: accessSentinel, + refreshToken: refreshSentinel, + expiresAt: Date(timeIntervalSince1970: 1_900_000_000), + rateLimitTier: "default" + )) + ClaudeCredentialStore.isBootstrapCompleted = true + ClaudeCredentialStore.clearMemoryCacheForTesting() + + let record = try #require(try ClaudeCredentialStore.currentRecord()) + #expect(record.accessToken == accessSentinel) + #expect(record.refreshToken == nil) + #expect(!FileManager.default.fileExists(atPath: ClaudeCredentialStore.cacheFileURL().path)) + let keys = harness.fakeKeychain.storedKeys( + service: ClaudeCredentialStore.ourKeychainService, + account: ClaudeCredentialStore.ourKeychainAccount + ) + #expect(keys?.contains("refreshToken") != true) + } + } + + @Test("older legacy file loses to a newer Keychain item and is unlinked") + func olderLegacyFileLoses() throws { + try withHarness { _ in + try ClaudeCredentialStore.writeOurCache(record: .init( + accessToken: accessSentinel, + refreshToken: nil, + expiresAt: Date(timeIntervalSince1970: 1_900_000_000), + rateLimitTier: "default" + )) + try writeLegacyClaude0644(record: .init( + accessToken: "cb-stale-file-token", + refreshToken: refreshSentinel, + expiresAt: Date(timeIntervalSince1970: 1_700_000_000), + rateLimitTier: "default" + )) + ClaudeCredentialStore.isBootstrapCompleted = true + ClaudeCredentialStore.clearMemoryCacheForTesting() + + let record = try #require(try ClaudeCredentialStore.currentRecord()) + #expect(record.accessToken == accessSentinel) + #expect(!FileManager.default.fileExists(atPath: ClaudeCredentialStore.cacheFileURL().path)) + } + } + + @Test("Codex prefers the legacy file with the later lastRefresh") + func codexNewerLegacyFileWins() throws { + try withHarness { _ in + try CodexCredentialStore.writeOurCache(record: .init( + accessToken: "cb-stale-codex-access", + refreshToken: "cb-stale-codex-refresh", + idToken: nil, + accountId: accountSentinel, + expiresAt: nil, + lastRefresh: Date(timeIntervalSince1970: 1_700_000_000) + )) + try writeLegacyCodex0644(record: .init( + accessToken: accessSentinel, + refreshToken: refreshSentinel, + idToken: idSentinel, + accountId: accountSentinel, + expiresAt: nil, + lastRefresh: Date(timeIntervalSince1970: 1_800_000_000) + )) + CodexCredentialStore.isBootstrapCompleted = true + CodexCredentialStore.clearMemoryCacheForTesting() + + let record = try #require(try CodexCredentialStore.currentRecord()) + #expect(record.refreshToken == refreshSentinel) + #expect(!FileManager.default.fileExists(atPath: CodexCredentialStore.cacheFileURL().path)) + } + } + @Test("corrupt Keychain plus valid legacy repairs the CodeBurn item") func corruptKeychainRepairedFromLegacy() throws { try withHarness { harness in From 0f7bfb3eb2b433874c0cf3e5f8937d3edcd18315 Mon Sep 17 00:00:00 2001 From: iamtoruk Date: Wed, 19 Aug 2026 11:43:44 -0700 Subject: [PATCH 83/85] fix(menubar): make a failed disconnect leave a consistent state MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit disconnect() cleared the usage block before anyone knew whether the delete had worked, and AppStore then returned early on failure — so a failed disconnect cleared some state, left the rest, and still posted subscriptionDisconnected. It also carried a second !isSuccess branch that the early return had already made unreachable. Both services now return the delete result and only clear the usage block on success, so a failure changes nothing at all: the provider stays connected, Disconnect stays available, and the banner asks for a retry. That matches the success path's ordering instead of half-applying it. Errors reaching the generic catches now render localizedDescription rather than String(describing:), so a Keychain failure shows its message instead of an enum dump with the raw item name in it. --- mac/Sources/CodeBurnMenubar/AppStore.swift | 39 ++++++++----------- .../Data/ClaudeSubscriptionService.swift | 11 ++++-- .../Data/CodexSubscriptionService.swift | 11 ++++-- 3 files changed, 32 insertions(+), 29 deletions(-) diff --git a/mac/Sources/CodeBurnMenubar/AppStore.swift b/mac/Sources/CodeBurnMenubar/AppStore.swift index 2ca365f0..f8ff5bab 100644 --- a/mac/Sources/CodeBurnMenubar/AppStore.swift +++ b/mac/Sources/CodeBurnMenubar/AppStore.swift @@ -1053,7 +1053,7 @@ final class AppStore { return false } catch { guard gen == claudeRefreshGen else { return false } - subscriptionError = sanitizeForUI(String(describing: error)) + subscriptionError = sanitizeForUI(error.localizedDescription) subscriptionLoadState = .failed return false } @@ -1064,24 +1064,20 @@ final class AppStore { /// account or tier) starts clean. capacityEstimates and the snapshot store /// would otherwise contaminate "Based on last cycle" projections. func disconnectSubscription() { - ClaudeSubscriptionService.disconnect() + let result = ClaudeSubscriptionService.disconnect() // Bump the generation token so any in-flight refreshSubscription that // resumes after this point detects the disconnect and discards its // result instead of re-populating the cleared state. claudeRefreshGen &+= 1 - if let result = ClaudeCredentialStore.lastCacheDeleteResult, !result.isSuccess { - // Any leftover Keychain item or plaintext must keep Disconnect - // so the user can retry delete. + guard result.isSuccess else { + // Nothing was removed, so nothing is disconnected. Leave the + // connected state exactly as it was — the bootstrap flag is still + // set, Disconnect stays available, and the banner says to retry. subscriptionError = "Could not fully remove the local Claude credential cache. Disconnect again to retry." - NotificationCenter.default.post(name: .codeBurnSubscriptionDisconnected, object: nil) return } subscription = nil - if let result = ClaudeCredentialStore.lastCacheDeleteResult, !result.isSuccess { - subscriptionError = "Could not fully remove the local Claude credential cache." - } else { - subscriptionError = nil - } + subscriptionError = nil subscriptionLoadState = .notBootstrapped capacityEstimates = [:] Task.detached { await SubscriptionSnapshotStore.clearAll() } @@ -1102,7 +1098,7 @@ final class AppStore { } catch let err as CodexSubscriptionService.FetchError { applyCodexFetchError(err) } catch { - codexError = sanitizeForUI(String(describing: error)) + codexError = sanitizeForUI(error.localizedDescription) codexLoadState = .failed } } @@ -1135,26 +1131,23 @@ final class AppStore { return false } catch { guard gen == codexRefreshGen else { return false } - codexError = sanitizeForUI(String(describing: error)) + codexError = sanitizeForUI(error.localizedDescription) codexLoadState = .failed return false } } func disconnectCodex() { - CodexSubscriptionService.disconnect() + let result = CodexSubscriptionService.disconnect() codexRefreshGen &+= 1 - if let result = CodexCredentialStore.lastCacheDeleteResult, !result.isSuccess { + guard result.isSuccess else { + // Nothing removed means nothing disconnected; keep state intact so + // Disconnect stays available for a retry. codexError = "Could not fully remove the local Codex credential cache. Disconnect again to retry." - NotificationCenter.default.post(name: .codeBurnSubscriptionDisconnected, object: nil) return } codexUsage = nil - if let result = CodexCredentialStore.lastCacheDeleteResult, !result.isSuccess { - codexError = "Could not fully remove the local Codex credential cache." - } else { - codexError = nil - } + codexError = nil codexLoadState = .notBootstrapped NotificationCenter.default.post(name: .codeBurnSubscriptionDisconnected, object: nil) } @@ -1196,7 +1189,7 @@ final class AppStore { applyKimiFetchError(err) } catch { guard gen == kimiRefreshGen else { return } - kimiError = sanitizeForUI(String(describing: error)) + kimiError = sanitizeForUI(error.localizedDescription) kimiLoadState = .failed } } @@ -1230,7 +1223,7 @@ final class AppStore { return false } catch { guard gen == kimiRefreshGen else { return false } - kimiError = sanitizeForUI(String(describing: error)) + kimiError = sanitizeForUI(error.localizedDescription) kimiLoadState = .failed return false } diff --git a/mac/Sources/CodeBurnMenubar/Data/ClaudeSubscriptionService.swift b/mac/Sources/CodeBurnMenubar/Data/ClaudeSubscriptionService.swift index d452b049..8a12b411 100644 --- a/mac/Sources/CodeBurnMenubar/Data/ClaudeSubscriptionService.swift +++ b/mac/Sources/CodeBurnMenubar/Data/ClaudeSubscriptionService.swift @@ -99,9 +99,14 @@ enum ClaudeSubscriptionService { } /// Reset everything — used on user-initiated disconnect. - static func disconnect() { - _ = ClaudeCredentialStore.resetBootstrap() - clearUsageBlock() + /// Returns the delete outcome so callers only tear down UI state once the + /// credential material is actually gone. A failed delete leaves the usage + /// block intact too, so a retry starts from the same state. + @discardableResult + static func disconnect() -> ClaudeCredentialStore.CacheDeleteResult { + let result = ClaudeCredentialStore.resetBootstrap() + if result.isSuccess { clearUsageBlock() } + return result } // MARK: - Internal diff --git a/mac/Sources/CodeBurnMenubar/Data/CodexSubscriptionService.swift b/mac/Sources/CodeBurnMenubar/Data/CodexSubscriptionService.swift index 7fd5968b..275f7848 100644 --- a/mac/Sources/CodeBurnMenubar/Data/CodexSubscriptionService.swift +++ b/mac/Sources/CodeBurnMenubar/Data/CodexSubscriptionService.swift @@ -78,9 +78,14 @@ enum CodexSubscriptionService { } } - static func disconnect() { - _ = CodexCredentialStore.resetBootstrap() - clearUsageBlock() + /// Returns the delete outcome so callers only tear down UI state once the + /// credential material is actually gone. A failed delete leaves the usage + /// block intact too, so a retry starts from the same state. + @discardableResult + static func disconnect() -> CodexCredentialStore.CacheDeleteResult { + let result = CodexCredentialStore.resetBootstrap() + if result.isSuccess { clearUsageBlock() } + return result } private static func fetchWithToken(_ token: String, allowOne401Recovery: Bool) async throws -> CodexUsage { From 0dff0b66d50bcfe687f9fc4a752d5cee95ad8c0b Mon Sep 17 00:00:00 2001 From: iamtoruk Date: Wed, 19 Aug 2026 11:43:44 -0700 Subject: [PATCH 84/85] docs(menubar): state what the Keychain move actually guarantees The Codex settings copy implied the cached credential was app-private. It is a normal login-Keychain item: reachable by programs running as you, with no per-app ACL. The real win is that it is no longer a world-readable 0644 file, so say that instead. Also documents why readAfterSecuringPermissions repairs permissions before validating content (validating first would read the secret while it is still world-readable, which is the window the function exists to close), and why the Keychain service names are deliberately not derived from CFBundleIdentifier (the Electron app hardcodes the same strings). Adds the #1037 changelog entry. --- CHANGELOG.md | 1 + mac/Sources/CodeBurnMenubar/Security/SafeFile.swift | 6 ++++++ mac/Sources/CodeBurnMenubar/Views/SettingsView.swift | 2 +- 3 files changed, 8 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9bee3bad..6a2a7fb4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -29,6 +29,7 @@ - **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) ### 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) - **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) diff --git a/mac/Sources/CodeBurnMenubar/Security/SafeFile.swift b/mac/Sources/CodeBurnMenubar/Security/SafeFile.swift index 9ff37fe4..3746f539 100644 --- a/mac/Sources/CodeBurnMenubar/Security/SafeFile.swift +++ b/mac/Sources/CodeBurnMenubar/Security/SafeFile.swift @@ -147,6 +147,12 @@ enum SafeFile { /// Legacy credential migration path: open with `O_NOFOLLOW`, refuse non-regular / /// non-owned files, `fchmod(0600)` and verify mode, then read bounded bytes from /// the same descriptor. Permissions are repaired before any secret byte is read. + /// + /// The chmod deliberately precedes any content check: validating JSON first would + /// mean reading the secret while it is still world-readable, which is the exact + /// window this function exists to close. The cost is that a non-credential file + /// sitting at the caller's exact cache path also gets tightened to 0600 — bounded + /// to our own Application Support directory, and already symlink- and owner-checked. static func readAfterSecuringPermissions( from path: String, maxBytes: Int = defaultReadLimit, diff --git a/mac/Sources/CodeBurnMenubar/Views/SettingsView.swift b/mac/Sources/CodeBurnMenubar/Views/SettingsView.swift index 3ee25a50..d63bcffa 100644 --- a/mac/Sources/CodeBurnMenubar/Views/SettingsView.swift +++ b/mac/Sources/CodeBurnMenubar/Views/SettingsView.swift @@ -508,7 +508,7 @@ private struct CodexSettingsTab: View { CodexConnectionRow() } Section { - Text("Codex live-quota tracking reads `~/.codex/auth.json` once on Connect, then keeps a CodeBurn-owned copy in the macOS Keychain so subsequent quota fetches don't re-read the original. Only ChatGPT-mode auth (Plus / Pro / Team / Business / Edu / Enterprise) is supported. API-key users are billed per request and have a different reporting surface. Credit-metered workspaces report no rate-limit windows, so their monthly credit allowance is shown instead.") + Text("Codex live-quota tracking reads `~/.codex/auth.json` once on Connect, then keeps a CodeBurn-owned copy in your login Keychain instead of a world-readable file, so subsequent quota fetches don't re-read the original. The item is reachable by programs running as you, the same as any login-Keychain entry. Only ChatGPT-mode auth (Plus / Pro / Team / Business / Edu / Enterprise) is supported. API-key users are billed per request and have a different reporting surface. Credit-metered workspaces report no rate-limit windows, so their monthly credit allowance is shown instead.") .font(.system(size: 11)) .foregroundStyle(.secondary) } header: { From dba9a4582539767eae8c95b886200055086018da Mon Sep 17 00:00:00 2001 From: iamtoruk Date: Wed, 19 Aug 2026 11:58:39 -0700 Subject: [PATCH 85/85] test(mac): stop the concurrent-timeout test starving the pool it waits on The test blocked on a DispatchSemaphore with a 15s deadline, commented as running "on a real thread, not the cooperative pool". Swift Testing invokes synchronous test bodies from a task on the cooperative pool, so the wait parked one of activeProcessorCount workers on the very task group it was waiting for. With 16 cores locally there is slack; on the 3-core macos-latest runner, alongside the rest of the parallel suite, the group made no progress at all and the wait expired. Await the group directly instead, which also lets the compiler reject the blocking wait (unavailable from async contexts), and bound the test with .timeLimit rather than a hand-rolled wall clock. Assert each child came back with a signal status, so the test now proves the timeout killed every hung process instead of only that the group returned. Reproduced by parking all but 3 cooperative threads for the run: 3/3 failures at 15.0s before, 3/3 passes after. 10x full suite under CPU load: 160/160. --- .../DataClientProcessTests.swift | 58 +++++++++++-------- 1 file changed, 33 insertions(+), 25 deletions(-) diff --git a/mac/Tests/CodeBurnMenubarTests/DataClientProcessTests.swift b/mac/Tests/CodeBurnMenubarTests/DataClientProcessTests.swift index 93d4ed6e..24939dea 100644 --- a/mac/Tests/CodeBurnMenubarTests/DataClientProcessTests.swift +++ b/mac/Tests/CodeBurnMenubarTests/DataClientProcessTests.swift @@ -89,38 +89,46 @@ struct DataClientProcessTests { /// Concurrency + timeout smoke test: launch more hung subprocesses than /// there are cooperative threads, all at once, with a short timeout, and - /// assert every call returns once the timeout kills its sleep. + /// assert every call returns because the timeout killed its sleep. /// /// NOTE: this does NOT reproduce the production permanent deadlock (16/16 - /// cooperative threads parked in waitUntilExit). In a short-lived unit-test - /// process libdispatch spins up replacement threads for blocked workers, so - /// even the old blocking-on-the-pool code completes here. The real deadlock - /// built up over ~2 days under the @MainActor refresh loop and is confirmed - /// by the live `sample`, not by this test. Kept as a guard that the - /// off-pool wait + timeout path stays correct under concurrency. - @Test("concurrent timed-out processes all complete") - func concurrentTimedOutProcessesAllComplete() { + /// cooperative threads parked in waitUntilExit). The real deadlock built up + /// over ~2 days under the @MainActor refresh loop and is confirmed by the + /// live `sample`, not by this test. Kept as a guard that the off-pool wait + /// + timeout path stays correct under concurrency. + /// + /// The body must stay `async` and await the group directly. It used to + /// block on a `DispatchSemaphore` with a 15s deadline, on the claim that a + /// test body runs on a real thread. It does not: Swift Testing invokes even + /// synchronous test bodies from a task on the cooperative pool, so the wait + /// parked one of the pool's `activeProcessorCount` workers on the very work + /// it was waiting for. A 16-core dev box has slack, a 3-core CI runner does + /// not, and the wait expired with the group making no progress at all. + /// Keeping it `async` also lets the compiler reject the blocking wait, + /// which is unavailable from asynchronous contexts. + @Test("concurrent timed-out processes all complete", .timeLimit(.minutes(1))) + func concurrentTimedOutProcessesAllComplete() async { let count = ProcessInfo.processInfo.activeProcessorCount * 2 + 4 - let done = DispatchSemaphore(value: 0) - - Task { - await withTaskGroup(of: Void.self) { group in - for _ in 0.. [Int32?] in + for _ in 0..