From 09a6f7f3ff96d117ef27310bba9656b1dd1c19f3 Mon Sep 17 00:00:00 2001
From: pekth <5535435+pekth@users.noreply.github.com>
Date: Sat, 26 Sep 2026 20:46:37 -0400
Subject: [PATCH 1/6] feat: meter a second Claude or Codex account
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
People who hold two accounts with the same tool got one meter row that
showed the first account's budget and silently mixed both accounts'
tokens. A second account is now its own provider slot (.codexAlt /
.claudeAlt) with its own quota windows, plan badge, session history,
heatmap, alerts, and (for Codex) reset credits — never merged, because
two accounts' windows are two different budgets.
A slot exists when its alternate config directory exists on the Mac:
METERUSAGE_CODEX_ALT_HOME (CODEX_HOME-equivalent) or
METERUSAGE_CLAUDE_ALT_CONFIG (CLAUDE_CONFIG_DIR-equivalent), with
stored defaults keys as fallback. The Codex slot spawns its own CLI
subprocess with that home; meterusage still never touches any auth
file. Slots are named by position only; no account identifier is ever
read or displayed.
Verified with swift build and swift test on macOS (362 tests) plus a
demo-mode meterusage json run listing both accounts per tool.
---
CHANGELOG.md | 22 ++
README.md | 32 ++
Sources/MeterUsage/App/AppDelegate.swift | 109 +++++-
Sources/MeterUsage/Core/AppCoordinator.swift | 116 ++++--
Sources/MeterUsage/Core/Preferences.swift | 16 +
Sources/MeterUsage/Models/UsageModels.swift | 65 ++-
.../Services/ClaudeLocalSource.swift | 17 +-
.../Services/ClaudePlanSource.swift | 11 +-
.../Services/CodexLocalSource.swift | 12 +-
.../Services/CodexQuotaSource.swift | 40 +-
Sources/MeterUsage/Services/DataSource.swift | 140 +++++++
Sources/MeterUsage/Services/DemoSources.swift | 51 ++-
.../MeterUsage/Services/JSONRPCClient.swift | 23 +-
.../Services/OptionalQuotaFileSource.swift | 12 +-
.../Services/StatusPageSource.swift | 6 +-
Sources/MeterUsage/Views/MenuBarLabel.swift | 47 ++-
Sources/MeterUsage/Views/PopoverRoot.swift | 15 +-
Sources/MeterUsage/Views/QuotaSection.swift | 10 +-
Sources/MeterUsage/Views/SettingsView.swift | 54 ++-
.../MeterUsage/Views/SharedComponents.swift | 6 +-
.../MeterUsage/Views/SideNotchPanelView.swift | 49 ++-
Tests/MeterUsageTests/MenuBarTests.swift | 2 +-
Tests/MeterUsageTests/MultiAccountTests.swift | 370 ++++++++++++++++++
.../MeterUsageTests/SideNotchPanelTests.swift | 4 +-
.../SupplementalUsageSourceTests.swift | 2 +-
docs/KB.md | 2 +
docs/PRIVACY.md | 4 +
docs/adr/0005-multi-account-slots.md | 65 +++
docs/adr/README.md | 1 +
29 files changed, 1176 insertions(+), 127 deletions(-)
create mode 100644 Tests/MeterUsageTests/MultiAccountTests.swift
create mode 100644 docs/adr/0005-multi-account-slots.md
diff --git a/CHANGELOG.md b/CHANGELOG.md
index ed5e542..984ef39 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -4,6 +4,28 @@ 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
+
+- **Second-account meters for Codex and Claude.** People who hold two accounts
+ with the same tool get a second, independent meter row instead of a merge —
+ two accounts' windows are two different budgets. A slot exists when its
+ alternate config directory exists on the machine: point
+ `METERUSAGE_CODEX_ALT_HOME` (a `CODEX_HOME`-equivalent) or
+ `METERUSAGE_CLAUDE_ALT_CONFIG` (a `CLAUDE_CONFIG_DIR`-equivalent) at the
+ second account's directory, or store the path under the
+ `meterusage.codexAltHome` / `meterusage.claudeAltConfig` defaults keys. 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. Slot rows carry a "2" beside the mark in the
+ tray and side notch, and the `meterusage json` report lists the second
+ accounts under `codexAlt` / `claudeAlt` with their own windows, plan, and
+ reset credits. No account identifier is ever read or displayed — slots are
+ named by position, 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..34c3ccd 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,35 @@ 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 accounts with the same tool are two different budgets, so meterusage gives
+each one its own meter row — own quota windows, plan badge, session history,
+heatmap, alerts, and (for Codex) reset credits. Slots are named by position
+("Codex second account", a "2" beside the mark); no account identifier is ever
+read or displayed.
+
+A slot exists when its alternate config directory exists on this Mac. Point
+meterusage at the second account's directory either through the environment or
+a stored default, then relaunch the app:
+
+```sh
+# Codex: the alternate home is passed to the codex subprocess the same way
+# you would run the CLI for that account.
+launchctl setenv METERUSAGE_CODEX_ALT_HOME ~/.codex-alt
+# or: defaults write meterusage meterusage.codexAltHome ~/.codex-alt
+
+# Claude: the alternate config directory (CLAUDE_CONFIG_DIR-equivalent) holds
+# that account's projects/ transcripts, .claude.json plan tier, and any
+# companion quota snapshot.
+launchctl setenv METERUSAGE_CLAUDE_ALT_CONFIG ~/.claude-alt
+# or: defaults write meterusage meterusage.claudeAltConfig ~/.claude-alt
+```
+
+The environment wins over the stored default. Toggle the slots on or off in
+Settings → Providers; both accounts always stay under their own keys in the
+Agent Budget API (`codexAlt` / `claudeAlt`).
+
---
## 🔒 Privacy & Architecture
diff --git a/Sources/MeterUsage/App/AppDelegate.swift b/Sources/MeterUsage/App/AppDelegate.swift
index 448fe4c..5e2e45e 100644
--- a/Sources/MeterUsage/App/AppDelegate.swift
+++ b/Sources/MeterUsage/App/AppDelegate.swift
@@ -34,14 +34,14 @@ 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(),
planSources: Composition.planSources(),
// Same factory, so "clear cache" can rebuild the activity sources
// and get a genuinely cold scan rather than a re-warmed one.
- activitySourceFactory: Composition.activitySources
+ activitySourceFactory: Composition.activitySources,
+ slotHomes: Composition.slotHomes
)
self.preferences = preferences
self.coordinator = coordinator
@@ -134,7 +134,7 @@ 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: provider)?.value,
service.severity != .operational {
// Unknown reads as its own word, never silence: a check that
// cannot report is information, not health.
@@ -265,9 +265,17 @@ enum Composition {
static func quotaSources() -> [QuotaSource] {
if isDemoMode {
- return [DemoClaudeQuotaSource(), DemoCodexQuotaSource(), DemoOpenRouterQuotaSource(), DemoOpenCodeGoQuotaSource(), DemoGrokQuotaSource(), DemoAntigravityQuotaSource()]
+ // Demo mounts both accounts per tool, so multi-account rows get
+ // real screenshot coverage. All numbers stay synthetic.
+ return [
+ DemoClaudeQuotaSource(),
+ DemoClaudeQuotaSource(provider: .claudeAlt),
+ DemoCodexQuotaSource(),
+ DemoCodexQuotaSource(provider: .codexAlt),
+ 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 +301,72 @@ enum Composition {
// Google Gemini CLI quota and accounts
GeminiQuotaSource()
]
+ // A second Codex account exists when the user has configured an
+ // alternate Codex home (`AccountSlots`). Its source spawns the CLI
+ // with that home, so the subprocess authenticates as the other
+ // account; meterusage still never touches any auth file.
+ if let codexAltHome = slotHomes.codexAltHome {
+ sources.append(
+ CodexQuotaSource(
+ provider: .codexAlt,
+ client: SubprocessJSONRPCClient(codexHome: codexAltHome)
+ )
+ )
+ }
+ // A second Claude account reads its companion-written quota snapshot
+ // inside the alternate config directory, if one exists. Absence is
+ // the normal `.noData` state, exactly like the primary slot.
+ if let claudeAltConfig = slotHomes.claudeAltConfig {
+ sources.append(
+ OptionalQuotaFileSource(
+ provider: .claudeAlt,
+ candidatePaths: [
+ claudeAltConfig.appendingPathComponent("meterusage-usage.json"),
+ claudeAltConfig.appendingPathComponent("claudewatch-usage.json"),
+ ]
+ )
+ )
+ }
+ return sources
}
static func activitySources() -> [LocalActivitySource] {
- isDemoMode
- ? [DemoLocalActivitySource(), DemoCodexActivitySource()]
- : [ClaudeLocalSource(), CodexLocalSource()]
+ if isDemoMode {
+ return [
+ DemoLocalActivitySource(),
+ DemoLocalActivitySource(provider: .claudeAlt),
+ DemoCodexActivitySource(),
+ DemoCodexActivitySource(provider: .codexAlt)
+ ]
+ }
+ var sources: [LocalActivitySource] = [ClaudeLocalSource(), CodexLocalSource()]
+ if let codexAltHome = slotHomes.codexAltHome {
+ // The alternate account's rollout store lives inside its own
+ // home; the rollout format is the primary account's, relocated.
+ sources.append(
+ CodexLocalSource(provider: .codexAlt, root: codexAltHome.appendingPathComponent("sessions", isDirectory: true))
+ )
+ }
+ if let claudeAltConfig = slotHomes.claudeAltConfig {
+ // 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(
+ provider: .claudeAlt,
+ root: claudeAltConfig.appendingPathComponent("projects", isDirectory: true),
+ cacheFileURL: Self.cacheDirectory.appendingPathComponent("claude-local-scan-cache-alt.json")
+ )
+ )
+ }
+ 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 +387,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 +402,26 @@ 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.
+ /// The second Claude account reads its tier from the alternate config
+ /// directory's 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(provider: .claudeAlt, tier: .max20x)]
+ }
+ var sources: [PlanSource] = [ClaudePlanSource()]
+ if let claudeAltConfig = slotHomes.claudeAltConfig {
+ sources.append(
+ ClaudePlanSource(provider: .claudeAlt, fileURL: claudeAltConfig.appendingPathComponent(".claude.json"))
+ )
+ }
+ return sources
}
+
+ /// Alternate-account homes, resolved once per launch. Composition-root
+ /// state: sources, the coordinator, and Settings all read the same
+ /// resolution rather than re-reading the environment.
+ static let slotHomes: AccountSlots.Resolved = isDemoMode
+ ? .none
+ : AccountSlots.resolve()
}
diff --git a/Sources/MeterUsage/Core/AppCoordinator.swift b/Sources/MeterUsage/Core/AppCoordinator.swift
index d06e521..4f9559e 100644
--- a/Sources/MeterUsage/Core/AppCoordinator.swift
+++ b/Sources/MeterUsage/Core/AppCoordinator.swift
@@ -67,8 +67,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: [Provider: 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 +87,11 @@ 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?
+ /// Resolved alternate-account homes. A slot only polls, renders, and
+ /// alerts when its own config directory exists — an alternate account is
+ /// a directory the user configured, not a guess. Injectable so tests can
+ /// point slots at fixture directories.
+ let slotHomes: AccountSlots.Resolved
/// 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
@@ -144,17 +153,34 @@ final class AppCoordinator: ObservableObject {
isDemoMode: Bool = false,
quotaSources: [QuotaSource] = [],
resetConsumer: QuotaResetConsumer? = nil,
+ resetConsumers: [Provider: QuotaResetConsumer] = [:],
activitySources: [LocalActivitySource] = [],
usageSources: [UsageSource] = [],
statusSources: [StatusSource] = [],
planSources: [PlanSource] = [],
activitySourceFactory: (() -> [LocalActivitySource])? = nil,
- quotaArchiveURL: URL? = nil
+ quotaArchiveURL: URL? = nil,
+ slotHomes: AccountSlots.Resolved = .none
) {
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: [Provider: QuotaResetConsumer] = resetConsumers
+ for source in quotaSources {
+ if let consumer = source as? QuotaResetConsumer {
+ consumers[source.provider] = 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 +189,24 @@ final class AppCoordinator: ObservableObject {
let archiveURL = quotaArchiveURL ?? QuotaArchive.defaultURL
self.quotaArchiveURL = archiveURL
self.archivedQuotas = QuotaArchive.load(from: archiveURL)
+ self.slotHomes = slotHomes
}
// 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]
+ ) -> Provider {
+ if let source = consumer as? QuotaSource { return source.provider }
+ return sources.first(where: { $0 is QuotaResetConsumer })?.provider ?? .codex
+ }
+
// MARK: Lifecycle
func start() {
@@ -333,19 +371,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; a second Codex account is
+ /// redeemable only through its own source's subprocess.
+ func canUseCodexReset(for provider: Provider = .codex) -> Bool {
+ resetConsumers[provider] != 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 provider: Provider = .codex) async throws {
+ let claimedProvider = quotas.first(where: { _, state in
+ state.value?.resetCredits.contains { $0.id == creditID } == true
+ })?.key ?? provider
+ guard let consumer = resetConsumers[claimedProvider] ?? resetConsumers[provider] else {
+ throw SourceUnavailable.failed(claimedProvider)
+ }
+ guard try await consumer.consumeReset(creditID: creditID) else {
+ throw SourceUnavailable.failed(claimedProvider)
}
refresh()
}
@@ -358,17 +409,17 @@ 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) {
+ for source in quotaSources where showsProvider(source.provider) {
if forceAll || !isBackedOff(kind: "quota", provider: source.provider, now: now) {
group.addTask { [weak self] in await self?.load(quota: source) }
}
}
- for source in activitySources where preferences.isEnabled(source.provider) {
+ for source in activitySources where showsProvider(source.provider) {
if forceAll || !isBackedOff(kind: "activity", provider: source.provider, now: now) {
group.addTask { [weak self] in await self?.load(activity: source) }
}
}
- for source in usageSources where preferences.isEnabled(source.provider) {
+ for source in usageSources where showsProvider(source.provider) {
if forceAll || !isBackedOff(kind: "usage", provider: source.provider, now: now) {
group.addTask { [weak self] in await self?.load(usage: source) }
}
@@ -380,7 +431,7 @@ final class AppCoordinator: ObservableObject {
group.addTask { [weak self] in await self?.load(status: source) }
}
}
- for source in planSources where preferences.isEnabled(source.provider) {
+ for source in planSources where showsProvider(source.provider) {
if forceAll || !isBackedOff(kind: "plan", provider: source.provider, now: now) {
group.addTask { [weak self] in await self?.load(plan: source) }
}
@@ -408,19 +459,19 @@ final class AppCoordinator: ObservableObject {
private func performRefresh(providers: [Provider]) async {
let wanted = Set(providers)
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.provider) && showsProvider(source.provider) {
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.provider) && showsProvider(source.provider) {
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.provider) && showsProvider(source.provider) {
group.addTask { [weak self] in await self?.load(usage: source) }
}
for source in statusSources where wanted.contains(source.provider) {
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.provider) && showsProvider(source.provider) {
group.addTask { [weak self] in await self?.load(plan: source) }
}
}
@@ -435,6 +486,13 @@ final class AppCoordinator: ObservableObject {
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] ?? provider.baseProvider.flatMap { statuses[$0] }
+ }
+
/// 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.
@@ -649,9 +707,23 @@ final class AppCoordinator: ObservableObject {
// MARK: Derived state
+ /// Whether a slot's sources should be polled and shown: the provider
+ /// toggle is on AND an alternate-account slot's config directory exists.
+ /// Service status is polled independently of both gates (see
+ /// `performRefresh`), so hiding an account never hides an outage.
+ /// Demo mode mounts synthetic rows for both accounts of the demoed
+ /// tools regardless of either gate — they exist to be screenshotted.
+ func showsProvider(_ provider: Provider) -> Bool {
+ if isDemoMode {
+ return provider.isAltSlot || preferences.isEnabled(provider)
+ }
+ return preferences.isEnabled(provider)
+ && AccountSlots.isPresent(provider, homes: slotHomes)
+ }
+
/// Providers the user has switched on, in a stable display order.
var visibleProviders: [Provider] {
- Provider.allCases.filter { preferences.isEnabled($0) }
+ Provider.allCases.filter { showsProvider($0) }
}
var visibleQuotaProviders: [Provider] {
diff --git a/Sources/MeterUsage/Core/Preferences.swift b/Sources/MeterUsage/Core/Preferences.swift
index e47a6a4..aaa4b51 100644
--- a/Sources/MeterUsage/Core/Preferences.swift
+++ b/Sources/MeterUsage/Core/Preferences.swift
@@ -20,6 +20,12 @@ enum PrefKey {
static let refreshInterval = "refreshIntervalSeconds"
static let showClaude = "showProviderClaude"
static let showCodex = "showProviderCodex"
+ /// Second-account slots. These toggle an *additional* account of a tool
+ /// whose data lives under a separately-configured config directory (see
+ /// `AccountSlots`); they default off so a single-account install renders
+ /// exactly as before.
+ static let showCodexAlt = "showProviderCodexAlt"
+ static let showClaudeAlt = "showProviderClaudeAlt"
static let showAntigravity = "showProviderAntigravity"
static let showGrok = "showProviderGrok"
static let showOpenCodeGo = "showProviderOpenCodeGo"
@@ -29,6 +35,8 @@ enum PrefKey {
static let showGemini = "showProviderGemini"
static let menuBarClaude = "menuBarProviderClaude"
static let menuBarCodex = "menuBarProviderCodex"
+ static let menuBarCodexAlt = "menuBarProviderCodexAlt"
+ static let menuBarClaudeAlt = "menuBarProviderClaudeAlt"
static let menuBarAntigravity = "menuBarProviderAntigravity"
static let menuBarGrok = "menuBarProviderGrok"
static let menuBarOpenCodeGo = "menuBarProviderOpenCodeGo"
@@ -146,6 +154,8 @@ final class Preferences: ObservableObject {
// normal use.
PrefKey.showClaude: false,
PrefKey.showCodex: true,
+ PrefKey.showCodexAlt: false,
+ PrefKey.showClaudeAlt: false,
PrefKey.showAntigravity: false,
PrefKey.showGrok: false,
PrefKey.showOpenCodeGo: true,
@@ -157,6 +167,8 @@ final class Preferences: ObservableObject {
// down to the ones they glance at.
PrefKey.menuBarClaude: true,
PrefKey.menuBarCodex: true,
+ PrefKey.menuBarCodexAlt: true,
+ PrefKey.menuBarClaudeAlt: true,
PrefKey.menuBarAntigravity: true,
PrefKey.menuBarGrok: true,
PrefKey.menuBarOpenCodeGo: true,
@@ -204,11 +216,13 @@ final class Preferences: ObservableObject {
var providers = Set()
if defaults.bool(forKey: PrefKey.showCodex) { providers.insert(.codex) }
+ if defaults.bool(forKey: PrefKey.showCodexAlt) { providers.insert(.codexAlt) }
if defaults.bool(forKey: PrefKey.showAntigravity) { providers.insert(.antigravity) }
if defaults.bool(forKey: PrefKey.showGrok) { providers.insert(.grok) }
if defaults.bool(forKey: PrefKey.showOpenCodeGo) { providers.insert(.openCodeGo) }
if defaults.bool(forKey: PrefKey.showOpenRouter) { providers.insert(.openRouter) }
if defaults.bool(forKey: PrefKey.showClaude) { providers.insert(.claude) }
+ if defaults.bool(forKey: PrefKey.showClaudeAlt) { providers.insert(.claudeAlt) }
if defaults.bool(forKey: PrefKey.showCursor) { providers.insert(.cursor) }
if defaults.bool(forKey: PrefKey.showCopilot) { providers.insert(.copilot) }
if defaults.bool(forKey: PrefKey.showGemini) { providers.insert(.gemini) }
@@ -216,11 +230,13 @@ final class Preferences: ObservableObject {
var menuBar = Set()
if defaults.bool(forKey: PrefKey.menuBarCodex) { menuBar.insert(.codex) }
+ if defaults.bool(forKey: PrefKey.menuBarCodexAlt) { menuBar.insert(.codexAlt) }
if defaults.bool(forKey: PrefKey.menuBarAntigravity) { menuBar.insert(.antigravity) }
if defaults.bool(forKey: PrefKey.menuBarGrok) { menuBar.insert(.grok) }
if defaults.bool(forKey: PrefKey.menuBarOpenCodeGo) { menuBar.insert(.openCodeGo) }
if defaults.bool(forKey: PrefKey.menuBarOpenRouter) { menuBar.insert(.openRouter) }
if defaults.bool(forKey: PrefKey.menuBarClaude) { menuBar.insert(.claude) }
+ if defaults.bool(forKey: PrefKey.menuBarClaudeAlt) { menuBar.insert(.claudeAlt) }
if defaults.bool(forKey: PrefKey.menuBarCursor) { menuBar.insert(.cursor) }
if defaults.bool(forKey: PrefKey.menuBarCopilot) { menuBar.insert(.copilot) }
if defaults.bool(forKey: PrefKey.menuBarGemini) { menuBar.insert(.gemini) }
diff --git a/Sources/MeterUsage/Models/UsageModels.swift b/Sources/MeterUsage/Models/UsageModels.swift
index 97daab4..4974079 100644
--- a/Sources/MeterUsage/Models/UsageModels.swift
+++ b/Sources/MeterUsage/Models/UsageModels.swift
@@ -426,23 +426,78 @@ public struct ProviderQuota: Equatable, Sendable {
public enum Provider: String, CaseIterable, Codable, Sendable {
case codex
+ /// A second, separately-configured Codex account.
+ ///
+ /// Sources for this slot point at an alternate Codex home directory (see
+ /// `AccountSlots`), so its readings come from a different `codex` login
+ /// than the primary slot's. Everything downstream (coordinator maps,
+ /// archive, alerts, JSON report, tray) treats it as its own meter row —
+ /// never merged with `.codex`, because two accounts' percents are two
+ /// different budgets.
+ case codexAlt
case antigravity
case grok
case openCodeGo
case openRouter
case claude
+ /// A second, separately-configured Claude Code account.
+ ///
+ /// Sources for this slot scan an alternate Claude projects tree (see
+ /// `AccountSlots`) and a per-account quota/plan snapshot, so readings
+ /// belong to a different Claude account than the primary `.claude` slot.
+ case claudeAlt
case cursor
case copilot
case gemini
+ /// The provider this slot reads the same tool as, or `nil` for the
+ /// primary slot. An alternate case answers its own identity here and is
+ /// otherwise present-tense in every surface: "Claude second account" is a
+ /// distinct meter, not a footnote.
+ public var baseProvider: Provider? {
+ switch self {
+ case .codexAlt: return .codex
+ case .claudeAlt: return .claude
+ default: return nil
+ }
+ }
+
+ /// True for the second-account slots. These providers follow their base
+ /// provider for status pages, plan badges, and identity glyphs — see the
+ /// per-field resolution helpers below.
+ public var isAltSlot: Bool { baseProvider != nil }
+
+ /// The slot digit ("2") shown beside an alternate-account slot's mark.
+ /// `nil` for primary slots, which never carry a digit.
+ public var altSlotDigit: String? {
+ switch self {
+ case .codexAlt, .claudeAlt: return "2"
+ default: return nil
+ }
+ }
+
+ /// Service-health identity: an alternate-account slot shares its base
+ /// provider's service, so the status strip keeps one row per service.
+ public var statusProvider: Provider { baseProvider ?? self }
+
+ /// Identity glyph: the alternate-account slots reuse their base
+ /// provider's bundled mark so both account rows read as the same tool.
+ /// ADR 0002 keeps the marks themselves unchanged.
+ public var markProvider: Provider {
+ guard let base = baseProvider else { return self }
+ return base
+ }
+
public var displayName: String {
switch self {
case .codex: return "Codex"
+ case .codexAlt: return "Codex second account"
case .antigravity: return "Antigravity"
case .grok: return "Grok"
case .openCodeGo: return "OpenCode Go"
case .openRouter: return "OpenRouter"
case .claude: return "Claude"
+ case .claudeAlt: return "Claude second account"
case .cursor: return "Cursor"
case .copilot: return "Copilot CLI"
case .gemini: return "Gemini CLI"
@@ -480,12 +535,12 @@ public enum Provider: String, CaseIterable, Codable, Sendable {
/// figure.
public func headlineWindow(from windows: [QuotaWindow]) -> QuotaWindow? {
switch self {
- case .codex:
+ case .codex, .codexAlt:
if let session = windows.first(where: { $0.isSessionWindow }) {
return session
}
return windows.count == 1 ? windows[0] : nil
- case .claude:
+ case .claude, .claudeAlt:
if let session = windows.first(where: { $0.isSessionWindow }) {
return session
}
@@ -508,7 +563,9 @@ public enum Provider: String, CaseIterable, Codable, Sendable {
public var sourceLabel: String {
switch self {
case .codex: return "Codex CLI"
+ case .codexAlt: return "Codex CLI · second account"
case .claude: return "Claude Code"
+ case .claudeAlt: return "Claude Code · second account"
case .antigravity: return "agy CLI"
case .grok: return "Grok CLI"
case .openCodeGo: return "OpenCode"
@@ -524,9 +581,9 @@ public enum Provider: String, CaseIterable, Codable, Sendable {
/// than sending the user to a guessed or unrelated page.
public var statusPageURL: URL? {
switch self {
- case .codex:
+ case .codex, .codexAlt:
return URL(string: "https://status.openai.com/")
- case .claude:
+ case .claude, .claudeAlt:
return URL(string: "https://status.claude.com/")
case .cursor:
return URL(string: "https://status.cursor.com/")
diff --git a/Sources/MeterUsage/Services/ClaudeLocalSource.swift b/Sources/MeterUsage/Services/ClaudeLocalSource.swift
index 16ac79b..bc81e57 100644
--- a/Sources/MeterUsage/Services/ClaudeLocalSource.swift
+++ b/Sources/MeterUsage/Services/ClaudeLocalSource.swift
@@ -62,16 +62,22 @@ import Foundation
// for a JSON decode at all.
public actor ClaudeLocalSource: LocalActivitySource {
- public nonisolated let provider: Provider = .claude
+ public nonisolated let provider: Provider
/// Root directory to scan. Defaults to the real `~/.claude/projects`,
/// but is injectable so tests can point at a fixture tree instead of
- /// the user's actual home directory.
+ /// the user's actual home directory. A second-account instance points
+ /// this at the alternate config directory's `projects` tree (see
+ /// `AccountSlots`) so only that account's transcripts are counted.
private let root: URL
private let fileManager: FileManager
/// Where the on-disk scan cache is persisted. Injectable so tests never
- /// touch the user's real Application Support directory.
+ /// touch the user's real Application Support directory. Each account slot
+ /// persists its own cache file: two concurrent scans writing one file
+ /// would let a later writer drop the earlier scan's freshly-parsed
+ /// entries (each write carries the loader's own snapshot), so slots are
+ /// kept disjoint rather than merged.
private let cacheFileURL: URL
/// Per-file parse cache, keyed by a non-reversible hash of the file's
@@ -84,7 +90,8 @@ public actor ClaudeLocalSource: LocalActivitySource {
private var diskCacheLoaded = false
private var diskCacheDirty = false
- public init(root: URL? = nil, fileManager: FileManager = .default, cacheFileURL: URL? = nil) {
+ public init(provider: Provider = .claude, root: URL? = nil, fileManager: FileManager = .default, cacheFileURL: URL? = nil) {
+ self.provider = provider
self.root = root ?? HomeDirectory.real.appendingPathComponent(".claude/projects", isDirectory: true)
self.fileManager = fileManager
self.cacheFileURL = cacheFileURL ?? Self.defaultCacheFileURL
@@ -188,7 +195,7 @@ public actor ClaudeLocalSource: LocalActivitySource {
)
return LocalActivity(
- provider: .claude,
+ provider: provider,
sessions: sortedSessions,
daily: daily,
scannedAt: now,
diff --git a/Sources/MeterUsage/Services/ClaudePlanSource.swift b/Sources/MeterUsage/Services/ClaudePlanSource.swift
index 1e96f7b..f1517ac 100644
--- a/Sources/MeterUsage/Services/ClaudePlanSource.swift
+++ b/Sources/MeterUsage/Services/ClaudePlanSource.swift
@@ -36,13 +36,18 @@ import Foundation
// explicit privacy review — do not casually add a key here to unblock
// something else.
public struct ClaudePlanSource: PlanSource {
- public let provider: Provider = .claude
+ public let provider: Provider
/// Path to `~/.claude.json`. Injectable so tests never read the real
- /// file — they point this at a fixture instead.
+ /// file — they point this at a fixture instead. A second-account slot
+ /// passes both the alternate provider case and the alternate config
+ /// directory's own `.claude.json` (see `AccountSlots`), so its tier is
+ /// read from that account's file and never falls back to the primary
+ /// account's — a wrong-but-plausible tier would be worse than none.
private let fileURL: URL
- public init(fileURL: URL? = nil) {
+ public init(provider: Provider = .claude, fileURL: URL? = nil) {
+ self.provider = provider
self.fileURL = fileURL ?? HomeDirectory.real.appendingPathComponent(".claude.json")
}
diff --git a/Sources/MeterUsage/Services/CodexLocalSource.swift b/Sources/MeterUsage/Services/CodexLocalSource.swift
index 0aed7d1..c8c84c3 100644
--- a/Sources/MeterUsage/Services/CodexLocalSource.swift
+++ b/Sources/MeterUsage/Services/CodexLocalSource.swift
@@ -14,12 +14,18 @@ import Foundation
// in the payloads is read — no prompts, no tool output.
public actor CodexLocalSource: LocalActivitySource {
- public nonisolated let provider: Provider = .codex
+ public nonisolated let provider: Provider
private let root: URL
private let fileManager: FileManager
- public init(root: URL? = nil, fileManager: FileManager = .default) {
+ /// One Codex account slot. The default scans the primary account's
+ /// rollout store under the real home; a second-account instance passes
+ /// `provider: .codexAlt` and a `root` under the alternate Codex home (see
+ /// `AccountSlots`), so only that login's sessions are counted. The rollout
+ /// format is identical across accounts — only the directory differs.
+ public init(provider: Provider = .codex, root: URL? = nil, fileManager: FileManager = .default) {
+ self.provider = provider
self.root = root ?? HomeDirectory.real
.appendingPathComponent(".codex", isDirectory: true)
.appendingPathComponent("sessions", isDirectory: true)
@@ -99,7 +105,7 @@ public actor CodexLocalSource: LocalActivitySource {
)
return LocalActivity(
- provider: .codex,
+ provider: provider,
sessions: sessions.sorted { $0.startedAt < $1.startedAt },
daily: daily,
scannedAt: now,
diff --git a/Sources/MeterUsage/Services/CodexQuotaSource.swift b/Sources/MeterUsage/Services/CodexQuotaSource.swift
index c2a7db8..49c84fe 100644
--- a/Sources/MeterUsage/Services/CodexQuotaSource.swift
+++ b/Sources/MeterUsage/Services/CodexQuotaSource.swift
@@ -7,11 +7,17 @@ import Foundation
/// auth entirely inside the `codex` subprocess (it manages its own login
/// state) and keeps this type unit-testable without a real CLI installed.
public struct CodexQuotaSource: QuotaSource, QuotaResetConsumer {
- public let provider: Provider = .codex
+ public let provider: Provider
private let client: JSONRPCClient
- public init(client: JSONRPCClient = SubprocessJSONRPCClient()) {
+ /// One Codex account slot. The default is the primary account reading
+ /// the user's normal codex configuration; a second-account source is
+ /// built with a client whose `codexHome` points at the alternate home,
+ /// and with `provider: .codexAlt` so its readings land in their own
+ /// meter row rather than overwriting the primary account's.
+ public init(provider: Provider = .codex, client: JSONRPCClient = SubprocessJSONRPCClient()) {
+ self.provider = provider
self.client = client
}
@@ -20,7 +26,7 @@ public struct CodexQuotaSource: QuotaSource, QuotaResetConsumer {
do {
responseLine = try await client.requestCodexRateLimits()
} catch let transportError as JSONRPCTransportError {
- throw Self.map(transportError)
+ throw Self.map(transportError, provider: provider)
}
// Anything else (a client implementation throwing something other
// than JSONRPCTransportError) is a programmer error in the client,
@@ -33,16 +39,17 @@ public struct CodexQuotaSource: QuotaSource, QuotaResetConsumer {
// A response line that isn't even valid JSON-RPC is not a
// provider error we understand well enough to name — fail
// generically rather than guess.
- throw SourceUnavailable.failed(.codex)
+ throw SourceUnavailable.failed(provider)
}
if let rpcError = envelope.error {
- throw Self.map(rpcError)
+ throw Self.map(rpcError, provider: provider)
}
guard let result = envelope.result, let rateLimits = result.rateLimits else {
- throw SourceUnavailable.failed(.codex)
+ throw SourceUnavailable.failed(provider)
}
return Self.buildQuota(
+ provider: provider,
from: rateLimits,
additional: result.rateLimitsByLimitId,
resets: result.rateLimitResetCredits
@@ -52,24 +59,24 @@ public struct CodexQuotaSource: QuotaSource, QuotaResetConsumer {
/// Redeems one provider-issued reset. The UI confirms this action before
/// calling it; this source only reports whether the provider accepted it.
public func consumeReset(creditID: String) async throws -> Bool {
- guard !creditID.isEmpty else { throw SourceUnavailable.failed(.codex) }
+ guard !creditID.isEmpty else { throw SourceUnavailable.failed(provider) }
let responseLine: Data
do {
responseLine = try await client.consumeCodexRateLimitReset(creditID: creditID)
} catch let transportError as JSONRPCTransportError {
- throw Self.map(transportError)
+ throw Self.map(transportError, provider: provider)
}
let envelope: ConsumeRPCEnvelope
do {
envelope = try JSONDecoder().decode(ConsumeRPCEnvelope.self, from: responseLine)
} catch {
- throw SourceUnavailable.failed(.codex)
+ throw SourceUnavailable.failed(provider)
}
if let rpcError = envelope.error {
- throw Self.map(rpcError)
+ throw Self.map(rpcError, provider: provider)
}
return envelope.result?.outcome == "reset"
}
@@ -254,6 +261,7 @@ public struct CodexQuotaSource: QuotaSource, QuotaResetConsumer {
// MARK: - Parsing
private static func buildQuota(
+ provider: Provider,
from payload: RateLimitsPayload,
additional: [String: RateLimitsPayload],
resets: RateLimitResetCredits?
@@ -303,7 +311,7 @@ public struct CodexQuotaSource: QuotaSource, QuotaResetConsumer {
)
} ?? []
return ProviderQuota(
- provider: .codex,
+ provider: provider,
windows: baseWindows,
groups: groups,
credits: credits,
@@ -342,16 +350,16 @@ public struct CodexQuotaSource: QuotaSource, QuotaResetConsumer {
// MARK: - Error mapping
- private static func map(_ transportError: JSONRPCTransportError) -> SourceUnavailable {
+ private static func map(_ transportError: JSONRPCTransportError, provider: Provider) -> SourceUnavailable {
switch transportError {
case .processNotFound:
return .cliNotFound("codex")
case .timeout, .processExited:
- return .failed(.codex)
+ return .failed(provider)
}
}
- private static func map(_ rpcError: RPCErrorPayload) -> SourceUnavailable {
+ private static func map(_ rpcError: RPCErrorPayload, provider: Provider) -> SourceUnavailable {
let message = rpcError.message.lowercased()
// codex-cli's exact wording for "not logged in" wasn't available to
// confirm empirically on this machine (that requires a logged-out
@@ -360,7 +368,7 @@ public struct CodexQuotaSource: QuotaSource, QuotaResetConsumer {
// that doesn't match falls through to `.failed`, per the mapping
// rule: never guess past what we can name confidently.
if message.contains("not logged in") || message.contains("not authenticated") || message.contains("sign in") {
- return .notSignedIn(.codex)
+ return .notSignedIn(provider)
}
// Verified: code -32603 with a message mentioning the backend is
// unreachable is how a network-down `account/rateLimits/read` call
@@ -369,6 +377,6 @@ public struct CodexQuotaSource: QuotaSource, QuotaResetConsumer {
&& (message.contains("reach") || message.contains("backend") || message.contains("network") || message.contains("connect")) {
return .offline
}
- return .failed(.codex)
+ return .failed(provider)
}
}
diff --git a/Sources/MeterUsage/Services/DataSource.swift b/Sources/MeterUsage/Services/DataSource.swift
index c8a11fd..55755b8 100644
--- a/Sources/MeterUsage/Services/DataSource.swift
+++ b/Sources/MeterUsage/Services/DataSource.swift
@@ -58,6 +58,146 @@ public enum HomeDirectory {
}
}
+// MARK: - Alternate-account slots
+//
+// People who hold two accounts with the same tool get a second meter row per
+// tool slot (`.codexAlt`, `.claudeAlt`) rather than a merge, because two
+// accounts' windows are two different budgets. What makes a slot real is a
+// separately-configured CLI home directory on this Mac; `AccountSlots` is the
+// one place that resolution lives.
+//
+// Pure data — no source construction happens here — and injectable in tests.
+
+public enum AccountSlots {
+
+ /// UserDefaults key holding the absolute path of the alternate Codex home.
+ /// An environment variable of the same name is consulted first so a
+ /// launch wrapper can override the stored value.
+ public static let codexAltHomeKey = "meterusage.codexAltHome"
+ /// UserDefaults key holding the absolute path of the alternate Claude
+ /// config directory.
+ public static let claudeAltConfigKey = "meterusage.claudeAltConfig"
+
+ /// Where the alternate-account home paths come from. Injectable so tests
+ /// never depend on the process environment.
+ public protocol Environment: Sendable {
+ func string(forKey key: String) -> String?
+ }
+
+ /// The process environment: the app's own launch environment is inherited
+ /// by `make-app.sh` invocations and by the headless CLI.
+ public struct ProcessEnvironment: Environment {
+ public init() {}
+ public func string(forKey key: String) -> String? {
+ ProcessInfo.processInfo.environment[key]
+ }
+ }
+
+ /// UserDefaults-backed overlay. Injectable for tests. `UserDefaults` is
+ /// documented thread-safe; the unchecked conformance bridges the SDK's
+ /// missing `Sendable` annotation rather than implying any real risk.
+ public struct DefaultsEnvironment: @unchecked Sendable, Environment {
+ private let defaults: UserDefaults
+ public init(defaults: UserDefaults) { self.defaults = defaults }
+ public func string(forKey key: String) -> String? { defaults.string(forKey: key) }
+ }
+
+ /// The concrete alternate-account home directories this machine has.
+ public struct Resolved: Equatable, Sendable {
+ /// Alternate Codex home (`CODEX_HOME`-equivalent). `nil` when no
+ /// alternate Codex account is configured.
+ public let codexAltHome: URL?
+ /// Alternate Claude config directory (`CLAUDE_CONFIG_DIR`-equivalent).
+ /// `nil` when no alternate Claude account is configured.
+ public let claudeAltConfig: URL?
+
+ /// Convenience for tests/composition: both slots absent.
+ public static let none = Self(codexAltHome: nil, claudeAltConfig: nil)
+ }
+
+ /// Reads the raw string from the overlays and interprets it as a
+ /// directory path. Empty or whitespace-only strings, "~" as configured,
+ /// and missing values all resolve to nil — a stored-but-blank key is
+ /// "not configured", never a broken path.
+ static func resolve(
+ key: String,
+ overlays: [Environment],
+ home: URL
+ ) -> URL? {
+ for overlay in overlays {
+ guard let raw = overlay.string(forKey: key)?.trimmingCharacters(in: .whitespacesAndNewlines),
+ !raw.isEmpty else { continue }
+ if raw == "~" || raw.hasPrefix("~/") {
+ return home.appendingPathComponent(String(raw.dropFirst(2)))
+ }
+ return URL(fileURLWithPath: raw, isDirectory: true)
+ }
+ return nil
+ }
+
+ /// The resolved slot homes. Ordering matters: the process environment
+ /// wins over stored defaults, so `launchctl setenv` or a shell-launched
+ /// CLI run can override the persisted value without editing defaults.
+ public static func resolve(
+ codexKey: String = codexAltHomeKey,
+ claudeKey: String = claudeAltConfigKey,
+ overlays: [Environment] = [ProcessEnvironment(), DefaultsEnvironment(defaults: .standard)],
+ home: URL = HomeDirectory.real
+ ) -> Resolved {
+ Resolved(
+ codexAltHome: resolve(key: codexKey, overlays: overlays, home: home),
+ claudeAltConfig: resolve(key: claudeKey, overlays: overlays, home: home)
+ )
+ }
+
+ /// Whether the named slot has a home directory on this machine. Primary
+ /// slots are always present — their sources handle a missing default
+ /// installation by reporting "not signed in", which is information.
+ /// An alternate slot without its directory is not an account at all: it
+ /// must not poll, render, or alert.
+ public static func isPresent(
+ _ provider: Provider,
+ homes: Resolved,
+ fileManager: FileManager = .default
+ ) -> Bool {
+ func dirExists(_ url: URL?) -> Bool {
+ guard let url else { return false }
+ var isDirectory: ObjCBool = false
+ return fileManager.fileExists(atPath: url.path, isDirectory: &isDirectory)
+ && isDirectory.boolValue
+ }
+ switch provider {
+ case .codexAlt: return dirExists(homes.codexAltHome)
+ case .claudeAlt: return dirExists(homes.claudeAltConfig)
+ default: return true
+ }
+ }
+
+ /// Tilde-relative display of a slot home (Settings rows only). The path
+ /// never enters caches, diagnostics, or the JSON report; this renders the
+ /// user's own configuration back to them, with the OS username reduced to
+ /// `~` exactly like any shell prompt.
+ public static func displayPath(
+ for provider: Provider,
+ homes: Resolved,
+ home: URL = HomeDirectory.real
+ ) -> String? {
+ let url: URL?
+ switch provider {
+ case .codexAlt: url = homes.codexAltHome
+ case .claudeAlt: url = homes.claudeAltConfig
+ default: url = nil
+ }
+ guard let url else { return nil }
+ let homePath = home.path
+ if url.path == homePath { return "~" }
+ if url.path.hasPrefix(homePath + "/") {
+ return "~" + url.path.dropFirst(homePath.count)
+ }
+ return url.path
+ }
+}
+
// MARK: - Privacy
/// Helpers that keep identifying data out of anything we display, cache, or log.
diff --git a/Sources/MeterUsage/Services/DemoSources.swift b/Sources/MeterUsage/Services/DemoSources.swift
index a8c6367..d192301 100644
--- a/Sources/MeterUsage/Services/DemoSources.swift
+++ b/Sources/MeterUsage/Services/DemoSources.swift
@@ -55,12 +55,18 @@ enum DemoMode {
/// colour scale in one alarming tint, so the user could not tell that the app
/// distinguishes healthy from tight at all.
struct DemoClaudeQuotaSource: QuotaSource {
- let provider: Provider = .claude
+ /// Defaults to the primary demo slot; demo composition mounts a second
+ /// instance on `.claudeAlt` so screenshots exercise multi-account rows.
+ let provider: Provider
+
+ init(provider: Provider = .claude) {
+ self.provider = provider
+ }
func fetchQuota() async throws -> ProviderQuota {
let now = Date()
return ProviderQuota(
- provider: .claude,
+ provider: provider,
windows: [
// Comfortable. Renders green.
QuotaWindow(label: "5-hour", usedPercent: 34, resetsAt: now.addingTimeInterval(3.25 * 3600)),
@@ -83,12 +89,18 @@ struct DemoClaudeQuotaSource: QuotaSource {
/// looks broken rather than calm. 82% is tight enough to tint and low enough
/// not to read as a crisis.
struct DemoCodexQuotaSource: QuotaSource {
- let provider: Provider = .codex
+ /// Defaults to the primary demo slot; demo composition mounts a second
+ /// instance on `.codexAlt` so screenshots exercise multi-account rows.
+ let provider: Provider
+
+ init(provider: Provider = .codex) {
+ self.provider = provider
+ }
func fetchQuota() async throws -> ProviderQuota {
let now = Date()
return ProviderQuota(
- provider: .codex,
+ provider: provider,
windows: [
QuotaWindow(label: "5-hour", usedPercent: 82, resetsAt: now.addingTimeInterval(1.75 * 3600)),
QuotaWindow(label: "Weekly", usedPercent: 72, resetsAt: now.addingTimeInterval(4.5 * 86_400))
@@ -266,11 +278,20 @@ struct DemoAntigravityQuotaSource: QuotaSource {
/// Synthetic subscription tier. `.max5x` rather than `.max20x` so the badge
/// shows a mid-tier plan — the common case, and the one where the percentages
-/// beside it most need the context.
+/// beside it most need the context. The second-account demo slot reports a
+/// different tier so the two accounts' rows don't read as one account
+/// rendered twice.
struct DemoPlanSource: PlanSource {
- let provider: Provider = .claude
+ let provider: Provider
- func fetchPlan() async throws -> PlanTier { .max5x }
+ init(provider: Provider = .claude, tier: PlanTier = .max5x) {
+ self.provider = provider
+ self.tier = tier
+ }
+
+ private let tier: PlanTier
+
+ func fetchPlan() async throws -> PlanTier { tier }
}
// MARK: - Status
@@ -494,12 +515,16 @@ struct DemoOpenRouterUsageSource: UsageSource {
/// in the app for a future model with no published rate; the demo simply
/// doesn't need to exercise them right now.
struct DemoLocalActivitySource: LocalActivitySource {
- let provider: Provider = .claude
+ let provider: Provider
+
+ init(provider: Provider = .claude) {
+ self.provider = provider
+ }
func scan() async throws -> LocalActivity {
let now = Date()
return LocalActivity(
- provider: .claude,
+ provider: provider,
sessions: DemoActivityData.sessions(now: now),
daily: DemoActivityData.daily(now: now),
scannedAt: now
@@ -512,7 +537,11 @@ struct DemoLocalActivitySource: LocalActivitySource {
/// real source's will. The days reuse `DemoActivityData` but strip tokens and
/// keep only the per-day session counts, matching Codex's count-based data.
struct DemoCodexActivitySource: LocalActivitySource {
- let provider: Provider = .codex
+ let provider: Provider
+
+ init(provider: Provider = .codex) {
+ self.provider = provider
+ }
func scan() async throws -> LocalActivity {
let now = Date()
@@ -545,7 +574,7 @@ struct DemoCodexActivitySource: LocalActivitySource {
dailyHistory: history
)
return LocalActivity(
- provider: .codex,
+ provider: provider,
sessions: [],
daily: daily,
scannedAt: now,
diff --git a/Sources/MeterUsage/Services/JSONRPCClient.swift b/Sources/MeterUsage/Services/JSONRPCClient.swift
index e06f4e1..ede3c47 100644
--- a/Sources/MeterUsage/Services/JSONRPCClient.swift
+++ b/Sources/MeterUsage/Services/JSONRPCClient.swift
@@ -56,10 +56,17 @@ public final class SubprocessJSONRPCClient: JSONRPCClient {
/// generous headroom for a cold-started CLI without hanging the UI
/// indefinitely if the process wedges.
private let timeout: TimeInterval
+ /// Alternate Codex home directory. When `codexAltHome` is set, a
+ /// `CODEX_HOME` environment variable is passed to the child process, so
+ /// it reads a different login's auth/session state (see `AccountSlots`)
+ /// rather than the user's primary `~/.codex`. This stays entirely
+ /// inside the subprocess: meterusage still never reads any auth file.
+ public let codexHome: URL?
- public init(clientVersion: String = "1.0", timeout: TimeInterval = 20) {
+ public init(clientVersion: String = "1.0", timeout: TimeInterval = 20, codexHome: URL? = nil) {
self.clientVersion = clientVersion
self.timeout = timeout
+ self.codexHome = codexHome
}
public func requestCodexRateLimits() async throws -> Data {
@@ -84,10 +91,11 @@ public final class SubprocessJSONRPCClient: JSONRPCClient {
let binary = try Self.resolveCodexBinary()
let clientVersion = self.clientVersion
let timeout = self.timeout
+ let codexHome = self.codexHome
return try await withThrowingTaskGroup(of: Data.self) { group in
group.addTask {
- try Self.runExchange(binary: binary, clientVersion: clientVersion, operation: operation)
+ try Self.runExchange(binary: binary, clientVersion: clientVersion, operation: operation, codexHome: codexHome)
}
group.addTask {
try await Task.sleep(nanoseconds: UInt64(timeout * 1_000_000_000))
@@ -127,7 +135,7 @@ public final class SubprocessJSONRPCClient: JSONRPCClient {
/// Synchronous by design: this runs inside a `Task.addTask` closure that
/// already has its own thread from the cooperative pool's blocking-work
/// allowance, and the whole call is raced against a timeout task above.
- private static func runExchange(binary: String, clientVersion: String, operation: Operation) throws -> Data {
+ private static func runExchange(binary: String, clientVersion: String, operation: Operation, codexHome: URL?) throws -> Data {
let process = Process()
process.executableURL = URL(fileURLWithPath: binary)
process.arguments = ["app-server", "--stdio"]
@@ -145,6 +153,15 @@ public final class SubprocessJSONRPCClient: JSONRPCClient {
]
let existingPath = env["PATH"] ?? "/usr/bin:/bin"
env["PATH"] = (extraPaths + [existingPath]).joined(separator: ":")
+ // An alternate Codex home is how the child process reads the second
+ // account's login state. CODEX_HOME is the codex CLI's own supported
+ // per-account redirection; setting it is the whole per-account seam,
+ // and meterusage still never opens anything inside that directory.
+ // Left unset for the primary account so a CODEX_HOME the user
+ // exported before launching this app keeps steering the primary slot.
+ if let codexHome {
+ env["CODEX_HOME"] = codexHome.path
+ }
process.environment = env
let stdinPipe = Pipe()
diff --git a/Sources/MeterUsage/Services/OptionalQuotaFileSource.swift b/Sources/MeterUsage/Services/OptionalQuotaFileSource.swift
index cc2623b..a842664 100644
--- a/Sources/MeterUsage/Services/OptionalQuotaFileSource.swift
+++ b/Sources/MeterUsage/Services/OptionalQuotaFileSource.swift
@@ -95,7 +95,7 @@ import Foundation
// the coarser single seven-day figure rather than being merged with it.
public struct OptionalQuotaFileSource: QuotaSource {
- public let provider: Provider = .claude
+ public let provider: Provider
private let candidatePaths: [URL]
// `FileManager` isn't marked `Sendable` upstream even though `.default`
@@ -103,9 +103,15 @@ public struct OptionalQuotaFileSource: QuotaSource {
// known-safe gap instead of pretending it doesn't exist.
private nonisolated(unsafe) let fileManager: FileManager
+ /// - Parameter provider: The account slot this instance meters. The
+ /// default is the primary Claude account; a second-account instance
+ /// passes `.claudeAlt` together with `candidatePaths` inside that
+ /// account's own config directory (see `AccountSlots`), so the two
+ /// accounts' quota readings never overwrite each other.
/// - Parameter candidatePaths: Override for tests. Defaults to the two
/// real well-known locations under the user's real home directory.
- public init(candidatePaths: [URL]? = nil, fileManager: FileManager = .default) {
+ public init(provider: Provider = .claude, candidatePaths: [URL]? = nil, fileManager: FileManager = .default) {
+ self.provider = provider
if let candidatePaths {
self.candidatePaths = candidatePaths
} else {
@@ -276,7 +282,7 @@ public struct OptionalQuotaFileSource: QuotaSource {
}
return ProviderQuota(
- provider: .claude,
+ provider: provider,
windows: windows,
credits: credits,
planType: nil,
diff --git a/Sources/MeterUsage/Services/StatusPageSource.swift b/Sources/MeterUsage/Services/StatusPageSource.swift
index ca51957..27f247d 100644
--- a/Sources/MeterUsage/Services/StatusPageSource.swift
+++ b/Sources/MeterUsage/Services/StatusPageSource.swift
@@ -46,7 +46,9 @@ public struct StatusPageSource: StatusSource {
}
private static func defaultEndpoint(for provider: Provider) -> URL {
- switch provider {
+ // Alternate-account slots share the base service's feed: service
+ // health is per service, not per account (see `Provider.statusProvider`).
+ switch provider.statusProvider {
case .openRouter:
return URL(string: "https://status.openrouter.ai/api/v2/components.json")!
case .claude:
@@ -59,7 +61,7 @@ public struct StatusPageSource: StatusSource {
}
private static func componentFragments(for provider: Provider) -> [String] {
- switch provider {
+ switch provider.statusProvider {
case .openRouter:
return ["api", "chat", "data"]
case .claude:
diff --git a/Sources/MeterUsage/Views/MenuBarLabel.swift b/Sources/MeterUsage/Views/MenuBarLabel.swift
index 624bb83..484349f 100644
--- a/Sources/MeterUsage/Views/MenuBarLabel.swift
+++ b/Sources/MeterUsage/Views/MenuBarLabel.swift
@@ -92,6 +92,15 @@ struct MenuBarLabel: View {
.font(.system(size: 11, weight: .medium).monospacedDigit())
.foregroundColor(cluster.numberTint)
}
+ // A second-account slot meters the same tool, so its
+ // mark alone would be indistinguishable. The digit is
+ // the slot number — the only account attribute that
+ // exists in this app (see the privacy contract).
+ if cluster.provider.isAltSlot, let digit = cluster.provider.altSlotDigit {
+ Text(digit)
+ .font(.system(size: 7, weight: .bold))
+ .foregroundColor(MU.textSecondary)
+ }
if let eta = cluster.etaText {
Text(eta)
.font(.system(size: 9.5, weight: .semibold).monospacedDigit())
@@ -130,7 +139,7 @@ struct MenuBarLabel: View {
let window = display.flatMap { provider.headlineWindow(from: $0.quota.windows) }
let isStale = display?.isStale ?? false
- let status = coordinator.statuses[provider]?.value
+ let status = coordinator.status(for: provider)?.value
let markTint: Color
if let status {
markTint = Self.statusTint(status.severity, for: provider)
@@ -262,10 +271,12 @@ struct ProviderMark: View {
var body: some View {
Group {
+ // Alternate-account slots group with their base provider: the
+ // mark names the tool; the slot digit beside it names the account.
switch provider {
- case .codex, .grok, .openCodeGo, .antigravity:
+ case .codex, .codexAlt, .grok, .openCodeGo, .antigravity:
bundledMark(named: Self.resourceName(for: provider))
- case .claude:
+ case .claude, .claudeAlt:
ClaudeMascotShape()
.fill(tint, style: FillStyle(eoFill: true))
case .openRouter, .cursor, .copilot, .gemini:
@@ -303,30 +314,32 @@ struct ProviderMark: View {
/// Bundle resource name (without extension) for providers that ship a logo
/// asset; `nil` would mean "no logo" but callers guard by provider first.
+ /// Alternate-account slots bundle their base provider's asset (same tool,
+ /// second account) so the tray and cards keep one glyph per tool.
private static func resourceName(for provider: Provider) -> String {
switch provider {
- case .codex: return "codex-logo"
- case .grok: return "grok-logo"
- case .openCodeGo: return "opencode-logo"
- case .antigravity:return "antigravity-logo"
- case .openRouter, .claude, .cursor, .copilot, .gemini: return ""
+ case .codex, .codexAlt: return "codex-logo"
+ case .grok: return "grok-logo"
+ case .openCodeGo: return "opencode-logo"
+ case .antigravity: return "antigravity-logo"
+ case .openRouter, .claude, .claudeAlt, .cursor, .copilot, .gemini: return ""
}
}
static func symbol(for provider: Provider) -> String {
switch provider {
- case .codex: return "sparkle"
- case .antigravity:return "sparkles"
- case .grok: return "eye"
+ case .codex, .codexAlt: return "sparkle"
+ case .antigravity: return "sparkles"
+ case .grok: return "eye"
// A real SF Symbol name: an invalid name renders as nothing, which
// silently blanked this provider's mark wherever no bundled logo
// exists (e.g. a bare debug binary).
- case .openCodeGo: return "arrow.up.left.and.arrow.down.right"
- case .openRouter: return "arrow.triangle.branch"
- case .claude: return "sparkles"
- case .cursor: return "cursorarrow.rays"
- case .copilot: return "terminal"
- case .gemini: return "diamond"
+ case .openCodeGo: return "arrow.up.left.and.arrow.down.right"
+ case .openRouter: return "arrow.triangle.branch"
+ case .claude, .claudeAlt: return "sparkles"
+ case .cursor: return "cursorarrow.rays"
+ case .copilot: return "terminal"
+ case .gemini: return "diamond"
}
}
}
diff --git a/Sources/MeterUsage/Views/PopoverRoot.swift b/Sources/MeterUsage/Views/PopoverRoot.swift
index f5d18e2..7baaa14 100644
--- a/Sources/MeterUsage/Views/PopoverRoot.swift
+++ b/Sources/MeterUsage/Views/PopoverRoot.swift
@@ -352,7 +352,10 @@ struct PopoverRoot: View {
plan: coordinator.plans[provider] ?? .idle,
now: coordinator.clock,
onUseReset: { creditID in
- try await coordinator.consumeCodexReset(creditID: creditID)
+ // The slot is passed explicitly: with a second
+ // Codex account mounted, a reset belongs to the
+ // card it was confirmed on.
+ try await coordinator.consumeCodexReset(creditID: creditID, in: provider)
},
// Codex and Claude render their weekly heatmaps inside
// their own quota cards so all of a provider's figures
@@ -360,7 +363,7 @@ struct PopoverRoot: View {
// ledger); Claude shades by tokens. Other providers
// pass nothing and render unchanged.
heatmapDaily: heatmapDaily(for: provider),
- heatmapIntensity: provider == .codex ? .sessions : .tokens,
+ heatmapIntensity: provider.statusProvider == .codex ? .sessions : .tokens,
lastBurn: BurnRecency.lastBurn(
of: coordinator.activities[provider]?.value?.sessions ?? [])
)
@@ -376,9 +379,11 @@ struct PopoverRoot: View {
}
private func heatmapDaily(for provider: Provider) -> [DailyActivity] {
- switch provider {
- case .codex: return coordinator.activities[.codex]?.value?.daily ?? []
- case .claude: return coordinator.activities[.claude]?.value?.daily ?? []
+ // Each account slot carries its own daily history, so a second
+ // account's card shades its own activity.
+ switch provider.statusProvider {
+ case .codex: return coordinator.activities[provider]?.value?.daily ?? []
+ case .claude: return coordinator.activities[provider]?.value?.daily ?? []
default: return []
}
}
diff --git a/Sources/MeterUsage/Views/QuotaSection.swift b/Sources/MeterUsage/Views/QuotaSection.swift
index e440160..6b637f8 100644
--- a/Sources/MeterUsage/Views/QuotaSection.swift
+++ b/Sources/MeterUsage/Views/QuotaSection.swift
@@ -101,7 +101,7 @@ struct QuotaSection: View {
// MARK: Header
private var showsRemaining: Bool {
- provider == .codex || provider == .antigravity
+ provider.statusProvider == .codex || provider.statusProvider == .antigravity
}
private var header: some View {
@@ -259,7 +259,9 @@ struct QuotaSection: View {
}
private var providerHeatmapEnabled: Bool {
- switch provider {
+ // Alternate-account slots share the family toggle: it governs the
+ // heatmap feature for the tool, not for one account.
+ switch provider.statusProvider {
case .codex: return showCodexHeatmap
case .claude: return showClaudeHeatmap
default: return false
@@ -352,10 +354,10 @@ private struct WindowRow: View {
@AppStorage(PrefKey.showPacingBurnRate) private var showPacingBurnRate: Bool = true
private var showsRemaining: Bool {
- provider == .codex || provider == .antigravity
+ provider.statusProvider == .codex || provider.statusProvider == .antigravity
}
- private var isCodex: Bool { provider == .codex }
+ private var isCodex: Bool { provider.statusProvider == .codex }
private var tint: Color { headroomColor(window.usedPercent) }
diff --git a/Sources/MeterUsage/Views/SettingsView.swift b/Sources/MeterUsage/Views/SettingsView.swift
index ba621d8..36799ed 100644
--- a/Sources/MeterUsage/Views/SettingsView.swift
+++ b/Sources/MeterUsage/Views/SettingsView.swift
@@ -17,6 +17,8 @@ struct SettingsView: View {
@AppStorage(PrefKey.refreshInterval) private var refreshInterval: Double = Preferences.defaultRefreshInterval
@AppStorage(PrefKey.showClaude) private var showClaude: Bool = false
@AppStorage(PrefKey.showCodex) private var showCodex: Bool = true
+ @AppStorage(PrefKey.showCodexAlt) private var showCodexAlt: Bool = false
+ @AppStorage(PrefKey.showClaudeAlt) private var showClaudeAlt: Bool = false
@AppStorage(PrefKey.showAntigravity) private var showAntigravity: Bool = false
@AppStorage(PrefKey.showGrok) private var showGrok: Bool = false
@AppStorage(PrefKey.showOpenCodeGo) private var showOpenCodeGo: Bool = true
@@ -26,6 +28,8 @@ struct SettingsView: View {
@AppStorage(PrefKey.showGemini) private var showGemini: Bool = true
@AppStorage(PrefKey.menuBarClaude) private var menuBarClaude: Bool = true
@AppStorage(PrefKey.menuBarCodex) private var menuBarCodex: Bool = true
+ @AppStorage(PrefKey.menuBarCodexAlt) private var menuBarCodexAlt: Bool = true
+ @AppStorage(PrefKey.menuBarClaudeAlt) private var menuBarClaudeAlt: Bool = true
@AppStorage(PrefKey.menuBarAntigravity) private var menuBarAntigravity: Bool = true
@AppStorage(PrefKey.menuBarGrok) private var menuBarGrok: Bool = true
@AppStorage(PrefKey.menuBarOpenCodeGo) private var menuBarOpenCodeGo: Bool = true
@@ -83,6 +87,15 @@ struct SettingsView: View {
isOn: $showCodex,
menuBarIsOn: $menuBarCodex
)
+ if codexAltPresent {
+ Divider().overlay(MU.hairline)
+ ProviderRow(
+ provider: .codexAlt,
+ subtitle: "Second Codex account · \(codexAltPath ?? "")",
+ isOn: $showCodexAlt,
+ menuBarIsOn: $menuBarCodexAlt
+ )
+ }
Divider().overlay(MU.hairline)
ProviderRow(
provider: .antigravity,
@@ -118,6 +131,15 @@ struct SettingsView: View {
isOn: $showClaude,
menuBarIsOn: $menuBarClaude
)
+ if claudeAltPresent {
+ Divider().overlay(MU.hairline)
+ ProviderRow(
+ provider: .claudeAlt,
+ subtitle: "Second Claude account · \(claudeAltPath ?? "")",
+ isOn: $showClaudeAlt,
+ menuBarIsOn: $menuBarClaudeAlt
+ )
+ }
Divider().overlay(MU.hairline)
ProviderRow(
provider: .cursor,
@@ -174,6 +196,12 @@ struct SettingsView: View {
Text("Switching a provider off above stops reading its credential entirely. It does not sign you out of that tool.")
.font(.muCaption)
.foregroundColor(MU.textTertiary)
+ if codexAltPresent || claudeAltPresent {
+ Divider().overlay(MU.hairline)
+ Text("A second account appears when its config directory exists — \(codexAltPresent ? AccountSlots.displayPath(for: .codexAlt, homes: coordinator.slotHomes) ?? "" : "")\(codexAltPresent && claudeAltPresent ? " · " : "")\(claudeAltPresent ? AccountSlots.displayPath(for: .claudeAlt, homes: coordinator.slotHomes) ?? "" : "").")
+ .font(.muCaption)
+ .foregroundColor(MU.textTertiary)
+ }
}
}
@@ -332,8 +360,32 @@ struct SettingsView: View {
}
}
+ // MARK: Second-account slots
+
+ // Demo builds mount synthetic second-account rows, so their toggles show
+ // there even though no real config directory exists.
+
+ private var codexAltPresent: Bool {
+ coordinator.isDemoMode || AccountSlots.isPresent(.codexAlt, homes: coordinator.slotHomes)
+ }
+
+ private var claudeAltPresent: Bool {
+ coordinator.isDemoMode || AccountSlots.isPresent(.claudeAlt, homes: coordinator.slotHomes)
+ }
+
+ private var codexAltPath: String? {
+ AccountSlots.displayPath(for: .codexAlt, homes: coordinator.slotHomes)
+ }
+
+ private var claudeAltPath: String? {
+ AccountSlots.displayPath(for: .claudeAlt, homes: coordinator.slotHomes)
+ }
+
private var visibleAccounts: [Provider] {
- Provider.allCases.filter { coordinator.preferences.isEnabled($0) }
+ // The coordinator's visibility, not the raw preference: an
+ // alternate-account slot with no config directory is not an account,
+ // so it gets no row.
+ coordinator.visibleProviders
}
private static func intervalLabel(_ seconds: Double) -> String {
diff --git a/Sources/MeterUsage/Views/SharedComponents.swift b/Sources/MeterUsage/Views/SharedComponents.swift
index afe5a24..dd8dfb8 100644
--- a/Sources/MeterUsage/Views/SharedComponents.swift
+++ b/Sources/MeterUsage/Views/SharedComponents.swift
@@ -115,9 +115,10 @@ func severityColor(_ severity: Severity) -> Color {
/// Provider identity colours. These are paired with names and never carry
/// meaning by colour alone; quota severity still uses the separate green/amber/
-/// red headroom scale.
+/// red headroom scale. Alternate-account slots share their base provider's
+/// identity colour: the tool is the identity, the account is named in text.
func providerColor(_ provider: Provider) -> Color {
- switch provider {
+ switch provider.statusProvider {
case .codex: return MU.accent
case .antigravity:return MU.antigravity
case .grok: return MU.grok
@@ -127,6 +128,7 @@ func providerColor(_ provider: Provider) -> Color {
case .cursor: return Color(red: 0.1, green: 0.7, blue: 0.9)
case .copilot: return Color(red: 0.4, green: 0.6, blue: 1.0)
case .gemini: return Color(red: 0.3, green: 0.5, blue: 0.95)
+ default: return MU.neutral
}
}
diff --git a/Sources/MeterUsage/Views/SideNotchPanelView.swift b/Sources/MeterUsage/Views/SideNotchPanelView.swift
index 59140a6..f5588a5 100644
--- a/Sources/MeterUsage/Views/SideNotchPanelView.swift
+++ b/Sources/MeterUsage/Views/SideNotchPanelView.swift
@@ -172,6 +172,10 @@ struct SideNotchPanelView: View {
@State private var isHoveringPanel = false
@State private var confirmingResetID: String?
@State private var consumingResetID: String?
+ /// The slot a pending reset belongs to, so a card pinned open by a
+ /// confirmation keeps showing *that* account even after the pointer
+ /// leaves — with two Codex accounts, `.codex` would be the wrong card.
+ @State private var resetProvider: Provider?
@State private var resetStatusMessage: String?
@State private var resetErrorMessage: String?
@State private var stripHeight: CGFloat = 0
@@ -196,7 +200,8 @@ struct SideNotchPanelView: View {
/// True when a detail card is actively showing beside the strip.
private var isCardShowing: Bool {
- let activeHovered = hoveredProvider ?? ((confirmingResetID != nil || consumingResetID != nil) ? .codex : nil)
+ let activeHovered = hoveredProvider
+ ?? ((confirmingResetID != nil || consumingResetID != nil) ? (resetProvider ?? .codex) : nil)
return !panel.isDragging && activeHovered != nil && entries.contains(where: { $0.provider == activeHovered })
}
@@ -329,7 +334,8 @@ struct SideNotchPanelView: View {
/// on drop.
@ViewBuilder
private var cardColumn: some View {
- let activeHovered = hoveredProvider ?? ((confirmingResetID != nil || consumingResetID != nil) ? .codex : nil)
+ let activeHovered = hoveredProvider
+ ?? ((confirmingResetID != nil || consumingResetID != nil) ? (resetProvider ?? .codex) : nil)
if !panel.isDragging,
let hovered = activeHovered,
entries.contains(where: { $0.provider == hovered }) {
@@ -531,7 +537,7 @@ struct SideNotchPanelView: View {
// Service status badge for anything but operational. Unknown gets
// its neutral badge rather than silence: an unreadable check is
// information, not health.
- if let status = coordinator.statuses[provider]?.value,
+ if let status = coordinator.status(for: provider)?.value,
status.severity != .operational {
StatusBadge(severity: status.severity)
}
@@ -603,12 +609,12 @@ struct SideNotchPanelView: View {
}
}
- // Usage limit resets (Codex)
+ // Usage limit resets (either Codex account slot)
if showSideNotchResetButton,
- provider == .codex,
+ provider.statusProvider == .codex,
let quota,
(quota.resetCreditCount ?? 0) > 0 || !quota.resetCredits.isEmpty {
- resetCreditsSection(quota: quota)
+ resetCreditsSection(provider: provider, quota: quota)
}
// Activity Telemetry 2-column grid
@@ -997,7 +1003,7 @@ struct SideNotchPanelView: View {
}
private func windowDisplayTitle(for window: QuotaWindow, provider: Provider) -> String {
- if provider == .codex && (window.label == "5-hour" || window.label.lowercased().contains("session")) {
+ if provider.statusProvider == .codex && (window.label == "5-hour" || window.label.lowercased().contains("session")) {
return "Current session"
}
// A key spending limit stays a spending limit: only the legacy
@@ -1013,7 +1019,7 @@ struct SideNotchPanelView: View {
// MARK: - Reset credits
@ViewBuilder
- private func resetCreditsSection(quota: ProviderQuota) -> some View {
+ private func resetCreditsSection(provider: Provider, quota: ProviderQuota) -> some View {
let count = quota.resetCreditCount ?? quota.resetCredits.count
VStack(alignment: .leading, spacing: 6) {
HStack(alignment: .firstTextBaseline) {
@@ -1090,7 +1096,7 @@ struct SideNotchPanelView: View {
} else if isConfirming {
HStack(spacing: 4) {
Button {
- executeReset(creditID: credit.id)
+ executeReset(creditID: credit.id, provider: provider)
} label: {
Text("Confirm")
.font(.system(size: 10, weight: .semibold))
@@ -1118,6 +1124,7 @@ struct SideNotchPanelView: View {
} else if available {
Button {
confirmingResetID = credit.id
+ resetProvider = provider
} label: {
Text("Use reset")
.font(.system(size: 10, weight: .medium))
@@ -1130,7 +1137,7 @@ struct SideNotchPanelView: View {
)
}
.buttonStyle(.plain)
- .disabled(consumingResetID != nil || !coordinator.canUseCodexReset)
+ .disabled(consumingResetID != nil || !coordinator.canUseCodexReset(for: provider))
} else {
Text(statusLabel(for: credit))
.font(.system(size: 9.5, weight: .regular))
@@ -1165,14 +1172,15 @@ struct SideNotchPanelView: View {
return status.replacingOccurrences(of: "_", with: " ").capitalized
}
- private func executeReset(creditID: String) {
+ private func executeReset(creditID: String, provider: Provider) {
confirmingResetID = nil
consumingResetID = creditID
+ resetProvider = provider
resetErrorMessage = nil
resetStatusMessage = nil
Task { @MainActor in
do {
- try await coordinator.consumeCodexReset(creditID: creditID)
+ try await coordinator.consumeCodexReset(creditID: creditID, in: provider)
consumingResetID = nil
resetStatusMessage = "Reset applied ✓"
Task {
@@ -1353,7 +1361,10 @@ struct SideNotchPanelView: View {
guard let window = provider.headlineWindow(from: windows)
else { return nil }
let markTint: Color
- if let status = statuses[provider]?.value {
+ // Alternate-account slots resolve to the base service's check:
+ // one outage tints every account row of that service.
+ let status = statuses[provider] ?? statuses[provider.statusProvider]
+ if let status = status?.value {
markTint = MenuBarLabel.statusTint(status.severity, for: provider)
} else {
// Headroom tints rings and percents only; the mark keeps the
@@ -1539,6 +1550,18 @@ private struct QuotaRing: View {
// A hit limit dims the glyph: the full orange ring already
// carries the state, and the mark steps back.
.opacity(fraction >= 1 ? 0.5 : 1.0)
+ // A second-account slot meters the same tool, so the mark alone
+ // would be indistinguishable from the primary account's ring.
+ // The digit is the slot number — the only account attribute that
+ // exists in this app (see the privacy contract).
+ if provider.isAltSlot {
+ Text("2")
+ .font(.system(size: 6.5, weight: .bold))
+ .foregroundColor(Notch.text)
+ .frame(width: 9, height: 9)
+ .background(Circle().fill(Notch.track))
+ .offset(x: 10, y: 10)
+ }
}
.frame(width: 26, height: 26)
// A ring that jumps reads as a glitch; one that sweeps reads as a
diff --git a/Tests/MeterUsageTests/MenuBarTests.swift b/Tests/MeterUsageTests/MenuBarTests.swift
index 3c918f6..64dd131 100644
--- a/Tests/MeterUsageTests/MenuBarTests.swift
+++ b/Tests/MeterUsageTests/MenuBarTests.swift
@@ -35,7 +35,7 @@ final class MenuBarTests: XCTestCase {
let first = Preferences(defaults: defaults)
let second = Preferences(defaults: defaults)
- XCTAssertEqual(first.menuBarProviders, Set([.codex, .antigravity, .openCodeGo, .openRouter, .claude, .cursor, .copilot, .gemini]))
+ XCTAssertEqual(first.menuBarProviders, Set([.codex, .codexAlt, .antigravity, .openCodeGo, .openRouter, .claude, .claudeAlt, .cursor, .copilot, .gemini]))
XCTAssertEqual(second.menuBarProviders, first.menuBarProviders)
XCTAssertFalse(first.showsInMenuBar(.grok))
}
diff --git a/Tests/MeterUsageTests/MultiAccountTests.swift b/Tests/MeterUsageTests/MultiAccountTests.swift
new file mode 100644
index 0000000..0936f49
--- /dev/null
+++ b/Tests/MeterUsageTests/MultiAccountTests.swift
@@ -0,0 +1,370 @@
+import XCTest
+@testable import MeterUsage
+
+// MARK: - Multi-account (second-account slots)
+//
+// A second Claude or Codex account is a separately-configured config
+// directory on this Mac, surfaced as its own provider slot (`.codexAlt` /
+// `.claudeAlt`). These tests pin the three seams that make that honest:
+// where the directory comes from (`AccountSlots`), when the slot exists
+// (presence gating in `AppCoordinator`), and that per-slot readings and
+// reset actions never leak across accounts.
+
+final class MultiAccountTests: XCTestCase {
+
+ // MARK: Provider identity
+
+ func testAltSlotNamingCarriesNoAccountIdentity() {
+ // The slots are labeled by position, never by any account attribute:
+ // no email, no id, no org name can be derived from these strings.
+ XCTAssertEqual(Provider.codexAlt.displayName, "Codex second account")
+ XCTAssertEqual(Provider.claudeAlt.displayName, "Claude second account")
+ XCTAssertEqual(Provider.codexAlt.sourceLabel, "Codex CLI · second account")
+ XCTAssertEqual(Provider.claudeAlt.sourceLabel, "Claude Code · second account")
+ }
+
+ func testAltSlotsResolveToBaseProviderFacts() {
+ XCTAssertEqual(Provider.codexAlt.baseProvider, .codex)
+ XCTAssertEqual(Provider.claudeAlt.baseProvider, .claude)
+ XCTAssertNil(Provider.codex.baseProvider)
+ XCTAssertNil(Provider.claude.baseProvider)
+
+ // Shared service page, shared glyph family.
+ XCTAssertEqual(Provider.codexAlt.statusPageURL, Provider.codex.statusPageURL)
+ XCTAssertEqual(Provider.claudeAlt.statusPageURL, Provider.claude.statusPageURL)
+ XCTAssertEqual(Provider.codexAlt.markProvider, .codex)
+ XCTAssertEqual(Provider.claudeAlt.markProvider, .claude)
+ XCTAssertFalse(Provider.codex.isAltSlot)
+ XCTAssertTrue(Provider.claudeAlt.isAltSlot)
+ }
+
+ func testAltSlotsFollowTheFamilyHeadlineRules() {
+ // The slot is the same tool, so the headline-window contract —
+ // the named session window, never positional — must hold identically.
+ let codexWindows = [
+ QuotaWindow(label: "Weekly", usedPercent: 10, resetsAt: nil),
+ QuotaWindow(label: "5-hour", usedPercent: 20, resetsAt: nil),
+ ]
+ XCTAssertEqual(Provider.codexAlt.headlineWindow(from: codexWindows)?.label, "5-hour")
+
+ let claudeWindows = [
+ QuotaWindow(label: "Weekly · All models", usedPercent: 10, resetsAt: nil),
+ QuotaWindow(label: "5-hour", usedPercent: 20, resetsAt: nil),
+ ]
+ XCTAssertEqual(Provider.claudeAlt.headlineWindow(from: claudeWindows)?.label, "5-hour")
+ }
+
+ // MARK: AccountSlots resolution
+
+ private final class Overlay: AccountSlots.Environment, @unchecked Sendable {
+ private let values: [String: String]
+ init(_ values: [String: String]) { self.values = values }
+ func string(forKey key: String) -> String? { values[key] }
+ }
+
+ func testResolutionPrefersEnvironmentOverDefaults() {
+ let home = URL(fileURLWithPath: "/home/testuser")
+ let defaults = Overlay([AccountSlots.codexAltHomeKey: "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/stored/codex-alt"])
+ let env = Overlay([AccountSlots.codexAltHomeKey: "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/env/codex-alt"])
+
+ let withEnv = AccountSlots.resolve(
+ overlays: [env, defaults],
+ home: home
+ )
+ XCTAssertEqual(withEnv.codexAltHome?.path, "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/env/codex-alt")
+ }
+
+ func testResolutionFallsBackToStoredDefaults() {
+ let home = URL(fileURLWithPath: "/home/testuser")
+ let defaults = Overlay([AccountSlots.codexAltHomeKey: "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/stored/codex-alt"])
+
+ let resolved = AccountSlots.resolve(overlays: [defaults], home: home)
+ XCTAssertEqual(resolved.codexAltHome?.path, "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/stored/codex-alt")
+ XCTAssertNil(resolved.claudeAltConfig)
+ }
+
+ func testResolutionExpandsTildeAgainstTheRealHome() {
+ let home = URL(fileURLWithPath: "/home/testuser")
+ let overlay = Overlay([
+ AccountSlots.codexAltHomeKey: "~/codex-alt",
+ AccountSlots.claudeAltConfigKey: "~",
+ ])
+
+ let resolved = AccountSlots.resolve(overlays: [overlay], home: home)
+ XCTAssertEqual(resolved.codexAltHome?.path, "/home/testuser/codex-alt")
+ XCTAssertEqual(resolved.claudeAltConfig?.path, "/home/testuser")
+ }
+
+ func testBlankOrMissingKeysMeanUnconfigured() {
+ let home = URL(fileURLWithPath: "/home/testuser")
+ let overlay = Overlay([
+ AccountSlots.codexAltHomeKey: " ",
+ AccountSlots.claudeAltConfigKey: "",
+ ])
+
+ let resolved = AccountSlots.resolve(overlays: [overlay], home: home)
+ XCTAssertNil(resolved.codexAltHome)
+ XCTAssertNil(resolved.claudeAltConfig)
+ }
+
+ // MARK: Presence gating
+
+ @MainActor
+ func testAltSlotOnlyVisibleWhenItsDirectoryExists() throws {
+ let base = FileManager.default.temporaryDirectory
+ .appendingPathComponent("meterusage-slots-\(UUID().uuidString)", isDirectory: true)
+ let codexHome = base.appendingPathComponent("codex-alt", isDirectory: true)
+ try FileManager.default.createDirectory(at: codexHome, withIntermediateDirectories: true)
+ defer { try? FileManager.default.removeItem(at: base) }
+
+ let suiteName = "MeterUsageTests-" + UUID().uuidString
+ let defaults = try XCTUnwrap(UserDefaults(suiteName: suiteName))
+ defer { defaults.removePersistentDomain(forName: suiteName) }
+ defaults.set(true, forKey: PrefKey.showCodexAlt)
+ defaults.set(true, forKey: PrefKey.showClaudeAlt)
+
+ let homes = AccountSlots.Resolved(codexAltHome: codexHome, claudeAltConfig: nil)
+ let coordinator = AppCoordinator(
+ preferences: Preferences(defaults: defaults),
+ slotHomes: homes
+ )
+
+ // The configured slot appears; the configured-but-missing one does not.
+ XCTAssertTrue(coordinator.visibleProviders.contains(.codexAlt))
+ XCTAssertFalse(coordinator.visibleProviders.contains(.claudeAlt))
+
+ // Directory removed mid-session: the slot is no longer an account.
+ try FileManager.default.removeItem(at: codexHome)
+ XCTAssertFalse(coordinator.visibleProviders.contains(.codexAlt))
+ }
+
+ @MainActor
+ func testPrimarySlotsAreNeverGatedBySlotPresence() throws {
+ let suiteName = "MeterUsageTests-" + UUID().uuidString
+ let defaults = try XCTUnwrap(UserDefaults(suiteName: suiteName))
+ defer { defaults.removePersistentDomain(forName: suiteName) }
+ defaults.set(true, forKey: PrefKey.showCodex)
+
+ let coordinator = AppCoordinator(
+ preferences: Preferences(defaults: defaults),
+ slotHomes: .none
+ )
+ XCTAssertTrue(coordinator.visibleProviders.contains(.codex))
+ }
+
+ // MARK: Per-slot polling and readings
+
+ @MainActor
+ func testBothCodexSlotsLoadIntoTheirOwnRows() async throws {
+ let suiteName = "MeterUsageTests-" + UUID().uuidString
+ let defaults = try XCTUnwrap(UserDefaults(suiteName: suiteName))
+ defer { defaults.removePersistentDomain(forName: suiteName) }
+ defaults.set(true, forKey: PrefKey.showCodexAlt)
+
+ let codexHome = FileManager.default.temporaryDirectory
+ .appendingPathComponent("meterusage-slots-\(UUID().uuidString)", isDirectory: true)
+ try FileManager.default.createDirectory(at: codexHome, withIntermediateDirectories: true)
+ defer { try? FileManager.default.removeItem(at: codexHome) }
+
+ let coordinator = AppCoordinator(
+ preferences: Preferences(defaults: defaults),
+ quotaSources: [
+ StubQuotaSource(
+ provider: .codex,
+ windows: [QuotaWindow(label: "5-hour", usedPercent: 10, resetsAt: nil)]
+ ),
+ StubQuotaSource(
+ provider: .codexAlt,
+ windows: [QuotaWindow(label: "5-hour", usedPercent: 90, resetsAt: nil)]
+ ),
+ ],
+ slotHomes: AccountSlots.Resolved(codexAltHome: codexHome, claudeAltConfig: nil)
+ )
+
+ coordinator.refresh()
+ for _ in 0..<200 {
+ if coordinator.quotas[.codex]?.value != nil, coordinator.quotas[.codexAlt]?.value != nil { break }
+ try? await Task.sleep(nanoseconds: 10_000_000)
+ }
+
+ // Two slots, two readings, never merged or overwritten.
+ XCTAssertEqual(coordinator.quotas[.codex]?.value?.windows.first?.usedPercent, 10)
+ XCTAssertEqual(coordinator.quotas[.codexAlt]?.value?.windows.first?.usedPercent, 90)
+ }
+
+ // MARK: Reset routing
+
+ private final class ResetBox: @unchecked Sendable {
+ var consumed: [Provider: [String]] = [:]
+ }
+
+ private struct SlotResetSource: QuotaSource, QuotaResetConsumer {
+ let provider: Provider
+ let windows: [QuotaWindow]
+ let box: ResetBox
+
+ func fetchQuota() async throws -> ProviderQuota {
+ ProviderQuota(
+ provider: provider,
+ windows: windows,
+ resetCreditCount: 1,
+ resetCredits: [QuotaResetCredit(id: "credit-\(provider.rawValue)", title: "Full reset", status: "available")],
+ capturedAt: Date()
+ )
+ }
+
+ func consumeReset(creditID: String) async throws -> Bool {
+ box.consumed[provider, default: []].append(creditID)
+ return true
+ }
+ }
+
+ @MainActor
+ func testResetRoutesToTheSlotThatOwnsTheCredit() async throws {
+ let suiteName = "MeterUsageTests-" + UUID().uuidString
+ let defaults = try XCTUnwrap(UserDefaults(suiteName: suiteName))
+ defer { defaults.removePersistentDomain(forName: suiteName) }
+ defaults.set(true, forKey: PrefKey.showCodexAlt)
+
+ let codexHome = FileManager.default.temporaryDirectory
+ .appendingPathComponent("meterusage-slots-\(UUID().uuidString)", isDirectory: true)
+ try FileManager.default.createDirectory(at: codexHome, withIntermediateDirectories: true)
+ defer { try? FileManager.default.removeItem(at: codexHome) }
+
+ let box = ResetBox()
+ let coordinator = AppCoordinator(
+ preferences: Preferences(defaults: defaults),
+ quotaSources: [
+ SlotResetSource(provider: .codex, windows: [], box: box),
+ SlotResetSource(provider: .codexAlt, windows: [], box: box),
+ ],
+ slotHomes: AccountSlots.Resolved(codexAltHome: codexHome, claudeAltConfig: nil)
+ )
+
+ // Both slots' credits are redeemable...
+ XCTAssertTrue(coordinator.canUseCodexReset(for: .codex))
+ XCTAssertTrue(coordinator.canUseCodexReset(for: .codexAlt))
+ // ...a non-Codex slot is not...
+ XCTAssertFalse(coordinator.canUseCodexReset(for: .claude))
+
+ coordinator.refresh()
+ for _ in 0..<200 {
+ if coordinator.quotas[.codex]?.value != nil, coordinator.quotas[.codexAlt]?.value != nil { break }
+ try? await Task.sleep(nanoseconds: 10_000_000)
+ }
+
+ // ...and the credit id routes to the slot whose quota reported it.
+ try await coordinator.consumeCodexReset(creditID: "credit-codexAlt")
+ XCTAssertEqual(box.consumed[.codexAlt], ["credit-codexAlt"])
+ XCTAssertNil(box.consumed[.codex])
+ }
+
+ // MARK: Source identity threading
+
+ func testCodexQuotaSourceCarriesItsSlotThroughReadingsAndErrors() async throws {
+ struct NotSignedInClient: JSONRPCClient {
+ func requestCodexRateLimits() async throws -> Data {
+ Data(#"{"id":2,"error":{"code":-32000,"message":"Not logged in"}}"#.utf8)
+ }
+ func consumeCodexRateLimitReset(creditID: String) async throws -> Data {
+ Data("{}".utf8)
+ }
+ }
+
+ let alt = CodexQuotaSource(provider: .codexAlt, client: NotSignedInClient())
+ do {
+ _ = try await alt.fetchQuota()
+ XCTFail("expected notSignedIn")
+ } catch let reason as SourceUnavailable {
+ // The error names the slot, so the calm empty state says which
+ // account to sign in rather than ambiguously "Codex".
+ XCTAssertEqual(reason, .notSignedIn(.codexAlt))
+ }
+ }
+
+ func testClaudeLocalSourceCarriesItsSlotThroughScans() async throws {
+ let base = FileManager.default.temporaryDirectory
+ .appendingPathComponent("meterusage-tests-\(UUID().uuidString)", isDirectory: true)
+ let projects = base.appendingPathComponent("projects", isDirectory: true)
+ let projectDir = projects.appendingPathComponent("-Users-testuser-Developer-samplerepo", isDirectory: true)
+ try FileManager.default.createDirectory(at: projectDir, withIntermediateDirectories: true)
+ guard let fixture = Bundle.module.url(forResource: "claude_session_sample", withExtension: "jsonl", subdirectory: "Fixtures") else {
+ XCTFail("missing fixture claude_session_sample.jsonl")
+ return
+ }
+ try FileManager.default.copyItem(at: fixture, to: projectDir.appendingPathComponent("session-one.jsonl"))
+ defer { try? FileManager.default.removeItem(at: base) }
+
+ let altSource = ClaudeLocalSource(
+ provider: .claudeAlt,
+ root: projects,
+ cacheFileURL: base.appendingPathComponent("cache-alt.json")
+ )
+ let activity = try await altSource.scan()
+ XCTAssertEqual(activity.provider, .claudeAlt)
+ }
+
+ func testQuotaArchiveRoundTripsAltSlots() {
+ let url = FileManager.default.temporaryDirectory
+ .appendingPathComponent("meterusage-tests-archive-\(UUID().uuidString).json")
+ let quotas: [Provider: ProviderQuota] = [
+ .codexAlt: ProviderQuota(
+ provider: .codexAlt,
+ windows: [QuotaWindow(label: "5-hour", usedPercent: 33, resetsAt: nil)],
+ capturedAt: Date(timeIntervalSince1970: 1_785_000_000)
+ ),
+ ]
+ QuotaArchive.save(quotas, to: url)
+ let loaded = QuotaArchive.load(from: url)
+ XCTAssertEqual(loaded[.codexAlt]?.windows.first?.usedPercent, 33)
+ try? FileManager.default.removeItem(at: url)
+ }
+
+ func testDurableHistoryKeysPerSlot() {
+ let url = FileManager.default.temporaryDirectory
+ .appendingPathComponent("meterusage-tests-history-\(UUID().uuidString).json")
+ let store = DurableHistoryStore(storeURL: url)
+ let day = DailyActivity(
+ day: Date(timeIntervalSince1970: 1_785_000_000),
+ tokens: TokenTotals(input: 100, output: 10),
+ estimatedCostUSD: 0,
+ sessionCount: 2
+ )
+ store.record(provider: .codexAlt, daily: [day])
+ XCTAssertEqual(store.records(for: .codexAlt).first?.tokens.input, 100)
+ XCTAssertNil(store.records(for: .codex).first, "second-account history must not leak into the primary slot")
+ try? FileManager.default.removeItem(at: url)
+ }
+
+ func testLimitsReportEmitsDistinctSlotEntries() {
+ let now = Date()
+ let report = LimitsReporter.build(
+ quotas: [
+ .codex: .value(ProviderQuota(
+ provider: .codex,
+ windows: [QuotaWindow(label: "5-hour", usedPercent: 10, resetsAt: nil)],
+ capturedAt: now
+ )),
+ .codexAlt: .value(ProviderQuota(
+ provider: .codexAlt,
+ windows: [QuotaWindow(label: "5-hour", usedPercent: 80, resetsAt: nil)],
+ capturedAt: now
+ )),
+ ],
+ order: [.codex, .codexAlt],
+ now: now
+ )
+
+ XCTAssertEqual(report.providers.map(\.provider), ["codex", "codexAlt"])
+ XCTAssertEqual(report.providers.map(\.windows.first?.usedPercent), [10, 80])
+ }
+}
+
+private struct StubQuotaSource: QuotaSource {
+ let provider: Provider
+ let windows: [QuotaWindow]
+
+ func fetchQuota() async throws -> ProviderQuota {
+ ProviderQuota(provider: provider, windows: windows, capturedAt: Date())
+ }
+}
diff --git a/Tests/MeterUsageTests/SideNotchPanelTests.swift b/Tests/MeterUsageTests/SideNotchPanelTests.swift
index 61df4b0..19edd48 100644
--- a/Tests/MeterUsageTests/SideNotchPanelTests.swift
+++ b/Tests/MeterUsageTests/SideNotchPanelTests.swift
@@ -837,13 +837,13 @@ final class SideNotchPanelTests: XCTestCase {
let prefs = Preferences(defaults: defaults)
let withoutConsumer = AppCoordinator(preferences: prefs)
- XCTAssertFalse(withoutConsumer.canUseCodexReset)
+ XCTAssertFalse(withoutConsumer.canUseCodexReset())
let withConsumer = AppCoordinator(
preferences: prefs,
resetConsumer: StubResetConsumer()
)
- XCTAssertTrue(withConsumer.canUseCodexReset)
+ XCTAssertTrue(withConsumer.canUseCodexReset())
}
@MainActor
diff --git a/Tests/MeterUsageTests/SupplementalUsageSourceTests.swift b/Tests/MeterUsageTests/SupplementalUsageSourceTests.swift
index ed33c2c..81a1149 100644
--- a/Tests/MeterUsageTests/SupplementalUsageSourceTests.swift
+++ b/Tests/MeterUsageTests/SupplementalUsageSourceTests.swift
@@ -14,7 +14,7 @@ final class SupplementalUsageSourceTests: XCTestCase {
func testProviderOrderIsCodexFirst() {
XCTAssertEqual(
Provider.allCases,
- [.codex, .antigravity, .grok, .openCodeGo, .openRouter, .claude, .cursor, .copilot, .gemini]
+ [.codex, .codexAlt, .antigravity, .grok, .openCodeGo, .openRouter, .claude, .claudeAlt, .cursor, .copilot, .gemini]
)
}
diff --git a/docs/KB.md b/docs/KB.md
index 0813fd0..cf3b9a0 100644
--- a/docs/KB.md
+++ b/docs/KB.md
@@ -25,6 +25,7 @@ Last verified: 2026-09-25
- Providers tray icon in Settings (the small button beside the "Notch" caption) uses the `menubar.rectangle` symbol for both switch states; state is carried by tint. `menubar.rectangle.fill` is absent from the system symbol catalog (returns nil, renders as nothing), so a filled variant must be probed with `NSImage(systemSymbolName:)` before use.
- `status.openai.com` runs on incident.io, which serves a Statuspage-compatible `components.json` but uses `full_outage` where Atlassian pages use `major_outage`. `StatusPageSource.severity(for:)` maps both to `.majorOutage`; an unmapped status degrades to `.unknown`, never to a false all-clear. Verified against the live feed on 2026-09-25 during an active Codex outage.
- Provider mark tint is identity unless the service check diverges from healthy (`MenuBarLabel.statusTint(_:for:)`): operational keeps `providerColor`, degraded/outage repaint amber/red, unreadable goes neutral grey. Quota headroom tint stays on rings and percents only; the side notch, menu bar, and status rows share this rule per ADR 0002's mark semantics.
+- Second accounts (multi-account support, ADR 0005): `.codexAlt` / `.claudeAlt` provider slots are keyed by an alternate config directory resolved in `AccountSlots` (`Sources/MeterUsage/Services/DataSource.swift`) from `METERUSAGE_CODEX_ALT_HOME` / `METERUSAGE_CLAUDE_ALT_CONFIG` or the `meterusage.codexAltHome` / `meterusage.claudeAltConfig` defaults keys. A slot exists only when its directory exists; slots never merge with the primary account, keep per-slot durable history and archive keys, and are named by position only (no account identity is read). The JSON report lists them under `codexAlt` / `claudeAlt`.
## Verification gaps
@@ -41,6 +42,7 @@ Last verified: 2026-09-25
- [`docs/PRIVACY.md`](PRIVACY.md): data-handling boundaries and enforcement claims.
- [`docs/DEMO.md`](DEMO.md): synthetic demo mode.
- [`docs/SIDE-NOTCH.md`](SIDE-NOTCH.md): side notch states, anchor and geometry invariants, interaction, and verification.
+- [`docs/adr/0005-multi-account-slots.md`](adr/0005-multi-account-slots.md): second-account slot decision.
- [`docs/mockups/README.md`](mockups/README.md): Wave 1–3 concept mockups; provider marks stay.
- [`docs/plans/feature-and-windows-pipeline.md`](plans/feature-and-windows-pipeline.md): macOS feature waves + Windows track.
- [`Package.swift`](../Package.swift): package targets and platform requirement.
diff --git a/docs/PRIVACY.md b/docs/PRIVACY.md
index 10bb3be..40471a7 100644
--- a/docs/PRIVACY.md
+++ b/docs/PRIVACY.md
@@ -27,10 +27,12 @@ meterusage reads AI coding-assistant usage from your own machine. That means it
| Source | Mechanism | Network? |
|---|---|---|
| Codex quota | Spawns `codex app-server --stdio` and makes a JSON-RPC `account/rateLimits/read` call with the CLI's experimental rate-limit detail capability enabled. That returns general/model-specific windows and earned reset-credit expiry details when the account provides them. This is a supported CLI surface; the subprocess authenticates itself using your existing `codex login`. meterusage never sees the token. | Yes, by the CLI subprocess |
+| Second Codex account | The same subprocess mechanism with `CODEX_HOME` pointed at the alternate account's config directory (`METERUSAGE_CODEX_ALT_HOME` or a stored default). The child authenticates itself from that home; meterusage still never opens any auth file, and only spawns the subprocess when that directory exists. | Yes, by the CLI subprocess |
| OpenRouter quota | Calls the documented `/api/v1/key` and `/api/v1/credits` endpoints with an existing API key and retains only aggregate dollar usage, account balance, optional limit, and reset cadence. It does not send prompts or model requests. | Yes |
| OpenRouter activity | Calls `https://openrouter.ai/api/v1/activity` with an OpenRouter Management Key (`OPENROUTER_MANAGEMENT_KEY` or `~/.cli-proxy-api/openrouter-management-key`) to fetch aggregate daily token volume (input, output, reasoning) over the last 30 days. | Yes |
| Grok quota | Calls the billing endpoint the Grok CLI itself uses (`cli-chat-proxy.grok.com/v1/billing`). The OIDC bearer token is re-read from `~/.grok/auth.json` on every refresh — never cached from launch — and is sent only in the request Authorization header. Only the allowance percent, period type, and reset time are retained; no prompts or model requests are sent. | Yes |
| Claude activity | Streams your own transcript files under `~/.claude/projects/`, summing token-usage fields. | No |
+| Second Claude account | The same local-only mechanisms pointed at the alternate account's config directory (`METERUSAGE_CLAUDE_ALT_CONFIG` or a stored default): transcripts under `/projects/`, the plan tier via the same narrow decode of `/.claude.json`, and a companion quota snapshot inside that directory. Nothing is read outside that directory for the second account — in particular, the primary account's `.claude.json` is never consulted for it, because a wrong-but-plausible plan badge is worse than none. | No |
| Codex activity | Counts sessions per day from `~/.codex/sessions/**/*.jsonl`, reading only the start timestamp on each rollout's first event (falling back to the file's modification date). Session payloads are never opened. | No |
| Claude quota *(optional)* | Read-only parse of a local usage snapshot if a companion already wrote one (`~/.claude/claudewatch-usage.json` or `~/.claude/meterusage-usage.json`). Supports legacy `five_hour` / `seven_day` / `weekly` shapes and, when present, a `limits[]` array (session / weekly_all / weekly_scoped, including Fable). meterusage does **not** fetch Anthropic quota and does **not** read Claude credentials. Absent by default and never requested. | No |
| Antigravity quota | Selects a healthy available Docker or Podman runtime, then runs `agy -p "/usage"` inside the same container image and volumes the user's agy wrapper uses. If no runtime is healthy, it may start only an existing `podman-machine-default` after inspecting that machine. It never creates a machine. It reads only each row's model group, window label, percent remaining, and reset timestamp. No prompt or model request is sent; the CLI's own backend quota refresh runs inside the container. No credential leaves the volume. | Yes, by the CLI |
@@ -92,6 +94,8 @@ Provider payloads and local transcripts both carry material that should not reac
- Session UUIDs are replaced with opaque, non-reversible ids used only for list diffing.
- The Codex RPC handshake returns a machine `installationId`, a hostname, and a user-agent string identifying your terminal. All three are dropped on read.
+Second-account slots follow the same boundary with one addition: a slot is identified by **position only** ("Codex second account", digit "2"). No email, account id, org name, or any provider-issued identity is read to name it — the tools keep multiple accounts in separate config directories, and meterusage keys the slot on the directory you configured, nothing inside it. The directory path is rendered only back to you in Settings, reduced to `~` form, and never enters caches, diagnostics, or the JSON report.
+
Because these conversions happen in the source layer rather than the view layer, there is no code path that renders the raw value.
The supplemental sources apply the same boundary by construction: Antigravity reduces its conversation stores to numeric token fields and turn timestamps, Grok reduces its stores to counts and dates, and OpenCode Go asks its database command for numeric usage columns only. Provider names and metric colours are rendered alongside written labels, so colour is never the only meaningful signal.
diff --git a/docs/adr/0005-multi-account-slots.md b/docs/adr/0005-multi-account-slots.md
new file mode 100644
index 0000000..19ac37b
--- /dev/null
+++ b/docs/adr/0005-multi-account-slots.md
@@ -0,0 +1,65 @@
+# ADR 0005: Second accounts are per-directory provider slots
+
+## Status
+
+Accepted (2026-09-26)
+
+## Context
+
+People who hold two accounts with the same CLI tool (a personal and a work
+Codex login, or two Claude Code accounts) still get one meter row per tool.
+The windows those rows report come from one authenticated CLI session each,
+so a two-account user sees only the first account's budget and a token total
+that silently mixes both accounts.
+
+meterusage's privacy contract forbids reading account identity: no
+`auth.json`, no credential material, no OAuth account fields. That rules out
+ enumerating accounts from provider state. It also rules out merging two
+accounts' windows into one row: a merged reading would be a third, fictional
+budget — neither account's percent used is the other's.
+
+## Decision
+
+A second account is a **provider slot**: a new `Provider` case (`.codexAlt`,
+`.claudeAlt`) that every surface treats as its own meter row — quota card,
+ring, tray cluster, alerts, durable history, archive, and the machine-readable
+JSON report. Slots are keyed by config directory, the only account boundary
+these CLIs expose:
+
+- Codex: `CODEX_HOME` relocation. The slot's source spawns its own
+ `codex app-server` subprocess with that home set, so the subprocess
+ authenticates as the second account. meterusage still never reads anything
+ inside it.
+- Claude: `CLAUDE_CONFIG_DIR` relocation. The slot scans that directory's
+ `projects/` transcript tree, reads the plan tier from its own `.claude.json`
+ (narrow decode, same as the primary slot), and parses a companion quota
+ snapshot inside it.
+
+Resolution lives in one place (`AccountSlots`): the path comes from the
+environment (`METERUSAGE_CODEX_ALT_HOME`, `METERUSAGE_CLAUDE_ALT_CONFIG`) or
+the stored defaults keys (`meterusage.codexAltHome`,
+`meterusage.claudeAltConfig`). A slot exists only when its directory exists —
+an alternate slot without its directory is not an account, so it never polls,
+renders, or alerts.
+
+Slots are labelled by position ("Codex second account", digit "2" beside the
+mark). No account identifier is read or displayed; the user's own directory
+path is rendered only back to them in Settings, reduced to `~` form.
+
+## Consequences
+
+- Single-account installs are unchanged: the slots default off and absent
+ directories gate them out of every surface.
+- One alternate account per tool is supported. A third account would need
+ either more slot cases or a general `(provider, slot)` identity axis through
+ the coordinator maps; the case-based seam keeps that a deliberate future
+ decision rather than an accident.
+- Per-slot state (durable history, quota archive, alerts) keys on the slot's
+ raw value (`codexAlt`, `claudeAlt`). Older app versions reading a newer
+ archive degrade to an empty archive rather than an error.
+- The JSON report gains `codexAlt` / `claudeAlt` provider entries. The schema
+ is unchanged (additive entries); consumers keyed on provider strings must
+ treat unknown entries as additional meters, not errors.
+- Service health stays per service: an alternate slot resolves its status
+ through its base provider, so one outage tints both accounts without
+ polling a public feed twice.
diff --git a/docs/adr/README.md b/docs/adr/README.md
index acd7ff6..e07b0c8 100644
--- a/docs/adr/README.md
+++ b/docs/adr/README.md
@@ -10,3 +10,4 @@ This directory records decisions that affect repository work. Read an ADR before
| [0002](0002-provider-marks-stay.md) | Keep existing provider logos/marks (`ProviderMark` + `Resources/*-logo.png`); do not redesign them in feature or cross-platform work. | Accepted |
| [0003](0003-burn-attribution-scope.md) | Scope burn attribution to the last 7 days across all token-bearing providers, with per-provider aggregates that never count as long chats. | Accepted |
| [0004](0004-side-notch-anchor-invariant.md) | Keep the side notch anchored by the strip's top-right corner, use whole-point frames, anchor content to the window top, and require captured frame evidence for layout or motion changes. | Accepted |
+| [0005](0005-multi-account-slots.md) | Model a second Claude/Codex account as its own provider slot keyed by an alternate config directory; never merge two accounts' windows and never read or display account identity. | Accepted |
From fd2e33cbc435105f6eff37e64c1b3fda7345841f Mon Sep 17 00:00:00 2001
From: pekth <5535435+pekth@users.noreply.github.com>
Date: Sat, 26 Sep 2026 21:21:08 -0400
Subject: [PATCH 2/6] fix(settings): always show second-account rows with a
directory field
The second-account rows only appeared once the alternate config
directory already existed, so a fresh install showed nothing under
Settings and the feature was undiscoverable. The rows now always show
with an editable directory field (the same keys the environment
overrides), and the Accounts caption explains the setup flow.
---
README.md | 17 +--
Sources/MeterUsage/Services/DataSource.swift | 24 ----
Sources/MeterUsage/Views/SettingsView.swift | 139 ++++++++++++++-----
docs/KB.md | 2 +-
docs/adr/0005-multi-account-slots.md | 3 +-
5 files changed, 117 insertions(+), 68 deletions(-)
diff --git a/README.md b/README.md
index 34c3ccd..e99d26f 100644
--- a/README.md
+++ b/README.md
@@ -119,26 +119,27 @@ heatmap, alerts, and (for Codex) reset credits. Slots are named by position
("Codex second account", a "2" beside the mark); no account identifier is ever
read or displayed.
-A slot exists when its alternate config directory exists on this Mac. Point
-meterusage at the second account's directory either through the environment or
-a stored default, then relaunch the app:
+A slot exists when its alternate config directory exists on this Mac. Set it
+in **Settings → Providers → "Codex second account" / "Claude second account"**
+(type the directory, e.g. `~/.codex-alt`, then relaunch MeterUsage), or point
+meterusage at it through the environment:
```sh
# Codex: the alternate home is passed to the codex subprocess the same way
# you would run the CLI for that account.
launchctl setenv METERUSAGE_CODEX_ALT_HOME ~/.codex-alt
-# or: defaults write meterusage meterusage.codexAltHome ~/.codex-alt
# Claude: the alternate config directory (CLAUDE_CONFIG_DIR-equivalent) holds
# that account's projects/ transcripts, .claude.json plan tier, and any
# companion quota snapshot.
launchctl setenv METERUSAGE_CLAUDE_ALT_CONFIG ~/.claude-alt
-# or: defaults write meterusage meterusage.claudeAltConfig ~/.claude-alt
```
-The environment wins over the stored default. Toggle the slots on or off in
-Settings → Providers; both accounts always stay under their own keys in the
-Agent Budget API (`codexAlt` / `claudeAlt`).
+The environment wins over the stored default. Sign the second CLI account in
+under that directory (e.g. `CODEX_HOME=~/.codex-alt codex login`); readings
+appear after relaunch. Toggle the slots on or off in Settings → Providers;
+both accounts always stay under their own keys in the Agent Budget API
+(`codexAlt` / `claudeAlt`).
---
diff --git a/Sources/MeterUsage/Services/DataSource.swift b/Sources/MeterUsage/Services/DataSource.swift
index 55755b8..1cb7362 100644
--- a/Sources/MeterUsage/Services/DataSource.swift
+++ b/Sources/MeterUsage/Services/DataSource.swift
@@ -172,30 +172,6 @@ public enum AccountSlots {
default: return true
}
}
-
- /// Tilde-relative display of a slot home (Settings rows only). The path
- /// never enters caches, diagnostics, or the JSON report; this renders the
- /// user's own configuration back to them, with the OS username reduced to
- /// `~` exactly like any shell prompt.
- public static func displayPath(
- for provider: Provider,
- homes: Resolved,
- home: URL = HomeDirectory.real
- ) -> String? {
- let url: URL?
- switch provider {
- case .codexAlt: url = homes.codexAltHome
- case .claudeAlt: url = homes.claudeAltConfig
- default: url = nil
- }
- guard let url else { return nil }
- let homePath = home.path
- if url.path == homePath { return "~" }
- if url.path.hasPrefix(homePath + "/") {
- return "~" + url.path.dropFirst(homePath.count)
- }
- return url.path
- }
}
// MARK: - Privacy
diff --git a/Sources/MeterUsage/Views/SettingsView.swift b/Sources/MeterUsage/Views/SettingsView.swift
index 36799ed..52dbdbe 100644
--- a/Sources/MeterUsage/Views/SettingsView.swift
+++ b/Sources/MeterUsage/Views/SettingsView.swift
@@ -19,6 +19,10 @@ struct SettingsView: View {
@AppStorage(PrefKey.showCodex) private var showCodex: Bool = true
@AppStorage(PrefKey.showCodexAlt) private var showCodexAlt: Bool = false
@AppStorage(PrefKey.showClaudeAlt) private var showClaudeAlt: Bool = false
+ // Second-account config directories. Empty means "not configured"; the
+ // same keys AccountSlots resolves (environment still wins over these).
+ @AppStorage(AccountSlots.codexAltHomeKey) private var codexAltHomePath: String = ""
+ @AppStorage(AccountSlots.claudeAltConfigKey) private var claudeAltHomePath: String = ""
@AppStorage(PrefKey.showAntigravity) private var showAntigravity: Bool = false
@AppStorage(PrefKey.showGrok) private var showGrok: Bool = false
@AppStorage(PrefKey.showOpenCodeGo) private var showOpenCodeGo: Bool = true
@@ -87,15 +91,19 @@ struct SettingsView: View {
isOn: $showCodex,
menuBarIsOn: $menuBarCodex
)
- if codexAltPresent {
- Divider().overlay(MU.hairline)
- ProviderRow(
- provider: .codexAlt,
- subtitle: "Second Codex account · \(codexAltPath ?? "")",
- isOn: $showCodexAlt,
- menuBarIsOn: $menuBarCodexAlt
- )
- }
+ Divider().overlay(MU.hairline)
+ // Second-account rows always show with their directory
+ // field, so the feature is discoverable before anything
+ // is configured. The card itself still appears only once
+ // that directory exists (after a relaunch).
+ AltAccountRow(
+ provider: .codexAlt,
+ subtitle: "Second CODEX home · quota and reset credits",
+ pathPlaceholder: "~/.codex-alt",
+ isOn: $showCodexAlt,
+ menuBarIsOn: $menuBarCodexAlt,
+ homePath: $codexAltHomePath
+ )
Divider().overlay(MU.hairline)
ProviderRow(
provider: .antigravity,
@@ -131,15 +139,15 @@ struct SettingsView: View {
isOn: $showClaude,
menuBarIsOn: $menuBarClaude
)
- if claudeAltPresent {
- Divider().overlay(MU.hairline)
- ProviderRow(
- provider: .claudeAlt,
- subtitle: "Second Claude account · \(claudeAltPath ?? "")",
- isOn: $showClaudeAlt,
- menuBarIsOn: $menuBarClaudeAlt
- )
- }
+ Divider().overlay(MU.hairline)
+ AltAccountRow(
+ provider: .claudeAlt,
+ subtitle: "Second CLAUDE_CONFIG_DIR · plan and sessions",
+ pathPlaceholder: "~/.claude-alt",
+ isOn: $showClaudeAlt,
+ menuBarIsOn: $menuBarClaudeAlt,
+ homePath: $claudeAltHomePath
+ )
Divider().overlay(MU.hairline)
ProviderRow(
provider: .cursor,
@@ -196,12 +204,11 @@ struct SettingsView: View {
Text("Switching a provider off above stops reading its credential entirely. It does not sign you out of that tool.")
.font(.muCaption)
.foregroundColor(MU.textTertiary)
- if codexAltPresent || claudeAltPresent {
- Divider().overlay(MU.hairline)
- Text("A second account appears when its config directory exists — \(codexAltPresent ? AccountSlots.displayPath(for: .codexAlt, homes: coordinator.slotHomes) ?? "" : "")\(codexAltPresent && claudeAltPresent ? " · " : "")\(claudeAltPresent ? AccountSlots.displayPath(for: .claudeAlt, homes: coordinator.slotHomes) ?? "" : "").")
- .font(.muCaption)
- .foregroundColor(MU.textTertiary)
- }
+ Divider().overlay(MU.hairline)
+ Text("A second account appears once its directory exists: sign that CLI in under the directory you set above (for example CODEX_HOME=~/.codex-alt codex login), then relaunch MeterUsage.")
+ .font(.muCaption)
+ .foregroundColor(MU.textTertiary)
+ .fixedSize(horizontal: false, vertical: true)
}
}
@@ -362,8 +369,8 @@ struct SettingsView: View {
// MARK: Second-account slots
- // Demo builds mount synthetic second-account rows, so their toggles show
- // there even though no real config directory exists.
+ // Demo builds mount synthetic second-account rows, so the presence-based
+ // Accounts list shows them even though no real config directory exists.
private var codexAltPresent: Bool {
coordinator.isDemoMode || AccountSlots.isPresent(.codexAlt, homes: coordinator.slotHomes)
@@ -373,14 +380,6 @@ struct SettingsView: View {
coordinator.isDemoMode || AccountSlots.isPresent(.claudeAlt, homes: coordinator.slotHomes)
}
- private var codexAltPath: String? {
- AccountSlots.displayPath(for: .codexAlt, homes: coordinator.slotHomes)
- }
-
- private var claudeAltPath: String? {
- AccountSlots.displayPath(for: .claudeAlt, homes: coordinator.slotHomes)
- }
-
private var visibleAccounts: [Provider] {
// The coordinator's visibility, not the raw preference: an
// alternate-account slot with no config directory is not an account,
@@ -578,6 +577,78 @@ private struct ProviderRow: View {
}
}
+/// One second-account row: the same switch pair as `ProviderRow` plus a
+/// directory field, because an alternate account is *defined* by the config
+/// directory it points at. The field stores the raw string the user typed
+/// (usually tilde-relative); it never displays a resolved absolute path.
+private struct AltAccountRow: View {
+ let provider: Provider
+ let subtitle: String
+ let pathPlaceholder: String
+ @Binding var isOn: Bool
+ var menuBarIsOn: Binding?
+ @Binding var homePath: String
+
+ @State private var hoveringTray = false
+
+ var body: some View {
+ VStack(alignment: .leading, spacing: 6) {
+ HStack(alignment: .center, spacing: 8) {
+ ProviderMark(provider: provider, tint: providerColor(provider))
+ .frame(width: 13, height: 13)
+ VStack(alignment: .leading, spacing: 1) {
+ Text(provider.displayName)
+ .font(.muBody)
+ .foregroundColor(MU.text)
+ Text(subtitle)
+ .font(.muCaption)
+ .foregroundColor(MU.textTertiary)
+ .lineLimit(1)
+ }
+ Spacer(minLength: 6)
+ if let menuBarIsOn {
+ HStack(spacing: 4) {
+ Text("Notch")
+ .font(.muCaption)
+ .foregroundColor(MU.textTertiary)
+ Button {
+ menuBarIsOn.wrappedValue.toggle()
+ } label: {
+ Image(systemName: "menubar.rectangle")
+ .font(.system(size: 11, weight: .medium))
+ .foregroundColor(
+ menuBarIsOn.wrappedValue
+ ? MU.calm
+ : (hoveringTray ? MU.textSecondary : MU.textTertiary.opacity(0.6))
+ )
+ .frame(width: 22, height: 20)
+ .background(
+ RoundedRectangle(cornerRadius: 4, style: .continuous)
+ .fill(hoveringTray ? MU.well : Color.clear)
+ )
+ }
+ .buttonStyle(.plain)
+ .help(menuBarIsOn.wrappedValue
+ ? "Hide \(provider.displayName) from the side notch and menu bar"
+ : "Show \(provider.displayName) in the side notch and menu bar")
+ .onHover { hoveringTray = $0 }
+ }
+ }
+ Toggle("Show \(provider.displayName)", isOn: $isOn)
+ .labelsHidden()
+ .toggleStyle(.switch)
+ .controlSize(.mini)
+ }
+ TextField("Directory, e.g. \(pathPlaceholder)", text: $homePath, prompt: Text(pathPlaceholder))
+ .textFieldStyle(.roundedBorder)
+ .controlSize(.small)
+ .font(.muCaption)
+ .help("The config directory of the second account. Readings appear after MeterUsage relaunches.")
+ }
+ .accessibilityElement(children: .contain)
+ }
+}
+
/// One row of the Accounts section: the provider's mark, its reported plan
/// (when the provider reports one), and the tool holding the credential.
/// Read-only — visibility lives in the Providers section above.
diff --git a/docs/KB.md b/docs/KB.md
index cf3b9a0..d12a4f0 100644
--- a/docs/KB.md
+++ b/docs/KB.md
@@ -25,7 +25,7 @@ Last verified: 2026-09-25
- Providers tray icon in Settings (the small button beside the "Notch" caption) uses the `menubar.rectangle` symbol for both switch states; state is carried by tint. `menubar.rectangle.fill` is absent from the system symbol catalog (returns nil, renders as nothing), so a filled variant must be probed with `NSImage(systemSymbolName:)` before use.
- `status.openai.com` runs on incident.io, which serves a Statuspage-compatible `components.json` but uses `full_outage` where Atlassian pages use `major_outage`. `StatusPageSource.severity(for:)` maps both to `.majorOutage`; an unmapped status degrades to `.unknown`, never to a false all-clear. Verified against the live feed on 2026-09-25 during an active Codex outage.
- Provider mark tint is identity unless the service check diverges from healthy (`MenuBarLabel.statusTint(_:for:)`): operational keeps `providerColor`, degraded/outage repaint amber/red, unreadable goes neutral grey. Quota headroom tint stays on rings and percents only; the side notch, menu bar, and status rows share this rule per ADR 0002's mark semantics.
-- Second accounts (multi-account support, ADR 0005): `.codexAlt` / `.claudeAlt` provider slots are keyed by an alternate config directory resolved in `AccountSlots` (`Sources/MeterUsage/Services/DataSource.swift`) from `METERUSAGE_CODEX_ALT_HOME` / `METERUSAGE_CLAUDE_ALT_CONFIG` or the `meterusage.codexAltHome` / `meterusage.claudeAltConfig` defaults keys. A slot exists only when its directory exists; slots never merge with the primary account, keep per-slot durable history and archive keys, and are named by position only (no account identity is read). The JSON report lists them under `codexAlt` / `claudeAlt`.
+- Second accounts (multi-account support, ADR 0005): `.codexAlt` / `.claudeAlt` provider slots are keyed by an alternate config directory resolved in `AccountSlots` (`Sources/MeterUsage/Services/DataSource.swift`) from `METERUSAGE_CODEX_ALT_HOME` / `METERUSAGE_CLAUDE_ALT_CONFIG` or the `meterusage.codexAltHome` / `meterusage.claudeAltConfig` defaults keys, which are editable in Settings → Providers. A slot exists only when its directory exists; slots never merge with the primary account, keep per-slot durable history and archive keys, and are named by position only (no account identity is read). The JSON report lists them under `codexAlt` / `claudeAlt`.
## Verification gaps
diff --git a/docs/adr/0005-multi-account-slots.md b/docs/adr/0005-multi-account-slots.md
index 19ac37b..819589b 100644
--- a/docs/adr/0005-multi-account-slots.md
+++ b/docs/adr/0005-multi-account-slots.md
@@ -38,7 +38,8 @@ these CLIs expose:
Resolution lives in one place (`AccountSlots`): the path comes from the
environment (`METERUSAGE_CODEX_ALT_HOME`, `METERUSAGE_CLAUDE_ALT_CONFIG`) or
the stored defaults keys (`meterusage.codexAltHome`,
-`meterusage.claudeAltConfig`). A slot exists only when its directory exists —
+`meterusage.claudeAltConfig`), which the Settings → Providers rows edit in
+place. A slot exists only when its directory exists —
an alternate slot without its directory is not an account, so it never polls,
renders, or alerts.
From f6152bd1c63c6dfc7bac9e239405d324e35492ec Mon Sep 17 00:00:00 2001
From: pekth <5535435+pekth@users.noreply.github.com>
Date: Sat, 26 Sep 2026 21:59:46 -0400
Subject: [PATCH 3/6] feat: add and remove any number of second accounts in
Settings
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The two fixed second-account slots capped at one extra account per
tool. Accounts are now a managed list: Settings gains a Second accounts
card with add and remove per row, a name, and the config directory —
a user with three Codex logins adds three rows and the app meters each.
Every surface keys readings by the slot's identity (provider + generated
id), so labels can change without orphaning history, archive, or alert
state; the JSON report carries the label in an additive account field.
Readings appear after relaunch, when composition builds each account's
sources.
---
Sources/MeterUsage/App/AppDelegate.swift | 154 ++++----
Sources/MeterUsage/Core/AppCoordinator.swift | 268 +++++++-------
Sources/MeterUsage/Core/CliJSON.swift | 30 +-
Sources/MeterUsage/Core/LimitsReport.swift | 31 +-
Sources/MeterUsage/Core/Preferences.swift | 67 +++-
Sources/MeterUsage/Core/QuotaAlerts.swift | 73 ++--
Sources/MeterUsage/Models/UsageModels.swift | 219 ++++++++----
.../Services/BurnAttributionCalculator.swift | 18 +-
.../Services/ClaudeLocalSource.swift | 15 +-
.../Services/ClaudePlanSource.swift | 17 +-
.../Services/CodexLocalSource.swift | 18 +-
.../Services/CodexQuotaSource.swift | 33 +-
Sources/MeterUsage/Services/DataSource.swift | 151 +++-----
Sources/MeterUsage/Services/DemoSources.swift | 49 +--
.../Services/DiagnosticsReport.swift | 26 +-
.../Services/DurableHistoryStore.swift | 24 +-
.../Services/OptionalQuotaFileSource.swift | 17 +-
.../MeterUsage/Services/QuotaArchive.swift | 26 +-
.../Services/StatusPageSource.swift | 6 +-
Sources/MeterUsage/Views/MenuBarLabel.swift | 96 ++---
Sources/MeterUsage/Views/PopoverRoot.swift | 100 +++---
Sources/MeterUsage/Views/QuotaSection.swift | 22 +-
Sources/MeterUsage/Views/SettingsView.swift | 235 ++++++------
.../MeterUsage/Views/SharedComponents.swift | 2 +-
.../MeterUsage/Views/SideNotchPanelView.swift | 233 ++++++------
Tests/MeterUsageTests/BurnRecencyTests.swift | 20 +-
.../DurableHistoryStoreTests.swift | 4 +-
Tests/MeterUsageTests/LimitsReportTests.swift | 4 +-
Tests/MeterUsageTests/MarkTintTests.swift | 6 +-
Tests/MeterUsageTests/MenuBarTests.swift | 6 +-
Tests/MeterUsageTests/MultiAccountTests.swift | 337 +++++++++---------
Tests/MeterUsageTests/NewPipelineTests.swift | 6 +-
Tests/MeterUsageTests/QuotaAlertsTests.swift | 4 +-
Tests/MeterUsageTests/QuotaArchiveTests.swift | 8 +-
.../MeterUsageTests/SideNotchPanelTests.swift | 38 +-
.../SupplementalUsageSourceTests.swift | 2 +-
36 files changed, 1277 insertions(+), 1088 deletions(-)
diff --git a/Sources/MeterUsage/App/AppDelegate.swift b/Sources/MeterUsage/App/AppDelegate.swift
index 5e2e45e..33272f8 100644
--- a/Sources/MeterUsage/App/AppDelegate.swift
+++ b/Sources/MeterUsage/App/AppDelegate.swift
@@ -40,8 +40,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
planSources: Composition.planSources(),
// Same factory, so "clear cache" can rebuild the activity sources
// and get a genuinely cold scan rather than a re-warmed one.
- activitySourceFactory: Composition.activitySources,
- slotHomes: Composition.slotHomes
+ activitySourceFactory: Composition.activitySources
)
self.preferences = preferences
self.coordinator = coordinator
@@ -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.status(for: 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,13 +264,13 @@ enum Composition {
static func quotaSources() -> [QuotaSource] {
if isDemoMode {
- // Demo mounts both accounts per tool, so multi-account rows get
- // real screenshot coverage. All numbers stay synthetic.
+ // Demo mounts a second account per tool, so multi-account rows
+ // get real screenshot coverage. All numbers stay synthetic.
return [
DemoClaudeQuotaSource(),
- DemoClaudeQuotaSource(provider: .claudeAlt),
+ DemoClaudeQuotaSource(slot: Self.demoSlot(.claude)),
DemoCodexQuotaSource(),
- DemoCodexQuotaSource(provider: .codexAlt),
+ DemoCodexQuotaSource(slot: Self.demoSlot(.codex)),
DemoOpenRouterQuotaSource(), DemoOpenCodeGoQuotaSource(), DemoGrokQuotaSource(), DemoAntigravityQuotaSource()
]
}
@@ -301,31 +300,37 @@ enum Composition {
// Google Gemini CLI quota and accounts
GeminiQuotaSource()
]
- // A second Codex account exists when the user has configured an
- // alternate Codex home (`AccountSlots`). Its source spawns the CLI
- // with that home, so the subprocess authenticates as the other
- // account; meterusage still never touches any auth file.
- if let codexAltHome = slotHomes.codexAltHome {
- sources.append(
- CodexQuotaSource(
- provider: .codexAlt,
- client: SubprocessJSONRPCClient(codexHome: codexAltHome)
+ // 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)
+ )
)
- )
- }
- // A second Claude account reads its companion-written quota snapshot
- // inside the alternate config directory, if one exists. Absence is
- // the normal `.noData` state, exactly like the primary slot.
- if let claudeAltConfig = slotHomes.claudeAltConfig {
- sources.append(
- OptionalQuotaFileSource(
- provider: .claudeAlt,
- candidatePaths: [
- claudeAltConfig.appendingPathComponent("meterusage-usage.json"),
- claudeAltConfig.appendingPathComponent("claudewatch-usage.json"),
- ]
+ 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
}
@@ -334,30 +339,36 @@ enum Composition {
if isDemoMode {
return [
DemoLocalActivitySource(),
- DemoLocalActivitySource(provider: .claudeAlt),
+ DemoLocalActivitySource(slot: Self.demoSlot(.claude)),
DemoCodexActivitySource(),
- DemoCodexActivitySource(provider: .codexAlt)
+ DemoCodexActivitySource(slot: Self.demoSlot(.codex))
]
}
var sources: [LocalActivitySource] = [ClaudeLocalSource(), CodexLocalSource()]
- if let codexAltHome = slotHomes.codexAltHome {
- // The alternate account's rollout store lives inside its own
- // home; the rollout format is the primary account's, relocated.
- sources.append(
- CodexLocalSource(provider: .codexAlt, root: codexAltHome.appendingPathComponent("sessions", isDirectory: true))
- )
- }
- if let claudeAltConfig = slotHomes.claudeAltConfig {
- // 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(
- provider: .claudeAlt,
- root: claudeAltConfig.appendingPathComponent("projects", isDirectory: true),
- cacheFileURL: Self.cacheDirectory.appendingPathComponent("claude-local-scan-cache-alt.json")
+ 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
}
@@ -402,26 +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.
- /// The second Claude account reads its tier from the alternate config
- /// directory's own account file, never falling back to the primary
- /// account's — a wrong-but-plausible tier is worse than none.
+ /// 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] {
if isDemoMode {
- return [DemoPlanSource(), DemoPlanSource(provider: .claudeAlt, tier: .max20x)]
+ return [
+ DemoPlanSource(),
+ DemoPlanSource(slot: Self.demoSlot(.claude), tier: .max20x)
+ ]
}
var sources: [PlanSource] = [ClaudePlanSource()]
- if let claudeAltConfig = slotHomes.claudeAltConfig {
+ for account in managedAccounts() where account.provider == .claude {
+ guard let home = ManagedAccountPaths.home(for: account) else { continue }
sources.append(
- ClaudePlanSource(provider: .claudeAlt, fileURL: claudeAltConfig.appendingPathComponent(".claude.json"))
+ ClaudePlanSource(slot: account.slot, fileURL: home.appendingPathComponent(".claude.json"))
)
}
return sources
}
- /// Alternate-account homes, resolved once per launch. Composition-root
- /// state: sources, the coordinator, and Settings all read the same
- /// resolution rather than re-reading the environment.
- static let slotHomes: AccountSlots.Resolved = isDemoMode
- ? .none
- : AccountSlots.resolve()
+ /// 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 4f9559e..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
@@ -72,7 +75,7 @@ final class AppCoordinator: ObservableObject {
/// 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: [Provider: QuotaResetConsumer]
+ 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]
@@ -87,11 +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?
- /// Resolved alternate-account homes. A slot only polls, renders, and
- /// alerts when its own config directory exists — an alternate account is
- /// a directory the user configured, not a guess. Injectable so tests can
- /// point slots at fixture directories.
- let slotHomes: AccountSlots.Resolved
+ /// 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
@@ -144,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(
@@ -153,14 +155,14 @@ final class AppCoordinator: ObservableObject {
isDemoMode: Bool = false,
quotaSources: [QuotaSource] = [],
resetConsumer: QuotaResetConsumer? = nil,
- resetConsumers: [Provider: QuotaResetConsumer] = [:],
+ resetConsumers: [ProviderSlot: QuotaResetConsumer] = [:],
activitySources: [LocalActivitySource] = [],
usageSources: [UsageSource] = [],
statusSources: [StatusSource] = [],
planSources: [PlanSource] = [],
activitySourceFactory: (() -> [LocalActivitySource])? = nil,
quotaArchiveURL: URL? = nil,
- slotHomes: AccountSlots.Resolved = .none
+ accountHome: ((ManagedAccount) -> URL?)? = nil
) {
self.preferences = preferences
self.isDemoMode = isDemoMode
@@ -170,10 +172,10 @@ final class AppCoordinator: ObservableObject {
// 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: [Provider: QuotaResetConsumer] = resetConsumers
+ var consumers: [ProviderSlot: QuotaResetConsumer] = resetConsumers
for source in quotaSources {
if let consumer = source as? QuotaResetConsumer {
- consumers[source.provider] = consumer
+ consumers[source.slot] = consumer
}
}
if let resetConsumer {
@@ -189,7 +191,9 @@ final class AppCoordinator: ObservableObject {
let archiveURL = quotaArchiveURL ?? QuotaArchive.defaultURL
self.quotaArchiveURL = archiveURL
self.archivedQuotas = QuotaArchive.load(from: archiveURL)
- self.slotHomes = slotHomes
+ // 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
@@ -202,9 +206,9 @@ final class AppCoordinator: ObservableObject {
private nonisolated static func resetConsumerSlot(
of consumer: QuotaResetConsumer,
sources: [QuotaSource]
- ) -> Provider {
- if let source = consumer as? QuotaSource { return source.provider }
- return sources.first(where: { $0 is QuotaResetConsumer })?.provider ?? .codex
+ ) -> ProviderSlot {
+ if let source = consumer as? QuotaSource { return source.slot }
+ return sources.first(where: { $0 is QuotaResetConsumer })?.slot ?? .primary(.codex)
}
// MARK: Lifecycle
@@ -346,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.
@@ -360,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
@@ -372,10 +377,10 @@ final class AppCoordinator: ObservableObject {
}
/// Whether a Codex rate-limit reset action is currently usable for a
- /// slot. Defaults to the primary account; a second Codex account is
+ /// slot. Defaults to the primary account; an additional Codex account is
/// redeemable only through its own source's subprocess.
- func canUseCodexReset(for provider: Provider = .codex) -> Bool {
- resetConsumers[provider] != nil
+ func canUseCodexReset(for slot: ProviderSlot = .primary(.codex)) -> Bool {
+ resetConsumers[slot] != nil
}
/// Performs the explicitly confirmed Codex reset and refreshes all data so
@@ -388,15 +393,15 @@ final class AppCoordinator: ObservableObject {
/// 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 provider: Provider = .codex) async throws {
- let claimedProvider = quotas.first(where: { _, state in
+ 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 ?? provider
- guard let consumer = resetConsumers[claimedProvider] ?? resetConsumers[provider] else {
- throw SourceUnavailable.failed(claimedProvider)
+ })?.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(claimedProvider)
+ throw SourceUnavailable.failed(claimedSlot.provider)
}
refresh()
}
@@ -409,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 showsProvider(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 showsProvider(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 showsProvider(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 showsProvider(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) }
}
}
@@ -442,46 +447,46 @@ 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) && showsProvider(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) && showsProvider(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) && showsProvider(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) && showsProvider(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()
}
@@ -490,17 +495,17 @@ final class AppCoordinator: ObservableObject {
/// 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] ?? provider.baseProvider.flatMap { statuses[$0] }
+ 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
@@ -513,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
}
@@ -528,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
@@ -553,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 {
@@ -566,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()
@@ -598,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 {
@@ -609,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 {
@@ -623,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.
@@ -637,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.
@@ -707,55 +712,69 @@ final class AppCoordinator: ObservableObject {
// MARK: Derived state
- /// Whether a slot's sources should be polled and shown: the provider
- /// toggle is on AND an alternate-account slot's config directory exists.
- /// Service status is polled independently of both gates (see
- /// `performRefresh`), so hiding an account never hides an outage.
- /// Demo mode mounts synthetic rows for both accounts of the demoed
- /// tools regardless of either gate — they exist to be screenshotted.
- func showsProvider(_ provider: Provider) -> Bool {
+ /// 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 provider.isAltSlot || preferences.isEnabled(provider)
+ return slot.isPrimary ? preferences.isEnabled(slot.provider) : true
}
- return preferences.isEnabled(provider)
- && AccountSlots.isPresent(provider, homes: slotHomes)
+ 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) }
}
- /// Providers the user has switched on, in a stable display order.
- var visibleProviders: [Provider] {
- Provider.allCases.filter { showsProvider($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] {
@@ -774,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
@@ -801,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 aaa4b51..7cdc738 100644
--- a/Sources/MeterUsage/Core/Preferences.swift
+++ b/Sources/MeterUsage/Core/Preferences.swift
@@ -20,12 +20,10 @@ enum PrefKey {
static let refreshInterval = "refreshIntervalSeconds"
static let showClaude = "showProviderClaude"
static let showCodex = "showProviderCodex"
- /// Second-account slots. These toggle an *additional* account of a tool
- /// whose data lives under a separately-configured config directory (see
- /// `AccountSlots`); they default off so a single-account install renders
- /// exactly as before.
- static let showCodexAlt = "showProviderCodexAlt"
- static let showClaudeAlt = "showProviderClaudeAlt"
+ /// 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"
@@ -35,8 +33,6 @@ enum PrefKey {
static let showGemini = "showProviderGemini"
static let menuBarClaude = "menuBarProviderClaude"
static let menuBarCodex = "menuBarProviderCodex"
- static let menuBarCodexAlt = "menuBarProviderCodexAlt"
- static let menuBarClaudeAlt = "menuBarProviderClaudeAlt"
static let menuBarAntigravity = "menuBarProviderAntigravity"
static let menuBarGrok = "menuBarProviderGrok"
static let menuBarOpenCodeGo = "menuBarProviderOpenCodeGo"
@@ -138,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?
@@ -154,8 +155,6 @@ final class Preferences: ObservableObject {
// normal use.
PrefKey.showClaude: false,
PrefKey.showCodex: true,
- PrefKey.showCodexAlt: false,
- PrefKey.showClaudeAlt: false,
PrefKey.showAntigravity: false,
PrefKey.showGrok: false,
PrefKey.showOpenCodeGo: true,
@@ -167,8 +166,6 @@ final class Preferences: ObservableObject {
// down to the ones they glance at.
PrefKey.menuBarClaude: true,
PrefKey.menuBarCodex: true,
- PrefKey.menuBarCodexAlt: true,
- PrefKey.menuBarClaudeAlt: true,
PrefKey.menuBarAntigravity: true,
PrefKey.menuBarGrok: true,
PrefKey.menuBarOpenCodeGo: true,
@@ -216,13 +213,11 @@ final class Preferences: ObservableObject {
var providers = Set()
if defaults.bool(forKey: PrefKey.showCodex) { providers.insert(.codex) }
- if defaults.bool(forKey: PrefKey.showCodexAlt) { providers.insert(.codexAlt) }
if defaults.bool(forKey: PrefKey.showAntigravity) { providers.insert(.antigravity) }
if defaults.bool(forKey: PrefKey.showGrok) { providers.insert(.grok) }
if defaults.bool(forKey: PrefKey.showOpenCodeGo) { providers.insert(.openCodeGo) }
if defaults.bool(forKey: PrefKey.showOpenRouter) { providers.insert(.openRouter) }
if defaults.bool(forKey: PrefKey.showClaude) { providers.insert(.claude) }
- if defaults.bool(forKey: PrefKey.showClaudeAlt) { providers.insert(.claudeAlt) }
if defaults.bool(forKey: PrefKey.showCursor) { providers.insert(.cursor) }
if defaults.bool(forKey: PrefKey.showCopilot) { providers.insert(.copilot) }
if defaults.bool(forKey: PrefKey.showGemini) { providers.insert(.gemini) }
@@ -230,13 +225,11 @@ final class Preferences: ObservableObject {
var menuBar = Set()
if defaults.bool(forKey: PrefKey.menuBarCodex) { menuBar.insert(.codex) }
- if defaults.bool(forKey: PrefKey.menuBarCodexAlt) { menuBar.insert(.codexAlt) }
if defaults.bool(forKey: PrefKey.menuBarAntigravity) { menuBar.insert(.antigravity) }
if defaults.bool(forKey: PrefKey.menuBarGrok) { menuBar.insert(.grok) }
if defaults.bool(forKey: PrefKey.menuBarOpenCodeGo) { menuBar.insert(.openCodeGo) }
if defaults.bool(forKey: PrefKey.menuBarOpenRouter) { menuBar.insert(.openRouter) }
if defaults.bool(forKey: PrefKey.menuBarClaude) { menuBar.insert(.claude) }
- if defaults.bool(forKey: PrefKey.menuBarClaudeAlt) { menuBar.insert(.claudeAlt) }
if defaults.bool(forKey: PrefKey.menuBarCursor) { menuBar.insert(.cursor) }
if defaults.bool(forKey: PrefKey.menuBarCopilot) { menuBar.insert(.copilot) }
if defaults.bool(forKey: PrefKey.menuBarGemini) { menuBar.insert(.gemini) }
@@ -280,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 4974079..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 ?? []) }
}
@@ -426,84 +426,34 @@ public struct ProviderQuota: Equatable, Sendable {
public enum Provider: String, CaseIterable, Codable, Sendable {
case codex
- /// A second, separately-configured Codex account.
- ///
- /// Sources for this slot point at an alternate Codex home directory (see
- /// `AccountSlots`), so its readings come from a different `codex` login
- /// than the primary slot's. Everything downstream (coordinator maps,
- /// archive, alerts, JSON report, tray) treats it as its own meter row —
- /// never merged with `.codex`, because two accounts' percents are two
- /// different budgets.
- case codexAlt
case antigravity
case grok
case openCodeGo
case openRouter
case claude
- /// A second, separately-configured Claude Code account.
- ///
- /// Sources for this slot scan an alternate Claude projects tree (see
- /// `AccountSlots`) and a per-account quota/plan snapshot, so readings
- /// belong to a different Claude account than the primary `.claude` slot.
- case claudeAlt
case cursor
case copilot
case gemini
- /// The provider this slot reads the same tool as, or `nil` for the
- /// primary slot. An alternate case answers its own identity here and is
- /// otherwise present-tense in every surface: "Claude second account" is a
- /// distinct meter, not a footnote.
- public var baseProvider: Provider? {
- switch self {
- case .codexAlt: return .codex
- case .claudeAlt: return .claude
- default: return nil
- }
- }
-
- /// True for the second-account slots. These providers follow their base
- /// provider for status pages, plan badges, and identity glyphs — see the
- /// per-field resolution helpers below.
- public var isAltSlot: Bool { baseProvider != nil }
-
- /// The slot digit ("2") shown beside an alternate-account slot's mark.
- /// `nil` for primary slots, which never carry a digit.
- public var altSlotDigit: String? {
- switch self {
- case .codexAlt, .claudeAlt: return "2"
- default: return nil
- }
- }
-
- /// Service-health identity: an alternate-account slot shares its base
- /// provider's service, so the status strip keeps one row per service.
- public var statusProvider: Provider { baseProvider ?? self }
-
- /// Identity glyph: the alternate-account slots reuse their base
- /// provider's bundled mark so both account rows read as the same tool.
- /// ADR 0002 keeps the marks themselves unchanged.
- public var markProvider: Provider {
- guard let base = baseProvider else { return self }
- return base
- }
-
public var displayName: String {
switch self {
case .codex: return "Codex"
- case .codexAlt: return "Codex second account"
case .antigravity: return "Antigravity"
case .grok: return "Grok"
case .openCodeGo: return "OpenCode Go"
case .openRouter: return "OpenRouter"
case .claude: return "Claude"
- case .claudeAlt: return "Claude second account"
case .cursor: return "Cursor"
case .copilot: return "Copilot CLI"
case .gemini: return "Gemini CLI"
}
}
+ /// 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".
@@ -535,12 +485,12 @@ public enum Provider: String, CaseIterable, Codable, Sendable {
/// figure.
public func headlineWindow(from windows: [QuotaWindow]) -> QuotaWindow? {
switch self {
- case .codex, .codexAlt:
+ case .codex:
if let session = windows.first(where: { $0.isSessionWindow }) {
return session
}
return windows.count == 1 ? windows[0] : nil
- case .claude, .claudeAlt:
+ case .claude:
if let session = windows.first(where: { $0.isSessionWindow }) {
return session
}
@@ -563,9 +513,7 @@ public enum Provider: String, CaseIterable, Codable, Sendable {
public var sourceLabel: String {
switch self {
case .codex: return "Codex CLI"
- case .codexAlt: return "Codex CLI · second account"
case .claude: return "Claude Code"
- case .claudeAlt: return "Claude Code · second account"
case .antigravity: return "agy CLI"
case .grok: return "Grok CLI"
case .openCodeGo: return "OpenCode"
@@ -581,9 +529,9 @@ public enum Provider: String, CaseIterable, Codable, Sendable {
/// than sending the user to a guessed or unrelated page.
public var statusPageURL: URL? {
switch self {
- case .codex, .codexAlt:
+ case .codex:
return URL(string: "https://status.openai.com/")
- case .claude, .claudeAlt:
+ case .claude:
return URL(string: "https://status.claude.com/")
case .cursor:
return URL(string: "https://status.cursor.com/")
@@ -595,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
+/// " ·