From 4c1aba1ae2583a24ba72f547d452810b3bbe4c8a Mon Sep 17 00:00:00 2001 From: reviewer Date: Wed, 22 Jul 2026 00:15:14 +0200 Subject: [PATCH 01/38] desktop: add Microsoft Store package workflow --- .github/workflows/build-windows-store.yml | 45 ++++++++++++++++++++++ app/DISTRIBUTION.md | 22 +++++++++++ app/build/appx/Square150x150Logo.png | Bin 0 -> 16271 bytes app/build/appx/Square44x44Logo.png | Bin 0 -> 2262 bytes app/build/appx/StoreLogo.png | Bin 0 -> 2668 bytes app/build/appx/Wide310x150Logo.png | Bin 0 -> 13822 bytes app/package.json | 15 ++++++++ 7 files changed, 82 insertions(+) create mode 100644 .github/workflows/build-windows-store.yml create mode 100644 app/build/appx/Square150x150Logo.png create mode 100644 app/build/appx/Square44x44Logo.png create mode 100644 app/build/appx/StoreLogo.png create mode 100644 app/build/appx/Wide310x150Logo.png diff --git a/.github/workflows/build-windows-store.yml b/.github/workflows/build-windows-store.yml new file mode 100644 index 0000000..a7e6f03 --- /dev/null +++ b/.github/workflows/build-windows-store.yml @@ -0,0 +1,45 @@ +name: Build Windows Store package + +on: + workflow_dispatch: + pull_request: + paths: + - .github/workflows/build-windows-store.yml + - app/** + - src/** + - package.json + - package-lock.json + +permissions: + contents: read + +jobs: + appx: + runs-on: windows-latest + steps: + - uses: actions/checkout@v6 + + - uses: actions/setup-node@v6 + with: + node-version: 22.13.0 + cache: npm + cache-dependency-path: | + package-lock.json + app/package-lock.json + + - name: Install CLI dependencies + run: npm ci + + - name: Install desktop dependencies + run: npm ci --prefix app + + - name: Build Microsoft Store package + run: npm --prefix app run package:store + + - name: Upload Store package + uses: actions/upload-artifact@v6 + with: + name: CodeBurn-Microsoft-Store + path: app/release/CodeBurn-Store-*.appx + if-no-files-found: error + retention-days: 14 diff --git a/app/DISTRIBUTION.md b/app/DISTRIBUTION.md index 7bd4bd0..6916502 100644 --- a/app/DISTRIBUTION.md +++ b/app/DISTRIBUTION.md @@ -66,6 +66,7 @@ npm --prefix app run package # macOS, both arm64 and x64 npm --prefix app run package:arm64 # macOS arm64 only (faster on Apple Silicon) npm --prefix app run package:x64 # macOS x64 only npm --prefix app run package:win # Windows NSIS installer, x64 +npm --prefix app run package:store # Microsoft Store AppX, x64 (Windows host only) npm --prefix app run package:linux # Linux AppImage, x64 ``` @@ -184,6 +185,27 @@ shows **"Windows protected your PC"**. Users click **"More info" → "Run anyway"** to launch it. This is expected for an unsigned build; the only fix is a purchased code-signing (Authenticode/EV) certificate. +### Microsoft Store (`package:store`) + +The Store build is a separate AppX target so the GitHub NSIS installer remains +unchanged. AppX packaging requires Windows 10 or newer and is built by the +manual `Build Windows Store package` GitHub Actions workflow on +`windows-latest`. Download its `CodeBurn-Microsoft-Store` workflow artifact and +upload the contained `CodeBurn-Store--x64.appx` file in Partner Center. + +The manifest identity must exactly match the reserved Partner Center product: + +- Identity name: `Codeburn.CodeBurn` +- Publisher: `CN=3EFA3336-87E1-46F2-9DFA-2EB5A7693F89` +- Publisher display name: `Codeburn` +- Store ID: `9P0R4ZL5XMB8` + +The Store package is intentionally unsigned: Microsoft signs it during Store +submission. Direct sideloading requires a separate trusted or development +certificate. The AppX declares `runFullTrust` (electron-builder's required +default for Electron apps), so CodeBurn retains access to the user's local +provider session files rather than running in a UWP application sandbox. + ### Linux (`package:linux`) `electron-builder --linux` produces a single artifact in `app/release/`: diff --git a/app/build/appx/Square150x150Logo.png b/app/build/appx/Square150x150Logo.png new file mode 100644 index 0000000000000000000000000000000000000000..f80200bd7c156d1019686b76f8eab628bb48447d GIT binary patch literal 16271 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 literal 0 HcmV?d00001 diff --git a/app/build/appx/StoreLogo.png b/app/build/appx/StoreLogo.png new file mode 100644 index 0000000000000000000000000000000000000000..bc440fed2826cef96d167a42d68caac0ed7d7113 GIT binary patch literal 2668 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 a!2bX%enGdv>;Ojq00005?zjMyL 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 Date: Wed, 22 Jul 2026 00:18:25 +0200 Subject: [PATCH 02/38] desktop: make CLI staging portable on Windows --- app/scripts/stage-cli.mjs | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/app/scripts/stage-cli.mjs b/app/scripts/stage-cli.mjs index 827776e..a107504 100644 --- a/app/scripts/stage-cli.mjs +++ b/app/scripts/stage-cli.mjs @@ -81,11 +81,15 @@ try { // Map each back to its top-level node_modules entry (`name` or `@scope/name`), // then copy those dirs whole — a package's own nested node_modules comes with // it, which is exactly the closure it needs at runtime. -const prefix = rootModules + '/' +// `npm ls --parseable` uses native separators on Windows. Normalize both sides +// before extracting the package name so Store builds do not treat a populated +// node_modules tree as empty merely because it uses `\\` instead of `/`. +const prefix = rootModules.replaceAll('\\', '/') + '/' const topLevel = new Set() for (const line of listed.split('\n')) { - if (!line.startsWith(prefix)) continue - const rest = line.slice(prefix.length) + const normalizedLine = line.trim().replaceAll('\\', '/') + if (!normalizedLine.startsWith(prefix)) continue + const rest = normalizedLine.slice(prefix.length) const match = rest.match(/^(@[^/]+\/[^/]+|[^/]+)/) if (match) topLevel.add(match[1]) } From 212f3d99912b03a36d518308f4510929807401f1 Mon Sep 17 00:00:00 2001 From: reviewer Date: Wed, 22 Jul 2026 00:21:06 +0200 Subject: [PATCH 03/38] desktop: invoke npm shim on Windows --- app/scripts/stage-cli.mjs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/app/scripts/stage-cli.mjs b/app/scripts/stage-cli.mjs index a107504..aa5d361 100644 --- a/app/scripts/stage-cli.mjs +++ b/app/scripts/stage-cli.mjs @@ -68,7 +68,10 @@ writeFileSync( // extraneous warnings, so capture stdout regardless of exit code. let listed = '' try { - listed = execFileSync('npm', ['ls', '--omit=dev', '--all', '--parseable'], { + // npm is a .cmd shim on Windows; child_process does not resolve that shim + // through a shell the way an interactive PowerShell/npm step does. + const npmExecutable = process.platform === 'win32' ? 'npm.cmd' : 'npm' + listed = execFileSync(npmExecutable, ['ls', '--omit=dev', '--all', '--parseable'], { cwd: root, encoding: 'utf8', maxBuffer: 64 * 1024 * 1024, From 5ef385761f974d0170f0a0651a4be8f96c4b0ea0 Mon Sep 17 00:00:00 2001 From: reviewer Date: Wed, 22 Jul 2026 00:23:24 +0200 Subject: [PATCH 04/38] desktop: run npm portably during staging --- app/scripts/stage-cli.mjs | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/app/scripts/stage-cli.mjs b/app/scripts/stage-cli.mjs index aa5d361..6908567 100644 --- a/app/scripts/stage-cli.mjs +++ b/app/scripts/stage-cli.mjs @@ -68,10 +68,11 @@ writeFileSync( // extraneous warnings, so capture stdout regardless of exit code. let listed = '' try { - // npm is a .cmd shim on Windows; child_process does not resolve that shim - // through a shell the way an interactive PowerShell/npm step does. - const npmExecutable = process.platform === 'win32' ? 'npm.cmd' : 'npm' - listed = execFileSync(npmExecutable, ['ls', '--omit=dev', '--all', '--parseable'], { + // Execute npm's JavaScript entry point with the current Node binary. Windows + // exposes npm as a .cmd shim, which execFile cannot launch without a shell. + const npmCli = process.env.npm_execpath + if (!npmCli) throw new Error('npm_execpath is unavailable') + listed = execFileSync(process.execPath, [npmCli, 'ls', '--omit=dev', '--all', '--parseable'], { cwd: root, encoding: 'utf8', maxBuffer: 64 * 1024 * 1024, @@ -88,10 +89,12 @@ try { // before extracting the package name so Store builds do not treat a populated // node_modules tree as empty merely because it uses `\\` instead of `/`. const prefix = rootModules.replaceAll('\\', '/') + '/' +const comparisonPrefix = process.platform === 'win32' ? prefix.toLowerCase() : prefix const topLevel = new Set() for (const line of listed.split('\n')) { const normalizedLine = line.trim().replaceAll('\\', '/') - if (!normalizedLine.startsWith(prefix)) continue + const comparisonLine = process.platform === 'win32' ? normalizedLine.toLowerCase() : normalizedLine + if (!comparisonLine.startsWith(comparisonPrefix)) continue const rest = normalizedLine.slice(prefix.length) const match = rest.match(/^(@[^/]+\/[^/]+|[^/]+)/) if (match) topLevel.add(match[1]) From be5c0c00aadb3f1d8a3ed420655f907964c51ca2 Mon Sep 17 00:00:00 2001 From: reviewer Date: Thu, 23 Jul 2026 21:31:25 +0200 Subject: [PATCH 05/38] kimicode: discover desktop-runtime sessions and fix menubar visibility - Resolve k3/k3-agent/k2d6-agent model aliases to canonical Kimi names - Discover sessions across all Kimi Code homes (CLI + desktop runtime) - Accept conv-*/ctitle-* session directory naming, not just session_* - Add Kimi Code provider tab with brand color to the menubar - Show short model names (Kimi K3, Kimi K2.6) in the menubar payload --- docs/providers/kimicode.md | 13 +++++-- .../CodeBurnMenubar/Views/AgentTabStrip.swift | 1 + src/dashboard.tsx | 2 ++ src/menubar-json.ts | 21 ++++++++++-- src/models.ts | 8 +++++ src/providers/kimicode.ts | 27 ++++++++++++--- tests/menubar-json.test.ts | 25 ++++++++++++++ tests/menubar-savings.test.ts | 2 +- tests/models.test.ts | 7 ++++ tests/providers/kimicode.test.ts | 34 +++++++++++++++++++ 10 files changed, 129 insertions(+), 11 deletions(-) diff --git a/docs/providers/kimicode.md b/docs/providers/kimicode.md index 8030088..a92356b 100644 --- a/docs/providers/kimicode.md +++ b/docs/providers/kimicode.md @@ -8,14 +8,23 @@ MoonshotAI Kimi Code local session usage and tool activity. ## Where it reads from -The provider reads `~/.kimi-code` by default and honors the Kimi Code CLI's `KIMI_CODE_HOME` environment variable. It scans: +By default the provider scans every known Kimi Code runtime store: ```text -$KIMI_CODE_HOME/sessions/wd_*/session_*/ +~/.kimi-code +~/Library/Application Support/kimi-desktop/daimon-share/daimon/runtime/kimi-code/home (Kimi desktop / IDE embedded runtime) +``` + +Setting `KIMI_CODE_HOME` (or passing a home override) narrows the scan to that single home. Inside each home it scans: + +```text +$HOME/sessions/wd_*// ├── state.json └── agents//wire.jsonl ``` +Session directory naming depends on the host product: the CLI uses `session_*`, embedded runtimes use `conv-*` / `ctitle-*`. Any directory is accepted; the `agents/*/wire.jsonl` probe gates real sessions. + Every agent wire is a cache source. Main-agent and subagent calls share the session ID from the `session_*` directory. `state.json.workDir` supplies the project name and path. `probeRoots()` reports the resolved Kimi Code home for `codeburn doctor` even when there are no sessions. ## Storage format diff --git a/mac/Sources/CodeBurnMenubar/Views/AgentTabStrip.swift b/mac/Sources/CodeBurnMenubar/Views/AgentTabStrip.swift index 8e34638..40deb71 100644 --- a/mac/Sources/CodeBurnMenubar/Views/AgentTabStrip.swift +++ b/mac/Sources/CodeBurnMenubar/Views/AgentTabStrip.swift @@ -494,6 +494,7 @@ extension ProviderFilter { case .kiloCode: return Color(red: 0x00/255.0, green: 0x96/255.0, blue: 0x88/255.0) case .kiro: return Color(red: 0x4A/255.0, green: 0x9E/255.0, blue: 0xC4/255.0) case .kimi: return Color(red: 0xA4/255.0, green: 0xC6/255.0, blue: 0x39/255.0) + case .kimiCode: return Color(red: 0xA3/255.0, green: 0xE6/255.0, blue: 0x35/255.0) case .lingtaiTui: return Color(red: 0x22/255.0, green: 0xA7/255.0, blue: 0xA0/255.0) case .openclaw: return Color(red: 0xDA/255.0, green: 0x70/255.0, blue: 0x56/255.0) case .opencode: return Color(red: 0x5B/255.0, green: 0x83/255.0, blue: 0x5B/255.0) diff --git a/src/dashboard.tsx b/src/dashboard.tsx index 1d9b6d2..1b68a22 100644 --- a/src/dashboard.tsx +++ b/src/dashboard.tsx @@ -83,6 +83,7 @@ const PROVIDER_COLORS: Record = { opencode: '#A78BFA', pi: '#F472B6', kimi: '#B6E34A', + kimicode: '#A3E635', all: '#FF8C42', } @@ -670,6 +671,7 @@ const PROVIDER_DISPLAY_NAMES: Record = { opencode: 'OpenCode', pi: 'Pi', kimi: 'Kimi', + kimicode: 'Kimi Code', } function getProviderDisplayName(name: string): string { return PROVIDER_DISPLAY_NAMES[name] ?? name } diff --git a/src/menubar-json.ts b/src/menubar-json.ts index 2066572..7435426 100644 --- a/src/menubar-json.ts +++ b/src/menubar-json.ts @@ -84,6 +84,7 @@ export type ProviderCost = { import type { OptimizeResult } from './optimize.js' import { getCurrency } from './currency.js' import type { GranularHistory } from './granular-history.js' +import { getShortModelName } from './models.js' import type { ReworkedFile } from './workflow-insights.js' import type { PrRow, BranchRow } from './sessions-report.js' @@ -369,10 +370,24 @@ function buildTopActivities(categories: PeriodData['categories']): MenubarPayloa } function buildTopModels(models: PeriodData['models']): MenubarPayload['current']['topModels'] { - return models - .filter(m => m.name !== SYNTHETIC_MODEL_NAME) + // Day entries key models by the raw provider id (day-aggregator), so resolve + // display names here — the menubar shows "Kimi K3" rather than "k3". Ids that + // collapse to one display name (e.g. k3 and kimi-k3) merge into a single row. + const merged = new Map() + for (const m of models) { + if (m.name === SYNTHETIC_MODEL_NAME) continue + const name = getShortModelName(m.name) + const acc = merged.get(name) ?? { cost: 0, calls: 0, savingsUSD: 0, estimatedCostUSD: 0 } + acc.cost += m.cost + acc.calls += m.calls + acc.savingsUSD += m.savingsUSD ?? 0 + acc.estimatedCostUSD += m.estimatedCostUSD ?? 0 + merged.set(name, acc) + } + return [...merged.entries()] + .sort(([, a], [, b]) => b.cost - a.cost) .slice(0, TOP_MODELS_LIMIT) - .map(m => ({ name: m.name, cost: m.cost, calls: m.calls, savingsUSD: m.savingsUSD, savingsBaselineModel: '', estimatedCostUSD: m.estimatedCostUSD ?? 0 })) + .map(([name, d]) => ({ name, cost: d.cost, calls: d.calls, savingsUSD: d.savingsUSD, savingsBaselineModel: '', estimatedCostUSD: d.estimatedCostUSD })) } function buildOptimize(optimize: OptimizeResult | null): MenubarPayload['optimize'] { diff --git a/src/models.ts b/src/models.ts index 20d3091..6e05785 100644 --- a/src/models.ts +++ b/src/models.ts @@ -290,6 +290,12 @@ const BUILTIN_ALIASES: Record = { 'kimi-auto': 'kimi-k2-thinking', 'kimi-code': 'kimi-k2-thinking', 'kimi-for-coding': 'kimi-k2-thinking', + // Kimi Code wires report the bare `k3` id in llm.request.model; without an + // alias those calls priced at $0 and the provider looked absent in the UI. + 'k3': 'kimi-k3', + // Kimi desktop/IDE embedded runtime serves `k3-agent` / `k2d6-agent`. + 'k3-agent': 'kimi-k3', + 'k2d6-agent': 'kimi-k2p6', 'mimo-v2-flash': 'xiaomi/mimo-v2-flash', 'kat-coder-pro-v1': 'kwaipilot/kat-coder-pro', // Cursor emits dot-version tier-last names plus tier/reasoning suffixes @@ -879,6 +885,8 @@ const SHORT_NAMES: Record = { 'gemini-2.5-flash': 'Gemini 2.5 Flash', 'kimi-k2-thinking-turbo': 'Kimi K2 Thinking Turbo', 'kimi-k2-thinking': 'Kimi K2 Thinking', + 'kimi-k3': 'Kimi K3', + 'kimi-k2p6': 'Kimi K2.6', 'kimi-thinking-preview': 'Kimi Thinking', 'kimi-k2.6': 'Kimi K2.6', 'kimi-k2.5': 'Kimi K2.5', diff --git a/src/providers/kimicode.ts b/src/providers/kimicode.ts index e843ae1..b85845a 100644 --- a/src/providers/kimicode.ts +++ b/src/providers/kimicode.ts @@ -71,8 +71,18 @@ function timestampIso(value: unknown): string { return Number.isNaN(date.getTime()) ? '' : date.toISOString() } -function kimicodeHome(override?: string): string { - return resolve(override || process.env['KIMI_CODE_HOME'] || join(homedir(), '.kimi-code')) +function kimicodeHomes(override?: string): string[] { + const explicit = override || process.env['KIMI_CODE_HOME'] + if (explicit) return [resolve(explicit)] + // Default stores. Beyond the CLI's own ~/.kimi-code, embedded runtimes keep + // the same wire layout under their own home (Kimi desktop app, Kimi Code + // IDE); each home is scanned so embedded-agent usage is not invisible. + const home = homedir() + const homes = [ + join(home, '.kimi-code'), + join(home, 'Library', 'Application Support', 'kimi-desktop', 'daimon-share', 'daimon', 'runtime', 'kimi-code', 'home'), + ] + return [...new Set(homes.map(h => resolve(h)))] } async function directoryEntries(path: string) { @@ -120,7 +130,10 @@ async function discoverSources(root: string): Promise { const workDirPath = join(sessionsDir, workDirEntry.name) for (const sessionEntry of await directoryEntries(workDirPath)) { - if (!sessionEntry.isDirectory() || !sessionEntry.name.startsWith('session_')) continue + // Session dir naming differs by host product: the CLI uses session_*, + // embedded runtimes (desktop app, IDE) use conv-*/ctitle-*. Any directory + // is accepted; the agents/*/wire.jsonl probe below gates real sessions. + if (!sessionEntry.isDirectory()) continue const sessionDir = join(workDirPath, sessionEntry.name) const state = await readState(sessionDir) const project = projectFromWorkDir(state.workDir ?? '', workDirEntry.name) @@ -344,11 +357,15 @@ export function createKimicodeProvider(homeOverride?: string): Provider { }, async probeRoots(): Promise { - return [{ path: kimicodeHome(homeOverride), label: 'Kimi Code home' }] + return kimicodeHomes(homeOverride).map(path => ({ path, label: 'Kimi Code home' })) }, async discoverSessions(): Promise { - return discoverSources(kimicodeHome(homeOverride)) + const all: SessionSource[] = [] + for (const home of kimicodeHomes(homeOverride)) { + all.push(...await discoverSources(home)) + } + return all.sort((a, b) => a.path.localeCompare(b.path)) }, createSessionParser(source: SessionSource, seenKeys: Set): SessionParser { diff --git a/tests/menubar-json.test.ts b/tests/menubar-json.test.ts index fe9b55a..cd44741 100644 --- a/tests/menubar-json.test.ts +++ b/tests/menubar-json.test.ts @@ -159,6 +159,31 @@ describe('buildMenubarPayload', () => { expect(payload.current.topModels[0].name).toBe('Model0') }) + it('resolves raw model ids to display names in topModels and merges rows that collapse', () => { + // Day entries key models by the raw wire id (day-aggregator); the menubar + // must show friendly names and merge ids that share one (k3 + kimi-k3). + const period: PeriodData = { + label: 'Today', + cost: 0, calls: 0, sessions: 0, + inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0, + categories: [], + models: [ + { name: 'k3', cost: 2.5, calls: 78 }, + { name: 'kimi-k3', cost: 0.5, calls: 2 }, + { name: 'kimi-for-coding', cost: 0.06, calls: 13 }, + { name: 'k3-agent', cost: 1.2, calls: 40 }, + ], + } + const payload = buildMenubarPayload(period, [], null) + const kimiK3 = payload.current.topModels.find(m => m.name === 'Kimi K3')! + expect(kimiK3.cost).toBeCloseTo(2.5 + 0.5 + 1.2) + expect(kimiK3.calls).toBe(78 + 2 + 40) + const k2 = payload.current.topModels.find(m => m.name === 'Kimi K2 Thinking')! + expect(k2.cost).toBeCloseTo(0.06) + expect(payload.current.topModels.find(m => m.name === 'k3')).toBeUndefined() + expect(payload.current.topModels.find(m => m.name === 'k3-agent')).toBeUndefined() + }) + it('caps topActivities at 20 so all task categories can surface', () => { const period: PeriodData = { label: 'Today', diff --git a/tests/menubar-savings.test.ts b/tests/menubar-savings.test.ts index ba416ab..bb60897 100644 --- a/tests/menubar-savings.test.ts +++ b/tests/menubar-savings.test.ts @@ -53,7 +53,7 @@ describe('buildMenubarPayload: local-model savings', () => { const local = payload.current.topModels.find(m => m.name === 'Local Model')! expect(local.savingsUSD).toBe(5) expect(local.cost).toBe(0) - const paid = payload.current.topModels.find(m => m.name === 'gpt-4o')! + const paid = payload.current.topModels.find(m => m.name === 'GPT-4o')! expect(paid.savingsUSD).toBe(0) }) diff --git a/tests/models.test.ts b/tests/models.test.ts index 160c5f8..e0d3cfd 100644 --- a/tests/models.test.ts +++ b/tests/models.test.ts @@ -672,6 +672,9 @@ describe('observed provider model aliases', () => { const cases: Array<[string, string]> = [ ['MiMo-V2-Flash', 'xiaomi/mimo-v2-flash'], ['KAT-Coder-Pro-V1', 'kwaipilot/kat-coder-pro'], + // Kimi Code wires report bare `k3` in llm.request.model; it must price + // through the kimi-k3 table entry, not fall through to $0. + ['k3', 'kimi-k3'], ] for (const [input, expectedModel] of cases) { @@ -685,6 +688,10 @@ describe('observed provider model aliases', () => { }) } + it('k3 shows the Kimi K3 display name', () => { + expect(getShortModelName('k3')).toBe('Kimi K3') + }) + it('does not map dated Qwen3 Max to a reseller price without provider context', () => { expect(getModelCosts('qwen3-max-2026-01-23')).toBeNull() expect(calculateCost('qwen3-max-2026-01-23', 1_000_000, 1_000_000, 0, 0, 0)).toBe(0) diff --git a/tests/providers/kimicode.test.ts b/tests/providers/kimicode.test.ts index 6bcc089..2ff426a 100644 --- a/tests/providers/kimicode.test.ts +++ b/tests/providers/kimicode.test.ts @@ -167,6 +167,40 @@ describe('Kimi Code provider', () => { expect(sources.every(source => source.sourcePath === '/workspace/neutral-project')).toBe(true) }) + it('discovers embedded-runtime conv-* and ctitle-* session directories', async () => { + // Embedded runtimes (Kimi desktop app, Kimi Code IDE) name session dirs + // conv-*/ctitle-* instead of session_*; their wires carry the same event + // format and must be discovered and priced the same way. + for (const dirName of ['conv-abc123def456', 'ctitle-019f8f78-db81']) { + const agentDir = join(fixtureHome, 'sessions', 'wd_neutral-project_0123456789ab', dirName, 'agents', 'main') + await mkdir(agentDir, { recursive: true }) + await writeFile(join(agentDir, '..', 'state.json'), JSON.stringify({ + createdAt: '2026-07-01T10:00:00.000Z', + updatedAt: '2026-07-01T10:05:00.000Z', + workDir: '/workspace/neutral-project', + })) + await writeFile(join(agentDir, 'wire.jsonl'), [ + JSON.stringify({ type: 'metadata', protocol_version: '1.4', created_at: 1782900000000 }), + JSON.stringify(prompt('hello from an embedded runtime', 1782900000000)), + JSON.stringify(request('0.1', 'k3-agent', 'k3-agent', 1782900001000)), + JSON.stringify(usage('k3-agent', 1782900002000, { input: 100, output: 50 })), + '', + ].join('\n')) + } + + const provider = createKimicodeProvider(fixtureHome) + const sources = await provider.discoverSessions() + expect(sources).toHaveLength(2) + expect(sources.every(source => source.project === 'neutral-project')).toBe(true) + + const seen = new Set() + const calls = (await Promise.all(sources.map(source => collect(provider, source, seen)))).flat() + expect(calls).toHaveLength(2) + expect(calls.every(call => call.model === 'k3-agent')).toBe(true) + // k3-agent prices through the kimi-k3 alias, never $0. + expect(calls.every(call => call.costUSD > 0)).toBe(true) + }) + it('parses single-turn usage with the real model id and estimated token pricing', async () => { const [wirePath] = await writeSession('single-turn', [{ id: 'main', lines: [ prompt('Summarize the neutral module.', 1782900000000), From 576f47d8540825bd8e82ee4dbbea3a9789058592 Mon Sep 17 00:00:00 2001 From: reviewer Date: Thu, 23 Jul 2026 21:31:55 +0200 Subject: [PATCH 06/38] menubar: add Kimi Code subscription quota tracking Read ~/.kimi-code credentials directly and poll api.kimi.com /coding/v1/usages for the weekly quota and rate-limit windows, mirroring the existing Codex/Claude quota services. - New KimiSubscriptionService with lenient decoding (string/number values, enum-style time units, derived used from remaining) and 429 backoff - Quota chip on the Kimi Code tab, Plan insight, and a Kimi tab in Settings with connect/disconnect - Independent refresh cadence anchor so Kimi-only setups don't poll on every payload tick - Expired tokens surface as terminal with automatic recovery once the CLI refreshes the credential file - Keep insights visible for quota-capable providers on empty days - Widen Settings so six tabs don't collapse into the overflow menu --- mac/Sources/CodeBurnMenubar/AppStore.swift | 133 +++++++ mac/Sources/CodeBurnMenubar/CodeBurnApp.swift | 22 +- .../Data/KimiSubscriptionService.swift | 372 ++++++++++++++++++ .../Views/HeatmapSection.swift | 108 ++++- .../Views/MenuBarContent.swift | 4 + .../CodeBurnMenubar/Views/SettingsView.swift | 133 ++++++- .../KimiUsageParsingTests.swift | 114 ++++++ 7 files changed, 882 insertions(+), 4 deletions(-) create mode 100644 mac/Sources/CodeBurnMenubar/Data/KimiSubscriptionService.swift create mode 100644 mac/Tests/CodeBurnMenubarTests/KimiUsageParsingTests.swift diff --git a/mac/Sources/CodeBurnMenubar/AppStore.swift b/mac/Sources/CodeBurnMenubar/AppStore.swift index fb3edbb..36171f0 100644 --- a/mac/Sources/CodeBurnMenubar/AppStore.swift +++ b/mac/Sources/CodeBurnMenubar/AppStore.swift @@ -138,11 +138,19 @@ final class AppStore { var codexError: String? var codexLoadState: SubscriptionLoadState = CodexCredentialStore.isBootstrapCompleted ? .dormant : .notBootstrapped + var kimiUsage: KimiUsage? + var kimiError: String? + // No keychain dance for Kimi — "connected" just means the CLI's + // credential file exists, so we start dormant and auto-activate on the + // first refresh tick. + var kimiLoadState: SubscriptionLoadState = KimiSubscriptionService.hasCredential ? .dormant : .notBootstrapped + /// Generation tokens for the in-flight refresh tasks. Incremented on every /// disconnect / reset so a fetch that started before the disconnect cannot /// resume after the await and re-populate the freshly-cleared state. private var claudeRefreshGen: Int = 0 private var codexRefreshGen: Int = 0 + private var kimiRefreshGen: Int = 0 private var cache: [PayloadCacheKey: CachedPayload] = [:] private var cacheDate: String = "" @@ -1104,6 +1112,90 @@ final class AppStore { } } + // MARK: - Kimi Code + + /// Unlike Claude/Codex there is no keychain bootstrap: reading the CLI's + /// credential file is prompt-free, so the first refresh tick activates + /// the dormant state automatically. + func bootstrapKimi() async { + // Capture the generation before the await so a disconnect that lands + // mid-fetch cannot be resurrected into .loaded when the fetch returns. + let gen = kimiRefreshGen + kimiLoadState = .bootstrapping + do { + let usage = try await KimiSubscriptionService.refresh() + guard gen == kimiRefreshGen else { return } + kimiUsage = usage + kimiError = nil + kimiLoadState = .loaded + } catch let err as KimiSubscriptionService.FetchError { + guard gen == kimiRefreshGen else { return } + applyKimiFetchError(err) + } catch { + guard gen == kimiRefreshGen else { return } + kimiError = sanitizeForUI(String(describing: error)) + kimiLoadState = .failed + } + } + + func refreshKimi() async { + _ = await refreshKimiReportingSuccess() + } + + @discardableResult + func refreshKimiReportingSuccess() async -> Bool { + if case .dormant = kimiLoadState { + await bootstrapKimi() + return kimiLoadState == .loaded + } + guard KimiSubscriptionService.hasCredential else { + if kimiLoadState != .notBootstrapped { kimiLoadState = .notBootstrapped } + return false + } + let gen = kimiRefreshGen + if kimiUsage == nil { kimiLoadState = .loading } + do { + let usage = try await KimiSubscriptionService.refresh() + guard gen == kimiRefreshGen else { return false } + kimiUsage = usage + kimiError = nil + kimiLoadState = .loaded + return true + } catch let err as KimiSubscriptionService.FetchError { + guard gen == kimiRefreshGen else { return false } + applyKimiFetchError(err) + return false + } catch { + guard gen == kimiRefreshGen else { return false } + kimiError = sanitizeForUI(String(describing: error)) + kimiLoadState = .failed + return false + } + } + + func disconnectKimi() { + KimiSubscriptionService.disconnect() + kimiRefreshGen &+= 1 + kimiUsage = nil + kimiError = nil + kimiLoadState = .notBootstrapped + NotificationCenter.default.post(name: .codeBurnSubscriptionDisconnected, object: nil) + } + + private func applyKimiFetchError(_ err: KimiSubscriptionService.FetchError) { + let sanitized = sanitizeForUI(err.errorDescription) + kimiError = sanitized + if case .noCredentials = err { + kimiLoadState = .noCredentials + } else if err.isTerminal { + kimiLoadState = .terminalFailure(reason: sanitized) + } else if let retryAt = err.rateLimitRetryAt { + kimiLoadState = .transientFailure(retryAt: retryAt) + } else { + kimiLoadState = .failed + } + } + private func applyFetchError(_ err: ClaudeSubscriptionService.FetchError) { let sanitized = sanitizeForUI(err.errorDescription) subscriptionError = sanitized @@ -1172,6 +1264,10 @@ final class AppStore { let worst = max(usage.primary?.usedPercent ?? 0, usage.secondary?.usedPercent ?? 0) if worst > 0 { providers.append(("Codex", worst)) } } + if let usage = kimiUsage, shouldIncludeCachedQuota(loadState: kimiLoadState) { + let worst = max(usage.primary?.usedPercent ?? 0, usage.details.map(\.usedPercent).max() ?? 0) + if worst > 0 { providers.append(("Kimi Code", worst)) } + } let worst = providers.map(\.percent).max() ?? 0 let severity = QuotaSummary.severity(for: worst / 100) let sorted = providers.sorted { $0.percent > $1.percent } @@ -1192,6 +1288,7 @@ final class AppStore { switch filter { case .claude: return claudeQuotaSummary(filter: filter) case .codex: return codexQuotaSummary(filter: filter) + case .kimiCode: return kimiQuotaSummary(filter: filter) default: return nil } } @@ -1297,6 +1394,39 @@ final class AppStore { return QuotaSummary(providerFilter: filter, connection: connection, primary: primary, details: details, planLabel: plan, footerLines: footerLines) } + private func kimiQuotaSummary(filter: ProviderFilter) -> QuotaSummary? { + if case .notBootstrapped = kimiLoadState { return nil } + if case .bootstrapping = kimiLoadState { return nil } + if case .noCredentials = kimiLoadState { return nil } + + let connection: QuotaSummary.Connection = { + switch kimiLoadState { + case .notBootstrapped, .dormant, .bootstrapping, .noCredentials: return .disconnected + case .loading: return kimiUsage == nil ? .loading : .stale + case .loaded: return .connected + case .failed: return kimiUsage == nil ? .loading : .stale + case let .terminalFailure(reason): return .terminalFailure(reason: reason) + case .transientFailure: return .transientFailure + } + }() + + var primary: QuotaSummary.Window? + var details: [QuotaSummary.Window] = [] + if let usage = kimiUsage { + if let w = usage.primary { + let row = QuotaSummary.Window(label: w.label, percent: w.usedPercent / 100, resetsAt: w.resetsAt) + primary = row + details.append(row) + } + for w in usage.details { + let row = QuotaSummary.Window(label: w.label, percent: w.usedPercent / 100, resetsAt: w.resetsAt) + if primary == nil { primary = row } + details.append(row) + } + } + return QuotaSummary(providerFilter: filter, connection: connection, primary: primary, details: details, planLabel: kimiUsage?.plan ?? "Kimi Code", footerLines: []) + } + /// Persist one snapshot per window so we can answer "what did the prior cycle end at?" /// when the current window has just reset and projection from current data isn't meaningful. /// Also computes the effective_tokens consumed inside each 7-day window from local history, @@ -1400,6 +1530,7 @@ enum ProviderFilter: String, CaseIterable, Identifiable { case ibmBob = "IBM Bob" case kiro = "Kiro" case kimi = "Kimi" + case kimiCode = "Kimi Code" case lingtaiTui = "LingTai TUI" case kiloCode = "KiloCode" case openclaw = "OpenClaw" @@ -1432,6 +1563,7 @@ enum ProviderFilter: String, CaseIterable, Identifiable { case .grok: ["grok", "grok build"] case .hermes: ["hermes", "hermes agent"] case .lingtaiTui: ["lingtai-tui", "lingtai tui"] + case .kimiCode: ["kimicode", "kimi code"] default: [rawValue.lowercased()] } } @@ -1453,6 +1585,7 @@ enum ProviderFilter: String, CaseIterable, Identifiable { case .kiloCode: "kilo-code" case .kiro: "kiro" case .kimi: "kimi" + case .kimiCode: "kimicode" case .lingtaiTui: "lingtai-tui" case .openclaw: "openclaw" case .opencode: "opencode" diff --git a/mac/Sources/CodeBurnMenubar/CodeBurnApp.swift b/mac/Sources/CodeBurnMenubar/CodeBurnApp.swift index fefdffa..489af5f 100644 --- a/mac/Sources/CodeBurnMenubar/CodeBurnApp.swift +++ b/mac/Sources/CodeBurnMenubar/CodeBurnApp.swift @@ -519,6 +519,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate { fileprivate var lastSubscriptionRefreshAt: Date? fileprivate var lastCodexRefreshAt: Date? + fileprivate var lastKimiRefreshAt: Date? private var claudeQuotaFailureCount = 0 private var nextClaudeQuotaRefreshAt: Date? @@ -614,8 +615,24 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate { if let task = codexQuotaRefreshTask { return await task.value } - let task = Task { [store] in - await store.refreshCodexReportingSuccess() + // Kimi Code rides the same tick, but with its own cadence anchor: + // when Codex is not connected its refresh returns false immediately, + // so lastCodexRefreshAt never advances — anchoring Kimi on it would + // poll api.kimi.com on every payload tick instead of the configured + // quota cadence. Anchor on attempt (not success) so a failing Kimi + // endpoint also respects the cadence. + let kimiDue: Bool = { + let cadence = SubscriptionRefreshCadence.current + guard cadence != .manual else { return false } + return Date().timeIntervalSince(lastKimiRefreshAt ?? .distantPast) >= TimeInterval(cadence.rawValue) + }() + if kimiDue { lastKimiRefreshAt = Date() } + let task = Task { [store, kimiDue] in + async let codex = store.refreshCodexReportingSuccess() + if kimiDue { + _ = await store.refreshKimiReportingSuccess() + } + return await codex } codexQuotaRefreshTask = task let result = await task.value @@ -833,6 +850,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate { func resetSubscriptionCadenceAnchor() { lastSubscriptionRefreshAt = nil lastCodexRefreshAt = nil + lastKimiRefreshAt = nil claudeQuotaFailureCount = 0 nextClaudeQuotaRefreshAt = nil } diff --git a/mac/Sources/CodeBurnMenubar/Data/KimiSubscriptionService.swift b/mac/Sources/CodeBurnMenubar/Data/KimiSubscriptionService.swift new file mode 100644 index 0000000..1925c33 --- /dev/null +++ b/mac/Sources/CodeBurnMenubar/Data/KimiSubscriptionService.swift @@ -0,0 +1,372 @@ +import Foundation + +/// Live quota snapshot for a Kimi Code subscription, returned by +/// GET https://api.kimi.com/coding/v1/usages. Shape mirrors what +/// steipete/CodexBar consumes: a top-level `usage` envelope plus a +/// `limits` array of additional rate-limit windows. Numeric fields are +/// decoded leniently (String or Int/Double) because the API has shipped +/// both. +struct KimiUsage: Sendable, Equatable { + struct Window: Sendable, Equatable { + let label: String + let limit: Double + let used: Double + let remaining: Double? + let resetsAt: Date? + + var usedPercent: Double { // 0.0 ... 100.0 + guard limit > 0 else { return 0 } + return min(100, max(0, used / limit * 100)) + } + } + + /// The top-level `usage` envelope — treated as the primary window. + let primary: Window? + /// Additional windows from the `limits` array (e.g. 5-hour rate limit). + let details: [Window] + /// Membership tier from user.membership.level (e.g. "Intermediate"). + let plan: String? + /// Max parallel sessions from parallel.limit, when reported. + let parallelLimit: Int? + let fetchedAt: Date +} + +/// Mirror of CodexSubscriptionService for Kimi Code. Reads the CLI's +/// credential file directly (~/.kimi-code/credentials/kimi-code.json) — +/// no keychain bootstrap, no OAuth refresh. Tokens are short-lived +/// (~15 min) and only the Kimi CLI refreshes them, so an expired token is +/// a terminal state: the UI tells the user to run the CLI once. +enum KimiSubscriptionService { + private static let usageURL = URL(string: "https://api.kimi.com/coding/v1/usages")! + private static let usageBlockedUntilKey = "codeburn.kimi.usage.blockedUntil" + + enum FetchError: Error, LocalizedError { + case noCredentials + case tokenExpired + case rateLimited(retryAt: Date) + case usageHTTPError(Int, String?) + case usageDecodeFailed + case network(Error) + + var errorDescription: String? { + switch self { + case .noCredentials: + return "No Kimi Code credentials found. Sign in with the Kimi CLI first." + case .tokenExpired: + return "Kimi Code login expired. Run the Kimi CLI once to refresh, then try again." + case let .rateLimited(retryAt): + let f = RelativeDateTimeFormatter() + f.unitsStyle = .short + return "Kimi rate-limited the quota endpoint. Retrying \(f.localizedString(for: retryAt, relativeTo: Date()))." + case let .usageHTTPError(code, body): + return "Kimi quota fetch failed (HTTP \(code))\(body.map { ": \($0)" } ?? "")" + case .usageDecodeFailed: return "Kimi quota response was malformed." + case let .network(err): return "Network error: \(err.localizedDescription)" + } + } + + var isTerminal: Bool { + if case .tokenExpired = self { return true } + if case .noCredentials = self { return true } + return false + } + + var rateLimitRetryAt: Date? { + if case let .rateLimited(retryAt) = self { return retryAt } + return nil + } + } + + // MARK: - Credential file + + private struct CredentialFile: Decodable { + let accessToken: String + let expiresAt: Double + + enum CodingKeys: String, CodingKey { + case accessToken = "access_token" + case expiresAt = "expires_at" + } + + init(from decoder: Decoder) throws { + let c = try decoder.container(keyedBy: CodingKeys.self) + accessToken = try c.decode(String.self, forKey: .accessToken) + // Tolerate Int / Double / String epoch-seconds. + if let n = try? c.decode(Double.self, forKey: .expiresAt) { + expiresAt = n + } else if let s = try? c.decode(String.self, forKey: .expiresAt), let n = Double(s) { + expiresAt = n + } else { + throw DecodingError.dataCorruptedError(forKey: .expiresAt, in: c, debugDescription: "expires_at missing or not numeric") + } + } + } + + private static var credentialsURL: URL { + let home = ProcessInfo.processInfo.environment["KIMI_CODE_HOME"] + ?? NSHomeDirectory() + "/.kimi-code" + return URL(fileURLWithPath: home + "/credentials/kimi-code.json") + } + + static var hasCredential: Bool { + FileManager.default.fileExists(atPath: credentialsURL.path) + } + + /// Returns the access token only when it is still fresh (60s skew). + /// Throws noCredentials / tokenExpired otherwise. + private static func freshToken() throws -> String { + guard let data = FileManager.default.contents(atPath: credentialsURL.path), + let cred = try? JSONDecoder().decode(CredentialFile.self, from: data), + !cred.accessToken.isEmpty else { + throw FetchError.noCredentials + } + guard cred.expiresAt > Date().timeIntervalSince1970 + 60 else { + throw FetchError.tokenExpired + } + return cred.accessToken + } + + private static func deviceId() -> String? { + let home = ProcessInfo.processInfo.environment["KIMI_CODE_HOME"] + ?? NSHomeDirectory() + "/.kimi-code" + guard let data = FileManager.default.contents(atPath: home + "/device_id"), + let id = String(data: data, encoding: .utf8)?.trimmingCharacters(in: .whitespacesAndNewlines), + !id.isEmpty else { return nil } + return id + } + + // MARK: - Fetch + + static func refresh() async throws -> KimiUsage { + if let until = usageBlockedUntil(), until > Date() { + throw FetchError.rateLimited(retryAt: until) + } + let token = try freshToken() + + var request = URLRequest(url: usageURL) + request.httpMethod = "GET" + request.timeoutInterval = 30 + request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") + request.setValue("application/json", forHTTPHeaderField: "Accept") + request.setValue("CodeBurn", forHTTPHeaderField: "User-Agent") + // Kimi server expects these platform headers (same as CodexBar sends). + request.setValue("kimi_code_cli", forHTTPHeaderField: "X-Msh-Platform") + if let deviceId = deviceId() { + request.setValue(deviceId, forHTTPHeaderField: "X-Msh-Device-Id") + } + + let data: Data + let response: URLResponse + do { + (data, response) = try await URLSession.shared.data(for: request) + } catch { + throw FetchError.network(error) + } + guard let http = response as? HTTPURLResponse else { + throw FetchError.usageHTTPError(-1, nil) + } + + switch http.statusCode { + case 200: + clearUsageBlock() + do { + return try parseUsage(data: data) + } catch { + // Never log the body — account data readable via `log stream`. + NSLog("CodeBurn: kimi usage decode failed: %@", String(describing: error)) + throw FetchError.usageDecodeFailed + } + case 401, 403: + // We don't self-refresh; surface as terminal so the UI prompts + // the user to run the CLI. + throw FetchError.tokenExpired + case 429: + let retryAfter = parseRetryAfterHeader(http.value(forHTTPHeaderField: "Retry-After")) + let until = recordUsageRateLimit(retryAfterSeconds: retryAfter) + throw FetchError.rateLimited(retryAt: until) + default: + throw FetchError.usageHTTPError(http.statusCode, String(data: data, encoding: .utf8)) + } + } + + // MARK: - Decode (internal so tests can drive fixtures) + + private struct LenientDouble: Decodable { + let value: Double? + init(from decoder: Decoder) throws { + let c = try decoder.singleValueContainer() + if let n = try? c.decode(Double.self) { value = n } + else if let s = try? c.decode(String.self) { value = Double(s) } + else { value = nil } + } + } + + private struct UsageEnvelopeDTO: Decodable { + let limit: LenientDouble? + let used: LenientDouble? + let remaining: LenientDouble? + let resetTime: LenientString? + let resetAt: LenientString? + let reset_time: LenientString? + let reset_at: LenientString? + + var resetsAtRaw: String? { + resetTime?.value ?? resetAt?.value ?? reset_time?.value ?? reset_at?.value + } + } + + /// Reset timestamps are normally ISO-8601 strings, but decode numbers + /// tolerantly too (epoch seconds) so a schema drift can't fail the whole + /// response. A plain String? would throw on a JSON number. + private struct LenientString: Decodable { + let value: String? + init(from decoder: Decoder) throws { + let c = try decoder.singleValueContainer() + if let s = try? c.decode(String.self) { value = s } + else if let n = try? c.decode(Double.self) { value = String(n) } + else { value = nil } + } + } + + private struct LimitDTO: Decodable { + let window: WindowDTO? + let detail: UsageEnvelopeDTO? + + struct WindowDTO: Decodable { + let duration: LenientDouble? + let timeUnit: String? + } + } + + private struct ResponseDTO: Decodable { + let usage: UsageEnvelopeDTO? + let limits: [LimitDTO]? + let user: UserDTO? + let parallel: ParallelDTO? + + struct UserDTO: Decodable { + let membership: MembershipDTO? + struct MembershipDTO: Decodable { + let level: String? + } + } + struct ParallelDTO: Decodable { + let limit: LenientDouble? + } + } + + static func parseUsage(data: Data, now: Date = Date()) throws -> KimiUsage { + let root = try JSONDecoder().decode(ResponseDTO.self, from: data) + // The top-level usage envelope is the account's weekly quota (its + // reset lands ~7 days out), so label it like Claude's weekly window. + let primary = makeWindow(label: "Weekly", dto: root.usage) + let details: [KimiUsage.Window] = (root.limits ?? []).compactMap { limit in + guard let dto = limit.detail else { return nil } + let label = windowLabel(duration: limit.window?.duration?.value, timeUnit: limit.window?.timeUnit) + return makeWindow(label: label, dto: dto) + } + guard primary != nil || !details.isEmpty else { + throw FetchError.usageDecodeFailed + } + return KimiUsage( + primary: primary, + details: details, + plan: planName(from: root.user?.membership?.level), + parallelLimit: root.parallel?.limit?.value.map { Int($0) }, + fetchedAt: now + ) + } + + /// "LEVEL_INTERMEDIATE" → "Intermediate". Unknown / missing → nil so the + /// UI falls back to the plain "Kimi Code" label. + private static func planName(from level: String?) -> String? { + guard var raw = level, !raw.isEmpty else { return nil } + if raw.hasPrefix("LEVEL_") { raw = String(raw.dropFirst("LEVEL_".count)) } + return raw.replacingOccurrences(of: "_", with: " ").capitalized + } + + private static func makeWindow(label: String, dto: UsageEnvelopeDTO?) -> KimiUsage.Window? { + guard let dto, let limit = dto.limit?.value, limit > 0 else { return nil } + // Rate-limit windows report only limit + remaining — derive used. + let used = dto.used?.value ?? max(0, limit - (dto.remaining?.value ?? limit)) + return KimiUsage.Window( + label: label, + limit: limit, + used: used, + remaining: dto.remaining?.value, + resetsAt: dto.resetsAtRaw.flatMap(parseResetTime) + ) + } + + /// Window size → human label. The API sends enum-style units + /// ("TIME_UNIT_MINUTE", duration 300) as well as plain ones ("hour"), + /// so normalize first; sub-hour durations roll up to hours when exact. + private static func windowLabel(duration: Double?, timeUnit: String?) -> String { + guard let duration, let rawUnit = timeUnit else { return "Rate Limit" } + var unit = rawUnit.lowercased() + if unit.hasPrefix("time_unit_") { unit = String(unit.dropFirst("time_unit_".count)) } + var d = duration + // Roll up exact sub-day durations: 300 minutes → 5 hours. + if unit == "minute" || unit == "minutes", d.truncatingRemainder(dividingBy: 60) == 0, d >= 60 { + d /= 60; unit = "hour" + } + let i = Int(d) + switch unit { + case "minute", "minutes": return i == 1 ? "Minutely" : "\(i)-min" + case "hour", "hours": return i == 1 ? "Hourly" : "\(i)-hour" + case "day", "days": + if i == 1 { return "Daily" } + if i == 7 { return "Weekly" } + return "\(i)-day" + case "week", "weeks": return i == 1 ? "Weekly" : "\(i)-week" + case "month", "months": return i == 1 ? "Monthly" : "\(i)-month" + default: return "\(i) \(unit)" + } + } + + /// resetTime arrives as ISO-8601 (fractional seconds optional) or epoch seconds. + private static func parseResetTime(_ raw: String) -> Date? { + let fractional = ISO8601DateFormatter() + fractional.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + if let date = fractional.date(from: raw) { return date } + let plain = ISO8601DateFormatter() + plain.formatOptions = [.withInternetDateTime] + if let date = plain.date(from: raw) { return date } + if let epoch = Double(raw) { return Date(timeIntervalSince1970: epoch) } + return nil + } + + // MARK: - 429 backoff + + private static func usageBlockedUntil() -> Date? { + UserDefaults.standard.object(forKey: usageBlockedUntilKey) as? Date + } + + private static func clearUsageBlock() { + UserDefaults.standard.removeObject(forKey: usageBlockedUntilKey) + } + + private static func parseRetryAfterHeader(_ value: String?) -> Int? { + guard let value = value?.trimmingCharacters(in: .whitespaces), !value.isEmpty else { return nil } + if let seconds = Int(value), seconds >= 0 { return seconds } + let f = DateFormatter() + f.locale = Locale(identifier: "en_US_POSIX") + f.timeZone = TimeZone(secondsFromGMT: 0) + f.dateFormat = "EEE, dd MMM yyyy HH:mm:ss zzz" + if let date = f.date(from: value) { + return max(0, Int(date.timeIntervalSinceNow)) + } + return nil + } + + private static func recordUsageRateLimit(retryAfterSeconds: Int?) -> Date { + let seconds = max(retryAfterSeconds ?? 300, 60) + let until = Date().addingTimeInterval(TimeInterval(seconds)) + UserDefaults.standard.set(until, forKey: usageBlockedUntilKey) + return until + } + + static func disconnect() { + clearUsageBlock() + } +} diff --git a/mac/Sources/CodeBurnMenubar/Views/HeatmapSection.swift b/mac/Sources/CodeBurnMenubar/Views/HeatmapSection.swift index fbd5871..8f02676 100644 --- a/mac/Sources/CodeBurnMenubar/Views/HeatmapSection.swift +++ b/mac/Sources/CodeBurnMenubar/Views/HeatmapSection.swift @@ -62,7 +62,7 @@ struct HeatmapSection: View { // their own quota data sources. InsightMode.allCases.filter { mode in if mode == .plan { - return store.selectedProvider == .claude || store.selectedProvider == .codex + return store.selectedProvider == .claude || store.selectedProvider == .codex || store.selectedProvider == .kimiCode } return true } @@ -80,6 +80,8 @@ struct HeatmapSection: View { case .plan: if store.selectedProvider == .codex { CodexPlanInsight() + } else if store.selectedProvider == .kimiCode { + KimiPlanInsight() } else { PlanInsight(usage: store.subscription) } @@ -2158,6 +2160,110 @@ private struct CodexPlanInsight: View { } } +/// Plan tab for Kimi Code. Reads the CLI credential file (no keychain, no +/// OAuth refresh — tokens are short-lived and only the CLI renews them), so +/// terminal failure means "run the CLI once to refresh your login". +private struct KimiPlanInsight: View { + @Environment(AppStore.self) private var store + + var body: some View { + Group { + switch store.kimiLoadState { + case .notBootstrapped, .noCredentials: + PlanNoCredentialsView( + title: "No Kimi Code credentials found", + message: "Sign in with the Kimi CLI first. Then click Try Again." + ) { Task { await store.bootstrapKimi() } } + case .dormant, .bootstrapping: + PlanLoadingView(message: "Reading Kimi Code credentials...") + case .loading: + if let usage = store.kimiUsage { + loadedBody(usage: usage) + } else { + PlanLoadingView(message: "Reading Kimi Code credentials...") + } + case .failed: + PlanFailedView( + error: store.kimiError + ) { Task { await store.refreshKimi() } } + case .transientFailure: + if let usage = store.kimiUsage { + loadedBody(usage: usage) + } else { + PlanFailedView( + error: store.kimiError ?? "Kimi temporarily unreachable — retrying." + ) { Task { await store.refreshKimi() } } + } + case let .terminalFailure(reason): + PlanReconnectView( + title: "Refresh Kimi Code login", + reason: reason, + fallback: "Kimi Code tokens are short-lived. Run the Kimi CLI once to refresh your login, then click Reconnect." + ) { Task { await store.bootstrapKimi() } } + case .loaded: + if let usage = store.kimiUsage { + loadedBody(usage: usage) + } else { + PlanLoadingView(message: "Reading Kimi Code credentials...") + } + } + } + } + + @ViewBuilder + private func loadedBody(usage: KimiUsage) -> some View { + VStack(alignment: .leading, spacing: 10) { + HStack(alignment: .firstTextBaseline) { + Text(usage.plan ?? "Kimi Code") + .font(.system(size: 13, weight: .semibold)) + .foregroundStyle(.primary) + Spacer() + if let resetsAt = usage.primary?.resetsAt { + Text("Resets \(relativeReset(resetsAt))") + .font(.system(size: 10.5)) + .foregroundStyle(.secondary) + } + } + if let primary = usage.primary { + UtilizationRow( + label: "\(primary.label) window", + percent: primary.usedPercent, + resetsAt: primary.resetsAt, + projection: nil + ) + } + ForEach(Array(usage.details.enumerated()), id: \.offset) { _, window in + UtilizationRow( + label: "\(window.label) window", + percent: window.usedPercent, + resetsAt: window.resetsAt, + projection: nil + ) + } + if let parallel = usage.parallelLimit, parallel > 0 { + HStack(alignment: .firstTextBaseline) { + Text("Parallel sessions") + .font(.system(size: 11, weight: .medium)) + .foregroundStyle(.secondary) + Spacer() + Text("\(parallel)") + .font(.system(size: 10.5)) + .foregroundStyle(.secondary) + } + } + } + .padding(.horizontal, 14) + .padding(.top, 4) + .padding(.bottom, 8) + } + + private func relativeReset(_ date: Date) -> String { + let f = RelativeDateTimeFormatter() + f.unitsStyle = .short + return f.localizedString(for: date, relativeTo: Date()) + } +} + private struct WindowProjection { enum Source { case linear, historicalBaseline } let percent: Double diff --git a/mac/Sources/CodeBurnMenubar/Views/MenuBarContent.swift b/mac/Sources/CodeBurnMenubar/Views/MenuBarContent.swift index d92bd74..18b6a13 100644 --- a/mac/Sources/CodeBurnMenubar/Views/MenuBarContent.swift +++ b/mac/Sources/CodeBurnMenubar/Views/MenuBarContent.swift @@ -92,6 +92,10 @@ struct MenuBarContent: View { private var isFilteredEmpty: Bool { guard store.selectedProvider != .all else { return false } + // Plan-capable providers keep their sections visible so the Plan tab + // (live subscription quota) stays reachable even on days with no + // local usage — the quota endpoint doesn't depend on local sessions. + if store.selectedProvider == .claude || store.selectedProvider == .codex || store.selectedProvider == .kimiCode { return false } if store.payload.current.cost > 0 || store.payload.current.calls > 0 { return false } if providerHasCostInAllPayload { return false } return true diff --git a/mac/Sources/CodeBurnMenubar/Views/SettingsView.swift b/mac/Sources/CodeBurnMenubar/Views/SettingsView.swift index 285822a..f220ba5 100644 --- a/mac/Sources/CodeBurnMenubar/Views/SettingsView.swift +++ b/mac/Sources/CodeBurnMenubar/Views/SettingsView.swift @@ -21,6 +21,10 @@ struct SettingsView: View { .tabItem { Label("Codex", systemImage: "chevron.left.forwardslash.chevron.right") } .tag("codex") + KimiSettingsTab() + .tabItem { Label("Kimi", systemImage: "moon.stars") } + .tag("kimi") + DevinSettingsTab() .tabItem { Label("Devin", systemImage: "flame.fill") } .tag("devin") @@ -29,7 +33,9 @@ struct SettingsView: View { .tabItem { Label("About", systemImage: "info.circle") } .tag("about") } - .frame(width: 520, height: 430) + // 6 tabs need ~600pt to render as a visible tab bar; narrower widths + // make SwiftUI collapse the tab bar into a ">>" overflow menu. + .frame(width: 600, height: 430) } } @@ -606,6 +612,131 @@ private struct CodexConnectionRow: View { } } +// MARK: - Kimi Code + +private struct KimiSettingsTab: View { + var body: some View { + Form { + Section("Connection") { + KimiConnectionRow() + } + Section { + Text("Kimi Code live-quota tracking reads `~/.kimi-code/credentials/kimi-code.json` directly — nothing is copied or stored. Access tokens are short-lived (~15 minutes) and only the Kimi CLI refreshes them, so if the connection shows as expired, run the Kimi CLI once and click Reconnect.") + .font(.system(size: 11)) + .foregroundStyle(.secondary) + } header: { + Text("How it works") + } + } + .formStyle(.grouped) + .padding() + } +} + +private struct KimiConnectionRow: View { + @Environment(AppStore.self) private var store + @State private var showDisconnectConfirm = false + + var body: some View { + HStack(alignment: .top, spacing: 12) { + Image(systemName: stateIcon) + .font(.system(size: 18)) + .foregroundStyle(stateTint) + .frame(width: 22) + VStack(alignment: .leading, spacing: 2) { + Text(stateTitle) + .font(.system(size: 12, weight: .semibold)) + Text(stateDetail) + .font(.system(size: 11)) + .foregroundStyle(.secondary) + .lineLimit(2) + } + Spacer() + actionButton + } + .padding(.vertical, 4) + } + + private var stateIcon: String { + switch store.kimiLoadState { + case .loaded: return "checkmark.circle.fill" + case .terminalFailure: return "exclamationmark.triangle.fill" + case .transientFailure: return "clock.arrow.circlepath" + case .bootstrapping, .loading: return "ellipsis.circle" + case .notBootstrapped, .dormant, .noCredentials: return "link.circle" + case .failed: return "xmark.circle" + } + } + + private var stateTint: Color { + switch store.kimiLoadState { + case .loaded: return .green + case .terminalFailure, .failed: return .red + case .transientFailure: return .orange + default: return .secondary + } + } + + private var stateTitle: String { + switch store.kimiLoadState { + case .loaded: return "Connected" + case let .terminalFailure(reason): return reason ?? "Login refresh required" + case .transientFailure: return "Backing off" + case .bootstrapping: return "Connecting…" + case .loading: return "Refreshing…" + case .dormant: return "Ready" + case .notBootstrapped, .noCredentials: return "Not connected" + case .failed: return "Couldn't load Kimi quota" + } + } + + private var stateDetail: String { + switch store.kimiLoadState { + case .loaded: + return "Live quota tracked from api.kimi.com." + case .terminalFailure: + return "Run the Kimi CLI once to refresh your login, then click Reconnect." + case .transientFailure: return store.kimiError ?? "Kimi rate-limited; auto-retrying." + case .bootstrapping: return "Reading ~/.kimi-code credentials." + case .loading: return "Background refresh in progress." + case .dormant: return "Tap Load Quota to fetch live usage from api.kimi.com." + case .notBootstrapped, .noCredentials: + return "Sign in with the Kimi CLI first, then click Connect." + case .failed: return store.kimiError ?? "" + } + } + + @ViewBuilder + private var actionButton: some View { + switch store.kimiLoadState { + case .loaded, .transientFailure, .loading: + Button("Disconnect") { showDisconnectConfirm = true } + .confirmationDialog( + "Disconnect Kimi Code?", + isPresented: $showDisconnectConfirm + ) { + Button("Disconnect", role: .destructive) { + store.disconnectKimi() + } + Button("Cancel", role: .cancel) {} + } message: { + Text("CodeBurn will stop tracking Kimi Code quota. Your ~/.kimi-code credentials are untouched — the Kimi CLI keeps working.") + } + case .terminalFailure, .noCredentials, .failed: + Button("Reconnect") { Task { await store.bootstrapKimi() } } + .buttonStyle(.borderedProminent) + case .dormant: + Button("Load Quota") { Task { await store.bootstrapKimi() } } + .buttonStyle(.borderedProminent) + case .notBootstrapped: + Button("Connect") { Task { await store.bootstrapKimi() } } + .buttonStyle(.borderedProminent) + case .bootstrapping: + ProgressView().controlSize(.small) + } + } +} + // MARK: - Devin private struct DevinSettingsTab: View { diff --git a/mac/Tests/CodeBurnMenubarTests/KimiUsageParsingTests.swift b/mac/Tests/CodeBurnMenubarTests/KimiUsageParsingTests.swift new file mode 100644 index 0000000..2d78c43 --- /dev/null +++ b/mac/Tests/CodeBurnMenubarTests/KimiUsageParsingTests.swift @@ -0,0 +1,114 @@ +import XCTest +@testable import CodeBurnMenubar + +/// Fixture-driven decode tests for the Kimi Code /coding/v1/usages response. +/// The API has shipped numbers as both JSON numbers and strings, and the +/// reset timestamp under several key spellings (resetTime / reset_at / ...), +/// so the parser must tolerate all of them. +final class KimiUsageParsingTests: XCTestCase { + + func testParsesNumericShapeWithResetTime() throws { + let json = """ + { + "usage": {"limit": 100, "used": 40, "remaining": 60, "resetTime": "2026-07-30T12:00:00Z"}, + "limits": [ + {"window": {"duration": 5, "timeUnit": "hour"}, + "detail": {"limit": 20, "used": 10, "remaining": 10, "resetTime": "2026-07-23T21:00:00Z"}} + ] + } + """.data(using: .utf8)! + let usage = try KimiSubscriptionService.parseUsage(data: json) + XCTAssertEqual(usage.primary?.limit, 100) + XCTAssertEqual(usage.primary?.used, 40) + XCTAssertEqual(usage.primary?.usedPercent ?? -1, 40, accuracy: 0.001) + XCTAssertEqual(usage.primary?.remaining, 60) + XCTAssertNotNil(usage.primary?.resetsAt) + XCTAssertEqual(usage.details.count, 1) + XCTAssertEqual(usage.details.first?.label, "5-hour") + XCTAssertEqual(usage.details.first?.usedPercent ?? -1, 50, accuracy: 0.001) + } + + func testParsesStringNumbersAndSnakeCaseReset() throws { + let json = """ + { + "usage": {"limit": "500", "used": "123", "remaining": "377", "reset_at": "2026-07-30T12:00:00.000Z"}, + "limits": [] + } + """.data(using: .utf8)! + let usage = try KimiSubscriptionService.parseUsage(data: json) + XCTAssertEqual(usage.primary?.limit, 500) + XCTAssertEqual(usage.primary?.used, 123) + XCTAssertNotNil(usage.primary?.resetsAt) + } + + func testWeeklyWindowLabel() throws { + let json = """ + { + "limits": [ + {"window": {"duration": 7, "timeUnit": "day"}, + "detail": {"limit": 1000, "used": 250}} + ] + } + """.data(using: .utf8)! + let usage = try KimiSubscriptionService.parseUsage(data: json) + XCTAssertNil(usage.primary) + XCTAssertEqual(usage.details.first?.label, "Weekly") + } + + func testEpochResetTime() throws { + let json = """ + {"usage": {"limit": 10, "used": 5, "resetTime": "1784900000"}} + """.data(using: .utf8)! + let usage = try KimiSubscriptionService.parseUsage(data: json) + XCTAssertEqual(usage.primary?.resetsAt, Date(timeIntervalSince1970: 1_784_900_000)) + } + + func testNumericEpochResetTime() throws { + // A JSON number (not string) must not fail the whole decode. + let json = """ + {"usage": {"limit": 10, "used": 5, "resetTime": 1784900000}} + """.data(using: .utf8)! + let usage = try KimiSubscriptionService.parseUsage(data: json) + XCTAssertEqual(usage.primary?.resetsAt, Date(timeIntervalSince1970: 1_784_900_000)) + } + + func testLiveResponseShape() { + // Captured from GET https://api.kimi.com/coding/v1/usages (2026-07-23). + let json = """ + { + "user": {"userId": "x", "region": "REGION_OVERSEA", + "membership": {"level": "LEVEL_INTERMEDIATE"}}, + "usage": {"limit": "100", "used": "5", "remaining": "95", + "resetTime": "2026-07-30T13:27:17.211180Z"}, + "limits": [ + {"window": {"duration": 300, "timeUnit": "TIME_UNIT_MINUTE"}, + "detail": {"limit": "100", "remaining": "100", + "resetTime": "2026-07-23T23:27:17.211180Z"}} + ], + "parallel": {"limit": "20"} + } + """.data(using: .utf8)! + let usage = try! KimiSubscriptionService.parseUsage(data: json) + XCTAssertEqual(usage.plan, "Intermediate") + XCTAssertEqual(usage.parallelLimit, 20) + XCTAssertEqual(usage.primary?.label, "Weekly") + XCTAssertEqual(usage.primary?.usedPercent ?? -1, 5, accuracy: 0.001) + // 300 minutes rolls up to a 5-hour label; used derives from remaining. + XCTAssertEqual(usage.details.count, 1) + XCTAssertEqual(usage.details.first?.label, "5-hour") + XCTAssertEqual(usage.details.first?.usedPercent ?? -1, 0, accuracy: 0.001) + XCTAssertNotNil(usage.details.first?.resetsAt) + } + + func testEmptyEnvelopeThrows() { + let json = "{}".data(using: .utf8)! + XCTAssertThrowsError(try KimiSubscriptionService.parseUsage(data: json)) + } + + func testZeroLimitWindowDropped() throws { + let json = """ + {"usage": {"limit": 0, "used": 0}, "limits": []} + """.data(using: .utf8)! + XCTAssertThrowsError(try KimiSubscriptionService.parseUsage(data: json)) + } +} From d92b9fea43b2506424fa240f82ed289100f20cce Mon Sep 17 00:00:00 2001 From: ozymandiashh <234437643+ozymandiashh@users.noreply.github.com> Date: Fri, 24 Jul 2026 00:30:30 +0300 Subject: [PATCH 07/38] fix(claude): discover Claude Desktop/Cowork sessions in Windows MSIX installs The desktop sessions resolver returned a single per-platform path, so Microsoft Store (MSIX) installs of Claude Desktop were invisible: their data lives under %LOCALAPPDATA%\Packages\\LocalCache\Roaming\Claude\local-agent-mode-sessions and a filesystem junction workaround breaks Cowork's own file access (reported and verified in #611). getDesktopSessionsDir() becomes getDesktopSessionsDirs(): an ordered, deduped candidate list (override, then classic APPDATA, then MSIX packages matching Claude_* or *.Claude_*, existence-checked, lexicographically sorted; .config on Linux). Results are memoized per env-input tuple so the parser's per-file classification never rescans Packages. All call sites scan every candidate; macOS, Linux and classic Windows behavior unchanged. Fixes #611 --- CHANGELOG.md | 5 + docs/providers/claude.md | 17 ++- src/parser.ts | 12 +- src/providers/claude.ts | 159 ++++++++++++++------- src/usage-aggregator.ts | 8 +- tests/providers/claude-config-dirs.test.ts | 142 ++++++++++++++++-- 6 files changed, 278 insertions(+), 65 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2cfdf1f..98c3807 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,10 @@ # Changelog +## Unreleased + +### Fixed +- Claude Desktop and Cowork sessions are discovered for Windows Microsoft Store (MSIX) installs. (#611) + ## 0.9.19 - 2026-07-20 One version across every surface: CLI, macOS menubar, and the desktop app all ship as 0.9.19. diff --git a/docs/providers/claude.md b/docs/providers/claude.md index b5954c1..3412897 100644 --- a/docs/providers/claude.md +++ b/docs/providers/claude.md @@ -12,11 +12,26 @@ Anthropic Claude Code CLI and Claude Desktop's local agent mode. |---|---| | Claude Code CLI | `$CLAUDE_CONFIG_DIR` if set, otherwise `~/.claude/projects/` | | Claude Desktop (macOS) | `~/Library/Application Support/Claude/local-agent-mode-sessions/` | -| Claude Desktop (Windows) | `%APPDATA%/Claude/local-agent-mode-sessions/` | +| Claude Desktop (Windows, classic) | `%APPDATA%/Claude/local-agent-mode-sessions/` | +| Claude Desktop (Windows, MSIX) | `%LOCALAPPDATA%/Packages//LocalCache/Roaming/Claude/local-agent-mode-sessions/` | | Claude Desktop (Linux) | `~/.config/Claude/local-agent-mode-sessions/` | For Desktop, `findDesktopProjectDirs` walks up to 8 levels deep looking for `projects/` subdirectories, skipping `node_modules` and `.git`. +Desktop session roots are resolved in this order: + +1. A non-empty `CODEBURN_DESKTOP_SESSIONS_DIR` overrides discovery and is the + only returned root. +2. macOS uses the single path shown above. +3. Windows always includes the classic path first. It then scans + `%LOCALAPPDATA%/Packages` for package directories whose names start with + `Claude_` or contain `.Claude_`, sorted by package name, and includes only + packages whose full MSIX sessions path exists as a directory. +4. Other platforms use the single Linux path shown above. + +All returned roots are absolute, resolved, and deduplicated. Missing or +unreadable Windows package directories are ignored. + ## Storage format JSONL, one event per line, per session file. Sessions live under `/.jsonl`. diff --git a/src/parser.ts b/src/parser.ts index 91856cd..ee165e3 100644 --- a/src/parser.ts +++ b/src/parser.ts @@ -8,7 +8,7 @@ import { normalizeContentBlocks } from './content-utils.js' import { discoverAllSessions, getProvider } from './providers/index.js' import { flushCodexCache } from './codex-cache.js' import { antigravityCascadeIdFromPath, flushAntigravityCache, shouldReparseAntigravitySource } from './providers/antigravity.js' -import { getDesktopSessionsDir } from './providers/claude.js' +import { getDesktopSessionsDirs } from './providers/claude.js' import { isSqliteBusyError } from './sqlite.js' import { type CachedCall, @@ -81,9 +81,13 @@ function projectNameFromPath(projectPath: string, fallback: string): string { // In both cases the grouping key comes from the Cowork space name resolved in // claude.ts::discoverSessions(). function isCoworkSession(cwd: string, filePath: string): boolean { - const base = resolve(getDesktopSessionsDir()) - const inBase = (p: string) => p.startsWith(base + sep) || p.startsWith(base + '/') - return inBase(resolve(cwd)) || inBase(resolve(filePath)) + const resolvedCwd = resolve(cwd) + const resolvedFilePath = resolve(filePath) + return getDesktopSessionsDirs().some(base => { + const resolvedBase = resolve(base) + const inBase = (p: string) => p.startsWith(resolvedBase + sep) || p.startsWith(resolvedBase + '/') + return inBase(resolvedCwd) || inBase(resolvedFilePath) + }) } async function resolveCanonicalProjectPath(cwd: string): Promise<{ path: string; isWorktree: boolean }> { diff --git a/src/providers/claude.ts b/src/providers/claude.ts index f987b59..dd6b388 100644 --- a/src/providers/claude.ts +++ b/src/providers/claude.ts @@ -1,3 +1,4 @@ +import { readdirSync, statSync } from 'fs' import { readFile, readdir, stat } from 'fs/promises' import { basename, delimiter as pathDelimiter, join, resolve } from 'path' import { homedir } from 'os' @@ -101,15 +102,72 @@ export async function discoverClaudeConfigSources(): Promise() + +function cacheDesktopSessionsDirs(key: string, candidates: string[]): string[] { + const dirs = dedupeResolved(candidates.map(candidate => resolve(candidate))) + desktopSessionsDirsCache.set(key, dirs) + return [...dirs] +} + +export function getDesktopSessionsDirs(): string[] { const override = process.env['CODEBURN_DESKTOP_SESSIONS_DIR'] - if (override) return override - if (process.platform === 'darwin') return join(homedir(), 'Library', 'Application Support', 'Claude', 'local-agent-mode-sessions') - if (process.platform === 'win32') { - const appData = process.env['APPDATA']?.trim() - return join(appData || join(homedir(), 'AppData', 'Roaming'), 'Claude', 'local-agent-mode-sessions') + const appDataInput = process.env['APPDATA'] + const localAppDataInput = process.env['LOCALAPPDATA'] + const platform = process.platform + const cacheKey = JSON.stringify([platform, override ?? null, appDataInput ?? null, localAppDataInput ?? null]) + const cached = desktopSessionsDirsCache.get(cacheKey) + if (cached) return [...cached] + + if (override) return cacheDesktopSessionsDirs(cacheKey, [override]) + if (platform === 'darwin') { + return cacheDesktopSessionsDirs( + cacheKey, + [join(homedir(), 'Library', 'Application Support', 'Claude', 'local-agent-mode-sessions')], + ) } - return join(homedir(), '.config', 'Claude', 'local-agent-mode-sessions') + if (platform === 'win32') { + const appData = appDataInput?.trim() + const candidates = [ + join(appData || join(homedir(), 'AppData', 'Roaming'), 'Claude', 'local-agent-mode-sessions'), + ] + + const localAppData = localAppDataInput?.trim() + const packagesDir = join(localAppData || join(homedir(), 'AppData', 'Local'), 'Packages') + try { + const entries = readdirSync(packagesDir, { withFileTypes: true }) + .filter(entry => + entry.isDirectory() && + (entry.name.startsWith('Claude_') || entry.name.includes('.Claude_')), + ) + .sort((a, b) => a.name < b.name ? -1 : a.name > b.name ? 1 : 0) + + for (const entry of entries) { + const sessionsDir = join( + packagesDir, + entry.name, + 'LocalCache', + 'Roaming', + 'Claude', + 'local-agent-mode-sessions', + ) + try { + if (statSync(sessionsDir).isDirectory()) candidates.push(sessionsDir) + } catch { + // A package may disappear or be unreadable while Packages is scanned. + } + } + } catch { + // Missing or unreadable Packages is equivalent to no MSIX candidates. + } + + return cacheDesktopSessionsDirs(cacheKey, candidates) + } + return cacheDesktopSessionsDirs( + cacheKey, + [join(homedir(), '.config', 'Claude', 'local-agent-mode-sessions')], + ) } async function findDesktopProjectDirs(base: string): Promise { @@ -221,7 +279,7 @@ export const claude: Provider = { async probeRoots(): Promise { const dirs = await getClaudeConfigDirs() const roots: ProbeRoot[] = dirs.map(dir => ({ path: join(dir, 'projects'), label: 'projects' })) - roots.push({ path: getDesktopSessionsDir(), label: 'desktop' }) + roots.push(...getDesktopSessionsDirs().map(path => ({ path, label: 'desktop' }))) return roots }, @@ -282,51 +340,52 @@ export const claude: Provider = { ) } - const desktopBase = getDesktopSessionsDir() - const desktopDirs = await findDesktopProjectDirs(desktopBase) - const sep = desktopBase.includes('\\') ? '\\' : '/' - // Desktop / Cowork sessions belong to no CLAUDE_CONFIG_DIR. Tag them with a - // distinct source so a per-config view can account for them as their own - // "Claude Desktop" bucket instead of silently dropping them (which made - // sum-of-configs < All). - const desktopSourceId = 'claude-desktop:' + createHash('sha256').update(resolve(desktopBase)).digest('hex').slice(0, 16) - for (const dirPath of desktopDirs) { - const resolved = resolve(dirPath) - if (seenProjectDirs.has(resolved)) continue - seenProjectDirs.add(resolved) + for (const desktopBase of getDesktopSessionsDirs()) { + const desktopDirs = await findDesktopProjectDirs(desktopBase) + const sep = desktopBase.includes('\\') ? '\\' : '/' + // Desktop / Cowork sessions belong to no CLAUDE_CONFIG_DIR. Tag them with a + // distinct source so a per-config view can account for them as their own + // "Claude Desktop" bucket instead of silently dropping them (which made + // sum-of-configs < All). + const desktopSourceId = 'claude-desktop:' + createHash('sha256').update(resolve(desktopBase)).digest('hex').slice(0, 16) + for (const dirPath of desktopDirs) { + const resolved = resolve(dirPath) + if (seenProjectDirs.has(resolved)) continue + seenProjectDirs.add(resolved) - // For Claude Desktop local-agent-mode (Cowork) sessions, the project dir - // lives inside local_/.claude/projects/. We resolve the space - // name from the sibling .json and spaces.json so it groups correctly. - // Path structure: ///local_/.claude/projects/ - let projectName = basename(dirPath) - const resolvedBase = resolve(desktopBase) - if (resolved.startsWith(resolvedBase + sep) || resolved.startsWith(resolvedBase + '/')) { - const rel = resolved.slice(resolvedBase.length + 1) - const parts = rel.split(/[/\\]/) - // parts = [appId, workspaceId, local_sessionId, .claude, projects, slug] - if ( - parts.length >= 6 && - parts[2]?.startsWith('local_') && - parts[3] === '.claude' && - parts[4] === 'projects' - ) { - const workspaceDir = join(resolvedBase, parts[0]!, parts[1]!) - const sessionId = parts[2]! - const spaceName = await resolveCoworkSpaceName(workspaceDir, sessionId) - if (spaceName) projectName = spaceName + // For Claude Desktop local-agent-mode (Cowork) sessions, the project dir + // lives inside local_/.claude/projects/. We resolve the space + // name from the sibling .json and spaces.json so it groups correctly. + // Path structure: ///local_/.claude/projects/ + let projectName = basename(dirPath) + const resolvedBase = resolve(desktopBase) + if (resolved.startsWith(resolvedBase + sep) || resolved.startsWith(resolvedBase + '/')) { + const rel = resolved.slice(resolvedBase.length + 1) + const parts = rel.split(/[/\\]/) + // parts = [appId, workspaceId, local_sessionId, .claude, projects, slug] + if ( + parts.length >= 6 && + parts[2]?.startsWith('local_') && + parts[3] === '.claude' && + parts[4] === 'projects' + ) { + const workspaceDir = join(resolvedBase, parts[0]!, parts[1]!) + const sessionId = parts[2]! + const spaceName = await resolveCoworkSpaceName(workspaceDir, sessionId) + if (spaceName) projectName = spaceName + } } - } - sources.push({ - path: dirPath, - project: projectName, - provider: 'claude', - sourceId: desktopSourceId, - sourceLabel: 'Claude Desktop', - sourcePath: desktopBase, - sourceKind: 'claude-desktop', - }) + sources.push({ + path: dirPath, + project: projectName, + provider: 'claude', + sourceId: desktopSourceId, + sourceLabel: 'Claude Desktop', + sourcePath: desktopBase, + sourceKind: 'claude-desktop', + }) + } } return sources diff --git a/src/usage-aggregator.ts b/src/usage-aggregator.ts index fe7024a..baf4c31 100644 --- a/src/usage-aggregator.ts +++ b/src/usage-aggregator.ts @@ -4,7 +4,7 @@ import { type PeriodData, type ProviderCost, type BreakdownArrays, type MenubarP import { parseAllSessions, filterProjectsByName, filterProjectsByDays, filterProjectsByClaudeConfigSource, isSessionHydrationComplete } from './parser.js' import { findUnpricedModels, getLocalModelSavingsConfigHash, getPriceOverridesConfigHash, getShortModelName, isExpectedFreeModel } from './models.js' import { getAllProviders, safeDiscoverSessions } from './providers/index.js' -import { claude, getClaudeConfigDirs, getDesktopSessionsDir } from './providers/claude.js' +import { claude, getClaudeConfigDirs, getDesktopSessionsDirs } from './providers/claude.js' import { stat } from 'node:fs/promises' import { aggregateProjectsIntoDays, buildPeriodDataFromDays } from './day-aggregator.js' import { aggregateModelEfficiency } from './model-efficiency.js' @@ -149,7 +149,11 @@ async function claudeConfigSelector(projects: ProjectSummary[], selectedId?: str // idle config or Claude Desktop is still selectable). Only worth it when a // second source is possible: more than one config dir, or a Claude Desktop // sessions dir exists. A plain single-config user skips it entirely. - const desktopExists = await stat(getDesktopSessionsDir()).then(s => s.isDirectory()).catch(() => false) + const desktopExists = ( + await Promise.all( + getDesktopSessionsDirs().map(dir => stat(dir).then(s => s.isDirectory()).catch(() => false)), + ) + ).some(Boolean) if ((await getClaudeConfigDirs()).length > 1 || desktopExists) { for (const source of await claude.discoverSessions()) { if ((source.sourceKind !== 'claude-config' && source.sourceKind !== 'claude-desktop') || !source.sourceId || !source.sourceLabel || !source.sourcePath) continue diff --git a/tests/providers/claude-config-dirs.test.ts b/tests/providers/claude-config-dirs.test.ts index c316c74..571469b 100644 --- a/tests/providers/claude-config-dirs.test.ts +++ b/tests/providers/claude-config-dirs.test.ts @@ -1,10 +1,10 @@ import { mkdtemp, mkdir, rm, writeFile } from 'fs/promises' -import { delimiter as pathDelimiter, join } from 'path' +import { delimiter as pathDelimiter, join, resolve, sep } from 'path' import { homedir, tmpdir } from 'os' import { describe, it, expect, beforeEach, afterEach } from 'vitest' -import { claude, getDesktopSessionsDir } from '../../src/providers/claude.js' +import { claude, getDesktopSessionsDirs } from '../../src/providers/claude.js' import { clearSessionCache, filterProjectsByClaudeConfigSource, parseAllSessions } from '../../src/parser.js' let tmpRoot: string @@ -13,6 +13,7 @@ const savedEnv = { CLAUDE_CONFIG_DIRS: process.env['CLAUDE_CONFIG_DIRS'], CODEBURN_DESKTOP_SESSIONS_DIR: process.env['CODEBURN_DESKTOP_SESSIONS_DIR'], APPDATA: process.env['APPDATA'], + LOCALAPPDATA: process.env['LOCALAPPDATA'], HOME: process.env['HOME'], } @@ -39,6 +40,7 @@ beforeEach(async () => { delete process.env['CLAUDE_CONFIG_DIRS'] delete process.env['CODEBURN_DESKTOP_SESSIONS_DIR'] delete process.env['APPDATA'] + delete process.env['LOCALAPPDATA'] }) afterEach(async () => { @@ -218,30 +220,154 @@ describe('claude provider — CLAUDE_CONFIG_DIRS discovery', () => { }) describe('claude provider — Desktop sessions dir', () => { - it('uses APPDATA as the Windows Claude Desktop sessions root', () => { + async function makeMsixSessionsDir(localAppData: string, packageName: string): Promise { + const sessionsDir = join( + localAppData, + 'Packages', + packageName, + 'LocalCache', + 'Roaming', + 'Claude', + 'local-agent-mode-sessions', + ) + await mkdir(sessionsDir, { recursive: true }) + return resolve(sessionsDir) + } + + it('returns the classic Windows root first and discovers an MSIX sessions root', async () => { + const appData = join(tmpRoot, 'roaming-profile') + const localAppData = join(tmpRoot, 'local-profile') + const classic = join(appData, 'Claude', 'local-agent-mode-sessions') + const msix = await makeMsixSessionsDir(localAppData, 'Claude_fresh9k2m') + await mkdir(classic, { recursive: true }) + process.env['APPDATA'] = appData + process.env['LOCALAPPDATA'] = localAppData + + withPlatform('win32', () => { + expect(getDesktopSessionsDirs()).toEqual([resolve(classic), msix]) + }) + }) + + it('filters MSIX package names and keeps both supported Claude name shapes', async () => { + const appData = join(tmpRoot, 'roaming-profile') + const localAppData = join(tmpRoot, 'local-profile') + const classic = resolve(join(appData, 'Claude', 'local-agent-mode-sessions')) + const direct = await makeMsixSessionsDir(localAppData, 'Claude_new7v4p') + const publisher = await makeMsixSessionsDir(localAppData, 'SomePublisher.Claude_q8w3e') + await makeMsixSessionsDir(localAppData, 'ClaudeXtra_foo') + await makeMsixSessionsDir(localAppData, 'NotClaude_x') + process.env['APPDATA'] = appData + process.env['LOCALAPPDATA'] = localAppData + + withPlatform('win32', () => { + expect(getDesktopSessionsDirs()).toEqual([classic, direct, publisher]) + }) + }) + + it('returns only the classic root when LOCALAPPDATA is unset and Packages is missing', () => { const appData = join(tmpRoot, 'roaming-profile') process.env['APPDATA'] = appData + delete process.env['LOCALAPPDATA'] withPlatform('win32', () => { - expect(getDesktopSessionsDir()).toBe(join(appData, 'Claude', 'local-agent-mode-sessions')) + expect(() => getDesktopSessionsDirs()).not.toThrow() + expect(getDesktopSessionsDirs()).toEqual([ + resolve(join(appData, 'Claude', 'local-agent-mode-sessions')), + ]) }) }) - it('falls back to the legacy Windows roaming profile path when APPDATA is unset', () => { + it('falls back to the legacy Windows roaming path when APPDATA is unset or empty', () => { + const expected = resolve(join(homedir(), 'AppData', 'Roaming', 'Claude', 'local-agent-mode-sessions')) delete process.env['APPDATA'] + delete process.env['LOCALAPPDATA'] withPlatform('win32', () => { - expect(getDesktopSessionsDir()).toBe(join(homedir(), 'AppData', 'Roaming', 'Claude', 'local-agent-mode-sessions')) + expect(getDesktopSessionsDirs()).toEqual([expected]) + process.env['APPDATA'] = '' + expect(getDesktopSessionsDirs()).toEqual([expected]) }) }) - it('keeps CODEBURN_DESKTOP_SESSIONS_DIR ahead of Windows APPDATA discovery', () => { + it('excludes an MSIX package whose sessions subpath is missing', async () => { + const appData = join(tmpRoot, 'roaming-profile') + const localAppData = join(tmpRoot, 'local-profile') + await mkdir(join(localAppData, 'Packages', 'Claude_empty6r1t', 'LocalCache'), { recursive: true }) + process.env['APPDATA'] = appData + process.env['LOCALAPPDATA'] = localAppData + + withPlatform('win32', () => { + expect(getDesktopSessionsDirs()).toEqual([ + resolve(join(appData, 'Claude', 'local-agent-mode-sessions')), + ]) + }) + }) + + it('returns exactly the resolved override without platform discovery', async () => { const override = join(tmpRoot, 'desktop-override') + const localAppData = join(tmpRoot, 'local-profile') + await makeMsixSessionsDir(localAppData, 'Claude_shouldnotappear5n8d') process.env['CODEBURN_DESKTOP_SESSIONS_DIR'] = override process.env['APPDATA'] = join(tmpRoot, 'roaming-profile') + process.env['LOCALAPPDATA'] = localAppData withPlatform('win32', () => { - expect(getDesktopSessionsDir()).toBe(override) + expect(getDesktopSessionsDirs()).toEqual([resolve(override)]) + }) + }) + + it('resolves an override containing a parent segment and trailing separator', () => { + const override = join(tmpRoot, 'desktop-parent', 'nested') + `${sep}..${sep}sessions${sep}` + process.env['CODEBURN_DESKTOP_SESSIONS_DIR'] = override + + withPlatform('win32', () => { + expect(getDesktopSessionsDirs()).toEqual([resolve(override)]) + }) + }) + + it('returns one platform root on darwin and linux without including MSIX packages', async () => { + const localAppData = join(tmpRoot, 'local-profile') + await makeMsixSessionsDir(localAppData, 'Claude_crossplatform3b7j') + process.env['LOCALAPPDATA'] = localAppData + + withPlatform('darwin', () => { + expect(getDesktopSessionsDirs()).toEqual([ + resolve(join(homedir(), 'Library', 'Application Support', 'Claude', 'local-agent-mode-sessions')), + ]) + }) + withPlatform('linux', () => { + expect(getDesktopSessionsDirs()).toEqual([ + resolve(join(homedir(), '.config', 'Claude', 'local-agent-mode-sessions')), + ]) + }) + }) + + it('sorts two valid MSIX roots by package entry name', async () => { + const appData = join(tmpRoot, 'roaming-profile') + const localAppData = join(tmpRoot, 'local-profile') + const later = await makeMsixSessionsDir(localAppData, 'Claude_zulu4c9x') + const earlier = await makeMsixSessionsDir(localAppData, 'Alpha.Claude_alpha2m6v') + process.env['APPDATA'] = appData + process.env['LOCALAPPDATA'] = localAppData + + withPlatform('win32', () => { + expect(getDesktopSessionsDirs()).toEqual([ + resolve(join(appData, 'Claude', 'local-agent-mode-sessions')), + earlier, + later, + ]) + }) + }) + + it('dedupes classic and MSIX candidates that resolve to the same path', async () => { + const localAppData = join(tmpRoot, 'local-profile') + const packageName = 'Claude_duplicate8h3s' + const sessionsDir = await makeMsixSessionsDir(localAppData, packageName) + process.env['APPDATA'] = join(localAppData, 'Packages', packageName, 'LocalCache', 'Roaming') + process.env['LOCALAPPDATA'] = localAppData + + withPlatform('win32', () => { + expect(getDesktopSessionsDirs()).toEqual([sessionsDir]) }) }) }) From f7eafecb7f5890803ba832be0c76c3e537828c37 Mon Sep 17 00:00:00 2001 From: "Frenky Harry S. Sinaga" Date: Fri, 24 Jul 2026 19:22:24 +0700 Subject: [PATCH 08/38] fix(mac): keep right-click status-item menu open and stable Present the context menu on rightMouseUp (not down) so the matching mouse-up no longer dismisses it, and open via statusItem.menu + performClick so AppKit tracks the menu under the status item instead of manual popUp (which scrolled the Today row away on mouse move). Clears statusItem.menu in menuDidClose so left-click still opens the popover. Debounce + legacy rightMouseUp path retained for macOS <= 26. Closes #802 --- mac/Sources/CodeBurnMenubar/CodeBurnApp.swift | 67 ++++++++++++++++--- 1 file changed, 56 insertions(+), 11 deletions(-) diff --git a/mac/Sources/CodeBurnMenubar/CodeBurnApp.swift b/mac/Sources/CodeBurnMenubar/CodeBurnApp.swift index fefdffa..1fc54e2 100644 --- a/mac/Sources/CodeBurnMenubar/CodeBurnApp.swift +++ b/mac/Sources/CodeBurnMenubar/CodeBurnApp.swift @@ -49,11 +49,14 @@ struct CodeBurnApp: App { } @MainActor -final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate { +final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate, NSMenuDelegate { private var statusItem: NSStatusItem! private var popover: NSPopover! private var rightClickMonitor: Any? private var lastContextMenuPresentedAt: Date = .distantPast + /// Held only while the right-click menu is open. Cleared in menuDidClose so + /// left-click goes back to the popover action instead of re-showing the menu. + private var contextMenu: NSMenu? fileprivate let store = AppStore() let updateChecker = UpdateChecker() /// True while the displays are asleep. Refresh ticks skip spawning @@ -909,15 +912,25 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate { button.sendAction(on: [.leftMouseUp, .rightMouseUp]) // macOS 27 no longer routes any right-mouse event to the status-item - // button's target/action. A global monitor still observes right-mouse-down; + // button's target/action. A global monitor still observes right-mouse-up; // we hit-test it against our own status-item window and present the menu - // ourselves. Harmless and stable on 15/26 too (the debounce in - // showContextMenu prevents a double-present if the legacy path also fires). - rightClickMonitor = NSEvent.addGlobalMonitorForEvents(matching: [.rightMouseDown]) { [weak self] _ in + // ourselves. + // + // Must be mouse-*up*, not mouse-down: presenting on down starts menu + // tracking while the button is still held, so the matching rightMouseUp + // is treated as an outside click and the menu flashes then dismisses. + // Presenting on up (after the click completes) keeps it open. Harmless + // on 15/26 too (the debounce in showContextMenu prevents a double-present + // if the legacy path also fires). + rightClickMonitor = NSEvent.addGlobalMonitorForEvents(matching: [.rightMouseUp]) { [weak self] _ in guard let self, let button = self.statusItem.button, let window = button.window, window.frame.contains(NSEvent.mouseLocation) else { return } + // Defer one turn so menu presentation is not nested inside the + // monitor callback. Safe on mouse-*up* (the click is already + // complete); on mouse-down a deferred present was killed by the + // matching up event and the menu only flashed. DispatchQueue.main.async { self.showContextMenu(from: button) } } @@ -1130,6 +1143,11 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate { guard now.timeIntervalSince(lastContextMenuPresentedAt) > 0.3 else { return } lastContextMenuPresentedAt = now + // Don't let an open popover steal the click / sit under the menu. + if popover?.isShown == true { + popover.performClose(nil) + } + let menu = NSMenu() // Glanceable "today" usage at the top as a single non-interactive (dimmed) @@ -1160,12 +1178,39 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate { quitItem.target = self menu.addItem(quitItem) - // Present directly. The previous `statusItem.menu = menu; button.performClick` - // trick relies on the click -> action path that macOS 27 changed; popUp is - // version-stable. Open a few px below the status item so the menu clears the - // menu bar: anchoring flush clips the top edge and makes macOS engage menu - // scrolling (a scroll chevron appears and the first row slides up on hover). - menu.popUp(positioning: nil, at: NSPoint(x: 0, y: button.bounds.height + 6), in: button) + // Present via the status item's own menu slot. AppKit positions and tracks + // that menu correctly under the status item (no scroll chevron / first-row + // jump). Manual NSMenu.popUp(at:in:) is what caused the jump: the menu was + // tracked against a point while the cursor still sat on the status item + // above it, so the first mouse move engaged scroll mode. + // + // #472 dropped this pattern because assigning `statusItem.menu` from the + // right-mouse *action* never ran on macOS 27 (right-clicks no longer reach + // the button action). We still open from our global rightMouseUp monitor + // (or the legacy action on ≤26); once we set `statusItem.menu` ourselves, + // performClick is just "open the attached menu" and works on 27 too. + // + // menuDidClose clears `statusItem.menu` so the next left-click hits our + // action (popover) instead of re-opening this menu. + menu.delegate = self + contextMenu = menu + statusItem.menu = menu + button.performClick(nil) + } + + // MARK: - NSMenuDelegate + + // AppKit invokes menu callbacks on the main thread. Clear the status-item + // menu slot so the next left-click hits our action (popover) again. + nonisolated func menuDidClose(_ menu: NSMenu) { + // Hop explicitly — don't assumeIsolated across the NSMenuDelegate boundary + // under Swift 6 strict concurrency (NSMenu isn't Sendable). + DispatchQueue.main.async { [weak self] in + guard let self else { return } + // Always clear: we only ever attach our own context menu to the item. + self.statusItem.menu = nil + self.contextMenu = nil + } } /// One-line "today" summary for the context menu's usage row. From 8e8b58626aff5fe051be67ea4c83b1bf53cd83f6 Mon Sep 17 00:00:00 2001 From: "Frenky Harry S. Sinaga" Date: Fri, 24 Jul 2026 19:27:25 +0700 Subject: [PATCH 09/38] test(mac): lock status-item context menu policy (#802) Extract StatusItemContextMenuPolicy (event mask, debounce, presentation mode) and unit-test it so rightMouseUp + statusItemMenu cannot regress to the flash/scroll-jump paths without a failing test. --- mac/Sources/CodeBurnMenubar/CodeBurnApp.swift | 10 ++- .../StatusItemContextMenuPolicy.swift | 47 ++++++++++ .../StatusItemContextMenuPolicyTests.swift | 87 +++++++++++++++++++ 3 files changed, 140 insertions(+), 4 deletions(-) create mode 100644 mac/Sources/CodeBurnMenubar/StatusItemContextMenuPolicy.swift create mode 100644 mac/Tests/CodeBurnMenubarTests/StatusItemContextMenuPolicyTests.swift diff --git a/mac/Sources/CodeBurnMenubar/CodeBurnApp.swift b/mac/Sources/CodeBurnMenubar/CodeBurnApp.swift index 1fc54e2..e60cf3c 100644 --- a/mac/Sources/CodeBurnMenubar/CodeBurnApp.swift +++ b/mac/Sources/CodeBurnMenubar/CodeBurnApp.swift @@ -922,7 +922,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate, NSM // Presenting on up (after the click completes) keeps it open. Harmless // on 15/26 too (the debounce in showContextMenu prevents a double-present // if the legacy path also fires). - rightClickMonitor = NSEvent.addGlobalMonitorForEvents(matching: [.rightMouseUp]) { [weak self] _ in + rightClickMonitor = NSEvent.addGlobalMonitorForEvents(matching: StatusItemContextMenuPolicy.presentEventMask) { [weak self] _ in guard let self, let button = self.statusItem.button, let window = button.window, @@ -1139,9 +1139,11 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate, NSM private func showContextMenu(from button: NSStatusBarButton) { // Debounce: on macOS <= 26 both the legacy action path and the global // monitor can fire for a single right-click. Present at most once per click. - let now = Date() - guard now.timeIntervalSince(lastContextMenuPresentedAt) > 0.3 else { return } - lastContextMenuPresentedAt = now + // Policy lives in StatusItemContextMenuPolicy so the gate is unit-tested (#802). + guard StatusItemContextMenuPolicy.acceptPresent( + now: Date(), + lastPresentedAt: &lastContextMenuPresentedAt + ) else { return } // Don't let an open popover steal the click / sit under the menu. if popover?.isShown == true { diff --git a/mac/Sources/CodeBurnMenubar/StatusItemContextMenuPolicy.swift b/mac/Sources/CodeBurnMenubar/StatusItemContextMenuPolicy.swift new file mode 100644 index 0000000..845a9f3 --- /dev/null +++ b/mac/Sources/CodeBurnMenubar/StatusItemContextMenuPolicy.swift @@ -0,0 +1,47 @@ +import AppKit +import Foundation + +/// Pure policy for the status-item right-click menu (#802). +/// +/// Keeps the event-mask, debounce, and presentation choices out of AppDelegate +/// so they can be unit-tested without spinning up an NSStatusItem. The bugs this +/// encodes: +/// +/// 1. **Flash** — presenting on `rightMouseDown` lets the matching `rightMouseUp` +/// dismiss the menu immediately. Monitor (and legacy action) use mouse-*up*. +/// 2. **Jump/scroll** — manual `NSMenu.popUp(at:in:)` tracks poorly while the +/// cursor still sits on the status item above the menu. Attach via +/// `statusItem.menu` + `performClick` instead. +/// 3. **Double-present** — on macOS ≤26 both the button action and the global +/// monitor can fire for one click; debounce collapses them. +enum StatusItemContextMenuPolicy { + /// Global-monitor / action event. Must be mouse-up (see flash note above). + static let presentEventMask: NSEvent.EventTypeMask = .rightMouseUp + + /// Minimum gap between presents. Covers the dual-path race on macOS ≤26. + static let presentDebounceSeconds: TimeInterval = 0.3 + + /// How the menu is shown once a present is accepted. + enum Presentation: Equatable { + /// Assign `statusItem.menu` then `button.performClick`. AppKit positions + /// and tracks the menu under the status item. Clear menu in menuDidClose. + case statusItemMenu + /// Manual `NSMenu.popUp(at:in:)`. Causes scroll-chevron jump on mouse move + /// when the cursor starts above the menu. Kept only as a named anti-pattern + /// so tests can lock that we do *not* use it. + case manualPopUp + } + + static let presentation: Presentation = .statusItemMenu + + /// Returns true and advances `lastPresentedAt` when a new present is allowed. + static func acceptPresent( + now: Date, + lastPresentedAt: inout Date, + debounce: TimeInterval = presentDebounceSeconds + ) -> Bool { + guard now.timeIntervalSince(lastPresentedAt) > debounce else { return false } + lastPresentedAt = now + return true + } +} diff --git a/mac/Tests/CodeBurnMenubarTests/StatusItemContextMenuPolicyTests.swift b/mac/Tests/CodeBurnMenubarTests/StatusItemContextMenuPolicyTests.swift new file mode 100644 index 0000000..e601fa8 --- /dev/null +++ b/mac/Tests/CodeBurnMenubarTests/StatusItemContextMenuPolicyTests.swift @@ -0,0 +1,87 @@ +import AppKit +import XCTest +@testable import CodeBurnMenubar + +/// Locks the right-click menu policy that fixed flash + scroll-jump (#802). +/// Does not drive a real NSStatusItem — that needs manual/AppKit integration. +final class StatusItemContextMenuPolicyTests: XCTestCase { + func testPresentEventMaskIsRightMouseUpNotDown() { + // Presenting on mouse-down lets the matching up dismiss the menu (flash). + XCTAssertEqual( + StatusItemContextMenuPolicy.presentEventMask, + NSEvent.EventTypeMask.rightMouseUp + ) + XCTAssertNotEqual( + StatusItemContextMenuPolicy.presentEventMask, + NSEvent.EventTypeMask.rightMouseDown + ) + // Mask must include up and must not include down (single-bit masks here). + XCTAssertTrue(StatusItemContextMenuPolicy.presentEventMask.contains(.rightMouseUp)) + XCTAssertFalse(StatusItemContextMenuPolicy.presentEventMask.contains(.rightMouseDown)) + } + + func testPresentationUsesStatusItemMenuNotManualPopUp() { + // Manual popUp tracks against a point while the cursor sits on the status + // item above the menu → scroll chevron / Today-row jump on mouse move. + XCTAssertEqual( + StatusItemContextMenuPolicy.presentation, + .statusItemMenu + ) + XCTAssertNotEqual( + StatusItemContextMenuPolicy.presentation, + .manualPopUp + ) + } + + func testDebounceAcceptsFirstPresent() { + var last = Date.distantPast + let now = Date(timeIntervalSince1970: 1_000) + XCTAssertTrue( + StatusItemContextMenuPolicy.acceptPresent(now: now, lastPresentedAt: &last) + ) + XCTAssertEqual(last, now) + } + + func testDebounceRejectsWithinWindow() { + let t0 = Date(timeIntervalSince1970: 1_000) + var last = t0 + // Just inside the 0.3s window + let t1 = t0.addingTimeInterval(0.299) + XCTAssertFalse( + StatusItemContextMenuPolicy.acceptPresent(now: t1, lastPresentedAt: &last) + ) + XCTAssertEqual(last, t0, "reject must not advance lastPresentedAt") + } + + func testDebounceAcceptsAfterWindow() { + let t0 = Date(timeIntervalSince1970: 1_000) + var last = t0 + let t1 = t0.addingTimeInterval(StatusItemContextMenuPolicy.presentDebounceSeconds + 0.001) + XCTAssertTrue( + StatusItemContextMenuPolicy.acceptPresent(now: t1, lastPresentedAt: &last) + ) + XCTAssertEqual(last, t1) + } + + func testDebounceBoundaryIsStrictlyGreaterThan() { + // Gate uses `>` not `>=`: exactly debounce seconds later is still rejected. + let t0 = Date(timeIntervalSince1970: 1_000) + var last = t0 + let exact = t0.addingTimeInterval(StatusItemContextMenuPolicy.presentDebounceSeconds) + XCTAssertFalse( + StatusItemContextMenuPolicy.acceptPresent(now: exact, lastPresentedAt: &last) + ) + XCTAssertEqual(last, t0) + } + + func testCustomDebounceOverride() { + var last = Date(timeIntervalSince1970: 0) + let now = Date(timeIntervalSince1970: 0.5) + XCTAssertFalse( + StatusItemContextMenuPolicy.acceptPresent(now: now, lastPresentedAt: &last, debounce: 1.0) + ) + XCTAssertTrue( + StatusItemContextMenuPolicy.acceptPresent(now: now, lastPresentedAt: &last, debounce: 0.4) + ) + } +} From f54c48f3502c27b225278b31a4176cd7bf888430 Mon Sep 17 00:00:00 2001 From: Andrew Lee Date: Fri, 24 Jul 2026 17:56:50 +0000 Subject: [PATCH 10/38] fix(sync): setup error handling, Windows browser open, themed callback pages - Catch errors in 'sync setup' action: AuthError/DiscoveryError (login timeout, port exhaustion, discovery failures) now print a clean one-line message and exit 1 instead of crashing Node with an unhandled rejection stack trace. Also guard the double rejection when all callback ports are in use (both 'ready' and the callback promise reject). - Windows: open the auth URL via 'rundll32 url.dll,FileProtocolHandler' instead of 'cmd /c start'. cmd.exe splits its command line on '&', which truncated the OAuth query string at the first parameter, so the IdP received a bare /authorize request ('no parameters'). rundll32 receives the URL as a plain process argument with no shell parsing. - Theme the OAuth callback landing pages to match the dashboard (warm paper background, ink text, forest-green success / terracotta failure), fully inline with no network or asset dependencies. AI-Origin: human --- src/sync/auth.ts | 37 ++++++++- src/sync/cli.ts | 201 ++++++++++++++++++++++++++--------------------- 2 files changed, 144 insertions(+), 94 deletions(-) diff --git a/src/sync/auth.ts b/src/sync/auth.ts index fa91232..ff60a71 100644 --- a/src/sync/auth.ts +++ b/src/sync/auth.ts @@ -141,6 +141,35 @@ export interface CallbackResult { port: number } +/** + * Themed HTML for the OAuth callback landing page. + * + * Matches the CodeBurn dashboard theme (dash/src/index.css): warm paper + * surfaces, ink text, forest-green accent. Everything is inline — this page + * is served once from a throwaway localhost server, so it must not depend + * on network fonts, external CSS, or dashboard assets. + */ +export function renderCallbackPage(ok: boolean, title: string, message: string): string { + const accent = ok ? '#1f8a5b' : '#c8541f' // --primary / --chart-5 (terracotta) + const mark = ok ? '✓' : '✕' // ✓ / ✕ + return ` + + + + +CodeBurn — ${title} + + +
+
${mark}
+

