mirror of
https://github.com/AgentSeal/codeburn.git
synced 2026-07-26 09:24:59 +00:00
Scopes the menubar to one Claude config for multi-config (CLAUDE_CONFIG_DIRS) setups, with All as the default. Rebased onto main and fixed the review findings from the original #635: - Fix a TS2206 build break (a 'type' modifier inside an import type block). - Reject --claude-config-source with a non-Claude --provider, and scan Claude only in the scoped branch (a config is Claude-only): fixes provider data leaking into a scoped query and avoids parsing every provider's corpus. - Scope the macOS menu-bar figure to the selected config (badge matched the popover), clear the selection when switching to a non-Claude provider tab, and stop the on-disk badge fallback from showing an unscoped number while scoped. - Tag Claude Desktop / Cowork sessions as their own 'claude-desktop' source so they are a selectable bucket instead of silently vanishing from per-config views (sum of options now equals All). - Skip the redundant Claude discovery walk for plain single-config users while keeping idle configs and Claude Desktop selectable. Reviewed by Codex 5.6; all findings addressed. Full suite: 1581 TS tests, 76 Swift tests, tsc clean.
468 lines
16 KiB
Swift
468 lines
16 KiB
Swift
import Foundation
|
|
|
|
/// Shape of `codeburn status --format menubar-json --period <period>`.
|
|
/// `current` is scoped to the requested period; the whole payload reflects that slice.
|
|
struct MenubarPayload: Codable, Sendable {
|
|
let generated: String
|
|
let current: CurrentBlock
|
|
let optimize: OptimizeBlock
|
|
let history: HistoryBlock
|
|
let combined: CombinedUsage?
|
|
let claudeConfigs: ClaudeConfigSelector?
|
|
|
|
init(generated: String,
|
|
current: CurrentBlock,
|
|
optimize: OptimizeBlock,
|
|
history: HistoryBlock,
|
|
combined: CombinedUsage?,
|
|
claudeConfigs: ClaudeConfigSelector? = nil) {
|
|
self.generated = generated
|
|
self.current = current
|
|
self.optimize = optimize
|
|
self.history = history
|
|
self.combined = combined
|
|
self.claudeConfigs = claudeConfigs
|
|
}
|
|
|
|
enum CodingKeys: String, CodingKey {
|
|
case generated, current, optimize, history, combined, claudeConfigs
|
|
}
|
|
|
|
init(from decoder: Decoder) throws {
|
|
let c = try decoder.container(keyedBy: CodingKeys.self)
|
|
generated = try c.decode(String.self, forKey: .generated)
|
|
current = try c.decode(CurrentBlock.self, forKey: .current)
|
|
optimize = try c.decode(OptimizeBlock.self, forKey: .optimize)
|
|
history = try c.decode(HistoryBlock.self, forKey: .history)
|
|
combined = try c.decodeIfPresent(CombinedUsage.self, forKey: .combined)
|
|
claudeConfigs = try c.decodeIfPresent(ClaudeConfigSelector.self, forKey: .claudeConfigs)
|
|
}
|
|
}
|
|
|
|
struct ClaudeConfigSelector: Codable, Sendable {
|
|
let selectedId: String?
|
|
let options: [ClaudeConfigOption]
|
|
}
|
|
|
|
struct ClaudeConfigOption: Codable, Identifiable, Hashable, Sendable {
|
|
let id: String
|
|
let label: String
|
|
let path: String
|
|
}
|
|
|
|
struct CombinedUsage: Codable, Sendable {
|
|
let perDevice: [CombinedDeviceUsage]
|
|
let combined: CombinedUsageTotals
|
|
}
|
|
|
|
struct CombinedDeviceUsage: Codable, Sendable {
|
|
let id: String
|
|
let name: String
|
|
let local: Bool
|
|
let error: String?
|
|
let cost: Double
|
|
let calls: Int
|
|
let sessions: Int
|
|
let inputTokens: Int
|
|
let outputTokens: Int
|
|
let cacheCreateTokens: Int
|
|
let cacheReadTokens: Int
|
|
let totalTokens: Int
|
|
}
|
|
|
|
struct CombinedUsageTotals: Codable, Sendable {
|
|
let cost: Double
|
|
let calls: Int
|
|
let sessions: Int
|
|
let inputTokens: Int
|
|
let outputTokens: Int
|
|
let cacheCreateTokens: Int
|
|
let cacheReadTokens: Int
|
|
let totalTokens: Int
|
|
let deviceCount: Int
|
|
let reachableCount: Int
|
|
}
|
|
|
|
struct HistoryBlock: Codable, Sendable {
|
|
let daily: [DailyHistoryEntry]
|
|
}
|
|
|
|
struct DailyModelBreakdown: Codable, Sendable {
|
|
let name: String
|
|
let cost: Double
|
|
let savingsUSD: Double
|
|
let calls: Int
|
|
let inputTokens: Int
|
|
let outputTokens: Int
|
|
|
|
var totalTokens: Int { inputTokens + outputTokens }
|
|
|
|
enum CodingKeys: String, CodingKey {
|
|
case name, cost, savingsUSD, calls, inputTokens, outputTokens
|
|
}
|
|
init(from decoder: Decoder) throws {
|
|
let c = try decoder.container(keyedBy: CodingKeys.self)
|
|
name = try c.decode(String.self, forKey: .name)
|
|
cost = try c.decode(Double.self, forKey: .cost)
|
|
savingsUSD = try c.decodeIfPresent(Double.self, forKey: .savingsUSD) ?? 0
|
|
calls = try c.decode(Int.self, forKey: .calls)
|
|
inputTokens = try c.decode(Int.self, forKey: .inputTokens)
|
|
outputTokens = try c.decode(Int.self, forKey: .outputTokens)
|
|
}
|
|
}
|
|
|
|
struct DailyHistoryEntry: Codable, Sendable {
|
|
let date: String
|
|
let cost: Double
|
|
let savingsUSD: Double
|
|
let calls: Int
|
|
let inputTokens: Int
|
|
let outputTokens: Int
|
|
let cacheReadTokens: Int
|
|
let cacheWriteTokens: Int
|
|
let topModels: [DailyModelBreakdown]
|
|
|
|
/// Pricing-ratio prior: input + 5x output + cache_creation + 0.1x cache_read.
|
|
/// Matches Anthropic's published per-token pricing on Sonnet/Opus closely enough to be a useful proxy.
|
|
var effectiveTokens: Double {
|
|
Double(inputTokens) + 5.0 * Double(outputTokens) + Double(cacheWriteTokens) + 0.1 * Double(cacheReadTokens)
|
|
}
|
|
}
|
|
|
|
extension DailyHistoryEntry {
|
|
/// Required for legacy payloads (no topModels emitted yet).
|
|
enum CodingKeys: String, CodingKey {
|
|
case date, cost, savingsUSD, calls, inputTokens, outputTokens, cacheReadTokens, cacheWriteTokens, topModels
|
|
}
|
|
init(from decoder: Decoder) throws {
|
|
let c = try decoder.container(keyedBy: CodingKeys.self)
|
|
date = try c.decode(String.self, forKey: .date)
|
|
cost = try c.decode(Double.self, forKey: .cost)
|
|
savingsUSD = try c.decodeIfPresent(Double.self, forKey: .savingsUSD) ?? 0
|
|
calls = try c.decode(Int.self, forKey: .calls)
|
|
inputTokens = try c.decode(Int.self, forKey: .inputTokens)
|
|
outputTokens = try c.decode(Int.self, forKey: .outputTokens)
|
|
cacheReadTokens = try c.decode(Int.self, forKey: .cacheReadTokens)
|
|
cacheWriteTokens = try c.decode(Int.self, forKey: .cacheWriteTokens)
|
|
topModels = try c.decodeIfPresent([DailyModelBreakdown].self, forKey: .topModels) ?? []
|
|
}
|
|
}
|
|
|
|
struct RetryTaxModelEntry: Codable, Sendable {
|
|
let name: String
|
|
let taxUSD: Double
|
|
let retries: Int
|
|
let retriesPerEdit: Double?
|
|
}
|
|
|
|
struct RetryTax: Codable, Sendable {
|
|
let totalUSD: Double
|
|
let retries: Int
|
|
let editTurns: Int
|
|
let byModel: [RetryTaxModelEntry]
|
|
}
|
|
|
|
struct RoutingWasteModelEntry: Codable, Sendable {
|
|
let name: String
|
|
let costPerEdit: Double
|
|
let editTurns: Int
|
|
let actualUSD: Double
|
|
let counterfactualUSD: Double
|
|
let savingsUSD: Double
|
|
}
|
|
|
|
struct RoutingWaste: Codable, Sendable {
|
|
let totalSavingsUSD: Double
|
|
let baselineModel: String
|
|
let baselineCostPerEdit: Double
|
|
let byModel: [RoutingWasteModelEntry]
|
|
}
|
|
|
|
struct CurrentBlock: Codable, Sendable {
|
|
let label: String
|
|
let cost: Double
|
|
let calls: Int
|
|
let sessions: Int
|
|
let oneShotRate: Double?
|
|
let inputTokens: Int
|
|
let outputTokens: Int
|
|
let cacheHitPercent: Double
|
|
/// Codex credits consumed in the period (nil on payloads from older builds).
|
|
let codexCredits: Double?
|
|
let topActivities: [ActivityEntry]
|
|
let topModels: [ModelEntry]
|
|
let localModelSavings: LocalModelSavings
|
|
let providers: [String: Double]
|
|
let topProjects: [ProjectEntry]
|
|
let modelEfficiency: [ModelEfficiencyEntry]
|
|
let topSessions: [TopSessionEntry]
|
|
let retryTax: RetryTax
|
|
let routingWaste: RoutingWaste
|
|
let tools: [ToolEntry]
|
|
let skills: [SkillEntry]
|
|
let subagents: [SubagentEntry]
|
|
let mcpServers: [McpServerEntry]
|
|
}
|
|
|
|
extension CurrentBlock {
|
|
enum CodingKeys: String, CodingKey {
|
|
case label, cost, calls, sessions, oneShotRate, inputTokens, outputTokens,
|
|
cacheHitPercent, codexCredits, topActivities, topModels, localModelSavings, providers, topProjects,
|
|
modelEfficiency, topSessions, retryTax, routingWaste,
|
|
tools, skills, subagents, mcpServers
|
|
}
|
|
init(from decoder: Decoder) throws {
|
|
let c = try decoder.container(keyedBy: CodingKeys.self)
|
|
label = try c.decode(String.self, forKey: .label)
|
|
cost = try c.decode(Double.self, forKey: .cost)
|
|
calls = try c.decode(Int.self, forKey: .calls)
|
|
sessions = try c.decode(Int.self, forKey: .sessions)
|
|
oneShotRate = try c.decodeIfPresent(Double.self, forKey: .oneShotRate)
|
|
inputTokens = try c.decode(Int.self, forKey: .inputTokens)
|
|
outputTokens = try c.decode(Int.self, forKey: .outputTokens)
|
|
cacheHitPercent = try c.decodeIfPresent(Double.self, forKey: .cacheHitPercent) ?? 0
|
|
codexCredits = try c.decodeIfPresent(Double.self, forKey: .codexCredits)
|
|
topActivities = try c.decodeIfPresent([ActivityEntry].self, forKey: .topActivities) ?? []
|
|
topModels = try c.decodeIfPresent([ModelEntry].self, forKey: .topModels) ?? []
|
|
localModelSavings = try c.decodeIfPresent(LocalModelSavings.self, forKey: .localModelSavings) ?? LocalModelSavings(totalUSD: 0, calls: 0, byModel: [], byProvider: [])
|
|
providers = try c.decodeIfPresent([String: Double].self, forKey: .providers) ?? [:]
|
|
topProjects = try c.decodeIfPresent([ProjectEntry].self, forKey: .topProjects) ?? []
|
|
modelEfficiency = try c.decodeIfPresent([ModelEfficiencyEntry].self, forKey: .modelEfficiency) ?? []
|
|
topSessions = try c.decodeIfPresent([TopSessionEntry].self, forKey: .topSessions) ?? []
|
|
retryTax = try c.decodeIfPresent(RetryTax.self, forKey: .retryTax) ?? RetryTax(totalUSD: 0, retries: 0, editTurns: 0, byModel: [])
|
|
routingWaste = try c.decodeIfPresent(RoutingWaste.self, forKey: .routingWaste) ?? RoutingWaste(totalSavingsUSD: 0, baselineModel: "", baselineCostPerEdit: 0, byModel: [])
|
|
tools = try c.decodeIfPresent([ToolEntry].self, forKey: .tools) ?? []
|
|
skills = try c.decodeIfPresent([SkillEntry].self, forKey: .skills) ?? []
|
|
subagents = try c.decodeIfPresent([SubagentEntry].self, forKey: .subagents) ?? []
|
|
mcpServers = try c.decodeIfPresent([McpServerEntry].self, forKey: .mcpServers) ?? []
|
|
}
|
|
}
|
|
|
|
struct LocalModelSavingsByModel: Codable, Sendable {
|
|
let name: String
|
|
let calls: Int
|
|
let actualUSD: Double
|
|
let savingsUSD: Double
|
|
let baselineModel: String
|
|
let inputTokens: Int
|
|
let outputTokens: Int
|
|
}
|
|
|
|
struct LocalModelSavingsByProvider: Codable, Sendable {
|
|
let name: String
|
|
let calls: Int
|
|
let savingsUSD: Double
|
|
}
|
|
|
|
struct LocalModelSavings: Codable, Sendable {
|
|
let totalUSD: Double
|
|
let calls: Int
|
|
let byModel: [LocalModelSavingsByModel]
|
|
let byProvider: [LocalModelSavingsByProvider]
|
|
}
|
|
|
|
struct ActivityEntry: Codable, Sendable {
|
|
let name: String
|
|
let cost: Double
|
|
let savingsUSD: Double
|
|
let turns: Int
|
|
let oneShotRate: Double?
|
|
|
|
init(from decoder: Decoder) throws {
|
|
let c = try decoder.container(keyedBy: CodingKeys.self)
|
|
name = try c.decode(String.self, forKey: .name)
|
|
cost = try c.decode(Double.self, forKey: .cost)
|
|
savingsUSD = try c.decodeIfPresent(Double.self, forKey: .savingsUSD) ?? 0
|
|
turns = try c.decode(Int.self, forKey: .turns)
|
|
oneShotRate = try c.decodeIfPresent(Double.self, forKey: .oneShotRate)
|
|
}
|
|
|
|
private enum CodingKeys: String, CodingKey {
|
|
case name, cost, savingsUSD, turns, oneShotRate
|
|
}
|
|
}
|
|
|
|
struct ModelEntry: Codable, Sendable {
|
|
let name: String
|
|
let cost: Double
|
|
let savingsUSD: Double
|
|
let savingsBaselineModel: String
|
|
let calls: Int
|
|
|
|
init(from decoder: Decoder) throws {
|
|
let c = try decoder.container(keyedBy: CodingKeys.self)
|
|
name = try c.decode(String.self, forKey: .name)
|
|
cost = try c.decode(Double.self, forKey: .cost)
|
|
savingsUSD = try c.decodeIfPresent(Double.self, forKey: .savingsUSD) ?? 0
|
|
savingsBaselineModel = try c.decodeIfPresent(String.self, forKey: .savingsBaselineModel) ?? ""
|
|
calls = try c.decode(Int.self, forKey: .calls)
|
|
}
|
|
|
|
private enum CodingKeys: String, CodingKey {
|
|
case name, cost, savingsUSD, savingsBaselineModel, calls
|
|
}
|
|
}
|
|
|
|
struct SessionModelEntry: Codable, Sendable {
|
|
let name: String
|
|
let cost: Double
|
|
let savingsUSD: Double
|
|
|
|
init(from decoder: Decoder) throws {
|
|
let c = try decoder.container(keyedBy: CodingKeys.self)
|
|
name = try c.decode(String.self, forKey: .name)
|
|
cost = try c.decode(Double.self, forKey: .cost)
|
|
savingsUSD = try c.decodeIfPresent(Double.self, forKey: .savingsUSD) ?? 0
|
|
}
|
|
|
|
private enum CodingKeys: String, CodingKey {
|
|
case name, cost, savingsUSD
|
|
}
|
|
}
|
|
|
|
struct SessionDetailEntry: Codable, Sendable {
|
|
let cost: Double
|
|
let savingsUSD: Double
|
|
let calls: Int
|
|
let inputTokens: Int
|
|
let outputTokens: Int
|
|
let date: String
|
|
let models: [SessionModelEntry]
|
|
|
|
init(from decoder: Decoder) throws {
|
|
let c = try decoder.container(keyedBy: CodingKeys.self)
|
|
cost = try c.decode(Double.self, forKey: .cost)
|
|
savingsUSD = try c.decodeIfPresent(Double.self, forKey: .savingsUSD) ?? 0
|
|
calls = try c.decode(Int.self, forKey: .calls)
|
|
inputTokens = try c.decode(Int.self, forKey: .inputTokens)
|
|
outputTokens = try c.decode(Int.self, forKey: .outputTokens)
|
|
date = try c.decode(String.self, forKey: .date)
|
|
models = try c.decodeIfPresent([SessionModelEntry].self, forKey: .models) ?? []
|
|
}
|
|
|
|
private enum CodingKeys: String, CodingKey {
|
|
case cost, savingsUSD, calls, inputTokens, outputTokens, date, models
|
|
}
|
|
}
|
|
|
|
struct ProjectEntry: Codable, Sendable {
|
|
let name: String
|
|
let cost: Double
|
|
let savingsUSD: Double
|
|
let sessions: Int
|
|
let avgCostPerSession: Double
|
|
let sessionDetails: [SessionDetailEntry]
|
|
|
|
init(from decoder: Decoder) throws {
|
|
let c = try decoder.container(keyedBy: CodingKeys.self)
|
|
name = try c.decode(String.self, forKey: .name)
|
|
cost = try c.decode(Double.self, forKey: .cost)
|
|
savingsUSD = try c.decodeIfPresent(Double.self, forKey: .savingsUSD) ?? 0
|
|
sessions = try c.decode(Int.self, forKey: .sessions)
|
|
avgCostPerSession = try c.decode(Double.self, forKey: .avgCostPerSession)
|
|
sessionDetails = try c.decodeIfPresent([SessionDetailEntry].self, forKey: .sessionDetails) ?? []
|
|
}
|
|
|
|
private enum CodingKeys: String, CodingKey {
|
|
case name, cost, savingsUSD, sessions, avgCostPerSession, sessionDetails
|
|
}
|
|
}
|
|
|
|
struct ModelEfficiencyEntry: Codable, Sendable {
|
|
let name: String
|
|
let costPerEdit: Double?
|
|
let oneShotRate: Double?
|
|
}
|
|
|
|
struct TopSessionEntry: Codable, Sendable {
|
|
let project: String
|
|
let cost: Double
|
|
let savingsUSD: Double
|
|
let calls: Int
|
|
let date: String
|
|
|
|
init(from decoder: Decoder) throws {
|
|
let c = try decoder.container(keyedBy: CodingKeys.self)
|
|
project = try c.decode(String.self, forKey: .project)
|
|
cost = try c.decode(Double.self, forKey: .cost)
|
|
savingsUSD = try c.decodeIfPresent(Double.self, forKey: .savingsUSD) ?? 0
|
|
calls = try c.decode(Int.self, forKey: .calls)
|
|
date = try c.decode(String.self, forKey: .date)
|
|
}
|
|
|
|
private enum CodingKeys: String, CodingKey {
|
|
case project, cost, savingsUSD, calls, date
|
|
}
|
|
}
|
|
|
|
struct ToolEntry: Codable, Sendable {
|
|
let name: String
|
|
let calls: Int
|
|
}
|
|
|
|
struct SkillEntry: Codable, Sendable {
|
|
let name: String
|
|
let turns: Int
|
|
let cost: Double
|
|
}
|
|
|
|
struct SubagentEntry: Codable, Sendable {
|
|
let name: String
|
|
let calls: Int
|
|
let cost: Double
|
|
}
|
|
|
|
struct McpServerEntry: Codable, Sendable {
|
|
let name: String
|
|
let calls: Int
|
|
}
|
|
|
|
struct OptimizeBlock: Codable, Sendable {
|
|
let findingCount: Int
|
|
let savingsUSD: Double
|
|
let topFindings: [FindingEntry]
|
|
}
|
|
|
|
struct FindingEntry: Codable, Sendable {
|
|
let title: String
|
|
let impact: String
|
|
let savingsUSD: Double
|
|
}
|
|
|
|
// MARK: - Empty fallback
|
|
|
|
extension MenubarPayload {
|
|
/// Strictly-empty payload. Used as the fallback before real data arrives, so no
|
|
/// plausible-looking fake numbers leak into the UI.
|
|
static let empty = MenubarPayload(
|
|
generated: "",
|
|
current: CurrentBlock(
|
|
label: "",
|
|
cost: 0,
|
|
calls: 0,
|
|
sessions: 0,
|
|
oneShotRate: nil,
|
|
inputTokens: 0,
|
|
outputTokens: 0,
|
|
cacheHitPercent: 0,
|
|
codexCredits: nil,
|
|
topActivities: [],
|
|
topModels: [],
|
|
localModelSavings: LocalModelSavings(totalUSD: 0, calls: 0, byModel: [], byProvider: []),
|
|
providers: [:],
|
|
topProjects: [],
|
|
modelEfficiency: [],
|
|
topSessions: [],
|
|
retryTax: RetryTax(totalUSD: 0, retries: 0, editTurns: 0, byModel: []),
|
|
routingWaste: RoutingWaste(totalSavingsUSD: 0, baselineModel: "", baselineCostPerEdit: 0, byModel: []),
|
|
tools: [],
|
|
skills: [],
|
|
subagents: [],
|
|
mcpServers: []
|
|
),
|
|
optimize: OptimizeBlock(findingCount: 0, savingsUSD: 0, topFindings: []),
|
|
history: HistoryBlock(daily: []),
|
|
combined: nil,
|
|
claudeConfigs: nil
|
|
)
|
|
}
|