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