${title}

+

${message}

+

CodeBurn local sync login

+
+ +` +} + export function startCallbackServer( expectedState: string, timeoutMs: number = 300_000, // 5 minutes @@ -179,8 +208,8 @@ export function startCallbackServer( // Connection: close on every response — the callback server is // single-purpose and must never leave pooled keep-alive sockets behind. if (error) { - res.writeHead(400, { 'Content-Type': 'text/html', 'Connection': 'close' }) - res.end('

Login failed

You can close this tab.

') + res.writeHead(400, { 'Content-Type': 'text/html; charset=utf-8', 'Connection': 'close' }) + res.end(renderCallbackPage(false, 'Login failed', 'The identity provider rejected the login. You can close this tab and retry from the terminal.')) clearTimeout(timer) shutdown() reject(new AuthError(`IdP returned error: ${error}`)) @@ -199,8 +228,8 @@ export function startCallbackServer( return } - res.writeHead(200, { 'Content-Type': 'text/html', 'Connection': 'close' }) - res.end('

✓ Login successful

You can close this tab.

') + res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8', 'Connection': 'close' }) + res.end(renderCallbackPage(true, 'Login successful', 'CodeBurn sync is now authenticated. You can close this tab and return to the terminal.')) clearTimeout(timer) shutdown() resolve({ code, port: resolvedPort }) diff --git a/src/sync/cli.ts b/src/sync/cli.ts index 9136dbf..174b616 100644 --- a/src/sync/cli.ts +++ b/src/sync/cli.ts @@ -7,8 +7,9 @@ import type { Command } from 'commander' import { randomBytes } from 'crypto' -import { fetchDiscoveryDoc } from './discovery.js' +import { fetchDiscoveryDoc, DiscoveryError } from './discovery.js' import { + AuthError, fetchOidcConfig, generatePkce, buildAuthUrl, @@ -33,100 +34,120 @@ export function registerSyncCommands(program: Command): void { .command('setup ') .description('Configure sync with a remote endpoint (one-time)') .action(async (url: string) => { - const baseUrl = url.replace(/\/$/, '') - process.stderr.write(`Fetching discovery doc from ${baseUrl}...\n`) - - // 1. Fetch codeburn discovery doc - const discovery = await fetchDiscoveryDoc(baseUrl) - process.stderr.write(` Issuer: ${discovery.issuer}\n`) - process.stderr.write(` Client: ${discovery.client_id}\n`) - - // 2. Fetch OIDC configuration from the issuer - const oidc = await fetchOidcConfig(discovery.issuer) - process.stderr.write(` Auth endpoint: ${oidc.authorization_endpoint}\n`) - - // 3. Resolve scopes - const scopes = resolveScopes(discovery.scopes, oidc.scopes_supported) - - // 4. Generate PKCE - const pkce = generatePkce() - const state = randomBytes(16).toString('hex') - - // 5. Start callback server — await the actually-bound port (port - // fallback means it may not be the first in CALLBACK_PORTS) - const { promise: callbackPromise, ready } = startCallbackServer(state) - const port = await ready - const redirectUri = `http://127.0.0.1:${port}/callback` - - // 6. Build auth URL and open browser - const authUrl = buildAuthUrl({ - authorization_endpoint: oidc.authorization_endpoint, - client_id: discovery.client_id, - redirect_uri: redirectUri, - scopes, - state, - pkce, - }) - - process.stderr.write(`\nOpening browser for login...\n`) - process.stderr.write(`If the browser doesn't open, visit:\n ${authUrl}\n\n`) - - // Open browser (best-effort, platform-specific). - // execFileSync with args array — authUrl comes from the remote discovery - // doc so it must never be shell-interpolated. Scheme is also validated. try { - if (!/^https:\/\//.test(authUrl)) { - throw new Error('auth URL must be https') + const baseUrl = url.replace(/\/$/, '') + process.stderr.write(`Fetching discovery doc from ${baseUrl}...\n`) + + // 1. Fetch codeburn discovery doc + const discovery = await fetchDiscoveryDoc(baseUrl) + process.stderr.write(` Issuer: ${discovery.issuer}\n`) + process.stderr.write(` Client: ${discovery.client_id}\n`) + + // 2. Fetch OIDC configuration from the issuer + const oidc = await fetchOidcConfig(discovery.issuer) + process.stderr.write(` Auth endpoint: ${oidc.authorization_endpoint}\n`) + + // 3. Resolve scopes + const scopes = resolveScopes(discovery.scopes, oidc.scopes_supported) + + // 4. Generate PKCE + const pkce = generatePkce() + const state = randomBytes(16).toString('hex') + + // 5. Start callback server — await the actually-bound port (port + // fallback means it may not be the first in CALLBACK_PORTS) + const { promise: callbackPromise, ready } = startCallbackServer(state) + // If all ports are in use, BOTH `ready` and `callbackPromise` reject. + // Mark callbackPromise as handled so the second rejection can't crash + // the process as an unhandled rejection while we're throwing from + // `await ready`. The later `await callbackPromise` still throws. + callbackPromise.catch(() => {}) + const port = await ready + const redirectUri = `http://127.0.0.1:${port}/callback` + + // 6. Build auth URL and open browser + const authUrl = buildAuthUrl({ + authorization_endpoint: oidc.authorization_endpoint, + client_id: discovery.client_id, + redirect_uri: redirectUri, + scopes, + state, + pkce, + }) + + process.stderr.write(`\nOpening browser for login...\n`) + process.stderr.write(`If the browser doesn't open, visit:\n ${authUrl}\n\n`) + + // Open browser (best-effort, platform-specific). + // execFileSync with args array — authUrl comes from the remote discovery + // doc so it must never be shell-interpolated. Scheme is also validated. + try { + if (!/^https:\/\//.test(authUrl)) { + throw new Error('auth URL must be https') + } + const { execFileSync } = await import('child_process') + if (process.platform === 'darwin') { + execFileSync('open', [authUrl], { stdio: 'ignore' }) + } else if (process.platform === 'win32') { + // Do NOT use `cmd /c start` here: cmd.exe treats `&` as a command + // separator, so the auth URL's query string gets truncated at the + // first parameter and the IdP sees a bare /authorize request. + // rundll32's FileProtocolHandler receives the URL as a plain + // process argument with no shell parsing, so `&` survives intact. + execFileSync('rundll32', ['url.dll,FileProtocolHandler', authUrl], { stdio: 'ignore' }) + } else { + execFileSync('xdg-open', [authUrl], { stdio: 'ignore' }) + } + } catch { + // Browser open failed — user sees the URL above } - const { execFileSync } = await import('child_process') - if (process.platform === 'darwin') { - execFileSync('open', [authUrl], { stdio: 'ignore' }) - } else if (process.platform === 'win32') { - // `start` is a cmd builtin; empty first arg is the window title - execFileSync('cmd', ['/c', 'start', '', authUrl], { stdio: 'ignore' }) + + // 7. Wait for callback + process.stderr.write(`Waiting for login (5 min timeout)...\n`) + const callback = await callbackPromise + + // 8. Exchange code for tokens + const tokenRedirectUri = `http://127.0.0.1:${callback.port}/callback` + const tokens = await exchangeCode( + oidc.token_endpoint, + callback.code, + pkce.code_verifier, + tokenRedirectUri, + discovery.client_id, + ) + + if (!tokens.refresh_token) { + process.stderr.write(`Warning: IdP did not return a refresh token. You may need to re-authenticate frequently.\n`) + } + + // 9. Store credentials + const store = createCredentialStore() + if (tokens.refresh_token) { + store.store(tokens.refresh_token) + } + + // 10. Write config + writeSyncConfig({ + baseUrl, + clientId: discovery.client_id, + tracesPath: discovery.traces_path, + issuer: discovery.issuer, + }) + + process.stderr.write(`\n✓ Sync configured successfully.\n`) + process.stderr.write(` Endpoint: ${baseUrl}\n`) + process.stderr.write(` Token stored in: ${store.method()}\n`) + process.stderr.write(`\nRun \`codeburn sync push\` to send telemetry data.\n`) + } catch (err) { + // Known errors (login timeout, port exhaustion, discovery failures) + // get a clean one-line message instead of a raw Node crash dump. + if (err instanceof AuthError || err instanceof DiscoveryError) { + process.stderr.write(`\nError: ${err.message}\n`) } else { - execFileSync('xdg-open', [authUrl], { stdio: 'ignore' }) + process.stderr.write(`\nError: ${(err as Error).stack ?? (err as Error).message}\n`) } - } catch { - // Browser open failed — user sees the URL above + process.exit(1) } - - // 7. Wait for callback - process.stderr.write(`Waiting for login (5 min timeout)...\n`) - const callback = await callbackPromise - - // 8. Exchange code for tokens - const tokenRedirectUri = `http://127.0.0.1:${callback.port}/callback` - const tokens = await exchangeCode( - oidc.token_endpoint, - callback.code, - pkce.code_verifier, - tokenRedirectUri, - discovery.client_id, - ) - - if (!tokens.refresh_token) { - process.stderr.write(`Warning: IdP did not return a refresh token. You may need to re-authenticate frequently.\n`) - } - - // 9. Store credentials - const store = createCredentialStore() - if (tokens.refresh_token) { - store.store(tokens.refresh_token) - } - - // 10. Write config - writeSyncConfig({ - baseUrl, - clientId: discovery.client_id, - tracesPath: discovery.traces_path, - issuer: discovery.issuer, - }) - - process.stderr.write(`\n✓ Sync configured successfully.\n`) - process.stderr.write(` Endpoint: ${baseUrl}\n`) - process.stderr.write(` Token stored in: ${store.method()}\n`) - process.stderr.write(`\nRun \`codeburn sync push\` to send telemetry data.\n`) }) // --- status --- From a6bf81f756203b12848b9214cbec4d90a12e8c7f Mon Sep 17 00:00:00 2001 From: ihearttokyo <164558075+ihearttokyo@users.noreply.github.com> Date: Fri, 24 Jul 2026 22:53:43 -0400 Subject: [PATCH 11/38] feat(codex): add tool-excluded active throughput metrics --- src/codex-cache.ts | 5 +- src/codex-throughput.ts | 521 +++++++++++++++++++++++++++++++++ src/dashboard.tsx | 16 +- src/main.ts | 106 +++++++ src/model-breakdown.ts | 5 + src/parser.ts | 14 + src/providers/codex.ts | 272 ++++++++++++++++- src/providers/types.ts | 3 + src/session-cache.ts | 8 +- src/types.ts | 5 +- tests/cli-codex-tps.test.ts | 46 +++ tests/codex-throughput.test.ts | 124 ++++++++ tests/providers/codex.test.ts | 216 ++++++++++++++ 13 files changed, 1326 insertions(+), 15 deletions(-) create mode 100644 src/codex-throughput.ts create mode 100644 tests/cli-codex-tps.test.ts create mode 100644 tests/codex-throughput.test.ts diff --git a/src/codex-cache.ts b/src/codex-cache.ts index 0eb59b4..6146e8e 100644 --- a/src/codex-cache.ts +++ b/src/codex-cache.ts @@ -11,9 +11,10 @@ import type { ParsedProviderCall } from './providers/types.js' // v5: also attribute CLI-wrapped MCP calls (`mcp-cli call server tool`) that // Codex logs as a plain exec_command (issue #478 follow-up). Force a re-parse // so sessions cached under v4 pick up the CLI-MCP attribution. -// v6: rich-session-capture — per-call locAdded/locRemoved/editFailed from +// v6/v7: rich-session-capture — per-call locAdded/locRemoved/editFailed from // patch_apply_end. Sessions cached under v5 lack these fields; re-parse to add. -const CODEX_CACHE_VERSION = 7 +// v8: persist native MCP timing and compact invocation attribution. +const CODEX_CACHE_VERSION = 8 const CACHE_FILE = 'codex-results.json' type FileFingerprint = { mtimeMs: number; sizeBytes: number } diff --git a/src/codex-throughput.ts b/src/codex-throughput.ts new file mode 100644 index 0000000..4796206 --- /dev/null +++ b/src/codex-throughput.ts @@ -0,0 +1,521 @@ +import { open, stat } from 'node:fs/promises' +import { StringDecoder } from 'node:string_decoder' + +export type CodexThroughputPoint = { + timestamp: string + model?: string + outputTokens: number + reasoningTokens: number + generatedTokens: number + taskGeneratedTokens?: number + elapsedSeconds?: number + generatedTokensPerSecond?: number + activeDurationSeconds?: number + activeGeneratedTokensPerSecond?: number + toolWaitSeconds?: number +} + +type TokenUsage = { + output_tokens?: number + reasoning_output_tokens?: number + total_tokens?: number +} + +type RolloutLine = { + type?: string + timestamp?: string + payload?: { + type?: string + turn_id?: string + call_id?: string + started_at?: number + duration_ms?: number + duration?: { secs?: number; nanos?: number } | string + model?: string + forked_from_id?: string + info?: { + last_token_usage?: TokenUsage + total_token_usage?: TokenUsage + } + } +} + +const CHUNK_BYTES = 64 * 1024 +const MAX_PENDING_LINE_CHARS = 4 * 1024 * 1024 +const TRUNCATION_MARKER = '__CODEBURN_TRUNCATED_LINE__' + +function rawString(source: string, field: string): string | undefined { + const match = new RegExp(`"${field}"\\s*:\\s*"((?:\\\\.|[^"\\\\])*)"`).exec(source) + if (!match) return undefined + try { return JSON.parse(`"${match[1]}"`) as string } catch { return undefined } +} + +function rawNumber(source: string, field: string): number | undefined { + const match = new RegExp(`"${field}"\\s*:\\s*(-?\\d+(?:\\.\\d+)?)`).exec(source) + if (!match) return undefined + const value = Number(match[1]) + return Number.isFinite(value) ? value : undefined +} + +function compactUsage(source: string, field: 'last_token_usage' | 'total_token_usage'): TokenUsage | undefined { + const index = source.indexOf(`"${field}"`) + if (index < 0) return undefined + const body = source.slice(index, index + 4096) + return { + output_tokens: rawNumber(body, 'output_tokens'), + reasoning_output_tokens: rawNumber(body, 'reasoning_output_tokens'), + total_tokens: rawNumber(body, 'total_tokens'), + } +} + +function parseRawDurationValue(value: string): number | undefined { + const objectMatch = /^\s*\{\s*"secs"\s*:\s*(-?\d+(?:\.\d+)?)\s*,\s*"nanos"\s*:\s*(-?\d+(?:\.\d+)?)\s*\}/.exec(value) + if (objectMatch) { + const seconds = Number(objectMatch[1]) + const nanos = Number(objectMatch[2]) + if (Number.isFinite(seconds) && Number.isFinite(nanos)) return seconds * 1000 + nanos / 1e6 + } + const stringMatch = /^\s*"(\d+(?:\.\d+)?)(ms|s)?"/.exec(value) + if (stringMatch) { + const parsed = Number(stringMatch[1]) + if (Number.isFinite(parsed)) return parsed * (stringMatch[2] === 's' ? 1000 : 1) + } + const numberMatch = /^\s*(-?\d+(?:\.\d+)?)/.exec(value) + if (numberMatch) { + const parsed = Number(numberMatch[1]) + if (Number.isFinite(parsed)) return parsed + } + return undefined +} + +function durationMs(payload: RolloutLine['payload']): number | undefined { + if (!payload) return undefined + if (typeof payload.duration_ms === 'number' && Number.isFinite(payload.duration_ms)) return payload.duration_ms + if (typeof payload.duration === 'object' && payload.duration) { + const seconds = payload.duration.secs + const nanos = payload.duration.nanos + if (typeof seconds === 'number' && typeof nanos === 'number' && Number.isFinite(seconds) && Number.isFinite(nanos)) { + return seconds * 1000 + nanos / 1e6 + } + } + if (typeof payload.duration === 'string') { + const match = /^(\d+(?:\.\d+)?)(ms|s)?$/.exec(payload.duration.trim()) + if (match) return Number(match[1]) * (match[2] === 's' ? 1000 : 1) + } + return undefined +} + +function mergeToolIntervals(intervals: Array<[number, number]>, durationMs: number, taskStartedAt?: number, taskCompletedAt?: number): number { + const windowStart = taskStartedAt ?? (taskCompletedAt !== undefined ? taskCompletedAt - durationMs : undefined) + const windowEnd = windowStart !== undefined ? windowStart + durationMs : undefined + const clipped = intervals.map(([start, end]) => [ + windowStart !== undefined ? Math.max(start, windowStart) : start, + windowEnd !== undefined ? Math.min(end, windowEnd) : end, + ] as [number, number]).filter(([start, end]) => end > start) + const merged = clipped.sort((a, b) => a[0] - b[0]).reduce>((result, interval) => { + const previous = result.at(-1) + if (previous && interval[0] <= previous[1]) previous[1] = Math.max(previous[1], interval[1]) + else result.push([...interval]) + return result + }, []) + return Math.min(durationMs, merged.reduce((total, [start, end]) => total + end - start, 0)) +} + +function parseLine(line: string): RolloutLine | null { + const payloadStart = line.indexOf('"payload"') + const payloadHead = payloadStart >= 0 ? line.slice(payloadStart) : line + if (line.length > 256 * 1024 || line.startsWith(TRUNCATION_MARKER)) { + const payloadType = rawString(payloadHead, 'type') + const infoStart = payloadHead.indexOf('"info"') + const info = infoStart >= 0 ? payloadHead.slice(infoStart) : '' + return { + type: rawString(line, 'type'), + timestamp: rawString(line, 'timestamp'), + payload: { + type: payloadType, + turn_id: rawString(payloadHead, 'turn_id'), + call_id: rawString(payloadHead, 'call_id'), + started_at: rawNumber(payloadHead, 'started_at'), + duration_ms: rawNumber(payloadHead, 'duration_ms'), + duration: rawString(payloadHead, 'duration') ?? (rawNumber(payloadHead, 'secs') !== undefined + ? { secs: rawNumber(payloadHead, 'secs'), nanos: rawNumber(payloadHead, 'nanos') } + : undefined), + model: rawString(payloadHead, 'model'), + forked_from_id: rawString(payloadHead, 'forked_from_id'), + info: { + last_token_usage: compactUsage(info, 'last_token_usage'), + total_token_usage: compactUsage(info, 'total_token_usage'), + }, + }, + } + } + try { + return JSON.parse(line) as RolloutLine + } catch { + return null + } +} + +/** + * Estimate generated tokens/sec from a Codex rollout's persisted checkpoints. + * Codex JSONL has no per-token timestamps, so this is deliberately a + * checkpoint-to-checkpoint estimate, not live decode speed. + */ +type ThroughputState = { + model?: string + previousTotal?: number + previousOutput: number + previousReasoning: number + previousTimestamp?: number + currentTaskGenerated: number + currentTaskToolIntervals: Array<[number, number]> + currentTaskStartedAt?: number + toolStarts: Map + latestPoint?: CodexThroughputPoint + points: CodexThroughputPoint[] + forkCutoffMs?: number +} + +function newThroughputState(): ThroughputState { + return { + previousOutput: 0, + previousReasoning: 0, + currentTaskGenerated: 0, + currentTaskToolIntervals: [], + toolStarts: new Map(), + points: [], + } +} + +/** + * Incrementally parses a rollout. Watch mode feeds only newly appended bytes + * to this reader, so a growing JSONL file is not reparsed from byte zero. + */ +export class CodexThroughputReader { + private offset = 0 + private pending = '' + private decoder = new StringDecoder('utf8') + private pendingDurationMs: number | undefined + private scanDepth = 0 + private scanPayloadDepth: number | undefined + private scanInString = false + private scanEscape = false + private scanString = '' + private scanLastString = '' + private scanAwaitingColon = false + private scanCurrentKey: string | undefined + private scanCapture: { mode: 'string' | 'object' | 'primitive'; text: string; depth: number } | undefined + private state = newThroughputState() + + reset(): void { + this.offset = 0 + this.pending = '' + this.decoder = new StringDecoder('utf8') + this.pendingDurationMs = undefined + this.scanDepth = 0 + this.scanPayloadDepth = undefined + this.scanInString = false + this.scanEscape = false + this.scanString = '' + this.scanLastString = '' + this.scanAwaitingColon = false + this.scanCurrentKey = undefined + this.scanCapture = undefined + this.state = newThroughputState() + } + + private finishDurationCapture(): void { + if (!this.scanCapture) return + const value = this.scanCapture.mode === 'string' ? `"${this.scanCapture.text}"` : this.scanCapture.text + const parsed = parseRawDurationValue(value) + if (parsed !== undefined && this.pendingDurationMs === undefined) this.pendingDurationMs = parsed + this.scanCapture = undefined + } + + private scanDurationSegment(source: string): void { + for (let i = 0; i < source.length; i++) { + const char = source[i]! + if (this.scanInString) { + if (this.scanEscape) { + this.scanEscape = false + if (this.scanCapture?.mode === 'object') this.scanCapture.text += char + else if (this.scanCapture?.mode === 'string') this.scanCapture.text += char + else this.scanString += char + continue + } + if (char === '\\') { + this.scanEscape = true + if (this.scanCapture?.mode === 'object' || this.scanCapture?.mode === 'string') this.scanCapture.text += char + continue + } + if (char === '"') { + if (this.scanCapture?.mode === 'object') this.scanCapture.text += char + this.scanInString = false + if (this.scanCapture?.mode === 'string') this.finishDurationCapture() + else if (this.scanCapture?.mode === 'object') { + this.scanAwaitingColon = false + this.scanCurrentKey = undefined + } else { + this.scanLastString = this.scanString + this.scanAwaitingColon = true + } + continue + } + if (this.scanCapture?.mode === 'object' || this.scanCapture?.mode === 'string') this.scanCapture.text += char + else this.scanString += char + continue + } + + if (this.scanCapture?.mode === 'primitive') { + if (char === ',' || char === '}' || char === ']') this.finishDurationCapture() + else { this.scanCapture.text += char; continue } + } + if (this.scanAwaitingColon) { + if (/\s/.test(char)) continue + if (char === ':') { + this.scanCurrentKey = this.scanLastString + this.scanAwaitingColon = false + continue + } + this.scanAwaitingColon = false + } + if (char === '"') { + this.scanString = '' + if (this.scanCapture?.mode === 'object') this.scanCapture.text += char + if (this.scanCurrentKey === 'duration' && this.scanPayloadDepth === this.scanDepth) { + this.scanCapture = { mode: 'string', text: '', depth: this.scanDepth } + this.scanCurrentKey = undefined + } + this.scanInString = true + continue + } + if (char === '{' || char === '[') { + if (this.scanCurrentKey === 'payload' && char === '{' && this.scanPayloadDepth === undefined) { + this.scanPayloadDepth = this.scanDepth + 1 + } + if (this.scanCurrentKey === 'duration' && this.scanPayloadDepth === this.scanDepth) { + this.scanCapture = { mode: 'object', text: char, depth: this.scanDepth + 1 } + this.scanCurrentKey = undefined + } else if (this.scanCapture?.mode === 'object') { + this.scanCapture.text += char + } + this.scanDepth++ + continue + } + if (char === '}' || char === ']') { + if (this.scanCapture?.mode === 'object') this.scanCapture.text += char + this.scanDepth = Math.max(0, this.scanDepth - 1) + if (this.scanCapture?.mode === 'object' && this.scanDepth < this.scanCapture.depth) this.finishDurationCapture() + continue + } + if (this.scanCurrentKey === 'duration' && this.scanPayloadDepth === this.scanDepth && !/\s/.test(char)) { + this.scanCapture = { mode: 'primitive', text: char, depth: this.scanDepth } + this.scanCurrentKey = undefined + continue + } + if (this.scanCapture?.mode === 'object') this.scanCapture.text += char + } + } + + private processLine(line: string, durationOverride?: number): void { + const entry = parseLine(line) + if (!entry) return + if (durationOverride !== undefined && (line.startsWith(TRUNCATION_MARKER) || line.length > 256 * 1024) && entry.type === 'event_msg' && (entry.payload?.type === 'mcp_tool_call_end' || entry.payload?.type === 'task_complete')) { + entry.payload = { ...entry.payload, duration_ms: durationOverride } + } + const state = this.state + if (entry.type === 'session_meta') { + if (entry.payload?.model) state.model = entry.payload.model + if (entry.payload?.forked_from_id && entry.timestamp) { + const timestamp = Date.parse(entry.timestamp) + if (Number.isFinite(timestamp)) state.forkCutoffMs = timestamp + 5000 + } + return + } + if (entry.type === 'turn_context' && entry.payload?.model) state.model = entry.payload.model + const entryTimestamp = entry.timestamp ? Date.parse(entry.timestamp) : NaN + const isForkReplay = state.forkCutoffMs !== undefined && Number.isFinite(entryTimestamp) && entryTimestamp < state.forkCutoffMs + if (isForkReplay && ( + entry.payload?.type === 'task_started' || + entry.payload?.type === 'task_complete' || + entry.payload?.type === 'function_call' || + entry.payload?.type === 'function_call_output' || + entry.payload?.type === 'custom_tool_call' || + entry.payload?.type === 'custom_tool_call_output' || + entry.payload?.type === 'mcp_tool_call_end' || + entry.payload?.type === 'patch_apply_end' || + entry.payload?.type === 'token_count' + )) return + if (entry.type === 'event_msg' && entry.payload?.type === 'task_started') { + state.currentTaskGenerated = 0 + state.currentTaskToolIntervals = [] + const startedAt = entry.timestamp ? Date.parse(entry.timestamp) : NaN + state.currentTaskStartedAt = Number.isFinite(startedAt) ? startedAt : undefined + state.toolStarts.clear() + } + if (entry.type === 'response_item' && (entry.payload?.type === 'function_call' || entry.payload?.type === 'custom_tool_call') && entry.payload.call_id && entry.timestamp) { + const started = Date.parse(entry.timestamp) + if (Number.isFinite(started)) state.toolStarts.set(entry.payload.call_id, started) + } + if (entry.type === 'response_item' && (entry.payload?.type === 'function_call_output' || entry.payload?.type === 'custom_tool_call_output') && entry.payload.call_id && entry.timestamp) { + const ended = Date.parse(entry.timestamp) + const started = state.toolStarts.get(entry.payload.call_id) + if (started !== undefined && Number.isFinite(ended) && ended > started) state.currentTaskToolIntervals.push([started, ended]) + state.toolStarts.delete(entry.payload.call_id) + } + if (entry.type === 'event_msg' && entry.payload?.type === 'mcp_tool_call_end' && entry.timestamp) { + const ended = Date.parse(entry.timestamp) + const elapsed = durationMs(entry.payload) + if (Number.isFinite(ended) && elapsed !== undefined && elapsed > 0) state.currentTaskToolIntervals.push([ended - elapsed, ended]) + } + if (entry.type === 'event_msg' && entry.payload?.type === 'task_complete') { + const taskDurationMs = durationMs(entry.payload) + if (state.latestPoint && typeof taskDurationMs === 'number' && taskDurationMs > 0 && state.currentTaskGenerated > 0) { + state.latestPoint.taskGeneratedTokens = state.currentTaskGenerated + const completedAt = entry.timestamp ? Date.parse(entry.timestamp) : undefined + const toolWaitMs = mergeToolIntervals(state.currentTaskToolIntervals, taskDurationMs, state.currentTaskStartedAt, Number.isFinite(completedAt) ? completedAt : undefined) + const activeMs = taskDurationMs - toolWaitMs + if (activeMs > 0) { + state.latestPoint.activeDurationSeconds = activeMs / 1000 + state.latestPoint.toolWaitSeconds = toolWaitMs / 1000 + state.latestPoint.activeGeneratedTokensPerSecond = state.currentTaskGenerated / (activeMs / 1000) + } + } + } + if (entry.type !== 'event_msg' || entry.payload?.type !== 'token_count') return + const info = entry.payload.info + if (!info || !entry.timestamp) return + const last = info.last_token_usage + const total = info.total_token_usage + const cumulative = total?.total_tokens + if (cumulative !== undefined && cumulative === state.previousTotal) return + let outputTokens = last?.output_tokens ?? 0 + let reasoningTokens = last?.reasoning_output_tokens ?? 0 + if (!last && total && cumulative !== undefined && state.previousTotal !== undefined) { + outputTokens = Math.max(0, (total.output_tokens ?? 0) - state.previousOutput) + reasoningTokens = Math.max(0, (total.reasoning_output_tokens ?? 0) - state.previousReasoning) + } + if (cumulative !== undefined) { + state.previousTotal = cumulative + state.previousOutput = total?.output_tokens ?? state.previousOutput + state.previousReasoning = total?.reasoning_output_tokens ?? state.previousReasoning + } + const generatedTokens = outputTokens + reasoningTokens + if (generatedTokens <= 0) return + const timestampMs = Date.parse(entry.timestamp) + if (!Number.isFinite(timestampMs)) return + const point: CodexThroughputPoint = { + timestamp: entry.timestamp, + model: state.model, + outputTokens, + reasoningTokens, + generatedTokens, + } + state.currentTaskGenerated += generatedTokens + state.latestPoint = point + if (state.previousTimestamp !== undefined && timestampMs > state.previousTimestamp) { + const elapsedSeconds = (timestampMs - state.previousTimestamp) / 1000 + point.elapsedSeconds = elapsedSeconds + point.generatedTokensPerSecond = generatedTokens / elapsedSeconds + } + state.previousTimestamp = timestampMs + state.points.push(point) + if (state.points.length > 10000) state.points.splice(0, state.points.length - 10000) + } + + async update(filePath: string, limit = 10, finalize = false): Promise { + const info = await stat(filePath) + if (info.size < this.offset) this.reset() + const bytesToRead = info.size - this.offset + if (bytesToRead > 0) { + const file = await open(filePath, 'r') + try { + let position = this.offset + while (position < info.size) { + const buffer = Buffer.allocUnsafe(Math.min(CHUNK_BYTES, info.size - position)) + const { bytesRead } = await file.read(buffer, 0, buffer.length, position) + if (bytesRead === 0) break + position += bytesRead + this.offset = position + let chunk = this.decoder.write(buffer.subarray(0, bytesRead)) + while (chunk.length > 0) { + const newlineIndex = chunk.search(/\r?\n/) + const segment = newlineIndex >= 0 ? chunk.slice(0, newlineIndex) : chunk + this.pending += segment + this.scanDurationSegment(segment) + if (newlineIndex < 0) break + const newlineLength = chunk[newlineIndex] === '\r' ? 2 : 1 + const line = this.pending + const durationOverride = this.pendingDurationMs + this.pending = '' + this.pendingDurationMs = undefined + this.scanDepth = 0 + this.scanPayloadDepth = undefined + this.scanInString = false + this.scanEscape = false + this.scanString = '' + this.scanLastString = '' + this.scanAwaitingColon = false + this.scanCurrentKey = undefined + this.scanCapture = undefined + this.processLine(line, durationOverride) + chunk = chunk.slice(newlineIndex + newlineLength) + } + if (this.pending.length > MAX_PENDING_LINE_CHARS) { + const body = this.pending.startsWith(TRUNCATION_MARKER) + ? this.pending.slice(TRUNCATION_MARKER.length) + : this.pending + this.pending = TRUNCATION_MARKER + body.slice(0, 256 * 1024) + body.slice(-256 * 1024) + } + } + } finally { + await file.close() + } + } + if (finalize && this.pending) { + this.processLine(this.pending, this.pendingDurationMs) + this.pending = '' + this.pendingDurationMs = undefined + } + return limit > 0 ? this.state.points.slice(-limit) : this.state.points.slice() + } +} + +export async function readCodexThroughput(filePath: string, limit = 10): Promise { + return new CodexThroughputReader().update(filePath, limit, true) +} + +export async function newestCodexSession(sessions: Array<{ path: string }>): Promise { + let newest: { path: string; mtimeMs: number } | undefined + for (const session of sessions) { + try { + const info = await stat(session.path) + if (!newest || info.mtimeMs > newest.mtimeMs) newest = { path: session.path, mtimeMs: info.mtimeMs } + } catch { + // A session can disappear while Codex rotates or archives it. + } + } + return newest?.path +} + +export function renderCodexThroughput(points: CodexThroughputPoint[], filePath: string): string { + const latest = points.at(-1) + if (!latest) return `No token_count checkpoints found in ${filePath}.` + const lines = [ + 'CodeBurn Codex throughput estimate', + `Session: ${filePath}`, + `Latest checkpoint: ${latest.timestamp}`, + `Latest checkpoint tokens: ${latest.generatedTokens.toLocaleString()} (${latest.outputTokens.toLocaleString()} output + ${latest.reasoningTokens.toLocaleString()} reasoning)`, + ] + if (latest.taskGeneratedTokens !== undefined) lines.push(`Completed task total: ${latest.taskGeneratedTokens.toLocaleString()} generated tokens`) + if (latest.activeGeneratedTokensPerSecond !== undefined) { + lines.push(`Active throughput: ${latest.activeGeneratedTokensPerSecond.toFixed(1)} generated tokens/sec over ${latest.activeDurationSeconds!.toFixed(1)}s`) + lines.push(`Excluded tool wait: ${latest.toolWaitSeconds!.toFixed(1)}s`) + } else if (latest.generatedTokensPerSecond !== undefined) { + lines.push(`Checkpoint estimate: ${latest.generatedTokensPerSecond.toFixed(1)} generated tokens/sec over ${latest.elapsedSeconds!.toFixed(1)}s`) + } else { + lines.push('Throughput: unavailable (waiting for a completed turn)') + } + lines.push('Note: offline JSONL estimate; tool intervals are removed, but server/prompt latency may remain.') + return lines.join('\n') +} diff --git a/src/dashboard.tsx b/src/dashboard.tsx index 1d9b6d2..0fad8cd 100644 --- a/src/dashboard.tsx +++ b/src/dashboard.tsx @@ -46,7 +46,10 @@ export function showEmptyState(projectCount: number, scrollableHistory: boolean, return historyProjectCount === 0 && !historyLoading } -const MIN_WIDE = 90 +// The By Model panel now carries six numeric columns. Keep panels stacked until +// each half has enough room for those columns instead of truncating Tok/s at +// ordinary 100–120 column terminals. +const MIN_WIDE = 130 const ORANGE = '#FF8C42' const DIM = '#555555' const GOLD = '#FFD700' @@ -439,6 +442,7 @@ const MODEL_COL_COST = 8 const MODEL_COL_CACHE = 7 const MODEL_COL_CALLS = 7 const MODEL_COL_ONESHOT = 7 +const MODEL_COL_TPS = 7 const MODEL_NAME_WIDTH = 14 const MIN_EDIT_TURNS_FOR_RATE = 5 @@ -448,6 +452,7 @@ function ModelBreakdown({ projects, pw, bw }: { projects: ProjectSummary[]; pw: const modelTotals = aggregateModelTotals(projects) const modelEfficiency = aggregateModelEfficiency(projects) const anyEstimated = Object.values(modelTotals).some(d => d.estimatedCostUSD > 0) + const anyActiveTiming = Object.values(modelTotals).some(d => d.activeDurationMs > 0 && d.activeGeneratedTokens > 0) const sorted = Object.entries(modelTotals).sort(([, a], [, b]) => b.costUSD - a.costUSD) const maxCost = sorted[0]?.[1]?.costUSD ?? 0 const unpriced = findUnpricedModels(Object.entries(modelTotals).map(([model, d]) => ({ @@ -459,7 +464,7 @@ function ModelBreakdown({ projects, pw, bw }: { projects: ProjectSummary[]; pw: return ( - {''.padEnd(bw + 1 + MODEL_NAME_WIDTH)}{'cost'.padStart(MODEL_COL_COST)}{'cache'.padStart(MODEL_COL_CACHE)}{'calls'.padStart(MODEL_COL_CALLS)}{'1-shot'.padStart(MODEL_COL_ONESHOT)} + {''.padEnd(bw + 1 + MODEL_NAME_WIDTH)}{'cost'.padStart(MODEL_COL_COST)}{'cache'.padStart(MODEL_COL_CACHE)}{'calls'.padStart(MODEL_COL_CALLS)}{'1-shot'.padStart(MODEL_COL_ONESHOT)}{'Tok/s'.padStart(MODEL_COL_TPS)} {sorted.map(([model, data], i) => { const totalInput = data.freshInput + data.cacheRead + data.cacheWrite const cacheHit = totalInput > 0 ? (data.cacheRead / totalInput) * 100 : 0 @@ -468,6 +473,9 @@ function ModelBreakdown({ projects, pw, bw }: { projects: ProjectSummary[]; pw: const oneShotLabel = efficiency && efficiency.editTurns >= MIN_EDIT_TURNS_FOR_RATE && efficiency.oneShotRate !== null ? `${efficiency.oneShotRate.toFixed(1)}%` : '-' + const tpsLabel = data.activeDurationMs > 0 && data.activeGeneratedTokens > 0 + ? (data.activeGeneratedTokens / (data.activeDurationMs / 1000)).toFixed(1) + : '-' return ( @@ -476,6 +484,7 @@ function ModelBreakdown({ projects, pw, bw }: { projects: ProjectSummary[]; pw: {cacheLabel.padStart(MODEL_COL_CACHE)} {String(data.calls).padStart(MODEL_COL_CALLS)} {oneShotLabel.padStart(MODEL_COL_ONESHOT)} + {tpsLabel.padStart(MODEL_COL_TPS)} ) })} @@ -487,6 +496,9 @@ function ModelBreakdown({ projects, pw, bw }: { projects: ProjectSummary[]; pw: {anyEstimated && ( ~ estimated cost (priced from estimated tokens) )} + {anyActiveTiming && ( + ~ Tok/s: generated tokens / active time; tool wait excluded + )} ) } diff --git a/src/main.ts b/src/main.ts index ae6867c..d24789e 100644 --- a/src/main.ts +++ b/src/main.ts @@ -5,6 +5,7 @@ import { exportCsv, exportJson, type PeriodExport } from './export.js' import { findUnpricedModels, loadPricing, setModelAliases, setPriceOverrides, setLocalModelSavings, setProxyPaths, normalizeProxyPath } from './models.js' import { parseAllSessions, filterProjectsByName, filterProjectsByDateRange, clearSessionCache, setInteractiveScanUI } from './parser.js' import { allProviderNames, getAllProviders } from './providers/index.js' +import { getProvider } from './providers/index.js' import { convertCost, formatCost } from './currency.js' import { renderStatusBar } from './format.js' import { toDateString } from './daily-cache.js' @@ -46,6 +47,7 @@ import { createRequire } from 'node:module' const require = createRequire(import.meta.url) const { version } = require('../package.json') import { loadCurrency, getCurrency, isValidCurrencyCode } from './currency.js' +import { CodexThroughputReader, newestCodexSession, renderCodexThroughput } from './codex-throughput.js' // A downstream reader that closes the pipe early (`| head`, quitting `less`, or // a missing command) makes stdout writes fail with EPIPE. Exit cleanly rather @@ -68,6 +70,22 @@ function parseInteger(value: string): number { return parseInt(value, 10) } +function parseCodexTpsLimit(value: string): number { + const parsed = Number(value) + if (!Number.isFinite(parsed) || !Number.isInteger(parsed) || parsed < 1 || parsed > 10000) { + throw new Error('limit must be an integer from 1 to 10000') + } + return parsed +} + +function parseCodexTpsWatch(value: string): number { + const parsed = Number(value) + if (!Number.isFinite(parsed) || parsed < 0 || (parsed > 0 && parsed < 1) || parsed > 3600) { + throw new Error('watch must be 0 or at least 1 second (up to 3600 seconds)') + } + return parsed +} + type PriceOverrideConfig = NonNullable[string] type PriceOverrideOptions = { @@ -1843,6 +1861,94 @@ program await runContextCommand(session, opts) }) +program + .command('codex-tps [session]') + .description('Retrospective Codex generated-tokens/sec estimate from rollout checkpoints (not live decode speed)') + .option('--json', 'JSON output') + .option('--limit ', 'Number of recent checkpoints to scan', parseCodexTpsLimit, 10) + .option('--watch ', 'Refresh continuously while Codex writes checkpoints', parseCodexTpsWatch, 0) + .action(async (session: string | undefined, opts: { json?: boolean; limit: number; watch: number }) => { + const intervalMs = Math.max(0, opts.watch) * 1000 + if (opts.json && intervalMs > 0) { + process.stderr.write('codeburn codex-tps: --json cannot be combined with --watch; use text watch output or one-shot JSON.\n') + process.exitCode = 2 + return + } + const provider = await getProvider('codex') + if (!provider) { + process.stderr.write('codeburn codex-tps: Codex provider is unavailable.\n') + process.exitCode = 1 + return + } + let cachedPath: string | undefined = session + let throughputReader: CodexThroughputReader | undefined + let lastFileState: { size: number; mtimeMs: number } | undefined + let lastDiscoveryMs = 0 + let refreshInFlight = false + const render = async (): Promise => { + if (refreshInFlight) return + refreshInFlight = true + try { + let filePath = session ?? cachedPath + // Keep an idle watcher on its chosen rollout. A full active+archive + // discovery can be hundreds of milliseconds on large histories, so + // only re-scan slowly to notice rotation; disappearance still triggers + // an immediate discovery on the next tick. + if (!session && (!filePath || Date.now() - lastDiscoveryMs >= 60_000)) { + lastDiscoveryMs = Date.now() + filePath = await newestCodexSession(await provider.discoverSessions()) + } + if (!filePath) { + process.stderr.write('codeburn codex-tps: no Codex rollout sessions found.\n') + if (intervalMs === 0) process.exitCode = 1 + return + } + const previousPath = cachedPath + cachedPath = filePath + if (previousPath !== filePath || !throughputReader) throughputReader = new CodexThroughputReader() + const fileInfo = await import('node:fs/promises').then(fs => fs.stat(filePath)).catch(() => null) + if (!fileInfo) { + process.stderr.write(`codeburn codex-tps: session file not found: ${filePath}\n`) + if (intervalMs === 0) process.exitCode = 1 + if (!session) cachedPath = undefined + return + } + if (intervalMs > 0 && lastFileState && fileInfo.size === lastFileState.size && fileInfo.mtimeMs === lastFileState.mtimeMs) return + lastFileState = { size: fileInfo.size, mtimeMs: fileInfo.mtimeMs } + const points = await throughputReader!.update(filePath, opts.limit, intervalMs === 0) + if (opts.json) { + process.stdout.write(JSON.stringify({ session: filePath, points, live: intervalMs > 0 }, null, 2) + '\n') + } else { + if (intervalMs > 0) process.stdout.write('\x1b[2J\x1b[H') + process.stdout.write(renderCodexThroughput(points, filePath) + (intervalMs > 0 ? '\nWatching for new Codex checkpoints... (Ctrl-C to stop)\n' : '\n')) + } + } finally { + refreshInFlight = false + } + } + try { + await render() + } catch (error) { + const message = error instanceof Error ? error.message : String(error) + process.stderr.write(`codeburn codex-tps: refresh failed: ${message}\n`) + if (intervalMs === 0) { + process.exitCode = 1 + return + } + } + if (intervalMs > 0) { + await new Promise((resolve) => { + const timer = setInterval(() => { + void render().catch(error => { + const message = error instanceof Error ? error.message : String(error) + process.stderr.write(`codeburn codex-tps: refresh failed: ${message}\n`) + }) + }, intervalMs) + process.once('SIGINT', () => { clearInterval(timer); resolve() }) + }) + } + }) + program .command('compare') .description('Compare two AI models side-by-side') diff --git a/src/model-breakdown.ts b/src/model-breakdown.ts index 5801be8..799338e 100644 --- a/src/model-breakdown.ts +++ b/src/model-breakdown.ts @@ -8,6 +8,8 @@ export interface ModelTotals { freshInput: number cacheRead: number cacheWrite: number + activeDurationMs: number + activeGeneratedTokens: number } /// Aggregate per-model usage across every session, keyed by the friendly display @@ -24,6 +26,7 @@ export function aggregateModelTotals(projects: ProjectSummary[]): Record b[0 const toolNameMap: Record = { exec_command: 'Bash', + // Codex Desktop's custom-tool transport uses the shorter `exec` name for + // the same shell tool that CLI rollouts record as `exec_command`. + exec: 'Bash', read_file: 'Read', write_file: 'Edit', apply_diff: 'Edit', @@ -96,6 +99,11 @@ type CodexEntry = { timestamp?: string payload?: { type?: string + turn_id?: string + call_id?: string + started_at?: number + duration_ms?: number + duration?: { secs?: number; nanos?: number } | string role?: string cwd?: string model_provider?: string @@ -104,6 +112,7 @@ type CodexEntry = { forked_from_id?: string model?: string name?: string + invocation?: { server?: string; tool?: string } content?: Array<{ type?: string; text?: string }> info?: { model?: string @@ -196,11 +205,128 @@ function getRawJsonStringField(head: string, field: string): string | undefined } } +function getRawJsonNumberField(head: string, field: string): number | undefined { + const match = new RegExp(`"${field}"\\s*:\\s*(-?\\d+(?:\\.\\d+)?)`).exec(head) + if (!match) return undefined + const value = Number(match[1]) + return Number.isFinite(value) ? value : undefined +} + +function getRawPayloadFieldWindow(source: Buffer, field: string, windowBytes = 4096): string | undefined { + const payloadKey = Buffer.from('"payload"') + const payloadIndex = source.indexOf(payloadKey) + if (payloadIndex < 0) return undefined + let payloadStart = source.indexOf(0x7b, payloadIndex + payloadKey.length) // { + if (payloadStart < 0) return undefined + + let depth = 0 + let inString = false + let escaped = false + for (let i = payloadStart; i < source.length; i++) { + const byte = source[i]! + if (inString) { + if (escaped) escaped = false + else if (byte === 0x5c) escaped = true // \\ + else if (byte === 0x22) inString = false // " + continue + } + if (byte === 0x22) { + const keyStart = i + 1 + let keyEnd = keyStart + let keyEscaped = false + for (; keyEnd < source.length; keyEnd++) { + const keyByte = source[keyEnd]! + if (keyEscaped) { keyEscaped = false; continue } + if (keyByte === 0x5c) { keyEscaped = true; continue } + if (keyByte === 0x22) break + } + if (depth === 1 && keyEnd < source.length) { + const key = source.subarray(keyStart, keyEnd).toString('utf-8') + let valueStart = keyEnd + 1 + while (valueStart < source.length && (source[valueStart] === 0x20 || source[valueStart] === 0x09 || source[valueStart] === 0x0a || source[valueStart] === 0x0d)) valueStart++ + if (source[valueStart] === 0x3a && key === field) { + return source.subarray(i, Math.min(source.length, i + windowBytes)).toString('utf-8') + } + } + i = keyEnd + inString = false + continue + } + if (byte === 0x22) inString = true + else if (byte === 0x7b || byte === 0x5b) depth++ // { or [ + else if (byte === 0x7d || byte === 0x5d) depth-- // } or ] + if (depth < 0) break + } + return undefined +} + +function getRawDurationMs(head: string): number | undefined { + const objectMatch = /"duration"\s*:\s*\{\s*"secs"\s*:\s*(-?\d+(?:\.\d+)?)\s*,\s*"nanos"\s*:\s*(-?\d+(?:\.\d+)?)\s*\}/.exec(head) + if (objectMatch) { + const seconds = Number(objectMatch[1]) + const nanos = Number(objectMatch[2]) + if (Number.isFinite(seconds) && Number.isFinite(nanos)) return seconds * 1000 + nanos / 1e6 + } + const text = getRawJsonStringField(head, 'duration') + if (text) { + const match = /^(\d+(?:\.\d+)?)(ms|s)?$/.exec(text.trim()) + if (match) { + const value = Number(match[1]) + if (Number.isFinite(value)) return value * (match[2] === 's' ? 1000 : 1) + } + } + return undefined +} + +function durationValueMs(value: unknown): number | undefined { + if (typeof value === 'number' && Number.isFinite(value)) return value + if (typeof value === 'object' && value) { + const record = value as Record + const seconds = record['secs'] + const nanos = record['nanos'] + if (typeof seconds === 'number' && typeof nanos === 'number' && Number.isFinite(seconds) && Number.isFinite(nanos)) { + return seconds * 1000 + nanos / 1e6 + } + } + if (typeof value === 'string') { + const match = /^(\d+(?:\.\d+)?)(ms|s)?$/.exec(value.trim()) + if (match) { + const parsed = Number(match[1]) + if (Number.isFinite(parsed)) return parsed * (match[2] === 's' ? 1000 : 1) + } + } + return undefined +} + +function getRawTokenUsage(head: string, field: 'last_token_usage' | 'total_token_usage'): CodexTokenUsage | undefined { + const match = new RegExp(`"${field}"\\s*:\\s*\\{([^}]*)\\}`).exec(head) + if (!match) return undefined + const body = match[1]! + return { + input_tokens: getRawJsonNumberField(body, 'input_tokens'), + cached_input_tokens: getRawJsonNumberField(body, 'cached_input_tokens'), + output_tokens: getRawJsonNumberField(body, 'output_tokens'), + reasoning_output_tokens: getRawJsonNumberField(body, 'reasoning_output_tokens'), + total_tokens: getRawJsonNumberField(body, 'total_tokens'), + } +} + function payloadHead(head: string): string { const idx = head.indexOf('"payload"') return idx === -1 ? head : head.slice(idx) } +function getRawInvocation(head: string): { server?: string; tool?: string } | undefined { + const idx = head.indexOf('"invocation"') + if (idx === -1) return undefined + // Server/tool are shallow fields and precede the potentially huge arguments + // object in Codex MCP records. Limit this scan to keep compact parsing cheap. + const invocationHead = head.slice(idx, idx + 8192) + const server = getRawJsonStringField(invocationHead, 'server') + const tool = getRawJsonStringField(invocationHead, 'tool') + return server || tool ? { server, tool } : undefined +} + function countJsonStringBytes(source: Buffer, valueStart: number): number { let count = 0 for (let i = valueStart; i < source.length; i++) { @@ -270,6 +396,31 @@ function parseCodexLine(line: string | Buffer): CodexEntry | null { const pHead = payloadHead(head) const payloadType = getRawJsonStringField(pHead, 'type') const role = getRawJsonStringField(pHead, 'role') + // task_complete appends the potentially huge final assistant message before + // its duration fields. Fall back to the full Buffer only for this event so + // timing metadata is not lost when the compact head stops early. + const needsTimingTail = type === 'event_msg' && (payloadType === 'task_complete' || payloadType === 'mcp_tool_call_end') + const timingTail = needsTimingTail && line.length > RAW_HEAD_BYTES + ? line.subarray(Math.max(0, line.length - 16 * 1024)).toString('utf-8') + : pHead + const timingNumber = (field: string): number | undefined => + getRawJsonNumberField(pHead, field) ?? getRawJsonNumberField(timingTail, field) + // MCP records can place a large invocation.arguments object before duration + // and a large result after it. Searching a small window around the field + // avoids materializing the middle of the Buffer while still preserving wait + // timing for those records. + const payloadDuration = payloadType === 'mcp_tool_call_end' + ? getRawDurationMs(getRawPayloadFieldWindow(line, 'duration') ?? '') + : undefined + const timingDuration = payloadDuration ?? getRawDurationMs(pHead) ?? getRawDurationMs(timingTail) + const compactModel = getRawJsonStringField(pHead, 'model') + const compactModelName = getRawJsonStringField(pHead, 'model_name') + const compactLastUsage = getRawTokenUsage(pHead, 'last_token_usage') + const compactTotalUsage = getRawTokenUsage(pHead, 'total_token_usage') + const compactInfo = compactModel || compactModelName || compactLastUsage || compactTotalUsage + ? { model: compactModel, model_name: compactModelName, last_token_usage: compactLastUsage, total_token_usage: compactTotalUsage } + : undefined + const invocation = getRawInvocation(pHead) ?? getRawInvocation(timingTail) const entry: CodexEntry = { type, @@ -284,6 +435,12 @@ function parseCodexLine(line: string | Buffer): CodexEntry | null { forked_from_id: getRawJsonStringField(pHead, 'forked_from_id'), model: getRawJsonStringField(pHead, 'model'), name: getRawJsonStringField(pHead, 'name'), + invocation, + call_id: getRawJsonStringField(pHead, 'call_id'), + turn_id: getRawJsonStringField(pHead, 'turn_id'), + duration_ms: timingNumber('duration_ms') ?? timingDuration, + started_at: timingNumber('started_at'), + info: compactInfo, }, } @@ -296,26 +453,48 @@ function parseCodexLine(line: string | Buffer): CodexEntry | null { return entry } -async function discoverSessionFile(filePath: string): Promise { +type DiscoveredCodexSession = { + source: SessionSource + sessionId?: string +} + +async function discoverSessionFile(filePath: string): Promise { const s = await stat(filePath).catch(() => null) if (!s?.isFile()) return null const cachedProject = await getCachedCodexProject(filePath) + const { valid, meta } = await isValidCodexSession(filePath) if (cachedProject) { - return { path: filePath, project: cachedProject, provider: 'codex' } + return { + source: { path: filePath, project: cachedProject, provider: 'codex' }, + sessionId: valid ? meta?.payload?.session_id : undefined, + } } - const { valid, meta } = await isValidCodexSession(filePath) if (!valid || !meta) return null const cwd = meta.payload?.cwd ?? 'unknown' - return { path: filePath, project: sanitizeProject(cwd), provider: 'codex' } + return { + source: { path: filePath, project: sanitizeProject(cwd), provider: 'codex' }, + sessionId: meta.payload?.session_id, + } } async function discoverSessionsInDir(codexDir: string): Promise { const sources: SessionSource[] = [] + // A rollout can exist in both roots during/after archiving. The active root + // is scanned first, and session_id keeps the archived copy from resurfacing. + const seenSessionIds = new Set() const sessionsDir = join(codexDir, 'sessions') + const addSession = (discovered: DiscoveredCodexSession | null): void => { + if (!discovered) return + const sessionId = discovered.sessionId?.trim() + if (sessionId && seenSessionIds.has(sessionId)) return + if (sessionId) seenSessionIds.add(sessionId) + sources.push(discovered.source) + } + const years = await readdir(sessionsDir).catch(() => [] as string[]) for (const year of years) { @@ -336,8 +515,7 @@ async function discoverSessionsInDir(codexDir: string): Promise for (const file of files) { if (!file.startsWith('rollout-') || !file.endsWith('.jsonl')) continue const filePath = join(dayDir, file) - const source = await discoverSessionFile(filePath) - if (source) sources.push(source) + addSession(await discoverSessionFile(filePath)) } } } @@ -349,8 +527,7 @@ async function discoverSessionsInDir(codexDir: string): Promise const archivedFiles = await readdir(archivedDir).catch(() => [] as string[]) for (const file of archivedFiles) { if (!file.startsWith('rollout-') || !file.endsWith('.jsonl')) continue - const source = await discoverSessionFile(join(archivedDir, file)) - if (source) sources.push(source) + addSession(await discoverSessionFile(join(archivedDir, file))) } return sources @@ -410,6 +587,11 @@ function createParser(source: SessionSource, seenKeys: Set): SessionPars let currentTurnId = `${sessionId}:t0` let sawAnyLine = false const results: ParsedProviderCall[] = [] + let taskResultStart = 0 + let taskGeneratedTokens = 0 + let taskToolIntervals: Array<[number, number]> = [] + let taskStartedAt: number | undefined + const openToolStarts = new Map() // Stream the session file line by line. Heavy Codex sessions can exceed // 250 MB on disk; reading the entire file into a string would either hit @@ -437,7 +619,29 @@ function createParser(source: SessionSource, seenKeys: Set): SessionPars continue } - if (entry.type === 'response_item' && entry.payload?.type === 'function_call') { + const isForkReplay = Boolean(forkCutoff && entry.timestamp && entry.timestamp < forkCutoff) + if (isForkReplay && ( + entry.payload?.type === 'task_started' || + entry.payload?.type === 'task_complete' || + entry.payload?.type === 'function_call' || + entry.payload?.type === 'function_call_output' || + entry.payload?.type === 'custom_tool_call' || + entry.payload?.type === 'custom_tool_call_output' || + entry.payload?.type === 'mcp_tool_call_end' || + entry.payload?.type === 'patch_apply_end' + )) continue + + if (entry.type === 'event_msg' && entry.payload?.type === 'task_started') { + taskResultStart = results.length + taskGeneratedTokens = 0 + taskToolIntervals = [] + const startedAt = entry.timestamp ? Date.parse(entry.timestamp) : NaN + taskStartedAt = Number.isFinite(startedAt) ? startedAt : undefined + openToolStarts.clear() + continue + } + + if (entry.type === 'response_item' && (entry.payload?.type === 'function_call' || entry.payload?.type === 'custom_tool_call')) { const rawName = entry.payload.name ?? '' const mapped = toolNameMap[rawName] ?? rawName pendingTools.push(mapped) @@ -459,10 +663,53 @@ function createParser(source: SessionSource, seenKeys: Set): SessionPars pendingToolSequence.push([{ tool: mcpTool }]) } } + const callId = entry.payload.call_id + const started = entry.timestamp ? Date.parse(entry.timestamp) : NaN + if (callId && Number.isFinite(started)) openToolStarts.set(callId, started) pendingToolSequence.push([call]) continue } + if (entry.type === 'response_item' && (entry.payload?.type === 'function_call_output' || entry.payload?.type === 'custom_tool_call_output')) { + const callId = entry.payload.call_id + const ended = entry.timestamp ? Date.parse(entry.timestamp) : NaN + const started = callId ? openToolStarts.get(callId) : undefined + if (started !== undefined && Number.isFinite(ended) && ended > started) taskToolIntervals.push([started, ended]) + if (callId) openToolStarts.delete(callId) + continue + } + + if (entry.type === 'event_msg' && entry.payload?.type === 'task_complete') { + const durationMs = entry.payload.duration_ms + if (typeof durationMs === 'number' && durationMs > 0 && taskGeneratedTokens > 0 && taskResultStart < results.length) { + const completedAt = entry.timestamp ? Date.parse(entry.timestamp) : NaN + const windowStart = taskStartedAt ?? (Number.isFinite(completedAt) ? completedAt - durationMs : undefined) + const windowEnd = windowStart !== undefined ? windowStart + durationMs : undefined + const clipped = taskToolIntervals.map(([start, end]) => [ + windowStart !== undefined ? Math.max(start, windowStart) : start, + windowEnd !== undefined ? Math.min(end, windowEnd) : end, + ] as [number, number]).filter(([start, end]) => end > start) + const merged = clipped.sort((a, b) => a[0] - b[0]).reduce>((acc, interval) => { + const previous = acc.at(-1) + if (previous && interval[0] <= previous[1]) previous[1] = Math.max(previous[1], interval[1]) + else acc.push([...interval]) + return acc + }, []) + const toolWaitMs = Math.min(durationMs, merged.reduce((sum, interval) => sum + interval[1] - interval[0], 0)) + const activeMs = durationMs - toolWaitMs + if (activeMs <= 0) continue + for (let i = taskResultStart; i < results.length; i++) { + const call = results[i]! + const generated = call.outputTokens + call.reasoningTokens + if (generated <= 0) continue + call.activeGeneratedTokens = generated + call.activeDurationMs = activeMs * (generated / taskGeneratedTokens) + call.toolWaitMs = toolWaitMs * (generated / taskGeneratedTokens) + } + } + continue + } + if (entry.type === 'event_msg' && entry.payload?.type === 'patch_apply_end') { pendingTools.push('Edit') const p = entry.payload as Record @@ -490,6 +737,11 @@ function createParser(source: SessionSource, seenKeys: Set): SessionPars // attributed. Rebuild the canonical `mcp____` name the // classifier recognizes. if (entry.type === 'event_msg' && entry.payload?.type === 'mcp_tool_call_end') { + const endedAt = entry.timestamp ? Date.parse(entry.timestamp) : NaN + const durationMs = entry.payload.duration_ms ?? durationValueMs(entry.payload.duration) + if (typeof durationMs === 'number' && durationMs > 0 && Number.isFinite(endedAt)) { + taskToolIntervals.push([endedAt - durationMs, endedAt]) + } const inv = (entry.payload as Record)['invocation'] as Record | undefined const server = typeof inv?.['server'] === 'string' ? inv['server'] as string : '' const tool = typeof inv?.['tool'] === 'string' ? inv['tool'] as string : '' @@ -568,6 +820,7 @@ function createParser(source: SessionSource, seenKeys: Set): SessionPars ...(pendingLocRemoved ? { locRemoved: pendingLocRemoved } : {}), ...(pendingEditFailed ? { editFailed: pendingEditFailed } : {}), }) + taskGeneratedTokens += estOutput pendingTools = [] pendingToolSequence = [] @@ -685,6 +938,7 @@ function createParser(source: SessionSource, seenKeys: Set): SessionPars ...(pendingLocRemoved ? { locRemoved: pendingLocRemoved } : {}), ...(pendingEditFailed ? { editFailed: pendingEditFailed } : {}), }) + taskGeneratedTokens += outputTokens + reasoningTokens pendingTools = [] pendingToolSequence = [] diff --git a/src/providers/types.ts b/src/providers/types.ts index b5396a2..8f9e902 100644 --- a/src/providers/types.ts +++ b/src/providers/types.ts @@ -53,6 +53,9 @@ export type ParsedProviderCall = { // Exact provider-recorded cwd, kept separately because projectPath may later // canonicalize a linked worktree to its main repository. workingDirectory?: string + activeDurationMs?: number + activeGeneratedTokens?: number + toolWaitMs?: number } // A directory or database file that a provider's discoverSessions() scans. diff --git a/src/session-cache.ts b/src/session-cache.ts index 461dd46..2682756 100644 --- a/src/session-cache.ts +++ b/src/session-cache.ts @@ -51,6 +51,9 @@ export type CachedCall = { toolErrors?: number // Codex: count of this call's patch applications with success === false. editFailed?: number + activeDurationMs?: number + activeGeneratedTokens?: number + toolWaitMs?: number } export type CachedTurn = { @@ -213,7 +216,7 @@ export const PROVIDER_PARSE_VERSIONS: Record = { // rich-session-capture-v1: per-call LOC deltas + editFailed from // patch_apply_end. (The codex-results.json CODEX_CACHE_VERSION is bumped in // lockstep so the pre-session-cache layer re-parses too.) - codex: 'mcp-attribution-v2-est-cost-rich-capture-v1-cross-provider-pr-v1', + codex: 'mcp-attribution-v5-est-cost-active-timing-mcp-wait-rich-capture-v1-cross-provider-pr-v1', cursor: 'composer-anchored-crediting-v1-est-cost', 'cursor-agent': 'workspaceless-transcript-v1', copilot: 'cli-shutdown-cost-v1-skills', @@ -337,6 +340,9 @@ function validateCall(c: unknown): c is CachedCall { && (o['speed'] === 'standard' || o['speed'] === 'fast') && isOptionalNum(o['costUSD']) && isOptionalBool(o['isEstimated']) + && isOptionalNum(o['activeDurationMs']) + && isOptionalNum(o['activeGeneratedTokens']) + && isOptionalNum(o['toolWaitMs']) && isStringArray(o['tools']) && isStringArray(o['bashCommands']) && isStringArray(o['skills']) diff --git a/src/types.ts b/src/types.ts index d5624cf..a51ae67 100644 --- a/src/types.ts +++ b/src/types.ts @@ -153,6 +153,9 @@ export type ParsedApiCall = { /// Count of this call's tool results flagged `is_error` (Claude tool_result /// blocks). Bash stderr alone is NOT counted (warnings go there). Omitted at 0. toolErrors?: number + activeDurationMs?: number + activeGeneratedTokens?: number + toolWaitMs?: number } export type ToolCall = { @@ -268,7 +271,7 @@ export type SessionSummary = { /// from a provider that never captures branches (→ contributes nothing). /// Claude only; absent otherwise. everHadBranch?: boolean - modelBreakdown: Record + modelBreakdown: Record toolBreakdown: Record mcpBreakdown: Record bashBreakdown: Record diff --git a/tests/cli-codex-tps.test.ts b/tests/cli-codex-tps.test.ts new file mode 100644 index 0000000..4b39027 --- /dev/null +++ b/tests/cli-codex-tps.test.ts @@ -0,0 +1,46 @@ +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { spawnSync } from 'node:child_process' +import { afterEach, describe, expect, it } from 'vitest' + +const homes: string[] = [] + +afterEach(async () => { + while (homes.length) await rm(homes.pop()!, { recursive: true, force: true }) +}) + +function runCli(args: string[], home: string) { + return spawnSync(process.execPath, ['--import', 'tsx', 'src/cli.ts', ...args], { + cwd: process.cwd(), + env: { ...process.env, HOME: home, CODEX_HOME: join(home, '.codex'), TZ: 'UTC' }, + encoding: 'utf-8', + timeout: 30_000, + }) +} + +describe('codex-tps CLI validation', () => { + it('rejects sub-second watch intervals', async () => { + const home = await mkdtemp(join(tmpdir(), 'codeburn-tps-cli-')) + homes.push(home) + const result = runCli(['codex-tps', '--watch', '0.1'], home) + expect(result.status).toBe(1) + expect(result.stderr).toContain('watch must be 0 or at least 1 second') + }) + + it('rejects JSON watch output instead of concatenating invalid JSON documents', async () => { + const home = await mkdtemp(join(tmpdir(), 'codeburn-tps-cli-')) + homes.push(home) + const result = runCli(['codex-tps', '--json', '--watch', '1'], home) + expect(result.status).toBe(2) + expect(result.stderr).toContain('--json cannot be combined with --watch') + }) + + it('returns a nonzero status for a missing explicit rollout', async () => { + const home = await mkdtemp(join(tmpdir(), 'codeburn-tps-cli-')) + homes.push(home) + const result = runCli(['codex-tps', join(home, 'missing.jsonl')], home) + expect(result.status).toBe(1) + expect(result.stderr).toContain('session file not found') + }) +}) diff --git a/tests/codex-throughput.test.ts b/tests/codex-throughput.test.ts new file mode 100644 index 0000000..1e0fd4d --- /dev/null +++ b/tests/codex-throughput.test.ts @@ -0,0 +1,124 @@ +import { appendFile, mkdtemp, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { describe, expect, it } from 'vitest' +import { CodexThroughputReader, readCodexThroughput, renderCodexThroughput } from '../src/codex-throughput.js' + +describe('Codex throughput prototype', () => { + it('estimates generated tokens/sec between token_count checkpoints', async () => { + const dir = await mkdtemp(join(tmpdir(), 'codeburn-tps-')) + const path = join(dir, 'rollout.jsonl') + await writeFile(path, [ + JSON.stringify({ type: 'session_meta', timestamp: '2026-07-25T00:00:00.000Z', payload: { model: 'gpt-5.6-sol' } }), + JSON.stringify({ type: 'event_msg', timestamp: '2026-07-25T00:00:00.000Z', payload: { type: 'task_started' } }), + JSON.stringify({ type: 'response_item', timestamp: '2026-07-25T00:00:02.000Z', payload: { type: 'function_call', call_id: 'tool-1' } }), + JSON.stringify({ type: 'response_item', timestamp: '2026-07-25T00:00:05.000Z', payload: { type: 'function_call_output', call_id: 'tool-1' } }), + JSON.stringify({ type: 'event_msg', timestamp: '2026-07-25T00:00:05.000Z', payload: { type: 'mcp_tool_call_end', duration: { secs: 3, nanos: 0 } } }), + JSON.stringify({ type: 'event_msg', timestamp: '2026-07-25T00:00:10.000Z', payload: { type: 'token_count', info: { last_token_usage: { output_tokens: 80, reasoning_output_tokens: 20 }, total_token_usage: { total_tokens: 100, output_tokens: 80, reasoning_output_tokens: 20 } } } }), + JSON.stringify({ type: 'event_msg', timestamp: '2026-07-25T00:00:15.000Z', payload: { type: 'token_count', info: { last_token_usage: { output_tokens: 40, reasoning_output_tokens: 10 }, total_token_usage: { total_tokens: 150, output_tokens: 120, reasoning_output_tokens: 30 } } } }), + JSON.stringify({ type: 'event_msg', timestamp: '2026-07-25T00:00:16.000Z', payload: { type: 'task_complete', duration_ms: 10000 } }), + ].join('\n')) + + const points = await readCodexThroughput(path) + expect(points).toHaveLength(2) + expect(points[1]).toMatchObject({ generatedTokens: 50, elapsedSeconds: 5, generatedTokensPerSecond: 10, activeDurationSeconds: 7, activeGeneratedTokensPerSecond: 21.428571428571427, toolWaitSeconds: 3, model: 'gpt-5.6-sol' }) + expect(renderCodexThroughput(points, path)).toContain('21.4 generated tokens/sec') + }) + + it('parses only appended complete lines while watching a growing rollout', async () => { + const dir = await mkdtemp(join(tmpdir(), 'codeburn-tps-watch-')) + const path = join(dir, 'rollout.jsonl') + const first = JSON.stringify({ type: 'event_msg', timestamp: '2026-07-25T00:00:00.000Z', payload: { type: 'token_count', info: { last_token_usage: { output_tokens: 8, reasoning_output_tokens: 2 }, total_token_usage: { total_tokens: 10, output_tokens: 8, reasoning_output_tokens: 2 } } } }) + const second = JSON.stringify({ type: 'event_msg', timestamp: '2026-07-25T00:00:01.000Z', payload: { type: 'token_count', info: { last_token_usage: { output_tokens: 4, reasoning_output_tokens: 1 }, total_token_usage: { total_tokens: 15, output_tokens: 12, reasoning_output_tokens: 3 } } } }) + await writeFile(path, first.slice(0, 40)) + const reader = new CodexThroughputReader() + expect(await reader.update(path)).toEqual([]) + await appendFile(path, first.slice(40) + '\n' + second + '\n') + const points = await reader.update(path) + expect(points).toHaveLength(2) + expect(points[1]).toMatchObject({ generatedTokens: 5, generatedTokensPerSecond: 5 }) + }) + + it('ignores replayed pre-fork checkpoints before estimating new work', async () => { + const dir = await mkdtemp(join(tmpdir(), 'codeburn-tps-fork-')) + const path = join(dir, 'rollout.jsonl') + const line = (timestamp: string, payload: Record) => JSON.stringify({ type: 'event_msg', timestamp, payload }) + await writeFile(path, [ + JSON.stringify({ type: 'session_meta', timestamp: '2026-07-25T00:00:00.000Z', payload: { model: 'gpt-5.6-sol', forked_from_id: 'parent' } }), + line('2026-07-25T00:00:01.000Z', { type: 'task_started' }), + line('2026-07-25T00:00:02.000Z', { type: 'token_count', info: { last_token_usage: { output_tokens: 100 }, total_token_usage: { total_tokens: 100, output_tokens: 100 } } }), + line('2026-07-25T00:00:03.000Z', { type: 'task_complete', duration_ms: 1000 }), + line('2026-07-25T00:00:06.000Z', { type: 'task_started' }), + line('2026-07-25T00:00:08.000Z', { type: 'token_count', info: { last_token_usage: { output_tokens: 20 }, total_token_usage: { total_tokens: 20, output_tokens: 20 } } }), + line('2026-07-25T00:00:10.000Z', { type: 'task_complete', duration_ms: 4000 }), + ].join('\n')) + + const points = await readCodexThroughput(path) + expect(points).toHaveLength(1) + expect(points[0]).toMatchObject({ generatedTokens: 20, activeGeneratedTokensPerSecond: 5 }) + }) + + it('keeps oversized rollout lines bounded while extracting token usage', async () => { + const dir = await mkdtemp(join(tmpdir(), 'codeburn-tps-large-')) + const path = join(dir, 'rollout.jsonl') + const largeResult = JSON.stringify({ + type: 'event_msg', + timestamp: '2026-07-25T00:00:01.000Z', + payload: { + type: 'token_count', + info: { last_token_usage: { output_tokens: 12 }, total_token_usage: { total_tokens: 12, output_tokens: 12 } }, + result: 'x'.repeat(5 * 1024 * 1024), + }, + }) + await writeFile(path, largeResult) + const points = await readCodexThroughput(path) + expect(points).toHaveLength(1) + expect(points[0]?.generatedTokens).toBe(12) + }) + + it('keeps MCP duration when arguments and result surround the middle field', async () => { + const dir = await mkdtemp(join(tmpdir(), 'codeburn-tps-mcp-large-')) + const path = join(dir, 'rollout.jsonl') + const mcp = JSON.stringify({ + type: 'event_msg', timestamp: '2026-07-25T00:00:05.000Z', + payload: { + type: 'mcp_tool_call_end', + invocation: { server: 'github', tool: 'get_issue', arguments: { body: 'x'.repeat(5 * 1024 * 1024) } }, + duration: { secs: 3, nanos: 0 }, + result: { duration: '9s', text: 'x'.repeat(5 * 1024 * 1024) }, + }, + }) + await writeFile(path, [ + JSON.stringify({ type: 'session_meta', timestamp: '2026-07-25T00:00:00.000Z', payload: { model: 'gpt-5.6-sol' } }), + JSON.stringify({ type: 'event_msg', timestamp: '2026-07-25T00:00:00.000Z', payload: { type: 'task_started' } }), + mcp, + JSON.stringify({ type: 'event_msg', timestamp: '2026-07-25T00:00:08.000Z', payload: { type: 'token_count', info: { last_token_usage: { output_tokens: 100 }, total_token_usage: { total_tokens: 100, output_tokens: 100 } } } }), + JSON.stringify({ type: 'event_msg', timestamp: '2026-07-25T00:00:10.000Z', payload: { type: 'task_complete', duration_ms: 10000 } }), + ].join('\n')) + const points = await readCodexThroughput(path) + expect(points[0]).toMatchObject({ toolWaitSeconds: 3, activeDurationSeconds: 7, activeGeneratedTokensPerSecond: 100 / 7 }) + }) + + it('keeps a streamed string MCP duration when arguments and result surround the middle field', async () => { + const dir = await mkdtemp(join(tmpdir(), 'codeburn-tps-mcp-string-large-')) + const path = join(dir, 'rollout.jsonl') + const mcp = JSON.stringify({ + type: 'event_msg', timestamp: '2026-07-25T00:00:05.000Z', + payload: { + type: 'mcp_tool_call_end', + invocation: { server: 'github', tool: 'get_issue', arguments: { body: 'x'.repeat(5 * 1024 * 1024) } }, + duration: '3s', + result: { duration: '9s', text: 'x'.repeat(5 * 1024 * 1024) }, + }, + }) + await writeFile(path, [ + JSON.stringify({ type: 'session_meta', timestamp: '2026-07-25T00:00:00.000Z', payload: { model: 'gpt-5.6-sol' } }), + JSON.stringify({ type: 'event_msg', timestamp: '2026-07-25T00:00:00.000Z', payload: { type: 'task_started' } }), + mcp, + JSON.stringify({ type: 'event_msg', timestamp: '2026-07-25T00:00:08.000Z', payload: { type: 'token_count', info: { last_token_usage: { output_tokens: 100 }, total_token_usage: { total_tokens: 100, output_tokens: 100 } } } }), + JSON.stringify({ type: 'event_msg', timestamp: '2026-07-25T00:00:10.000Z', payload: { type: 'task_complete', duration_ms: 10000 } }), + ].join('\n')) + const points = await readCodexThroughput(path) + expect(points[0]).toMatchObject({ toolWaitSeconds: 3, activeDurationSeconds: 7, activeGeneratedTokensPerSecond: 100 / 7 }) + }) +}) diff --git a/tests/providers/codex.test.ts b/tests/providers/codex.test.ts index 9595e35..0b59ecb 100644 --- a/tests/providers/codex.test.ts +++ b/tests/providers/codex.test.ts @@ -160,6 +160,56 @@ describe('codex provider - session discovery', () => { }]) }) + it('deduplicates the same session_id across active and archived roots', async () => { + const sharedLines = [ + sessionMeta({ cwd: '/Users/test/shared', session_id: 'sess-shared' }), + tokenCount({ last: { input: 100, output: 50 }, total: { total: 150 } }), + ] + const activePath = await writeSession(tmpDir, '2026-04-14', 'rollout-shared.jsonl', sharedLines) + const archivedCopyPath = await writeArchivedSession(tmpDir, 'rollout-shared.jsonl', sharedLines) + const distinctPath = await writeArchivedSession(tmpDir, 'rollout-distinct.jsonl', [ + sessionMeta({ cwd: '/Users/test/distinct', session_id: 'sess-distinct' }), + tokenCount({ last: { input: 200, output: 50 }, total: { total: 250 } }), + ]) + + const provider = createCodexProvider(tmpDir) + const sessions = await provider.discoverSessions() + const paths = sessions.map(session => session.path) + + expect(sessions).toHaveLength(2) + expect(paths).toEqual(expect.arrayContaining([activePath, distinctPath])) + expect(paths).not.toContain(archivedCopyPath) + }) + + it('does not double-count usage for an archived copy while counting distinct sessions', async () => { + const sharedLines = [ + sessionMeta({ session_id: 'sess-shared' }), + tokenCount({ last: { input: 100, output: 50 }, total: { total: 150 } }), + ] + await writeSession(tmpDir, '2026-04-14', 'rollout-shared.jsonl', sharedLines) + await writeArchivedSession(tmpDir, 'rollout-shared-copy.jsonl', sharedLines) + await writeArchivedSession(tmpDir, 'rollout-distinct.jsonl', [ + sessionMeta({ session_id: 'sess-distinct' }), + tokenCount({ last: { input: 200, output: 50 }, total: { total: 250 } }), + ]) + + const provider = createCodexProvider(tmpDir) + const sessions = await provider.discoverSessions() + const seenKeys = new Set() + const calls: ParsedProviderCall[] = [] + for (const session of sessions) { + for await (const call of provider.createSessionParser(session, seenKeys).parse()) { + calls.push(call) + } + } + + expect(calls.map(call => call.sessionId).sort()).toEqual(['sess-distinct', 'sess-shared']) + expect(calls.reduce( + (total, call) => total + call.inputTokens + call.cachedInputTokens + call.outputTokens + call.reasoningTokens, + 0, + )).toBe(400) + }) + it('returns empty for non-existent directory', async () => { const provider = createCodexProvider('/nonexistent/path/that/does/not/exist') const sessions = await provider.discoverSessions() @@ -332,6 +382,89 @@ describe('codex provider - JSONL parsing', () => { expect(call.deduplicationKey).toContain('codex:') }) + it('parses large rollout lines and computes active timing for custom tool calls', async () => { + const largeTokenLine = JSON.stringify({ + type: 'event_msg', + timestamp: '2026-04-14T10:01:10Z', + payload: { + type: 'token_count', + info: { + last_token_usage: { input_tokens: 100, cached_input_tokens: 0, output_tokens: 100, reasoning_output_tokens: 20, total_tokens: 220 }, + total_token_usage: { input_tokens: 100, cached_input_tokens: 0, output_tokens: 100, reasoning_output_tokens: 20, total_tokens: 220 }, + }, + rate_limits: { filler: 'x'.repeat(40_000) }, + }, + }) + const largeCompleteLine = JSON.stringify({ + type: 'event_msg', + timestamp: '2026-04-14T10:01:11Z', + payload: { type: 'task_complete', last_agent_message: 'x'.repeat(40_000), duration_ms: 10_000 }, + }) + const filePath = await writeSession(tmpDir, '2026-04-14', 'rollout-timing.jsonl', [ + sessionMeta({ session_id: 'sess-timing', model: 'gpt-5.5' }), + JSON.stringify({ type: 'event_msg', timestamp: '2026-04-14T10:00:00Z', payload: { type: 'task_started', turn_id: 'turn-1' } }), + userMessage('run the tool'), + JSON.stringify({ type: 'response_item', timestamp: '2026-04-14T10:00:02Z', payload: { type: 'custom_tool_call', call_id: 'call-1', name: 'exec' } }), + JSON.stringify({ type: 'response_item', timestamp: '2026-04-14T10:00:05Z', payload: { type: 'custom_tool_call_output', call_id: 'call-1', output: 'done' } }), + largeTokenLine, + largeCompleteLine, + ]) + + const provider = createCodexProvider(tmpDir) + const source = { path: filePath, project: 'test', provider: 'codex' } + const calls: ParsedProviderCall[] = [] + for await (const call of provider.createSessionParser(source, new Set()).parse()) calls.push(call) + + expect(calls).toHaveLength(1) + expect(calls[0]).toMatchObject({ + outputTokens: 100, + reasoningTokens: 20, + tools: ['Bash'], + activeDurationMs: 7000, + activeGeneratedTokens: 120, + toolWaitMs: 3000, + }) + }) + + it('keeps estimated output parsing for large token lines without usage info', async () => { + // Some rollout variants put token_count metadata beyond the compact head + // or omit `info` entirely. The line must still reach the character-based + // estimate path rather than being interpreted as an empty usage object. + const largeTokenLine = JSON.stringify({ + type: 'event_msg', + timestamp: '2026-04-14T10:01:10Z', + payload: { type: 'token_count' }, + filler: 'x'.repeat(40_000), + }) + const assistantLine = JSON.stringify({ + type: 'response_item', + timestamp: '2026-04-14T10:01:05Z', + payload: { + type: 'message', + role: 'assistant', + content: [{ type: 'output_text', text: 'generated response '.repeat(100) }], + }, + }) + const filePath = await writeSession(tmpDir, '2026-04-14', 'rollout-estimated-large.jsonl', [ + sessionMeta({ session_id: 'sess-estimated-large', model: 'gpt-5.5' }), + userMessage('summarize the result'), + assistantLine, + largeTokenLine, + ]) + + const provider = createCodexProvider(tmpDir) + const source = { path: filePath, project: 'test', provider: 'codex' } + const calls: ParsedProviderCall[] = [] + for await (const call of provider.createSessionParser(source, new Set()).parse()) calls.push(call) + + expect(calls).toHaveLength(1) + expect(calls[0]).toMatchObject({ + model: 'gpt-5.5', + costIsEstimated: true, + }) + expect(calls[0]!.outputTokens).toBeGreaterThan(0) + }) + it('attributes MCP calls emitted as event_msg/mcp_tool_call_end', async () => { const filePath = await writeSession(tmpDir, '2026-04-14', 'rollout-mcp.jsonl', [ sessionMeta({ session_id: 'sess-mcp', model: 'gpt-5.5' }), @@ -356,6 +489,89 @@ describe('codex provider - JSONL parsing', () => { expect(calls[0]!.tools).toEqual(['mcp__github__get_issue']) }) + it('subtracts native MCP wait time from active timing', async () => { + const filePath = await writeSession(tmpDir, '2026-04-14', 'rollout-mcp-timing.jsonl', [ + sessionMeta({ session_id: 'sess-mcp-timing', model: 'gpt-5.5' }), + JSON.stringify({ type: 'event_msg', timestamp: '2026-04-14T10:00:00Z', payload: { type: 'task_started' } }), + userMessage('look up the issue'), + JSON.stringify({ + type: 'event_msg', + timestamp: '2026-04-14T10:00:05Z', + payload: { + type: 'mcp_tool_call_end', + call_id: 'mcp-1', + invocation: { server: 'github', tool: 'get_issue', arguments: {} }, + duration: { secs: 3, nanos: 0 }, + }, + }), + tokenCount({ + timestamp: '2026-04-14T10:00:08Z', + last: { input: 300, output: 100 }, + total: { total: 400 }, + }), + JSON.stringify({ type: 'event_msg', timestamp: '2026-04-14T10:00:10Z', payload: { type: 'task_complete', duration_ms: 10_000 } }), + ]) + + const provider = createCodexProvider(tmpDir) + const source = { path: filePath, project: 'test', provider: 'codex' } + const calls: ParsedProviderCall[] = [] + for await (const call of provider.createSessionParser(source, new Set()).parse()) calls.push(call) + + expect(calls).toHaveLength(1) + expect(calls[0]).toMatchObject({ activeDurationMs: 7000, toolWaitMs: 3000 }) + }) + + it('keeps MCP attribution on large result lines', async () => { + const largeMcpLine = JSON.stringify({ + type: 'event_msg', + timestamp: '2026-04-14T10:00:05Z', + payload: { + type: 'mcp_tool_call_end', + call_id: 'mcp-large', + invocation: { server: 'github', tool: 'get_issue', arguments: { duration: '1s', body: 'x'.repeat(100_000) } }, + duration: { secs: 3, nanos: 0 }, + result: { Ok: { content: [{ type: 'text', text: 'x'.repeat(40_000) }] } }, + }, + }) + const filePath = await writeSession(tmpDir, '2026-04-14', 'rollout-mcp-large.jsonl', [ + sessionMeta({ session_id: 'sess-mcp-large', model: 'gpt-5.5' }), + JSON.stringify({ type: 'event_msg', timestamp: '2026-04-14T10:00:00Z', payload: { type: 'task_started' } }), + userMessage('look up the issue'), + largeMcpLine, + tokenCount({ timestamp: '2026-04-14T10:00:08Z', last: { input: 300, output: 100 }, total: { total: 400 } }), + JSON.stringify({ type: 'event_msg', timestamp: '2026-04-14T10:00:10Z', payload: { type: 'task_complete', duration_ms: 10_000 } }), + ]) + + const provider = createCodexProvider(tmpDir) + const source = { path: filePath, project: 'test', provider: 'codex' } + const calls: ParsedProviderCall[] = [] + for await (const call of provider.createSessionParser(source, new Set()).parse()) calls.push(call) + + expect(calls).toHaveLength(1) + expect(calls[0]).toMatchObject({ tools: ['mcp__github__get_issue'], activeDurationMs: 7000, toolWaitMs: 3000 }) + }) + + it('omits active timing when recorded tool wait consumes the task duration', async () => { + const filePath = await writeSession(tmpDir, '2026-04-14', 'rollout-degenerate-timing.jsonl', [ + sessionMeta({ session_id: 'sess-degenerate-timing', model: 'gpt-5.5' }), + JSON.stringify({ type: 'event_msg', timestamp: '2026-04-14T10:00:00Z', payload: { type: 'task_started' } }), + userMessage('wait for the tool'), + JSON.stringify({ type: 'response_item', timestamp: '2026-04-14T10:00:00Z', payload: { type: 'custom_tool_call', call_id: 'call-1', name: 'exec' } }), + JSON.stringify({ type: 'response_item', timestamp: '2026-04-14T10:00:10Z', payload: { type: 'custom_tool_call_output', call_id: 'call-1', output: 'done' } }), + tokenCount({ timestamp: '2026-04-14T10:00:12Z', last: { input: 300, output: 100 }, total: { total: 400 } }), + JSON.stringify({ type: 'event_msg', timestamp: '2026-04-14T10:00:13Z', payload: { type: 'task_complete', duration_ms: 10_000 } }), + ]) + + const provider = createCodexProvider(tmpDir) + const source = { path: filePath, project: 'test', provider: 'codex' } + const calls: ParsedProviderCall[] = [] + for await (const call of provider.createSessionParser(source, new Set()).parse()) calls.push(call) + + expect(calls).toHaveLength(1) + expect(calls[0]!.activeDurationMs).toBeUndefined() + expect(calls[0]!.toolWaitMs).toBeUndefined() + }) + it('attributes CLI-wrapped MCP calls (mcp-cli call server tool) to MCP + Bash', async () => { const execStr = (command: string) => JSON.stringify({ type: 'response_item', From 4e9c3a1dfe41b232a4eee7bf8bef632e83bc4b11 Mon Sep 17 00:00:00 2001 From: hao Date: Sun, 26 Jul 2026 15:20:13 +0800 Subject: [PATCH 12/38] fix(menubar): clarify update failure status --- .../CodeBurnMenubar/Data/UpdateChecker.swift | 53 ++++++++++++++++++- .../Views/MenuBarContent.swift | 10 ++-- .../UpdateCheckerTests.swift | 38 +++++++++++++ 3 files changed, 97 insertions(+), 4 deletions(-) diff --git a/mac/Sources/CodeBurnMenubar/Data/UpdateChecker.swift b/mac/Sources/CodeBurnMenubar/Data/UpdateChecker.swift index 8028bde..c7d2459 100644 --- a/mac/Sources/CodeBurnMenubar/Data/UpdateChecker.swift +++ b/mac/Sources/CodeBurnMenubar/Data/UpdateChecker.swift @@ -14,6 +14,35 @@ private let maxUpdateStderrBytes = 64 * 1024 // `menubar --force`, so we refuse to run them and ask the user to upgrade the CLI first. private let minCliVersionForUpdate = "0.9.9" +enum UpdateFailureStage: Equatable { + case check + case cliUpdate + case menubarUpdate + + var badgeLabel: String { + switch self { + case .check: "Update Check Failed" + case .cliUpdate: "CLI Update Failed" + case .menubarUpdate: "Menubar Update Failed" + } + } + + var summary: String { + switch self { + case .check: "CodeBurn could not check GitHub for updates." + case .cliUpdate: "CodeBurn could not update the CLI." + case .menubarUpdate: "CodeBurn could not update the menubar app." + } + } + + var retryHelp: String { + switch self { + case .check: "Click to retry the update check." + case .cliUpdate, .menubarUpdate: "Click to retry the update." + } + } +} + private final class LockedDataBuffer: @unchecked Sendable { private let lock = NSLock() private var data = Data() @@ -38,6 +67,19 @@ final class UpdateChecker { var installedCliVersion: String? var isUpdating = false var updateError: String? + var updateFailureStage: UpdateFailureStage? + + var updateBadgeLabel: String { + if isUpdating { return "Updating..." } + return updateFailureStage?.badgeLabel ?? "Update" + } + + var updateHelpText: String { + guard let error = updateError, let stage = updateFailureStage else { + return "Update the CLI and menubar to the latest release" + } + return "\(stage.summary)\n\n\(error)\n\n\(stage.retryHelp)" + } var updateAvailable: Bool { guard let latest = latestVersion else { return false } @@ -88,6 +130,7 @@ final class UpdateChecker { func check() async { updateError = nil + updateFailureStage = nil installedCliVersion = Self.queryInstalledCliVersion() guard let url = URL(string: releasesAPI) else { return } var request = URLRequest(url: url) @@ -118,7 +161,8 @@ final class UpdateChecker { UserDefaults.standard.set(version, forKey: cachedVersionKey) if let cliVersion { UserDefaults.standard.set(cliVersion, forKey: cachedCliVersionKey) } } catch { - updateError = "Update check failed: \(error.localizedDescription)" + updateFailureStage = .check + updateError = error.localizedDescription NSLog("CodeBurn: update check failed: \(error)") } } @@ -195,9 +239,11 @@ final class UpdateChecker { if cliUpdateAvailable || cliTooOldForUpdate { isUpdating = true updateError = nil + updateFailureStage = nil let cliPath = CodeburnCLI.baseArgv().first ?? "" guard let argv = Self.cliUpdateInvocation(cliPath: cliPath), let bin = argv.first else { isUpdating = false + updateFailureStage = .cliUpdate updateError = "Could not find the package manager for \(cliPath.isEmpty ? "the CLI" : cliPath). Run \u{201C}\(cliUpdateCommand)\u{201D} manually, then try again." return } @@ -209,6 +255,7 @@ final class UpdateChecker { guard let self else { return } if status != 0 { self.isUpdating = false + self.updateFailureStage = .cliUpdate self.updateError = stderr.isEmpty ? "CLI update failed (exit \(status))" : stderr NSLog("CodeBurn: CLI update failed (exit \(status)): \(stderr)") return @@ -263,11 +310,13 @@ final class UpdateChecker { func performUpdate() { installedCliVersion = Self.queryInstalledCliVersion() if cliTooOldForUpdate { + updateFailureStage = .menubarUpdate updateError = "Your codeburn CLI (\(AppVersion.display(installedCliVersion ?? ""))) is too old to update the menubar. Run “\(cliUpdateCommand)” first, then try again." return } isUpdating = true updateError = nil + updateFailureStage = nil let process = CodeburnCLI.makeProcess(subcommand: ["menubar", "--force"]) let errPipe = Pipe() @@ -297,6 +346,7 @@ final class UpdateChecker { guard let self else { return } self.isUpdating = false if proc.terminationStatus != 0 { + self.updateFailureStage = .menubarUpdate self.updateError = stderr.isEmpty ? "Update failed (exit \(proc.terminationStatus))" : stderr NSLog("CodeBurn: update failed (exit \(proc.terminationStatus)): \(stderr)") } else { @@ -309,6 +359,7 @@ final class UpdateChecker { try process.run() } catch { isUpdating = false + updateFailureStage = .menubarUpdate updateError = error.localizedDescription NSLog("CodeBurn: update spawn failed: \(error)") } diff --git a/mac/Sources/CodeBurnMenubar/Views/MenuBarContent.swift b/mac/Sources/CodeBurnMenubar/Views/MenuBarContent.swift index d92bd74..9257b2f 100644 --- a/mac/Sources/CodeBurnMenubar/Views/MenuBarContent.swift +++ b/mac/Sources/CodeBurnMenubar/Views/MenuBarContent.swift @@ -520,7 +520,9 @@ private struct UpdateBadge: View { var body: some View { Button { - if updateChecker.updateAvailable || updateChecker.cliUpdateAvailable { + if updateChecker.updateFailureStage == .check { + Task { await updateChecker.check() } + } else if updateChecker.updateAvailable || updateChecker.cliUpdateAvailable { updateChecker.performFullUpdate() } else { Task { await updateChecker.check() } @@ -538,7 +540,7 @@ private struct UpdateBadge: View { Image(systemName: "arrow.down.circle.fill") .font(.system(size: 10)) } - Text(updateChecker.isUpdating ? "Updating..." : (updateChecker.updateError == nil ? "Update" : "Failed")) + Text(updateChecker.updateBadgeLabel) .font(.system(size: 10, weight: .medium)) } .padding(.horizontal, 8) @@ -548,7 +550,9 @@ private struct UpdateBadge: View { .tint(Theme.brandAccent) .controlSize(.mini) .disabled(updateChecker.isUpdating) - .help(updateChecker.updateError ?? "Update the CLI and menubar to the latest release") + .help(updateChecker.updateHelpText) + .accessibilityLabel(updateChecker.updateBadgeLabel) + .accessibilityHint(updateChecker.updateHelpText) } } diff --git a/mac/Tests/CodeBurnMenubarTests/UpdateCheckerTests.swift b/mac/Tests/CodeBurnMenubarTests/UpdateCheckerTests.swift index 6c4a99f..1abf5f0 100644 --- a/mac/Tests/CodeBurnMenubarTests/UpdateCheckerTests.swift +++ b/mac/Tests/CodeBurnMenubarTests/UpdateCheckerTests.swift @@ -59,6 +59,44 @@ struct UpdateCheckerTests { } } +@Suite("Update failure presentation") +@MainActor +struct UpdateFailurePresentationTests { + @Test("identifies an automatic update check failure") + func updateCheckFailure() { + let checker = UpdateChecker() + checker.updateFailureStage = .check + checker.updateError = "GitHub returned HTTP 403." + + #expect(checker.updateBadgeLabel == "Update Check Failed") + #expect(checker.updateHelpText.contains("could not check GitHub for updates")) + #expect(checker.updateHelpText.contains("GitHub returned HTTP 403.")) + #expect(checker.updateHelpText.contains("retry the update check")) + } + + @Test("distinguishes CLI update failures") + func cliUpdateFailure() { + let checker = UpdateChecker() + checker.updateFailureStage = .cliUpdate + checker.updateError = "npm exited with status 1." + + #expect(checker.updateBadgeLabel == "CLI Update Failed") + #expect(checker.updateHelpText.contains("could not update the CLI")) + #expect(checker.updateHelpText.contains("npm exited with status 1.")) + } + + @Test("distinguishes menubar update failures") + func menubarUpdateFailure() { + let checker = UpdateChecker() + checker.updateFailureStage = .menubarUpdate + checker.updateError = "Checksum mismatch." + + #expect(checker.updateBadgeLabel == "Menubar Update Failed") + #expect(checker.updateHelpText.contains("could not update the menubar app")) + #expect(checker.updateHelpText.contains("Checksum mismatch.")) + } +} + // MARK: - one-click full update: package-manager resolution From 494136a3e8f9813c1cb6289b38be80c332874434 Mon Sep 17 00:00:00 2001 From: iamtoruk Date: Sun, 26 Jul 2026 06:35:13 -0700 Subject: [PATCH 13/38] fix(menubar): keep showing Kimi quota when the login is idle, stamp stale data Kimi Code tokens live ~15 min and only the CLI renews them, so the load state sits in .terminalFailure as its dominant steady state between CLI uses. The Plan tab and tab-strip chip flapped to a reconnect screen every cycle even with a good last snapshot on hand. Extract the display decision into a pure KimiQuotaPresentation helper: terminal-with-data shows the usage bars (flagged idle) and only the no-data case falls through to reconnect. loadedBody stamps an 'as of