diff --git a/CHANGELOG.md b/CHANGELOG.md index ed5e542..1fc3d67 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,25 @@ All notable changes to this project are documented here. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project follows [Semantic Versioning](https://semver.org/). +## [Unreleased] + +### Added + +- **Multi-account meters for Codex and Claude.** People who hold two or more + accounts with the same tool get one independent meter row per account + instead of a merge — two accounts' windows are two different budgets. Add + accounts in Settings → Second accounts (name + config directory, any + number, add or remove freely); readings appear after relaunch once the + directory exists. The Codex slot spawns its own `codex` subprocess with + that home (the CLI authenticates as that account; meterusage still never + touches any auth file); the Claude slot scans that account's own transcript + tree, plan file, and companion quota snapshot. Account rows carry your + label ("Codex · Work") and a digit beside the mark in the tray and side + notch, and the `meterusage json` report lists each account with its own + windows, plan, and reset credits plus an additive `account` label field. + No account identifier is ever read or displayed — labels are user-typed, + and each account's history, archive, and alerts are kept separate. + ## [0.2.37] - 2026-09-25 ### Fixed diff --git a/README.md b/README.md index 12cf4db..82c8687 100644 --- a/README.md +++ b/README.md @@ -22,6 +22,7 @@ **meterusage** brings all your AI coding allowances, token burn rates, and rate-limit reset timers together into a clean macOS menu bar item and an interactive floating side-notch HUD. * **Broad Provider Coverage**: OpenAI Codex, Google Antigravity, Claude Code, OpenRouter, Grok, OpenCode Go, Cursor, GitHub Copilot CLI, and Google Gemini CLI. +* **Second-Account Meters**: Hold two Codex or Claude Code accounts? Each one gets its own independent meter row — own quota windows, plan badge, history, and alerts — instead of a merged guess. See [Second accounts](#second-accounts). * **Ambient Time-to-Empty**: Live depletion velocity against reset deadlines (`~47m left at current pace` / `Paced to last until reset`) directly in the menu bar and side notch. * **Burn Attribution & Context Waste**: Token breakdown by project, model, and turns over the last 7 days across every token-bearing provider, plus cache-hit efficiency % and long-chat flags. * **Durable Daily History**: Local summary store surviving CLI transcript purges and session cleanup. @@ -92,6 +93,8 @@ Toggle providers on or off, choose refresh cadence, and switch themes: *Claude publishes no public quota API; meterusage reads an on-disk JSON snapshot if a local companion writes one (see [docs/COMPANION.md](docs/COMPANION.md) and [`Scripts/claude-companion.sh`](Scripts/claude-companion.sh)). +Second accounts for Codex and Claude use the same mechanisms pointed at the alternate account's config directory (see [Second accounts](#second-accounts) and [docs/adr/0005](docs/adr/0005-multi-account-slots.md)). + --- ## 🚀 Key Features @@ -108,6 +111,24 @@ Toggle providers on or off, choose refresh cadence, and switch themes: * **Opt-In Pacing Alerts** — Native macOS notifications when an active window crosses critical burn velocity or drops below 30 minutes to empty. Pace alerts fire only on a current burn; threshold alerts (80%/95%) remain state-based. * **Share screenshot**: The share button on each provider's usage card (side notch panel detail card) shares a sharp 2x image of the panel through macOS share services, or saves it for X and other apps. +### Second accounts + +Two or more accounts with the same tool are separate budgets, so meterusage +gives each one its own meter row — own quota windows, plan badge, session +history, alerts, and (for Codex) reset credits. Add as many as you need: +**Settings → Second accounts → "Add Codex/Claude account"**, then name the +account and point it at that account's own config directory (e.g. +`~/.codex-work`, `~/.claude-personal`). Sign the CLI in under that directory +(`CODEX_HOME=~/.codex-work codex login`, or run Claude Code with +`CLAUDE_CONFIG_DIR=~/.claude-personal`), and relaunch meterusage — readings +appear once the directory exists. Removing a row stops metering that account; +nothing in the directory is deleted. + +Slots are named by your own label ("Codex · Work", a digit beside the mark in +the tray and notch); no account identifier is ever read or displayed. Each +account stays under its own keys in the Agent Budget API, which reports the +label in an additive `account` field. + --- ## 🔒 Privacy & Architecture diff --git a/Sources/MeterUsage/App/AppDelegate.swift b/Sources/MeterUsage/App/AppDelegate.swift index 448fe4c..33272f8 100644 --- a/Sources/MeterUsage/App/AppDelegate.swift +++ b/Sources/MeterUsage/App/AppDelegate.swift @@ -34,7 +34,6 @@ final class AppDelegate: NSObject, NSApplicationDelegate { preferences: preferences, isDemoMode: Composition.isDemoMode, quotaSources: quotaSources, - resetConsumer: quotaSources.compactMap { $0 as? QuotaResetConsumer }.first, activitySources: Composition.activitySources(), usageSources: Composition.usageSources(), statusSources: Composition.statusSources(), @@ -122,10 +121,10 @@ final class AppDelegate: NSObject, NSApplicationDelegate { static func tooltip(for coordinator: AppCoordinator) -> String { var lines: [String] = [] - for provider in coordinator.menuBarProviders { + for slot in coordinator.menuBarSlots { var parts: [String] = [] - if let display = coordinator.displayQuota(for: provider), - let window = provider.headlineWindow(from: display.quota.windows) { + if let display = coordinator.displayQuota(for: slot), + let window = slot.provider.headlineWindow(from: display.quota.windows) { parts.append("\(Fmt.percent(window.usedPercent)) used") if let resets = window.resetsAt, let until = Fmt.timeUntil(resets) { parts.append("resets in \(until)") @@ -134,14 +133,14 @@ static func tooltip(for coordinator: AppCoordinator) -> String { parts.append("last reading \(Fmt.timeSince(display.quota.capturedAt))") } } - if let service = coordinator.statuses[provider]?.value, + if let service = coordinator.status(for: slot.provider)?.value, service.severity != .operational { // Unknown reads as its own word, never silence: a check that // cannot report is information, not health. parts.append(service.severity.displayName) } if !parts.isEmpty { - lines.append("\(provider.displayName): \(parts.joined(separator: " · "))") + lines.append("\(slot.displayName): \(parts.joined(separator: " · "))") } } @@ -265,9 +264,17 @@ enum Composition { static func quotaSources() -> [QuotaSource] { if isDemoMode { - return [DemoClaudeQuotaSource(), DemoCodexQuotaSource(), DemoOpenRouterQuotaSource(), DemoOpenCodeGoQuotaSource(), DemoGrokQuotaSource(), DemoAntigravityQuotaSource()] + // Demo mounts a second account per tool, so multi-account rows + // get real screenshot coverage. All numbers stay synthetic. + return [ + DemoClaudeQuotaSource(), + DemoClaudeQuotaSource(slot: Self.demoSlot(.claude)), + DemoCodexQuotaSource(), + DemoCodexQuotaSource(slot: Self.demoSlot(.codex)), + DemoOpenRouterQuotaSource(), DemoOpenCodeGoQuotaSource(), DemoGrokQuotaSource(), DemoAntigravityQuotaSource() + ] } - return [ + var sources: [QuotaSource] = [ // Live Codex limits, read over the CLI's local RPC. CodexQuotaSource(), // OpenRouter key spend and optional limit, read from its @@ -293,12 +300,84 @@ enum Composition { // Google Gemini CLI quota and accounts GeminiQuotaSource() ] + // Additional accounts, one source set per configured directory. A + // Codex account's source spawns the CLI with that home, so the + // subprocess authenticates as that account; meterusage still never + // touches any auth file. A Claude account reads its companion- + // written quota snapshot inside its own directory; absence is the + // normal `.noData` state, exactly like the primary slot. + for account in managedAccounts() { + guard let home = ManagedAccountPaths.home(for: account) else { continue } + switch account.provider { + case .codex: + sources.append( + CodexQuotaSource( + slot: account.slot, + client: SubprocessJSONRPCClient(codexHome: home) + ) + ) + case .claude: + sources.append( + OptionalQuotaFileSource( + slot: account.slot, + candidatePaths: [ + home.appendingPathComponent("meterusage-usage.json"), + home.appendingPathComponent("claudewatch-usage.json"), + ] + ) + ) + default: + // Only tools with per-account config directories mount extra + // slots; other providers stay single-account. + continue + } + } + return sources } static func activitySources() -> [LocalActivitySource] { - isDemoMode - ? [DemoLocalActivitySource(), DemoCodexActivitySource()] - : [ClaudeLocalSource(), CodexLocalSource()] + if isDemoMode { + return [ + DemoLocalActivitySource(), + DemoLocalActivitySource(slot: Self.demoSlot(.claude)), + DemoCodexActivitySource(), + DemoCodexActivitySource(slot: Self.demoSlot(.codex)) + ] + } + var sources: [LocalActivitySource] = [ClaudeLocalSource(), CodexLocalSource()] + for account in managedAccounts() { + guard let home = ManagedAccountPaths.home(for: account) else { continue } + switch account.provider { + case .codex: + // The account's rollout store lives inside its own home; the + // rollout format is the primary account's, relocated. + sources.append( + CodexLocalSource(slot: account.slot, root: home.appendingPathComponent("sessions", isDirectory: true)) + ) + case .claude: + // A distinct scan cache per slot: two concurrent scans sharing + // one cache file would let the later writer drop the earlier + // scan's freshly-parsed entries. + sources.append( + ClaudeLocalSource( + slot: account.slot, + root: home.appendingPathComponent("projects", isDirectory: true), + cacheFileURL: Self.cacheDirectory + .appendingPathComponent("claude-local-scan-cache-\(account.id.prefix(8)).json") + ) + ) + default: + continue + } + } + return sources + } + + /// The directory this app is allowed to write its own caches into. + static var cacheDirectory: URL { + HomeDirectory.real + .appendingPathComponent("Library/Application Support", isDirectory: true) + .appendingPathComponent("MeterUsage", isDirectory: true) } static func usageSources() -> [UsageSource] { @@ -319,6 +398,10 @@ enum Composition { } static func statusSources() -> [StatusSource] { + // One check per service: alternate-account slots resolve their + // health through the base provider (`AppCoordinator.status(for:)`), + // so an outage tints both accounts without polling the same public + // feed twice. if isDemoMode { return [.codex, .claude].map { DemoStatusSource(provider: $0) } } @@ -330,7 +413,39 @@ enum Composition { /// Codex reports its plan inline with its quota, so it needs no source /// here; Claude's tier lives in local account metadata and needs its own. + /// An additional Claude account reads its tier from its own account file, + /// never falling back to the primary account's — a wrong-but-plausible + /// tier is worse than none. static func planSources() -> [PlanSource] { - isDemoMode ? [DemoPlanSource()] : [ClaudePlanSource()] + if isDemoMode { + return [ + DemoPlanSource(), + DemoPlanSource(slot: Self.demoSlot(.claude), tier: .max20x) + ] + } + var sources: [PlanSource] = [ClaudePlanSource()] + for account in managedAccounts() where account.provider == .claude { + guard let home = ManagedAccountPaths.home(for: account) else { continue } + sources.append( + ClaudePlanSource(slot: account.slot, fileURL: home.appendingPathComponent(".claude.json")) + ) + } + return sources + } + + /// The configured additional accounts, read straight from defaults: + /// composition runs before a `Preferences` instance exists, and the + /// headless CLI has none at all. + static func managedAccounts() -> [ManagedAccount] { + guard let data = UserDefaults.standard.data(forKey: PrefKey.managedAccounts), + let accounts = try? JSONDecoder().decode([ManagedAccount].self, from: data) + else { return [] } + return accounts + } + + /// The demo's synthetic second account per tool. Fixed id so demo state + /// stays stable across launches of the same run. + static func demoSlot(_ provider: Provider) -> ProviderSlot { + ProviderSlot(provider: provider, slotID: "demo-second", label: "Second account") } } diff --git a/Sources/MeterUsage/Core/AppCoordinator.swift b/Sources/MeterUsage/Core/AppCoordinator.swift index d06e521..697c707 100644 --- a/Sources/MeterUsage/Core/AppCoordinator.swift +++ b/Sources/MeterUsage/Core/AppCoordinator.swift @@ -32,22 +32,25 @@ final class AppCoordinator: ObservableObject { // MARK: Published state - @Published private(set) var quotas: [Provider: Loaded] = [:] - @Published private(set) var activities: [Provider: Loaded] = [:] - @Published private(set) var usages: [Provider: Loaded] = [:] + /// State per metered account slot. Primary slots key exactly like the + /// pre-slot maps; additional slots key on their own generated id, so two + /// accounts of one tool never overwrite each other. + @Published private(set) var quotas: [ProviderSlot: Loaded] = [:] + @Published private(set) var activities: [ProviderSlot: Loaded] = [:] + @Published private(set) var usages: [ProviderSlot: Loaded] = [:] @Published private(set) var statuses: [Provider: Loaded] = [:] /// Subscription tier per provider. Kept in its own map rather than folded /// into `quotas` because a plan is read from a different place than the /// quota (account metadata vs. rate-limit endpoint) and either can be /// present without the other. - @Published private(set) var plans: [Provider: Loaded] = [:] + @Published private(set) var plans: [ProviderSlot: Loaded] = [:] @Published private(set) var isRefreshing = false @Published private(set) var isClearingCache = false @Published private(set) var lastRefreshedAt: Date? /// Last good quota per provider, restored from disk at launch. Feeds only /// the ambient surfaces (notch, tray, tooltip) when live data is absent — /// always rendered dimmed and dated, never as a live number. - @Published private(set) var archivedQuotas: [Provider: ProviderQuota] = [:] + @Published private(set) var archivedQuotas: [ProviderSlot: ProviderQuota] = [:] private let quotaArchiveURL: URL /// Ticks once a minute purely so relative labels ("resets in 2h 14m") stay @@ -67,8 +70,12 @@ final class AppCoordinator: ObservableObject { // MARK: Sources private let quotaSources: [QuotaSource] - /// Optional because only Codex exposes an account-mutating reset action. - private let resetConsumer: QuotaResetConsumer? + /// Reset consumers per account slot. Optional because only Codex exposes + /// an account-mutating reset action, and per-slot because a second + /// Codex account's reset credits are redeemed against that account's own + /// subprocess (each slot's source holds its own client). A credit id can + /// only be redeemed by the slot whose quota reported it. + private let resetConsumers: [ProviderSlot: QuotaResetConsumer] /// Not `let`: clearing the cache replaces these instances (see /// `performCacheClear`), which is how a re-scan is made genuinely cold. private var activitySources: [LocalActivitySource] @@ -83,6 +90,10 @@ final class AppCoordinator: ObservableObject { /// Optional quota-alert delivery. `nil` in tests and any build that does /// not want notifications; the coordinator never depends on it. var quotaAlertService: QuotaAlertService? + /// Resolves where an additional account's config directory lives, for + /// presence checks. Injectable so tests can point slots at fixture + /// directories instead of the user's real home. + let accountHome: (ManagedAccount) -> URL? /// Optional update-availability checker. `nil` in demo builds (an update /// banner would spoil marketing screenshots) and tests. Its published /// state is forwarded to `objectWillChange` so the popover re-renders @@ -135,8 +146,8 @@ final class AppCoordinator: ObservableObject { return Double(min(exp, 30)) * 60 } - private static func backoffKey(kind: String, provider: Provider) -> String { - "\(kind)-\(provider.rawValue)" + private static func backoffKey(kind: String, slot: ProviderSlot) -> String { + "\(kind)-\(slot.key)" } init( @@ -144,17 +155,34 @@ final class AppCoordinator: ObservableObject { isDemoMode: Bool = false, quotaSources: [QuotaSource] = [], resetConsumer: QuotaResetConsumer? = nil, + resetConsumers: [ProviderSlot: QuotaResetConsumer] = [:], activitySources: [LocalActivitySource] = [], usageSources: [UsageSource] = [], statusSources: [StatusSource] = [], planSources: [PlanSource] = [], activitySourceFactory: (() -> [LocalActivitySource])? = nil, - quotaArchiveURL: URL? = nil + quotaArchiveURL: URL? = nil, + accountHome: ((ManagedAccount) -> URL?)? = nil ) { self.preferences = preferences self.isDemoMode = isDemoMode self.quotaSources = quotaSources - self.resetConsumer = resetConsumer + // Reset consumers are derived from the quota sources themselves: a + // source that can both read and mutate its slot owns that slot's + // resets, so the map builds itself as slots come and go. The explicit + // single-consumer parameter stays for tests and simpler call sites; + // a derived entry wins when both exist for the same slot. + var consumers: [ProviderSlot: QuotaResetConsumer] = resetConsumers + for source in quotaSources { + if let consumer = source as? QuotaResetConsumer { + consumers[source.slot] = consumer + } + } + if let resetConsumer { + let slot = Self.resetConsumerSlot(of: resetConsumer, sources: quotaSources) + if consumers[slot] == nil { consumers[slot] = resetConsumer } + } + self.resetConsumers = consumers self.activitySources = activitySources self.activitySourceFactory = activitySourceFactory self.usageSources = usageSources @@ -163,12 +191,26 @@ final class AppCoordinator: ObservableObject { let archiveURL = quotaArchiveURL ?? QuotaArchive.defaultURL self.quotaArchiveURL = archiveURL self.archivedQuotas = QuotaArchive.load(from: archiveURL) + // Default: the account's configured path, tilde-expanded against the + // real home. A nil result (blank path) reads as "not configured". + self.accountHome = accountHome ?? { ManagedAccountPaths.home(for: $0) } } // No `deinit`: one coordinator is created by the app delegate and lives for // the process lifetime, so there is nothing to tear down, and a nonisolated // `deinit` cannot touch main-actor state cleanly. + /// Which slot a reset consumer belongs to. A consumer that is itself a + /// quota source names its own slot; a bare stub (tests, demo) attaches to + /// the first Codex-family source, the only kind that can redeem resets. + private nonisolated static func resetConsumerSlot( + of consumer: QuotaResetConsumer, + sources: [QuotaSource] + ) -> ProviderSlot { + if let source = consumer as? QuotaSource { return source.slot } + return sources.first(where: { $0 is QuotaResetConsumer })?.slot ?? .primary(.codex) + } + // MARK: Lifecycle func start() { @@ -308,12 +350,13 @@ final class AppCoordinator: ObservableObject { if Date().timeIntervalSince(last) > maxAge { refresh() } } - /// Refreshes only one provider's quota, usage, status, and plan sources. + /// Refreshes only one metered slot's quota, usage, status, and plan + /// sources. /// /// An explicit per-ring request: it bypasses backoff like any other - /// user-initiated refresh, but never spends the other providers' + /// user-initiated refresh, but never spends the other slots' /// rate-limit budget. Used by the side notch panel's click-to-refresh. - func refresh(provider: Provider) { + func refresh(slot: ProviderSlot) { guard refreshTask == nil else { // A sweep is already running; fall back to a forced full refresh // afterwards rather than dropping the request. @@ -322,7 +365,7 @@ final class AppCoordinator: ObservableObject { } isRefreshing = true refreshTask = Task { [weak self] in - await self?.performRefresh(providers: [provider]) + await self?.performRefresh(slots: [slot]) self?.isRefreshing = false self?.lastRefreshedAt = Date() self?.refreshTask = nil @@ -333,19 +376,32 @@ final class AppCoordinator: ObservableObject { } } - /// Whether a Codex rate limit reset action is currently usable. - var canUseCodexReset: Bool { - resetConsumer != nil + /// Whether a Codex rate-limit reset action is currently usable for a + /// slot. Defaults to the primary account; an additional Codex account is + /// redeemable only through its own source's subprocess. + func canUseCodexReset(for slot: ProviderSlot = .primary(.codex)) -> Bool { + resetConsumers[slot] != nil } /// Performs the explicitly confirmed Codex reset and refreshes all data so /// the menu immediately reflects the provider's new limits and remaining - /// reset credits. A non-reset outcome is treated as unavailable rather than - /// presented as a successful mutation. - func consumeCodexReset(creditID: String) async throws { - guard let resetConsumer else { throw SourceUnavailable.failed(.codex) } - guard try await resetConsumer.consumeReset(creditID: creditID) else { - throw SourceUnavailable.failed(.codex) + /// reset credits. A non-reset outcome is treated as unavailable rather + /// than presented as a successful mutation. + /// + /// The reset is routed to the slot whose loaded quota reports the credit + /// id, so a second Codex account's credits are redeemed against that + /// account's own subprocess. When no loaded quota claims the id (credit + /// expired between sweeps), the requested slot's consumer is used, and a + /// single-account build keeps behaving exactly as before. + func consumeCodexReset(creditID: String, in slot: ProviderSlot = .primary(.codex)) async throws { + let claimedSlot = quotas.first(where: { _, state in + state.value?.resetCredits.contains { $0.id == creditID } == true + })?.key ?? slot + guard let consumer = resetConsumers[claimedSlot] ?? resetConsumers[slot] else { + throw SourceUnavailable.failed(claimedSlot.provider) + } + guard try await consumer.consumeReset(creditID: creditID) else { + throw SourceUnavailable.failed(claimedSlot.provider) } refresh() } @@ -358,30 +414,30 @@ final class AppCoordinator: ObservableObject { // the sweep costs as long as the slowest one, not their sum. let now = Date() await withTaskGroup(of: Void.self) { group in - for source in quotaSources where preferences.isEnabled(source.provider) { - if forceAll || !isBackedOff(kind: "quota", provider: source.provider, now: now) { + for source in quotaSources where showsSlot(source.slot) { + if forceAll || !isBackedOff(kind: "quota", slot: source.slot, now: now) { group.addTask { [weak self] in await self?.load(quota: source) } } } - for source in activitySources where preferences.isEnabled(source.provider) { - if forceAll || !isBackedOff(kind: "activity", provider: source.provider, now: now) { + for source in activitySources where showsSlot(source.slot) { + if forceAll || !isBackedOff(kind: "activity", slot: source.slot, now: now) { group.addTask { [weak self] in await self?.load(activity: source) } } } - for source in usageSources where preferences.isEnabled(source.provider) { - if forceAll || !isBackedOff(kind: "usage", provider: source.provider, now: now) { + for source in usageSources where showsSlot(source.slot) { + if forceAll || !isBackedOff(kind: "usage", slot: source.slot, now: now) { group.addTask { [weak self] in await self?.load(usage: source) } } } // Server health is independent of the provider visibility toggles: // hiding Claude usage should not hide the top-level health signal. for source in statusSources { - if forceAll || !isBackedOff(kind: "status", provider: source.provider, now: now) { + if forceAll || !isBackedOff(kind: "status", slot: source.statusSlot, now: now) { group.addTask { [weak self] in await self?.load(status: source) } } } - for source in planSources where preferences.isEnabled(source.provider) { - if forceAll || !isBackedOff(kind: "plan", provider: source.provider, now: now) { + for source in planSources where showsSlot(source.slot) { + if forceAll || !isBackedOff(kind: "plan", slot: source.slot, now: now) { group.addTask { [weak self] in await self?.load(plan: source) } } } @@ -391,58 +447,65 @@ final class AppCoordinator: ObservableObject { // not individual sources were skipped for backoff — a skipped source // simply keeps its previous reading. Pace alerts additionally require // a current burn, so the last-burn map rides along. - quotaAlertService?.process(quotas: quotas, lastBurn: lastBurnByProvider) + quotaAlertService?.process(quotas: quotas, lastBurn: lastBurnBySlot) // Build the machine-readable report and notify any snapshot listener. didPublishSnapshot?( LimitsReporter.build( quotas: quotas, - order: visibleQuotaProviders, - lastBurn: lastBurnByProvider, + order: visibleQuotaSlots, + lastBurn: lastBurnBySlot, now: clock)) saveArchive() } - /// Single-provider variant of the sweep above. Loads only the named - /// provider's sources; status sources stay included because hiding usage - /// must not hide the health signal. - private func performRefresh(providers: [Provider]) async { - let wanted = Set(providers) + /// Single-slot variant of the sweep above. Loads only the named slot's + /// sources; status sources stay included because hiding usage must not + /// hide the health signal. + private func performRefresh(slots: [ProviderSlot]) async { + let wanted = Set(slots) await withTaskGroup(of: Void.self) { group in - for source in quotaSources where wanted.contains(source.provider) && preferences.isEnabled(source.provider) { + for source in quotaSources where wanted.contains(source.slot) && showsSlot(source.slot) { group.addTask { [weak self] in await self?.load(quota: source) } } - for source in activitySources where wanted.contains(source.provider) && preferences.isEnabled(source.provider) { + for source in activitySources where wanted.contains(source.slot) && showsSlot(source.slot) { group.addTask { [weak self] in await self?.load(activity: source) } } - for source in usageSources where wanted.contains(source.provider) && preferences.isEnabled(source.provider) { + for source in usageSources where wanted.contains(source.slot) && showsSlot(source.slot) { group.addTask { [weak self] in await self?.load(usage: source) } } - for source in statusSources where wanted.contains(source.provider) { + for source in statusSources where wanted.contains(source.statusSlot) { group.addTask { [weak self] in await self?.load(status: source) } } - for source in planSources where wanted.contains(source.provider) && preferences.isEnabled(source.provider) { + for source in planSources where wanted.contains(source.slot) && showsSlot(source.slot) { group.addTask { [weak self] in await self?.load(plan: source) } } } clock = Date() - quotaAlertService?.process(quotas: quotas, lastBurn: lastBurnByProvider) + quotaAlertService?.process(quotas: quotas, lastBurn: lastBurnBySlot) didPublishSnapshot?( LimitsReporter.build( quotas: quotas, - order: visibleQuotaProviders, - lastBurn: lastBurnByProvider, + order: visibleQuotaSlots, + lastBurn: lastBurnBySlot, now: clock)) saveArchive() } + /// Service health for a slot. Alternate-account slots resolve to their + /// base provider's check: the service is shared across accounts, and + // only one status source exists per service. + func status(for provider: Provider) -> Loaded? { + statuses[provider] + } + /// The quota an ambient surface should render: the live reading when /// present, otherwise the archived last-good reading marked stale. `nil` /// only when neither exists — the honest "we do not know" case. - func displayQuota(for provider: Provider) -> (quota: ProviderQuota, isStale: Bool)? { - if let live = quotas[provider]?.value { + func displayQuota(for slot: ProviderSlot) -> (quota: ProviderQuota, isStale: Bool)? { + if let live = quotas[slot]?.value { return (live, false) } - if let remembered = archivedQuotas[provider] { + if let remembered = archivedQuotas[slot] { return (remembered, true) } return nil @@ -455,12 +518,12 @@ final class AppCoordinator: ObservableObject { /// Most recent observable burn per provider. Feeds the pace-honesty gate: /// alerts and the machine report may only claim "burning fast" while the /// provider burned inside the quiet period (see `BurnRecency`). - var lastBurnByProvider: [Provider: Date] { + var lastBurnBySlot: [ProviderSlot: Date] { BurnRecency.lastBurns(from: activities) } - private func isBackedOff(kind: String, provider: Provider, now: Date) -> Bool { - guard let backoff = backoffs[Self.backoffKey(kind: kind, provider: provider)] else { return false } + private func isBackedOff(kind: String, slot: ProviderSlot, now: Date) -> Bool { + guard let backoff = backoffs[Self.backoffKey(kind: kind, slot: slot)] else { return false } return now < backoff.nextAttemptAt } @@ -470,8 +533,8 @@ final class AppCoordinator: ObservableObject { /// yet — are facts about the machine, not outages, so they never back off: /// the user should see them resolve on the very next sweep after they fix /// the cause. - private func record(kind: String, provider: Provider, result: SourceUnavailable?) { - let key = Self.backoffKey(kind: kind, provider: provider) + private func record(kind: String, slot: ProviderSlot, result: SourceUnavailable?) { + let key = Self.backoffKey(kind: kind, slot: slot) guard let reason = result, Self.isTransient(reason) else { backoffs[key] = nil return @@ -495,12 +558,12 @@ final class AppCoordinator: ObservableObject { do { let quota = try await source.fetchQuota() result = .value(quota) - archivedQuotas[source.provider] = quota + archivedQuotas[source.slot] = quota } catch { - result = .missing(Self.reason(for: error, provider: source.provider)) + result = .missing(Self.reason(for: error, provider: source.slot.provider)) } - quotas[source.provider] = result - record(kind: "quota", provider: source.provider, result: result.unavailable) + quotas[source.slot] = result + record(kind: "quota", slot: source.slot, result: result.unavailable) } private func load(activity source: LocalActivitySource) async { @@ -508,9 +571,9 @@ final class AppCoordinator: ObservableObject { do { var activity = try await source.scan() if !isDemoMode { - let peak = quotas[source.provider]?.value?.windows.map(\.usedPercent).max() - DurableHistoryStore.shared.record(provider: source.provider, daily: activity.daily, peakUsedPercent: peak) - let durableDaily = DurableHistoryStore.shared.records(for: source.provider) + let peak = quotas[source.slot]?.value?.windows.map(\.usedPercent).max() + DurableHistoryStore.shared.record(key: source.slot.key, daily: activity.daily, peakUsedPercent: peak) + let durableDaily = DurableHistoryStore.shared.records(forKey: source.slot.key) if !durableDaily.isEmpty { var byDay: [String: DailyActivity] = [:] let dayFormatter = DateFormatter() @@ -540,10 +603,10 @@ final class AppCoordinator: ObservableObject { ? .missing(.noData) : .value(activity) } catch { - result = .missing(Self.reason(for: error, provider: source.provider)) + result = .missing(Self.reason(for: error, provider: source.slot.provider)) } - activities[source.provider] = result - record(kind: "activity", provider: source.provider, result: result.unavailable) + activities[source.slot] = result + record(kind: "activity", slot: source.slot, result: result.unavailable) } private func load(usage source: UsageSource) async { @@ -551,10 +614,10 @@ final class AppCoordinator: ObservableObject { do { result = .value(try await source.fetchUsage()) } catch { - result = .missing(Self.reason(for: error, provider: source.provider)) + result = .missing(Self.reason(for: error, provider: source.slot.provider)) } - usages[source.provider] = result - record(kind: "usage", provider: source.provider, result: result.unavailable) + usages[source.slot] = result + record(kind: "usage", slot: source.slot, result: result.unavailable) } private func load(status source: StatusSource) async { @@ -565,7 +628,7 @@ final class AppCoordinator: ObservableObject { result = .missing(Self.reason(for: error, provider: source.provider)) } statuses[source.provider] = result - record(kind: "status", provider: source.provider, result: result.unavailable) + record(kind: "status", slot: source.statusSlot, result: result.unavailable) } /// A plan we couldn't read is not worth a message. @@ -579,10 +642,10 @@ final class AppCoordinator: ObservableObject { do { result = .value(try await source.fetchPlan()) } catch { - result = .missing(Self.reason(for: error, provider: source.provider)) + result = .missing(Self.reason(for: error, provider: source.slot.provider)) } - plans[source.provider] = result - record(kind: "plan", provider: source.provider, result: result.unavailable) + plans[source.slot] = result + record(kind: "plan", slot: source.slot, result: result.unavailable) } /// Downloads, verifies, and installs the visible update, then relaunches. @@ -649,41 +712,69 @@ final class AppCoordinator: ObservableObject { // MARK: Derived state - /// Providers the user has switched on, in a stable display order. - var visibleProviders: [Provider] { - Provider.allCases.filter { preferences.isEnabled($0) } + /// Whether a slot's sources should be polled and shown: the primary + /// slot follows its provider toggle; an additional slot must be enabled + /// AND its config directory must exist — an additional account is a + /// directory the user configured, not a guess. Service status is polled + /// independently of both gates (see `performRefresh`), so hiding an + /// account never hides an outage. Demo mode mounts synthetic slots for + /// the additional accounts of the demoed tools regardless of either + /// gate — they exist to be screenshotted. + func showsSlot(_ slot: ProviderSlot) -> Bool { + if isDemoMode { + return slot.isPrimary ? preferences.isEnabled(slot.provider) : true + } + if slot.isPrimary { + return preferences.isEnabled(slot.provider) + } + guard let account = preferences.managedAccounts.first(where: { $0.id == slot.slotID }), + account.enabled, + accountHome(account) != nil else { return false } + var isDirectory: ObjCBool = false + return FileManager.default.fileExists(atPath: accountHome(account)!.path, isDirectory: &isDirectory) + && isDirectory.boolValue + } + + /// Metered slots the user has switched on, in a stable display order. + var visibleSlots: [ProviderSlot] { + (Provider.allCases.map { ProviderSlot.primary($0) } + additionalSlots).filter { showsSlot($0) } + } + + /// Additional configured slots, in the order they were added. + var additionalSlots: [ProviderSlot] { + preferences.managedAccounts.map(\.slot) } - var visibleQuotaProviders: [Provider] { - let providers = Set(quotaSources.map(\.provider)) - return visibleProviders.filter { providers.contains($0) } + var visibleQuotaSlots: [ProviderSlot] { + let slots = Set(quotaSources.map(\.slot)) + return visibleSlots.filter { slots.contains($0) } } - /// Enabled providers the user also chose to show in the menu bar, in the + /// Enabled slots the user also chose to show in the menu bar, in the /// stable display order. OpenRouter is pay-as-you-go (no quota), so it is /// always excluded from the tray regardless of the stored preference. - var menuBarProviders: [Provider] { - visibleProviders.filter { preferences.showsInMenuBar($0) && $0 != .openRouter } + var menuBarSlots: [ProviderSlot] { + visibleSlots.filter { preferences.showsInMenuBar($0.provider) && $0.provider != .openRouter } } - /// Enabled providers the user also chose to show in the side notch panel, + /// Enabled slots the user also chose to show in the side notch panel, /// in the stable display order. The tray and the notch are independent - /// surfaces (see `menuBarProviders`): the tray keeps OpenRouter out, while + /// surfaces (see `menuBarSlots`): the tray keeps OpenRouter out, while /// the notch honors its toggle — a configured key limit yields a real /// quota window for the ring to render. - var sideNotchProviders: [Provider] { - visibleProviders.filter { preferences.showsInMenuBar($0) } + var sideNotchSlots: [ProviderSlot] { + visibleSlots.filter { preferences.showsInMenuBar($0.provider) } } - var visibleActivityProviders: [Provider] { - let providers = Set(activitySources.map(\.provider)) - return visibleProviders.filter { providers.contains($0) } + var visibleActivitySlots: [ProviderSlot] { + let slots = Set(activitySources.map(\.slot)) + return visibleSlots.filter { slots.contains($0) } } var visibleUsageProviders: [Provider] { - let providers = Set(usageSources.map(\.provider)) - let quotaProviders = Set(visibleQuotaProviders) - return visibleProviders.filter { providers.contains($0) && !quotaProviders.contains($0) } + let providers = Set(usageSources.map(\.slot.provider)) + let quotaProviders = Set(visibleQuotaSlots.map(\.provider)) + return visibleSlots.map(\.provider).filter { providers.contains($0) && !quotaProviders.contains($0) } } var visibleStatusProviders: [Provider] { @@ -702,21 +793,24 @@ final class AppCoordinator: ObservableObject { } var combinedActivity: [LocalActivity] { - visibleActivityProviders.compactMap { activities[$0]?.value } + visibleActivitySlots.compactMap { activities[$0]?.value } } - /// Whose readings these are: the plan the provider reports (when it - /// reports one) and the tool holding the credential. No identity is - /// read — a plan tier is context for the percentages, never an account. + /// Whose readings these are: the plan the slot reports (when it reports + /// one) and the tool holding the credential. No identity is read — a + /// plan tier is context for the percentages, never an account. struct ProviderAccount { let plan: String? let via: String + /// The slot's own display name, so two accounts of one tool read as + /// two rows. + let name: String } - func account(for provider: Provider) -> ProviderAccount { - let plan = plans[provider]?.value?.displayName - ?? quotas[provider]?.value?.planType - return ProviderAccount(plan: plan, via: provider.sourceLabel) + func account(for slot: ProviderSlot) -> ProviderAccount { + let plan = plans[slot]?.value?.displayName + ?? quotas[slot]?.value?.planType + return ProviderAccount(plan: plan, via: slot.provider.sourceLabel, name: slot.displayName) } /// Sanitized diagnostics for the "Copy diagnostics" button, including @@ -729,7 +823,7 @@ final class AppCoordinator: ObservableObject { refreshInterval: preferences.refreshInterval, lastRefreshedAt: lastRefreshedAt, now: clock, - enabledProviders: visibleProviders, + enabledSlots: visibleSlots, quotas: quotas, activities: activities, usages: usages, diff --git a/Sources/MeterUsage/Core/CliJSON.swift b/Sources/MeterUsage/Core/CliJSON.swift index da1fdbb..a7337f5 100644 --- a/Sources/MeterUsage/Core/CliJSON.swift +++ b/Sources/MeterUsage/Core/CliJSON.swift @@ -36,9 +36,9 @@ enum CliMode { /// Scans one activity source down to its most recent observable burn. /// A source that cannot be read contributes nothing — no burn evidence, /// no pace claims for that provider. - private static func scanBurn(_ source: LocalActivitySource) async -> (Provider, Date?) { + private static func scanBurn(_ source: LocalActivitySource) async -> (ProviderSlot, Date?) { let at = (try? await source.scan()).flatMap { BurnRecency.lastBurn(of: $0.sessions) } - return (source.provider, at) + return (source.slot, at) } /// Polls every configured quota source concurrently and builds the report. @@ -51,31 +51,31 @@ enum CliMode { static func run() async -> LimitsReport { let sources = Composition.quotaSources() let activitySources = Composition.activitySources() - var loaded: [Provider: Loaded] = [:] - var lastBurn: [Provider: Date] = [:] - await withTaskGroup(of: (Provider, Loaded).self) { group in + var loaded: [ProviderSlot: Loaded] = [:] + var lastBurn: [ProviderSlot: Date] = [:] + await withTaskGroup(of: (ProviderSlot, Loaded).self) { group in for source in sources { group.addTask { await Self.fetchOne(source) } } - for await (provider, result) in group { - loaded[provider] = result + for await (slot, result) in group { + loaded[slot] = result } } - await withTaskGroup(of: (Provider, Date?).self) { group in + await withTaskGroup(of: (ProviderSlot, Date?).self) { group in for source in activitySources { group.addTask { await Self.scanBurn(source) } } - for await (provider, at) in group { + for await (slot, at) in group { if let at { - lastBurn[provider] = at + lastBurn[slot] = at } } } - let order = sources.map(\.provider) + let order = sources.map(\.slot) return LimitsReporter.build( quotas: loaded, order: order, @@ -87,18 +87,18 @@ enum CliMode { /// One source poll, collapsed to the same `Loaded` shape the coordinator /// stores, with a timeout guard so a wedged CLI subprocess cannot hang a /// calling script. - static func fetchOne(_ source: QuotaSource) async -> (Provider, Loaded) { + static func fetchOne(_ source: QuotaSource) async -> (ProviderSlot, Loaded) { do { let quota = try await withTimeout(sourceTimeout) { try await source.fetchQuota() } - return (source.provider, .value(quota)) + return (source.slot, .value(quota)) } catch let reason as SourceUnavailable { - return (source.provider, .missing(reason)) + return (source.slot, .missing(reason)) } catch { // Deliberately opaque, matching the coordinator's rule: raw errors // can echo URLs, headers, or account hints. - return (source.provider, .missing(.failed(source.provider))) + return (source.slot, .missing(.failed(source.slot.provider))) } } } diff --git a/Sources/MeterUsage/Core/LimitsReport.swift b/Sources/MeterUsage/Core/LimitsReport.swift index 10dc523..47f0367 100644 --- a/Sources/MeterUsage/Core/LimitsReport.swift +++ b/Sources/MeterUsage/Core/LimitsReport.swift @@ -35,6 +35,10 @@ struct LimitsReport: Equatable, Sendable, Codable { struct ProviderReport: Equatable, Sendable, Codable { var provider: String + /// The user's name for the metered account, present only for additional + /// accounts (a second Codex login, for example). Primary slots omit it, + /// so existing consumers keyed on `provider` see no change. + var account: String? /// "ok" when windows or credits were read; "unavailable" otherwise. var status: String /// Present only when status is "unavailable"; verbatim from @@ -44,10 +48,11 @@ struct ProviderReport: Equatable, Sendable, Codable { var windows: [WindowReport] var credits: CreditsReport? - init(provider: String, status: String, reason: String? = nil, + init(provider: String, account: String? = nil, status: String, reason: String? = nil, plan: String? = nil, windows: [WindowReport] = [], credits: CreditsReport? = nil) { self.provider = provider + self.account = account self.status = status self.reason = reason self.plan = plan @@ -149,13 +154,13 @@ enum LimitsReporter { /// burn that stopped days ago. Pacing is reported as on-pace for those /// quiet windows; percent-used figures are state claims and stay raw. static func build( - quotas: [Provider: Loaded], - order: [Provider], - lastBurn: [Provider: Date] = [:], + quotas: [ProviderSlot: Loaded], + order: [ProviderSlot], + lastBurn: [ProviderSlot: Date] = [:], now: Date = Date() ) -> LimitsReport { - let providers = order.map { provider -> ProviderReport in - switch quotas[provider] { + let providers = order.map { slot -> ProviderReport in + switch quotas[slot] { case .value(let quota): // The report is the glance-level usage view (tray, widget, // CLI), so it carries the provider's regular allowance @@ -164,11 +169,12 @@ enum LimitsReporter { // regular usage the tray does, never a model-specific limit. let windows = quota.windows return ProviderReport( - provider: provider.rawValue, + provider: slot.provider.rawValue, + account: slot.isPrimary ? nil : slot.label, status: "ok", plan: quota.planType, windows: windows.map { - WindowReport.from($0, lastBurn: lastBurn[provider], now: now) + WindowReport.from($0, lastBurn: lastBurn[slot], now: now) }, credits: quota.credits.map { CreditsReport( @@ -180,14 +186,19 @@ enum LimitsReporter { ) case .missing(let reason): return ProviderReport( - provider: provider.rawValue, + provider: slot.provider.rawValue, + account: slot.isPrimary ? nil : slot.label, status: "unavailable", reason: reason.userFacingMessage ) case .idle, .none: // Never refreshed — indistinguishable to a reader from a // source we haven't polled yet, which is exactly what it is. - return ProviderReport(provider: provider.rawValue, status: "unavailable") + return ProviderReport( + provider: slot.provider.rawValue, + account: slot.isPrimary ? nil : slot.label, + status: "unavailable" + ) } } return LimitsReport(generatedAt: now, providers: providers) diff --git a/Sources/MeterUsage/Core/Preferences.swift b/Sources/MeterUsage/Core/Preferences.swift index e47a6a4..7cdc738 100644 --- a/Sources/MeterUsage/Core/Preferences.swift +++ b/Sources/MeterUsage/Core/Preferences.swift @@ -20,6 +20,10 @@ enum PrefKey { static let refreshInterval = "refreshIntervalSeconds" static let showClaude = "showProviderClaude" static let showCodex = "showProviderCodex" + /// User-configured additional accounts (Claude, Codex). Encoded + /// `[ManagedAccount]` JSON data; empty/absent means "no additional + /// accounts", which is the single-account default install. + static let managedAccounts = "managedAccounts" static let showAntigravity = "showProviderAntigravity" static let showGrok = "showProviderGrok" static let showOpenCodeGo = "showProviderOpenCodeGo" @@ -130,6 +134,11 @@ final class Preferences: ObservableObject { @Published private(set) var showActivityTelemetry: Bool = true @Published private(set) var showDailyActivityChart: Bool = true @Published private(set) var showSideNotchResetButton: Bool = true + /// Additional, user-configured accounts per tool. Unbounded on purpose: + /// a user with three Codex logins adds three rows and the app meters + /// each. Sources are built from this list at launch (see `Composition`), + /// so edits apply after relaunch. + @Published private(set) var managedAccounts: [ManagedAccount] = [] private let defaults: UserDefaults private var observer: NSObjectProtocol? @@ -264,6 +273,48 @@ final class Preferences: ObservableObject { let sideNotchReset = defaults.object(forKey: PrefKey.showSideNotchResetButton) == nil ? true : defaults.bool(forKey: PrefKey.showSideNotchResetButton) if sideNotchReset != showSideNotchResetButton { showSideNotchResetButton = sideNotchReset } + + // Managed accounts: absent key means "no additional accounts", the + // everyday single-account case. A malformed payload degrades to the + // empty list rather than throwing away the rest of the settings. + let accounts: [ManagedAccount] = { + guard let data = defaults.data(forKey: PrefKey.managedAccounts), + let decoded = try? JSONDecoder().decode([ManagedAccount].self, from: data) + else { return [] } + return decoded + }() + if accounts != managedAccounts { managedAccounts = accounts } + } + + // MARK: Additional accounts + + /// Adds one additional account. The list is the single source of truth + /// for which additional meters exist; composition reads it at launch. + func add(account: ManagedAccount) { + managedAccounts.append(account) + persistAccounts() + } + + /// Replaces one account (label, path, or enabled flag changed). Identity + /// (`id`) never changes, so history and archive keys survive edits. + func update(account: ManagedAccount) { + guard let index = managedAccounts.firstIndex(where: { $0.id == account.id }) else { return } + managedAccounts[index] = account + persistAccounts() + } + + /// Removes one additional account. Its readings simply stop; nothing is + /// deleted from the tool's own directory, and persisted history for the + /// removed slot key is left in place so re-adding the same directory + /// (same generated id) keeps its past. Unknown ids are a no-op. + func remove(accountID: String) { + managedAccounts.removeAll { $0.id == accountID } + persistAccounts() + } + + private func persistAccounts() { + guard let data = try? JSONEncoder().encode(managedAccounts) else { return } + defaults.set(data, forKey: PrefKey.managedAccounts) } func isEnabled(_ provider: Provider) -> Bool { enabledProviders.contains(provider) } diff --git a/Sources/MeterUsage/Core/QuotaAlerts.swift b/Sources/MeterUsage/Core/QuotaAlerts.swift index 2ddd269..74a58a4 100644 --- a/Sources/MeterUsage/Core/QuotaAlerts.swift +++ b/Sources/MeterUsage/Core/QuotaAlerts.swift @@ -28,23 +28,24 @@ enum QuotaAlertThreshold: Int, CaseIterable { } } -/// One alertable observation, produced by the evaluator. +/// One alertable observation, produced by the evaluator. Events name the +/// metered slot, so two accounts of one tool alert (and re-arm) separately. enum QuotaAlertEvent: Equatable { /// A window crossed `threshold` upward this refresh. - case threshold(provider: Provider, windowLabel: String, usedPercent: Double, threshold: QuotaAlertThreshold) + case threshold(slot: ProviderSlot, windowLabel: String, usedPercent: Double, threshold: QuotaAlertThreshold) /// Burning fast and projected to run out in < 30 minutes before reset. - case paceCliff(provider: Provider, windowLabel: String, etaMinutes: Int) + case paceCliff(slot: ProviderSlot, windowLabel: String, etaMinutes: Int) /// Soft warning when burning fast past 50% window consumption. - case paceSoftWarning(provider: Provider, windowLabel: String, usedPercent: Double) + case paceSoftWarning(slot: ProviderSlot, windowLabel: String, usedPercent: Double) /// A reset credit will expire within `ExpiringCredit.window`. - case expiringCredit(provider: Provider, creditID: String, creditTitle: String) + case expiringCredit(slot: ProviderSlot, creditID: String, creditTitle: String) - var provider: Provider { + var slot: ProviderSlot { switch self { - case .threshold(let provider, _, _, _): return provider - case .paceCliff(let provider, _, _): return provider - case .paceSoftWarning(let provider, _, _): return provider - case .expiringCredit(let provider, _, _): return provider + case .threshold(let slot, _, _, _): return slot + case .paceCliff(let slot, _, _): return slot + case .paceSoftWarning(let slot, _, _): return slot + case .expiringCredit(let slot, _, _): return slot } } } @@ -79,17 +80,17 @@ struct QuotaAlertEvaluator { /// ungated. Providers absent from the map lose pace alerts but keep /// threshold alerts. mutating func events( - for quotas: [Provider: Loaded], - lastBurn: [Provider: Date] = [:], + for quotas: [ProviderSlot: Loaded], + lastBurn: [ProviderSlot: Date] = [:], now: Date ) -> [QuotaAlertEvent] { var events: [QuotaAlertEvent] = [] - for (provider, state) in quotas { + for (slot, state) in quotas { guard let quota = state.value else { continue } for window in quota.windows { - let key = "\(provider.rawValue)/\(window.label)" + let key = "\(slot.key)/\(window.label)" let previous = highWater[key] ?? 0 // A drop below the last alerted threshold re-arms the ladder: @@ -106,7 +107,7 @@ struct QuotaAlertEvaluator { let threshold = QuotaAlertThreshold.allCases.last(where: { window.usedPercent >= Double($0.rawValue) }), rearmedPrevious < Double(threshold.rawValue) { events.append(.threshold( - provider: provider, + slot: slot, windowLabel: window.label, usedPercent: window.usedPercent, threshold: threshold @@ -117,13 +118,13 @@ struct QuotaAlertEvaluator { // Pace-based checks. The effective pace demotes a deficit // whose burn has gone quiet, so soft warnings and cliffs // only ever fire on a current burn. - if let pace = window.pace(now: now)?.effective(lastBurn: lastBurn[provider], now: now) { + if let pace = window.pace(now: now)?.effective(lastBurn: lastBurn[slot], now: now) { if pace.status.isDeficit, window.usedPercent >= 50, !flaggedSoftWarnings.contains(key) { flaggedSoftWarnings.insert(key) events.append(.paceSoftWarning( - provider: provider, + slot: slot, windowLabel: window.label, usedPercent: window.usedPercent )) @@ -135,7 +136,7 @@ struct QuotaAlertEvaluator { !flaggedPaceCliffs.contains(key) { flaggedPaceCliffs.insert(key) events.append(.paceCliff( - provider: provider, + slot: slot, windowLabel: window.label, etaMinutes: max(1, Int(eta / 60)) )) @@ -144,14 +145,14 @@ struct QuotaAlertEvaluator { } for credit in quota.resetCredits { - let key = "\(provider.rawValue)/\(credit.id)" + let key = "\(slot.key)/\(credit.id)" guard !flaggedCredits.contains(key), let expiresAt = credit.expiresAt, expiresAt > now, expiresAt.timeIntervalSince(now) <= Self.expiringCreditWindow else { continue } flaggedCredits.insert(key) events.append(.expiringCredit( - provider: provider, + slot: slot, creditID: credit.id, creditTitle: credit.title )) @@ -189,8 +190,8 @@ final class QuotaAlertService { /// Called by the coordinator after each sweep with the full quota map. /// `lastBurn` gates pace events on burn recency (see the evaluator). func process( - quotas: [Provider: Loaded], - lastBurn: [Provider: Date] = [:], + quotas: [ProviderSlot: Loaded], + lastBurn: [ProviderSlot: Date] = [:], now: Date = Date() ) { guard preferences.quotaAlertsEnabled, let center else { return } @@ -244,37 +245,37 @@ final class QuotaAlertService { /// Builds the delivered notification. Bodies carry percentages and labels /// only — never account detail, paths, or raw provider payloads. - private static func computeFailoverNudge(for excluded: Provider, quotas: [Provider: Loaded]) -> String? { + private static func computeFailoverNudge(for excluded: ProviderSlot, quotas: [ProviderSlot: Loaded]) -> String? { var options: [String] = [] - for (p, state) in quotas { - guard p != excluded, let q = state.value, let h = p.headlineWindow(from: q.windows) else { continue } + for (slot, state) in quotas { + guard slot != excluded, let q = state.value, let h = slot.provider.headlineWindow(from: q.windows) else { continue } if h.usedPercent < 50 { - options.append("\(p.displayName) \(Int(h.usedPercent))%") + options.append("\(slot.displayName) \(Int(h.usedPercent))%") } } guard !options.isEmpty else { return nil } return " Switch suggestion: " + options.joined(separator: " · ") } - private static func request(for event: QuotaAlertEvent, quotas: [Provider: Loaded] = [:]) -> UNNotificationRequest { + private static func request(for event: QuotaAlertEvent, quotas: [ProviderSlot: Loaded] = [:]) -> UNNotificationRequest { let content = UNMutableNotificationContent() - let failover = computeFailoverNudge(for: event.provider, quotas: quotas) ?? "" + let failover = computeFailoverNudge(for: event.slot, quotas: quotas) ?? "" switch event { - case .threshold(let provider, let windowLabel, let usedPercent, let threshold): + case .threshold(let slot, let windowLabel, let usedPercent, let threshold): content.title = threshold.title - content.body = "\(provider.displayName) \(windowLabel) window is at \(Fmt.percent(usedPercent)) used.\(failover)" + content.body = "\(slot.displayName) \(windowLabel) window is at \(Fmt.percent(usedPercent)) used.\(failover)" content.sound = threshold == .critical ? .default : nil - case .paceCliff(let provider, let windowLabel, let etaMinutes): - content.title = "\(provider.displayName) burning fast" + case .paceCliff(let slot, let windowLabel, let etaMinutes): + content.title = "\(slot.displayName) burning fast" content.body = "\(windowLabel) window projected to empty in ~\(etaMinutes)m before reset.\(failover)" content.sound = .default - case .paceSoftWarning(let provider, let windowLabel, let usedPercent): - content.title = "\(provider.displayName) pace warning" + case .paceSoftWarning(let slot, let windowLabel, let usedPercent): + content.title = "\(slot.displayName) pace warning" content.body = "\(windowLabel) window at \(Fmt.percent(usedPercent)) used and burning faster than pace.\(failover)" content.sound = nil - case .expiringCredit(let provider, _, let creditTitle): + case .expiringCredit(let slot, _, let creditTitle): content.title = "Reset credit expiring" - content.body = "A \(provider.displayName) reset credit (\(creditTitle)) expires within 24 hours." + content.body = "A \(slot.displayName) reset credit (\(creditTitle)) expires within 24 hours." } return UNNotificationRequest(identifier: UUID().uuidString, content: content, trigger: nil) } diff --git a/Sources/MeterUsage/Models/UsageModels.swift b/Sources/MeterUsage/Models/UsageModels.swift index 97daab4..b8d7b78 100644 --- a/Sources/MeterUsage/Models/UsageModels.swift +++ b/Sources/MeterUsage/Models/UsageModels.swift @@ -176,10 +176,10 @@ public enum BurnRecency { sessions.map(\.activeUntil).max() } - /// Last burn per provider from loaded local activity. Providers without a + /// Last burn per metered slot from loaded local activity. Slots without a /// session store are absent — no burn evidence, no pace claims. Internal /// because the coordinator is the only caller that maps whole activities. - static func lastBurns(from activities: [Provider: Loaded]) -> [Provider: Date] { + static func lastBurns(from activities: [ProviderSlot: Loaded]) -> [ProviderSlot: Date] { activities.compactMapValues { lastBurn(of: $0.value?.sessions ?? []) } } @@ -449,6 +449,11 @@ public enum Provider: String, CaseIterable, Codable, Sendable { } } + /// Tools that support additional, separately-configured accounts. Each + /// additional account is a `ProviderSlot` on one of these providers, + /// backed by its own config directory (see `ManagedAccount`). + public static let supportsAdditionalAccounts: [Provider] = [.codex, .claude] + /// The window the ring, tray cluster, and tooltip headline mean for this /// provider — declared by name, never positional and never "whichever is /// biggest". @@ -538,6 +543,144 @@ public enum Provider: String, CaseIterable, Codable, Sendable { } } +// MARK: - Account slots + +/// One metered account: a provider tool plus, for additional accounts, the +/// slot that identifies which configured account the readings belong to. +/// +/// The primary slot of each provider carries an empty `slotID` and renders +/// exactly the provider's own name. Additional slots render as +/// " ·