From c782907de8a338fb73c3cd986c91e096565c91cb Mon Sep 17 00:00:00 2001
From: brunoleemann-code
Date: Wed, 6 May 2026 18:40:03 +0200
Subject: [PATCH 01/28] Fix cli-plan test failing on Windows
os.homedir() on Windows uses USERPROFILE, not HOME. Set both env vars
in the test so the temp dir is respected as the home directory on all
platforms.
---
tests/cli-plan.test.ts | 3 +++
1 file changed, 3 insertions(+)
diff --git a/tests/cli-plan.test.ts b/tests/cli-plan.test.ts
index b146f2a..1430eb7 100644
--- a/tests/cli-plan.test.ts
+++ b/tests/cli-plan.test.ts
@@ -11,6 +11,9 @@ function runCli(args: string[], home: string) {
env: {
...process.env,
HOME: home,
+ USERPROFILE: home, // os.homedir() uses USERPROFILE on Windows
+ HOMEPATH: home,
+ HOMEDRIVE: '',
},
encoding: 'utf-8',
})
From f058f36dbd67c49ce8da711307ef3c91fcb37fe1 Mon Sep 17 00:00:00 2001
From: iamtoruk
Date: Mon, 11 May 2026 11:21:39 -0700
Subject: [PATCH 02/28] Normalize menubar version display
---
mac/Scripts/package-app.sh | 8 ++--
mac/Sources/CodeBurnMenubar/AppVersion.swift | 43 +++++++++++++++++++
mac/Sources/CodeBurnMenubar/CodeBurnApp.swift | 4 +-
.../CodeBurnMenubar/Data/UpdateChecker.swift | 6 +--
.../Views/MenuBarContent.swift | 2 +-
.../CodeBurnMenubar/Views/SettingsView.swift | 6 +--
.../AppVersionTests.swift | 19 ++++++++
7 files changed, 75 insertions(+), 13 deletions(-)
create mode 100644 mac/Sources/CodeBurnMenubar/AppVersion.swift
create mode 100644 mac/Tests/CodeBurnMenubarTests/AppVersionTests.swift
diff --git a/mac/Scripts/package-app.sh b/mac/Scripts/package-app.sh
index ee0dc06..6df7abb 100755
--- a/mac/Scripts/package-app.sh
+++ b/mac/Scripts/package-app.sh
@@ -9,6 +9,8 @@
set -euo pipefail
VERSION="${1:-dev}"
+ASSET_VERSION="${VERSION#mac-}"
+BUNDLE_VERSION="${ASSET_VERSION#v}"
BUNDLE_NAME="CodeBurnMenubar.app"
BUNDLE_ID="org.agentseal.codeburn-menubar"
EXECUTABLE_NAME="CodeBurnMenubar"
@@ -66,9 +68,9 @@ cat > "${BUNDLE}/Contents/Info.plist" <CFBundlePackageType
APPL
CFBundleShortVersionString
- ${VERSION}
+ ${BUNDLE_VERSION}
CFBundleVersion
- ${VERSION}
+ ${BUNDLE_VERSION}
LSMinimumSystemVersion
${MIN_MACOS}
LSUIElement
@@ -93,7 +95,7 @@ echo "▸ Ad-hoc signing..."
codesign --force --sign - --timestamp=none --deep "${BUNDLE}" 2>/dev/null || true
codesign --verify --deep --strict "${BUNDLE}" 2>/dev/null || echo " (signature verify skipped)"
-ZIP_NAME="CodeBurnMenubar-${VERSION}.zip"
+ZIP_NAME="CodeBurnMenubar-${ASSET_VERSION}.zip"
ZIP_PATH="${DIST_DIR}/${ZIP_NAME}"
echo "▸ Packaging ${ZIP_NAME}..."
(cd "${DIST_DIR}" && COPYFILE_DISABLE=1 /usr/bin/ditto -c -k --norsrc --keepParent "${BUNDLE_NAME}" "${ZIP_NAME}")
diff --git a/mac/Sources/CodeBurnMenubar/AppVersion.swift b/mac/Sources/CodeBurnMenubar/AppVersion.swift
new file mode 100644
index 0000000..c5ee14a
--- /dev/null
+++ b/mac/Sources/CodeBurnMenubar/AppVersion.swift
@@ -0,0 +1,43 @@
+import Foundation
+
+enum AppVersion {
+ static var bundleShortVersion: String {
+ Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? ""
+ }
+
+ static var bundleBuildVersion: String {
+ Bundle.main.infoDictionary?["CFBundleVersion"] as? String ?? ""
+ }
+
+ static var normalizedBundleShortVersion: String {
+ normalize(bundleShortVersion)
+ }
+
+ static var normalizedBundleBuildVersion: String {
+ normalize(bundleBuildVersion)
+ }
+
+ static var displayBundleShortVersion: String {
+ display(bundleShortVersion)
+ }
+
+ static func normalize(_ version: String) -> String {
+ let trimmed = version.trimmingCharacters(in: .whitespacesAndNewlines)
+ if trimmed.lowercased().hasPrefix("mac-v") {
+ return String(trimmed.dropFirst(5))
+ }
+ if trimmed.lowercased().hasPrefix("v") {
+ return String(trimmed.dropFirst())
+ }
+ return trimmed
+ }
+
+ static func display(_ version: String) -> String {
+ let normalized = normalize(version)
+ guard !normalized.isEmpty else { return "v?" }
+ if normalized == "?" || normalized == "dev" || normalized == "dev-preview" || normalized == "—" {
+ return normalized
+ }
+ return "v\(normalized)"
+ }
+}
diff --git a/mac/Sources/CodeBurnMenubar/CodeBurnApp.swift b/mac/Sources/CodeBurnMenubar/CodeBurnApp.swift
index a58d044..851ad43 100644
--- a/mac/Sources/CodeBurnMenubar/CodeBurnApp.swift
+++ b/mac/Sources/CodeBurnMenubar/CodeBurnApp.swift
@@ -705,10 +705,10 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate {
alert.icon = codeburnAlertIcon()
if updateChecker.updateAvailable, let latest = updateChecker.latestVersion {
alert.messageText = "Update Available"
- alert.informativeText = "v\(latest) is available (you have v\(updateChecker.currentVersion)). Run:\n\ncodeburn menubar --force"
+ alert.informativeText = "\(AppVersion.display(latest)) is available (you have \(AppVersion.display(updateChecker.currentVersion))). Run:\n\ncodeburn menubar --force"
} else {
alert.messageText = "Up to Date"
- alert.informativeText = "You're on the latest version (v\(updateChecker.currentVersion))."
+ alert.informativeText = "You're on the latest version (\(AppVersion.display(updateChecker.currentVersion)))."
}
alert.alertStyle = .informational
alert.addButton(withTitle: "OK")
diff --git a/mac/Sources/CodeBurnMenubar/Data/UpdateChecker.swift b/mac/Sources/CodeBurnMenubar/Data/UpdateChecker.swift
index ce575b6..955718f 100644
--- a/mac/Sources/CodeBurnMenubar/Data/UpdateChecker.swift
+++ b/mac/Sources/CodeBurnMenubar/Data/UpdateChecker.swift
@@ -16,14 +16,14 @@ final class UpdateChecker {
var updateAvailable: Bool {
guard let latest = latestVersion else { return false }
let current = currentVersion
- let normalizedLatest = latest.hasPrefix("v") ? String(latest.dropFirst()) : latest
- let normalizedCurrent = current.hasPrefix("v") ? String(current.dropFirst()) : current
+ let normalizedLatest = AppVersion.normalize(latest)
+ let normalizedCurrent = AppVersion.normalize(current)
guard !normalizedCurrent.isEmpty && normalizedCurrent != "dev" else { return false }
return normalizedLatest.compare(normalizedCurrent, options: .numeric) == .orderedDescending
}
var currentVersion: String {
- Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? ""
+ AppVersion.normalizedBundleShortVersion
}
func checkIfNeeded() async {
diff --git a/mac/Sources/CodeBurnMenubar/Views/MenuBarContent.swift b/mac/Sources/CodeBurnMenubar/Views/MenuBarContent.swift
index fbf3dd9..6a38b1c 100644
--- a/mac/Sources/CodeBurnMenubar/Views/MenuBarContent.swift
+++ b/mac/Sources/CodeBurnMenubar/Views/MenuBarContent.swift
@@ -567,7 +567,7 @@ struct FooterBar: View {
Spacer()
- Text("v\(Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "?")")
+ Text(AppVersion.displayBundleShortVersion)
.font(.system(size: 10, weight: .regular, design: .monospaced))
.foregroundStyle(.tertiary)
diff --git a/mac/Sources/CodeBurnMenubar/Views/SettingsView.swift b/mac/Sources/CodeBurnMenubar/Views/SettingsView.swift
index a4c3585..a317380 100644
--- a/mac/Sources/CodeBurnMenubar/Views/SettingsView.swift
+++ b/mac/Sources/CodeBurnMenubar/Views/SettingsView.swift
@@ -337,10 +337,8 @@ private struct CodexConnectionRow: View {
// MARK: - About
private struct AboutSettingsTab: View {
- private let appVersion: String =
- (Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String) ?? "—"
- private let buildVersion: String =
- (Bundle.main.infoDictionary?["CFBundleVersion"] as? String) ?? "—"
+ private let appVersion: String = AppVersion.normalizedBundleShortVersion
+ private let buildVersion: String = AppVersion.normalizedBundleBuildVersion
var body: some View {
VStack(spacing: 14) {
diff --git a/mac/Tests/CodeBurnMenubarTests/AppVersionTests.swift b/mac/Tests/CodeBurnMenubarTests/AppVersionTests.swift
new file mode 100644
index 0000000..898f5e0
--- /dev/null
+++ b/mac/Tests/CodeBurnMenubarTests/AppVersionTests.swift
@@ -0,0 +1,19 @@
+import Testing
+@testable import CodeBurnMenubar
+
+@Suite("AppVersion")
+struct AppVersionTests {
+ @Test("display avoids duplicate v prefix")
+ func displayAvoidsDuplicatePrefix() {
+ #expect(AppVersion.display("0.9.8") == "v0.9.8")
+ #expect(AppVersion.display("v0.9.8") == "v0.9.8")
+ #expect(AppVersion.display("mac-v0.9.8") == "v0.9.8")
+ }
+
+ @Test("bundle metadata stores unprefixed semver")
+ func normalizeBundleVersion() {
+ #expect(AppVersion.normalize("v0.9.8") == "0.9.8")
+ #expect(AppVersion.normalize("mac-v0.9.8") == "0.9.8")
+ #expect(AppVersion.normalize("dev") == "dev")
+ }
+}
From 4737bfb1fa8b4513263f112a2c0c13e48245d302 Mon Sep 17 00:00:00 2001
From: iamtoruk
Date: Mon, 11 May 2026 20:03:27 -0700
Subject: [PATCH 03/28] Contribution rules: require real-data testing for new
providers, one PR at a time
---
.github/PULL_REQUEST_TEMPLATE.md | 18 ++++++++++++++++++
CONTRIBUTING.md | 17 +++++++++++++++++
2 files changed, 35 insertions(+)
create mode 100644 .github/PULL_REQUEST_TEMPLATE.md
diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md
new file mode 100644
index 0000000..9af748a
--- /dev/null
+++ b/.github/PULL_REQUEST_TEMPLATE.md
@@ -0,0 +1,18 @@
+## Summary
+
+
+
+## Testing
+
+- [ ] I have tested this locally against real data (not just unit tests)
+- [ ] `npm test` passes
+- [ ] `npm run build` succeeds
+
+### For new providers only:
+
+- [ ] I installed the tool and generated real sessions by using it
+- [ ] `npm run dev -- today` shows correct costs and session counts for this provider
+- [ ] `npm run dev -- models --provider ` shows correct model names and pricing
+- [ ] Screenshot or terminal output attached below proving it works with real data
+
+
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index 84b21f4..aebe0f2 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -84,6 +84,23 @@ The `.github/workflows/block-claude-coauthor.yml` workflow rejects any PR whose
If a flagged PR rejects on this check, the workflow prints the exact rebase command to fix it.
+## Before You Start
+
+**Comment on the issue first.** Before writing code for a feature or new provider, leave a comment on the relevant issue saying what you plan to do. Wait for a maintainer to confirm the approach. Unsolicited PRs that duplicate work already in progress or take an incompatible approach will be closed.
+
+**One PR at a time.** We will not review a second PR from you until the first is merged or closed. This keeps the review queue manageable and ensures each contribution gets proper attention.
+
+## Adding a New Provider
+
+New providers have the highest bar because broken parsing silently produces wrong data for users. Before opening a PR:
+
+1. **Install the tool and use it.** Generate real sessions by actually coding with the provider. We do this ourselves for every provider we ship.
+2. **Test against real data.** Run `npm run dev -- today` and `npm run dev -- models` with your real sessions and confirm the output looks correct — costs are non-zero, model names resolve, session counts match what you see in the tool.
+3. **Include proof in the PR.** Attach a screenshot or terminal output showing codeburn correctly parsing your real sessions. PRs for new providers without evidence of local testing will not be reviewed.
+4. **Do not rely on AI-generated guesses about storage paths or schemas.** Tools change their data formats between versions. The only way to know the current schema is to install the tool and inspect the actual files on disk.
+
+PRs that add a provider based solely on online documentation or AI-generated code, without evidence of testing against real data, will be closed.
+
## Pull Requests
1. Fork or branch from `main`.
From b4b28becc831adc8fab72d9d40a6eb14d32adb31 Mon Sep 17 00:00:00 2001
From: iamtoruk
Date: Mon, 11 May 2026 20:44:06 -0700
Subject: [PATCH 04/28] Harden menubar refresh recovery
---
mac/Sources/CodeBurnMenubar/AppStore.swift | 28 ++++++
mac/Sources/CodeBurnMenubar/CodeBurnApp.swift | 89 +++++++++++++------
src/parser.ts | 50 ++++++++---
src/providers/opencode.ts | 5 +-
src/sqlite.ts | 24 +++++
tests/blob-to-text.test.ts | 16 +++-
6 files changed, 170 insertions(+), 42 deletions(-)
diff --git a/mac/Sources/CodeBurnMenubar/AppStore.swift b/mac/Sources/CodeBurnMenubar/AppStore.swift
index 38d370a..2757da6 100644
--- a/mac/Sources/CodeBurnMenubar/AppStore.swift
+++ b/mac/Sources/CodeBurnMenubar/AppStore.swift
@@ -51,6 +51,7 @@ final class AppStore {
private var cache: [PayloadCacheKey: CachedPayload] = [:]
private var cacheDate: String = ""
private var switchTask: Task?
+ private var payloadRefreshGeneration: UInt64 = 0
/// Tracks the last successful fetch timestamp per key for stuck-loading
/// diagnostics. NOT used for cache-freshness logic — `CachedPayload.fetchedAt`
/// is authoritative there. This map persists across cache wipes (day
@@ -87,6 +88,20 @@ final class AppStore {
cache[currentKey] != nil
}
+ var hasStaleLoading: Bool {
+ let now = Date()
+ return loadingStartedAtByKey.values.contains {
+ now.timeIntervalSince($0) > loadingWatchdogSeconds
+ }
+ }
+
+ var needsInteractivePayloadRefresh: Bool {
+ let todayKey = PayloadCacheKey(period: .today, provider: .all)
+ return cache[currentKey]?.isFresh != true ||
+ cache[todayKey]?.isFresh != true ||
+ hasStaleLoading
+ }
+
/// True if any cached payload reports at least one provider. Used to keep the
/// AgentTabStrip visible across period/provider switches even when the current
/// key's payload is briefly empty (e.g. immediately after a `switchTo` and
@@ -135,6 +150,7 @@ final class AppStore {
private var inFlightKeys: Set = []
func resetLoadingState() {
+ payloadRefreshGeneration &+= 1
loadingCountsByKey.removeAll()
loadingStartedAtByKey.removeAll()
inFlightKeys.removeAll()
@@ -161,6 +177,7 @@ final class AppStore {
}
guard !staleEntries.isEmpty else { return false }
+ payloadRefreshGeneration &+= 1
for (key, started) in staleEntries {
NSLog("CodeBurn: loading stuck for %ds on %@/%@ — auto-clearing",
Int(now.timeIntervalSince(started)), key.period.rawValue, key.provider.rawValue)
@@ -209,6 +226,7 @@ final class AppStore {
invalidateStaleDayCache()
let key = currentKey
let cacheDateAtStart = cacheDate
+ let generationAtStart = payloadRefreshGeneration
if !force, cache[key]?.isFresh == true { return }
if !force, inFlightKeys.contains(key) { return }
inFlightKeys.insert(key)
@@ -237,6 +255,10 @@ final class AppStore {
}
do {
let fresh = try await DataClient.fetch(period: key.period, provider: key.provider, includeOptimize: includeOptimize)
+ if generationAtStart != payloadRefreshGeneration {
+ NSLog("CodeBurn: dropping fetch result for \(key.period.rawValue)/\(key.provider.rawValue) — refresh pipeline reset mid-fetch")
+ return
+ }
if Task.isCancelled {
// Distinguish cancellation (user switched tabs mid-fetch) from
// the silent-no-result path. Without this log, a cancelled
@@ -263,6 +285,7 @@ final class AppStore {
do {
let fallback = try await DataClient.fetch(period: key.period, provider: key.provider, includeOptimize: false)
guard !Task.isCancelled else { return }
+ if generationAtStart != payloadRefreshGeneration { return }
if cacheDate != cacheDateAtStart { return }
cache[key] = CachedPayload(payload: fallback, fetchedAt: Date())
lastSuccessByKey[key] = Date()
@@ -288,8 +311,13 @@ final class AppStore {
func refreshQuietly(period: Period) async {
invalidateStaleDayCache()
let cacheDateAtStart = cacheDate
+ let generationAtStart = payloadRefreshGeneration
do {
let fresh = try await DataClient.fetch(period: period, provider: .all, includeOptimize: false)
+ if generationAtStart != payloadRefreshGeneration {
+ NSLog("CodeBurn: dropping quiet fetch result for \(period.rawValue) — refresh pipeline reset mid-fetch")
+ return
+ }
// Same day-rollover guard as refresh(): drop yesterday's payload if
// the calendar rolled over during the fetch.
if cacheDate != cacheDateAtStart { return }
diff --git a/mac/Sources/CodeBurnMenubar/CodeBurnApp.swift b/mac/Sources/CodeBurnMenubar/CodeBurnApp.swift
index 851ad43..0c7a76d 100644
--- a/mac/Sources/CodeBurnMenubar/CodeBurnApp.swift
+++ b/mac/Sources/CodeBurnMenubar/CodeBurnApp.swift
@@ -6,6 +6,8 @@ private let refreshIntervalSeconds: UInt64 = 30
private let nanosPerSecond: UInt64 = 1_000_000_000
private let refreshIntervalNanos: UInt64 = refreshIntervalSeconds * nanosPerSecond
private let forceRefreshWatchdogSeconds: TimeInterval = 90
+private let refreshLoopWatchdogSeconds: TimeInterval = 90
+private let refreshRateLimitSeconds: TimeInterval = 5
private let interactiveQuotaRefreshFloorSeconds: TimeInterval = 30
private let statusItemWidth: CGFloat = NSStatusItem.variableLength
private let popoverWidth: CGFloat = 360
@@ -42,6 +44,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate {
private var forceRefreshGeneration: UInt64 = 0
private var manualRefreshTask: Task?
private var manualRefreshGeneration: UInt64 = 0
+ private var refreshLoopHeartbeatAt: Date = .distantPast
func applicationWillFinishLaunching(_ notification: Notification) {
// Set accessory policy before the app's focus chain forms. On macOS Tahoe
@@ -94,30 +97,21 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate {
queue: .main
) { [weak self] _ in
Task { @MainActor in
- self?.forceRefreshTask?.cancel()
- self?.forceRefreshTask = nil
- self?.forceRefreshStartedAt = nil
- self?.forceRefreshGeneration &+= 1
- self?.manualRefreshTask?.cancel()
- self?.manualRefreshTask = nil
- self?.manualRefreshGeneration &+= 1
- self?.store.resetLoadingState()
- self?.refreshLoopTask?.cancel()
- self?.refreshLoopTask = nil
+ self?.prepareRefreshPipelineForSleep()
}
}
// didWakeNotification + screensDidWakeNotification can both fire on
- // the same wake. forceRefresh has a 5-second rate-limit gate so the
- // duplicate is squashed there. Restart the refresh loop too, since
- // we cancelled it on willSleep.
+ // the same wake. forceRefreshTask squashes overlap; both notifications
+ // still bypass the short manual-click rate limit so a just-before-sleep
+ // refresh cannot block wake recovery.
NSWorkspace.shared.notificationCenter.addObserver(
forName: NSWorkspace.didWakeNotification,
object: nil,
queue: .main
) { [weak self] _ in
Task { @MainActor in
- self?.recoverRefreshPipelineAfterInterruption(resetLoading: true)
+ self?.recoverRefreshPipelineAfterInterruption(resetLoading: true, reason: "wake")
}
}
@@ -127,7 +121,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate {
queue: .main
) { [weak self] _ in
Task { @MainActor in
- self?.recoverRefreshPipelineAfterInterruption(resetLoading: true)
+ self?.recoverRefreshPipelineAfterInterruption(resetLoading: true, reason: "screen wake")
}
}
}
@@ -139,21 +133,50 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate {
queue: .main
) { [weak self] _ in
Task { @MainActor in
- self?.recoverRefreshPipelineAfterInterruption(resetLoading: false)
+ self?.recoverRefreshPipelineAfterInterruption(resetLoading: false, reason: "launch agent")
}
}
}
- private func recoverRefreshPipelineAfterInterruption(resetLoading: Bool) {
+ private func prepareRefreshPipelineForSleep() {
+ forceRefreshTask?.cancel()
+ forceRefreshTask = nil
+ forceRefreshStartedAt = nil
+ forceRefreshGeneration &+= 1
+ manualRefreshTask?.cancel()
+ manualRefreshTask = nil
+ manualRefreshGeneration &+= 1
+ store.resetLoadingState()
+ refreshLoopTask?.cancel()
+ refreshLoopTask = nil
+ refreshLoopHeartbeatAt = .distantPast
+ lastRefreshTime = .distantPast
+ }
+
+ private func recoverRefreshPipelineAfterInterruption(resetLoading: Bool, clearCache: Bool = false, reason: String) {
if resetLoading {
- store.resetLoadingState()
+ forceRefreshTask?.cancel()
+ forceRefreshTask = nil
+ forceRefreshStartedAt = nil
+ forceRefreshGeneration &+= 1
+ manualRefreshTask?.cancel()
+ manualRefreshTask = nil
+ manualRefreshGeneration &+= 1
+ store.resetRefreshState(clearCache: clearCache)
} else {
_ = store.clearStaleLoadingIfNeeded()
}
- if refreshLoopTask == nil {
+ let now = Date()
+ let loopAge = now.timeIntervalSince(refreshLoopHeartbeatAt)
+ if refreshLoopTask == nil || loopAge > refreshLoopWatchdogSeconds {
+ if refreshLoopTask != nil {
+ NSLog("CodeBurn: refresh loop stale for %ds after %@ — restarting", Int(loopAge), reason)
+ }
+ refreshLoopTask?.cancel()
+ refreshLoopTask = nil
startRefreshLoop()
}
- forceRefresh()
+ forceRefresh(bypassRateLimit: true, forceQuota: true)
}
private func installLaunchAgentIfNeeded() {
@@ -250,11 +273,13 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate {
return false
}
- private func forceRefresh() {
+ private func forceRefresh(bypassRateLimit: Bool = false, forceQuota: Bool = false) {
let now = Date()
_ = clearStaleForceRefreshIfNeeded(now: now)
guard forceRefreshTask == nil else { return }
- guard now.timeIntervalSince(lastRefreshTime) > 5 else { return }
+ if !bypassRateLimit {
+ guard now.timeIntervalSince(lastRefreshTime) > refreshRateLimitSeconds else { return }
+ }
lastRefreshTime = now
forceRefreshStartedAt = now
forceRefreshGeneration &+= 1
@@ -262,9 +287,11 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate {
forceRefreshTask = Task {
async let main: Void = store.refresh(includeOptimize: false, force: true, showLoading: true)
- async let today: Void = store.refreshQuietly(period: .today)
- async let quotas: Bool = refreshLiveQuotaProgressIfDue()
- _ = await (main, today, quotas)
+ async let quotas: Bool = refreshLiveQuotaProgressIfDue(force: forceQuota)
+ if store.selectedPeriod != .today || store.selectedProvider != .all {
+ await store.refreshQuietly(period: .today)
+ }
+ _ = await main
refreshStatusButton()
await MainActor.run { [weak self] in
guard let self, self.forceRefreshGeneration == generation else { return }
@@ -272,6 +299,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate {
self.forceRefreshStartedAt = nil
self.lastRefreshTime = Date()
}
+ _ = await quotas
}
}
@@ -344,8 +372,17 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate {
}
}
+ private func refreshPayloadForPopoverOpen() {
+ guard store.needsInteractivePayloadRefresh else { return }
+ recoverRefreshPipelineAfterInterruption(
+ resetLoading: store.hasStaleLoading,
+ reason: "popover open"
+ )
+ }
+
private func startRefreshLoop() {
refreshLoopTask?.cancel()
+ refreshLoopHeartbeatAt = Date()
refreshLoopTask = Task { [weak self] in
// Provider refreshes only run when the user has explicitly connected.
// Each refresh is a no-op until its corresponding bootstrap flag is set.
@@ -354,6 +391,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate {
}
while !Task.isCancelled {
guard let self else { return }
+ self.refreshLoopHeartbeatAt = Date()
let clearedStaleForceRefresh = self.clearStaleForceRefreshIfNeeded()
let clearedStaleLoading = self.store.clearStaleLoadingIfNeeded()
// Skip the loop's tick if a wake / manual / distributed-
@@ -617,6 +655,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate {
window.collectionBehavior.insert(.canJoinAllSpaces)
window.makeKeyAndOrderFront(nil)
}
+ refreshPayloadForPopoverOpen()
refreshLiveQuotaProgressForPopoverOpen()
}
}
diff --git a/src/parser.ts b/src/parser.ts
index 50fa648..2b78609 100644
--- a/src/parser.ts
+++ b/src/parser.ts
@@ -5,6 +5,7 @@ import { calculateCost, getShortModelName } from './models.js'
import { discoverAllSessions, getProvider } from './providers/index.js'
import { flushCodexCache } from './codex-cache.js'
import { flushAntigravityCache } from './providers/antigravity.js'
+import { isSqliteBusyError } from './sqlite.js'
import type { ParsedProviderCall } from './providers/types.js'
import type {
AssistantMessageContent,
@@ -541,6 +542,19 @@ function providerCallToTurn(call: ParsedProviderCall): ParsedTurn {
}
}
+const warnedProviderReadFailures = new Set()
+
+function warnProviderReadFailureOnce(providerName: string, err: unknown): void {
+ const key = `${providerName}:sqlite-busy`
+ if (warnedProviderReadFailures.has(key)) return
+ warnedProviderReadFailures.add(key)
+ if (isSqliteBusyError(err)) {
+ process.stderr.write(
+ `codeburn: skipped ${providerName} data because its SQLite database is temporarily locked; will retry on the next refresh.\n`
+ )
+ }
+}
+
async function parseProviderSources(
providerName: string,
sources: Array<{ path: string; project: string }>,
@@ -565,23 +579,31 @@ async function parseProviderSources(
seenKeys,
)
- for await (const call of parser.parse()) {
- if (dateRange) {
- if (!call.timestamp) continue
- const ts = new Date(call.timestamp)
- if (ts < dateRange.start || ts > dateRange.end) continue
- }
+ try {
+ for await (const call of parser.parse()) {
+ if (dateRange) {
+ if (!call.timestamp) continue
+ const ts = new Date(call.timestamp)
+ if (ts < dateRange.start || ts > dateRange.end) continue
+ }
- const turn = providerCallToTurn(call)
- const classified = classifyTurn(turn)
- const key = `${providerName}:${call.sessionId}:${source.project}`
+ const turn = providerCallToTurn(call)
+ const classified = classifyTurn(turn)
+ const key = `${providerName}:${call.sessionId}:${source.project}`
- const existing = sessionMap.get(key)
- if (existing) {
- existing.turns.push(classified)
- } else {
- sessionMap.set(key, { project: source.project, turns: [classified] })
+ const existing = sessionMap.get(key)
+ if (existing) {
+ existing.turns.push(classified)
+ } else {
+ sessionMap.set(key, { project: source.project, turns: [classified] })
+ }
}
+ } catch (err) {
+ if (isSqliteBusyError(err)) {
+ warnProviderReadFailureOnce(providerName, err)
+ continue
+ }
+ throw err
}
}
} finally {
diff --git a/src/providers/opencode.ts b/src/providers/opencode.ts
index b39230c..4689203 100644
--- a/src/providers/opencode.ts
+++ b/src/providers/opencode.ts
@@ -4,7 +4,7 @@ import { homedir } from 'os'
import { calculateCost, getShortModelName } from '../models.js'
import { extractBashCommands } from '../bash-utils.js'
-import { isSqliteAvailable, getSqliteLoadError, openDatabase, blobToText, type SqliteDatabase } from '../sqlite.js'
+import { isSqliteAvailable, getSqliteLoadError, openDatabase, blobToText, isSqliteBusyError, type SqliteDatabase } from '../sqlite.js'
import type {
Provider,
SessionSource,
@@ -107,7 +107,8 @@ function validateSchemaDetailed(db: SqliteDatabase): SchemaCheckResult {
for (const table of required) {
try {
db.query<{ cnt: number }>(`SELECT COUNT(*) as cnt FROM ${table} LIMIT 1`)
- } catch {
+ } catch (err) {
+ if (isSqliteBusyError(err)) throw err
missing.push(table)
}
}
diff --git a/src/sqlite.ts b/src/sqlite.ts
index 9242c63..3fb3c6a 100644
--- a/src/sqlite.ts
+++ b/src/sqlite.ts
@@ -16,6 +16,7 @@ export type SqliteDatabase = {
type DatabaseSyncCtor = new (path: string, options?: { readOnly?: boolean }) => {
prepare(sql: string): { all(...params: unknown[]): Row[] }
+ exec?(sql: string): void
close(): void
}
@@ -97,12 +98,35 @@ export function getSqliteLoadError(): string {
return loadError ?? 'SQLite driver not available'
}
+export function isSqliteBusyError(err: unknown): boolean {
+ const e = err as { code?: unknown; errcode?: unknown; errstr?: unknown; message?: unknown } | null
+ const code = typeof e?.code === 'string' ? e.code : ''
+ const errcode = typeof e?.errcode === 'number' ? e.errcode : null
+ const message = [
+ typeof e?.message === 'string' ? e.message : '',
+ typeof e?.errstr === 'string' ? e.errstr : '',
+ ].join(' ')
+
+ return (
+ errcode === 5 ||
+ errcode === 6 ||
+ code === 'SQLITE_BUSY' ||
+ code === 'SQLITE_LOCKED' ||
+ /\bSQLITE_(BUSY|LOCKED)\b|database (?:is |table is )?locked/i.test(message)
+ )
+}
+
export function openDatabase(path: string): SqliteDatabase {
if (!loadDriver() || DatabaseSync === null) {
throw new Error(getSqliteLoadError())
}
const db = new DatabaseSync(path, { readOnly: true })
+ try {
+ db.exec?.('PRAGMA busy_timeout = 1000')
+ } catch {
+ // Best effort. Some Node sqlite builds may not expose exec on DatabaseSync.
+ }
return {
query(sql: string, params: unknown[] = []): T[] {
diff --git a/tests/blob-to-text.test.ts b/tests/blob-to-text.test.ts
index f54717e..aeb7ce3 100644
--- a/tests/blob-to-text.test.ts
+++ b/tests/blob-to-text.test.ts
@@ -1,5 +1,5 @@
import { describe, it, expect } from 'vitest'
-import { blobToText } from '../src/sqlite.js'
+import { blobToText, isSqliteBusyError } from '../src/sqlite.js'
describe('blobToText', () => {
it('returns empty string for null', () => {
@@ -37,3 +37,17 @@ describe('blobToText', () => {
expect(blobToText(new Uint8Array(0))).toBe('')
})
})
+
+describe('isSqliteBusyError', () => {
+ it('detects node:sqlite busy errors by errcode', () => {
+ expect(isSqliteBusyError({ code: 'ERR_SQLITE_ERROR', errcode: 5, errstr: 'database is locked' })).toBe(true)
+ })
+
+ it('detects sqlite locked messages', () => {
+ expect(isSqliteBusyError(new Error('SQLITE_LOCKED: database table is locked'))).toBe(true)
+ })
+
+ it('ignores unrelated sqlite errors', () => {
+ expect(isSqliteBusyError(new Error('no such table: session'))).toBe(false)
+ })
+})
From 03e22ecb80fbaaeb4e6f824b5724fc9a40233a7a Mon Sep 17 00:00:00 2001
From: AgentSeal
Date: Mon, 11 May 2026 20:54:13 -0700
Subject: [PATCH 05/28] Add IBM Bob provider with workspace extraction (#316)
* Add IBM Bob provider
* Add workspace extraction for Cline-family providers
Extract project name from workspace directory in api_conversation_history.json
so sessions show actual folder names instead of the provider display name.
Thread projectPath through ParsedProviderCall to avoid unsanitizePath mangling
hyphenated folder names.
---------
Co-authored-by: ozymandiashh <234437643+ozymandiashh@users.noreply.github.com>
Co-authored-by: iamtoruk
---
CHANGELOG.md | 11 ++
README.md | 7 +-
assets/providers/ibm-bob.svg | 6 +
docs/architecture.md | 6 +-
docs/providers/README.md | 3 +-
docs/providers/ibm-bob.md | 55 ++++++
docs/providers/vscode-cline-parser.md | 25 +--
mac/Sources/CodeBurnMenubar/AppStore.swift | 3 +
.../CodeBurnMenubar/Views/AgentTabStrip.swift | 1 +
package.json | 1 +
src/dashboard.tsx | 2 +
src/models.ts | 2 +
src/parser.ts | 26 +--
src/providers/ibm-bob.ts | 59 +++++++
src/providers/index.ts | 3 +-
src/providers/types.ts | 2 +
src/providers/vscode-cline-parser.ts | 57 ++++--
tests/provider-registry.test.ts | 2 +-
tests/providers/ibm-bob.test.ts | 164 ++++++++++++++++++
19 files changed, 395 insertions(+), 40 deletions(-)
create mode 100644 assets/providers/ibm-bob.svg
create mode 100644 docs/providers/ibm-bob.md
create mode 100644 src/providers/ibm-bob.ts
create mode 100644 tests/providers/ibm-bob.test.ts
diff --git a/CHANGELOG.md b/CHANGELOG.md
index e7dd43d..b6d3191 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,16 @@
# Changelog
+## Unreleased
+
+### Added (CLI)
+- **IBM Bob provider.** CodeBurn now discovers IBM Bob IDE task history from
+ `User/globalStorage/ibm.bob-code/tasks//` under both the GA
+ `IBM Bob` application data folder and preview-era `Bob-IDE` folder. The
+ provider reuses the Cline-family `ui_messages.json` parser for token/cost
+ records, reads `api_conversation_history.json` for model tags when present,
+ falls back to `ibm-bob-auto` pricing otherwise, and appears in CLI,
+ dashboard, JSON, docs, and the macOS provider tabs. Closes #248.
+
## 0.9.8 - 2026-05-10
### Added (CLI)
diff --git a/README.md b/README.md
index b370022..9db2a1f 100644
--- a/README.md
+++ b/README.md
@@ -13,7 +13,7 @@
-CodeBurn tracks token usage, cost, and performance across **18 AI coding tools**. It breaks down spending by task type, model, tool, project, and provider so you can see exactly where your budget goes.
+CodeBurn tracks token usage, cost, and performance across **19 AI coding tools**. It breaks down spending by task type, model, tool, project, and provider so you can see exactly where your budget goes.
Everything runs locally. No wrapper, no proxy, no API keys. CodeBurn reads session data directly from disk and prices every call using [LiteLLM](https://github.com/BerriAI/litellm).
@@ -104,6 +104,7 @@ Arrow keys switch between Today, 7 Days, 30 Days, Month, and 6 Months (use `--fr
|
| cursor-agent | Yes | [cursor-agent.md](docs/providers/cursor-agent.md) |
|
| Gemini CLI | Yes | [gemini.md](docs/providers/gemini.md) |
|
| GitHub Copilot | Yes | [copilot.md](docs/providers/copilot.md) |
+|
| IBM Bob | Yes | [ibm-bob.md](docs/providers/ibm-bob.md) |
|
| Kiro | Yes | [kiro.md](docs/providers/kiro.md) |
|
| OpenCode | Yes | [opencode.md](docs/providers/opencode.md) |
|
| OpenClaw | Yes | [openclaw.md](docs/providers/openclaw.md) |
@@ -119,7 +120,7 @@ Arrow keys switch between Today, 7 Days, 30 Days, Month, and 6 Months (use `--fr
Each provider doc lists the exact data location, storage format, and known quirks. Linux and Windows paths are detected automatically. If a path has changed or is wrong, please [open an issue](https://github.com/getagentseal/codeburn/issues).
-Provider logos are trademarks of their respective owners. The icon set was sourced from [tokscale](https://github.com/junhoyeo/tokscale) (MIT) plus official vendor assets, used under nominative fair use for the purpose of identifying supported tools.
+Provider logos are trademarks of their respective owners. The icon set was sourced from [tokscale](https://github.com/junhoyeo/tokscale) (MIT), official vendor assets, and simple provider identifiers, used under nominative fair use for the purpose of identifying supported tools.
CodeBurn auto-detects which AI coding tools you use. If multiple providers have session data on disk, press `p` in the dashboard to toggle between them.
@@ -378,6 +379,8 @@ These are starting points, not verdicts. A 60% cache hit on a single experimenta
**OpenClaw** stores agent sessions as JSONL at `~/.openclaw/agents/*.jsonl`. Also checks legacy paths `.clawdbot`, `.moltbot`, `.moldbot`. Token usage comes from assistant message `usage` blocks; model from `modelId` or `message.model` fields.
+**IBM Bob** stores IDE task history in `User/globalStorage/ibm.bob-code/tasks//` under the IBM Bob application data directory. CodeBurn reads `ui_messages.json` for API request token/cost records and `api_conversation_history.json` for the selected model, with support for both GA (`IBM Bob`) and preview (`Bob-IDE`) app data folders.
+
**Roo Code / KiloCode** are Cline-family VS Code extensions. CodeBurn reads `ui_messages.json` from each task directory in VS Code's `globalStorage`, filtering `type: "say"` entries with `say: "api_req_started"` to extract token counts.
CodeBurn deduplicates messages (by API message ID for Claude, by cumulative token cross-check for Codex, by conversation/timestamp for Cursor, by session ID for Gemini, by session+message ID for OpenCode, by responseId for Pi/OMP), filters by date range per entry, and classifies each turn.
diff --git a/assets/providers/ibm-bob.svg b/assets/providers/ibm-bob.svg
new file mode 100644
index 0000000..ab76047
--- /dev/null
+++ b/assets/providers/ibm-bob.svg
@@ -0,0 +1,6 @@
+
diff --git a/docs/architecture.md b/docs/architecture.md
index 9b1ea14..c3a8c25 100644
--- a/docs/architecture.md
+++ b/docs/architecture.md
@@ -128,14 +128,14 @@ type Provider = {
}
```
-`src/providers/index.ts` registers eighteen providers across two tiers:
+`src/providers/index.ts` registers nineteen providers across two tiers:
-- **Eager**: `claude`, `codex`, `copilot`, `droid`, `gemini`, `kilo-code`, `kiro`, `openclaw`, `pi`, `omp`, `qwen`, `roo-code`. Imported at module load.
+- **Eager**: `claude`, `codex`, `copilot`, `droid`, `gemini`, `ibm-bob`, `kilo-code`, `kiro`, `openclaw`, `pi`, `omp`, `qwen`, `roo-code`. Imported at module load.
- **Lazy**: `antigravity`, `goose`, `cursor`, `opencode`, `cursor-agent`, `crush`. Imported via dynamic `import()` so the heavy dependencies (SQLite, protobuf) do not touch users who do not have those tools installed.
Both lists hit the same `getAllProviders()` aggregator. A failed lazy import is silent and excludes that provider from the run.
-`src/providers/vscode-cline-parser.ts` is a shared helper consumed by `kilo-code` and `roo-code`. It is not registered as a provider on its own.
+`src/providers/vscode-cline-parser.ts` is a shared helper consumed by `ibm-bob`, `kilo-code`, and `roo-code`. It is not registered as a provider on its own.
For the per-provider data location, storage format, parser quirks, and test coverage, see `docs/providers/`.
diff --git a/docs/providers/README.md b/docs/providers/README.md
index 05f43db..600bd60 100644
--- a/docs/providers/README.md
+++ b/docs/providers/README.md
@@ -15,6 +15,7 @@ For the architectural picture, see `../architecture.md`.
| [Copilot](copilot.md) | JSONL | `src/providers/copilot.ts` | `tests/providers/copilot.test.ts` |
| [Droid](droid.md) | JSONL | `src/providers/droid.ts` | `tests/providers/droid.test.ts` |
| [Gemini](gemini.md) | JSON / JSONL | `src/providers/gemini.ts` | none |
+| [IBM Bob](ibm-bob.md) | JSON | `src/providers/ibm-bob.ts` | `tests/providers/ibm-bob.test.ts` |
| [KiloCode](kilo-code.md) | JSON | `src/providers/kilo-code.ts` | `tests/providers/kilo-code.test.ts` |
| [Kiro](kiro.md) | JSON | `src/providers/kiro.ts` | `tests/providers/kiro.test.ts` |
| [OpenClaw](openclaw.md) | JSONL | `src/providers/openclaw.ts` | `tests/providers/openclaw.test.ts` |
@@ -38,7 +39,7 @@ For the architectural picture, see `../architecture.md`.
| Helper | Used by | Source |
|---|---|---|
-| [vscode-cline-parser](vscode-cline-parser.md) | `kilo-code`, `roo-code` | `src/providers/vscode-cline-parser.ts` |
+| [vscode-cline-parser](vscode-cline-parser.md) | `ibm-bob`, `kilo-code`, `roo-code` | `src/providers/vscode-cline-parser.ts` |
## File Format
diff --git a/docs/providers/ibm-bob.md b/docs/providers/ibm-bob.md
new file mode 100644
index 0000000..c9d4373
--- /dev/null
+++ b/docs/providers/ibm-bob.md
@@ -0,0 +1,55 @@
+# IBM Bob
+
+IBM Bob IDE task history.
+
+- **Source:** `src/providers/ibm-bob.ts`
+- **Loading:** eager (`src/providers/index.ts`)
+- **Test:** `tests/providers/ibm-bob.test.ts`
+
+## Where It Reads From
+
+IBM Bob stores IDE task history below `User/globalStorage/ibm.bob-code/tasks/` in the application data directory.
+
+Default paths checked:
+
+| Platform | Paths |
+|---|---|
+| macOS | `~/Library/Application Support/IBM Bob/User/globalStorage/ibm.bob-code/`, `~/Library/Application Support/Bob-IDE/User/globalStorage/ibm.bob-code/` |
+| Windows | `%APPDATA%/IBM Bob/User/globalStorage/ibm.bob-code/`, `%APPDATA%/Bob-IDE/User/globalStorage/ibm.bob-code/` |
+| Linux | `$XDG_CONFIG_HOME/IBM Bob/User/globalStorage/ibm.bob-code/`, `$XDG_CONFIG_HOME/Bob-IDE/User/globalStorage/ibm.bob-code/` with `~/.config` fallback |
+
+The `Bob-IDE` paths cover the preview-era app name that some installs used before the GA `IBM Bob` directory.
+
+## Storage Format
+
+Each task is a directory under `tasks//` and must contain `ui_messages.json`.
+
+CodeBurn parses the same Cline-family UI event format used by Roo Code and KiloCode:
+
+- `ui_messages.json` entries with `type: "say"` and `say: "api_req_started"` contain serialized token/cost metrics.
+- `ui_messages.json` user text entries seed the turn's first user message.
+- `api_conversation_history.json` is optional and is used to extract the selected model from `...` environment details when present.
+- `task_metadata.json` may exist upstream, but CodeBurn does not need it for usage math today.
+
+If no model tag is present, the parser uses `ibm-bob-auto`, which is priced through the same conservative Sonnet fallback used for Cline-family auto modes.
+
+## Caching
+
+None at the provider level.
+
+## Deduplication
+
+Per `::` via `vscode-cline-parser.ts`.
+
+## Quirks
+
+- IBM Bob has shipped under both `IBM Bob` and `Bob-IDE` application data folder names.
+- This provider intentionally covers the IDE task-history format. Bob Shell's `~/.bob` checkpoint data is a separate storage surface and is not parsed until we have a stable usage schema fixture.
+- The shared Cline parser does not currently extract individual tool names from UI messages, so tool breakdowns are empty for IBM Bob just like Roo Code and KiloCode.
+
+## When Fixing A Bug Here
+
+1. Check whether the install uses `IBM Bob` or `Bob-IDE` as the application data directory.
+2. Confirm the task folder still contains `ui_messages.json` and `api_conversation_history.json`.
+3. If the UI message schema changed, add a focused fixture to `tests/providers/ibm-bob.test.ts`.
+4. If the change also affects Roo Code or KiloCode, update `src/providers/vscode-cline-parser.ts` and run all three provider test files.
diff --git a/docs/providers/vscode-cline-parser.md b/docs/providers/vscode-cline-parser.md
index 5b6bdfa..ea68eae 100644
--- a/docs/providers/vscode-cline-parser.md
+++ b/docs/providers/vscode-cline-parser.md
@@ -1,17 +1,18 @@
# vscode-cline-parser (Shared Helper)
-Shared discovery and parsing for VS Code extensions descended from Cline.
+Shared discovery and parsing for Cline-family task folders.
- **Source:** `src/providers/vscode-cline-parser.ts`
-- **Loading:** not a provider; imported by `kilo-code.ts` and `roo-code.ts`.
-- **Test:** none directly. Coverage comes from `tests/providers/kilo-code.test.ts` and `tests/providers/roo-code.test.ts`.
+- **Loading:** not a provider; imported by `ibm-bob.ts`, `kilo-code.ts`, and `roo-code.ts`.
+- **Test:** none directly. Coverage comes from `tests/providers/ibm-bob.test.ts`, `tests/providers/kilo-code.test.ts`, and `tests/providers/roo-code.test.ts`.
## What it does
Two responsibilities:
-1. `discoverClineTasks(extensionId)` walks VS Code's `globalStorage//tasks/` directories and returns one source per task that has a `ui_messages.json` file (`vscode-cline-parser.ts:25-50`).
-2. `createClineParser` reads each task's `ui_messages.json` and `api_conversation_history.json`, extracts model, tools, and token counts, and yields `ParsedProviderCall` objects.
+1. `discoverClineTasks(extensionId)` walks VS Code's `globalStorage//tasks/` directories and returns one source per task that has a `ui_messages.json` file.
+2. `discoverClineTasksInBaseDirs(baseDirs)` does the same for non-VS Code apps with compatible task storage, such as IBM Bob.
+3. `createClineParser` reads each task's `ui_messages.json` and `api_conversation_history.json`, extracts model and token counts, and yields `ParsedProviderCall` objects.
## Storage layout
@@ -25,25 +26,25 @@ Per task directory:
## Model resolution
-The model is extracted from `api_conversation_history.json` by searching user message content blocks for a `...` tag (`vscode-cline-parser.ts:54-72`). Falls back to `cline-auto` if no tag is found.
+The model is extracted from `api_conversation_history.json` by searching user message content blocks for a `...` tag. Falls back to the provider-supplied auto model (`cline-auto` by default) if no tag is found.
## Token extraction
-From `api_req_started` entries inside `ui_messages.json`. Each such entry's `text` field is JSON-parsed; the parsed object holds `tokensIn`, `tokensOut`, `cacheReads`, `cacheWrites`, and (optionally) `cost` (`vscode-cline-parser.ts:119-134`).
+From `api_req_started` entries inside `ui_messages.json`. Each such entry's `text` field is JSON-parsed; the parsed object holds `tokensIn`, `tokensOut`, `cacheReads`, `cacheWrites`, and (optionally) `cost`.
-If `cost` is present, it is used directly. If not, `calculateCost` from `src/models.ts` computes it from tokens (`vscode-cline-parser.ts:139`).
+If `cost` is present, it is used directly. If not, `calculateCost` from `src/models.ts` computes it from tokens.
## Deduplication
-Per `::` where `index` is the position of the `api_req_started` entry within `ui_messages.json` (`vscode-cline-parser.ts:109`).
+Per `::` where `index` is the position of the `api_req_started` entry within `ui_messages.json`.
## Quirks
-- Only the **first** user message is emitted as `userMessage` in the `ParsedProviderCall` (`vscode-cline-parser.ts:157`). Subsequent user turns are accounted but not surfaced.
+- Only the **first** user message is emitted as `userMessage` in the `ParsedProviderCall`. Subsequent user turns are accounted but not surfaced.
- The model regex looks inside content blocks, not at top-level fields. Some Cline-derivative extensions emit the model elsewhere; if you add support for one, branch on extension ID rather than rewriting the regex.
## When fixing a bug here
-1. A change here ripples to **both** KiloCode and Roo Code. Run both test files (`tests/providers/kilo-code.test.ts` and `tests/providers/roo-code.test.ts`) before opening a PR.
+1. A change here ripples to IBM Bob, KiloCode, and Roo Code. Run all three provider test files before opening a PR.
2. If you find that one of the two extensions emits a different shape, branch on the extension ID parameter that the discovery function already takes; do not duplicate the parser.
-3. If you add support for a third Cline-derivative extension, register it as a thin wrapper file in the same shape as `kilo-code.ts` and `roo-code.ts`.
+3. If you add support for another Cline-family task store, register it as a thin wrapper file in the same shape as `ibm-bob.ts`, `kilo-code.ts`, and `roo-code.ts`.
diff --git a/mac/Sources/CodeBurnMenubar/AppStore.swift b/mac/Sources/CodeBurnMenubar/AppStore.swift
index 38d370a..ec5fdfa 100644
--- a/mac/Sources/CodeBurnMenubar/AppStore.swift
+++ b/mac/Sources/CodeBurnMenubar/AppStore.swift
@@ -736,6 +736,7 @@ enum ProviderFilter: String, CaseIterable, Identifiable {
case copilot = "Copilot"
case droid = "Droid"
case gemini = "Gemini"
+ case ibmBob = "IBM Bob"
case kiro = "Kiro"
case kiloCode = "KiloCode"
case openclaw = "OpenClaw"
@@ -753,6 +754,7 @@ enum ProviderFilter: String, CaseIterable, Identifiable {
case .cursor: ["cursor", "cursor agent"]
case .rooCode: ["roo-code", "roo code"]
case .kiloCode: ["kilo-code", "kilocode"]
+ case .ibmBob: ["ibm-bob", "ibm bob"]
case .openclaw: ["openclaw"]
default: [rawValue.lowercased()]
}
@@ -767,6 +769,7 @@ enum ProviderFilter: String, CaseIterable, Identifiable {
case .copilot: "copilot"
case .droid: "droid"
case .gemini: "gemini"
+ case .ibmBob: "ibm-bob"
case .kiloCode: "kilo-code"
case .kiro: "kiro"
case .openclaw: "openclaw"
diff --git a/mac/Sources/CodeBurnMenubar/Views/AgentTabStrip.swift b/mac/Sources/CodeBurnMenubar/Views/AgentTabStrip.swift
index 6561cc9..df47c46 100644
--- a/mac/Sources/CodeBurnMenubar/Views/AgentTabStrip.swift
+++ b/mac/Sources/CodeBurnMenubar/Views/AgentTabStrip.swift
@@ -345,6 +345,7 @@ extension ProviderFilter {
case .copilot: return Color(red: 0x6D/255.0, green: 0x8F/255.0, blue: 0xA6/255.0)
case .droid: return Color(red: 0x7C/255.0, green: 0x3A/255.0, blue: 0xED/255.0)
case .gemini: return Color(red: 0x44/255.0, green: 0x85/255.0, blue: 0xF4/255.0)
+ case .ibmBob: return Color(red: 0x0F/255.0, green: 0x62/255.0, blue: 0xFE/255.0)
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 .openclaw: return Color(red: 0xDA/255.0, green: 0x70/255.0, blue: 0x56/255.0)
diff --git a/package.json b/package.json
index a58098d..b831b30 100644
--- a/package.json
+++ b/package.json
@@ -21,6 +21,7 @@
"claude-code",
"cursor",
"codex",
+ "ibm-bob",
"opencode",
"pi",
"ai-coding",
diff --git a/src/dashboard.tsx b/src/dashboard.tsx
index b46dbcc..e666b18 100644
--- a/src/dashboard.tsx
+++ b/src/dashboard.tsx
@@ -52,6 +52,7 @@ const PROVIDER_COLORS: Record = {
claude: '#FF8C42',
codex: '#5BF5A0',
cursor: '#00B4D8',
+ 'ibm-bob': '#0F62FE',
opencode: '#A78BFA',
pi: '#F472B6',
all: '#FF8C42',
@@ -513,6 +514,7 @@ const PROVIDER_DISPLAY_NAMES: Record = {
claude: 'Claude',
codex: 'Codex',
cursor: 'Cursor',
+ 'ibm-bob': 'IBM Bob',
opencode: 'OpenCode',
pi: 'Pi',
}
diff --git a/src/models.ts b/src/models.ts
index e4441e0..0d43793 100644
--- a/src/models.ts
+++ b/src/models.ts
@@ -166,6 +166,7 @@ const BUILTIN_ALIASES: Record = {
'copilot-auto': 'claude-sonnet-4-5',
'copilot-openai-auto': 'gpt-5.3-codex',
'copilot-anthropic-auto': 'claude-sonnet-4-5',
+ 'ibm-bob-auto': 'claude-sonnet-4-5',
'kiro-auto': 'claude-sonnet-4-5',
'cline-auto': 'claude-sonnet-4-5',
'openclaw-auto': 'claude-sonnet-4-5',
@@ -351,6 +352,7 @@ const autoModelNames: Record = {
'copilot-auto': 'Copilot (auto)',
'copilot-openai-auto': 'Copilot (OpenAI)',
'copilot-anthropic-auto': 'Copilot (Anthropic)',
+ 'ibm-bob-auto': 'IBM Bob (auto)',
'kiro-auto': 'Kiro (auto)',
'cline-auto': 'Cline (auto)',
'openclaw-auto': 'OpenClaw (auto)',
diff --git a/src/parser.ts b/src/parser.ts
index 50fa648..d49697b 100644
--- a/src/parser.ts
+++ b/src/parser.ts
@@ -550,7 +550,7 @@ async function parseProviderSources(
const provider = await getProvider(providerName)
if (!provider) return []
- const sessionMap = new Map()
+ const sessionMap = new Map()
try {
for (const source of sources) {
@@ -574,13 +574,15 @@ async function parseProviderSources(
const turn = providerCallToTurn(call)
const classified = classifyTurn(turn)
- const key = `${providerName}:${call.sessionId}:${source.project}`
+ const project = call.project ?? source.project
+ const key = `${providerName}:${call.sessionId}:${project}`
const existing = sessionMap.get(key)
if (existing) {
existing.turns.push(classified)
+ if (!existing.projectPath && call.projectPath) existing.projectPath = call.projectPath
} else {
- sessionMap.set(key, { project: source.project, turns: [classified] })
+ sessionMap.set(key, { project, projectPath: call.projectPath, turns: [classified] })
}
}
}
@@ -592,22 +594,26 @@ async function parseProviderSources(
}
}
- const projectMap = new Map()
- for (const [key, { project, turns }] of sessionMap) {
+ const projectMap = new Map()
+ for (const [key, { project, projectPath, turns }] of sessionMap) {
const sessionId = key.split(':')[1] ?? key
const session = buildSessionSummary(sessionId, project, turns)
if (session.apiCalls > 0) {
- const existing = projectMap.get(project) ?? []
- existing.push(session)
- projectMap.set(project, existing)
+ const existing = projectMap.get(project)
+ if (existing) {
+ existing.sessions.push(session)
+ if (!existing.projectPath && projectPath) existing.projectPath = projectPath
+ } else {
+ projectMap.set(project, { projectPath, sessions: [session] })
+ }
}
}
const projects: ProjectSummary[] = []
- for (const [dirName, sessions] of projectMap) {
+ for (const [dirName, { projectPath, sessions }] of projectMap) {
projects.push({
project: dirName,
- projectPath: unsanitizePath(dirName),
+ projectPath: projectPath ?? unsanitizePath(dirName),
sessions,
totalCostUSD: sessions.reduce((s, sess) => s + sess.totalCostUSD, 0),
totalApiCalls: sessions.reduce((s, sess) => s + sess.apiCalls, 0),
diff --git a/src/providers/ibm-bob.ts b/src/providers/ibm-bob.ts
new file mode 100644
index 0000000..5aec0f6
--- /dev/null
+++ b/src/providers/ibm-bob.ts
@@ -0,0 +1,59 @@
+import { join } from 'path'
+import { homedir } from 'os'
+
+import { getShortModelName } from '../models.js'
+import { discoverClineTasksInBaseDirs, createClineParser } from './vscode-cline-parser.js'
+import type { Provider, SessionSource, SessionParser } from './types.js'
+
+const PROVIDER_NAME = 'ibm-bob'
+const DISPLAY_NAME = 'IBM Bob'
+const EXTENSION_ID = 'ibm.bob-code'
+const FALLBACK_MODEL = 'ibm-bob-auto'
+
+export function getIBMBobGlobalStorageDirs(): string[] {
+ const home = homedir()
+ if (process.platform === 'darwin') {
+ return [
+ join(home, 'Library', 'Application Support', 'IBM Bob', 'User', 'globalStorage', EXTENSION_ID),
+ join(home, 'Library', 'Application Support', 'Bob-IDE', 'User', 'globalStorage', EXTENSION_ID),
+ ]
+ }
+ if (process.platform === 'win32') {
+ const appData = process.env['APPDATA'] ?? join(home, 'AppData', 'Roaming')
+ return [
+ join(appData, 'IBM Bob', 'User', 'globalStorage', EXTENSION_ID),
+ join(appData, 'Bob-IDE', 'User', 'globalStorage', EXTENSION_ID),
+ ]
+ }
+ const configHome = process.env['XDG_CONFIG_HOME'] ?? join(home, '.config')
+ return [
+ join(configHome, 'IBM Bob', 'User', 'globalStorage', EXTENSION_ID),
+ join(configHome, 'Bob-IDE', 'User', 'globalStorage', EXTENSION_ID),
+ ]
+}
+
+export function createIBMBobProvider(overrideDir?: string): Provider {
+ return {
+ name: PROVIDER_NAME,
+ displayName: DISPLAY_NAME,
+
+ modelDisplayName(model: string): string {
+ return getShortModelName(model)
+ },
+
+ toolDisplayName(rawTool: string): string {
+ return rawTool
+ },
+
+ async discoverSessions(): Promise {
+ const dirs = overrideDir ? [overrideDir] : getIBMBobGlobalStorageDirs()
+ return discoverClineTasksInBaseDirs(dirs, PROVIDER_NAME, DISPLAY_NAME)
+ },
+
+ createSessionParser(source: SessionSource, seenKeys: Set): SessionParser {
+ return createClineParser(source, seenKeys, PROVIDER_NAME, FALLBACK_MODEL)
+ },
+ }
+}
+
+export const ibmBob = createIBMBobProvider()
diff --git a/src/providers/index.ts b/src/providers/index.ts
index 38ed490..551d3a2 100644
--- a/src/providers/index.ts
+++ b/src/providers/index.ts
@@ -3,6 +3,7 @@ import { codex } from './codex.js'
import { copilot } from './copilot.js'
import { droid } from './droid.js'
import { gemini } from './gemini.js'
+import { ibmBob } from './ibm-bob.js'
import { kiloCode } from './kilo-code.js'
import { kiro } from './kiro.js'
import { openclaw } from './openclaw.js'
@@ -101,7 +102,7 @@ async function loadCrush(): Promise {
}
}
-const coreProviders: Provider[] = [claude, codex, copilot, droid, gemini, kiloCode, kiro, openclaw, pi, omp, qwen, rooCode]
+const coreProviders: Provider[] = [claude, codex, copilot, droid, gemini, ibmBob, kiloCode, kiro, openclaw, pi, omp, qwen, rooCode]
export async function getAllProviders(): Promise {
const [ag, gs, cursor, opencode, cursorAgent, crush] = await Promise.all([loadAntigravity(), loadGoose(), loadCursor(), loadOpenCode(), loadCursorAgent(), loadCrush()])
diff --git a/src/providers/types.ts b/src/providers/types.ts
index 4e9a98a..90d5e1c 100644
--- a/src/providers/types.ts
+++ b/src/providers/types.ts
@@ -27,6 +27,8 @@ export type ParsedProviderCall = {
deduplicationKey: string
userMessage: string
sessionId: string
+ project?: string
+ projectPath?: string
}
export type Provider = {
diff --git a/src/providers/vscode-cline-parser.ts b/src/providers/vscode-cline-parser.ts
index d1d26c0..ffad939 100644
--- a/src/providers/vscode-cline-parser.ts
+++ b/src/providers/vscode-cline-parser.ts
@@ -24,6 +24,23 @@ export function getVSCodeGlobalStoragePath(extensionId: string): string {
export async function discoverClineTasks(extensionId: string, providerName: string, displayName: string, overrideDir?: string): Promise {
const baseDir = overrideDir ?? getVSCodeGlobalStoragePath(extensionId)
+ return discoverClineTasksInBaseDirs([baseDir], providerName, displayName)
+}
+
+export async function discoverClineTasksInBaseDirs(baseDirs: string[], providerName: string, displayName: string): Promise {
+ const sources: SessionSource[] = []
+ const seen = new Set()
+ for (const baseDir of baseDirs) {
+ for (const source of await discoverClineTasksInBaseDir(baseDir, providerName, displayName)) {
+ if (seen.has(source.path)) continue
+ seen.add(source.path)
+ sources.push(source)
+ }
+ }
+ return sources
+}
+
+async function discoverClineTasksInBaseDir(baseDir: string, providerName: string, displayName: string): Promise {
const tasksDir = join(baseDir, 'tasks')
const sources: SessionSource[] = []
@@ -50,28 +67,43 @@ export async function discoverClineTasks(extensionId: string, providerName: stri
}
const MODEL_TAG_RE = /([^<]+)<\/model>/
+const WORKSPACE_DIR_RE = /Current Workspace Directory \(([^)]+)\)/
-function extractModelFromHistory(taskDir: string): Promise {
+type HistoryMeta = { model: string; workspace: string | null }
+
+function extractHistoryMeta(taskDir: string, fallbackModel: string): Promise {
return readFile(join(taskDir, 'api_conversation_history.json'), 'utf-8')
.then(raw => {
const msgs = JSON.parse(raw) as Array<{ role?: string; content?: Array<{ text?: string }> }>
- if (!Array.isArray(msgs)) return 'cline-auto'
+ if (!Array.isArray(msgs)) return { model: fallbackModel, workspace: null }
+ let model: string | null = null
+ let workspace: string | null = null
for (const msg of msgs) {
if (msg.role !== 'user' || !Array.isArray(msg.content)) continue
for (const block of msg.content) {
- const match = typeof block.text === 'string' && MODEL_TAG_RE.exec(block.text)
- if (match) {
- const raw = match[1]
- return raw.includes('/') ? raw.split('/').pop()! : raw
+ if (typeof block.text !== 'string') continue
+ if (!model) {
+ const mm = MODEL_TAG_RE.exec(block.text)
+ if (mm) model = mm[1].includes('/') ? mm[1].split('/').pop()! : mm[1]
}
+ if (!workspace) {
+ const wm = WORKSPACE_DIR_RE.exec(block.text)
+ if (wm) workspace = wm[1]
+ }
+ if (model && workspace) break
}
+ if (model && workspace) break
}
- return 'cline-auto'
+ return { model: model ?? fallbackModel, workspace }
})
- .catch(() => 'cline-auto')
+ .catch(() => ({ model: fallbackModel, workspace: null }))
}
-export function createClineParser(source: SessionSource, seenKeys: Set, providerName: string): SessionParser {
+function workspaceToProject(workspace: string): string {
+ return basename(workspace) || workspace
+}
+
+export function createClineParser(source: SessionSource, seenKeys: Set, providerName: string, fallbackModel = 'cline-auto'): SessionParser {
return {
async *parse(): AsyncGenerator {
const taskDir = source.path
@@ -93,7 +125,10 @@ export function createClineParser(source: SessionSource, seenKeys: Set,
if (!Array.isArray(uiMessages)) return
- const model = await extractModelFromHistory(taskDir)
+ const meta = await extractHistoryMeta(taskDir, fallbackModel)
+ const model = meta.model
+ const project = meta.workspace ? workspaceToProject(meta.workspace) : undefined
+ const projectPath = meta.workspace ?? undefined
let userMessage = ''
for (const msg of uiMessages) {
@@ -156,6 +191,8 @@ export function createClineParser(source: SessionSource, seenKeys: Set,
deduplicationKey: dedupKey,
userMessage: index === 0 ? userMessage : '',
sessionId: taskId,
+ project,
+ projectPath,
}
}
},
diff --git a/tests/provider-registry.test.ts b/tests/provider-registry.test.ts
index 4497946..2dc1dfc 100644
--- a/tests/provider-registry.test.ts
+++ b/tests/provider-registry.test.ts
@@ -3,7 +3,7 @@ import { providers, getAllProviders } from '../src/providers/index.js'
describe('provider registry', () => {
it('has core providers registered synchronously', () => {
- expect(providers.map(p => p.name)).toEqual(['claude', 'codex', 'copilot', 'droid', 'gemini', 'kilo-code', 'kiro', 'openclaw', 'pi', 'omp', 'qwen', 'roo-code'])
+ expect(providers.map(p => p.name)).toEqual(['claude', 'codex', 'copilot', 'droid', 'gemini', 'ibm-bob', 'kilo-code', 'kiro', 'openclaw', 'pi', 'omp', 'qwen', 'roo-code'])
})
it('includes sqlite providers after async load', async () => {
diff --git a/tests/providers/ibm-bob.test.ts b/tests/providers/ibm-bob.test.ts
new file mode 100644
index 0000000..d61f92e
--- /dev/null
+++ b/tests/providers/ibm-bob.test.ts
@@ -0,0 +1,164 @@
+import { describe, it, expect, beforeEach, afterEach } from 'vitest'
+import { mkdtemp, mkdir, writeFile, rm } from 'fs/promises'
+import { join } from 'path'
+import { tmpdir } from 'os'
+
+import { ibmBob, createIBMBobProvider } from '../../src/providers/ibm-bob.js'
+import type { ParsedProviderCall } from '../../src/providers/types.js'
+
+let tmpDir: string
+
+function makeUiMessages(opts: {
+ tokensIn?: number
+ tokensOut?: number
+ cacheReads?: number
+ cacheWrites?: number
+ cost?: number
+ userMessage?: string
+ ts?: number
+}): string {
+ const messages: unknown[] = []
+
+ if (opts.userMessage) {
+ messages.push({ type: 'say', say: 'user_feedback', text: opts.userMessage, ts: 1_700_000_000_000 })
+ }
+
+ const apiData: Record = {
+ tokensIn: opts.tokensIn ?? 100,
+ tokensOut: opts.tokensOut ?? 50,
+ cacheReads: opts.cacheReads ?? 0,
+ cacheWrites: opts.cacheWrites ?? 0,
+ }
+ if (opts.cost !== undefined) apiData.cost = opts.cost
+
+ messages.push({
+ type: 'say',
+ say: 'api_req_started',
+ text: JSON.stringify(apiData),
+ ts: opts.ts ?? 1_700_000_001_000,
+ })
+
+ return JSON.stringify(messages)
+}
+
+function makeApiHistory(model?: string): string {
+ const modelTag = model ? `${model}` : ''
+ return JSON.stringify([
+ { role: 'user', content: [{ type: 'text', text: `hello\n\n${modelTag}\n` }] },
+ { role: 'assistant', content: [{ type: 'text', text: 'response' }] },
+ ])
+}
+
+describe('ibm-bob provider - discovery and parsing', () => {
+ beforeEach(async () => {
+ tmpDir = await mkdtemp(join(tmpdir(), 'ibm-bob-test-'))
+ })
+
+ afterEach(async () => {
+ await rm(tmpDir, { recursive: true, force: true })
+ })
+
+ it('discovers IBM Bob task directories with ui_messages.json', async () => {
+ const task1 = join(tmpDir, 'tasks', 'task-a')
+ const task2 = join(tmpDir, 'tasks', 'task-b')
+ await mkdir(task1, { recursive: true })
+ await mkdir(task2, { recursive: true })
+ await writeFile(join(task1, 'ui_messages.json'), '[]')
+ await writeFile(join(task2, 'ui_messages.json'), '[]')
+
+ const provider = createIBMBobProvider(tmpDir)
+ const sessions = await provider.discoverSessions()
+
+ expect(sessions).toHaveLength(2)
+ expect(sessions.every(s => s.provider === 'ibm-bob')).toBe(true)
+ expect(sessions.every(s => s.project === 'IBM Bob')).toBe(true)
+ })
+
+ it('skips tasks without ui_messages.json', async () => {
+ const task = join(tmpDir, 'tasks', 'task-no-ui')
+ await mkdir(task, { recursive: true })
+ await writeFile(join(task, 'api_conversation_history.json'), '[]')
+
+ const provider = createIBMBobProvider(tmpDir)
+ const sessions = await provider.discoverSessions()
+
+ expect(sessions).toHaveLength(0)
+ })
+
+ it('parses token usage and provider cost from Bob ui messages', async () => {
+ const taskDir = join(tmpDir, 'tasks', 'task-001')
+ await mkdir(taskDir, { recursive: true })
+ await writeFile(join(taskDir, 'ui_messages.json'), makeUiMessages({
+ tokensIn: 250,
+ tokensOut: 125,
+ cacheReads: 60,
+ cacheWrites: 30,
+ cost: 0.08,
+ userMessage: 'modernize this class',
+ }))
+ await writeFile(join(taskDir, 'api_conversation_history.json'), makeApiHistory('anthropic/claude-sonnet-4-6'))
+
+ const source = { path: taskDir, project: 'IBM Bob', provider: 'ibm-bob' }
+ const calls: ParsedProviderCall[] = []
+ for await (const call of ibmBob.createSessionParser(source, new Set()).parse()) calls.push(call)
+
+ expect(calls).toHaveLength(1)
+ expect(calls[0]!).toMatchObject({
+ provider: 'ibm-bob',
+ model: 'claude-sonnet-4-6',
+ inputTokens: 250,
+ outputTokens: 125,
+ cacheReadInputTokens: 60,
+ cacheCreationInputTokens: 30,
+ costUSD: 0.08,
+ userMessage: 'modernize this class',
+ sessionId: 'task-001',
+ })
+ expect(calls[0]!.deduplicationKey).toBe('ibm-bob:task-001:0')
+ })
+
+ it('falls back to IBM Bob auto model when history has no model tag', async () => {
+ const taskDir = join(tmpDir, 'tasks', 'task-002')
+ await mkdir(taskDir, { recursive: true })
+ await writeFile(join(taskDir, 'ui_messages.json'), makeUiMessages({ tokensIn: 100, tokensOut: 50 }))
+ await writeFile(join(taskDir, 'api_conversation_history.json'), makeApiHistory())
+
+ const source = { path: taskDir, project: 'IBM Bob', provider: 'ibm-bob' }
+ const calls: ParsedProviderCall[] = []
+ for await (const call of ibmBob.createSessionParser(source, new Set()).parse()) calls.push(call)
+
+ expect(calls).toHaveLength(1)
+ expect(calls[0]!.model).toBe('ibm-bob-auto')
+ expect(calls[0]!.costUSD).toBeGreaterThan(0)
+ })
+
+ it('deduplicates across parser runs', async () => {
+ const taskDir = join(tmpDir, 'tasks', 'task-003')
+ await mkdir(taskDir, { recursive: true })
+ await writeFile(join(taskDir, 'ui_messages.json'), makeUiMessages({ tokensIn: 100, tokensOut: 50 }))
+
+ const source = { path: taskDir, project: 'IBM Bob', provider: 'ibm-bob' }
+ const seenKeys = new Set()
+
+ const calls1: ParsedProviderCall[] = []
+ for await (const call of ibmBob.createSessionParser(source, seenKeys).parse()) calls1.push(call)
+
+ const calls2: ParsedProviderCall[] = []
+ for await (const call of ibmBob.createSessionParser(source, seenKeys).parse()) calls2.push(call)
+
+ expect(calls1).toHaveLength(1)
+ expect(calls2).toHaveLength(0)
+ })
+})
+
+describe('ibm-bob provider - metadata', () => {
+ it('has correct name and displayName', () => {
+ expect(ibmBob.name).toBe('ibm-bob')
+ expect(ibmBob.displayName).toBe('IBM Bob')
+ })
+
+ it('uses shared short model display names', () => {
+ expect(ibmBob.modelDisplayName('ibm-bob-auto')).toBe('IBM Bob (auto)')
+ expect(ibmBob.modelDisplayName('claude-sonnet-4-6')).toBe('Sonnet 4.6')
+ })
+})
From c85beeaeaeaeec92671ddde6a0d1a385d5ff1d32 Mon Sep 17 00:00:00 2001
From: AgentSeal
Date: Mon, 11 May 2026 21:23:04 -0700
Subject: [PATCH 06/28] Fix Claude 1-hour cache write pricing (#317)
Co-authored-by: ozymandiashh <234437643+ozymandiashh@users.noreply.github.com>
Co-authored-by: iamtoruk
---
CHANGELOG.md | 15 ++++----
docs/providers/claude.md | 11 ++++++
src/daily-cache.ts | 25 ++++++-------
src/models.ts | 8 ++++-
src/parser.ts | 26 +++++++++++++-
src/types.ts | 4 +++
tests/daily-cache.test.ts | 30 ++++++++++++++++
tests/models.test.ts | 12 +++++++
tests/parser-claude-cwd.test.ts | 64 +++++++++++++++++++++++++++++----
9 files changed, 165 insertions(+), 30 deletions(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index b6d3191..d8c1163 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -3,13 +3,14 @@
## Unreleased
### Added (CLI)
-- **IBM Bob provider.** CodeBurn now discovers IBM Bob IDE task history from
- `User/globalStorage/ibm.bob-code/tasks//` under both the GA
- `IBM Bob` application data folder and preview-era `Bob-IDE` folder. The
- provider reuses the Cline-family `ui_messages.json` parser for token/cost
- records, reads `api_conversation_history.json` for model tags when present,
- falls back to `ibm-bob-auto` pricing otherwise, and appears in CLI,
- dashboard, JSON, docs, and the macOS provider tabs. Closes #248.
+- **IBM Bob provider.** Discovers IBM Bob IDE task history, reuses the
+ Cline-family parser for token/cost records, extracts model tags and
+ workspace-based project names from session data. Closes #248.
+
+### Fixed (CLI)
+- **Claude 1-hour cache write pricing.** 1-hour cache writes are now priced
+ at 2x base input (previously used the 5-minute 1.25x rate for all writes).
+ Daily cache bumped to v6 so stale totals are recomputed. Closes #276.
## 0.9.8 - 2026-05-10
diff --git a/docs/providers/claude.md b/docs/providers/claude.md
index b0b7b8c..b5954c1 100644
--- a/docs/providers/claude.md
+++ b/docs/providers/claude.md
@@ -25,6 +25,17 @@ JSONL, one event per line, per session file. Sessions live under `/ = {
'claude-opus-4-7': 6,
@@ -311,6 +312,7 @@ export function calculateCost(
cacheReadTokens: number,
webSearchRequests: number,
speed: 'standard' | 'fast' = 'standard',
+ oneHourCacheCreationTokens = 0,
): number {
const costs = getModelCosts(model)
if (!costs) {
@@ -336,11 +338,15 @@ export function calculateCost(
// from real spend in aggregate totals. NaN is also handled here; the
// arithmetic below short-circuits to 0 when any operand is non-finite.
const safe = (n: number) => (Number.isFinite(n) && n > 0 ? n : 0)
+ const safeOneHourCacheCreation = safe(oneHourCacheCreationTokens)
+ const safeCacheCreation = Math.max(safe(cacheCreationTokens), safeOneHourCacheCreation)
+ const safeFiveMinuteCacheCreation = Math.max(0, safeCacheCreation - safeOneHourCacheCreation)
return multiplier * (
safe(inputTokens) * costs.inputCostPerToken +
safe(outputTokens) * costs.outputCostPerToken +
- safe(cacheCreationTokens) * costs.cacheWriteCostPerToken +
+ safeFiveMinuteCacheCreation * costs.cacheWriteCostPerToken +
+ safeOneHourCacheCreation * costs.cacheWriteCostPerToken * ONE_HOUR_CACHE_WRITE_MULTIPLIER_FROM_FIVE_MINUTE_RATE +
safe(cacheReadTokens) * costs.cacheReadCostPerToken +
safe(webSearchRequests) * costs.webSearchCostPerRequest
)
diff --git a/src/parser.ts b/src/parser.ts
index d49697b..3bb602e 100644
--- a/src/parser.ts
+++ b/src/parser.ts
@@ -92,16 +92,39 @@ function getMessageId(entry: JournalEntry): string | null {
return msg?.id ?? null
}
+function positiveNumber(n: number | undefined): number {
+ return n !== undefined && Number.isFinite(n) && n > 0 ? n : 0
+}
+
+function extractClaudeCacheCreation(usage: AssistantMessageContent['usage']): { totalTokens: number; oneHourTokens: number } {
+ const legacyTotal = positiveNumber(usage.cache_creation_input_tokens)
+ const cacheCreation = usage.cache_creation
+ const fiveMinuteTokens = positiveNumber(cacheCreation?.ephemeral_5m_input_tokens)
+ const oneHourTokens = positiveNumber(cacheCreation?.ephemeral_1h_input_tokens)
+ const splitTotal = fiveMinuteTokens + oneHourTokens
+
+ if (splitTotal === 0) return { totalTokens: legacyTotal, oneHourTokens: 0 }
+
+ // Valid Claude usage reports the legacy total and split total as equal.
+ // Keep the larger value so malformed partial splits do not drop tokens.
+ const totalTokens = Math.max(legacyTotal, splitTotal)
+ return {
+ totalTokens,
+ oneHourTokens: Math.min(oneHourTokens, totalTokens),
+ }
+}
+
function parseApiCall(entry: JournalEntry): ParsedApiCall | null {
if (entry.type !== 'assistant') return null
const msg = entry.message as AssistantMessageContent | undefined
if (!msg?.usage || !msg?.model) return null
const usage = msg.usage
+ const cacheCreation = extractClaudeCacheCreation(usage)
const tokens: TokenUsage = {
inputTokens: usage.input_tokens ?? 0,
outputTokens: usage.output_tokens ?? 0,
- cacheCreationInputTokens: usage.cache_creation_input_tokens ?? 0,
+ cacheCreationInputTokens: cacheCreation.totalTokens,
cacheReadInputTokens: usage.cache_read_input_tokens ?? 0,
cachedInputTokens: 0,
reasoningTokens: 0,
@@ -118,6 +141,7 @@ function parseApiCall(entry: JournalEntry): ParsedApiCall | null {
tokens.cacheReadInputTokens,
tokens.webSearchRequests,
usage.speed ?? 'standard',
+ cacheCreation.oneHourTokens,
)
const bashCmds = extractBashCommandsFromContent(msg.content ?? [])
diff --git a/src/types.ts b/src/types.ts
index e5562e8..eecee5c 100644
--- a/src/types.ts
+++ b/src/types.ts
@@ -25,6 +25,10 @@ export type ApiUsage = {
input_tokens: number
output_tokens: number
cache_creation_input_tokens?: number
+ cache_creation?: {
+ ephemeral_5m_input_tokens?: number
+ ephemeral_1h_input_tokens?: number
+ }
cache_read_input_tokens?: number
server_tool_use?: {
web_search_requests?: number
diff --git a/tests/daily-cache.test.ts b/tests/daily-cache.test.ts
index 5ec2661..2f384cc 100644
--- a/tests/daily-cache.test.ts
+++ b/tests/daily-cache.test.ts
@@ -104,6 +104,36 @@ describe('loadDailyCache', () => {
expect(existsSync(join(TMP_CACHE_ROOT, 'daily-cache.json.v2.bak'))).toBe(true)
})
+ it('discards a v5 cache because cached Claude costs predate 1-hour cache pricing', async () => {
+ const saved = {
+ version: 5,
+ lastComputedDate: '2026-05-01',
+ days: [{
+ date: '2026-05-01',
+ cost: 0.37575,
+ calls: 1,
+ sessions: 1,
+ inputTokens: 0,
+ outputTokens: 0,
+ cacheReadTokens: 0,
+ cacheWriteTokens: 60_120,
+ editTurns: 0,
+ oneShotTurns: 0,
+ models: { 'Opus 4.7': { calls: 1, cost: 0.37575, inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 60_120 } },
+ categories: {},
+ providers: { claude: { calls: 1, cost: 0.37575 } },
+ }],
+ }
+ const { writeFile, mkdir } = await import('fs/promises')
+ await mkdir(TMP_CACHE_ROOT, { recursive: true })
+ await writeFile(join(TMP_CACHE_ROOT, 'daily-cache.json'), JSON.stringify(saved), 'utf-8')
+ const cache = await loadDailyCache()
+ expect(cache.version).toBe(DAILY_CACHE_VERSION)
+ expect(cache.days).toEqual([])
+ expect(cache.lastComputedDate).toBeNull()
+ expect(existsSync(join(TMP_CACHE_ROOT, 'daily-cache.json.v5.bak'))).toBe(true)
+ })
+
it('round-trips a valid cache through save and load', async () => {
const saved: DailyCache = {
version: DAILY_CACHE_VERSION,
diff --git a/tests/models.test.ts b/tests/models.test.ts
index 9fdf87b..41ccb5e 100644
--- a/tests/models.test.ts
+++ b/tests/models.test.ts
@@ -158,6 +158,18 @@ describe('calculateCost - OMP names produce non-zero cost', () => {
})
})
+describe('calculateCost - Claude cache write durations', () => {
+ it('prices 1-hour cache writes at 1.6x the 5-minute cache write rate', () => {
+ const fiveMinute = calculateCost('claude-opus-4-7', 0, 0, 1_000_000, 0, 0)
+ const oneHour = calculateCost('claude-opus-4-7', 0, 0, 1_000_000, 0, 0, 'standard', 1_000_000)
+ const mixed = calculateCost('claude-opus-4-7', 0, 0, 100_000, 0, 0, 'standard', 60_000)
+
+ expect(fiveMinute).toBeCloseTo(6.25, 6)
+ expect(oneHour).toBeCloseTo(10, 6)
+ expect(mixed).toBeCloseTo(0.85, 6)
+ })
+})
+
describe('existing model names still resolve', () => {
it('canonical claude-opus-4-6', () => {
expect(getModelCosts('claude-opus-4-6')).not.toBeNull()
diff --git a/tests/parser-claude-cwd.test.ts b/tests/parser-claude-cwd.test.ts
index 65c96db..179ad7c 100644
--- a/tests/parser-claude-cwd.test.ts
+++ b/tests/parser-claude-cwd.test.ts
@@ -31,7 +31,14 @@ function dayRange(day: string): DateRange {
}
}
-async function writeClaudeSession(projectSlug: string, sessionId: string, cwd: string, timestamp: string): Promise {
+async function writeClaudeSession(
+ projectSlug: string,
+ sessionId: string,
+ cwd: string,
+ timestamp: string,
+ usage: Record = { input_tokens: 100, output_tokens: 50 },
+ model = 'claude-sonnet-4-5',
+): Promise {
const projectDir = join(tmpDir, 'projects', projectSlug)
await mkdir(projectDir, { recursive: true })
const filePath = join(projectDir, `${sessionId}.jsonl`)
@@ -44,12 +51,9 @@ async function writeClaudeSession(projectSlug: string, sessionId: string, cwd: s
id: `msg-${sessionId}`,
type: 'message',
role: 'assistant',
- model: 'claude-sonnet-4-5',
+ model,
content: [],
- usage: {
- input_tokens: 100,
- output_tokens: 50,
- },
+ usage,
},
}) + '\n')
@@ -158,3 +162,51 @@ describe('Claude cwd project paths', () => {
expect(projects[0]!.projectPath).toBe('fallback/slug')
})
})
+
+describe('Claude cache creation pricing', () => {
+ it('prices 1-hour cache writes from usage.cache_creation at the 2x input rate', async () => {
+ await writeClaudeSession(
+ 'cache-pricing',
+ 'one-hour-cache',
+ '/tmp/cache-pricing',
+ '2099-05-05T10:00:00.000Z',
+ {
+ input_tokens: 0,
+ output_tokens: 0,
+ cache_creation_input_tokens: 60_120,
+ cache_creation: {
+ ephemeral_5m_input_tokens: 0,
+ ephemeral_1h_input_tokens: 60_120,
+ },
+ },
+ 'claude-opus-4-7',
+ )
+
+ const projects = await parseAllSessions(dayRange('2099-05-05'), 'claude')
+
+ expect(projects).toHaveLength(1)
+ expect(projects[0]!.sessions[0]!.totalCacheWriteTokens).toBe(60_120)
+ expect(projects[0]!.totalCostUSD).toBeCloseTo(0.6012, 6)
+ })
+
+ it('falls back to the legacy 5-minute cache write rate when split fields are absent', async () => {
+ await writeClaudeSession(
+ 'legacy-cache-pricing',
+ 'legacy-cache',
+ '/tmp/legacy-cache-pricing',
+ '2099-05-06T10:00:00.000Z',
+ {
+ input_tokens: 0,
+ output_tokens: 0,
+ cache_creation_input_tokens: 60_120,
+ },
+ 'claude-opus-4-7',
+ )
+
+ const projects = await parseAllSessions(dayRange('2099-05-06'), 'claude')
+
+ expect(projects).toHaveLength(1)
+ expect(projects[0]!.sessions[0]!.totalCacheWriteTokens).toBe(60_120)
+ expect(projects[0]!.totalCostUSD).toBeCloseTo(0.37575, 6)
+ })
+})
From a1b5e4bd00012de9b72efc67edabd4c8c0740e62 Mon Sep 17 00:00:00 2001
From: AgentSeal
Date: Mon, 11 May 2026 21:30:27 -0700
Subject: [PATCH 07/28] Fix OpenCode MCP usage reporting (#318)
* Fix OpenCode MCP usage reporting
* Move OpenCode MCP changelog entry to Unreleased section
---------
Co-authored-by: ozymandiashh <234437643+ozymandiashh@users.noreply.github.com>
Co-authored-by: iamtoruk
---
CHANGELOG.md | 4 ++
docs/providers/opencode.md | 14 ++--
src/providers/opencode.ts | 21 +++++-
tests/providers/opencode.test.ts | 118 +++++++++++++++++++++++++++++++
4 files changed, 151 insertions(+), 6 deletions(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index d8c1163..8f70616 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -11,6 +11,10 @@
- **Claude 1-hour cache write pricing.** 1-hour cache writes are now priced
at 2x base input (previously used the 5-minute 1.25x rate for all writes).
Daily cache bumped to v6 so stale totals are recomputed. Closes #276.
+- **OpenCode MCP usage now counted.** OpenCode stores MCP tool calls as
+ `_` names, which the shared MCP pipeline did not recognize.
+ The provider now normalizes these to the canonical `mcp____`
+ form so MCP breakdowns and `optimize` work correctly. Closes #308.
## 0.9.8 - 2026-05-10
diff --git a/docs/providers/opencode.md b/docs/providers/opencode.md
index 0251fcd..0148cc9 100644
--- a/docs/providers/opencode.md
+++ b/docs/providers/opencode.md
@@ -4,7 +4,7 @@ OpenCode (sst/opencode).
- **Source:** `src/providers/opencode.ts`
- **Loading:** lazy (`src/providers/index.ts:59-75`)
-- **Test:** `tests/providers/opencode.test.ts` (558 lines, the largest provider test)
+- **Test:** `tests/providers/opencode.test.ts` (676 lines, the largest provider test)
## Where it reads from
@@ -20,14 +20,18 @@ None.
## Deduplication
-Per `:` (`opencode.ts:242`).
+Per `:`.
## Quirks
-- **Schema validation is loud.** When a required table is missing, the parser logs an actionable warning telling the user which table is gone and what version of OpenCode it expects (`opencode.ts:104-131`). This is the right behavior; do not silently swallow these.
-- Source paths are encoded as `:` (`opencode.ts:147-150`).
-- Each message's `parts` are indexed (`opencode.ts:177-191`); preserving the order matters for reasoning-token correctness.
+- **Schema validation is loud.** When a required table is missing, the parser logs an actionable warning telling the user which table is gone and what version of OpenCode it expects. This is the right behavior; do not silently swallow these.
+- Source paths are encoded as `:`.
+- Each message's `parts` are indexed; preserving the order matters for reasoning-token correctness.
- Tokens are reported across `input`, `output`, `reasoning`, `cache.read`, and `cache.write`. Anthropic semantics.
+- External MCP tools are stored as `_` names (for example
+ `clickup_clickup_get_task`). The provider normalizes those to CodeBurn's
+ canonical `mcp____` names before aggregation so shared MCP
+ panels and `optimize` findings count OpenCode usage.
## When fixing a bug here
diff --git a/src/providers/opencode.ts b/src/providers/opencode.ts
index b39230c..5a2546f 100644
--- a/src/providers/opencode.ts
+++ b/src/providers/opencode.ts
@@ -64,6 +64,25 @@ const toolNameMap: Record = {
patch: 'Patch',
}
+function normalizeToolName(rawTool?: string): string {
+ if (!rawTool) return ''
+ if (rawTool.startsWith('mcp__')) return rawTool
+
+ const builtIn = toolNameMap[rawTool]
+ if (builtIn) return builtIn
+
+ // OpenCode stores MCP calls as `_` with no separate server field.
+ // Built-ins are handled above, and server ids are assumed not to contain `_`.
+ const serverSeparator = rawTool.indexOf('_')
+ if (serverSeparator > 0 && serverSeparator < rawTool.length - 1) {
+ const server = rawTool.slice(0, serverSeparator)
+ const tool = rawTool.slice(serverSeparator + 1)
+ return `mcp__${server}__${tool}`
+ }
+
+ return rawTool
+}
+
function sanitize(dir: string): string {
return dir.replace(/^\//, '').replace(/\//g, '-')
}
@@ -232,7 +251,7 @@ function createParser(
const msgParts = partsByMsg.get(msg.id) ?? []
const toolParts = msgParts.filter((p) => p.type === 'tool')
const tools = toolParts
- .map((p) => toolNameMap[p.tool ?? ''] ?? p.tool ?? '')
+ .map((p) => normalizeToolName(p.tool))
.filter(Boolean)
const bashCommands = toolParts
diff --git a/tests/providers/opencode.test.ts b/tests/providers/opencode.test.ts
index bd715be..3637b79 100644
--- a/tests/providers/opencode.test.ts
+++ b/tests/providers/opencode.test.ts
@@ -337,6 +337,124 @@ skipUnlessSqlite('opencode provider - session parsing', () => {
expect(call.deduplicationKey).toBe('opencode:sess-1:msg-2')
})
+ it('normalizes opencode MCP tool names for shared MCP reporting', async () => {
+ const dbPath = createTestDb(tmpDir)
+ withTestDb(dbPath, (db) => {
+ insertSession(db, 'sess-1')
+
+ insertMessage(db, 'msg-1', 'sess-1', 1700000000000, { role: 'user' })
+ insertPart(db, 'part-1', 'msg-1', 'sess-1', { type: 'text', text: 'look up the ClickUp task' })
+
+ insertMessage(db, 'msg-2', 'sess-1', 1700000001000, {
+ role: 'assistant',
+ modelID: 'claude-opus-4-6',
+ cost: 0.05,
+ tokens: { input: 100, output: 200, reasoning: 0, cache: { read: 0, write: 0 } },
+ })
+ insertPart(db, 'part-2', 'msg-2', 'sess-1', {
+ type: 'tool',
+ tool: 'clickup_clickup_get_task',
+ state: { status: 'completed', input: {} },
+ })
+ insertPart(db, 'part-3', 'msg-2', 'sess-1', {
+ type: 'tool',
+ tool: 'figma_get_file',
+ state: { status: 'completed', input: {} },
+ })
+ })
+
+ const calls = await collectCalls(createOpenCodeProvider(tmpDir), dbPath, 'sess-1')
+
+ expect(calls).toHaveLength(1)
+ expect(calls[0]!.tools).toEqual([
+ 'mcp__clickup__clickup_get_task',
+ 'mcp__figma__get_file',
+ ])
+ })
+
+ it('preserves already-normalized MCP tool names', async () => {
+ const dbPath = createTestDb(tmpDir)
+ withTestDb(dbPath, (db) => {
+ insertSession(db, 'sess-1')
+ insertMessage(db, 'msg-1', 'sess-1', 1700000001000, {
+ role: 'assistant',
+ modelID: 'claude-opus-4-6',
+ cost: 0.05,
+ tokens: { input: 100, output: 200, reasoning: 0, cache: { read: 0, write: 0 } },
+ })
+ insertPart(db, 'part-1', 'msg-1', 'sess-1', {
+ type: 'tool',
+ tool: 'mcp__github__search_code',
+ state: { status: 'completed', input: {} },
+ })
+ })
+
+ const calls = await collectCalls(createOpenCodeProvider(tmpDir), dbPath, 'sess-1')
+
+ expect(calls).toHaveLength(1)
+ expect(calls[0]!.tools).toEqual(['mcp__github__search_code'])
+ })
+
+ it('keeps extension tool names without a server prefix as regular tools', async () => {
+ const dbPath = createTestDb(tmpDir)
+ withTestDb(dbPath, (db) => {
+ insertSession(db, 'sess-1')
+ insertMessage(db, 'msg-1', 'sess-1', 1700000001000, {
+ role: 'assistant',
+ modelID: 'claude-opus-4-6',
+ cost: 0.05,
+ tokens: { input: 100, output: 200, reasoning: 0, cache: { read: 0, write: 0 } },
+ })
+ insertPart(db, 'part-1', 'msg-1', 'sess-1', {
+ type: 'tool',
+ tool: 'customtool',
+ state: { status: 'completed', input: {} },
+ })
+ })
+
+ const calls = await collectCalls(createOpenCodeProvider(tmpDir), dbPath, 'sess-1')
+
+ expect(calls).toHaveLength(1)
+ expect(calls[0]!.tools).toEqual(['customtool'])
+ })
+
+ it('keeps malformed server-prefixed tool names as regular tools', async () => {
+ const dbPath = createTestDb(tmpDir)
+ withTestDb(dbPath, (db) => {
+ insertSession(db, 'sess-1')
+ insertMessage(db, 'msg-1', 'sess-1', 1700000001000, {
+ role: 'assistant',
+ modelID: 'claude-opus-4-6',
+ cost: 0.05,
+ tokens: { input: 100, output: 200, reasoning: 0, cache: { read: 0, write: 0 } },
+ })
+ insertPart(db, 'part-1', 'msg-1', 'sess-1', {
+ type: 'tool',
+ tool: '_missing_server',
+ state: { status: 'completed', input: {} },
+ })
+ insertPart(db, 'part-2', 'msg-1', 'sess-1', {
+ type: 'tool',
+ tool: 'missing_',
+ state: { status: 'completed', input: {} },
+ })
+ insertPart(db, 'part-3', 'msg-1', 'sess-1', {
+ type: 'tool',
+ tool: '_',
+ state: { status: 'completed', input: {} },
+ })
+ })
+
+ const calls = await collectCalls(createOpenCodeProvider(tmpDir), dbPath, 'sess-1')
+
+ expect(calls).toHaveLength(1)
+ expect(calls[0]!.tools).toEqual([
+ '_missing_server',
+ 'missing_',
+ '_',
+ ])
+ })
+
it('skips zero-token messages with zero cost', async () => {
const dbPath = createTestDb(tmpDir)
withTestDb(dbPath, (db) => {
From 38e41e93c38c040c18778d829112e1947b5a4f04 Mon Sep 17 00:00:00 2001
From: Resham Joshi <65915470+iamtoruk@users.noreply.github.com>
Date: Mon, 11 May 2026 21:50:17 -0700
Subject: [PATCH 08/28] Add Node version guard for unsupported runtimes (#319)
Split CLI into a tiny launcher (src/cli.ts) that checks for Node >= 22.13.0
before dynamically importing the full CLI (src/main.ts). Users on Node 18
now get a clear upgrade message instead of a cryptic regex parse error from
string-width. Closes #232.
---
package.json | 4 +-
src/cli.ts | 987 +------------------------------------------------
src/main.ts | 978 ++++++++++++++++++++++++++++++++++++++++++++++++
tsup.config.ts | 5 +-
4 files changed, 993 insertions(+), 981 deletions(-)
create mode 100644 src/main.ts
diff --git a/package.json b/package.json
index b831b30..72dd6db 100644
--- a/package.json
+++ b/package.json
@@ -12,7 +12,7 @@
],
"scripts": {
"bundle-litellm": "node scripts/bundle-litellm.mjs",
- "build": "node scripts/bundle-litellm.mjs && tsup",
+ "build": "node scripts/bundle-litellm.mjs && tsup && node -e \"require('fs').copyFileSync('src/cli.ts','dist/cli.js')\"",
"dev": "tsx src/cli.ts",
"test": "vitest",
"prepublishOnly": "npm run build"
@@ -31,7 +31,7 @@
"developer-tools"
],
"engines": {
- "node": ">=22"
+ "node": ">=22.13.0"
},
"author": "AgentSeal ",
"license": "MIT",
diff --git a/src/cli.ts b/src/cli.ts
index 4ebfe33..dec3d49 100644
--- a/src/cli.ts
+++ b/src/cli.ts
@@ -1,978 +1,15 @@
-import { Command } from 'commander'
-import { installMenubarApp } from './menubar-installer.js'
-import { exportCsv, exportJson, type PeriodExport } from './export.js'
-import { loadPricing, setModelAliases } from './models.js'
-import { parseAllSessions, filterProjectsByName } from './parser.js'
-import { convertCost } from './currency.js'
-import { renderStatusBar } from './format.js'
-import { type PeriodData, type ProviderCost } from './menubar-json.js'
-import { buildMenubarPayload } from './menubar-json.js'
-import { getDaysInRange, ensureCacheHydrated, emptyCache, BACKFILL_DAYS, toDateString } from './daily-cache.js'
-import { aggregateProjectsIntoDays, buildPeriodDataFromDays, dateKey } from './day-aggregator.js'
-import { CATEGORY_LABELS, type DateRange, type ProjectSummary, type TaskCategory } from './types.js'
-import { aggregateModelEfficiency } from './model-efficiency.js'
-import { renderDashboard } from './dashboard.js'
-import { formatDateRangeLabel, parseDateRangeFlags, getDateRange, toPeriod, type Period } from './cli-date.js'
-import { runOptimize, scanAndDetect } from './optimize.js'
-import { renderCompare } from './compare.js'
-import { getAllProviders } from './providers/index.js'
-import { clearPlan, readConfig, readPlan, saveConfig, savePlan, getConfigFilePath, type PlanId } from './config.js'
-import { clampResetDay, getPlanUsageOrNull, type PlanUsage } from './plan-usage.js'
-import { getPresetPlan, isPlanId, isPlanProvider, planDisplayName } from './plans.js'
-import { createRequire } from 'node:module'
-
-const require = createRequire(import.meta.url)
-const { version } = require('../package.json')
-import { loadCurrency, getCurrency, isValidCurrencyCode } from './currency.js'
-
-async function hydrateCache() {
- try {
- return await ensureCacheHydrated(
- (range) => parseAllSessions(range, 'all'),
- aggregateProjectsIntoDays,
- )
- } catch {
- return emptyCache()
- }
+#!/usr/bin/env node
+// This launcher must stay parseable by Node 18. Do NOT add static imports.
+const [major, minor] = process.versions.node.split('.').map(Number)
+if (major < 22 || (major === 22 && minor < 13)) {
+ process.stderr.write(
+ `codeburn requires Node.js >= 22.13.0 (current: ${process.version})\n` +
+ 'Upgrade at https://nodejs.org/\n',
+ )
+ process.exit(1)
}
-function collect(val: string, acc: string[]): string[] {
- acc.push(val)
- return acc
-}
-
-function parseNumber(value: string): number {
- return Number(value)
-}
-
-function parseInteger(value: string): number {
- return parseInt(value, 10)
-}
-
-type JsonPlanSummary = {
- id: PlanId
- budget: number
- spent: number
- percentUsed: number
- status: 'under' | 'near' | 'over'
- projectedMonthEnd: number
- daysUntilReset: number
- periodStart: string
- periodEnd: string
-}
-
-function toJsonPlanSummary(planUsage: PlanUsage): JsonPlanSummary {
- return {
- id: planUsage.plan.id,
- budget: convertCost(planUsage.budgetUsd),
- spent: convertCost(planUsage.spentApiEquivalentUsd),
- percentUsed: Math.round(planUsage.percentUsed * 10) / 10,
- status: planUsage.status,
- projectedMonthEnd: convertCost(planUsage.projectedMonthUsd),
- daysUntilReset: planUsage.daysUntilReset,
- periodStart: planUsage.periodStart.toISOString(),
- periodEnd: planUsage.periodEnd.toISOString(),
- }
-}
-
-function assertFormat(value: string, allowed: readonly string[], command: string): void {
- if (!allowed.includes(value)) {
- process.stderr.write(
- `codeburn ${command}: unknown format "${value}". Valid values: ${allowed.join(', ')}.\n`
- )
- process.exit(1)
- }
-}
-
-async function runJsonReport(period: Period, provider: string, project: string[], exclude: string[]): Promise {
- await loadPricing()
- const { range, label } = getDateRange(period)
- const projects = filterProjectsByName(await parseAllSessions(range, provider), project, exclude)
- const report: ReturnType & { plan?: JsonPlanSummary } = buildJsonReport(projects, label, period)
- const planUsage = await getPlanUsageOrNull()
- if (planUsage) {
- report.plan = toJsonPlanSummary(planUsage)
- }
- console.log(JSON.stringify(report, null, 2))
-}
-
-const program = new Command()
- .name('codeburn')
- .description('See where your AI coding tokens go - by task, tool, model, and project')
- .version(version)
- .option('--verbose', 'print warnings to stderr on read failures and skipped files')
- .option('--timezone ', 'IANA timezone for date grouping (e.g. Asia/Tokyo, America/New_York)')
-
-program.hook('preAction', async (thisCommand) => {
- const tz = thisCommand.opts<{ timezone?: string }>().timezone ?? process.env['CODEBURN_TZ']
- if (tz) {
- try {
- Intl.DateTimeFormat(undefined, { timeZone: tz })
- } catch {
- console.error(`\n Invalid timezone: "${tz}". Use an IANA timezone like "America/New_York" or "Asia/Tokyo".\n`)
- process.exit(1)
- }
- process.env.TZ = tz
- }
- const config = await readConfig()
- setModelAliases(config.modelAliases ?? {})
- if (thisCommand.opts<{ verbose?: boolean }>().verbose) {
- process.env['CODEBURN_VERBOSE'] = '1'
- }
- await loadCurrency()
+import('./main.js').catch((err) => {
+ process.stderr.write(String(err?.message ?? err) + '\n')
+ process.exit(1)
})
-
-function buildJsonReport(projects: ProjectSummary[], period: string, periodKey: string) {
- const sessions = projects.flatMap(p => p.sessions)
- const { code } = getCurrency()
-
- const totalCostUSD = projects.reduce((s, p) => s + p.totalCostUSD, 0)
- const totalCalls = projects.reduce((s, p) => s + p.totalApiCalls, 0)
- const totalSessions = projects.reduce((s, p) => s + p.sessions.length, 0)
- const totalInput = sessions.reduce((s, sess) => s + sess.totalInputTokens, 0)
- const totalOutput = sessions.reduce((s, sess) => s + sess.totalOutputTokens, 0)
- const totalCacheRead = sessions.reduce((s, sess) => s + sess.totalCacheReadTokens, 0)
- const totalCacheWrite = sessions.reduce((s, sess) => s + sess.totalCacheWriteTokens, 0)
- // Match src/menubar-json.ts:cacheHitPercent: reads over reads+fresh-input. cache_write
- // counts tokens being stored, not served, so it doesn't belong in the denominator.
- const cacheHitDenom = totalInput + totalCacheRead
- const cacheHitPercent = cacheHitDenom > 0 ? Math.round((totalCacheRead / cacheHitDenom) * 1000) / 10 : 0
-
- // Per-day rollup. Mirrors parser.ts categoryBreakdown semantics so a
- // consumer summing daily[].editTurns over a period gets the same total as
- // sum(activities[].editTurns) for that period: every turn counts once for
- // `turns`, edit turns count for `editTurns`, edit turns with zero retries
- // count for `oneShotTurns`. Issue #279 — daily-resolution efficiency
- // dashboards need this without re-deriving from activity-level rollups.
- const dailyMap: Record = {}
- for (const sess of sessions) {
- for (const turn of sess.turns) {
- // Prefer the user-message timestamp on the turn; fall back to the first
- // assistant-call timestamp when the user line is missing (continuation
- // sessions where the JSONL begins mid-conversation). Previously these
- // turns dropped from daily but stayed in activities, breaking the
- // sum(daily[].editTurns) === sum(activities[].editTurns) invariant.
- const ts = turn.timestamp || turn.assistantCalls[0]?.timestamp
- if (!ts) { continue }
- const day = dateKey(ts)
- if (!dailyMap[day]) { dailyMap[day] = { cost: 0, calls: 0, turns: 0, editTurns: 0, oneShotTurns: 0 } }
- dailyMap[day].turns += 1
- if (turn.hasEdits) {
- dailyMap[day].editTurns += 1
- if (turn.retries === 0) dailyMap[day].oneShotTurns += 1
- }
- for (const call of turn.assistantCalls) {
- dailyMap[day].cost += call.costUSD
- dailyMap[day].calls += 1
- }
- }
- }
- const daily = Object.entries(dailyMap).sort().map(([date, d]) => ({
- date,
- cost: convertCost(d.cost),
- calls: d.calls,
- turns: d.turns,
- editTurns: d.editTurns,
- oneShotTurns: d.oneShotTurns,
- // Pre-computed convenience for dashboards that don't want to do the math.
- // null when there are no edit turns (the rate is undefined, not zero —
- // a day where the user only had Q&A turns shouldn't read as 0% one-shot).
- oneShotRate: d.editTurns > 0
- ? Math.round((d.oneShotTurns / d.editTurns) * 1000) / 10
- : null,
- }))
-
- const projectList = projects.map(p => ({
- name: p.project,
- path: p.projectPath,
- cost: convertCost(p.totalCostUSD),
- avgCostPerSession: p.sessions.length > 0
- ? convertCost(p.totalCostUSD / p.sessions.length)
- : null,
- calls: p.totalApiCalls,
- sessions: p.sessions.length,
- }))
-
- const modelMap: Record = {}
- const modelEfficiency = aggregateModelEfficiency(projects)
- for (const sess of sessions) {
- for (const [model, d] of Object.entries(sess.modelBreakdown)) {
- if (!modelMap[model]) { modelMap[model] = { calls: 0, cost: 0, inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0 } }
- modelMap[model].calls += d.calls
- modelMap[model].cost += d.costUSD
- modelMap[model].inputTokens += d.tokens.inputTokens
- modelMap[model].outputTokens += d.tokens.outputTokens
- modelMap[model].cacheReadTokens += d.tokens.cacheReadInputTokens
- modelMap[model].cacheWriteTokens += d.tokens.cacheCreationInputTokens
- }
- }
- const models = Object.entries(modelMap)
- .sort(([, a], [, b]) => b.cost - a.cost)
- .map(([name, { cost, ...rest }]) => {
- const efficiency = modelEfficiency.get(name)
- return {
- name,
- ...rest,
- cost: convertCost(cost),
- editTurns: efficiency?.editTurns ?? 0,
- oneShotTurns: efficiency?.oneShotTurns ?? 0,
- oneShotRate: efficiency?.oneShotRate ?? null,
- retriesPerEdit: efficiency?.retriesPerEdit ?? null,
- costPerEdit: efficiency?.costPerEditUSD !== null && efficiency?.costPerEditUSD !== undefined
- ? convertCost(efficiency.costPerEditUSD)
- : null,
- }
- })
-
- const catMap: Record = {}
- for (const sess of sessions) {
- for (const [cat, d] of Object.entries(sess.categoryBreakdown)) {
- if (!catMap[cat]) { catMap[cat] = { turns: 0, cost: 0, editTurns: 0, oneShotTurns: 0 } }
- catMap[cat].turns += d.turns
- catMap[cat].cost += d.costUSD
- catMap[cat].editTurns += d.editTurns
- catMap[cat].oneShotTurns += d.oneShotTurns
- }
- }
- const activities = Object.entries(catMap)
- .sort(([, a], [, b]) => b.cost - a.cost)
- .map(([cat, d]) => ({
- category: CATEGORY_LABELS[cat as TaskCategory] ?? cat,
- cost: convertCost(d.cost),
- turns: d.turns,
- editTurns: d.editTurns,
- oneShotTurns: d.oneShotTurns,
- oneShotRate: d.editTurns > 0 ? Math.round((d.oneShotTurns / d.editTurns) * 1000) / 10 : null,
- }))
-
- const toolMap: Record = {}
- const mcpMap: Record = {}
- const bashMap: Record = {}
- for (const sess of sessions) {
- for (const [tool, d] of Object.entries(sess.toolBreakdown)) {
- toolMap[tool] = (toolMap[tool] ?? 0) + d.calls
- }
- for (const [server, d] of Object.entries(sess.mcpBreakdown)) {
- mcpMap[server] = (mcpMap[server] ?? 0) + d.calls
- }
- for (const [cmd, d] of Object.entries(sess.bashBreakdown)) {
- bashMap[cmd] = (bashMap[cmd] ?? 0) + d.calls
- }
- }
-
- const sortedMap = (m: Record) =>
- Object.entries(m).sort(([, a], [, b]) => b - a).map(([name, calls]) => ({ name, calls }))
-
- const topSessions = projects
- .flatMap(p => p.sessions.map(s => ({ project: p.project, sessionId: s.sessionId, date: s.firstTimestamp ? dateKey(s.firstTimestamp) : null, cost: convertCost(s.totalCostUSD), calls: s.apiCalls })))
- .sort((a, b) => b.cost - a.cost)
- .slice(0, 5)
-
- return {
- generated: new Date().toISOString(),
- currency: code,
- period,
- periodKey,
- overview: {
- cost: convertCost(totalCostUSD),
- calls: totalCalls,
- sessions: totalSessions,
- cacheHitPercent,
- tokens: {
- input: totalInput,
- output: totalOutput,
- cacheRead: totalCacheRead,
- cacheWrite: totalCacheWrite,
- },
- },
- daily,
- projects: projectList,
- models,
- activities,
- tools: sortedMap(toolMap),
- mcpServers: sortedMap(mcpMap),
- shellCommands: sortedMap(bashMap),
- topSessions,
- }
-}
-
-program
- .command('report', { isDefault: true })
- .description('Interactive usage dashboard')
- .option('-p, --period ', 'Starting period: today, week, 30days, month, all', 'week')
- .option('--from ', 'Start date (YYYY-MM-DD). Overrides --period when set')
- .option('--to ', 'End date (YYYY-MM-DD). Overrides --period when set')
- .option('--provider ', 'Filter by provider (e.g. claude, gemini, cursor, copilot)', 'all')
- .option('--format ', 'Output format: tui, json', 'tui')
- .option('--project ', 'Show only projects matching name (repeatable)', collect, [])
- .option('--exclude ', 'Exclude projects matching name (repeatable)', collect, [])
- .option('--refresh ', 'Auto-refresh interval in seconds (0 to disable)', parseInteger, 30)
- .action(async (opts) => {
- assertFormat(opts.format, ['tui', 'json'], 'report')
- let customRange: DateRange | null = null
- try {
- customRange = parseDateRangeFlags(opts.from, opts.to)
- } catch (err) {
- const message = err instanceof Error ? err.message : String(err)
- console.error(`\n Error: ${message}\n`)
- process.exit(1)
- }
-
- const period = toPeriod(opts.period)
- if (opts.format === 'json') {
- await loadPricing()
- await hydrateCache()
- if (customRange) {
- const label = formatDateRangeLabel(opts.from, opts.to)
- const projects = filterProjectsByName(
- await parseAllSessions(customRange, opts.provider),
- opts.project,
- opts.exclude,
- )
- console.log(JSON.stringify(buildJsonReport(projects, label, 'custom'), null, 2))
- } else {
- await runJsonReport(period, opts.provider, opts.project, opts.exclude)
- }
- return
- }
- await hydrateCache()
- const customRangeLabel = customRange ? formatDateRangeLabel(opts.from, opts.to) : undefined
- await renderDashboard(period, opts.provider, opts.refresh, opts.project, opts.exclude, customRange, customRangeLabel)
- })
-
-function buildPeriodData(label: string, projects: ProjectSummary[]): PeriodData {
- const sessions = projects.flatMap(p => p.sessions)
- const catTotals: Record = {}
- const modelTotals: Record = {}
- let inputTokens = 0, outputTokens = 0, cacheReadTokens = 0, cacheWriteTokens = 0
-
- for (const sess of sessions) {
- inputTokens += sess.totalInputTokens
- outputTokens += sess.totalOutputTokens
- cacheReadTokens += sess.totalCacheReadTokens
- cacheWriteTokens += sess.totalCacheWriteTokens
- for (const [cat, d] of Object.entries(sess.categoryBreakdown)) {
- if (!catTotals[cat]) catTotals[cat] = { turns: 0, cost: 0, editTurns: 0, oneShotTurns: 0 }
- catTotals[cat].turns += d.turns
- catTotals[cat].cost += d.costUSD
- catTotals[cat].editTurns += d.editTurns
- catTotals[cat].oneShotTurns += d.oneShotTurns
- }
- for (const [model, d] of Object.entries(sess.modelBreakdown)) {
- if (!modelTotals[model]) modelTotals[model] = { calls: 0, cost: 0 }
- modelTotals[model].calls += d.calls
- modelTotals[model].cost += d.costUSD
- }
- }
-
- return {
- label,
- cost: projects.reduce((s, p) => s + p.totalCostUSD, 0),
- calls: projects.reduce((s, p) => s + p.totalApiCalls, 0),
- sessions: projects.reduce((s, p) => s + p.sessions.length, 0),
- inputTokens, outputTokens, cacheReadTokens, cacheWriteTokens,
- categories: Object.entries(catTotals)
- .sort(([, a], [, b]) => b.cost - a.cost)
- .map(([cat, d]) => ({ name: CATEGORY_LABELS[cat as TaskCategory] ?? cat, ...d })),
- models: Object.entries(modelTotals)
- .sort(([, a], [, b]) => b.cost - a.cost)
- .map(([name, d]) => ({ name, ...d })),
- }
-}
-
-program
- .command('status')
- .description('Compact status output (today + month)')
- .option('--format ', 'Output format: terminal, menubar-json, json', 'terminal')
- .option('--provider ', 'Filter by provider (e.g. claude, gemini, cursor, copilot)', 'all')
- .option('--project ', 'Show only projects matching name (repeatable)', collect, [])
- .option('--exclude ', 'Exclude projects matching name (repeatable)', collect, [])
- .option('--period ', 'Primary period for menubar-json: today, week, 30days, month, all', 'today')
- .option('--no-optimize', 'Skip optimize findings (menubar-json only, faster)')
- .action(async (opts) => {
- assertFormat(opts.format, ['terminal', 'menubar-json', 'json'], 'status')
- await loadPricing()
- const pf = opts.provider
- const fp = (p: ProjectSummary[]) => filterProjectsByName(p, opts.project, opts.exclude)
- if (opts.format === 'menubar-json') {
- const periodInfo = getDateRange(opts.period)
- const now = new Date()
- const todayStart = new Date(now.getFullYear(), now.getMonth(), now.getDate())
- const yesterdayStr = toDateString(new Date(now.getFullYear(), now.getMonth(), now.getDate() - 1))
- const isAllProviders = pf === 'all'
-
- const cache = await hydrateCache()
-
- // CURRENT PERIOD DATA
- // - .all provider: assemble from cache + today (fast)
- // - specific provider: parse the period range with provider filter (correct, but slower)
- let currentData: PeriodData
- let scanProjects: ProjectSummary[]
- let scanRange: DateRange
-
- if (isAllProviders) {
- // Parse only today's sessions; historical data comes from cache to avoid double-counting
- const todayRange: DateRange = { start: todayStart, end: new Date() }
- const todayProjects = fp(await parseAllSessions(todayRange, 'all'))
- const todayDays = aggregateProjectsIntoDays(todayProjects)
- const rangeStartStr = toDateString(periodInfo.range.start)
- const rangeEndStr = toDateString(periodInfo.range.end)
- const historicalDays = getDaysInRange(cache, rangeStartStr, yesterdayStr)
- const todayInRange = todayDays.filter(d => d.date >= rangeStartStr && d.date <= rangeEndStr)
- const allDays = [...historicalDays, ...todayInRange].sort((a, b) => a.date.localeCompare(b.date))
- currentData = buildPeriodDataFromDays(allDays, periodInfo.label)
- scanProjects = todayProjects
- scanRange = periodInfo.range
- } else {
- const projects = fp(await parseAllSessions(periodInfo.range, pf))
- currentData = buildPeriodData(periodInfo.label, projects)
- scanProjects = projects
- scanRange = periodInfo.range
- }
-
- // PROVIDERS
- // For .all: enumerate every provider with cost across the period (from cache) + installed-but-zero.
- // For specific: just this single provider with its scoped cost.
- const allProviders = await getAllProviders()
- const displayNameByName = new Map(allProviders.map(p => [p.name, p.displayName]))
- const providers: ProviderCost[] = []
- if (isAllProviders) {
- // Parse only today; historical provider costs come from cache
- const todayRangeForProviders: DateRange = { start: todayStart, end: new Date() }
- const todayDaysForProviders = aggregateProjectsIntoDays(fp(await parseAllSessions(todayRangeForProviders, 'all')))
- const rangeStartStr = toDateString(periodInfo.range.start)
- const todayStr = toDateString(todayStart)
- const allDaysForProviders = [
- ...getDaysInRange(cache, rangeStartStr, yesterdayStr),
- ...todayDaysForProviders.filter(d => d.date === todayStr),
- ]
- const providerTotals: Record = {}
- for (const d of allDaysForProviders) {
- for (const [name, p] of Object.entries(d.providers)) {
- providerTotals[name] = (providerTotals[name] ?? 0) + p.cost
- }
- }
- for (const [name, cost] of Object.entries(providerTotals)) {
- providers.push({ name: displayNameByName.get(name) ?? name, cost })
- }
- for (const p of allProviders) {
- if (providers.some(pc => pc.name === p.displayName)) continue
- const sources = await p.discoverSessions()
- if (sources.length > 0) providers.push({ name: p.displayName, cost: 0 })
- }
- } else {
- const display = displayNameByName.get(pf) ?? pf
- providers.push({ name: display, cost: currentData.cost })
- }
-
- // DAILY HISTORY (last 365 days)
- // Cache stores per-provider cost+calls per day in DailyEntry.providers, so we can derive
- // a provider-filtered history without re-parsing. Tokens aren't broken down per provider
- // in the cache, so the filtered view shows zero tokens (heatmap/trend still works on cost).
- const historyStartStr = toDateString(new Date(now.getFullYear(), now.getMonth(), now.getDate() - BACKFILL_DAYS))
- const allCacheDays = getDaysInRange(cache, historyStartStr, yesterdayStr)
- // Parse only today for history; historical days come from cache
- const todayRangeForHistory: DateRange = { start: todayStart, end: new Date() }
- const allTodayDaysForHistory = aggregateProjectsIntoDays(fp(await parseAllSessions(todayRangeForHistory, 'all')))
- const todayStrForHistory = toDateString(todayStart)
- const fullHistory = [...allCacheDays, ...allTodayDaysForHistory.filter(d => d.date === todayStrForHistory)]
- const dailyHistory = fullHistory.map(d => {
- if (isAllProviders) {
- const topModels = Object.entries(d.models)
- .filter(([name]) => name !== '')
- .sort(([, a], [, b]) => b.cost - a.cost)
- .slice(0, 5)
- .map(([name, m]) => ({
- name,
- cost: m.cost,
- calls: m.calls,
- inputTokens: m.inputTokens,
- outputTokens: m.outputTokens,
- }))
- return {
- date: d.date,
- cost: d.cost,
- calls: d.calls,
- inputTokens: d.inputTokens,
- outputTokens: d.outputTokens,
- cacheReadTokens: d.cacheReadTokens,
- cacheWriteTokens: d.cacheWriteTokens,
- topModels,
- }
- }
- const prov = d.providers[pf] ?? { calls: 0, cost: 0 }
- return {
- date: d.date,
- cost: prov.cost,
- calls: prov.calls,
- inputTokens: 0,
- outputTokens: 0,
- cacheReadTokens: 0,
- cacheWriteTokens: 0,
- topModels: [],
- }
- })
-
- const optimize = opts.optimize === false ? null : await scanAndDetect(scanProjects, scanRange)
- console.log(JSON.stringify(buildMenubarPayload(currentData, providers, optimize, dailyHistory)))
- return
- }
-
- if (opts.format === 'json') {
- await hydrateCache()
- const todayData = buildPeriodData('today', fp(await parseAllSessions(getDateRange('today').range, pf)))
- const monthData = buildPeriodData('month', fp(await parseAllSessions(getDateRange('month').range, pf)))
- const { code, rate } = getCurrency()
- const payload: {
- currency: string
- today: { cost: number; calls: number }
- month: { cost: number; calls: number }
- plan?: JsonPlanSummary
- } = {
- currency: code,
- today: { cost: Math.round(todayData.cost * rate * 100) / 100, calls: todayData.calls },
- month: { cost: Math.round(monthData.cost * rate * 100) / 100, calls: monthData.calls },
- }
- const planUsage = await getPlanUsageOrNull()
- if (planUsage) {
- payload.plan = toJsonPlanSummary(planUsage)
- }
- console.log(JSON.stringify(payload))
- return
- }
-
- await hydrateCache()
- const monthProjects = fp(await parseAllSessions(getDateRange('month').range, pf))
- console.log(renderStatusBar(monthProjects))
- })
-
-program
- .command('today')
- .description('Today\'s usage dashboard')
- .option('--provider ', 'Filter by provider (e.g. claude, gemini, cursor, copilot)', 'all')
- .option('--format ', 'Output format: tui, json', 'tui')
- .option('--project ', 'Show only projects matching name (repeatable)', collect, [])
- .option('--exclude ', 'Exclude projects matching name (repeatable)', collect, [])
- .option('--refresh ', 'Auto-refresh interval in seconds (0 to disable)', parseInteger, 30)
- .action(async (opts) => {
- assertFormat(opts.format, ['tui', 'json'], 'today')
- if (opts.format === 'json') {
- await runJsonReport('today', opts.provider, opts.project, opts.exclude)
- return
- }
- await hydrateCache()
- await renderDashboard('today', opts.provider, opts.refresh, opts.project, opts.exclude)
- })
-
-program
- .command('month')
- .description('This month\'s usage dashboard')
- .option('--provider ', 'Filter by provider (e.g. claude, gemini, cursor, copilot)', 'all')
- .option('--format ', 'Output format: tui, json', 'tui')
- .option('--project ', 'Show only projects matching name (repeatable)', collect, [])
- .option('--exclude ', 'Exclude projects matching name (repeatable)', collect, [])
- .option('--refresh ', 'Auto-refresh interval in seconds (0 to disable)', parseInteger, 30)
- .action(async (opts) => {
- assertFormat(opts.format, ['tui', 'json'], 'month')
- if (opts.format === 'json') {
- await runJsonReport('month', opts.provider, opts.project, opts.exclude)
- return
- }
- await hydrateCache()
- await renderDashboard('month', opts.provider, opts.refresh, opts.project, opts.exclude)
- })
-
-program
- .command('export')
- .description('Export usage data to CSV or JSON')
- .option('-f, --format ', 'Export format: csv, json', 'csv')
- .option('-o, --output ', 'Output file path')
- .option('--from ', 'Start date (YYYY-MM-DD). Exports a single custom period when set')
- .option('--to ', 'End date (YYYY-MM-DD). Exports a single custom period when set')
- .option('--provider ', 'Filter by provider (e.g. claude, gemini, cursor, copilot)', 'all')
- .option('--project ', 'Show only projects matching name (repeatable)', collect, [])
- .option('--exclude ', 'Exclude projects matching name (repeatable)', collect, [])
- .action(async (opts) => {
- assertFormat(opts.format, ['csv', 'json'], 'export')
- await loadPricing()
- await hydrateCache()
- const pf = opts.provider
- const fp = (p: ProjectSummary[]) => filterProjectsByName(p, opts.project, opts.exclude)
- let customRange: DateRange | null = null
- try {
- customRange = parseDateRangeFlags(opts.from, opts.to)
- } catch (err) {
- const message = err instanceof Error ? err.message : String(err)
- console.error(`\n Error: ${message}\n`)
- process.exit(1)
- }
-
- const periods: PeriodExport[] = customRange
- ? [{ label: formatDateRangeLabel(opts.from, opts.to), projects: fp(await parseAllSessions(customRange, pf)) }]
- : [
- { label: 'Today', projects: fp(await parseAllSessions(getDateRange('today').range, pf)) },
- { label: '7 Days', projects: fp(await parseAllSessions(getDateRange('week').range, pf)) },
- { label: '30 Days', projects: fp(await parseAllSessions(getDateRange('30days').range, pf)) },
- ]
-
- if (periods.every(p => p.projects.length === 0)) {
- console.log('\n No usage data found.\n')
- return
- }
-
- const defaultName = `codeburn-${toDateString(new Date())}`
- const outputPath = opts.output ?? `${defaultName}.${opts.format}`
-
- let savedPath: string
- try {
- if (opts.format === 'json') {
- savedPath = await exportJson(periods, outputPath)
- } else {
- savedPath = await exportCsv(periods, outputPath)
- }
- } catch (err) {
- // Protection guards in export.ts (symlink refusal, non-codeburn folder refusal, etc.)
- // throw with a user-readable message. Print just the message, not the stack, so the CLI
- // doesn't spray its internals at the user.
- const message = err instanceof Error ? err.message : String(err)
- console.error(`\n Export failed: ${message}\n`)
- process.exit(1)
- }
-
- const exportedLabel = customRange ? formatDateRangeLabel(opts.from, opts.to) : 'Today + 7 Days + 30 Days'
- console.log(`\n Exported (${exportedLabel}) to: ${savedPath}\n`)
- })
-
-program
- .command('menubar')
- .description('Install and launch the macOS menubar app (one command, no clone)')
- .option('--force', 'Reinstall even if an older copy is already in ~/Applications')
- .action(async (opts: { force?: boolean }) => {
- try {
- const result = await installMenubarApp({ force: opts.force })
- console.log(`\n Ready. ${result.installedPath}\n`)
- } catch (err) {
- const message = err instanceof Error ? err.message : String(err)
- console.error(`\n Menubar install failed: ${message}\n`)
- process.exit(1)
- }
- })
-
-program
- .command('currency [code]')
- .description('Set display currency (e.g. codeburn currency GBP)')
- .option('--symbol ', 'Override the currency symbol')
- .option('--reset', 'Reset to USD (removes currency config)')
- .action(async (code?: string, opts?: { symbol?: string; reset?: boolean }) => {
- if (opts?.reset) {
- const config = await readConfig()
- delete config.currency
- await saveConfig(config)
- console.log('\n Currency reset to USD.\n')
- return
- }
-
- if (!code) {
- const { code: activeCode, rate, symbol } = getCurrency()
- if (activeCode === 'USD' && rate === 1) {
- console.log('\n Currency: USD (default)')
- console.log(` Config: ${getConfigFilePath()}\n`)
- } else {
- console.log(`\n Currency: ${activeCode}`)
- console.log(` Symbol: ${symbol}`)
- console.log(` Rate: 1 USD = ${rate} ${activeCode}`)
- console.log(` Config: ${getConfigFilePath()}\n`)
- }
- return
- }
-
- const upperCode = code.toUpperCase()
- if (!isValidCurrencyCode(upperCode)) {
- console.error(`\n "${code}" is not a valid ISO 4217 currency code.\n`)
- process.exitCode = 1
- return
- }
-
- const config = await readConfig()
- config.currency = {
- code: upperCode,
- ...(opts?.symbol ? { symbol: opts.symbol } : {}),
- }
- await saveConfig(config)
-
- await loadCurrency()
- const { rate, symbol } = getCurrency()
-
- console.log(`\n Currency set to ${upperCode}.`)
- console.log(` Symbol: ${symbol}`)
- console.log(` Rate: 1 USD = ${rate} ${upperCode}`)
- console.log(` Config saved to ${getConfigFilePath()}\n`)
- })
-
-program
- .command('model-alias [from] [to]')
- .description('Map a provider model name to a canonical one for pricing (e.g. codeburn model-alias my-model claude-opus-4-6)')
- .option('--remove ', 'Remove an alias')
- .option('--list', 'List configured aliases')
- .action(async (from?: string, to?: string, opts?: { remove?: string; list?: boolean }) => {
- const config = await readConfig()
- const aliases = config.modelAliases ?? {}
-
- if (opts?.list || (!from && !opts?.remove)) {
- const entries = Object.entries(aliases)
- if (entries.length === 0) {
- console.log('\n No model aliases configured.')
- console.log(` Config: ${getConfigFilePath()}\n`)
- } else {
- console.log('\n Model aliases:')
- for (const [src, dst] of entries) {
- console.log(` ${src} -> ${dst}`)
- }
- console.log(` Config: ${getConfigFilePath()}\n`)
- }
- return
- }
-
- if (opts?.remove) {
- if (!(opts.remove in aliases)) {
- console.error(`\n Alias not found: ${opts.remove}\n`)
- process.exitCode = 1
- return
- }
- delete aliases[opts.remove]
- config.modelAliases = Object.keys(aliases).length > 0 ? aliases : undefined
- await saveConfig(config)
- console.log(`\n Removed alias: ${opts.remove}\n`)
- return
- }
-
- if (!from || !to) {
- console.error('\n Usage: codeburn model-alias \n')
- process.exitCode = 1
- return
- }
-
- aliases[from] = to
- config.modelAliases = aliases
- await saveConfig(config)
- console.log(`\n Alias saved: ${from} -> ${to}`)
- console.log(` Config: ${getConfigFilePath()}\n`)
- })
-
-program
- .command('plan [action] [id]')
- .description('Show or configure a subscription plan for overage tracking')
- .option('--format ', 'Output format: text or json', 'text')
- .option('--monthly-usd ', 'Monthly plan price in USD (for custom)', parseNumber)
- .option('--provider ', 'Provider scope: all, claude, codex, cursor', 'all')
- .option('--reset-day ', 'Day of month plan resets (1-28)', parseInteger, 1)
- .action(async (action?: string, id?: string, opts?: { format?: string; monthlyUsd?: number; provider?: string; resetDay?: number }) => {
- assertFormat(opts?.format ?? 'text', ['text', 'json'], 'plan')
- const mode = action ?? 'show'
-
- if (mode === 'show') {
- const plan = await readPlan()
- const displayPlan = !plan || plan.id === 'none'
- ? { id: 'none', monthlyUsd: 0, provider: 'all', resetDay: 1, setAt: null }
- : {
- id: plan.id,
- monthlyUsd: plan.monthlyUsd,
- provider: plan.provider,
- resetDay: clampResetDay(plan.resetDay),
- setAt: plan.setAt,
- }
- if (opts?.format === 'json') {
- console.log(JSON.stringify(displayPlan))
- return
- }
- if (!plan || plan.id === 'none') {
- console.log('\n Plan: none')
- console.log(' API-pricing view is active.')
- console.log(` Config: ${getConfigFilePath()}\n`)
- return
- }
- console.log(`\n Plan: ${planDisplayName(plan.id)} (${plan.id})`)
- console.log(` Budget: $${plan.monthlyUsd}/month`)
- console.log(` Provider: ${plan.provider}`)
- console.log(` Reset day: ${clampResetDay(plan.resetDay)}`)
- console.log(` Set at: ${plan.setAt}`)
- console.log(` Config: ${getConfigFilePath()}\n`)
- return
- }
-
- if (mode === 'reset') {
- await clearPlan()
- console.log('\n Plan reset. API-pricing view is active.\n')
- return
- }
-
- if (mode !== 'set') {
- console.error('\n Usage: codeburn plan [set | reset]\n')
- process.exitCode = 1
- return
- }
-
- if (!id || !isPlanId(id)) {
- console.error(`\n Plan id must be one of: claude-pro, claude-max, cursor-pro, custom, none; got "${id ?? ''}".\n`)
- process.exitCode = 1
- return
- }
-
- const resetDay = opts?.resetDay ?? 1
- if (!Number.isInteger(resetDay) || resetDay < 1 || resetDay > 28) {
- console.error(`\n --reset-day must be an integer from 1 to 28; got ${resetDay}.\n`)
- process.exitCode = 1
- return
- }
-
- if (id === 'none') {
- await clearPlan()
- console.log('\n Plan reset. API-pricing view is active.\n')
- return
- }
-
- if (id === 'custom') {
- if (opts?.monthlyUsd === undefined) {
- console.error('\n Custom plans require --monthly-usd .\n')
- process.exitCode = 1
- return
- }
- const monthlyUsd = opts.monthlyUsd
- if (!Number.isFinite(monthlyUsd) || monthlyUsd <= 0) {
- console.error(`\n --monthly-usd must be a positive number; got ${opts.monthlyUsd}.\n`)
- process.exitCode = 1
- return
- }
- const provider = opts?.provider ?? 'all'
- if (!isPlanProvider(provider)) {
- console.error(`\n --provider must be one of: all, claude, codex, cursor; got "${provider}".\n`)
- process.exitCode = 1
- return
- }
- await savePlan({
- id: 'custom',
- monthlyUsd,
- provider,
- resetDay,
- setAt: new Date().toISOString(),
- })
- console.log(`\n Plan set to custom ($${monthlyUsd}/month, ${provider}, reset day ${resetDay}).`)
- console.log(` Config saved to ${getConfigFilePath()}\n`)
- return
- }
-
- const preset = getPresetPlan(id)
- if (!preset) {
- console.error(`\n Unknown preset "${id}".\n`)
- process.exitCode = 1
- return
- }
-
- await savePlan({
- ...preset,
- resetDay,
- setAt: new Date().toISOString(),
- })
- console.log(`\n Plan set to ${planDisplayName(preset.id)} ($${preset.monthlyUsd}/month).`)
- console.log(` Provider: ${preset.provider}`)
- console.log(` Reset day: ${resetDay}`)
- console.log(` Config saved to ${getConfigFilePath()}\n`)
- })
-
-program
- .command('optimize')
- .description('Find token waste and get exact fixes')
- .option('-p, --period ', 'Analysis period: today, week, 30days, month, all', '30days')
- .option('--provider ', 'Filter by provider (e.g. claude, gemini, cursor, copilot)', 'all')
- .action(async (opts) => {
- await loadPricing()
- await hydrateCache()
- const { range, label } = getDateRange(opts.period)
- const projects = await parseAllSessions(range, opts.provider)
- await runOptimize(projects, label, range)
- })
-
-program
- .command('compare')
- .description('Compare two AI models side-by-side')
- .option('-p, --period ', 'Analysis period: today, week, 30days, month, all', 'all')
- .option('--provider ', 'Filter by provider (e.g. claude, gemini, cursor, copilot)', 'all')
- .action(async (opts) => {
- await loadPricing()
- await hydrateCache()
- const { range } = getDateRange(opts.period)
- await renderCompare(range, opts.provider)
- })
-
-program
- .command('models')
- .description('Per-model token + cost table, optionally exploded by task type')
- .option('-p, --period ', 'Analysis period: today, week, 30days, month, all', '30days')
- .option('--from ', 'Custom range start (YYYY-MM-DD)')
- .option('--to ', 'Custom range end (YYYY-MM-DD)')
- .option('--provider ', 'Filter by provider (e.g. claude, codex, cursor)', 'all')
- .option('--task ', 'Filter to one task type (e.g. feature, debugging, refactoring)')
- .option('--by-task', 'One row per (provider, model, task) instead of one row per (provider, model)')
- .option('--top ', 'Show only the top N rows', (v: string) => parseInt(v, 10))
- .option('--min-cost ', 'Hide rows below this cost threshold', (v: string) => parseFloat(v))
- .option('--no-totals', 'Suppress the footer totals row')
- .option('--format ', 'Output format: table, markdown, json, csv', 'table')
- .action(async (opts) => {
- const { aggregateModels, renderTable, renderMarkdown, renderJson, renderCsv } = await import('./models-report.js')
- await loadPricing()
- await hydrateCache()
-
- let range
- if (opts.from || opts.to) {
- const customRange = parseDateRangeFlags(opts.from, opts.to)
- if (!customRange) {
- process.stderr.write('codeburn: --from and --to must be valid YYYY-MM-DD dates\n')
- process.exit(1)
- }
- range = customRange
- } else {
- range = getDateRange(opts.period).range
- }
-
- const projects = await parseAllSessions(range, opts.provider)
- const rows = await aggregateModels(projects, {
- byTask: !!opts.byTask,
- taskFilter: opts.task,
- topN: typeof opts.top === 'number' && Number.isFinite(opts.top) ? opts.top : undefined,
- minCost: typeof opts.minCost === 'number' && Number.isFinite(opts.minCost) ? opts.minCost : 0.01,
- })
-
- const fmt = (opts.format ?? 'table').toLowerCase()
- if (rows.length === 0 && (fmt === 'table' || fmt === 'markdown')) {
- process.stdout.write('No model usage found for the selected period.\n')
- return
- }
- if (fmt === 'json') {
- process.stdout.write(renderJson(rows) + '\n')
- } else if (fmt === 'csv') {
- process.stdout.write(renderCsv(rows, { byTask: !!opts.byTask }) + '\n')
- } else if (fmt === 'markdown' || fmt === 'md') {
- process.stdout.write(renderMarkdown(rows, { byTask: !!opts.byTask, showTotals: opts.totals !== false }) + '\n')
- } else if (fmt === 'table') {
- process.stdout.write(renderTable(rows, { byTask: !!opts.byTask, showTotals: opts.totals !== false }) + '\n')
- } else {
- process.stderr.write(`codeburn: unknown --format "${opts.format}". Choose table, markdown, json, or csv.\n`)
- process.exit(1)
- }
- })
-
-program
- .command('yield')
- .description('Track which AI spend shipped to main vs reverted/abandoned (experimental)')
- .option('-p, --period ', 'Analysis period: today, week, 30days, month, all', 'week')
- .action(async (opts) => {
- const { computeYield, formatYieldSummary } = await import('./yield.js')
- await loadPricing()
- await hydrateCache()
- const { range, label } = getDateRange(opts.period)
- console.log(`\n Analyzing yield for ${label}...\n`)
- const summary = await computeYield(range, process.cwd())
- console.log(formatYieldSummary(summary))
- })
-
-program.parse()
diff --git a/src/main.ts b/src/main.ts
new file mode 100644
index 0000000..4ebfe33
--- /dev/null
+++ b/src/main.ts
@@ -0,0 +1,978 @@
+import { Command } from 'commander'
+import { installMenubarApp } from './menubar-installer.js'
+import { exportCsv, exportJson, type PeriodExport } from './export.js'
+import { loadPricing, setModelAliases } from './models.js'
+import { parseAllSessions, filterProjectsByName } from './parser.js'
+import { convertCost } from './currency.js'
+import { renderStatusBar } from './format.js'
+import { type PeriodData, type ProviderCost } from './menubar-json.js'
+import { buildMenubarPayload } from './menubar-json.js'
+import { getDaysInRange, ensureCacheHydrated, emptyCache, BACKFILL_DAYS, toDateString } from './daily-cache.js'
+import { aggregateProjectsIntoDays, buildPeriodDataFromDays, dateKey } from './day-aggregator.js'
+import { CATEGORY_LABELS, type DateRange, type ProjectSummary, type TaskCategory } from './types.js'
+import { aggregateModelEfficiency } from './model-efficiency.js'
+import { renderDashboard } from './dashboard.js'
+import { formatDateRangeLabel, parseDateRangeFlags, getDateRange, toPeriod, type Period } from './cli-date.js'
+import { runOptimize, scanAndDetect } from './optimize.js'
+import { renderCompare } from './compare.js'
+import { getAllProviders } from './providers/index.js'
+import { clearPlan, readConfig, readPlan, saveConfig, savePlan, getConfigFilePath, type PlanId } from './config.js'
+import { clampResetDay, getPlanUsageOrNull, type PlanUsage } from './plan-usage.js'
+import { getPresetPlan, isPlanId, isPlanProvider, planDisplayName } from './plans.js'
+import { createRequire } from 'node:module'
+
+const require = createRequire(import.meta.url)
+const { version } = require('../package.json')
+import { loadCurrency, getCurrency, isValidCurrencyCode } from './currency.js'
+
+async function hydrateCache() {
+ try {
+ return await ensureCacheHydrated(
+ (range) => parseAllSessions(range, 'all'),
+ aggregateProjectsIntoDays,
+ )
+ } catch {
+ return emptyCache()
+ }
+}
+
+function collect(val: string, acc: string[]): string[] {
+ acc.push(val)
+ return acc
+}
+
+function parseNumber(value: string): number {
+ return Number(value)
+}
+
+function parseInteger(value: string): number {
+ return parseInt(value, 10)
+}
+
+type JsonPlanSummary = {
+ id: PlanId
+ budget: number
+ spent: number
+ percentUsed: number
+ status: 'under' | 'near' | 'over'
+ projectedMonthEnd: number
+ daysUntilReset: number
+ periodStart: string
+ periodEnd: string
+}
+
+function toJsonPlanSummary(planUsage: PlanUsage): JsonPlanSummary {
+ return {
+ id: planUsage.plan.id,
+ budget: convertCost(planUsage.budgetUsd),
+ spent: convertCost(planUsage.spentApiEquivalentUsd),
+ percentUsed: Math.round(planUsage.percentUsed * 10) / 10,
+ status: planUsage.status,
+ projectedMonthEnd: convertCost(planUsage.projectedMonthUsd),
+ daysUntilReset: planUsage.daysUntilReset,
+ periodStart: planUsage.periodStart.toISOString(),
+ periodEnd: planUsage.periodEnd.toISOString(),
+ }
+}
+
+function assertFormat(value: string, allowed: readonly string[], command: string): void {
+ if (!allowed.includes(value)) {
+ process.stderr.write(
+ `codeburn ${command}: unknown format "${value}". Valid values: ${allowed.join(', ')}.\n`
+ )
+ process.exit(1)
+ }
+}
+
+async function runJsonReport(period: Period, provider: string, project: string[], exclude: string[]): Promise {
+ await loadPricing()
+ const { range, label } = getDateRange(period)
+ const projects = filterProjectsByName(await parseAllSessions(range, provider), project, exclude)
+ const report: ReturnType & { plan?: JsonPlanSummary } = buildJsonReport(projects, label, period)
+ const planUsage = await getPlanUsageOrNull()
+ if (planUsage) {
+ report.plan = toJsonPlanSummary(planUsage)
+ }
+ console.log(JSON.stringify(report, null, 2))
+}
+
+const program = new Command()
+ .name('codeburn')
+ .description('See where your AI coding tokens go - by task, tool, model, and project')
+ .version(version)
+ .option('--verbose', 'print warnings to stderr on read failures and skipped files')
+ .option('--timezone ', 'IANA timezone for date grouping (e.g. Asia/Tokyo, America/New_York)')
+
+program.hook('preAction', async (thisCommand) => {
+ const tz = thisCommand.opts<{ timezone?: string }>().timezone ?? process.env['CODEBURN_TZ']
+ if (tz) {
+ try {
+ Intl.DateTimeFormat(undefined, { timeZone: tz })
+ } catch {
+ console.error(`\n Invalid timezone: "${tz}". Use an IANA timezone like "America/New_York" or "Asia/Tokyo".\n`)
+ process.exit(1)
+ }
+ process.env.TZ = tz
+ }
+ const config = await readConfig()
+ setModelAliases(config.modelAliases ?? {})
+ if (thisCommand.opts<{ verbose?: boolean }>().verbose) {
+ process.env['CODEBURN_VERBOSE'] = '1'
+ }
+ await loadCurrency()
+})
+
+function buildJsonReport(projects: ProjectSummary[], period: string, periodKey: string) {
+ const sessions = projects.flatMap(p => p.sessions)
+ const { code } = getCurrency()
+
+ const totalCostUSD = projects.reduce((s, p) => s + p.totalCostUSD, 0)
+ const totalCalls = projects.reduce((s, p) => s + p.totalApiCalls, 0)
+ const totalSessions = projects.reduce((s, p) => s + p.sessions.length, 0)
+ const totalInput = sessions.reduce((s, sess) => s + sess.totalInputTokens, 0)
+ const totalOutput = sessions.reduce((s, sess) => s + sess.totalOutputTokens, 0)
+ const totalCacheRead = sessions.reduce((s, sess) => s + sess.totalCacheReadTokens, 0)
+ const totalCacheWrite = sessions.reduce((s, sess) => s + sess.totalCacheWriteTokens, 0)
+ // Match src/menubar-json.ts:cacheHitPercent: reads over reads+fresh-input. cache_write
+ // counts tokens being stored, not served, so it doesn't belong in the denominator.
+ const cacheHitDenom = totalInput + totalCacheRead
+ const cacheHitPercent = cacheHitDenom > 0 ? Math.round((totalCacheRead / cacheHitDenom) * 1000) / 10 : 0
+
+ // Per-day rollup. Mirrors parser.ts categoryBreakdown semantics so a
+ // consumer summing daily[].editTurns over a period gets the same total as
+ // sum(activities[].editTurns) for that period: every turn counts once for
+ // `turns`, edit turns count for `editTurns`, edit turns with zero retries
+ // count for `oneShotTurns`. Issue #279 — daily-resolution efficiency
+ // dashboards need this without re-deriving from activity-level rollups.
+ const dailyMap: Record = {}
+ for (const sess of sessions) {
+ for (const turn of sess.turns) {
+ // Prefer the user-message timestamp on the turn; fall back to the first
+ // assistant-call timestamp when the user line is missing (continuation
+ // sessions where the JSONL begins mid-conversation). Previously these
+ // turns dropped from daily but stayed in activities, breaking the
+ // sum(daily[].editTurns) === sum(activities[].editTurns) invariant.
+ const ts = turn.timestamp || turn.assistantCalls[0]?.timestamp
+ if (!ts) { continue }
+ const day = dateKey(ts)
+ if (!dailyMap[day]) { dailyMap[day] = { cost: 0, calls: 0, turns: 0, editTurns: 0, oneShotTurns: 0 } }
+ dailyMap[day].turns += 1
+ if (turn.hasEdits) {
+ dailyMap[day].editTurns += 1
+ if (turn.retries === 0) dailyMap[day].oneShotTurns += 1
+ }
+ for (const call of turn.assistantCalls) {
+ dailyMap[day].cost += call.costUSD
+ dailyMap[day].calls += 1
+ }
+ }
+ }
+ const daily = Object.entries(dailyMap).sort().map(([date, d]) => ({
+ date,
+ cost: convertCost(d.cost),
+ calls: d.calls,
+ turns: d.turns,
+ editTurns: d.editTurns,
+ oneShotTurns: d.oneShotTurns,
+ // Pre-computed convenience for dashboards that don't want to do the math.
+ // null when there are no edit turns (the rate is undefined, not zero —
+ // a day where the user only had Q&A turns shouldn't read as 0% one-shot).
+ oneShotRate: d.editTurns > 0
+ ? Math.round((d.oneShotTurns / d.editTurns) * 1000) / 10
+ : null,
+ }))
+
+ const projectList = projects.map(p => ({
+ name: p.project,
+ path: p.projectPath,
+ cost: convertCost(p.totalCostUSD),
+ avgCostPerSession: p.sessions.length > 0
+ ? convertCost(p.totalCostUSD / p.sessions.length)
+ : null,
+ calls: p.totalApiCalls,
+ sessions: p.sessions.length,
+ }))
+
+ const modelMap: Record = {}
+ const modelEfficiency = aggregateModelEfficiency(projects)
+ for (const sess of sessions) {
+ for (const [model, d] of Object.entries(sess.modelBreakdown)) {
+ if (!modelMap[model]) { modelMap[model] = { calls: 0, cost: 0, inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0 } }
+ modelMap[model].calls += d.calls
+ modelMap[model].cost += d.costUSD
+ modelMap[model].inputTokens += d.tokens.inputTokens
+ modelMap[model].outputTokens += d.tokens.outputTokens
+ modelMap[model].cacheReadTokens += d.tokens.cacheReadInputTokens
+ modelMap[model].cacheWriteTokens += d.tokens.cacheCreationInputTokens
+ }
+ }
+ const models = Object.entries(modelMap)
+ .sort(([, a], [, b]) => b.cost - a.cost)
+ .map(([name, { cost, ...rest }]) => {
+ const efficiency = modelEfficiency.get(name)
+ return {
+ name,
+ ...rest,
+ cost: convertCost(cost),
+ editTurns: efficiency?.editTurns ?? 0,
+ oneShotTurns: efficiency?.oneShotTurns ?? 0,
+ oneShotRate: efficiency?.oneShotRate ?? null,
+ retriesPerEdit: efficiency?.retriesPerEdit ?? null,
+ costPerEdit: efficiency?.costPerEditUSD !== null && efficiency?.costPerEditUSD !== undefined
+ ? convertCost(efficiency.costPerEditUSD)
+ : null,
+ }
+ })
+
+ const catMap: Record = {}
+ for (const sess of sessions) {
+ for (const [cat, d] of Object.entries(sess.categoryBreakdown)) {
+ if (!catMap[cat]) { catMap[cat] = { turns: 0, cost: 0, editTurns: 0, oneShotTurns: 0 } }
+ catMap[cat].turns += d.turns
+ catMap[cat].cost += d.costUSD
+ catMap[cat].editTurns += d.editTurns
+ catMap[cat].oneShotTurns += d.oneShotTurns
+ }
+ }
+ const activities = Object.entries(catMap)
+ .sort(([, a], [, b]) => b.cost - a.cost)
+ .map(([cat, d]) => ({
+ category: CATEGORY_LABELS[cat as TaskCategory] ?? cat,
+ cost: convertCost(d.cost),
+ turns: d.turns,
+ editTurns: d.editTurns,
+ oneShotTurns: d.oneShotTurns,
+ oneShotRate: d.editTurns > 0 ? Math.round((d.oneShotTurns / d.editTurns) * 1000) / 10 : null,
+ }))
+
+ const toolMap: Record = {}
+ const mcpMap: Record = {}
+ const bashMap: Record = {}
+ for (const sess of sessions) {
+ for (const [tool, d] of Object.entries(sess.toolBreakdown)) {
+ toolMap[tool] = (toolMap[tool] ?? 0) + d.calls
+ }
+ for (const [server, d] of Object.entries(sess.mcpBreakdown)) {
+ mcpMap[server] = (mcpMap[server] ?? 0) + d.calls
+ }
+ for (const [cmd, d] of Object.entries(sess.bashBreakdown)) {
+ bashMap[cmd] = (bashMap[cmd] ?? 0) + d.calls
+ }
+ }
+
+ const sortedMap = (m: Record) =>
+ Object.entries(m).sort(([, a], [, b]) => b - a).map(([name, calls]) => ({ name, calls }))
+
+ const topSessions = projects
+ .flatMap(p => p.sessions.map(s => ({ project: p.project, sessionId: s.sessionId, date: s.firstTimestamp ? dateKey(s.firstTimestamp) : null, cost: convertCost(s.totalCostUSD), calls: s.apiCalls })))
+ .sort((a, b) => b.cost - a.cost)
+ .slice(0, 5)
+
+ return {
+ generated: new Date().toISOString(),
+ currency: code,
+ period,
+ periodKey,
+ overview: {
+ cost: convertCost(totalCostUSD),
+ calls: totalCalls,
+ sessions: totalSessions,
+ cacheHitPercent,
+ tokens: {
+ input: totalInput,
+ output: totalOutput,
+ cacheRead: totalCacheRead,
+ cacheWrite: totalCacheWrite,
+ },
+ },
+ daily,
+ projects: projectList,
+ models,
+ activities,
+ tools: sortedMap(toolMap),
+ mcpServers: sortedMap(mcpMap),
+ shellCommands: sortedMap(bashMap),
+ topSessions,
+ }
+}
+
+program
+ .command('report', { isDefault: true })
+ .description('Interactive usage dashboard')
+ .option('-p, --period ', 'Starting period: today, week, 30days, month, all', 'week')
+ .option('--from ', 'Start date (YYYY-MM-DD). Overrides --period when set')
+ .option('--to ', 'End date (YYYY-MM-DD). Overrides --period when set')
+ .option('--provider ', 'Filter by provider (e.g. claude, gemini, cursor, copilot)', 'all')
+ .option('--format ', 'Output format: tui, json', 'tui')
+ .option('--project ', 'Show only projects matching name (repeatable)', collect, [])
+ .option('--exclude ', 'Exclude projects matching name (repeatable)', collect, [])
+ .option('--refresh ', 'Auto-refresh interval in seconds (0 to disable)', parseInteger, 30)
+ .action(async (opts) => {
+ assertFormat(opts.format, ['tui', 'json'], 'report')
+ let customRange: DateRange | null = null
+ try {
+ customRange = parseDateRangeFlags(opts.from, opts.to)
+ } catch (err) {
+ const message = err instanceof Error ? err.message : String(err)
+ console.error(`\n Error: ${message}\n`)
+ process.exit(1)
+ }
+
+ const period = toPeriod(opts.period)
+ if (opts.format === 'json') {
+ await loadPricing()
+ await hydrateCache()
+ if (customRange) {
+ const label = formatDateRangeLabel(opts.from, opts.to)
+ const projects = filterProjectsByName(
+ await parseAllSessions(customRange, opts.provider),
+ opts.project,
+ opts.exclude,
+ )
+ console.log(JSON.stringify(buildJsonReport(projects, label, 'custom'), null, 2))
+ } else {
+ await runJsonReport(period, opts.provider, opts.project, opts.exclude)
+ }
+ return
+ }
+ await hydrateCache()
+ const customRangeLabel = customRange ? formatDateRangeLabel(opts.from, opts.to) : undefined
+ await renderDashboard(period, opts.provider, opts.refresh, opts.project, opts.exclude, customRange, customRangeLabel)
+ })
+
+function buildPeriodData(label: string, projects: ProjectSummary[]): PeriodData {
+ const sessions = projects.flatMap(p => p.sessions)
+ const catTotals: Record = {}
+ const modelTotals: Record = {}
+ let inputTokens = 0, outputTokens = 0, cacheReadTokens = 0, cacheWriteTokens = 0
+
+ for (const sess of sessions) {
+ inputTokens += sess.totalInputTokens
+ outputTokens += sess.totalOutputTokens
+ cacheReadTokens += sess.totalCacheReadTokens
+ cacheWriteTokens += sess.totalCacheWriteTokens
+ for (const [cat, d] of Object.entries(sess.categoryBreakdown)) {
+ if (!catTotals[cat]) catTotals[cat] = { turns: 0, cost: 0, editTurns: 0, oneShotTurns: 0 }
+ catTotals[cat].turns += d.turns
+ catTotals[cat].cost += d.costUSD
+ catTotals[cat].editTurns += d.editTurns
+ catTotals[cat].oneShotTurns += d.oneShotTurns
+ }
+ for (const [model, d] of Object.entries(sess.modelBreakdown)) {
+ if (!modelTotals[model]) modelTotals[model] = { calls: 0, cost: 0 }
+ modelTotals[model].calls += d.calls
+ modelTotals[model].cost += d.costUSD
+ }
+ }
+
+ return {
+ label,
+ cost: projects.reduce((s, p) => s + p.totalCostUSD, 0),
+ calls: projects.reduce((s, p) => s + p.totalApiCalls, 0),
+ sessions: projects.reduce((s, p) => s + p.sessions.length, 0),
+ inputTokens, outputTokens, cacheReadTokens, cacheWriteTokens,
+ categories: Object.entries(catTotals)
+ .sort(([, a], [, b]) => b.cost - a.cost)
+ .map(([cat, d]) => ({ name: CATEGORY_LABELS[cat as TaskCategory] ?? cat, ...d })),
+ models: Object.entries(modelTotals)
+ .sort(([, a], [, b]) => b.cost - a.cost)
+ .map(([name, d]) => ({ name, ...d })),
+ }
+}
+
+program
+ .command('status')
+ .description('Compact status output (today + month)')
+ .option('--format ', 'Output format: terminal, menubar-json, json', 'terminal')
+ .option('--provider ', 'Filter by provider (e.g. claude, gemini, cursor, copilot)', 'all')
+ .option('--project ', 'Show only projects matching name (repeatable)', collect, [])
+ .option('--exclude ', 'Exclude projects matching name (repeatable)', collect, [])
+ .option('--period ', 'Primary period for menubar-json: today, week, 30days, month, all', 'today')
+ .option('--no-optimize', 'Skip optimize findings (menubar-json only, faster)')
+ .action(async (opts) => {
+ assertFormat(opts.format, ['terminal', 'menubar-json', 'json'], 'status')
+ await loadPricing()
+ const pf = opts.provider
+ const fp = (p: ProjectSummary[]) => filterProjectsByName(p, opts.project, opts.exclude)
+ if (opts.format === 'menubar-json') {
+ const periodInfo = getDateRange(opts.period)
+ const now = new Date()
+ const todayStart = new Date(now.getFullYear(), now.getMonth(), now.getDate())
+ const yesterdayStr = toDateString(new Date(now.getFullYear(), now.getMonth(), now.getDate() - 1))
+ const isAllProviders = pf === 'all'
+
+ const cache = await hydrateCache()
+
+ // CURRENT PERIOD DATA
+ // - .all provider: assemble from cache + today (fast)
+ // - specific provider: parse the period range with provider filter (correct, but slower)
+ let currentData: PeriodData
+ let scanProjects: ProjectSummary[]
+ let scanRange: DateRange
+
+ if (isAllProviders) {
+ // Parse only today's sessions; historical data comes from cache to avoid double-counting
+ const todayRange: DateRange = { start: todayStart, end: new Date() }
+ const todayProjects = fp(await parseAllSessions(todayRange, 'all'))
+ const todayDays = aggregateProjectsIntoDays(todayProjects)
+ const rangeStartStr = toDateString(periodInfo.range.start)
+ const rangeEndStr = toDateString(periodInfo.range.end)
+ const historicalDays = getDaysInRange(cache, rangeStartStr, yesterdayStr)
+ const todayInRange = todayDays.filter(d => d.date >= rangeStartStr && d.date <= rangeEndStr)
+ const allDays = [...historicalDays, ...todayInRange].sort((a, b) => a.date.localeCompare(b.date))
+ currentData = buildPeriodDataFromDays(allDays, periodInfo.label)
+ scanProjects = todayProjects
+ scanRange = periodInfo.range
+ } else {
+ const projects = fp(await parseAllSessions(periodInfo.range, pf))
+ currentData = buildPeriodData(periodInfo.label, projects)
+ scanProjects = projects
+ scanRange = periodInfo.range
+ }
+
+ // PROVIDERS
+ // For .all: enumerate every provider with cost across the period (from cache) + installed-but-zero.
+ // For specific: just this single provider with its scoped cost.
+ const allProviders = await getAllProviders()
+ const displayNameByName = new Map(allProviders.map(p => [p.name, p.displayName]))
+ const providers: ProviderCost[] = []
+ if (isAllProviders) {
+ // Parse only today; historical provider costs come from cache
+ const todayRangeForProviders: DateRange = { start: todayStart, end: new Date() }
+ const todayDaysForProviders = aggregateProjectsIntoDays(fp(await parseAllSessions(todayRangeForProviders, 'all')))
+ const rangeStartStr = toDateString(periodInfo.range.start)
+ const todayStr = toDateString(todayStart)
+ const allDaysForProviders = [
+ ...getDaysInRange(cache, rangeStartStr, yesterdayStr),
+ ...todayDaysForProviders.filter(d => d.date === todayStr),
+ ]
+ const providerTotals: Record = {}
+ for (const d of allDaysForProviders) {
+ for (const [name, p] of Object.entries(d.providers)) {
+ providerTotals[name] = (providerTotals[name] ?? 0) + p.cost
+ }
+ }
+ for (const [name, cost] of Object.entries(providerTotals)) {
+ providers.push({ name: displayNameByName.get(name) ?? name, cost })
+ }
+ for (const p of allProviders) {
+ if (providers.some(pc => pc.name === p.displayName)) continue
+ const sources = await p.discoverSessions()
+ if (sources.length > 0) providers.push({ name: p.displayName, cost: 0 })
+ }
+ } else {
+ const display = displayNameByName.get(pf) ?? pf
+ providers.push({ name: display, cost: currentData.cost })
+ }
+
+ // DAILY HISTORY (last 365 days)
+ // Cache stores per-provider cost+calls per day in DailyEntry.providers, so we can derive
+ // a provider-filtered history without re-parsing. Tokens aren't broken down per provider
+ // in the cache, so the filtered view shows zero tokens (heatmap/trend still works on cost).
+ const historyStartStr = toDateString(new Date(now.getFullYear(), now.getMonth(), now.getDate() - BACKFILL_DAYS))
+ const allCacheDays = getDaysInRange(cache, historyStartStr, yesterdayStr)
+ // Parse only today for history; historical days come from cache
+ const todayRangeForHistory: DateRange = { start: todayStart, end: new Date() }
+ const allTodayDaysForHistory = aggregateProjectsIntoDays(fp(await parseAllSessions(todayRangeForHistory, 'all')))
+ const todayStrForHistory = toDateString(todayStart)
+ const fullHistory = [...allCacheDays, ...allTodayDaysForHistory.filter(d => d.date === todayStrForHistory)]
+ const dailyHistory = fullHistory.map(d => {
+ if (isAllProviders) {
+ const topModels = Object.entries(d.models)
+ .filter(([name]) => name !== '')
+ .sort(([, a], [, b]) => b.cost - a.cost)
+ .slice(0, 5)
+ .map(([name, m]) => ({
+ name,
+ cost: m.cost,
+ calls: m.calls,
+ inputTokens: m.inputTokens,
+ outputTokens: m.outputTokens,
+ }))
+ return {
+ date: d.date,
+ cost: d.cost,
+ calls: d.calls,
+ inputTokens: d.inputTokens,
+ outputTokens: d.outputTokens,
+ cacheReadTokens: d.cacheReadTokens,
+ cacheWriteTokens: d.cacheWriteTokens,
+ topModels,
+ }
+ }
+ const prov = d.providers[pf] ?? { calls: 0, cost: 0 }
+ return {
+ date: d.date,
+ cost: prov.cost,
+ calls: prov.calls,
+ inputTokens: 0,
+ outputTokens: 0,
+ cacheReadTokens: 0,
+ cacheWriteTokens: 0,
+ topModels: [],
+ }
+ })
+
+ const optimize = opts.optimize === false ? null : await scanAndDetect(scanProjects, scanRange)
+ console.log(JSON.stringify(buildMenubarPayload(currentData, providers, optimize, dailyHistory)))
+ return
+ }
+
+ if (opts.format === 'json') {
+ await hydrateCache()
+ const todayData = buildPeriodData('today', fp(await parseAllSessions(getDateRange('today').range, pf)))
+ const monthData = buildPeriodData('month', fp(await parseAllSessions(getDateRange('month').range, pf)))
+ const { code, rate } = getCurrency()
+ const payload: {
+ currency: string
+ today: { cost: number; calls: number }
+ month: { cost: number; calls: number }
+ plan?: JsonPlanSummary
+ } = {
+ currency: code,
+ today: { cost: Math.round(todayData.cost * rate * 100) / 100, calls: todayData.calls },
+ month: { cost: Math.round(monthData.cost * rate * 100) / 100, calls: monthData.calls },
+ }
+ const planUsage = await getPlanUsageOrNull()
+ if (planUsage) {
+ payload.plan = toJsonPlanSummary(planUsage)
+ }
+ console.log(JSON.stringify(payload))
+ return
+ }
+
+ await hydrateCache()
+ const monthProjects = fp(await parseAllSessions(getDateRange('month').range, pf))
+ console.log(renderStatusBar(monthProjects))
+ })
+
+program
+ .command('today')
+ .description('Today\'s usage dashboard')
+ .option('--provider ', 'Filter by provider (e.g. claude, gemini, cursor, copilot)', 'all')
+ .option('--format ', 'Output format: tui, json', 'tui')
+ .option('--project ', 'Show only projects matching name (repeatable)', collect, [])
+ .option('--exclude ', 'Exclude projects matching name (repeatable)', collect, [])
+ .option('--refresh ', 'Auto-refresh interval in seconds (0 to disable)', parseInteger, 30)
+ .action(async (opts) => {
+ assertFormat(opts.format, ['tui', 'json'], 'today')
+ if (opts.format === 'json') {
+ await runJsonReport('today', opts.provider, opts.project, opts.exclude)
+ return
+ }
+ await hydrateCache()
+ await renderDashboard('today', opts.provider, opts.refresh, opts.project, opts.exclude)
+ })
+
+program
+ .command('month')
+ .description('This month\'s usage dashboard')
+ .option('--provider ', 'Filter by provider (e.g. claude, gemini, cursor, copilot)', 'all')
+ .option('--format ', 'Output format: tui, json', 'tui')
+ .option('--project ', 'Show only projects matching name (repeatable)', collect, [])
+ .option('--exclude ', 'Exclude projects matching name (repeatable)', collect, [])
+ .option('--refresh ', 'Auto-refresh interval in seconds (0 to disable)', parseInteger, 30)
+ .action(async (opts) => {
+ assertFormat(opts.format, ['tui', 'json'], 'month')
+ if (opts.format === 'json') {
+ await runJsonReport('month', opts.provider, opts.project, opts.exclude)
+ return
+ }
+ await hydrateCache()
+ await renderDashboard('month', opts.provider, opts.refresh, opts.project, opts.exclude)
+ })
+
+program
+ .command('export')
+ .description('Export usage data to CSV or JSON')
+ .option('-f, --format ', 'Export format: csv, json', 'csv')
+ .option('-o, --output ', 'Output file path')
+ .option('--from ', 'Start date (YYYY-MM-DD). Exports a single custom period when set')
+ .option('--to ', 'End date (YYYY-MM-DD). Exports a single custom period when set')
+ .option('--provider ', 'Filter by provider (e.g. claude, gemini, cursor, copilot)', 'all')
+ .option('--project ', 'Show only projects matching name (repeatable)', collect, [])
+ .option('--exclude ', 'Exclude projects matching name (repeatable)', collect, [])
+ .action(async (opts) => {
+ assertFormat(opts.format, ['csv', 'json'], 'export')
+ await loadPricing()
+ await hydrateCache()
+ const pf = opts.provider
+ const fp = (p: ProjectSummary[]) => filterProjectsByName(p, opts.project, opts.exclude)
+ let customRange: DateRange | null = null
+ try {
+ customRange = parseDateRangeFlags(opts.from, opts.to)
+ } catch (err) {
+ const message = err instanceof Error ? err.message : String(err)
+ console.error(`\n Error: ${message}\n`)
+ process.exit(1)
+ }
+
+ const periods: PeriodExport[] = customRange
+ ? [{ label: formatDateRangeLabel(opts.from, opts.to), projects: fp(await parseAllSessions(customRange, pf)) }]
+ : [
+ { label: 'Today', projects: fp(await parseAllSessions(getDateRange('today').range, pf)) },
+ { label: '7 Days', projects: fp(await parseAllSessions(getDateRange('week').range, pf)) },
+ { label: '30 Days', projects: fp(await parseAllSessions(getDateRange('30days').range, pf)) },
+ ]
+
+ if (periods.every(p => p.projects.length === 0)) {
+ console.log('\n No usage data found.\n')
+ return
+ }
+
+ const defaultName = `codeburn-${toDateString(new Date())}`
+ const outputPath = opts.output ?? `${defaultName}.${opts.format}`
+
+ let savedPath: string
+ try {
+ if (opts.format === 'json') {
+ savedPath = await exportJson(periods, outputPath)
+ } else {
+ savedPath = await exportCsv(periods, outputPath)
+ }
+ } catch (err) {
+ // Protection guards in export.ts (symlink refusal, non-codeburn folder refusal, etc.)
+ // throw with a user-readable message. Print just the message, not the stack, so the CLI
+ // doesn't spray its internals at the user.
+ const message = err instanceof Error ? err.message : String(err)
+ console.error(`\n Export failed: ${message}\n`)
+ process.exit(1)
+ }
+
+ const exportedLabel = customRange ? formatDateRangeLabel(opts.from, opts.to) : 'Today + 7 Days + 30 Days'
+ console.log(`\n Exported (${exportedLabel}) to: ${savedPath}\n`)
+ })
+
+program
+ .command('menubar')
+ .description('Install and launch the macOS menubar app (one command, no clone)')
+ .option('--force', 'Reinstall even if an older copy is already in ~/Applications')
+ .action(async (opts: { force?: boolean }) => {
+ try {
+ const result = await installMenubarApp({ force: opts.force })
+ console.log(`\n Ready. ${result.installedPath}\n`)
+ } catch (err) {
+ const message = err instanceof Error ? err.message : String(err)
+ console.error(`\n Menubar install failed: ${message}\n`)
+ process.exit(1)
+ }
+ })
+
+program
+ .command('currency [code]')
+ .description('Set display currency (e.g. codeburn currency GBP)')
+ .option('--symbol ', 'Override the currency symbol')
+ .option('--reset', 'Reset to USD (removes currency config)')
+ .action(async (code?: string, opts?: { symbol?: string; reset?: boolean }) => {
+ if (opts?.reset) {
+ const config = await readConfig()
+ delete config.currency
+ await saveConfig(config)
+ console.log('\n Currency reset to USD.\n')
+ return
+ }
+
+ if (!code) {
+ const { code: activeCode, rate, symbol } = getCurrency()
+ if (activeCode === 'USD' && rate === 1) {
+ console.log('\n Currency: USD (default)')
+ console.log(` Config: ${getConfigFilePath()}\n`)
+ } else {
+ console.log(`\n Currency: ${activeCode}`)
+ console.log(` Symbol: ${symbol}`)
+ console.log(` Rate: 1 USD = ${rate} ${activeCode}`)
+ console.log(` Config: ${getConfigFilePath()}\n`)
+ }
+ return
+ }
+
+ const upperCode = code.toUpperCase()
+ if (!isValidCurrencyCode(upperCode)) {
+ console.error(`\n "${code}" is not a valid ISO 4217 currency code.\n`)
+ process.exitCode = 1
+ return
+ }
+
+ const config = await readConfig()
+ config.currency = {
+ code: upperCode,
+ ...(opts?.symbol ? { symbol: opts.symbol } : {}),
+ }
+ await saveConfig(config)
+
+ await loadCurrency()
+ const { rate, symbol } = getCurrency()
+
+ console.log(`\n Currency set to ${upperCode}.`)
+ console.log(` Symbol: ${symbol}`)
+ console.log(` Rate: 1 USD = ${rate} ${upperCode}`)
+ console.log(` Config saved to ${getConfigFilePath()}\n`)
+ })
+
+program
+ .command('model-alias [from] [to]')
+ .description('Map a provider model name to a canonical one for pricing (e.g. codeburn model-alias my-model claude-opus-4-6)')
+ .option('--remove ', 'Remove an alias')
+ .option('--list', 'List configured aliases')
+ .action(async (from?: string, to?: string, opts?: { remove?: string; list?: boolean }) => {
+ const config = await readConfig()
+ const aliases = config.modelAliases ?? {}
+
+ if (opts?.list || (!from && !opts?.remove)) {
+ const entries = Object.entries(aliases)
+ if (entries.length === 0) {
+ console.log('\n No model aliases configured.')
+ console.log(` Config: ${getConfigFilePath()}\n`)
+ } else {
+ console.log('\n Model aliases:')
+ for (const [src, dst] of entries) {
+ console.log(` ${src} -> ${dst}`)
+ }
+ console.log(` Config: ${getConfigFilePath()}\n`)
+ }
+ return
+ }
+
+ if (opts?.remove) {
+ if (!(opts.remove in aliases)) {
+ console.error(`\n Alias not found: ${opts.remove}\n`)
+ process.exitCode = 1
+ return
+ }
+ delete aliases[opts.remove]
+ config.modelAliases = Object.keys(aliases).length > 0 ? aliases : undefined
+ await saveConfig(config)
+ console.log(`\n Removed alias: ${opts.remove}\n`)
+ return
+ }
+
+ if (!from || !to) {
+ console.error('\n Usage: codeburn model-alias \n')
+ process.exitCode = 1
+ return
+ }
+
+ aliases[from] = to
+ config.modelAliases = aliases
+ await saveConfig(config)
+ console.log(`\n Alias saved: ${from} -> ${to}`)
+ console.log(` Config: ${getConfigFilePath()}\n`)
+ })
+
+program
+ .command('plan [action] [id]')
+ .description('Show or configure a subscription plan for overage tracking')
+ .option('--format ', 'Output format: text or json', 'text')
+ .option('--monthly-usd ', 'Monthly plan price in USD (for custom)', parseNumber)
+ .option('--provider ', 'Provider scope: all, claude, codex, cursor', 'all')
+ .option('--reset-day ', 'Day of month plan resets (1-28)', parseInteger, 1)
+ .action(async (action?: string, id?: string, opts?: { format?: string; monthlyUsd?: number; provider?: string; resetDay?: number }) => {
+ assertFormat(opts?.format ?? 'text', ['text', 'json'], 'plan')
+ const mode = action ?? 'show'
+
+ if (mode === 'show') {
+ const plan = await readPlan()
+ const displayPlan = !plan || plan.id === 'none'
+ ? { id: 'none', monthlyUsd: 0, provider: 'all', resetDay: 1, setAt: null }
+ : {
+ id: plan.id,
+ monthlyUsd: plan.monthlyUsd,
+ provider: plan.provider,
+ resetDay: clampResetDay(plan.resetDay),
+ setAt: plan.setAt,
+ }
+ if (opts?.format === 'json') {
+ console.log(JSON.stringify(displayPlan))
+ return
+ }
+ if (!plan || plan.id === 'none') {
+ console.log('\n Plan: none')
+ console.log(' API-pricing view is active.')
+ console.log(` Config: ${getConfigFilePath()}\n`)
+ return
+ }
+ console.log(`\n Plan: ${planDisplayName(plan.id)} (${plan.id})`)
+ console.log(` Budget: $${plan.monthlyUsd}/month`)
+ console.log(` Provider: ${plan.provider}`)
+ console.log(` Reset day: ${clampResetDay(plan.resetDay)}`)
+ console.log(` Set at: ${plan.setAt}`)
+ console.log(` Config: ${getConfigFilePath()}\n`)
+ return
+ }
+
+ if (mode === 'reset') {
+ await clearPlan()
+ console.log('\n Plan reset. API-pricing view is active.\n')
+ return
+ }
+
+ if (mode !== 'set') {
+ console.error('\n Usage: codeburn plan [set | reset]\n')
+ process.exitCode = 1
+ return
+ }
+
+ if (!id || !isPlanId(id)) {
+ console.error(`\n Plan id must be one of: claude-pro, claude-max, cursor-pro, custom, none; got "${id ?? ''}".\n`)
+ process.exitCode = 1
+ return
+ }
+
+ const resetDay = opts?.resetDay ?? 1
+ if (!Number.isInteger(resetDay) || resetDay < 1 || resetDay > 28) {
+ console.error(`\n --reset-day must be an integer from 1 to 28; got ${resetDay}.\n`)
+ process.exitCode = 1
+ return
+ }
+
+ if (id === 'none') {
+ await clearPlan()
+ console.log('\n Plan reset. API-pricing view is active.\n')
+ return
+ }
+
+ if (id === 'custom') {
+ if (opts?.monthlyUsd === undefined) {
+ console.error('\n Custom plans require --monthly-usd .\n')
+ process.exitCode = 1
+ return
+ }
+ const monthlyUsd = opts.monthlyUsd
+ if (!Number.isFinite(monthlyUsd) || monthlyUsd <= 0) {
+ console.error(`\n --monthly-usd must be a positive number; got ${opts.monthlyUsd}.\n`)
+ process.exitCode = 1
+ return
+ }
+ const provider = opts?.provider ?? 'all'
+ if (!isPlanProvider(provider)) {
+ console.error(`\n --provider must be one of: all, claude, codex, cursor; got "${provider}".\n`)
+ process.exitCode = 1
+ return
+ }
+ await savePlan({
+ id: 'custom',
+ monthlyUsd,
+ provider,
+ resetDay,
+ setAt: new Date().toISOString(),
+ })
+ console.log(`\n Plan set to custom ($${monthlyUsd}/month, ${provider}, reset day ${resetDay}).`)
+ console.log(` Config saved to ${getConfigFilePath()}\n`)
+ return
+ }
+
+ const preset = getPresetPlan(id)
+ if (!preset) {
+ console.error(`\n Unknown preset "${id}".\n`)
+ process.exitCode = 1
+ return
+ }
+
+ await savePlan({
+ ...preset,
+ resetDay,
+ setAt: new Date().toISOString(),
+ })
+ console.log(`\n Plan set to ${planDisplayName(preset.id)} ($${preset.monthlyUsd}/month).`)
+ console.log(` Provider: ${preset.provider}`)
+ console.log(` Reset day: ${resetDay}`)
+ console.log(` Config saved to ${getConfigFilePath()}\n`)
+ })
+
+program
+ .command('optimize')
+ .description('Find token waste and get exact fixes')
+ .option('-p, --period ', 'Analysis period: today, week, 30days, month, all', '30days')
+ .option('--provider ', 'Filter by provider (e.g. claude, gemini, cursor, copilot)', 'all')
+ .action(async (opts) => {
+ await loadPricing()
+ await hydrateCache()
+ const { range, label } = getDateRange(opts.period)
+ const projects = await parseAllSessions(range, opts.provider)
+ await runOptimize(projects, label, range)
+ })
+
+program
+ .command('compare')
+ .description('Compare two AI models side-by-side')
+ .option('-p, --period ', 'Analysis period: today, week, 30days, month, all', 'all')
+ .option('--provider ', 'Filter by provider (e.g. claude, gemini, cursor, copilot)', 'all')
+ .action(async (opts) => {
+ await loadPricing()
+ await hydrateCache()
+ const { range } = getDateRange(opts.period)
+ await renderCompare(range, opts.provider)
+ })
+
+program
+ .command('models')
+ .description('Per-model token + cost table, optionally exploded by task type')
+ .option('-p, --period ', 'Analysis period: today, week, 30days, month, all', '30days')
+ .option('--from ', 'Custom range start (YYYY-MM-DD)')
+ .option('--to ', 'Custom range end (YYYY-MM-DD)')
+ .option('--provider ', 'Filter by provider (e.g. claude, codex, cursor)', 'all')
+ .option('--task ', 'Filter to one task type (e.g. feature, debugging, refactoring)')
+ .option('--by-task', 'One row per (provider, model, task) instead of one row per (provider, model)')
+ .option('--top ', 'Show only the top N rows', (v: string) => parseInt(v, 10))
+ .option('--min-cost ', 'Hide rows below this cost threshold', (v: string) => parseFloat(v))
+ .option('--no-totals', 'Suppress the footer totals row')
+ .option('--format ', 'Output format: table, markdown, json, csv', 'table')
+ .action(async (opts) => {
+ const { aggregateModels, renderTable, renderMarkdown, renderJson, renderCsv } = await import('./models-report.js')
+ await loadPricing()
+ await hydrateCache()
+
+ let range
+ if (opts.from || opts.to) {
+ const customRange = parseDateRangeFlags(opts.from, opts.to)
+ if (!customRange) {
+ process.stderr.write('codeburn: --from and --to must be valid YYYY-MM-DD dates\n')
+ process.exit(1)
+ }
+ range = customRange
+ } else {
+ range = getDateRange(opts.period).range
+ }
+
+ const projects = await parseAllSessions(range, opts.provider)
+ const rows = await aggregateModels(projects, {
+ byTask: !!opts.byTask,
+ taskFilter: opts.task,
+ topN: typeof opts.top === 'number' && Number.isFinite(opts.top) ? opts.top : undefined,
+ minCost: typeof opts.minCost === 'number' && Number.isFinite(opts.minCost) ? opts.minCost : 0.01,
+ })
+
+ const fmt = (opts.format ?? 'table').toLowerCase()
+ if (rows.length === 0 && (fmt === 'table' || fmt === 'markdown')) {
+ process.stdout.write('No model usage found for the selected period.\n')
+ return
+ }
+ if (fmt === 'json') {
+ process.stdout.write(renderJson(rows) + '\n')
+ } else if (fmt === 'csv') {
+ process.stdout.write(renderCsv(rows, { byTask: !!opts.byTask }) + '\n')
+ } else if (fmt === 'markdown' || fmt === 'md') {
+ process.stdout.write(renderMarkdown(rows, { byTask: !!opts.byTask, showTotals: opts.totals !== false }) + '\n')
+ } else if (fmt === 'table') {
+ process.stdout.write(renderTable(rows, { byTask: !!opts.byTask, showTotals: opts.totals !== false }) + '\n')
+ } else {
+ process.stderr.write(`codeburn: unknown --format "${opts.format}". Choose table, markdown, json, or csv.\n`)
+ process.exit(1)
+ }
+ })
+
+program
+ .command('yield')
+ .description('Track which AI spend shipped to main vs reverted/abandoned (experimental)')
+ .option('-p, --period ', 'Analysis period: today, week, 30days, month, all', 'week')
+ .action(async (opts) => {
+ const { computeYield, formatYieldSummary } = await import('./yield.js')
+ await loadPricing()
+ await hydrateCache()
+ const { range, label } = getDateRange(opts.period)
+ console.log(`\n Analyzing yield for ${label}...\n`)
+ const summary = await computeYield(range, process.cwd())
+ console.log(formatYieldSummary(summary))
+ })
+
+program.parse()
diff --git a/tsup.config.ts b/tsup.config.ts
index 2ba26c5..957fdce 100644
--- a/tsup.config.ts
+++ b/tsup.config.ts
@@ -1,7 +1,7 @@
import { defineConfig } from 'tsup'
export default defineConfig({
- entry: ['src/cli.ts'],
+ entry: ['src/main.ts'],
format: ['esm'],
target: 'node20',
outDir: 'dist',
@@ -9,7 +9,4 @@ export default defineConfig({
splitting: false,
sourcemap: true,
dts: false,
- banner: {
- js: '#!/usr/bin/env node',
- },
})
From 3b71650f243d4ccc80c7f60e07f10098f99d0e45 Mon Sep 17 00:00:00 2001
From: Resham Joshi <65915470+iamtoruk@users.noreply.github.com>
Date: Mon, 11 May 2026 22:02:38 -0700
Subject: [PATCH 09/28] Fix mangled project paths in dashboard (#320)
* Fix mangled project paths in By Project and Top Sessions panels
shortProject() decoded Claude Code slugs by splitting on '-', which
broke directory names containing dashes ('foo-bar' became 'foo/bar').
Switch the dashboard to consume ProjectSummary.projectPath (the
canonical cwd already extracted by parser.ts) and rewrite shortProject
to operate on a real absolute path.
* shortProject: cache homedir, normalize Windows backslashes, fix stale test helper
---------
Co-authored-by: Abdallah Meghraoui
---
src/dashboard.tsx | 25 ++++++++++++++-----------
tests/dashboard.test.ts | 35 ++++++++++++++++++++++++++++++++++-
2 files changed, 48 insertions(+), 12 deletions(-)
diff --git a/src/dashboard.tsx b/src/dashboard.tsx
index e666b18..0d84fbd 100644
--- a/src/dashboard.tsx
+++ b/src/dashboard.tsx
@@ -248,16 +248,19 @@ function DailyActivity({ projects, days = 14, pw, bw }: { projects: ProjectSumma
)
}
-const _homeEncoded = homedir().replace(/\//g, '-')
+const _home = homedir()
+const _homePrefix = _home.endsWith('/') ? _home : _home + '/'
-function shortProject(encoded: string): string {
- let path = encoded.replace(/^-/, '')
- if (path.startsWith(_homeEncoded.replace(/^-/, ''))) {
- path = path.slice(_homeEncoded.replace(/^-/, '').length).replace(/^-/, '')
- }
- path = path.replace(/^private-tmp-[^-]+-[^-]+-/, '').replace(/^private-tmp-/, '').replace(/^tmp-/, '')
+export function shortProject(absPath: string): string {
+ const normalized = absPath.replace(/\\/g, '/')
+ let path: string
+ if (normalized === _home) path = ''
+ else if (normalized.startsWith(_homePrefix)) path = normalized.slice(_homePrefix.length)
+ else path = normalized
+ path = path.replace(/^\/+/, '')
+ path = path.replace(/^private\/tmp\/[^/]+\/[^/]+\//, '').replace(/^private\/tmp\//, '').replace(/^tmp\//, '')
if (!path) return 'home'
- const parts = path.split('-').filter(Boolean)
+ const parts = path.split('/').filter(Boolean)
if (parts.length <= 3) return parts.join('/')
return parts.slice(-3).join('/')
}
@@ -283,7 +286,7 @@ function ProjectBreakdown({ projects, pw, bw, budgets }: { projects: ProjectSumm
return (
- {fit(shortProject(project.project), nw)}
+ {fit(shortProject(project.projectPath), nw)}
{formatCost(project.totalCostUSD).padStart(8)}
{avgCost.padStart(PROJECT_COL_AVG)}
{String(project.sessions.length).padStart(6)}
@@ -443,7 +446,7 @@ const TOP_SESSIONS_CALLS_COL = 6
function TopSessions({ projects, pw, bw }: { projects: ProjectSummary[]; pw: number; bw: number }) {
const allSessions = projects.flatMap(p =>
- p.sessions.map(s => ({ ...s, projectName: p.project }))
+ p.sessions.map(s => ({ ...s, projectPath: p.projectPath }))
)
const top = [...allSessions].sort((a, b) => b.totalCostUSD - a.totalCostUSD).slice(0, 5)
@@ -461,7 +464,7 @@ function TopSessions({ projects, pw, bw }: { projects: ProjectSummary[]; pw: num
const date = session.firstTimestamp
? session.firstTimestamp.slice(0, TOP_SESSIONS_DATE_LEN)
: '----------'
- const label = `${date} ${shortProject(session.projectName)}`
+ const label = `${date} ${shortProject(session.projectPath)}`
return (
diff --git a/tests/dashboard.test.ts b/tests/dashboard.test.ts
index 0d36e2e..da802f1 100644
--- a/tests/dashboard.test.ts
+++ b/tests/dashboard.test.ts
@@ -1,5 +1,8 @@
+import { homedir } from 'os'
+
import { describe, it, expect } from 'vitest'
+import { shortProject } from '../src/dashboard.js'
import { formatCost } from '../src/format.js'
import type { ProjectSummary, SessionSummary } from '../src/types.js'
@@ -53,7 +56,7 @@ function makeProject(name: string, sessions: SessionSummary[]): ProjectSummary {
// Logic replicated from TopSessions component
function getTopSessions(projects: ProjectSummary[], n = 5) {
- const all = projects.flatMap(p => p.sessions.map(s => ({ ...s, projectName: p.project })))
+ const all = projects.flatMap(p => p.sessions.map(s => ({ ...s, projectPath: p.projectPath })))
return [...all].sort((a, b) => b.totalCostUSD - a.totalCostUSD).slice(0, n)
}
@@ -99,6 +102,36 @@ describe('TopSessions - top-5 selection', () => {
})
})
+describe('shortProject - path shortening', () => {
+ const home = homedir()
+
+ it('preserves directory names containing dashes', () => {
+ expect(shortProject(`${home}/work/my-project`)).toBe('work/my-project')
+ })
+
+ it('preserves directory names containing dots', () => {
+ expect(shortProject(`${home}/work/my.app.io`)).toBe('work/my.app.io')
+ })
+
+ it('returns "home" for the home dir itself', () => {
+ expect(shortProject(home)).toBe('home')
+ })
+
+ it('does not strip a sibling whose name shares the home prefix', () => {
+ const sibling = `${home}-backup/proj`
+ expect(shortProject(sibling).endsWith('proj')).toBe(true)
+ expect(shortProject(sibling)).not.toMatch(/^-/)
+ })
+
+ it('keeps only the last 3 segments for deeply nested paths', () => {
+ expect(shortProject(`${home}/a/b/c/d/e/f`)).toBe('d/e/f')
+ })
+
+ it('handles paths outside the home dir', () => {
+ expect(shortProject('/opt/myproject')).toBe('opt/myproject')
+ })
+})
+
describe('avg/s in ProjectBreakdown', () => {
it('returns dash for a project with no sessions', () => {
const project = makeProject('proj', [])
From fe2e622038035b429c08fde6c969a49101e91521 Mon Sep 17 00:00:00 2001
From: Resham Joshi <65915470+iamtoruk@users.noreply.github.com>
Date: Mon, 11 May 2026 22:16:00 -0700
Subject: [PATCH 10/28] Skip Cursor bubble rows that lack a createdAt timestamp
(#321)
Bubble rows without createdAt were defaulting to new Date(), which
misattributed historical or undated usage to Today and inflated the
daily chart. Now filtered at the SQL level and skipped in application
code.
Based on the bubble-side fix from #262 by @darthrevanyunka.
---
src/providers/cursor.ts | 6 ++++--
1 file changed, 4 insertions(+), 2 deletions(-)
diff --git a/src/providers/cursor.ts b/src/providers/cursor.ts
index 6362512..ebd7f91 100644
--- a/src/providers/cursor.ts
+++ b/src/providers/cursor.ts
@@ -329,7 +329,8 @@ const USER_MESSAGES_QUERY = `
// the whole template. The original combined string is preserved as
// BUBBLE_QUERY_SINCE for any caller that doesn't want the cap.
const BUBBLE_QUERY_SINCE_HEAD = BUBBLE_QUERY_BASE + `
- AND (json_extract(value, '$.createdAt') > ? OR json_extract(value, '$.createdAt') IS NULL)`
+ AND json_extract(value, '$.createdAt') IS NOT NULL
+ AND json_extract(value, '$.createdAt') > ?`
const BUBBLE_QUERY_SINCE_TAIL = `
ORDER BY ROWID ASC
`
@@ -458,6 +459,7 @@ function parseBubbles(db: SqliteDatabase, seenKeys: Set): { calls: Parse
}
const createdAt = row.created_at ?? ''
+ if (!createdAt) continue
// The JSON `conversationId` field on bubbles is empty in current
// Cursor builds. The real composerId lives in the row key
// `bubbleId::`. Extract from the key so the
@@ -487,7 +489,7 @@ function parseBubbles(db: SqliteDatabase, seenKeys: Set): { calls: Parse
const costUSD = calculateCost(pricingModel, inputTokens, outputTokens, 0, 0, 0)
- const timestamp = createdAt || new Date().toISOString()
+ const timestamp = createdAt
const userQuestion = takeUserMessage(userMessages, conversationId)
const assistantText = blobToText(row.user_text)
const userText = (userQuestion + ' ' + assistantText).trim()
From f9a5d2c8e6ddfd995ed6063e6bf1ea5e8b6923f6 Mon Sep 17 00:00:00 2001
From: iamtoruk
Date: Mon, 11 May 2026 22:19:15 -0700
Subject: [PATCH 11/28] Add changelog entries for project path fix and Cursor
undated bubbles
---
CHANGELOG.md | 7 +++++++
1 file changed, 7 insertions(+)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 8f70616..2bf6977 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -15,6 +15,13 @@
`_` names, which the shared MCP pipeline did not recognize.
The provider now normalizes these to the canonical `mcp____`
form so MCP breakdowns and `optimize` work correctly. Closes #308.
+- **Mangled project names in dashboard.** The By Project and Top Sessions
+ panels decoded slugs by splitting on `-`, which broke directory names
+ containing dashes or dots (e.g. `my-project` rendered as `my/project`).
+ Now uses the real project path instead. Closes #196.
+- **Cursor undated bubble rows misattributed to Today.** Bubble rows without
+ a `createdAt` timestamp were defaulting to the current date, inflating
+ Today's spend. Now skipped at both the SQL and application level.
## 0.9.8 - 2026-05-10
From 151d24fb267b6855453a2add8ed06817a3a8f469 Mon Sep 17 00:00:00 2001
From: Resham Joshi <65915470+iamtoruk@users.noreply.github.com>
Date: Mon, 11 May 2026 22:25:32 -0700
Subject: [PATCH 12/28] Drop Z suffix from day-aggregator test timestamps for
timezone stability (#322)
Timestamps with Z are interpreted as UTC, causing date bucketing tests
to fail in non-UTC timezones (e.g. UTC+12 shifts Apr 9 10:00Z to Apr 8).
Local timestamps without Z are interpreted in the runtime timezone,
matching how the aggregator actually buckets dates.
Based on #112 by @lfl1337, extended to cover all affected timestamps.
---
tests/day-aggregator.test.ts | 34 +++++++++++++++++-----------------
1 file changed, 17 insertions(+), 17 deletions(-)
diff --git a/tests/day-aggregator.test.ts b/tests/day-aggregator.test.ts
index 9ca9239..c58937b 100644
--- a/tests/day-aggregator.test.ts
+++ b/tests/day-aggregator.test.ts
@@ -46,8 +46,8 @@ describe('aggregateProjectsIntoDays', () => {
sessions: [{
sessionId: 's1',
project: 'p',
- firstTimestamp: '2026-04-09T10:00:00Z',
- lastTimestamp: '2026-04-10T08:00:00Z',
+ firstTimestamp: '2026-04-09T10:00:00',
+ lastTimestamp: '2026-04-10T08:00:00',
totalCostUSD: 10,
totalInputTokens: 0,
totalOutputTokens: 0,
@@ -57,14 +57,14 @@ describe('aggregateProjectsIntoDays', () => {
turns: [
{
userMessage: 'hi',
- timestamp: '2026-04-09T10:00:00Z',
+ timestamp: '2026-04-09T10:00:00',
sessionId: 's1',
category: 'coding',
retries: 0,
hasEdits: true,
assistantCalls: [
- makeCall('2026-04-09T10:00:00Z', 4),
- makeCall('2026-04-10T08:00:00Z', 6),
+ makeCall('2026-04-09T10:00:00', 4),
+ makeCall('2026-04-10T08:00:00', 6),
],
},
],
@@ -92,8 +92,8 @@ describe('aggregateProjectsIntoDays', () => {
sessions: [{
sessionId: 's1',
project: 'p',
- firstTimestamp: '2026-04-09T10:00:00Z',
- lastTimestamp: '2026-04-09T10:05:00Z',
+ firstTimestamp: '2026-04-09T10:00:00',
+ lastTimestamp: '2026-04-09T10:05:00',
totalCostUSD: 3,
totalInputTokens: 0,
totalOutputTokens: 0,
@@ -103,12 +103,12 @@ describe('aggregateProjectsIntoDays', () => {
turns: [
{
userMessage: 'hi',
- timestamp: '2026-04-09T10:00:00Z',
+ timestamp: '2026-04-09T10:00:00',
sessionId: 's1',
category: 'coding',
retries: 0,
hasEdits: true,
- assistantCalls: [makeCall('2026-04-09T10:00:00Z', 3)],
+ assistantCalls: [makeCall('2026-04-09T10:00:00', 3)],
},
],
modelBreakdown: {},
@@ -138,8 +138,8 @@ describe('aggregateProjectsIntoDays', () => {
sessions: [{
sessionId: 's1',
project: 'p',
- firstTimestamp: '2026-04-09T23:59:00Z',
- lastTimestamp: '2026-04-10T00:10:00Z',
+ firstTimestamp: '2026-04-09T23:59:00',
+ lastTimestamp: '2026-04-10T00:10:00',
totalCostUSD: 1,
totalInputTokens: 0, totalOutputTokens: 0, totalCacheReadTokens: 0, totalCacheWriteTokens: 0,
apiCalls: 0,
@@ -151,7 +151,7 @@ describe('aggregateProjectsIntoDays', () => {
}),
]
const days = aggregateProjectsIntoDays(projects)
- const expectedDate = dateKey('2026-04-09T23:59:00Z')
+ const expectedDate = dateKey('2026-04-09T23:59:00')
expect(days[0]!.date).toBe(expectedDate)
expect(days[0]!.sessions).toBe(1)
})
@@ -162,18 +162,18 @@ describe('aggregateProjectsIntoDays', () => {
sessions: [{
sessionId: 's1',
project: 'p',
- firstTimestamp: '2026-04-10T10:00:00Z',
- lastTimestamp: '2026-04-10T10:00:00Z',
+ firstTimestamp: '2026-04-10T10:00:00',
+ lastTimestamp: '2026-04-10T10:00:00',
totalCostUSD: 10,
totalInputTokens: 0, totalOutputTokens: 0, totalCacheReadTokens: 0, totalCacheWriteTokens: 0,
apiCalls: 2,
turns: [
{
- userMessage: 'x', timestamp: '2026-04-10T10:00:00Z', sessionId: 's1',
+ userMessage: 'x', timestamp: '2026-04-10T10:00:00', sessionId: 's1',
category: 'coding', retries: 0, hasEdits: false,
assistantCalls: [
- makeCall('2026-04-10T10:00:00Z', 7, 'Opus 4.7', 'claude'),
- makeCall('2026-04-10T10:00:00Z', 3, 'gpt-5', 'codex'),
+ makeCall('2026-04-10T10:00:00', 7, 'Opus 4.7', 'claude'),
+ makeCall('2026-04-10T10:00:00', 3, 'gpt-5', 'codex'),
],
},
],
From 929c66e7d1c2c9417ae52e3cd8ecaea6245be044 Mon Sep 17 00:00:00 2001
From: ozymandiashh <234437643+ozymandiashh@users.noreply.github.com>
Date: Wed, 13 May 2026 00:51:39 +0300
Subject: [PATCH 13/28] Fix Antigravity Windows discovery
---
CHANGELOG.md | 4 +
docs/providers/antigravity.md | 33 ++++---
src/providers/antigravity.ts | 146 +++++++++++++++++++++-------
tests/providers/antigravity.test.ts | 123 +++++++++++++++++++++++
4 files changed, 258 insertions(+), 48 deletions(-)
create mode 100644 tests/providers/antigravity.test.ts
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 2bf6977..088f161 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -15,6 +15,10 @@
`_` names, which the shared MCP pipeline did not recognize.
The provider now normalizes these to the canonical `mcp____`
form so MCP breakdowns and `optimize` work correctly. Closes #308.
+- **Antigravity Windows language-server discovery.** Antigravity detection now
+ supports Windows process discovery, `--extension_server_port`,
+ `--extension_server_csrf_token`, `--flag=value` syntax, and both wrapped and
+ unwrapped Connect-RPC response shapes. Closes #249.
- **Mangled project names in dashboard.** The By Project and Top Sessions
panels decoded slugs by splitting on `-`, which broke directory names
containing dashes or dots (e.g. `my-project` rendered as `my/project`).
diff --git a/docs/providers/antigravity.md b/docs/providers/antigravity.md
index 723cef5..ca6d23f 100644
--- a/docs/providers/antigravity.md
+++ b/docs/providers/antigravity.md
@@ -3,41 +3,50 @@
Google Antigravity. The only provider that does not read files off disk: it speaks to a local language-server RPC endpoint instead.
- **Source:** `src/providers/antigravity.ts`
-- **Loading:** lazy (`src/providers/index.ts:14-27`). Lazy because the protobuf dependency is heavy.
-- **Test:** none. Mocking the RPC endpoint cleanly is the open issue.
+- **Loading:** lazy via `src/providers/index.ts`. Lazy because the protobuf dependency is heavy.
+- **Test:** focused helper coverage in `tests/providers/antigravity.test.ts`.
## Where it reads from
A local HTTPS RPC endpoint exposed by Antigravity's language server. The parser:
-1. Locates the running language-server process via `ps`.
+1. Locates the running language-server process via `ps` on POSIX or
+ `Get-CimInstance Win32_Process` on Windows.
2. Reads its port and CSRF token from process metadata.
3. Calls `GetCascadeTrajectoryGeneratorMetadata` over HTTPS.
-4. Validates the response (capped at 5-15 MB depending on cascade size).
+4. Validates the response (capped at 16 MB).
-If the language server is not running, the parser falls back to the cached results file (`antigravity.ts:262-272`).
+Antigravity exposes slightly different process flags across platforms:
+POSIX builds have used `--https_server_port` and `--csrf_token`; Windows
+builds can expose `--extension_server_port` and
+`--extension_server_csrf_token`. Both space-separated and `--flag=value`
+forms are supported.
+
+If the language server is not running, the parser falls back to the cached results file.
## Storage format
-Protobuf. Cascade and response objects map to `ParsedProviderCall` directly; see `antigravity.ts:299-323`.
+Protobuf. Cascade and response objects map to `ParsedProviderCall` directly.
## Caching
-Custom file cache at `$CODEBURN_CACHE_DIR/antigravity-results.json` (defaults to `~/.cache/codeburn/`). The version constant is at `antigravity.ts:12`; the cache machinery (`loadCache`, `flushCache`) lives in `antigravity.ts:75-125`. The cache is also used as the data source when the RPC endpoint is unavailable, not just as an optimization. Bumping the cache version forces a recompute.
+Custom file cache at `$CODEBURN_CACHE_DIR/antigravity-results.json` (defaults to `~/.cache/codeburn/`). The cache is also used as the data source when the RPC endpoint is unavailable, not just as an optimization. Bumping the cache version forces a recompute.
## Deduplication
-Per `:` (`antigravity.ts:308`).
+Per `:`.
## Quirks
- **Antigravity is the only provider that requires a live process.** A user who closes Antigravity loses the most-recent data until next launch (the cache covers older runs).
-- The 5-15 MB cap on RPC responses is necessary because individual cascades can balloon. Raising it risks OOM on the user's machine.
-- Token types are split across `inputTokens`, `responseOutputTokens`, and `thinkingOutputTokens` (`antigravity.ts:313-323`). Thinking is billed at output rate.
+- The 16 MB cap on RPC responses is necessary because individual cascades can balloon. Raising it risks OOM on the user's machine.
+- Token types are split across `inputTokens`, `responseOutputTokens`, and `thinkingOutputTokens`. Thinking is billed at output rate.
## When fixing a bug here
-1. Reproducing requires Antigravity running locally. There is no fixture for the RPC, which is a real testing gap.
+1. Reproducing the full provider path requires Antigravity running locally.
+ The unit tests cover process flag parsing and wrapped/unwrapped RPC response
+ extraction, but they do not stand up a live Antigravity RPC endpoint.
2. Before any change, capture a sample protobuf response (anonymized) so future regressions can be tested against a recording.
-3. If the bug is "no data after Antigravity update", the protobuf schema may have shifted. The parser's response handling at `antigravity.ts:299-323` is the place to look.
+3. If the bug is "no data after Antigravity update", the protobuf schema may have shifted. The parser's response handling is the place to look.
4. If the bug is "stale data", check whether the RPC is reachable; the cache fallback can mask connectivity issues.
diff --git a/src/providers/antigravity.ts b/src/providers/antigravity.ts
index 3f9667e..95f96c9 100644
--- a/src/providers/antigravity.ts
+++ b/src/providers/antigravity.ts
@@ -14,7 +14,7 @@ const CACHE_VERSION = 2
const RPC_TIMEOUT_MS = 5000
const MAX_RESPONSE_BYTES = 16 * 1024 * 1024
-type ServerInfo = {
+export type ServerInfo = {
port: number
csrfToken: string
}
@@ -31,7 +31,7 @@ type UsageEntry = {
responseId?: string
}
-type GeneratorMetadata = {
+export type GeneratorMetadata = {
stepIndices?: number[]
chatModel?: {
model: string
@@ -42,6 +42,20 @@ type GeneratorMetadata = {
}
}
+type ModelMapResponse = {
+ models?: Record
+ response?: {
+ models?: Record
+ }
+}
+
+type GeneratorMetadataResponse = {
+ generatorMetadata?: GeneratorMetadata[]
+ response?: {
+ generatorMetadata?: GeneratorMetadata[]
+ }
+}
+
type CachedCascade = {
mtimeMs: number
sizeBytes: number
@@ -59,6 +73,9 @@ let memCache: AntigravityCache | null = null
let cacheDirty = false
let httpsAgent: https.Agent | undefined
+const SERVER_PORT_FLAGS = ['https_server_port', 'extension_server_port']
+const CSRF_TOKEN_FLAGS = ['csrf_token', 'extension_server_csrf_token']
+
function getAgent(): https.Agent {
if (!httpsAgent) httpsAgent = new https.Agent({ rejectUnauthorized: false })
return httpsAgent
@@ -72,6 +89,72 @@ function getCachePath(): string {
return join(getCacheDir(), 'antigravity-results.json')
}
+function execFileText(command: string, args: string[], timeout = 3000): Promise {
+ return new Promise((resolve, reject) => {
+ execFile(command, args, { encoding: 'utf-8', timeout, maxBuffer: 1024 * 1024 }, (err, stdout) => {
+ if (err) reject(err)
+ else resolve(stdout)
+ })
+ })
+}
+
+function getFlagValue(line: string, names: string[]): string | null {
+ for (const name of names) {
+ const match = line.match(new RegExp(`--${name}(?:=|\\s+)(?:"([^"]+)"|'([^']+)'|([^\\s]+))`, 'i'))
+ const value = match?.[1] ?? match?.[2] ?? match?.[3]
+ if (value && !value.startsWith('--')) return value
+ }
+ return null
+}
+
+function isLikelyCsrfToken(value: string): boolean {
+ return value.length >= 16 && /^[A-Za-z0-9._~:/+=-]+$/.test(value)
+}
+
+export function parseAntigravityServerInfoFromLine(line: string): ServerInfo | null {
+ const lower = line.toLowerCase()
+ if (!lower.includes('language_server') || !lower.includes('antigravity')) return null
+
+ const rawPort = getFlagValue(line, SERVER_PORT_FLAGS)
+ const csrfToken = getFlagValue(line, CSRF_TOKEN_FLAGS)
+ if (!rawPort || !csrfToken) return null
+ if (!isLikelyCsrfToken(csrfToken)) return null
+
+ const port = Number(rawPort)
+ if (!Number.isInteger(port) || port <= 0 || port > 65535) return null
+
+ return { port, csrfToken }
+}
+
+export function parseAntigravityServerInfo(lines: string[]): ServerInfo | null {
+ for (const line of lines) {
+ const server = parseAntigravityServerInfoFromLine(line)
+ if (server) return server
+ }
+ return null
+}
+
+export function extractAntigravityModelMap(resp: unknown): ModelMap {
+ if (!resp || typeof resp !== 'object') return {}
+ const data = resp as ModelMapResponse
+ const models = data.response?.models ?? data.models
+ const map: ModelMap = {}
+ if (!models) return map
+ for (const [key, info] of Object.entries(models)) {
+ if (info && typeof info === 'object' && typeof info.model === 'string') {
+ map[info.model] = key
+ }
+ }
+ return map
+}
+
+export function extractAntigravityGeneratorMetadata(resp: unknown): GeneratorMetadata[] {
+ if (!resp || typeof resp !== 'object') return []
+ const data = resp as GeneratorMetadataResponse
+ const metadata = data.response?.generatorMetadata ?? data.generatorMetadata
+ return Array.isArray(metadata) ? metadata : []
+}
+
async function loadCache(): Promise {
if (memCache) return memCache
try {
@@ -124,27 +207,27 @@ async function flushCache(liveCascadeIds?: Set): Promise {
} catch { /* best-effort */ }
}
+async function readProcessCommandLines(): Promise {
+ if (process.platform === 'win32') {
+ const script = [
+ "$ErrorActionPreference = 'SilentlyContinue'",
+ '[Console]::OutputEncoding = [System.Text.Encoding]::UTF8',
+ "Get-CimInstance Win32_Process | Where-Object { $_.CommandLine -and $_.CommandLine -like '*language_server*' -and $_.CommandLine -like '*antigravity*' } | ForEach-Object { $_.CommandLine }",
+ ].join('; ')
+ const output = await execFileText('powershell.exe', ['-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-Command', script], 5000)
+ return output.split(/\r?\n/)
+ }
+
+ const output = await execFileText('ps', ['-ww', '-eo', 'args'])
+ return output.split('\n')
+}
+
async function detectServer(): Promise {
if (cachedServer !== undefined) return cachedServer
try {
- const output = await new Promise((resolve, reject) => {
- execFile('ps', ['-eo', 'args'], { encoding: 'utf-8', timeout: 3000 }, (err, stdout) => {
- if (err) reject(err)
- else resolve(stdout)
- })
- })
- for (const line of output.split('\n')) {
- if (!line.includes('language_server') || !line.includes('antigravity')) continue
- if (!line.includes('--https_server_port')) continue
-
- const csrfMatch = line.match(/--csrf_token\s+([0-9a-f-]{32,})/)
- const portMatch = line.match(/--https_server_port\s+(\d+)/)
- if (csrfMatch && portMatch) {
- cachedServer = { csrfToken: csrfMatch[1]!, port: parseInt(portMatch[1]!, 10) }
- return cachedServer
- }
- }
- } catch { /* ps failed or timed out */ }
+ cachedServer = parseAntigravityServerInfo(await readProcessCommandLines())
+ return cachedServer
+ } catch { /* process discovery failed or timed out */ }
cachedServer = null
return null
}
@@ -199,20 +282,12 @@ async function rpc(server: ServerInfo, method: string, body: Record {
if (cachedModelMap) return cachedModelMap
- const map: ModelMap = {}
try {
- const resp = await rpc(server, 'GetAvailableModels') as {
- response?: { models?: Record }
- }
- const models = resp?.response?.models
- if (models) {
- for (const [key, info] of Object.entries(models)) {
- if (info.model) map[info.model] = key
- }
- }
+ cachedModelMap = extractAntigravityModelMap(await rpc(server, 'GetAvailableModels'))
+ return cachedModelMap
} catch { /* best-effort */ }
- cachedModelMap = map
- return map
+ cachedModelMap = {}
+ return cachedModelMap
}
// Strip Antigravity-specific suffixes so the pricing DB can match
@@ -275,10 +350,9 @@ function createParser(source: SessionSource, seenKeys: Set): SessionPars
let metadata: GeneratorMetadata[]
try {
- const resp = await rpc(server, 'GetCascadeTrajectoryGeneratorMetadata', { cascadeId }) as {
- generatorMetadata?: GeneratorMetadata[]
- }
- metadata = resp?.generatorMetadata ?? []
+ metadata = extractAntigravityGeneratorMetadata(
+ await rpc(server, 'GetCascadeTrajectoryGeneratorMetadata', { cascadeId }),
+ )
} catch {
if (cached) {
for (const call of cached.calls) {
diff --git a/tests/providers/antigravity.test.ts b/tests/providers/antigravity.test.ts
new file mode 100644
index 0000000..9396c37
--- /dev/null
+++ b/tests/providers/antigravity.test.ts
@@ -0,0 +1,123 @@
+import { describe, expect, it } from 'vitest'
+
+import {
+ extractAntigravityGeneratorMetadata,
+ extractAntigravityModelMap,
+ parseAntigravityServerInfo,
+ parseAntigravityServerInfoFromLine,
+} from '../../src/providers/antigravity.js'
+
+describe('antigravity provider helpers', () => {
+ it('parses legacy https server flags from POSIX process args', () => {
+ const server = parseAntigravityServerInfoFromLine(
+ '/Applications/Antigravity.app/language_server_macos_arm --app_data_dir antigravity --https_server_port 57101 --csrf_token 01234567-89ab-cdef-0123-456789abcdef',
+ )
+
+ expect(server).toEqual({
+ port: 57101,
+ csrfToken: '01234567-89ab-cdef-0123-456789abcdef',
+ })
+ })
+
+ it('parses Windows extension server flags and equals syntax', () => {
+ const server = parseAntigravityServerInfoFromLine(
+ 'C:\\Users\\Admin\\AppData\\Local\\Programs\\Antigravity\\resources\\app\\extensions\\antigravity\\bin\\language_server_windows_x64.exe --extension_server_port=62225 --extension_server_csrf_token=abcdef01-2345-6789-abcd-ef0123456789',
+ )
+
+ expect(server).toEqual({
+ port: 62225,
+ csrfToken: 'abcdef01-2345-6789-abcd-ef0123456789',
+ })
+ })
+
+ it('parses Windows extension server flags and space syntax', () => {
+ const server = parseAntigravityServerInfo([
+ 'node something-unrelated',
+ 'language_server_windows_x64.exe --app_data_dir C:\\Users\\Admin\\.gemini\\antigravity --extension_server_port 62300 --extension_server_csrf_token fedcba98-7654-3210-fedc-ba9876543210',
+ ])
+
+ expect(server).toEqual({
+ port: 62300,
+ csrfToken: 'fedcba98-7654-3210-fedc-ba9876543210',
+ })
+ })
+
+ it('parses quoted flag values', () => {
+ const server = parseAntigravityServerInfoFromLine(
+ 'Antigravity language_server_windows_x64.exe --extension_server_port "62301" --extension_server_csrf_token "fedcba98-7654-3210-fedc-ba9876543211"',
+ )
+
+ expect(server).toEqual({
+ port: 62301,
+ csrfToken: 'fedcba98-7654-3210-fedc-ba9876543211',
+ })
+ })
+
+ it('matches language-server and antigravity markers case-insensitively', () => {
+ const server = parseAntigravityServerInfoFromLine(
+ 'ANTIGRAVITY LANGUAGE_SERVER_WINDOWS_X64.EXE --extension_server_port 62302 --extension_server_csrf_token fedcba98-7654-3210-fedc-ba9876543212',
+ )
+
+ expect(server).toEqual({
+ port: 62302,
+ csrfToken: 'fedcba98-7654-3210-fedc-ba9876543212',
+ })
+ })
+
+ it('ignores process args without an antigravity marker', () => {
+ expect(parseAntigravityServerInfoFromLine(
+ 'language_server --extension_server_port 62300 --extension_server_csrf_token fedcba98-7654-3210-fedc-ba9876543210',
+ )).toBeNull()
+ })
+
+ it('ignores invalid ports', () => {
+ expect(parseAntigravityServerInfoFromLine(
+ 'antigravity language_server --extension_server_port 99999 --extension_server_csrf_token fedcba98-7654-3210-fedc-ba9876543210',
+ )).toBeNull()
+ })
+
+ it('ignores chained flag names as values', () => {
+ expect(parseAntigravityServerInfoFromLine(
+ 'antigravity language_server --extension_server_port=--extension_server_csrf_token --extension_server_csrf_token fedcba98-7654-3210-fedc-ba9876543210',
+ )).toBeNull()
+ })
+
+ it('ignores implausibly short CSRF tokens', () => {
+ expect(parseAntigravityServerInfoFromLine(
+ 'antigravity language_server --extension_server_port 62300 --extension_server_csrf_token short',
+ )).toBeNull()
+ })
+
+ it('extracts model maps from wrapped and unwrapped RPC responses', () => {
+ expect(extractAntigravityModelMap({
+ response: { models: { high: { model: 'MODEL_PLACEHOLDER_M7' } } },
+ })).toEqual({ MODEL_PLACEHOLDER_M7: 'high' })
+
+ expect(extractAntigravityModelMap({
+ models: { low: { model: 'MODEL_PLACEHOLDER_M8' } },
+ })).toEqual({ MODEL_PLACEHOLDER_M8: 'low' })
+ expect(extractAntigravityModelMap({
+ models: { bad: null, good: { model: 'MODEL_PLACEHOLDER_M9' } },
+ })).toEqual({ MODEL_PLACEHOLDER_M9: 'good' })
+ expect(extractAntigravityModelMap(null)).toEqual({})
+ })
+
+ it('extracts generator metadata from wrapped and unwrapped RPC responses', () => {
+ const metadata = [{
+ chatModel: {
+ model: 'gemini-3-pro',
+ usage: {
+ model: 'gemini-3-pro',
+ inputTokens: '10',
+ outputTokens: '4',
+ apiProvider: 'google',
+ },
+ },
+ }]
+
+ expect(extractAntigravityGeneratorMetadata({ response: { generatorMetadata: metadata } })).toEqual(metadata)
+ expect(extractAntigravityGeneratorMetadata({ generatorMetadata: metadata })).toEqual(metadata)
+ expect(extractAntigravityGeneratorMetadata({ response: { generatorMetadata: null } })).toEqual([])
+ expect(extractAntigravityGeneratorMetadata(null)).toEqual([])
+ })
+})
From aa946d09658d9e6bbdb2baa04e1beb4a1bc74bf3 Mon Sep 17 00:00:00 2001
From: iamtoruk
Date: Tue, 12 May 2026 19:13:40 -0700
Subject: [PATCH 14/28] Keep CLI executable after build
---
package.json | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/package.json b/package.json
index a58098d..f98ef1d 100644
--- a/package.json
+++ b/package.json
@@ -12,7 +12,7 @@
],
"scripts": {
"bundle-litellm": "node scripts/bundle-litellm.mjs",
- "build": "node scripts/bundle-litellm.mjs && tsup",
+ "build": "node scripts/bundle-litellm.mjs && tsup && node -e \"require('fs').chmodSync('dist/cli.js',0o755)\"",
"dev": "tsx src/cli.ts",
"test": "vitest",
"prepublishOnly": "npm run build"
From c626fc4a4552b705582f5653cf18df59bb673f7a Mon Sep 17 00:00:00 2001
From: iamtoruk
Date: Wed, 13 May 2026 20:22:15 -0700
Subject: [PATCH 15/28] Fix menubar stale cache recovery
---
mac/Sources/CodeBurnMenubar/AppStore.swift | 38 +++++++++++
mac/Sources/CodeBurnMenubar/CodeBurnApp.swift | 33 +++-------
.../AppStoreRefreshRecoveryTests.swift | 63 +++++++++++++++++++
3 files changed, 109 insertions(+), 25 deletions(-)
create mode 100644 mac/Tests/CodeBurnMenubarTests/AppStoreRefreshRecoveryTests.swift
diff --git a/mac/Sources/CodeBurnMenubar/AppStore.swift b/mac/Sources/CodeBurnMenubar/AppStore.swift
index 2757da6..a1b7e2d 100644
--- a/mac/Sources/CodeBurnMenubar/AppStore.swift
+++ b/mac/Sources/CodeBurnMenubar/AppStore.swift
@@ -2,6 +2,7 @@ import Foundation
import Observation
private let cacheTTLSeconds: TimeInterval = 30
+private let interactiveRefreshResetSeconds: TimeInterval = 120
struct CachedPayload {
let payload: MenubarPayload
@@ -95,10 +96,34 @@ final class AppStore {
}
}
+ var hasStaleInteractivePayload: Bool {
+ staleInteractivePayloadAgeSeconds != nil
+ }
+
+ var shouldResetInteractiveRefreshPipeline: Bool {
+ hasStaleLoading || hasStaleInteractivePayload
+ }
+
+ var staleInteractivePayloadAgeSeconds: Int? {
+ let keys = Set([
+ currentKey,
+ PayloadCacheKey(period: .today, provider: .all),
+ PayloadCacheKey(period: selectedPeriod, provider: .all),
+ ])
+ let staleAges = keys.compactMap { key -> TimeInterval? in
+ guard let cached = cache[key] else { return nil }
+ let age = Date().timeIntervalSince(cached.fetchedAt)
+ return age > interactiveRefreshResetSeconds ? age : nil
+ }
+ return staleAges.max().map(Int.init)
+ }
+
var needsInteractivePayloadRefresh: Bool {
let todayKey = PayloadCacheKey(period: .today, provider: .all)
+ let periodAllKey = PayloadCacheKey(period: selectedPeriod, provider: .all)
return cache[currentKey]?.isFresh != true ||
cache[todayKey]?.isFresh != true ||
+ cache[periodAllKey]?.isFresh != true ||
hasStaleLoading
}
@@ -110,6 +135,12 @@ final class AppStore {
cache.values.contains { !$0.payload.current.providers.isEmpty }
}
+#if DEBUG
+ func setCachedPayloadForTesting(_ payload: MenubarPayload, period: Period, provider: ProviderFilter, fetchedAt: Date) {
+ cache[PayloadCacheKey(period: period, provider: provider)] = CachedPayload(payload: payload, fetchedAt: fetchedAt)
+ }
+#endif
+
var findingsCount: Int {
payload.optimize.findingCount
}
@@ -213,8 +244,15 @@ final class AppStore {
formatter.dateFormat = "yyyy-MM-dd"
let today = formatter.string(from: Date())
if cacheDate != today {
+ payloadRefreshGeneration &+= 1
cache.removeAll()
+ loadingCountsByKey.removeAll()
+ loadingStartedAtByKey.removeAll()
+ inFlightKeys.removeAll()
+ attemptedKeys.removeAll()
+ lastErrorByKey.removeAll()
cacheDate = today
+ NSLog("CodeBurn: reset menubar payload cache for new day %@", today)
}
}
diff --git a/mac/Sources/CodeBurnMenubar/CodeBurnApp.swift b/mac/Sources/CodeBurnMenubar/CodeBurnApp.swift
index 0c7a76d..7daccb2 100644
--- a/mac/Sources/CodeBurnMenubar/CodeBurnApp.swift
+++ b/mac/Sources/CodeBurnMenubar/CodeBurnApp.swift
@@ -374,8 +374,12 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate {
private func refreshPayloadForPopoverOpen() {
guard store.needsInteractivePayloadRefresh else { return }
+ let shouldResetPipeline = store.shouldResetInteractiveRefreshPipeline
+ if shouldResetPipeline, let age = store.staleInteractivePayloadAgeSeconds {
+ NSLog("CodeBurn: popover opened with %ds stale payload cache - resetting refresh pipeline", age)
+ }
recoverRefreshPipelineAfterInterruption(
- resetLoading: store.hasStaleLoading,
+ resetLoading: shouldResetPipeline,
reason: "popover open"
)
}
@@ -383,38 +387,17 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate {
private func startRefreshLoop() {
refreshLoopTask?.cancel()
refreshLoopHeartbeatAt = Date()
+ forceRefresh(bypassRateLimit: true, forceQuota: true)
refreshLoopTask = Task { [weak self] in
- // Provider refreshes only run when the user has explicitly connected.
- // Each refresh is a no-op until its corresponding bootstrap flag is set.
- if let self {
- await self.refreshLiveQuotaProgressIfDue(force: true)
- }
while !Task.isCancelled {
guard let self else { return }
self.refreshLoopHeartbeatAt = Date()
let clearedStaleForceRefresh = self.clearStaleForceRefreshIfNeeded()
let clearedStaleLoading = self.store.clearStaleLoadingIfNeeded()
- // Skip the loop's tick if a wake / manual / distributed-
- // notification refresh just ran. Without this gate, every
- // wake produced two refreshes (forceRefresh from the wake
- // observer plus the loop's natural tick).
let sinceLast = Date().timeIntervalSince(self.lastRefreshTime)
- if self.forceRefreshTask == nil && (clearedStaleForceRefresh || clearedStaleLoading || sinceLast >= 5) {
- if self.store.selectedPeriod != .today || self.store.selectedProvider != .all {
- async let quiet: Void = self.store.refreshQuietly(period: .today)
- async let main: Void = self.store.refresh(includeOptimize: false, force: true)
- _ = await (quiet, main)
- } else {
- await self.store.refresh(includeOptimize: false, force: true)
- }
- self.lastRefreshTime = Date()
- self.refreshStatusButton()
+ if clearedStaleForceRefresh || clearedStaleLoading || sinceLast >= TimeInterval(refreshIntervalSeconds) {
+ self.forceRefresh(bypassRateLimit: true)
}
- // Cadence-driven live-quota refresh, anchored on LAST SUCCESS
- // (not last attempt) so an intermittent failure doesn't reset
- // the timer. Each provider has its own anchor so a Codex 429
- // doesn't delay a due Claude refresh.
- await self.refreshLiveQuotaProgressIfDue()
try? await Task.sleep(nanoseconds: refreshIntervalNanos)
}
}
diff --git a/mac/Tests/CodeBurnMenubarTests/AppStoreRefreshRecoveryTests.swift b/mac/Tests/CodeBurnMenubarTests/AppStoreRefreshRecoveryTests.swift
new file mode 100644
index 0000000..0c7fb14
--- /dev/null
+++ b/mac/Tests/CodeBurnMenubarTests/AppStoreRefreshRecoveryTests.swift
@@ -0,0 +1,63 @@
+import Foundation
+import Testing
+@testable import CodeBurnMenubar
+
+private func menubarPayload(cost: Double) -> MenubarPayload {
+ MenubarPayload(
+ generated: "test",
+ current: CurrentBlock(
+ label: "Today",
+ cost: cost,
+ calls: 1,
+ sessions: 1,
+ oneShotRate: nil,
+ inputTokens: 1,
+ outputTokens: 1,
+ cacheHitPercent: 0,
+ topActivities: [],
+ topModels: [],
+ providers: ["claude": cost]
+ ),
+ optimize: OptimizeBlock(findingCount: 0, savingsUSD: 0, topFindings: []),
+ history: HistoryBlock(daily: [])
+ )
+}
+
+@Suite("AppStore refresh recovery")
+@MainActor
+struct AppStoreRefreshRecoveryTests {
+ @Test("stale visible payload triggers hard recovery without clearing cache")
+ func stalePayloadTriggersHardRecoveryWithoutClearingCache() {
+ let store = AppStore()
+ store.setCachedPayloadForTesting(
+ menubarPayload(cost: 92.33),
+ period: .today,
+ provider: .all,
+ fetchedAt: Date().addingTimeInterval(-180)
+ )
+
+ #expect(store.todayPayload?.current.cost == 92.33)
+ #expect(store.needsInteractivePayloadRefresh)
+ #expect(store.hasStaleInteractivePayload)
+ #expect(store.shouldResetInteractiveRefreshPipeline)
+
+ store.resetRefreshState(clearCache: false)
+
+ #expect(store.todayPayload?.current.cost == 92.33)
+ }
+
+ @Test("fresh visible payload does not trigger hard recovery")
+ func freshPayloadDoesNotTriggerHardRecovery() {
+ let store = AppStore()
+ store.setCachedPayloadForTesting(
+ menubarPayload(cost: 164.06),
+ period: .today,
+ provider: .all,
+ fetchedAt: Date()
+ )
+
+ #expect(!store.needsInteractivePayloadRefresh)
+ #expect(!store.hasStaleInteractivePayload)
+ #expect(!store.shouldResetInteractiveRefreshPipeline)
+ }
+}
From 00c55873a49c716c2c5389ab44560e42de75d2ea Mon Sep 17 00:00:00 2001
From: iamtoruk
Date: Thu, 14 May 2026 11:26:15 -0700
Subject: [PATCH 16/28] Fix menubar tab refresh recovery
---
mac/Sources/CodeBurnMenubar/AppStore.swift | 31 ++++++++++---------
mac/Sources/CodeBurnMenubar/CodeBurnApp.swift | 12 +++++--
.../AppStoreRefreshRecoveryTests.swift | 11 +++++++
3 files changed, 38 insertions(+), 16 deletions(-)
diff --git a/mac/Sources/CodeBurnMenubar/AppStore.swift b/mac/Sources/CodeBurnMenubar/AppStore.swift
index 94bb1c2..79ef9ca 100644
--- a/mac/Sources/CodeBurnMenubar/AppStore.swift
+++ b/mac/Sources/CodeBurnMenubar/AppStore.swift
@@ -100,8 +100,12 @@ final class AppStore {
staleInteractivePayloadAgeSeconds != nil
}
+ var hasMissingInteractivePayloadWithoutAttempt: Bool {
+ cache[currentKey] == nil && !isCurrentKeyLoading && !hasAttemptedCurrentKeyLoad
+ }
+
var shouldResetInteractiveRefreshPipeline: Bool {
- hasStaleLoading || hasStaleInteractivePayload
+ hasStaleLoading || hasStaleInteractivePayload || hasMissingInteractivePayloadWithoutAttempt
}
var staleInteractivePayloadAgeSeconds: Int? {
@@ -149,16 +153,7 @@ final class AppStore {
/// all-provider data in parallel so tab strip costs stay in sync with the hero.
func switchTo(period: Period) {
selectedPeriod = period
- switchTask?.cancel()
- switchTask = Task {
- if selectedProvider == .all {
- await refresh(includeOptimize: false, force: true)
- } else {
- async let main: Void = refresh(includeOptimize: false, force: true)
- async let all: Void = refreshQuietly(period: period)
- _ = await (main, all)
- }
- }
+ startInteractiveSelectionRefresh()
}
/// Switch to a provider filter. Cancels any in-flight switch so rapid tab tapping only
@@ -166,13 +161,21 @@ final class AppStore {
/// in parallel so the tab strip costs stay in sync with the hero.
func switchTo(provider: ProviderFilter) {
selectedProvider = provider
+ startInteractiveSelectionRefresh()
+ }
+
+ private func startInteractiveSelectionRefresh() {
switchTask?.cancel()
+ resetLoadingState()
+ let period = selectedPeriod
+ let provider = selectedProvider
+ lastErrorByKey[PayloadCacheKey(period: period, provider: provider)] = nil
switchTask = Task {
if provider == .all {
- await refresh(includeOptimize: false, force: true)
+ await refresh(includeOptimize: false, force: true, showLoading: true)
} else {
- async let main: Void = refresh(includeOptimize: false, force: true)
- async let all: Void = refreshQuietly(period: selectedPeriod)
+ async let main: Void = refresh(includeOptimize: false, force: true, showLoading: true)
+ async let all: Void = refreshQuietly(period: period)
_ = await (main, all)
}
}
diff --git a/mac/Sources/CodeBurnMenubar/CodeBurnApp.swift b/mac/Sources/CodeBurnMenubar/CodeBurnApp.swift
index 7daccb2..36ff798 100644
--- a/mac/Sources/CodeBurnMenubar/CodeBurnApp.swift
+++ b/mac/Sources/CodeBurnMenubar/CodeBurnApp.swift
@@ -259,10 +259,18 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate {
@discardableResult
private func clearStaleForceRefreshIfNeeded(now: Date = Date()) -> Bool {
- if let started = forceRefreshStartedAt, forceRefreshTask != nil {
+ if forceRefreshTask != nil {
+ guard let started = forceRefreshStartedAt else {
+ NSLog("CodeBurn: force refresh task had no start timestamp - clearing")
+ forceRefreshTask?.cancel()
+ forceRefreshTask = nil
+ forceRefreshGeneration &+= 1
+ store.resetLoadingState()
+ return true
+ }
let elapsed = now.timeIntervalSince(started)
guard elapsed > forceRefreshWatchdogSeconds else { return false }
- NSLog("CodeBurn: force refresh stuck for %ds — cancelling and restarting", Int(elapsed))
+ NSLog("CodeBurn: force refresh stuck for %ds - cancelling and restarting", Int(elapsed))
forceRefreshTask?.cancel()
forceRefreshTask = nil
forceRefreshStartedAt = nil
diff --git a/mac/Tests/CodeBurnMenubarTests/AppStoreRefreshRecoveryTests.swift b/mac/Tests/CodeBurnMenubarTests/AppStoreRefreshRecoveryTests.swift
index 0c7fb14..ca8219e 100644
--- a/mac/Tests/CodeBurnMenubarTests/AppStoreRefreshRecoveryTests.swift
+++ b/mac/Tests/CodeBurnMenubarTests/AppStoreRefreshRecoveryTests.swift
@@ -60,4 +60,15 @@ struct AppStoreRefreshRecoveryTests {
#expect(!store.hasStaleInteractivePayload)
#expect(!store.shouldResetInteractiveRefreshPipeline)
}
+
+ @Test("missing unattempted payload triggers hard recovery")
+ func missingUnattemptedPayloadTriggersHardRecovery() {
+ let store = AppStore()
+
+ #expect(!store.hasCachedData)
+ #expect(!store.hasAttemptedCurrentKeyLoad)
+ #expect(store.needsInteractivePayloadRefresh)
+ #expect(store.hasMissingInteractivePayloadWithoutAttempt)
+ #expect(store.shouldResetInteractiveRefreshPipeline)
+ }
}
From 478131d5b7138cecc12dff9e180d0136baa39b55 Mon Sep 17 00:00:00 2001
From: iamtoruk
Date: Thu, 14 May 2026 17:57:36 -0700
Subject: [PATCH 17/28] Harden menubar status refresh timer
---
mac/Sources/CodeBurnMenubar/AppStore.swift | 36 ++++-
mac/Sources/CodeBurnMenubar/CodeBurnApp.swift | 147 ++++++++++++++----
.../AppStoreRefreshRecoveryTests.swift | 10 ++
3 files changed, 157 insertions(+), 36 deletions(-)
diff --git a/mac/Sources/CodeBurnMenubar/AppStore.swift b/mac/Sources/CodeBurnMenubar/AppStore.swift
index 79ef9ca..20854ae 100644
--- a/mac/Sources/CodeBurnMenubar/AppStore.swift
+++ b/mac/Sources/CodeBurnMenubar/AppStore.swift
@@ -65,6 +65,10 @@ final class AppStore {
return Date().timeIntervalSince(last)
}
+ private var todayAllKey: PayloadCacheKey {
+ PayloadCacheKey(period: .today, provider: .all)
+ }
+
private var currentKey: PayloadCacheKey {
PayloadCacheKey(period: selectedPeriod, provider: selectedProvider)
}
@@ -76,7 +80,16 @@ final class AppStore {
/// Today (across all providers) is pinned for the always-visible menubar icon, independent of
/// the popover's selected period or provider.
var todayPayload: MenubarPayload? {
- cache[PayloadCacheKey(period: .today, provider: .all)]?.payload
+ cache[todayAllKey]?.payload
+ }
+
+ var todayPayloadAgeSeconds: Int? {
+ guard let cached = cache[todayAllKey] else { return nil }
+ return Int(Date().timeIntervalSince(cached.fetchedAt))
+ }
+
+ var needsStatusPayloadRefresh: Bool {
+ cache[todayAllKey]?.isFresh != true
}
/// All-provider payload for the selected period. Used by the tab strip to show
@@ -111,7 +124,7 @@ final class AppStore {
var staleInteractivePayloadAgeSeconds: Int? {
let keys = Set([
currentKey,
- PayloadCacheKey(period: .today, provider: .all),
+ todayAllKey,
PayloadCacheKey(period: selectedPeriod, provider: .all),
])
let staleAges = keys.compactMap { key -> TimeInterval? in
@@ -123,10 +136,9 @@ final class AppStore {
}
var needsInteractivePayloadRefresh: Bool {
- let todayKey = PayloadCacheKey(period: .today, provider: .all)
let periodAllKey = PayloadCacheKey(period: selectedPeriod, provider: .all)
return cache[currentKey]?.isFresh != true ||
- cache[todayKey]?.isFresh != true ||
+ cache[todayAllKey]?.isFresh != true ||
cache[periodAllKey]?.isFresh != true ||
hasStaleLoading
}
@@ -269,7 +281,7 @@ final class AppStore {
let cacheDateAtStart = cacheDate
let generationAtStart = payloadRefreshGeneration
if !force, cache[key]?.isFresh == true { return }
- if !force, inFlightKeys.contains(key) { return }
+ if inFlightKeys.contains(key) { return }
inFlightKeys.insert(key)
attemptedKeys.insert(key)
lastErrorByKey[key] = nil
@@ -349,10 +361,21 @@ final class AppStore {
/// Background refresh for a period other than the visible one (e.g. keeping today fresh for the menubar badge).
/// Does not toggle isLoading, so the popover's loading overlay is unaffected.
/// Always uses the .all provider since the menubar badge shows total spend.
- func refreshQuietly(period: Period) async {
+ func refreshQuietly(period: Period, force: Bool = false) async {
invalidateStaleDayCache()
+ let key = PayloadCacheKey(period: period, provider: .all)
+ if !force, cache[key]?.isFresh == true { return }
+ if inFlightKeys.contains(key) { return }
+ inFlightKeys.insert(key)
+ attemptedKeys.insert(key)
let cacheDateAtStart = cacheDate
let generationAtStart = payloadRefreshGeneration
+ if period == .today, let age = todayPayloadAgeSeconds, age > 120 {
+ NSLog("CodeBurn: refreshing stale today status payload after %ds", age)
+ }
+ defer {
+ inFlightKeys.remove(key)
+ }
do {
let fresh = try await DataClient.fetch(period: period, provider: .all, includeOptimize: false)
if generationAtStart != payloadRefreshGeneration {
@@ -362,7 +385,6 @@ final class AppStore {
// Same day-rollover guard as refresh(): drop yesterday's payload if
// the calendar rolled over during the fetch.
if cacheDate != cacheDateAtStart { return }
- let key = PayloadCacheKey(period: period, provider: .all)
cache[key] = CachedPayload(payload: fresh, fetchedAt: Date())
lastSuccessByKey[key] = Date()
lastErrorByKey[key] = nil
diff --git a/mac/Sources/CodeBurnMenubar/CodeBurnApp.swift b/mac/Sources/CodeBurnMenubar/CodeBurnApp.swift
index 36ff798..80bc471 100644
--- a/mac/Sources/CodeBurnMenubar/CodeBurnApp.swift
+++ b/mac/Sources/CodeBurnMenubar/CodeBurnApp.swift
@@ -3,10 +3,9 @@ import AppKit
import Observation
private let refreshIntervalSeconds: UInt64 = 30
-private let nanosPerSecond: UInt64 = 1_000_000_000
-private let refreshIntervalNanos: UInt64 = refreshIntervalSeconds * nanosPerSecond
private let forceRefreshWatchdogSeconds: TimeInterval = 90
private let refreshLoopWatchdogSeconds: TimeInterval = 90
+private let statusPayloadRefreshWatchdogSeconds: TimeInterval = 60
private let refreshRateLimitSeconds: TimeInterval = 5
private let interactiveQuotaRefreshFloorSeconds: TimeInterval = 30
private let statusItemWidth: CGFloat = NSStatusItem.variableLength
@@ -38,10 +37,13 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate {
/// Held for the lifetime of the app to opt out of App Nap and Automatic Termination.
private var backgroundActivity: NSObjectProtocol?
private var pendingRefreshWork: DispatchWorkItem?
- private var refreshLoopTask: Task?
+ private var refreshTimer: DispatchSourceTimer?
private var forceRefreshTask: Task?
private var forceRefreshStartedAt: Date?
private var forceRefreshGeneration: UInt64 = 0
+ private var statusPayloadRefreshTask: Task?
+ private var statusPayloadRefreshStartedAt: Date?
+ private var statusPayloadRefreshGeneration: UInt64 = 0
private var manualRefreshTask: Task?
private var manualRefreshGeneration: UInt64 = 0
private var refreshLoopHeartbeatAt: Date = .distantPast
@@ -146,9 +148,12 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate {
manualRefreshTask?.cancel()
manualRefreshTask = nil
manualRefreshGeneration &+= 1
+ statusPayloadRefreshTask?.cancel()
+ statusPayloadRefreshTask = nil
+ statusPayloadRefreshStartedAt = nil
+ statusPayloadRefreshGeneration &+= 1
store.resetLoadingState()
- refreshLoopTask?.cancel()
- refreshLoopTask = nil
+ stopRefreshTimer()
refreshLoopHeartbeatAt = .distantPast
lastRefreshTime = .distantPast
}
@@ -162,21 +167,24 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate {
manualRefreshTask?.cancel()
manualRefreshTask = nil
manualRefreshGeneration &+= 1
+ statusPayloadRefreshTask?.cancel()
+ statusPayloadRefreshTask = nil
+ statusPayloadRefreshStartedAt = nil
+ statusPayloadRefreshGeneration &+= 1
store.resetRefreshState(clearCache: clearCache)
} else {
_ = store.clearStaleLoadingIfNeeded()
}
let now = Date()
let loopAge = now.timeIntervalSince(refreshLoopHeartbeatAt)
- if refreshLoopTask == nil || loopAge > refreshLoopWatchdogSeconds {
- if refreshLoopTask != nil {
- NSLog("CodeBurn: refresh loop stale for %ds after %@ — restarting", Int(loopAge), reason)
+ if refreshTimer == nil || loopAge > refreshLoopWatchdogSeconds {
+ if refreshTimer != nil {
+ NSLog("CodeBurn: refresh loop stale for %ds after %@ - restarting", Int(loopAge), reason)
}
- refreshLoopTask?.cancel()
- refreshLoopTask = nil
startRefreshLoop()
+ } else {
+ runRefreshLoopTick(reason: reason, forcePayload: true, forceQuota: true)
}
- forceRefresh(bypassRateLimit: true, forceQuota: true)
}
private func installLaunchAgentIfNeeded() {
@@ -281,9 +289,57 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate {
return false
}
+ @discardableResult
+ private func clearStaleStatusPayloadRefreshIfNeeded(now: Date = Date()) -> Bool {
+ if statusPayloadRefreshTask != nil {
+ guard let started = statusPayloadRefreshStartedAt else {
+ NSLog("CodeBurn: today status refresh task had no start timestamp - clearing")
+ statusPayloadRefreshTask?.cancel()
+ statusPayloadRefreshTask = nil
+ statusPayloadRefreshGeneration &+= 1
+ return true
+ }
+ let elapsed = now.timeIntervalSince(started)
+ guard elapsed > statusPayloadRefreshWatchdogSeconds else { return false }
+ NSLog("CodeBurn: today status refresh stuck for %ds - cancelling", Int(elapsed))
+ statusPayloadRefreshTask?.cancel()
+ statusPayloadRefreshTask = nil
+ statusPayloadRefreshStartedAt = nil
+ statusPayloadRefreshGeneration &+= 1
+ return true
+ }
+ return false
+ }
+
+ private func refreshTodayStatusPayloadIfNeeded(reason: String, force: Bool = false) {
+ let now = Date()
+ _ = clearStaleStatusPayloadRefreshIfNeeded(now: now)
+ guard statusPayloadRefreshTask == nil else { return }
+ guard force || store.needsStatusPayloadRefresh else { return }
+
+ if let age = store.todayPayloadAgeSeconds, age > 120 {
+ NSLog("CodeBurn: today status payload stale for %ds on %@ refresh", age, reason)
+ }
+
+ statusPayloadRefreshStartedAt = now
+ statusPayloadRefreshGeneration &+= 1
+ let generation = statusPayloadRefreshGeneration
+ statusPayloadRefreshTask = Task { [weak self] in
+ guard let self else { return }
+ await self.store.refreshQuietly(period: .today, force: true)
+ self.refreshStatusButton()
+ guard self.statusPayloadRefreshGeneration == generation, !Task.isCancelled else { return }
+ self.statusPayloadRefreshTask = nil
+ self.statusPayloadRefreshStartedAt = nil
+ }
+ }
+
private func forceRefresh(bypassRateLimit: Bool = false, forceQuota: Bool = false) {
let now = Date()
_ = clearStaleForceRefreshIfNeeded(now: now)
+ if forceRefreshTask != nil {
+ refreshTodayStatusPayloadIfNeeded(reason: "blocked force refresh")
+ }
guard forceRefreshTask == nil else { return }
if !bypassRateLimit {
guard now.timeIntervalSince(lastRefreshTime) > refreshRateLimitSeconds else { return }
@@ -392,23 +448,53 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate {
)
}
- private func startRefreshLoop() {
- refreshLoopTask?.cancel()
+ private func stopRefreshTimer() {
+ refreshTimer?.setEventHandler {}
+ refreshTimer?.cancel()
+ refreshTimer = nil
+ }
+
+ private func runRefreshLoopTick(reason: String, forcePayload: Bool = false, forceQuota: Bool = false) {
refreshLoopHeartbeatAt = Date()
- forceRefresh(bypassRateLimit: true, forceQuota: true)
- refreshLoopTask = Task { [weak self] in
- while !Task.isCancelled {
- guard let self else { return }
- self.refreshLoopHeartbeatAt = Date()
- let clearedStaleForceRefresh = self.clearStaleForceRefreshIfNeeded()
- let clearedStaleLoading = self.store.clearStaleLoadingIfNeeded()
- let sinceLast = Date().timeIntervalSince(self.lastRefreshTime)
- if clearedStaleForceRefresh || clearedStaleLoading || sinceLast >= TimeInterval(refreshIntervalSeconds) {
- self.forceRefresh(bypassRateLimit: true)
- }
- try? await Task.sleep(nanoseconds: refreshIntervalNanos)
+ let hadForceRefreshInFlight = forceRefreshTask != nil
+ let clearedStaleForceRefresh = clearStaleForceRefreshIfNeeded()
+ let clearedStaleStatusRefresh = clearStaleStatusPayloadRefreshIfNeeded()
+ let clearedStaleLoading = store.clearStaleLoadingIfNeeded()
+ let statusPayloadStale = store.needsStatusPayloadRefresh
+ let sinceLast = Date().timeIntervalSince(lastRefreshTime)
+ let shouldForceRefresh = forcePayload ||
+ clearedStaleForceRefresh ||
+ clearedStaleLoading ||
+ sinceLast >= TimeInterval(refreshIntervalSeconds)
+
+ if shouldForceRefresh {
+ forceRefresh(bypassRateLimit: true, forceQuota: forceQuota)
+ }
+
+ let forceRefreshWasBlocked = hadForceRefreshInFlight && forceRefreshTask != nil
+ if statusPayloadStale && (!shouldForceRefresh || forceRefreshWasBlocked || clearedStaleStatusRefresh) {
+ refreshTodayStatusPayloadIfNeeded(reason: reason, force: forcePayload)
+ }
+ }
+
+ private func startRefreshLoop() {
+ stopRefreshTimer()
+ runRefreshLoopTick(reason: "start", forcePayload: true, forceQuota: true)
+
+ let timer = DispatchSource.makeTimerSource(queue: .main)
+ timer.schedule(
+ deadline: .now() + .seconds(Int(refreshIntervalSeconds)),
+ repeating: .seconds(Int(refreshIntervalSeconds)),
+ leeway: .seconds(2)
+ )
+ timer.setEventHandler { [weak self] in
+ Task { @MainActor [weak self] in
+ self?.runRefreshLoopTick(reason: "timer")
}
}
+ refreshTimer = timer
+ refreshLoopHeartbeatAt = Date()
+ timer.resume()
}
@MainActor
@@ -420,10 +506,13 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate {
forceRefreshTask = nil
forceRefreshStartedAt = nil
forceRefreshGeneration &+= 1
+ statusPayloadRefreshTask?.cancel()
+ statusPayloadRefreshTask = nil
+ statusPayloadRefreshStartedAt = nil
+ statusPayloadRefreshGeneration &+= 1
pendingRefreshWork?.cancel()
pendingRefreshWork = nil
- refreshLoopTask?.cancel()
- refreshLoopTask = nil
+ stopRefreshTimer()
store.resetRefreshState(clearCache: true)
lastRefreshTime = .distantPast
refreshStatusButton()
@@ -437,7 +526,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate {
async let payload: Void = self.store.refresh(includeOptimize: false, force: true, showLoading: true)
async let quotas: Bool = self.refreshLiveQuotaProgressIfDue(force: true)
if needsTodayTotal {
- await self.store.refreshQuietly(period: .today)
+ await self.store.refreshQuietly(period: .today, force: true)
}
_ = await payload
guard self.manualRefreshGeneration == generation, !Task.isCancelled else { return }
@@ -446,7 +535,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate {
_ = await quotas
guard self.manualRefreshGeneration == generation, !Task.isCancelled else { return }
self.manualRefreshTask = nil
- if self.refreshLoopTask == nil {
+ if self.refreshTimer == nil {
self.startRefreshLoop()
}
}
diff --git a/mac/Tests/CodeBurnMenubarTests/AppStoreRefreshRecoveryTests.swift b/mac/Tests/CodeBurnMenubarTests/AppStoreRefreshRecoveryTests.swift
index ca8219e..fd75fec 100644
--- a/mac/Tests/CodeBurnMenubarTests/AppStoreRefreshRecoveryTests.swift
+++ b/mac/Tests/CodeBurnMenubarTests/AppStoreRefreshRecoveryTests.swift
@@ -38,6 +38,7 @@ struct AppStoreRefreshRecoveryTests {
#expect(store.todayPayload?.current.cost == 92.33)
#expect(store.needsInteractivePayloadRefresh)
+ #expect(store.needsStatusPayloadRefresh)
#expect(store.hasStaleInteractivePayload)
#expect(store.shouldResetInteractiveRefreshPipeline)
@@ -57,10 +58,19 @@ struct AppStoreRefreshRecoveryTests {
)
#expect(!store.needsInteractivePayloadRefresh)
+ #expect(!store.needsStatusPayloadRefresh)
#expect(!store.hasStaleInteractivePayload)
#expect(!store.shouldResetInteractiveRefreshPipeline)
}
+ @Test("missing today status payload needs status refresh")
+ func missingTodayStatusPayloadNeedsStatusRefresh() {
+ let store = AppStore()
+
+ #expect(store.todayPayload == nil)
+ #expect(store.needsStatusPayloadRefresh)
+ }
+
@Test("missing unattempted payload triggers hard recovery")
func missingUnattemptedPayloadTriggersHardRecovery() {
let store = AppStore()
From 909efcf989061a88ee3d1444630b8faa77949b30 Mon Sep 17 00:00:00 2001
From: iamtoruk
Date: Thu, 14 May 2026 18:32:15 -0700
Subject: [PATCH 18/28] Harden menubar refresh and installer
---
.github/workflows/release-menubar.yml | 16 +--
RELEASING.md | 24 ++---
mac/README.md | 12 +--
mac/Scripts/package-app.sh | 11 +-
mac/Sources/CodeBurnMenubar/AppStore.swift | 45 ++++++--
mac/Sources/CodeBurnMenubar/CodeBurnApp.swift | 87 ++++++++++++---
.../Data/ClaudeCredentialStore.swift | 75 ++++++++++---
.../Data/CodexCredentialStore.swift | 67 ++++++++++--
.../CodeBurnMenubar/Data/UpdateChecker.swift | 100 ++++++++++++++++--
.../Security/CodeburnCLI.swift | 44 ++++++--
.../CodeBurnMenubar/Views/AgentTabStrip.swift | 3 +
.../Views/MenuBarContent.swift | 29 +++--
.../UpdateCheckerTests.swift | 39 +++++++
src/menubar-installer.ts | 99 ++++++++++++++---
tests/menubar-installer.test.ts | 40 ++++++-
15 files changed, 572 insertions(+), 119 deletions(-)
create mode 100644 mac/Tests/CodeBurnMenubarTests/UpdateCheckerTests.swift
diff --git a/.github/workflows/release-menubar.yml b/.github/workflows/release-menubar.yml
index b2cf949..db41334 100644
--- a/.github/workflows/release-menubar.yml
+++ b/.github/workflows/release-menubar.yml
@@ -2,8 +2,8 @@ name: Release macOS Menubar
# Triggers on a `mac-v*` tag push (e.g. `git tag mac-v0.8.0 && git push origin mac-v0.8.0`),
# or manually via the Actions tab. Builds a universal arm64+x86_64 bundle, ad-hoc signs it,
-# zips via `ditto`, and uploads the zip to the GitHub Release. `npx codeburn menubar` clears
-# the download quarantine flag on install so Gatekeeper stays quiet.
+# zips via `ditto`, and uploads the zip to the GitHub Release. The installer verifies
+# the checksum and bundle identity before replacing the local app.
on:
push:
tags:
@@ -60,13 +60,15 @@ jobs:
Install with:
```
- npx codeburn menubar
+ npm install -g codeburn
+ codeburn menubar
```
- That command drops the app into `~/Applications`, clears the download
- quarantine, and launches it. If you download the zip from this page directly
- and macOS shows "cannot verify developer", right-click the app in Finder and
- pick Open to whitelist it once.
+ That command drops the app into `~/Applications`, records the persistent
+ `codeburn` CLI path used by the menubar, verifies the downloaded checksum,
+ clears quarantine after bundle verification, and launches it. If you download
+ the zip from this page directly and macOS shows "cannot verify developer",
+ right-click the app in Finder and pick Open to whitelist it once.
files: |
mac/.build/dist/CodeBurnMenubar-${{ steps.version.outputs.value }}.zip
mac/.build/dist/CodeBurnMenubar-${{ steps.version.outputs.value }}.zip.sha256
diff --git a/RELEASING.md b/RELEASING.md
index 56e4124..b42d7d8 100644
--- a/RELEASING.md
+++ b/RELEASING.md
@@ -120,25 +120,25 @@ git push origin mac-v0.9.8
The `.github/workflows/release-menubar.yml` workflow automatically detects the `mac-v*` tag and:
1. Checks out the repo
-2. Runs `mac/Scripts/package-app.sh 0.9.8`
+2. Runs `mac/Scripts/package-app.sh v0.9.8`
3. Signs the app bundle (ad-hoc signing)
-4. Creates a zip file: `CodeBurnMenubar-0.9.8.zip`
-5. Computes a SHA-256 checksum: `CodeBurnMenubar-0.9.8.zip.sha256`
+4. Creates a zip file: `CodeBurnMenubar-v0.9.8.zip`
+5. Computes a SHA-256 checksum: `CodeBurnMenubar-v0.9.8.zip.sha256`
6. Uploads both to a GitHub Release named "Menubar v0.9.8"
The script output on the build machine shows:
```
-✓ Built /path/mac/.build/dist/CodeBurnMenubar-0.9.8.zip
-✓ Checksum /path/mac/.build/dist/CodeBurnMenubar-0.9.8.zip.sha256
- CodeBurnMenubar-0.9.8.zip
+✓ Built /path/mac/.build/dist/CodeBurnMenubar-v0.9.8.zip
+✓ Checksum /path/mac/.build/dist/CodeBurnMenubar-v0.9.8.zip.sha256
+ CodeBurnMenubar-v0.9.8.zip
```
No manual action is needed; the workflow handles everything.
### 4. Verify the Release
-After the workflow completes, the GitHub Release page shows the zip and sha256 files. The menubar installer command in the CLI calls `npx codeburn menubar`, which fetches the latest release from GitHub and installs it into `~/Applications`.
+After the workflow completes, the GitHub Release page shows the zip and sha256 files. The installed CLI command `codeburn menubar --force` fetches the newest `mac-v*` menubar release that includes both assets, verifies the checksum and bundle identity, and installs it into `~/Applications`.
## Homebrew Tap Update
@@ -227,12 +227,12 @@ If a release is published with broken assets (e.g., a menubar zip with a build e
Use `gh release upload` with the `--clobber` flag to overwrite existing files:
```bash
-# After re-running mac/Scripts/package-app.sh 0.9.8 to regenerate the zip and sha256
-gh release upload mac-v0.9.8 mac/.build/dist/CodeBurnMenubar-0.9.8.zip --clobber
-gh release upload mac-v0.9.8 mac/.build/dist/CodeBurnMenubar-0.9.8.zip.sha256 --clobber
+# After re-running mac/Scripts/package-app.sh v0.9.8 to regenerate the zip and sha256
+gh release upload mac-v0.9.8 mac/.build/dist/CodeBurnMenubar-v0.9.8.zip --clobber
+gh release upload mac-v0.9.8 mac/.build/dist/CodeBurnMenubar-v0.9.8.zip.sha256 --clobber
```
-The GitHub Release page will now serve the fixed assets. The menubar installer fetches from the Release by tag, so users who run `npx codeburn menubar` after the replacement get the fixed version automatically.
+The GitHub Release page will now serve the fixed assets. The menubar installer selects the newest `mac-v*` release with `CodeBurnMenubar-v*.zip` plus its checksum, so users who run `codeburn menubar --force` after the replacement get the fixed version automatically.
## Rollback
@@ -245,7 +245,7 @@ git push origin --delete v0.9.8
npm does not allow republishing to the same version. If you must unpublish from npm, use `npm unpublish codeburn@0.9.8 --force` (requires Owner role), but this is discouraged and all users who installed that version retain it.
-For the menubar, tag a new mac-v0.9.9 and let the workflow build and upload it. Users will see the update pill in the menubar settings and upgrade automatically (or manually via `npx codeburn menubar --force`).
+For the menubar, tag a new mac-v0.9.9 and let the workflow build and upload it. Users will see the update pill in the menubar settings and upgrade automatically (or manually via `codeburn menubar --force`).
## Summary
diff --git a/mac/README.md b/mac/README.md
index 3a7f1d7..b12b836 100644
--- a/mac/README.md
+++ b/mac/README.md
@@ -6,19 +6,17 @@ Native Swift + SwiftUI menubar app. The codeburn menubar surface.
- macOS 14+ (Sonoma)
- Swift 6.0+ toolchain (bundled with Xcode 16 or standalone)
-- `codeburn` CLI installed globally (`npm install -g codeburn`) or available at a path you pass via `CODEBURN_BIN`
+- `codeburn` CLI installed globally (`npm install -g codeburn`)
## Install (end users)
One command:
```bash
-npx codeburn menubar
+codeburn menubar
```
-That's it. The command downloads the latest `.app` from GitHub Releases, drops it into `~/Applications`, clears Gatekeeper quarantine, and launches it. Re-running it upgrades in place with `--force`, or just launches the existing copy otherwise.
-
-If you already have the CLI installed globally (`npm install -g codeburn`), `codeburn menubar` works the same way.
+That's it. The command records the persistent `codeburn` CLI path, downloads the latest `.app` from the newest `mac-v*` GitHub Release with a matching checksum, verifies it, drops it into `~/Applications`, clears Gatekeeper quarantine, and launches it. Re-running it upgrades in place with `--force`, or just launches the existing copy otherwise.
### Build from source
@@ -39,7 +37,7 @@ cd mac
swift build
# Point the app at your dev CLI build instead of the globally installed `codeburn`:
npm --prefix .. run build
-CODEBURN_BIN="node $(pwd)/../dist/cli.js" swift run
+CODEBURN_ALLOW_DEV_BIN=1 CODEBURN_BIN="node $(pwd)/../dist/cli.js" swift run
```
The app registers itself as a menubar accessory (`LSUIElement = true` at runtime). No Dock icon.
@@ -48,7 +46,7 @@ The app registers itself as a menubar accessory (`LSUIElement = true` at runtime
On launch and every 60 seconds thereafter, the app spawns `codeburn status --format menubar-json --no-optimize` directly (argv, no shell) via `CodeburnCLI.makeProcess` and decodes the JSON into `MenubarPayload`. The manual refresh button in the footer invokes the same command without `--no-optimize`, which includes optimize findings but takes longer.
-Override the binary via the `CODEBURN_BIN` environment variable (default: `codeburn` on PATH). The value is validated against a strict allowlist (alphanumerics plus `._/-` space) before use, so a malicious env var can't inject shell commands.
+Release installs record a persistent absolute CLI path in `~/Library/Application Support/CodeBurn/codeburn-cli-path.v1`, then fall back to Homebrew's common `codeburn` locations. For development only, set `CODEBURN_ALLOW_DEV_BIN=1` with `CODEBURN_BIN`; the value is validated against a strict allowlist before use, so a malicious env var can't inject shell commands.
## Project layout
diff --git a/mac/Scripts/package-app.sh b/mac/Scripts/package-app.sh
index 6df7abb..c9982a7 100755
--- a/mac/Scripts/package-app.sh
+++ b/mac/Scripts/package-app.sh
@@ -87,13 +87,12 @@ cat > "${BUNDLE}/Contents/PkgInfo" <<'PKG'
APPL????
PKG
-# Ad-hoc sign so macOS treats the bundle as internally consistent. This satisfies the
-# minimum bundle-validity checks on macOS 14+ and prevents a class of Gatekeeper edge
-# cases on managed Macs. A Developer ID signature (separate setup) would additionally
-# surface the publisher name in Finder; not required here.
+# Ad-hoc sign so macOS treats the bundle as internally consistent. Release
+# notarization can layer a Developer ID signature on top, but this local step
+# must still fail closed if signing or verification breaks.
echo "▸ Ad-hoc signing..."
-codesign --force --sign - --timestamp=none --deep "${BUNDLE}" 2>/dev/null || true
-codesign --verify --deep --strict "${BUNDLE}" 2>/dev/null || echo " (signature verify skipped)"
+codesign --force --sign - --timestamp=none --deep "${BUNDLE}"
+codesign --verify --deep --strict "${BUNDLE}"
ZIP_NAME="CodeBurnMenubar-${ASSET_VERSION}.zip"
ZIP_PATH="${DIST_DIR}/${ZIP_NAME}"
diff --git a/mac/Sources/CodeBurnMenubar/AppStore.swift b/mac/Sources/CodeBurnMenubar/AppStore.swift
index 20854ae..2c287e4 100644
--- a/mac/Sources/CodeBurnMenubar/AppStore.swift
+++ b/mac/Sources/CodeBurnMenubar/AppStore.swift
@@ -254,10 +254,14 @@ final class AppStore {
}
}
- private func invalidateStaleDayCache() {
+ private func currentCacheDate() -> String {
let formatter = DateFormatter()
formatter.dateFormat = "yyyy-MM-dd"
- let today = formatter.string(from: Date())
+ return formatter.string(from: Date())
+ }
+
+ private func invalidateStaleDayCache() {
+ let today = currentCacheDate()
if cacheDate != today {
payloadRefreshGeneration &+= 1
cache.removeAll()
@@ -324,7 +328,8 @@ final class AppStore {
// fetch, this payload was computed against yesterday's date and
// would pollute today's freshly-cleared cache. Drop it; the next
// tick will refetch with today's data.
- if cacheDate != cacheDateAtStart {
+ if cacheDate != cacheDateAtStart || cacheDate != currentCacheDate() {
+ invalidateStaleDayCache()
NSLog("CodeBurn: dropping fetch result for \(key.period.rawValue)/\(key.provider.rawValue) — calendar rolled mid-fetch")
return
}
@@ -339,7 +344,10 @@ final class AppStore {
let fallback = try await DataClient.fetch(period: key.period, provider: key.provider, includeOptimize: false)
guard !Task.isCancelled else { return }
if generationAtStart != payloadRefreshGeneration { return }
- if cacheDate != cacheDateAtStart { return }
+ if cacheDate != cacheDateAtStart || cacheDate != currentCacheDate() {
+ invalidateStaleDayCache()
+ return
+ }
cache[key] = CachedPayload(payload: fallback, fetchedAt: Date())
lastSuccessByKey[key] = Date()
lastErrorByKey[key] = nil
@@ -384,7 +392,10 @@ final class AppStore {
}
// Same day-rollover guard as refresh(): drop yesterday's payload if
// the calendar rolled over during the fetch.
- if cacheDate != cacheDateAtStart { return }
+ if cacheDate != cacheDateAtStart || cacheDate != currentCacheDate() {
+ invalidateStaleDayCache()
+ return
+ }
cache[key] = CachedPayload(payload: fresh, fetchedAt: Date())
lastSuccessByKey[key] = Date()
lastErrorByKey[key] = nil
@@ -607,7 +618,7 @@ final class AppStore {
var aggregateQuotaStatus: AggregateQuotaStatus {
var providers: [(name: String, percent: Double)] = []
- if case .loaded = subscriptionLoadState, let usage = subscription {
+ if let usage = subscription, shouldIncludeCachedQuota(loadState: subscriptionLoadState) {
let worst = [
usage.fiveHourPercent,
usage.sevenDayPercent,
@@ -616,7 +627,7 @@ final class AppStore {
].compactMap { $0 }.max() ?? 0
if worst > 0 { providers.append(("Claude", worst)) }
}
- if case .loaded = codexLoadState, let usage = codexUsage {
+ if let usage = codexUsage, shouldIncludeCachedQuota(loadState: codexLoadState) {
let worst = max(usage.primary?.usedPercent ?? 0, usage.secondary?.usedPercent ?? 0)
if worst > 0 { providers.append(("Codex", worst)) }
}
@@ -627,6 +638,15 @@ final class AppStore {
return AggregateQuotaStatus(severity: severity, warnings: warnings)
}
+ private func shouldIncludeCachedQuota(loadState: SubscriptionLoadState) -> Bool {
+ switch loadState {
+ case .notBootstrapped, .bootstrapping, .noCredentials:
+ return false
+ case .loading, .loaded, .failed, .terminalFailure, .transientFailure:
+ return true
+ }
+ }
+
func quotaSummary(for filter: ProviderFilter) -> QuotaSummary? {
switch filter {
case .claude: return claudeQuotaSummary(filter: filter)
@@ -824,6 +844,7 @@ enum ProviderFilter: String, CaseIterable, Identifiable {
case claude = "Claude"
case codex = "Codex"
case cursor = "Cursor"
+ case cursorAgent = "Cursor Agent"
case copilot = "Copilot"
case droid = "Droid"
case gemini = "Gemini"
@@ -837,16 +858,21 @@ enum ProviderFilter: String, CaseIterable, Identifiable {
case omp = "OMP"
case rooCode = "Roo Code"
case crush = "Crush"
+ case antigravity = "Antigravity"
+ case goose = "Goose"
var id: String { rawValue }
var providerKeys: [String] {
switch self {
- case .cursor: ["cursor", "cursor agent"]
+ case .cursor: ["cursor"]
+ case .cursorAgent: ["cursor-agent", "cursor agent"]
case .rooCode: ["roo-code", "roo code"]
case .kiloCode: ["kilo-code", "kilocode"]
case .ibmBob: ["ibm-bob", "ibm bob"]
case .openclaw: ["openclaw"]
+ case .antigravity: ["antigravity"]
+ case .goose: ["goose"]
default: [rawValue.lowercased()]
}
}
@@ -857,6 +883,7 @@ enum ProviderFilter: String, CaseIterable, Identifiable {
case .claude: "claude"
case .codex: "codex"
case .cursor: "cursor"
+ case .cursorAgent: "cursor-agent"
case .copilot: "copilot"
case .droid: "droid"
case .gemini: "gemini"
@@ -870,6 +897,8 @@ enum ProviderFilter: String, CaseIterable, Identifiable {
case .omp: "omp"
case .rooCode: "roo-code"
case .crush: "crush"
+ case .antigravity: "antigravity"
+ case .goose: "goose"
}
}
}
diff --git a/mac/Sources/CodeBurnMenubar/CodeBurnApp.swift b/mac/Sources/CodeBurnMenubar/CodeBurnApp.swift
index 80bc471..6191575 100644
--- a/mac/Sources/CodeBurnMenubar/CodeBurnApp.swift
+++ b/mac/Sources/CodeBurnMenubar/CodeBurnApp.swift
@@ -46,7 +46,10 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate {
private var statusPayloadRefreshGeneration: UInt64 = 0
private var manualRefreshTask: Task?
private var manualRefreshGeneration: UInt64 = 0
+ private var claudeQuotaRefreshTask: Task?
+ private var codexQuotaRefreshTask: Task?
private var refreshLoopHeartbeatAt: Date = .distantPast
+ private var lastLaunchAgentHeartbeatAt: Date = .distantPast
func applicationWillFinishLaunching(_ notification: Notification) {
// Set accessory policy before the app's focus chain forms. On macOS Tahoe
@@ -135,11 +138,28 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate {
queue: .main
) { [weak self] _ in
Task { @MainActor in
- self?.recoverRefreshPipelineAfterInterruption(resetLoading: false, reason: "launch agent")
+ self?.handleLaunchAgentHeartbeat()
}
}
}
+ private func handleLaunchAgentHeartbeat() {
+ let now = Date()
+ guard now.timeIntervalSince(lastLaunchAgentHeartbeatAt) >= refreshRateLimitSeconds else { return }
+ lastLaunchAgentHeartbeatAt = now
+ let loopAge = now.timeIntervalSince(refreshLoopHeartbeatAt)
+ guard refreshTimer == nil || loopAge > refreshLoopWatchdogSeconds else {
+ _ = store.clearStaleLoadingIfNeeded()
+ _ = clearStaleForceRefreshIfNeeded(now: now)
+ _ = clearStaleStatusPayloadRefreshIfNeeded(now: now)
+ return
+ }
+ if refreshTimer != nil {
+ NSLog("CodeBurn: refresh loop stale for %ds after launch agent - restarting", Int(loopAge))
+ }
+ startRefreshLoop(forceQuotaOnStart: false)
+ }
+
private func prepareRefreshPipelineForSleep() {
forceRefreshTask?.cancel()
forceRefreshTask = nil
@@ -181,9 +201,9 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate {
if refreshTimer != nil {
NSLog("CodeBurn: refresh loop stale for %ds after %@ - restarting", Int(loopAge), reason)
}
- startRefreshLoop()
+ startRefreshLoop(forceQuotaOnStart: false)
} else {
- runRefreshLoopTick(reason: reason, forcePayload: true, forceQuota: true)
+ runRefreshLoopTick(reason: reason, forcePayload: true, forceQuota: false)
}
}
@@ -244,7 +264,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate {
guard !UserDefaults.standard.bool(forKey: key) else { return }
let appPath = Bundle.main.bundlePath
- let script = "tell application \"System Events\" to make login item at end with properties {path:\"\(appPath)\", hidden:false}"
+ let script = "tell application \"System Events\" to make login item at end with properties {path:\(appleScriptStringLiteral(appPath)), hidden:false}"
let process = Process()
process.launchPath = "/usr/bin/osascript"
@@ -263,6 +283,14 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate {
}
}
+ private func appleScriptStringLiteral(_ value: String) -> String {
+ var escaped = value.replacingOccurrences(of: "\\", with: "\\\\")
+ escaped = escaped.replacingOccurrences(of: "\"", with: "\\\"")
+ escaped = escaped.replacingOccurrences(of: "\r", with: "")
+ escaped = escaped.replacingOccurrences(of: "\n", with: "")
+ return "\"\(escaped)\""
+ }
+
private var lastRefreshTime: Date = .distantPast
@discardableResult
@@ -405,16 +433,16 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate {
switch (shouldRefreshClaude, shouldRefreshCodex) {
case (true, true):
- async let claude = store.refreshSubscriptionReportingSuccess()
- async let codex = store.refreshCodexReportingSuccess()
+ async let claude = refreshClaudeQuotaSingleFlight()
+ async let codex = refreshCodexQuotaSingleFlight()
if await claude { lastSubscriptionRefreshAt = Date() }
if await codex { lastCodexRefreshAt = Date() }
case (true, false):
- if await store.refreshSubscriptionReportingSuccess() {
+ if await refreshClaudeQuotaSingleFlight() {
lastSubscriptionRefreshAt = Date()
}
case (false, true):
- if await store.refreshCodexReportingSuccess() {
+ if await refreshCodexQuotaSingleFlight() {
lastCodexRefreshAt = Date()
}
case (false, false):
@@ -423,6 +451,36 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate {
return true
}
+ private func refreshClaudeQuotaSingleFlight() async -> Bool {
+ if let task = claudeQuotaRefreshTask {
+ return await task.value
+ }
+ let task = Task { [store] in
+ await store.refreshSubscriptionReportingSuccess()
+ }
+ claudeQuotaRefreshTask = task
+ let result = await task.value
+ if claudeQuotaRefreshTask != nil {
+ claudeQuotaRefreshTask = nil
+ }
+ return result
+ }
+
+ private func refreshCodexQuotaSingleFlight() async -> Bool {
+ if let task = codexQuotaRefreshTask {
+ return await task.value
+ }
+ let task = Task { [store] in
+ await store.refreshCodexReportingSuccess()
+ }
+ codexQuotaRefreshTask = task
+ let result = await task.value
+ if codexQuotaRefreshTask != nil {
+ codexQuotaRefreshTask = nil
+ }
+ return result
+ }
+
private func refreshLiveQuotaProgressForPopoverOpen() {
let now = Date()
let claudeElapsed = now.timeIntervalSince(lastSubscriptionRefreshAt ?? .distantPast)
@@ -477,9 +535,9 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate {
}
}
- private func startRefreshLoop() {
+ private func startRefreshLoop(forceQuotaOnStart: Bool = false) {
stopRefreshTimer()
- runRefreshLoopTick(reason: "start", forcePayload: true, forceQuota: true)
+ runRefreshLoopTick(reason: "start", forcePayload: true, forceQuota: forceQuotaOnStart)
let timer = DispatchSource.makeTimerSource(queue: .main)
timer.schedule(
@@ -822,14 +880,19 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate {
await updateChecker.check()
let alert = NSAlert()
alert.icon = codeburnAlertIcon()
- if updateChecker.updateAvailable, let latest = updateChecker.latestVersion {
+ if let error = updateChecker.updateError {
+ alert.messageText = "Update Check Failed"
+ alert.informativeText = error
+ alert.alertStyle = .warning
+ } else if updateChecker.updateAvailable, let latest = updateChecker.latestVersion {
alert.messageText = "Update Available"
alert.informativeText = "\(AppVersion.display(latest)) is available (you have \(AppVersion.display(updateChecker.currentVersion))). Run:\n\ncodeburn menubar --force"
+ alert.alertStyle = .informational
} else {
alert.messageText = "Up to Date"
alert.informativeText = "You're on the latest version (\(AppVersion.display(updateChecker.currentVersion)))."
+ alert.alertStyle = .informational
}
- alert.alertStyle = .informational
alert.addButton(withTitle: "OK")
alert.runModal()
}
diff --git a/mac/Sources/CodeBurnMenubar/Data/ClaudeCredentialStore.swift b/mac/Sources/CodeBurnMenubar/Data/ClaudeCredentialStore.swift
index 9d887bf..e47db7b 100644
--- a/mac/Sources/CodeBurnMenubar/Data/ClaudeCredentialStore.swift
+++ b/mac/Sources/CodeBurnMenubar/Data/ClaudeCredentialStore.swift
@@ -36,15 +36,11 @@ enum ClaudeCredentialStore {
private static let credentialsRelativePath = ".claude/.credentials.json"
private static let maxCredentialBytes = 64 * 1024
- /// Local cache file. Stored under Application Support with 0600 permissions
- /// so only the current user can read it. We deliberately do NOT use the
- /// macOS Keychain for our own cache: keychain ACLs are bound to the binary
- /// code signature, so reading our own item triggers a prompt every time the
- /// binary changes (debug rebuilds, app updates with re-signing). Putting the
- /// cache in a plain file means the only Keychain prompt our user ever sees
- /// is the initial Connect read of Claude Code's own keychain entry.
- /// Threat model: same as ~/.claude/.credentials.json (also plaintext).
+ /// Legacy local cache file. New writes use the macOS Keychain; this path is
+ /// read once for migration and then removed.
private static let cacheFilename = "claude-credentials.v1.json"
+ private static let ourKeychainService = "org.agentseal.codeburn.menubar.claude.oauth.v1"
+ private static let ourKeychainAccount = "default"
private static let lock = NSLock()
private nonisolated(unsafe) static var memoryCache: CachedRecord?
@@ -283,6 +279,10 @@ enum ClaudeCredentialStore {
}
private static func readOurCache() throws -> CredentialRecord? {
+ if let record = try readOurKeychainCache() {
+ return record
+ }
+
let url = cacheFileURL()
guard FileManager.default.fileExists(atPath: url.path) else { return nil }
// Route through SafeFile.read so we lstat for symlinks before opening
@@ -291,21 +291,66 @@ enum ClaudeCredentialStore {
// CodeBurn/ between disconnect and reconnect could redirect our read
// to /dev/zero (unbounded memory) or another file the user owns.
let data = try SafeFile.read(from: url.path, maxBytes: maxCredentialBytes)
- return try? JSONDecoder().decode(CredentialRecord.self, from: data)
+ guard let record = try? JSONDecoder().decode(CredentialRecord.self, from: data) else { return nil }
+ try? writeOurKeychainCache(record: record)
+ try? FileManager.default.removeItem(at: url)
+ return record
}
private static func writeOurCache(record: CredentialRecord) throws {
+ try writeOurKeychainCache(record: record)
+ }
+
+ private static func readOurKeychainCache() throws -> CredentialRecord? {
+ let query: [String: Any] = [
+ kSecClass as String: kSecClassGenericPassword,
+ kSecAttrService as String: ourKeychainService,
+ kSecAttrAccount as String: ourKeychainAccount,
+ kSecMatchLimit as String: kSecMatchLimitOne,
+ kSecReturnData as String: true,
+ ]
+ var result: CFTypeRef?
+ let status = SecItemCopyMatching(query as CFDictionary, &result)
+ if status == errSecItemNotFound { return nil }
+ guard status == errSecSuccess, let data = result as? Data else {
+ throw StoreError.keychainReadFailed(status)
+ }
+ return try? JSONDecoder().decode(CredentialRecord.self, from: data)
+ }
+
+ private static func writeOurKeychainCache(record: CredentialRecord) throws {
let url = cacheFileURL()
let data = try JSONEncoder().encode(record)
- // SafeFile.write opens the temp file with O_CREAT | O_EXCL | O_NOFOLLOW
- // and the explicit 0600 mode in a single syscall — no race window
- // where the file briefly exists at default umask, and no chance of
- // following a malicious symlink at the destination path. Also creates
- // the parent dir at 0700.
- try SafeFile.write(data, to: url.path, mode: 0o600)
+ let query: [String: Any] = [
+ kSecClass as String: kSecClassGenericPassword,
+ kSecAttrService as String: ourKeychainService,
+ kSecAttrAccount as String: ourKeychainAccount,
+ ]
+ let attributes: [String: Any] = [
+ kSecValueData as String: data,
+ kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly,
+ ]
+ let status = SecItemUpdate(query as CFDictionary, attributes as CFDictionary)
+ if status == errSecItemNotFound {
+ var add = query
+ add.merge(attributes) { _, new in new }
+ let addStatus = SecItemAdd(add as CFDictionary, nil)
+ guard addStatus == errSecSuccess else {
+ throw StoreError.keychainWriteFailed(addStatus)
+ }
+ } else if status != errSecSuccess {
+ throw StoreError.keychainWriteFailed(status)
+ }
+ try? FileManager.default.removeItem(at: url)
}
private static func deleteOurCache() {
+ let query: [String: Any] = [
+ kSecClass as String: kSecClassGenericPassword,
+ kSecAttrService as String: ourKeychainService,
+ kSecAttrAccount as String: ourKeychainAccount,
+ ]
+ SecItemDelete(query as CFDictionary)
try? FileManager.default.removeItem(at: cacheFileURL())
}
diff --git a/mac/Sources/CodeBurnMenubar/Data/CodexCredentialStore.swift b/mac/Sources/CodeBurnMenubar/Data/CodexCredentialStore.swift
index d821151..cffae7b 100644
--- a/mac/Sources/CodeBurnMenubar/Data/CodexCredentialStore.swift
+++ b/mac/Sources/CodeBurnMenubar/Data/CodexCredentialStore.swift
@@ -1,4 +1,5 @@
import Foundation
+import Security
/// Owns the Codex (ChatGPT-mode) OAuth credential lifecycle. Mirrors
/// ClaudeCredentialStore but reads from ~/.codex/auth.json — Codex CLI
@@ -17,6 +18,8 @@ enum CodexCredentialStore {
private static let maxCredentialBytes = 64 * 1024
private static let cacheFilename = "codex-credentials.v1.json"
+ private static let ourKeychainService = "org.agentseal.codeburn.menubar.codex.oauth.v1"
+ private static let ourKeychainAccount = "default"
private static let lock = NSLock()
private nonisolated(unsafe) static var memoryCache: CachedRecord?
@@ -198,28 +201,74 @@ enum CodexCredentialStore {
}
private static func readOurCache() throws -> CredentialRecord? {
+ if let record = try readOurKeychainCache() {
+ return record
+ }
+
let url = cacheFileURL()
guard FileManager.default.fileExists(atPath: url.path) else { return nil }
// Symlink-defense + size cap (same hardening as ClaudeCredentialStore).
let data = try SafeFile.read(from: url.path, maxBytes: maxCredentialBytes)
- return try? JSONDecoder().decode(CredentialRecord.self, from: data)
+ guard let record = try? JSONDecoder().decode(CredentialRecord.self, from: data) else { return nil }
+ try? writeOurKeychainCache(record: record)
+ try? FileManager.default.removeItem(at: url)
+ return record
}
private static func writeOurCache(record: CredentialRecord) throws {
+ try writeOurKeychainCache(record: record)
+ }
+
+ private static func readOurKeychainCache() throws -> CredentialRecord? {
+ let query: [String: Any] = [
+ kSecClass as String: kSecClassGenericPassword,
+ kSecAttrService as String: ourKeychainService,
+ kSecAttrAccount as String: ourKeychainAccount,
+ kSecMatchLimit as String: kSecMatchLimitOne,
+ kSecReturnData as String: true,
+ ]
+ var result: CFTypeRef?
+ let status = SecItemCopyMatching(query as CFDictionary, &result)
+ if status == errSecItemNotFound { return nil }
+ guard status == errSecSuccess, let data = result as? Data else {
+ throw StoreError.fileWriteFailed("keychain read failed with status \(status)")
+ }
+ return try? JSONDecoder().decode(CredentialRecord.self, from: data)
+ }
+
+ private static func writeOurKeychainCache(record: CredentialRecord) throws {
let url = cacheFileURL()
let data = try JSONEncoder().encode(record)
- do {
- // SafeFile.write opens the temp file with O_CREAT | O_EXCL | O_NOFOLLOW
- // and the explicit 0600 mode in a single syscall — no race window
- // where the file briefly exists at default umask, and no chance of
- // following a malicious symlink at the destination path.
- try SafeFile.write(data, to: url.path, mode: 0o600)
- } catch {
- throw StoreError.fileWriteFailed(String(describing: error))
+ let query: [String: Any] = [
+ kSecClass as String: kSecClassGenericPassword,
+ kSecAttrService as String: ourKeychainService,
+ kSecAttrAccount as String: ourKeychainAccount,
+ ]
+ let attributes: [String: Any] = [
+ kSecValueData as String: data,
+ kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly,
+ ]
+ let status = SecItemUpdate(query as CFDictionary, attributes as CFDictionary)
+ if status == errSecItemNotFound {
+ var add = query
+ add.merge(attributes) { _, new in new }
+ let addStatus = SecItemAdd(add as CFDictionary, nil)
+ guard addStatus == errSecSuccess else {
+ throw StoreError.fileWriteFailed("keychain write failed with status \(addStatus)")
+ }
+ } else if status != errSecSuccess {
+ throw StoreError.fileWriteFailed("keychain update failed with status \(status)")
}
+ try? FileManager.default.removeItem(at: url)
}
private static func deleteOurCache() {
+ let query: [String: Any] = [
+ kSecClass as String: kSecClassGenericPassword,
+ kSecAttrService as String: ourKeychainService,
+ kSecAttrAccount as String: ourKeychainAccount,
+ ]
+ SecItemDelete(query as CFDictionary)
try? FileManager.default.removeItem(at: cacheFileURL())
}
diff --git a/mac/Sources/CodeBurnMenubar/Data/UpdateChecker.swift b/mac/Sources/CodeBurnMenubar/Data/UpdateChecker.swift
index 955718f..5441794 100644
--- a/mac/Sources/CodeBurnMenubar/Data/UpdateChecker.swift
+++ b/mac/Sources/CodeBurnMenubar/Data/UpdateChecker.swift
@@ -1,10 +1,28 @@
import Foundation
import Observation
-private let releasesAPI = "https://api.github.com/repos/getagentseal/codeburn/releases/latest"
+private let releasesAPI = "https://api.github.com/repos/getagentseal/codeburn/releases?per_page=20"
private let checkIntervalSeconds: TimeInterval = 2 * 24 * 60 * 60
private let lastCheckKey = "UpdateChecker.lastCheckDate"
private let cachedVersionKey = "UpdateChecker.latestVersion"
+private let updateTimeoutSeconds: UInt64 = 120
+private let maxUpdateStderrBytes = 64 * 1024
+
+private final class LockedDataBuffer: @unchecked Sendable {
+ private let lock = NSLock()
+ private var data = Data()
+
+ func append(_ chunk: Data, limit: Int) {
+ lock.withLock {
+ guard data.count < limit else { return }
+ data.append(Data(chunk.prefix(limit - data.count)))
+ }
+ }
+
+ func snapshot() -> Data {
+ lock.withLock { data }
+ }
+}
@MainActor
@Observable
@@ -37,19 +55,24 @@ final class UpdateChecker {
}
func check() async {
+ updateError = nil
guard let url = URL(string: releasesAPI) else { return }
var request = URLRequest(url: url)
request.setValue("codeburn-menubar-updater", forHTTPHeaderField: "User-Agent")
request.setValue("application/vnd.github+json", forHTTPHeaderField: "Accept")
do {
- let (data, _) = try await URLSession.shared.data(for: request)
- let release = try JSONDecoder().decode(GitHubRelease.self, from: data)
- guard let asset = release.assets.first(where: {
- $0.name.hasPrefix("CodeBurnMenubar-v") && $0.name.hasSuffix(".zip")
- }) else { return }
+ let (data, response) = try await URLSession.shared.data(for: request)
+ guard let http = response as? HTTPURLResponse, http.statusCode == 200 else {
+ let status = (response as? HTTPURLResponse)?.statusCode ?? -1
+ throw UpdateCheckError.http(status)
+ }
+ let releases = try JSONDecoder().decode([GitHubRelease].self, from: data)
+ guard let resolved = Self.resolveLatestMenubarRelease(in: releases) else {
+ throw UpdateCheckError.missingMenubarAsset
+ }
- let version = asset.name
+ let version = resolved.asset.name
.replacingOccurrences(of: "CodeBurnMenubar-", with: "")
.replacingOccurrences(of: ".zip", with: "")
@@ -57,22 +80,50 @@ final class UpdateChecker {
UserDefaults.standard.set(Date().timeIntervalSince1970, forKey: lastCheckKey)
UserDefaults.standard.set(version, forKey: cachedVersionKey)
} catch {
+ updateError = "Update check failed: \(error.localizedDescription)"
NSLog("CodeBurn: update check failed: \(error)")
}
}
+ nonisolated static func resolveLatestMenubarRelease(in releases: [GitHubRelease]) -> (release: GitHubRelease, asset: GitHubAsset)? {
+ for release in releases where release.tag_name.hasPrefix("mac-v") {
+ guard let asset = release.assets.first(where: {
+ $0.name.hasPrefix("CodeBurnMenubar-v") && $0.name.hasSuffix(".zip")
+ }) else { continue }
+ guard release.assets.contains(where: { $0.name == "\(asset.name).sha256" }) else { continue }
+ return (release, asset)
+ }
+ return nil
+ }
+
func performUpdate() {
isUpdating = true
updateError = nil
let process = CodeburnCLI.makeProcess(subcommand: ["menubar", "--force"])
let errPipe = Pipe()
+ let errBuffer = LockedDataBuffer()
process.standardOutput = FileHandle.nullDevice
process.standardError = errPipe
+ errPipe.fileHandleForReading.readabilityHandler = { handle in
+ let chunk = handle.availableData
+ guard !chunk.isEmpty else { return }
+ errBuffer.append(chunk, limit: maxUpdateStderrBytes)
+ }
+
+ let timeoutTask = Task.detached(priority: .utility) {
+ try? await Task.sleep(nanoseconds: updateTimeoutSeconds * 1_000_000_000)
+ if process.isRunning {
+ NSLog("CodeBurn: update subprocess timed out after %llus - terminating", updateTimeoutSeconds)
+ process.terminate()
+ }
+ }
process.terminationHandler = { [weak self] proc in
- let errData = errPipe.fileHandleForReading.readDataToEndOfFile()
- let stderr = String(data: errData, encoding: .utf8) ?? ""
+ timeoutTask.cancel()
+ errPipe.fileHandleForReading.readabilityHandler = nil
+ let stderrData = errBuffer.snapshot()
+ let stderr = Self.sanitizeForDisplay(String(data: stderrData, encoding: .utf8) ?? "")
Task { @MainActor in
guard let self else { return }
self.isUpdating = false
@@ -93,14 +144,41 @@ final class UpdateChecker {
NSLog("CodeBurn: update spawn failed: \(error)")
}
}
+
+ nonisolated private static func sanitizeForDisplay(_ value: String) -> String {
+ var cleaned = value.replacingOccurrences(of: "\u{0000}", with: "")
+ let patterns: [(String, String)] = [
+ (#"sk-ant-[A-Za-z0-9_-]+"#, "sk-ant-***"),
+ (#"sk-[A-Za-z0-9_-]{16,}"#, "sk-***"),
+ (#"eyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+"#, "eyJ***"),
+ (#"(?i)Bearer\s+\S+"#, "Bearer ***"),
+ ]
+ for (pattern, replacement) in patterns {
+ cleaned = cleaned.replacingOccurrences(of: pattern, with: replacement, options: .regularExpression)
+ }
+ if cleaned.count > 1_000 { cleaned = String(cleaned.prefix(1_000)) + "..." }
+ return cleaned.trimmingCharacters(in: .whitespacesAndNewlines)
+ }
}
-private struct GitHubRelease: Decodable {
+enum UpdateCheckError: LocalizedError {
+ case http(Int)
+ case missingMenubarAsset
+
+ var errorDescription: String? {
+ switch self {
+ case let .http(status): "GitHub returned HTTP \(status)."
+ case .missingMenubarAsset: "No mac-v release with a menubar zip and checksum was found."
+ }
+ }
+}
+
+struct GitHubRelease: Decodable {
let tag_name: String
let assets: [GitHubAsset]
}
-private struct GitHubAsset: Decodable {
+struct GitHubAsset: Decodable {
let name: String
let browser_download_url: String
}
diff --git a/mac/Sources/CodeBurnMenubar/Security/CodeburnCLI.swift b/mac/Sources/CodeBurnMenubar/Security/CodeburnCLI.swift
index 4f4a5f8..83251de 100644
--- a/mac/Sources/CodeBurnMenubar/Security/CodeburnCLI.swift
+++ b/mac/Sources/CodeBurnMenubar/Security/CodeburnCLI.swift
@@ -13,20 +13,50 @@ enum CodeburnCLI {
/// PATH additions for GUI-launched apps, which otherwise get a minimal PATH that misses
/// Homebrew and npm global installs.
private static let additionalPathEntries = ["/opt/homebrew/bin", "/usr/local/bin"]
+ private static let persistedPathFilename = "codeburn-cli-path.v1"
/// Returns the argv that launches the CLI. Dev override via `CODEBURN_BIN` is honoured only
/// if every whitespace-delimited token passes `safeArgPattern`. Otherwise falls back to the
/// plain `codeburn` name (resolved via PATH).
static func baseArgv() -> [String] {
- guard let raw = ProcessInfo.processInfo.environment["CODEBURN_BIN"], !raw.isEmpty else {
- return ["codeburn"]
+ if ProcessInfo.processInfo.environment["CODEBURN_ALLOW_DEV_BIN"] == "1",
+ let raw = ProcessInfo.processInfo.environment["CODEBURN_BIN"],
+ !raw.isEmpty
+ {
+ let parts = raw.split(separator: " ", omittingEmptySubsequences: true).map(String.init)
+ guard parts.allSatisfy(isSafe) else {
+ NSLog("CodeBurn: refusing unsafe CODEBURN_BIN; using installed codeburn")
+ return installedArgv()
+ }
+ return parts
}
- let parts = raw.split(separator: " ", omittingEmptySubsequences: true).map(String.init)
- guard parts.allSatisfy(isSafe) else {
- NSLog("CodeBurn: refusing unsafe CODEBURN_BIN; using default 'codeburn'")
- return ["codeburn"]
+
+ return installedArgv()
+ }
+
+ private static func installedArgv() -> [String] {
+ if let persisted = persistedCLIPath(), isSafe(persisted), FileManager.default.isExecutableFile(atPath: persisted) {
+ return [persisted]
}
- return parts
+ for candidate in additionalPathEntries.map({ "\($0)/codeburn" }) {
+ if FileManager.default.isExecutableFile(atPath: candidate) {
+ return [candidate]
+ }
+ }
+ return ["codeburn"]
+ }
+
+ private static func persistedCLIPath() -> String? {
+ let support = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first
+ ?? FileManager.default.homeDirectoryForCurrentUser.appendingPathComponent("Library/Application Support")
+ let url = support
+ .appendingPathComponent("CodeBurn", isDirectory: true)
+ .appendingPathComponent(persistedPathFilename)
+ guard let value = try? String(contentsOf: url, encoding: .utf8).trimmingCharacters(in: .whitespacesAndNewlines),
+ !value.isEmpty,
+ value.hasPrefix("/")
+ else { return nil }
+ return value
}
/// Builds a `Process` that runs the CLI with the given subcommand args. Uses `/usr/bin/env`
diff --git a/mac/Sources/CodeBurnMenubar/Views/AgentTabStrip.swift b/mac/Sources/CodeBurnMenubar/Views/AgentTabStrip.swift
index df47c46..99b31aa 100644
--- a/mac/Sources/CodeBurnMenubar/Views/AgentTabStrip.swift
+++ b/mac/Sources/CodeBurnMenubar/Views/AgentTabStrip.swift
@@ -342,6 +342,7 @@ extension ProviderFilter {
case .claude: return Theme.categoricalClaude
case .codex: return Theme.categoricalCodex
case .cursor: return Theme.categoricalCursor
+ case .cursorAgent: return Color(red: 0x4E/255.0, green: 0xC9/255.0, blue: 0xB0/255.0)
case .copilot: return Color(red: 0x6D/255.0, green: 0x8F/255.0, blue: 0xA6/255.0)
case .droid: return Color(red: 0x7C/255.0, green: 0x3A/255.0, blue: 0xED/255.0)
case .gemini: return Color(red: 0x44/255.0, green: 0x85/255.0, blue: 0xF4/255.0)
@@ -355,6 +356,8 @@ extension ProviderFilter {
case .omp: return Color(red: 0x8B/255.0, green: 0x5C/255.0, blue: 0xB0/255.0)
case .rooCode: return Color(red: 0x4C/255.0, green: 0xAF/255.0, blue: 0x50/255.0)
case .crush: return Color(red: 0xE0/255.0, green: 0x6C/255.0, blue: 0x9F/255.0)
+ case .antigravity: return Color(red: 0xFF/255.0, green: 0x7A/255.0, blue: 0x45/255.0)
+ case .goose: return Color(red: 0xB7/255.0, green: 0x8D/255.0, blue: 0x52/255.0)
}
}
}
diff --git a/mac/Sources/CodeBurnMenubar/Views/MenuBarContent.swift b/mac/Sources/CodeBurnMenubar/Views/MenuBarContent.swift
index 6a38b1c..7bad14b 100644
--- a/mac/Sources/CodeBurnMenubar/Views/MenuBarContent.swift
+++ b/mac/Sources/CodeBurnMenubar/Views/MenuBarContent.swift
@@ -279,7 +279,7 @@ private struct Header: View {
.foregroundStyle(.secondary)
}
Spacer()
- if updateChecker.updateAvailable {
+ if updateChecker.updateAvailable || updateChecker.updateError != nil {
UpdateBadge()
}
AccentPicker()
@@ -409,18 +409,25 @@ private struct UpdateBadge: View {
var body: some View {
Button {
- updateChecker.performUpdate()
+ if updateChecker.updateAvailable {
+ updateChecker.performUpdate()
+ } else {
+ Task { await updateChecker.check() }
+ }
} label: {
HStack(spacing: 4) {
if updateChecker.isUpdating {
ProgressView()
.controlSize(.mini)
.scaleEffect(0.7)
+ } else if updateChecker.updateError != nil {
+ Image(systemName: "exclamationmark.triangle.fill")
+ .font(.system(size: 10))
} else {
Image(systemName: "arrow.down.circle.fill")
.font(.system(size: 10))
}
- Text(updateChecker.isUpdating ? "Updating..." : "Update")
+ Text(updateChecker.isUpdating ? "Updating..." : (updateChecker.updateError == nil ? "Update" : "Failed"))
.font(.system(size: 10, weight: .medium))
}
.padding(.horizontal, 8)
@@ -430,6 +437,7 @@ private struct UpdateBadge: View {
.tint(Theme.brandAccent)
.controlSize(.mini)
.disabled(updateChecker.isUpdating)
+ .help(updateChecker.updateError ?? "Install the latest menubar build")
}
}
@@ -537,12 +545,7 @@ struct FooterBar: View {
.fixedSize()
Button {
- // showLoading: true is safe now that the overlay condition uses
- // `!hasCachedData` instead of `isLoading`. The button icon swaps
- // to the spinner glyph (driven by store.isLoading), giving the
- // user visible feedback the click was registered, but the
- // popover body keeps the existing data instead of blanking out.
- Task { await store.refresh(includeOptimize: false, force: true, showLoading: true) }
+ refreshNow()
} label: {
Image(systemName: store.isLoading ? "arrow.triangle.2.circlepath" : "arrow.clockwise")
.font(.system(size: 11, weight: .medium))
@@ -588,6 +591,14 @@ struct FooterBar: View {
TerminalLauncher.open(subcommand: ["report"])
}
+ private func refreshNow() {
+ if let delegate = NSApp.delegate as? AppDelegate {
+ delegate.refreshSubscriptionNow()
+ } else {
+ Task { await store.refresh(includeOptimize: false, force: true, showLoading: true) }
+ }
+ }
+
private enum ExportFormat {
case csv, json
var cliName: String { self == .csv ? "csv" : "json" }
diff --git a/mac/Tests/CodeBurnMenubarTests/UpdateCheckerTests.swift b/mac/Tests/CodeBurnMenubarTests/UpdateCheckerTests.swift
new file mode 100644
index 0000000..44f52b5
--- /dev/null
+++ b/mac/Tests/CodeBurnMenubarTests/UpdateCheckerTests.swift
@@ -0,0 +1,39 @@
+import Testing
+@testable import CodeBurnMenubar
+
+@Suite("UpdateChecker")
+struct UpdateCheckerTests {
+ @Test("selects newest mac release with zip and checksum")
+ func selectsNewestMacReleaseWithChecksum() {
+ let releases = [
+ GitHubRelease(
+ tag_name: "v0.9.9",
+ assets: [GitHubAsset(name: "codeburn-0.9.9.tgz", browser_download_url: "https://example.test/cli")]
+ ),
+ GitHubRelease(
+ tag_name: "mac-v0.9.8",
+ assets: [
+ GitHubAsset(name: "CodeBurnMenubar-v0.9.8.zip", browser_download_url: "https://example.test/app"),
+ GitHubAsset(name: "CodeBurnMenubar-v0.9.8.zip.sha256", browser_download_url: "https://example.test/app.sha256"),
+ ]
+ ),
+ ]
+
+ let resolved = UpdateChecker.resolveLatestMenubarRelease(in: releases)
+
+ #expect(resolved?.release.tag_name == "mac-v0.9.8")
+ #expect(resolved?.asset.name == "CodeBurnMenubar-v0.9.8.zip")
+ }
+
+ @Test("ignores mac release missing checksum")
+ func ignoresMacReleaseMissingChecksum() {
+ let releases = [
+ GitHubRelease(
+ tag_name: "mac-v0.9.8",
+ assets: [GitHubAsset(name: "CodeBurnMenubar-v0.9.8.zip", browser_download_url: "https://example.test/app")]
+ ),
+ ]
+
+ #expect(UpdateChecker.resolveLatestMenubarRelease(in: releases) == nil)
+ }
+}
diff --git a/src/menubar-installer.ts b/src/menubar-installer.ts
index 051c12c..915aefa 100644
--- a/src/menubar-installer.ts
+++ b/src/menubar-installer.ts
@@ -1,26 +1,29 @@
import { spawn } from 'node:child_process'
import { createHash } from 'node:crypto'
import { createWriteStream } from 'node:fs'
-import { mkdir, mkdtemp, readFile, rename, rm, stat } from 'node:fs/promises'
+import { chmod, mkdir, mkdtemp, readFile, rename, rm, stat, writeFile } from 'node:fs/promises'
import { homedir, platform, tmpdir } from 'node:os'
import { join } from 'node:path'
import { pipeline } from 'node:stream/promises'
import { Readable } from 'node:stream'
-/// Public GitHub repo that hosts signed macOS release builds. `/releases/latest` returns the
-/// newest tagged release; we filter its assets list for our zipped .app bundle.
-const RELEASE_API = 'https://api.github.com/repos/getagentseal/codeburn/releases/latest'
+/// Public GitHub repo that hosts macOS release builds. CLI and menubar releases share
+/// the repository, so we scan recent releases and choose the newest `mac-v*` release
+/// that actually contains the menubar zip.
+const RELEASE_API = 'https://api.github.com/repos/getagentseal/codeburn/releases?per_page=20'
const APP_BUNDLE_NAME = 'CodeBurnMenubar.app'
+const EXPECTED_BUNDLE_ID = 'org.agentseal.codeburn-menubar'
const VERSIONED_ASSET_PATTERN = /^CodeBurnMenubar-v.+\.zip$/
const APP_PROCESS_NAME = 'CodeBurnMenubar'
const SUPPORTED_OS = 'darwin'
const MIN_MACOS_MAJOR = 14
+const PERSISTED_CLI_PATH = join(homedir(), 'Library', 'Application Support', 'CodeBurn', 'codeburn-cli-path.v1')
export type InstallResult = { installedPath: string; launched: boolean }
export type ReleaseAsset = { name: string; browser_download_url: string }
export type ReleaseResponse = { tag_name: string; assets: ReleaseAsset[] }
-export type ResolvedAssets = { zip: ReleaseAsset; checksum: ReleaseAsset | null }
+export type ResolvedAssets = { release: ReleaseResponse; zip: ReleaseAsset; checksum: ReleaseAsset }
export function resolveMenubarReleaseAssets(release: ReleaseResponse): ResolvedAssets {
const zip = release.assets.find(a => VERSIONED_ASSET_PATTERN.test(a.name))
@@ -30,8 +33,23 @@ export function resolveMenubarReleaseAssets(release: ReleaseResponse): ResolvedA
`Check https://github.com/getagentseal/codeburn/releases.`
)
}
- const checksum = release.assets.find(a => a.name === `${zip.name}.sha256`) ?? null
- return { zip, checksum }
+ const checksum = release.assets.find(a => a.name === `${zip.name}.sha256`)
+ if (!checksum) {
+ throw new Error(`Missing checksum asset ${zip.name}.sha256 in release ${release.tag_name}.`)
+ }
+ return { release, zip, checksum }
+}
+
+export function resolveLatestMenubarReleaseAssets(releases: ReleaseResponse[]): ResolvedAssets {
+ for (const release of releases) {
+ if (!release.tag_name.startsWith('mac-v')) continue
+ try {
+ return resolveMenubarReleaseAssets(release)
+ } catch {
+ continue
+ }
+ }
+ throw new Error('No mac-v* release with a CodeBurnMenubar-v*.zip and checksum was found.')
}
function userApplicationsDir(): string {
@@ -81,8 +99,8 @@ async function fetchLatestReleaseAssets(): Promise {
if (!response.ok) {
throw new Error(`GitHub release lookup failed: HTTP ${response.status}`)
}
- const body = await response.json() as ReleaseResponse
- return resolveMenubarReleaseAssets(body)
+ const body = await response.json() as ReleaseResponse[]
+ return resolveLatestMenubarReleaseAssets(body)
}
async function verifyChecksum(archivePath: string, checksumUrl: string): Promise {
@@ -131,6 +149,57 @@ async function runCommand(command: string, args: string[]): Promise {
})
}
+async function captureCommand(command: string, args: string[]): Promise {
+ return new Promise((resolve, reject) => {
+ const proc = spawn(command, args, { stdio: ['ignore', 'pipe', 'pipe'] })
+ let out = ''
+ let err = ''
+ proc.stdout.on('data', (chunk: Buffer) => { out += chunk.toString() })
+ proc.stderr.on('data', (chunk: Buffer) => { err += chunk.toString() })
+ proc.on('error', reject)
+ proc.on('close', (code) => {
+ if (code === 0) resolve(out.trim())
+ else reject(new Error(`${command} exited with status ${code}${err ? `: ${err.trim()}` : ''}`))
+ })
+ })
+}
+
+async function verifyBundleIdentity(appPath: string): Promise