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
+/// " ·