codeburn/mac/Sources/CodeBurnMenubar/Data/MenubarPayload.swift
AgentSeal 6b6b6b8df6 feat(menubar): Claude config selector, hardened (supersedes #635)
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.
2026-07-10 01:44:51 +02:00

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
)
}