diff --git a/macos-bar/Package.swift b/macos-bar/Package.swift index 8e8a642f..1e06fdc1 100644 --- a/macos-bar/Package.swift +++ b/macos-bar/Package.swift @@ -10,7 +10,7 @@ import PackageDescription // the core is verified. let package = Package( name: "CCSBar", - platforms: [.macOS(.v13)], + platforms: [.macOS(.v14)], products: [ .executable(name: "CCSBar", targets: ["CCSBarApp"]), .executable(name: "ccs-bar-check", targets: ["CCSBarCheck"]), diff --git a/macos-bar/Sources/CCSBarApp/BarMenuView.swift b/macos-bar/Sources/CCSBarApp/BarMenuView.swift index 80017740..920d5db6 100644 --- a/macos-bar/Sources/CCSBarApp/BarMenuView.swift +++ b/macos-bar/Sources/CCSBarApp/BarMenuView.swift @@ -36,6 +36,11 @@ struct BarMenuView: View { /// monotonically (ContentHeightKey.reduce takes the max), so the frame never /// thrashes downward. @State private var contentHeight: CGFloat = 0 + /// Multi-profile carousel: which provider page is currently visible. Resets to + /// first provider on popover re-open (KISS — no UserDefaults persistence needed). + // Per-provider carousel position: provider -> selected profile row id. Each + // provider has its own profile carousel, so selection is tracked per provider. + @State private var selectedProfileByProvider: [String: String] = [:] // MARK: - Screen cap @@ -164,6 +169,9 @@ struct BarMenuView: View { viewModel.onOpen() // Disarm quit on every popover open so a stale armed state never persists. quitArmed = false + // Reset each provider's carousel to its first profile on every open — KISS, + // no persistence needed. + selectedProfileByProvider = [:] } } @@ -218,7 +226,7 @@ struct BarMenuView: View { /// present, preserving the single "Accounts" header for a CLIProxy-only setup. @ViewBuilder private var accountsSection: some View { let parts = BarFormatting.partitionSubscriptions(viewModel.rows) - VStack(alignment: .leading, spacing: 8) { + VStack(alignment: .leading, spacing: 6) { if let error = viewModel.lastError { ErrorBanner(message: error) } @@ -234,9 +242,62 @@ struct BarMenuView: View { BarRowView(row: row, viewModel: viewModel) } } else { + // Per-provider profile carousels: group subscription rows by provider, and + // render each provider as its OWN horizontally-paged carousel of PROFILE + // cards — one profile visible at a time, swipe left/right between that + // provider's profiles, page dots indicate count. Provider sections stack + // vertically. A provider with a single profile shows just its card (no + // carousel, no dots). + let groups = Dictionary( + grouping: orderedSubscriptions(parts.subscriptions), by: { $0.provider }) + let providers = groups.keys.sorted() // stable: "claude-code" < "codex" + let multiProvider = providers.count > 1 subscriptionsHeader(parts.subscriptions) - ForEach(orderedSubscriptions(parts.subscriptions)) { row in - BarSubscriptionCard(row: row, onRefresh: { viewModel.forceRefresh() }) + ForEach(providers, id: \.self) { prov in + let rows = groups[prov] ?? [] + VStack(alignment: .leading, spacing: 4) { + // Provider caption to delineate sections when more than one provider. + if multiProvider { + Text(BarFormatting.providerLabel(prov)) + .font(.system(size: 10, weight: .semibold)) + .foregroundStyle(.secondary) + } + if rows.count <= 1 { + // Single profile — render the card directly, no carousel. + if let row = rows.first { + BarSubscriptionCard( + row: row, isParked: row.paused, + onRefresh: { viewModel.forceRefresh() }) + } + } else { + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 0) { + ForEach(rows) { row in + BarSubscriptionCard( + row: row, isParked: row.paused, + onRefresh: { viewModel.forceRefresh() }) + // 340 leaves 10px side padding inside the 360-wide popover. + .frame(width: 340, alignment: .leading) + .padding(.horizontal, 2) + .id(row.id) + } + } + // scrollTargetLayout on the HStack (the paging container) snaps to + // each profile card on .paging behavior. + .scrollTargetLayout() + } + .scrollTargetBehavior(.paging) + .scrollPosition(id: Binding( + get: { selectedProfileByProvider[prov] ?? rows.first?.id }, + set: { selectedProfileByProvider[prov] = $0 ?? rows.first?.id })) + .frame(height: carouselHeight(rows)) + // Page controls — clickable prev/next arrows + dots so the carousel + // is navigable by MOUSE (click), not just by trackpad swipe. + // Programmatically setting the scrollPosition binding pages the + // ScrollView; withAnimation gives the same feel as a swipe. + carouselControls(prov: prov, rows: rows) + } + } } } } @@ -274,11 +335,14 @@ struct BarMenuView: View { } } - /// Order subscription cards by tightest binding window ascending (closest to - /// empty on top). Rows with no binding window (error/reauth) sink to the bottom - /// so the actionable quota always leads. + /// Order subscription cards so the default/base account leads its provider + /// carousel (it is the account the user runs by default), then by tightest + /// binding window ascending (closest to empty next). Rows with no binding + /// window (error/reauth) sink to the bottom so actionable quota leads. private func orderedSubscriptions(_ subs: [BarSummaryRow]) -> [BarSummaryRow] { subs.sorted { a, b in + // Default account first within its provider group. + if a.isDefault != b.isDefault { return a.isDefault } let ra = BarQuotaGauge.selectBindingWindow(a.quotaWindows ?? [])?.remainingPercent let rb = BarQuotaGauge.selectBindingWindow(b.quotaWindows ?? [])?.remainingPercent switch (ra, rb) { @@ -295,6 +359,64 @@ struct BarMenuView: View { } } + /// Prev/next arrows + clickable dots for a provider's profile carousel, so it + /// is navigable by MOUSE (click), not only by trackpad swipe. Selecting a page + /// sets the scrollPosition binding, which scrolls the ScrollView to that card. + @ViewBuilder private func carouselControls(prov: String, rows: [BarSummaryRow]) -> some View { + let currentId = selectedProfileByProvider[prov] ?? rows.first?.id + let curIdx = rows.firstIndex(where: { $0.id == currentId }) ?? 0 + HStack(spacing: 7) { + pageArrow(systemName: "chevron.left", enabled: curIdx > 0) { + if curIdx > 0 { selectPage(prov, rows[curIdx - 1].id) } + } + ForEach(rows) { row in + Circle() + .fill(currentId == row.id ? theme.subscription : Color.secondary.opacity(0.3)) + .frame(width: 6, height: 6) + .padding(4) // larger mouse hit target than the 6pt dot + .contentShape(Rectangle()) + .onTapGesture { selectPage(prov, row.id) } + } + pageArrow(systemName: "chevron.right", enabled: curIdx < rows.count - 1) { + if curIdx < rows.count - 1 { selectPage(prov, rows[curIdx + 1].id) } + } + } + .frame(maxWidth: .infinity, alignment: .center) + } + + /// Animate the carousel to the given profile card (mouse click or dot tap). + private func selectPage(_ prov: String, _ id: String) { + withAnimation(.easeInOut(duration: 0.2)) { + selectedProfileByProvider[prov] = id + } + } + + @ViewBuilder private func pageArrow( + systemName: String, enabled: Bool, action: @escaping () -> Void + ) -> some View { + Button(action: action) { + Image(systemName: systemName) + .font(.system(size: 9, weight: .bold)) + .frame(width: 16, height: 16) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .disabled(!enabled) + .opacity(enabled ? 0.7 : 0.25) + } + + /// Height of the tallest single card in a carousel — only one card is visible + /// at a time, so the paged frame is sized to fit it WITHOUT reserving blank + /// space beneath. Card = vertical padding (16) + title row (~22) + one bar per + /// quota window (~20) + an optional stale footnote (~16); a parked/reauth card + /// is just the title row plus a one-line status. + private func carouselHeight(_ rows: [BarSummaryRow]) -> CGFloat { + let maxWindows = rows.map { $0.quotaWindows?.count ?? 0 }.max() ?? 0 + if maxWindows == 0 { return 60 } + let hasFootnote = rows.contains { $0.staleAsOf != nil } + return 40 + CGFloat(maxWindows) * 20 + (hasFootnote ? 16 : 0) + } + private var header: some View { HStack(spacing: 8) { Image(nsImage: MenuBarIcon.headerImage()) diff --git a/macos-bar/Sources/CCSBarApp/BarSubscriptionCard.swift b/macos-bar/Sources/CCSBarApp/BarSubscriptionCard.swift index cf96fdde..9363fb2a 100644 --- a/macos-bar/Sources/CCSBarApp/BarSubscriptionCard.swift +++ b/macos-bar/Sources/CCSBarApp/BarSubscriptionCard.swift @@ -13,6 +13,9 @@ import CCSBarCore struct BarSubscriptionCard: View { @Environment(\.barTheme) private var theme let row: BarSummaryRow + /// When true this profile is parked (not the active/default profile for the + /// surface). The card is dimmed to 50% opacity to signal it is not live. + var isParked: Bool = false /// Injected clock — defaults to live Date() in production, pinned in previews /// and tests so countdown math is deterministic. var now: Date = Date() @@ -44,20 +47,30 @@ struct BarSubscriptionCard: View { .background( theme.cardSurface, in: RoundedRectangle(cornerRadius: 9)) + // Dim parked (non-active) profiles so the active profile clearly dominates. + .opacity(isParked ? 0.5 : 1.0) } // MARK: Title row - /// Health dot + product name + reauth chip + tier chip. No pause toggle — - /// subscriptions are not routable pool accounts. + /// Health dot + profile name (or provider label for legacy rows) + surface chip + /// + reauth chip + tier chip. No pause toggle — subscriptions are not routable + /// pool accounts. private var titleRow: some View { HStack(spacing: 8) { Circle() .fill(healthColor) .frame(width: 8, height: 8) - Text(BarFormatting.providerLabel(row.provider)) + // Default account shows the bare command ("ccsx"); a named profile shows its + // name ("ck"). Falls back to the provider label for legacy rows. + Text(BarFormatting.accountTitle(row)) .font(.system(.body, design: .default).weight(.semibold)) .lineLimit(1) + // Tag: "default" for the base account, else the owning surface ("ccsx") so a + // named profile reads as e.g. "ck · ccsx". + if let tag = BarFormatting.accountTag(row) { + Chip(tag, tint: theme.subscription.opacity(isParked ? 0.5 : 1)) + } if row.needsReauth { Chip("reauth", tint: theme.bandRed) } diff --git a/macos-bar/Sources/CCSBarCheck/main.swift b/macos-bar/Sources/CCSBarCheck/main.swift index 0c9d5833..72612c59 100644 --- a/macos-bar/Sources/CCSBarCheck/main.swift +++ b/macos-bar/Sources/CCSBarCheck/main.swift @@ -1686,6 +1686,182 @@ do { check(!BarUpdateChecker.isNewer("1.7", than: "1.7.0"), "isNewer: fewer than 3 parts rejected") } +// MARK: Multi-profile fields decode (GH-1595) +// +// Three new optional wire keys: surface, profile, is_subscription. +// Native rows carry all three; CLIProxy pool rows omit them — legacy decoders +// must not fail and must yield nil for the absent fields. + +let multiProfileJSON = """ +[ + { + "account_id": "ccs:work", + "provider": "claude-code", + "surface": "ccs", + "profile": "work", + "is_subscription": true, + "displayName": "work", + "tier": "max", + "paused": false, + "quota_percentage": 62, + "quotaStatus": "ok", + "next_reset": "2026-06-24T01:00:00.000Z", + "is_default": true, + "last_activity_at": null, + "today_cost": null, + "health": "ok", + "cached": false, + "fetchedAt": "2026-06-23T20:40:00.000Z", + "needsReauth": false + }, + { + "account_id": "ccsx:ck", + "provider": "codex", + "surface": "ccsx", + "profile": "ck", + "is_subscription": true, + "displayName": "ck", + "tier": "pro", + "paused": true, + "quota_percentage": 80, + "quotaStatus": "ok", + "next_reset": null, + "is_default": false, + "last_activity_at": null, + "today_cost": null, + "health": "ok", + "cached": true, + "fetchedAt": "2026-06-23T16:19:00.000Z", + "needsReauth": false, + "stale_as_of": "2026-06-23T16:19:00.000Z" + }, + { + "account_id": "alice@example.com", + "provider": "agy", + "displayName": "Alice (Ultra)", + "tier": "ultra", + "paused": false, + "quota_percentage": 70, + "quotaStatus": "ok", + "next_reset": null, + "is_default": false, + "last_activity_at": null, + "today_cost": null, + "health": "ok", + "cached": true, + "fetchedAt": "2026-06-23T20:00:00.000Z", + "needsReauth": false + } +] +""" + +do { + let rows = try JSONDecoder().decode([BarSummaryRow].self, from: Data(multiProfileJSON.utf8)) + check(rows.count == 3, "mp: decodes 3 rows (2 native + 1 legacy CLIProxy)") + + // (MP1) Native Claude active row + let claudeRow = rows[0] + check(claudeRow.accountId == "ccs:work", "mp: native Claude account_id = 'ccs:work'") + check(claudeRow.surface == "ccs", "mp: native Claude surface = 'ccs'") + check(claudeRow.profile == "work", "mp: native Claude profile = 'work'") + check(claudeRow.isSubscription == true, "mp: native Claude is_subscription = true") + check(claudeRow.paused == false, "mp: active Claude profile is not paused") + + // (MP2) Parked Codex row + let codexRow = rows[1] + check(codexRow.accountId == "ccsx:ck", "mp: parked Codex account_id = 'ccsx:ck'") + check(codexRow.surface == "ccsx", "mp: parked Codex surface = 'ccsx'") + check(codexRow.profile == "ck", "mp: parked Codex profile = 'ck'") + check(codexRow.isSubscription == true, "mp: parked Codex is_subscription = true") + check(codexRow.paused == true, "mp: parked Codex row has paused = true") + + // (MP3) Legacy CLIProxy row — new fields decode to nil without failure + let legacyRow = rows[2] + check(legacyRow.surface == nil, "mp: CLIProxy row surface decodes to nil (backward compat)") + check(legacyRow.profile == nil, "mp: CLIProxy row profile decodes to nil (backward compat)") + check(legacyRow.isSubscription == nil, "mp: CLIProxy row is_subscription decodes to nil") + + // (MP4) isNativeSubscription uses is_subscription flag when present + check( + BarFormatting.isNativeSubscription(claudeRow), + "mp: isNativeSubscription true when is_subscription=true (new flag path)") + check( + BarFormatting.isNativeSubscription(codexRow), + "mp: isNativeSubscription true for parked Codex row (is_subscription=true)") + check( + !BarFormatting.isNativeSubscription(legacyRow), + "mp: isNativeSubscription false for CLIProxy pool row (no is_subscription)") + + // (MP5) Legacy rows without is_subscription use fallback heuristic + let legacyClaudeRow = BarSummaryRow(accountId: "claude-code", provider: "claude-code") + check( + BarFormatting.isNativeSubscription(legacyClaudeRow), + "mp: legacy claude-code row uses heuristic fallback -> is native") + let legacyCodexRow = BarSummaryRow(accountId: "codex", provider: "codex") + check( + BarFormatting.isNativeSubscription(legacyCodexRow), + "mp: legacy codex row uses heuristic fallback -> is native") + let poolCodexRow = BarSummaryRow(accountId: "pool-codex-oauth-1", provider: "codex") + check( + !BarFormatting.isNativeSubscription(poolCodexRow), + "mp: CLIProxy codex pool row with no is_subscription -> not native (heuristic)") + + // (MP6) partitionSubscriptions correctly splits with new flag + let parts = BarFormatting.partitionSubscriptions(rows) + check(parts.subscriptions.count == 2, "mp: partition yields 2 native subscriptions") + check(parts.pool.count == 1, "mp: partition yields 1 CLIProxy pool row") + check( + parts.subscriptions.map { $0.accountId } == ["ccs:work", "ccsx:ck"], + "mp: subscriptions keep backend order") + + // (MP7) surfaceProfileLabel helper + check( + BarFormatting.surfaceProfileLabel(claudeRow) == "ccs · work", + "mp: surfaceProfileLabel for ccs:work -> 'ccs · work'") + check( + BarFormatting.surfaceProfileLabel(codexRow) == "ccsx · ck", + "mp: surfaceProfileLabel for ccsx:ck -> 'ccsx · ck'") + check( + BarFormatting.surfaceProfileLabel(legacyRow) == nil, + "mp: surfaceProfileLabel for CLIProxy row (no profile) -> nil") + + // (MP8) surfaceProfileLabel falls back to provider when surface is absent + let noSurfaceRow = BarSummaryRow( + accountId: "ccsx:personal", provider: "codex", + surface: nil, profile: "personal", isSubscription: true) + check( + BarFormatting.surfaceProfileLabel(noSurfaceRow) == "codex · personal", + "mp: surfaceProfileLabel falls back to provider when surface is nil") + + // (MP9) base/default account vs named profile presentation + let defaultCodexRow = BarSummaryRow( + accountId: "ccsx:default", provider: "codex", + surface: "ccsx", profile: "default", isSubscription: true) + check( + BarFormatting.isBaseAccount(defaultCodexRow), + "mp: 'default' profile is the base account") + check( + !BarFormatting.isBaseAccount(codexRow), + "mp: named profile 'ck' is not the base account") + check( + BarFormatting.accountTitle(defaultCodexRow) == "ccsx", + "mp: accountTitle for default account -> 'ccsx' (bare command)") + check( + BarFormatting.accountTitle(codexRow) == "ck", + "mp: accountTitle for named profile -> 'ck'") + check( + BarFormatting.accountTag(defaultCodexRow) == "default", + "mp: accountTag for default account -> 'default'") + check( + BarFormatting.accountTag(codexRow) == "ccsx", + "mp: accountTag for named profile -> 'ccsx' (surface)") + check( + BarFormatting.accountTag(legacyRow) == nil, + "mp: accountTag for CLIProxy pool row -> nil") +} catch { + check(false, "mp: multi-profile JSON decode failed: \(error)") +} + // cleanup try? FileManager.default.removeItem(atPath: tmp) diff --git a/macos-bar/Sources/CCSBarCore/BarFormatting.swift b/macos-bar/Sources/CCSBarCore/BarFormatting.swift index e996c9bf..f08530fc 100644 --- a/macos-bar/Sources/CCSBarCore/BarFormatting.swift +++ b/macos-bar/Sources/CCSBarCore/BarFormatting.swift @@ -165,11 +165,49 @@ public enum BarFormatting { /// Code or Codex plan) rather than a CLIProxy-managed OAuth pool account. Drives /// the "Subscriptions" grouping + badge so a user reads "this is MY plan quota", /// not one of the rotating pool credentials. + /// + /// Prefers the explicit `is_subscription` flag from the server (multi-profile + /// feature). Falls back to the legacy heuristic (`accountId == provider`) so + /// old single-profile payloads still work without `is_subscription`. public static func isNativeSubscription(_ row: BarSummaryRow) -> Bool { - (row.provider == "claude-code" && row.accountId == "claude-code") + if let s = row.isSubscription { return s } + // Legacy fallback for old payloads that omit is_subscription. + return (row.provider == "claude-code" && row.accountId == "claude-code") || (row.provider == "codex" && row.accountId == "codex") } + /// Surface + profile chip label for multi-profile cards, e.g. "ccs · work" or + /// "ccsx · ck". Returns nil for CLIProxy pool rows that have no profile. + public static func surfaceProfileLabel(_ row: BarSummaryRow) -> String? { + guard let p = row.profile else { return nil } + let s = row.surface ?? row.provider + return "\(s) · \(p)" + } + + /// True when this row is the surface's default/base account — the bare login + /// (e.g. `ccsx` => ~/.codex), as opposed to a named `ccsx ` profile. + /// The server names this account "default". + public static func isBaseAccount(_ row: BarSummaryRow) -> Bool { + row.profile == "default" + } + + /// Primary card title. For the default/base account it is the bare command the + /// user actually runs ("ccsx" / "ccs"); for a named profile it is the profile + /// name ("ck"). Falls back to the provider product label for legacy rows. + public static func accountTitle(_ row: BarSummaryRow) -> String { + if isBaseAccount(row) { return row.surface ?? providerLabel(row.provider) } + return row.profile ?? providerLabel(row.provider) + } + + /// Secondary chip beside the title. The default/base account is tagged + /// "default"; a named profile is tagged with the surface that owns it + /// ("ccs"/"ccsx") so "ck" reads as a ccsx profile. Nil for pool rows. + public static func accountTag(_ row: BarSummaryRow) -> String? { + guard row.profile != nil else { return nil } + if isBaseAccount(row) { return "default" } + return row.surface ?? row.provider + } + /// Friendly product label for a provider key. Native subscription keys read as /// products ("Claude Code", "Codex"); any other provider passes through verbatim /// (so "agy"/"ghcp"/"kiro" keep their established short chip text). diff --git a/macos-bar/Sources/CCSBarCore/BarSummary.swift b/macos-bar/Sources/CCSBarCore/BarSummary.swift index 930f06ed..f76bd5f8 100644 --- a/macos-bar/Sources/CCSBarCore/BarSummary.swift +++ b/macos-bar/Sources/CCSBarCore/BarSummary.swift @@ -65,6 +65,16 @@ public struct BarSummaryRow: Codable, Sendable, Identifiable, Equatable { public let cached: Bool public let fetchedAt: String? public let needsReauth: Bool + /// Multi-profile: which CCS surface owns this profile ("ccs" = Claude via + /// `ccs auth`, "ccsx" = Codex via `ccsx auth`). nil on CLIProxy pool rows. + public let surface: String? + /// Multi-profile: the profile name (e.g. "work", "ck", "personal"). nil on + /// CLIProxy pool rows. + public let profile: String? + /// Explicit native-subscription flag. true on all native rows (Claude/Codex + /// own subscription), nil/false on CLIProxy pool rows. Replaces the brittle + /// `accountId == "claude-code"` heuristic. + public let isSubscription: Bool? /// Native-only per-window quota breakdown (Claude: 5h/week/opus/sonnet, /// Codex: 5h/week). nil for CLIProxy pool rows, which omit "quota_windows" /// entirely — so legacy payloads decode unchanged (backward compatible). @@ -93,6 +103,9 @@ public struct BarSummaryRow: Codable, Sendable, Identifiable, Equatable { case cached case fetchedAt case needsReauth + case surface + case profile + case isSubscription = "is_subscription" case quotaWindows = "quota_windows" case staleAsOf = "stale_as_of" } @@ -113,6 +126,9 @@ public struct BarSummaryRow: Codable, Sendable, Identifiable, Equatable { cached: Bool = false, fetchedAt: String? = nil, needsReauth: Bool = false, + surface: String? = nil, + profile: String? = nil, + isSubscription: Bool? = nil, quotaWindows: [QuotaWindowDetail]? = nil, staleAsOf: String? = nil ) { @@ -131,6 +147,9 @@ public struct BarSummaryRow: Codable, Sendable, Identifiable, Equatable { self.cached = cached self.fetchedAt = fetchedAt self.needsReauth = needsReauth + self.surface = surface + self.profile = profile + self.isSubscription = isSubscription self.quotaWindows = quotaWindows self.staleAsOf = staleAsOf } @@ -156,6 +175,9 @@ public struct BarSummaryRow: Codable, Sendable, Identifiable, Equatable { cached = try c.decode(Bool.self, forKey: .cached) fetchedAt = try c.decodeIfPresent(String.self, forKey: .fetchedAt) needsReauth = try c.decode(Bool.self, forKey: .needsReauth) + surface = try c.decodeIfPresent(String.self, forKey: .surface) + profile = try c.decodeIfPresent(String.self, forKey: .profile) + isSubscription = try c.decodeIfPresent(Bool.self, forKey: .isSubscription) quotaWindows = try c.decodeIfPresent([QuotaWindowDetail].self, forKey: .quotaWindows) staleAsOf = try c.decodeIfPresent(String.self, forKey: .staleAsOf) } diff --git a/macos-bar/VERSION b/macos-bar/VERSION index a8fdfda1..f8e233b2 100644 --- a/macos-bar/VERSION +++ b/macos-bar/VERSION @@ -1 +1 @@ -1.8.1 +1.9.0 diff --git a/src/web-server/routes/bar-routes.ts b/src/web-server/routes/bar-routes.ts index c6d8af96..3acc5fb9 100644 --- a/src/web-server/routes/bar-routes.ts +++ b/src/web-server/routes/bar-routes.ts @@ -96,6 +96,22 @@ export interface BarSummaryRow { fetchedAt: string; /** True if account token is expired and needs re-authentication */ needsReauth: boolean; + /** + * Native subscription surface: "ccs" (Claude Code) or "ccsx" (Codex). + * Present ONLY on native subscription rows; omitted on CLIProxy pool rows. + */ + surface?: string; + /** + * Native profile name (e.g. "work", "ck", "personal"). + * Present ONLY on native subscription rows; omitted on CLIProxy pool rows. + */ + profile?: string; + /** + * Explicit native-subscription flag. true on all native rows; omitted on + * CLIProxy pool rows (decodes to false/nil). Replaces the brittle + * accountId == "claude-code" heuristic in Swift. + */ + is_subscription?: boolean; /** * Native-only per-window quota breakdown (Claude: 5h/week/opus/sonnet, * Codex: 5h/week). CLIProxy rows OMIT this field so existing decode/encode diff --git a/src/web-server/usage/native-quota-collector.ts b/src/web-server/usage/native-quota-collector.ts index bbddccf1..8c9e8188 100644 --- a/src/web-server/usage/native-quota-collector.ts +++ b/src/web-server/usage/native-quota-collector.ts @@ -14,10 +14,24 @@ * - circuit breaker stops calling after repeated 429s for a cooldown * - serve-stale-on-failure; only omit a row when there is genuinely no data * - * Claude path: reads native credentials + polls api.anthropic.com/api/oauth/usage. + * Claude path: reads per-profile .credentials.json (file-only, NO keychain) + * and polls api.anthropic.com/api/oauth/usage. If the file is absent the + * profile is emitted as a parked row (paused:true) — never a keychain call. + * * Codex path: PRIMARY = live network (chatgpt.com/backend-api/wham/usage, via * fetchCodexQuota), FALLBACK = local session logs (getCodexLocalQuota), mirroring * the same safety pattern as the Claude path. + * + * Multi-profile: each Claude or Codex profile gets its own ProviderState so a + * 429 on one profile never trips the breaker of another. The active/default + * profile for each surface is live-polled (paused:false); all other profiles are + * cache-only (paused:true, force=false hardcoded) so the 2.5s /summary deadline + * is maintained — at most 2 live upstream calls per /summary regardless of + * profile count. + * + * NO macOS Keychain access anywhere in this module. The old global-default + * Claude reader (readClaudeCredentials) is kept for back-compat but is no longer + * used by the multi-profile path. */ import { @@ -34,6 +48,11 @@ import { getCodexLocalQuota, type CodexLocalQuota } from './codex-local-quota-co import type { ClaudeQuotaResult, CodexQuotaResult } from '../../cliproxy/quota/quota-types'; import type { BarSummaryRow, QuotaWindowDetail } from '../routes/bar-routes'; +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import * as os from 'node:os'; +import { getCcsDir } from '../../utils/config-manager'; + // ============================================================================ // Safety constants (concrete, named, module-level) // ============================================================================ @@ -41,6 +60,15 @@ import type { BarSummaryRow, QuotaWindowDetail } from '../routes/bar-routes'; /** On-demand cache TTL. Floor is 5 min; we use 10 min because the bar polls * /summary far more often than a hook fires. */ const NATIVE_QUOTA_TTL_MS = 600_000; // 10 minutes +// After a 401/expired result, cache the reauth row and cool the profile down so +// an expired account is shown dimmed and re-checked at most this often instead +// of being re-polled (and re-401'd) on every /summary refresh. +const REAUTH_COOLDOWN_MS = NATIVE_QUOTA_TTL_MS; // 10 minutes +// Parked rows (no on-disk creds, quotaStatus 'unsupported') re-check on a short +// TTL — re-statting credentials is cheap, and this lets a profile that the user +// just logged into appear within seconds instead of staying dimmed for the full +// quota TTL. (Reauth/error rows keep the full TTL + cooldown to avoid re-401s.) +const PARKED_TTL_MS = 30_000; // 30 seconds /** Exponential backoff base; delay = min(base * 2^n, MAX) + jitter. */ const RETRY_BASE_MS = 1_000; @@ -57,16 +85,36 @@ const CB_TRIP_THRESHOLD = 3; /** How long the breaker stays open (zero network) once tripped. */ const CB_COOLDOWN_MS = 900_000; // 15 minutes -const CLAUDE_PROVIDER = 'claude-code'; -const CODEX_PROVIDER = 'codex'; +// Surface identifiers +const SURFACE_CLAUDE = 'ccs'; +const SURFACE_CODEX = 'ccsx'; + +// The "default way of running" a surface — the bare login (e.g. ~/.codex for +// ccsx), as opposed to a named `ccsx ` profile. Rendered in the Bar as +// the base command ("ccsx") with a "default" badge, not as a named profile. +const DEFAULT_PROFILE = 'default'; + +// Provider values on the wire (unchanged from before) +const CLAUDE_NATIVE_PROVIDER = 'claude-code'; +const CODEX_NATIVE_PROVIDER = 'codex'; + +// Keep old names as aliases to avoid breaking the existing global collector path +const CLAUDE_PROVIDER = CLAUDE_NATIVE_PROVIDER; +const CODEX_PROVIDER = CODEX_NATIVE_PROVIDER; // ============================================================================ // Injectable dependencies (tests inject mocks; never live endpoints in CI) // ============================================================================ export interface NativeQuotaDeps { - /** Read the native Claude Code credentials. */ + /** Read the native Claude Code credentials (global default path). */ readCredentials?: () => ClaudeNativeCredentials | null; + /** + * Read credentials for a specific Claude profile (file-only, no keychain). + * Injected so tests never touch real fs or Keychain. + * profile: the profile name (e.g. "work"); returns null when absent/unparseable. + */ + readClaudeCredentialsForProfile?: (profile: string) => ClaudeNativeCredentials | null; /** Fetch Claude quota with a directly-supplied native token. */ fetchClaudeQuota?: (accessToken: string, accountId?: string) => Promise; /** @@ -81,6 +129,20 @@ export interface NativeQuotaDeps { fetchCodexNetworkQuota?: (accountId: string) => Promise; /** Read Codex quota from local session logs (zero network, fallback). */ getCodexQuota?: () => Promise; + /** + * Read the native Codex auth for a profile (file-only, no keychain). + * DEFAULT_PROFILE ('default') reads ~/.codex/auth.json; other names read codex-instances//auth.json. + * Returns null when absent/unparseable. + */ + readCodexNativeAuth?: (profile: string) => { accessToken: string; accountId: string } | null; + /** Enumerate Claude profile names. Injected so tests never touch real fs. */ + listClaudeProfiles?: () => string[]; + /** Enumerate Codex profile names (including DEFAULT_PROFILE for bare ~/.codex). */ + listCodexProfiles?: () => string[]; + /** Resolve the default Claude profile name. */ + defaultClaudeProfile?: () => string | null; + /** Resolve the default Codex profile name. */ + defaultCodexProfile?: () => string | null; /** Clock seam for deterministic backoff/TTL/breaker tests. */ now?: () => number; /** Sleep seam (no real delay in tests). */ @@ -120,13 +182,23 @@ function freshProviderState(): ProviderState { }; } -const claudeState = freshProviderState(); -const codexState = freshProviderState(); +// Per-profile state maps (key = profile name) +const claudeProfileStates = new Map(); +const codexProfileStates = new Map(); + +function getState(map: Map, key: string): ProviderState { + let s = map.get(key); + if (!s) { + s = freshProviderState(); + map.set(key, s); + } + return s; +} /** Reset all module state. Tests call this to avoid cross-test pollution. */ export function resetNativeQuotaState(): void { - Object.assign(claudeState, freshProviderState()); - Object.assign(codexState, freshProviderState()); + claudeProfileStates.clear(); + codexProfileStates.clear(); } // ============================================================================ @@ -252,12 +324,21 @@ function buildClaudeQuotaWindows(quota: ClaudeQuotaResult): QuotaWindowDetail[] return windows; } -function buildClaudeRow(quota: ClaudeQuotaResult, tier: string | null, now: number): BarSummaryRow { +function buildClaudeRow( + quota: ClaudeQuotaResult, + tier: string | null, + now: number, + surface: string, + profile: string +): BarSummaryRow { const quotaWindows = buildClaudeQuotaWindows(quota); return { - account_id: CLAUDE_PROVIDER, - provider: CLAUDE_PROVIDER, - displayName: 'Claude Code', + account_id: `${surface}:${profile}`, + provider: CLAUDE_NATIVE_PROVIDER, + surface, + profile, + is_subscription: true, + displayName: profile, tier, paused: false, quota_percentage: deriveClaudeQuotaPercentage(quota), @@ -288,12 +369,20 @@ function buildCodexQuotaWindows(quota: CodexLocalQuota): QuotaWindowDetail[] { })); } -function buildCodexRow(quota: CodexLocalQuota, now: number): BarSummaryRow { +function buildCodexRow( + quota: CodexLocalQuota, + now: number, + surface: string, + profile: string +): BarSummaryRow { const quotaWindows = buildCodexQuotaWindows(quota); return { - account_id: CODEX_PROVIDER, - provider: CODEX_PROVIDER, - displayName: 'Codex', + account_id: `${surface}:${profile}`, + provider: CODEX_NATIVE_PROVIDER, + surface, + profile, + is_subscription: true, + displayName: profile, tier: quota.tier, paused: false, quota_percentage: quota.quotaPercentage, @@ -321,7 +410,12 @@ function buildCodexRow(quota: CodexLocalQuota, now: number): BarSummaryRow { * stable keys as the Claude path. quota_percentage = min remaining across present * core windows. next_reset = soonest core resetAt. No staleAsOf on a live result. */ -function buildCodexNetworkRow(quota: CodexQuotaResult, now: number): BarSummaryRow { +function buildCodexNetworkRow( + quota: CodexQuotaResult, + now: number, + surface: string, + profile: string +): BarSummaryRow { const windows: QuotaWindowDetail[] = []; const fiveHour = quota.coreUsage?.fiveHour; @@ -363,9 +457,12 @@ function buildCodexNetworkRow(quota: CodexQuotaResult, now: number): BarSummaryR const nextReset = resets.length > 0 ? resets[0].iso : null; return { - account_id: CODEX_PROVIDER, - provider: CODEX_PROVIDER, - displayName: 'Codex', + account_id: `${surface}:${profile}`, + provider: CODEX_NATIVE_PROVIDER, + surface, + profile, + is_subscription: true, + displayName: profile, tier: quota.planType ?? null, paused: false, quota_percentage: quotaPercentage, @@ -390,19 +487,227 @@ function serveCached(state: ProviderState): BarSummaryRow | null { } // ============================================================================ -// Claude path with full safety controls +// File-only Claude credentials reader for per-profile paths (NO keychain) // ============================================================================ -async function collectClaudeRow( +/** + * Read credentials for a specific Claude Code profile (file-only, no keychain). + * + * Looks for .credentials.json in the profile's instance directory. If the file + * is absent or unparseable, returns null — the caller emits a parked row. + * Never calls security/Keychain — zero new keychain access from this feature. + */ +function readClaudeCredentialsForProfileFromDisk(profile: string): ClaudeNativeCredentials | null { + // The bare `ccs` default login uses the standard global credential lookup: + // ~/.claude/.credentials.json, falling back to the single global + // "Claude Code-credentials" Keychain item that Claude Code itself maintains. + // This is the ONE pre-existing global read the shipped Bar already performs -- + // NOT a per-profile Keychain scan. Isolated `ccs auth` profiles below stay + // file-only and never touch the Keychain. + if (profile === DEFAULT_PROFILE) { + return readClaudeCredentials(); + } + try { + const instanceDir = path.join(getCcsDir(), 'instances', profile); + const credFile = path.join(instanceDir, '.credentials.json'); + if (!fs.existsSync(credFile)) return null; + const raw = fs.readFileSync(credFile, 'utf8'); + const parsed = JSON.parse(raw) as unknown; + if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) { + return parsed as ClaudeNativeCredentials; + } + return null; + } catch { + return null; + } +} + +// ============================================================================ +// Profile enumeration helpers (production implementations, DI-overridable) +// ============================================================================ + +/** + * Read Codex native auth from the profile's on-disk auth.json. + * 'personal' reads ~/.codex/auth.json; other names read codex-instances//auth.json. + * Returns null when absent or unparseable. + */ +function readCodexNativeAuthFromDisk( + profile: string +): { accessToken: string; accountId: string } | null { + try { + let authPath: string; + if (profile === DEFAULT_PROFILE) { + authPath = path.join(os.homedir(), '.codex', 'auth.json'); + } else { + // resolveCodexProfileDir would validate, but we do it inline to avoid the + // import coupling and to handle invalid names gracefully (return null). + const instancesDir = path.join(getCcsDir(), 'codex-instances'); + authPath = path.join(instancesDir, profile, 'auth.json'); + } + + if (!fs.existsSync(authPath)) return null; + const raw = fs.readFileSync(authPath, 'utf8'); + const parsed = JSON.parse(raw) as Record; + const tokens = parsed.tokens as Record | undefined; + if (!tokens) return null; + const accessToken = tokens.access_token; + const accountId = tokens.account_id; + if (typeof accessToken !== 'string' || !accessToken) return null; + return { + accessToken, + accountId: typeof accountId === 'string' ? accountId : '', + }; + } catch { + return null; + } +} + +/** + * List all Claude profiles from the profile registry (merged legacy + unified). + * Returns [] on any read error so the collector degrades gracefully. + */ +function listClaudeProfilesFromDisk(): string[] { + try { + // Import lazily inside function to avoid circular dep and DI override in tests + const { ProfileRegistry } = require('../../auth/profile-registry') as { + ProfileRegistry: new () => { + getAllProfilesMerged: () => Record; + }; + }; + const registry = new ProfileRegistry(); + const profiles = Object.keys(registry.getAllProfilesMerged()); + // Add the bare `ccs` default login (DEFAULT_PROFILE) when ~/.claude exists -- + // the default way of running `ccs`, distinct from named `ccs ` + // profiles. unshift so it leads before alphabetical sorting downstream. + if (fs.existsSync(path.join(os.homedir(), '.claude'))) { + if (!profiles.includes(DEFAULT_PROFILE)) profiles.unshift(DEFAULT_PROFILE); + } + return profiles; + } catch { + return []; + } +} + +/** + * Resolve the default Claude profile. A registry-designated default wins; + * otherwise the bare ~/.claude login (DEFAULT_PROFILE) is the default `ccs`. + */ +function getDefaultClaudeProfileFromDisk(): string | null { + try { + const { ProfileRegistry } = require('../../auth/profile-registry') as { + ProfileRegistry: new () => { + getDefaultResolved: () => string | null; + }; + }; + const registry = new ProfileRegistry(); + const reg = registry.getDefaultResolved(); + if (reg) return reg; + } catch { + // fall through to the bare default below + } + if (fs.existsSync(path.join(os.homedir(), '.claude'))) return DEFAULT_PROFILE; + return null; +} + +/** + * List all Codex profiles from the registry, plus DEFAULT_PROFILE when the bare + * ~/.codex/auth.json exists. Returns [] on any read error. + */ +function listCodexProfilesFromDisk(): string[] { + try { + const { CodexProfileRegistry } = require('../../codex-auth/codex-profile-registry') as { + CodexProfileRegistry: new () => { + listProfiles: () => string[]; + }; + }; + const registry = new CodexProfileRegistry(); + const profiles = registry.listProfiles(); + // Add the bare ~/.codex/auth.json (the default `ccsx` invocation) as the + // DEFAULT_PROFILE account when it exists. It is the "default way of running", + // distinct from named `ccsx ` profiles. + if (fs.existsSync(path.join(os.homedir(), '.codex', 'auth.json'))) { + if (!profiles.includes(DEFAULT_PROFILE)) profiles.push(DEFAULT_PROFILE); + } + return profiles; + } catch { + return []; + } +} + +/** + * Resolve the default Codex profile. Falls back to DEFAULT_PROFILE when the bare + * ~/.codex/auth.json exists and no registry default is set. + */ +function getDefaultCodexProfileFromDisk(): string | null { + try { + const { CodexProfileRegistry } = require('../../codex-auth/codex-profile-registry') as { + CodexProfileRegistry: new () => { + getDefault: () => string | null; + }; + }; + const registry = new CodexProfileRegistry(); + const def = registry.getDefault(); + if (def) return def; + // Fall back to the bare ~/.codex account (DEFAULT_PROFILE) when no registry + // default is set — it is the default `ccsx` invocation. + if (fs.existsSync(path.join(os.homedir(), '.codex', 'auth.json'))) return DEFAULT_PROFILE; + return null; + } catch { + return null; + } +} + +// ============================================================================ +// Per-profile collectors with full safety controls +// ============================================================================ + +/** + * Tag a row with whether it is the surface's default profile (drives the + * "active" badge only). The row's own `paused` flag is authoritative for + * dimming: it is already set by the collector to reflect LIVENESS — parked + * (no on-disk creds / unsupported) rows arrive with paused:true; profiles with + * a usable token arrive with paused:false regardless of default status. We must + * NOT override `paused` from `isDefault`, or a valid non-default subscription + * (e.g. an isolated `ccsx` profile) would render dimmed despite live quota. + */ +function markDefault(row: BarSummaryRow, isDefault: boolean): BarSummaryRow { + return { ...row, is_default: isDefault }; +} + +/** + * Tag the row with is_default AND write the flag back onto the cached copy. The + * collector caches a row before the default profile is known (the default is + * resolved in the enumerator), so without this the cache-fallback path + * (getCachedNativeAccountRows) would serve the default account with + * is_default:false and the UI would stop ordering/tagging it as the default. + */ +function markDefaultAndSyncCache( + map: Map, + profile: string, + row: BarSummaryRow, + isDefault: boolean +): BarSummaryRow { + const state = map.get(profile); + if (state?.cachedRow) { + state.cachedRow = { ...state.cachedRow, is_default: isDefault }; + } + return markDefault(row, isDefault); +} + +async function collectClaudeRowForProfile( + profile: string, deps: NativeQuotaDeps, force = false ): Promise { const now = (deps.now ?? Date.now)(); - const state = claudeState; + const state = getState(claudeProfileStates, profile); - // Serve from cache while within TTL — force bypasses TTL short-circuit. - if (!force && state.cachedRow && now - state.cachedAt < NATIVE_QUOTA_TTL_MS) { - return serveCached(state); + // Serve from cache while within TTL — force bypasses the short-circuit. Parked + // rows (no creds -> quotaStatus 'unsupported') use a short TTL so a fresh login + // is picked up within seconds instead of staying dimmed for the full quota TTL. + if (!force && state.cachedRow) { + const ttl = state.cachedRow.quotaStatus === 'unsupported' ? PARKED_TTL_MS : NATIVE_QUOTA_TTL_MS; + if (now - state.cachedAt < ttl) return serveCached(state); } // Breaker open or cooldown active -> zero network, serve stale (may be null). @@ -416,15 +721,56 @@ async function collectClaudeRow( return state.pending; } - const readCredentials = deps.readCredentials ?? readClaudeCredentials; + // For per-profile reads: use the injected seam (file-only, no keychain). + const readCreds = + deps.readClaudeCredentialsForProfile ?? + ((p: string) => readClaudeCredentialsForProfileFromDisk(p)); const fetchQuota = deps.fetchClaudeQuota ?? fetchClaudeQuotaWithToken; const sleep = deps.sleep ?? defaultSleep; state.pending = (async (): Promise => { try { - const creds = readCredentials(); + // Yield once so the `state.pending = (...)()` assignment above completes + // before any synchronous return below runs the finally that nulls it. + // Without this, a sync return (e.g. the no-creds parked path) clears + // pending DURING assignment, leaving a stale resolved promise that the + // next call's coalescing check would return instead of re-evaluating. + await Promise.resolve(); + const creds = readCreds(profile); + + // No credentials file found -> emit parked row (needs auth, file absent). + // This is the expected case when the profile exists in the registry but the + // user has not logged in via 'ccs auth' for this machine or the credentials + // are stored only in keychain (which we deliberately do not access here). + if (!creds) { + const parkedRow: BarSummaryRow = { + account_id: `${SURFACE_CLAUDE}:${profile}`, + provider: CLAUDE_NATIVE_PROVIDER, + surface: SURFACE_CLAUDE, + profile, + is_subscription: true, + displayName: profile, + tier: null, + paused: true, + quota_percentage: null, + quotaStatus: 'unsupported', + next_reset: null, + is_default: false, + last_activity_at: null, + today_cost: null, + health: 'ok', + cached: false, + fetchedAt: new Date(now).toISOString(), + needsReauth: true, + }; + // Cache the parked row so repeated calls don't re-stat the fs. + state.cachedRow = parkedRow; + state.cachedAt = now; + return parkedRow; + } + // No token / unsupported subscription -> never spend a call, omit the row. - if (!creds || !hasSupportedSubscription(creds)) { + if (!hasSupportedSubscription(creds)) { return serveCached(state); } const token = getAccessToken(creds); @@ -433,7 +779,7 @@ async function collectClaudeRow( } const tier = getSubscriptionTier(creds); - const quota = await fetchQuota(token, CLAUDE_PROVIDER); + const quota = await fetchQuota(token, `${SURFACE_CLAUDE}:${profile}`); if (quota.success) { // Success closes the breaker and clears backoff. @@ -441,21 +787,26 @@ async function collectClaudeRow( state.breakerOpenUntil = 0; state.cooldownUntil = 0; state.backoffAttempt = 0; - const row = buildClaudeRow(quota, tier, now); + const row = buildClaudeRow(quota, tier, now, SURFACE_CLAUDE, profile); state.cachedRow = row; state.cachedAt = now; return { ...row, cached: false }; } - // 401 -> token expired. Emit a reauth row so the bar can prompt; this is - // a real, actionable state distinct from a transient failure. + // 401 -> token expired. Emit a dimmed reauth row so the bar can prompt. + // Cache it and open a cooldown so the expired account is NOT re-polled + // (and re-401'd) on every refresh; it re-checks after REAUTH_COOLDOWN_MS, + // picking up a successful re-auth. if (quota.needsReauth) { const row: BarSummaryRow = { - account_id: CLAUDE_PROVIDER, - provider: CLAUDE_PROVIDER, - displayName: 'Claude Code', + account_id: `${SURFACE_CLAUDE}:${profile}`, + provider: CLAUDE_NATIVE_PROVIDER, + surface: SURFACE_CLAUDE, + profile, + is_subscription: true, + displayName: profile, tier, - paused: false, + paused: true, quota_percentage: null, quotaStatus: 'error', next_reset: null, @@ -467,9 +818,10 @@ async function collectClaudeRow( fetchedAt: new Date(now).toISOString(), needsReauth: true, }; - // Do not cache the reauth row as a good value; it should re-evaluate - // once the user re-auths. But return it now. - return row; + state.cachedRow = row; + state.cachedAt = now; + state.cooldownUntil = now + REAUTH_COOLDOWN_MS; + return { ...row, cached: false }; } // 429 / 5xx / transient. Apply backoff + breaker, then serve stale. @@ -508,20 +860,20 @@ async function collectClaudeRow( return state.pending; } -// ============================================================================ -// Codex path with live network as PRIMARY, local logs as FALLBACK -// ============================================================================ - -async function collectCodexRow( +async function collectCodexRowForProfile( + profile: string, deps: NativeQuotaDeps, force = false ): Promise { const now = (deps.now ?? Date.now)(); - const state = codexState; + const state = getState(codexProfileStates, profile); - // Serve from cache while within TTL — force bypasses TTL short-circuit. - if (!force && state.cachedRow && now - state.cachedAt < NATIVE_QUOTA_TTL_MS) { - return serveCached(state); + // Serve from cache while within TTL — force bypasses the short-circuit. Parked + // rows (no auth -> quotaStatus 'unsupported') use a short TTL so a fresh login + // is picked up within seconds instead of staying dimmed for the full quota TTL. + if (!force && state.cachedRow) { + const ttl = state.cachedRow.quotaStatus === 'unsupported' ? PARKED_TTL_MS : NATIVE_QUOTA_TTL_MS; + if (now - state.cachedAt < ttl) return serveCached(state); } // Breaker open or cooldown active -> skip network, go to LOCAL fallback. @@ -533,8 +885,12 @@ async function collectCodexRow( return state.pending; } - const getDefaultAccountId = - deps.getDefaultCodexAccountId ?? (() => getDefaultAccount('codex')?.id ?? null); + // Resolve the native auth for this profile to get a network accountId. + const readNativeAuth = + deps.readCodexNativeAuth ?? ((p: string) => readCodexNativeAuthFromDisk(p)); + + // For the network fallback: the legacy getDefaultCodexAccountId is the + // CLIProxy-registry path; for native profiles we use the on-disk auth directly. const fetchNetwork = deps.fetchCodexNetworkQuota ?? ((accountId: string) => fetchCodexQuota(accountId)); const getCodex = deps.getCodexQuota ?? getCodexLocalQuota; @@ -542,13 +898,19 @@ async function collectCodexRow( state.pending = (async (): Promise => { try { + // Yield once so the `state.pending = (...)()` assignment completes before + // any synchronous return below runs the finally that nulls it (otherwise a + // sync return leaves a stale resolved promise the next call would reuse). + await Promise.resolve(); // ---------------------------------------------------------------- // PRIMARY: live network fetch (skipped when breaker/cooldown active) // ---------------------------------------------------------------- if (!breakerOrCooldownActive) { - const accountId = getDefaultAccountId(); - if (accountId) { - const quota = await fetchNetwork(accountId); + const nativeAuth = readNativeAuth(profile); + // Use the on-disk accountId for the network call; fall through to local + // when the auth file is absent (parked profile). + if (nativeAuth) { + const quota = await fetchNetwork(nativeAuth.accountId || profile); if (quota.success) { // A healthy response closes the breaker and clears backoff, @@ -557,26 +919,37 @@ async function collectCodexRow( state.breakerOpenUntil = 0; state.cooldownUntil = 0; state.backoffAttempt = 0; - // Only usable when at least one core window (5h/weekly) resolved. A - // success with empty coreUsage (only code-review/additional windows, - // or a changed payload) carries no glanceable signal — do NOT cache - // a contentless "ok" row or clobber a good cache; fall through to - // the local fallback so the bar shows real data instead. + // At least one core window (5h/weekly) resolved -> full quota row. if (quota.coreUsage?.fiveHour || quota.coreUsage?.weekly) { - const row = buildCodexNetworkRow(quota, now); + const row = buildCodexNetworkRow(quota, now, SURFACE_CODEX, profile); state.cachedRow = row; state.cachedAt = now; return { ...row, cached: false }; } - // else: fall through to LOCAL fallback below. + // Success but no core window. The token authenticated, so this is a + // VALID active subscription with a sparse payload — emit an active + // (quota-less) row instead of parking it. Only the bare default keeps + // falling through to the global local session data below. + if (profile !== DEFAULT_PROFILE) { + const row = buildCodexNetworkRow(quota, now, SURFACE_CODEX, profile); + state.cachedRow = row; + state.cachedAt = now; + return { ...row, cached: false }; + } + // else (default): fall through to LOCAL fallback below. } else if (quota.needsReauth) { - // Token expired -> reauth row; do NOT cache as a good value. - return { - account_id: CODEX_PROVIDER, - provider: CODEX_PROVIDER, - displayName: 'Codex', + // Token expired -> dimmed reauth row. Cache it and cool down so the + // expired account is NOT re-polled (and re-401'd) every refresh; it + // re-checks after REAUTH_COOLDOWN_MS to pick up a re-auth. + const reauthRow: BarSummaryRow = { + account_id: `${SURFACE_CODEX}:${profile}`, + provider: CODEX_NATIVE_PROVIDER, + surface: SURFACE_CODEX, + profile, + is_subscription: true, + displayName: profile, tier: null, - paused: false, + paused: true, quota_percentage: null, quotaStatus: 'error', next_reset: null, @@ -588,6 +961,10 @@ async function collectCodexRow( fetchedAt: new Date(now).toISOString(), needsReauth: true, }; + state.cachedRow = reauthRow; + state.cachedAt = now; + state.cooldownUntil = now + REAUTH_COOLDOWN_MS; + return { ...reauthRow, cached: false }; } else if (quota.httpStatus === 429) { // 429: apply breaker + backoff, then fall through to local. state.consecutive429 += 1; @@ -614,23 +991,54 @@ async function collectCodexRow( } // Fall through to LOCAL fallback below. } - // No configured accountId -> fall through to local fallback. + // No on-disk auth for this profile -> fall through to the fallback below. } // ---------------------------------------------------------------- - // LOCAL FALLBACK: session log read (zero network, always attempted - // when network is unavailable / no accountId / breaker active) + // LOCAL FALLBACK: session-log read (zero network). The session logs live + // in the GLOBAL ~/.codex, so they represent ONLY the bare default account, + // never a named profile. Using them for a named profile would misattribute + // the default's usage to the profile, so only the default falls back to + // local; named profiles park instead. // ---------------------------------------------------------------- - const localQuota = await getCodex(); - if (localQuota) { - const row = buildCodexRow(localQuota, now); - state.cachedRow = row; - state.cachedAt = now; - return { ...row, cached: false }; + if (profile === DEFAULT_PROFILE) { + const localQuota = await getCodex(); + if (localQuota) { + const row = buildCodexRow(localQuota, now, SURFACE_CODEX, profile); + state.cachedRow = row; + state.cachedAt = now; + return { ...row, cached: false }; + } } - // No local data either: serve stale (may be null). - return serveCached(state); + // Named profile (or default with no local data): serve the last-known row + // if any, else a dimmed parked row -- never global local data for a named + // profile. + const stale = serveCached(state); + if (stale) return stale; + const parkedRow: BarSummaryRow = { + account_id: `${SURFACE_CODEX}:${profile}`, + provider: CODEX_NATIVE_PROVIDER, + surface: SURFACE_CODEX, + profile, + is_subscription: true, + displayName: profile, + tier: null, + paused: true, + quota_percentage: null, + quotaStatus: 'unsupported', + next_reset: null, + is_default: false, + last_activity_at: null, + today_cost: null, + health: 'ok', + cached: false, + fetchedAt: new Date(now).toISOString(), + needsReauth: true, + }; + state.cachedRow = parkedRow; + state.cachedAt = now; + return parkedRow; } catch { // Network/parse rejection -> treat as transient, serve stale. const backoff = computeBackoffMs(state.backoffAttempt); @@ -645,25 +1053,402 @@ async function collectCodexRow( return state.pending; } +// ============================================================================ +// Legacy single-profile collectors (unchanged; used by old tests + back-compat) +// ============================================================================ + +/** + * @deprecated Use collectClaudeRowForProfile with the 'default' or appropriate + * profile name. Kept for backward compatibility with existing tests that stub + * readCredentials/getDefaultCodexAccountId directly. + */ +async function collectClaudeRow( + deps: NativeQuotaDeps, + force = false +): Promise { + const now = (deps.now ?? Date.now)(); + + // Use the legacy single-state approach via profile key '__legacy__' to avoid + // breaking state isolation with the per-profile maps. + const state = getState(claudeProfileStates, '__legacy__'); + + // Serve from cache while within TTL — force bypasses TTL short-circuit. + if (!force && state.cachedRow && now - state.cachedAt < NATIVE_QUOTA_TTL_MS) { + return serveCached(state); + } + + // Breaker open or cooldown active -> zero network, serve stale (may be null). + if (now < state.breakerOpenUntil || now < state.cooldownUntil) { + return serveCached(state); + } + + // Coalesce: concurrent callers past TTL share one in-flight fetch. + if (state.pending) { + return state.pending; + } + + const readCredentialsFn = deps.readCredentials ?? readClaudeCredentials; + const fetchQuota = deps.fetchClaudeQuota ?? fetchClaudeQuotaWithToken; + const sleep = deps.sleep ?? defaultSleep; + + state.pending = (async (): Promise => { + try { + const creds = readCredentialsFn(); + // No token / unsupported subscription -> never spend a call, omit the row. + if (!creds || !hasSupportedSubscription(creds)) { + return serveCached(state); + } + const token = getAccessToken(creds); + if (!token) { + return serveCached(state); + } + const tier = getSubscriptionTier(creds); + + const quota = await fetchQuota(token, CLAUDE_PROVIDER); + + if (quota.success) { + // Success closes the breaker and clears backoff. + state.consecutive429 = 0; + state.breakerOpenUntil = 0; + state.cooldownUntil = 0; + state.backoffAttempt = 0; + const row = buildClaudeRowLegacy(quota, tier, now); + state.cachedRow = row; + state.cachedAt = now; + return { ...row, cached: false }; + } + + // 401 -> token expired. + if (quota.needsReauth) { + const row: BarSummaryRow = { + account_id: CLAUDE_PROVIDER, + provider: CLAUDE_PROVIDER, + displayName: 'Claude Code', + tier, + paused: false, + quota_percentage: null, + quotaStatus: 'error', + next_reset: null, + is_default: false, + last_activity_at: null, + today_cost: null, + health: 'error', + cached: false, + fetchedAt: new Date(now).toISOString(), + needsReauth: true, + }; + return row; + } + + // 429 / 5xx / transient. + const is429 = quota.httpStatus === 429; + if (is429) { + state.consecutive429 += 1; + if (state.consecutive429 >= CB_TRIP_THRESHOLD) { + state.breakerOpenUntil = now + CB_COOLDOWN_MS; + } + const retryAfter = parseRetryAfterMs(quota.errorDetail, now); + const backoff = retryAfter ?? computeBackoffMs(state.backoffAttempt); + state.cooldownUntil = now + backoff; + state.backoffAttempt += 1; + void sleep; + } else if (quota.retryable) { + const backoff = computeBackoffMs(state.backoffAttempt); + state.cooldownUntil = now + backoff; + state.backoffAttempt += 1; + } + + return serveCached(state); + } catch { + const backoff = computeBackoffMs(state.backoffAttempt); + state.cooldownUntil = now + backoff; + state.backoffAttempt += 1; + return serveCached(state); + } finally { + state.pending = null; + } + })(); + + return state.pending; +} + +/** Legacy row builder — no surface/profile/is_subscription fields. */ +function buildClaudeRowLegacy( + quota: ClaudeQuotaResult, + tier: string | null, + now: number +): BarSummaryRow { + const quotaWindows = buildClaudeQuotaWindows(quota); + return { + account_id: CLAUDE_PROVIDER, + provider: CLAUDE_PROVIDER, + displayName: 'Claude Code', + tier, + paused: false, + quota_percentage: deriveClaudeQuotaPercentage(quota), + quotaStatus: 'ok', + next_reset: deriveClaudeNextReset(quota), + is_default: false, + last_activity_at: null, + today_cost: null, + health: 'ok', + cached: false, + fetchedAt: new Date(now).toISOString(), + needsReauth: false, + ...(quotaWindows.length > 0 ? { quotaWindows } : {}), + }; +} + +/** + * Legacy Codex collector — uses the old getDefaultCodexAccountId dep. + * Kept for backward compatibility with existing tests. + */ +async function collectCodexRow( + deps: NativeQuotaDeps, + force = false +): Promise { + const now = (deps.now ?? Date.now)(); + const state = getState(codexProfileStates, '__legacy__'); + + // Serve from cache while within TTL — force bypasses TTL short-circuit. + if (!force && state.cachedRow && now - state.cachedAt < NATIVE_QUOTA_TTL_MS) { + return serveCached(state); + } + + // Breaker open or cooldown active -> skip network, go to LOCAL fallback. + const breakerOrCooldownActive = now < state.breakerOpenUntil || now < state.cooldownUntil; + + // Coalesce: concurrent callers past TTL share one in-flight resolution. + if (state.pending) { + return state.pending; + } + + const getDefaultAccountId = + deps.getDefaultCodexAccountId ?? (() => getDefaultAccount('codex')?.id ?? null); + const fetchNetwork = + deps.fetchCodexNetworkQuota ?? ((accountId: string) => fetchCodexQuota(accountId)); + const getCodex = deps.getCodexQuota ?? getCodexLocalQuota; + const sleep = deps.sleep ?? defaultSleep; + + state.pending = (async (): Promise => { + try { + // Yield once so the `state.pending = (...)()` assignment completes before + // any synchronous return below runs the finally that nulls it (otherwise a + // sync return leaves a stale resolved promise the next call would reuse). + await Promise.resolve(); + // ---------------------------------------------------------------- + // PRIMARY: live network fetch (skipped when breaker/cooldown active) + // ---------------------------------------------------------------- + if (!breakerOrCooldownActive) { + const accountId = getDefaultAccountId(); + if (accountId) { + const quota = await fetchNetwork(accountId); + + if (quota.success) { + state.consecutive429 = 0; + state.breakerOpenUntil = 0; + state.cooldownUntil = 0; + state.backoffAttempt = 0; + if (quota.coreUsage?.fiveHour || quota.coreUsage?.weekly) { + const row = buildCodexNetworkRowLegacy(quota, now); + state.cachedRow = row; + state.cachedAt = now; + return { ...row, cached: false }; + } + // else: fall through to LOCAL fallback below. + } else if (quota.needsReauth) { + return { + account_id: CODEX_PROVIDER, + provider: CODEX_PROVIDER, + displayName: 'Codex', + tier: null, + paused: false, + quota_percentage: null, + quotaStatus: 'error', + next_reset: null, + is_default: false, + last_activity_at: null, + today_cost: null, + health: 'error', + cached: false, + fetchedAt: new Date(now).toISOString(), + needsReauth: true, + }; + } else if (quota.httpStatus === 429) { + state.consecutive429 += 1; + if (state.consecutive429 >= CB_TRIP_THRESHOLD) { + state.breakerOpenUntil = now + CB_COOLDOWN_MS; + } + const retryAfter = parseRetryAfterMs(quota.errorDetail, now); + const backoff = retryAfter ?? computeBackoffMs(state.backoffAttempt); + state.cooldownUntil = now + backoff; + state.backoffAttempt += 1; + void sleep; + } else if (quota.retryable) { + const backoff = computeBackoffMs(state.backoffAttempt); + state.cooldownUntil = now + backoff; + state.backoffAttempt += 1; + } else { + const backoff = computeBackoffMs(state.backoffAttempt); + state.cooldownUntil = now + backoff; + state.backoffAttempt += 1; + } + // Fall through to LOCAL fallback below. + } + } + + // ---------------------------------------------------------------- + // LOCAL FALLBACK + // ---------------------------------------------------------------- + const localQuota = await getCodex(); + if (localQuota) { + const row = buildCodexRowLegacy(localQuota, now); + state.cachedRow = row; + state.cachedAt = now; + return { ...row, cached: false }; + } + + return serveCached(state); + } catch { + const backoff = computeBackoffMs(state.backoffAttempt); + state.cooldownUntil = now + backoff; + state.backoffAttempt += 1; + return serveCached(state); + } finally { + state.pending = null; + } + })(); + + return state.pending; +} + +/** Legacy Codex row builder (local quota). */ +function buildCodexRowLegacy(quota: CodexLocalQuota, now: number): BarSummaryRow { + const quotaWindows = buildCodexQuotaWindows(quota); + return { + account_id: CODEX_PROVIDER, + provider: CODEX_PROVIDER, + displayName: 'Codex', + tier: quota.tier, + paused: false, + quota_percentage: quota.quotaPercentage, + quotaStatus: 'ok', + next_reset: quota.nextReset, + is_default: false, + last_activity_at: null, + today_cost: null, + health: quota.stale ? 'warning' : 'ok', + cached: false, + fetchedAt: new Date(now).toISOString(), + needsReauth: false, + ...(quotaWindows.length > 0 ? { quotaWindows } : {}), + ...(quota.staleAsOf ? { staleAsOf: quota.staleAsOf } : {}), + }; +} + +/** Legacy Codex network row builder. */ +function buildCodexNetworkRowLegacy(quota: CodexQuotaResult, now: number): BarSummaryRow { + const windows: QuotaWindowDetail[] = []; + const fiveHour = quota.coreUsage?.fiveHour; + if (fiveHour) { + windows.push({ + key: 'five_hour', + label: '5h', + usedPercent: 100 - fiveHour.remainingPercent, + remainingPercent: fiveHour.remainingPercent, + resetAt: fiveHour.resetAt, + windowMinutes: FIVE_HOUR_MINUTES, + }); + } + const weekly = quota.coreUsage?.weekly; + if (weekly) { + windows.push({ + key: 'seven_day', + label: 'week', + usedPercent: 100 - weekly.remainingPercent, + remainingPercent: weekly.remainingPercent, + resetAt: weekly.resetAt, + windowMinutes: SEVEN_DAY_MINUTES, + }); + } + const coreWindows = [fiveHour, weekly].filter((w): w is NonNullable => !!w); + const quotaPercentage = + coreWindows.length > 0 ? Math.min(...coreWindows.map((w) => w.remainingPercent)) : null; + const resets = coreWindows + .map((w) => w.resetAt) + .filter((r): r is string => typeof r === 'string') + .map((r) => ({ iso: r, ms: new Date(r).getTime() })) + .filter((r) => Number.isFinite(r.ms)) + .sort((a, b) => a.ms - b.ms); + const nextReset = resets.length > 0 ? resets[0].iso : null; + return { + account_id: CODEX_PROVIDER, + provider: CODEX_PROVIDER, + displayName: 'Codex', + tier: quota.planType ?? null, + paused: false, + quota_percentage: quotaPercentage, + quotaStatus: 'ok', + next_reset: nextReset, + is_default: false, + last_activity_at: null, + today_cost: null, + health: 'ok', + cached: false, + fetchedAt: new Date(now).toISOString(), + needsReauth: false, + ...(windows.length > 0 ? { quotaWindows: windows } : {}), + }; +} + // ============================================================================ // Public entry point // ============================================================================ /** - * Build the native subscription rows (Claude Code + Codex) for /summary. + * Build the native subscription rows for /summary. * - * Each path is independently try/caught so one failing source never blocks the - * other or the response. Returns only rows that represent real data. + * When profile-enumeration deps are injected (listClaudeProfiles / listCodexProfiles + * etc.), all profiles are enumerated and the active/default profile is live-polled + * while non-default profiles are cache-only (parked). This keeps the 2.5s deadline: + * at most 2 live upstream calls per /summary regardless of profile count. * - * `opts.force` bypasses the TTL short-circuit on both paths so a debounce- - * passing refresh re-pulls native rows live. The circuit breaker is always - * respected regardless of force (account protection). + * When no enumeration deps are injected (legacy mode / old tests that stub only + * readCredentials + getDefaultCodexAccountId), the old single-profile collectors + * are used for backward compatibility. + * + * `opts.force` bypasses the TTL short-circuit on the ACTIVE profile. The circuit + * breaker is always respected regardless of force (account protection). */ export async function getNativeAccountRows( deps: NativeQuotaDeps = {}, opts?: { force?: boolean } ): Promise { const force = opts?.force ?? false; + + // Multi-profile enumeration is the DEFAULT (production) behavior. The legacy + // single-profile path is retained ONLY for backward-compat with old tests that + // inject readCredentials / getDefaultCodexAccountId and assert the original + // single-row output. Those harnesses never inject the enumeration seams; the + // new multi-profile tests pair both seams, and production injects neither — so + // everything except the legacy harness takes the multi-profile path below. + const hasProfileEnumeration = + deps.listClaudeProfiles !== undefined || + deps.listCodexProfiles !== undefined || + deps.defaultClaudeProfile !== undefined || + deps.defaultCodexProfile !== undefined; + + const isLegacyTestHarness = + !hasProfileEnumeration && + (deps.readCredentials !== undefined || deps.getDefaultCodexAccountId !== undefined); + + if (!isLegacyTestHarness) { + return getNativeAccountRowsMultiProfile(deps, force); + } + + // Legacy path: backward-compatible with old tests that only inject + // readCredentials / getDefaultCodexAccountId. Produces the old single-row + // output (account_id='claude-code'/'codex', no surface/profile). const [claude, codex] = await Promise.all([ collectClaudeRow(deps, force).catch(() => null), collectCodexRow(deps, force).catch(() => null), @@ -675,6 +1460,79 @@ export async function getNativeAccountRows( return rows; } +async function getNativeAccountRowsMultiProfile( + deps: NativeQuotaDeps, + force: boolean +): Promise { + const listClaude = deps.listClaudeProfiles ?? listClaudeProfilesFromDisk; + const listCodex = deps.listCodexProfiles ?? listCodexProfilesFromDisk; + const defaultClaude = deps.defaultClaudeProfile ?? getDefaultClaudeProfileFromDisk; + const defaultCodex = deps.defaultCodexProfile ?? getDefaultCodexProfileFromDisk; + + const claudeProfiles = (() => { + try { + return listClaude(); + } catch { + return []; + } + })(); + const codexProfiles = (() => { + try { + return listCodex(); + } catch { + return []; + } + })(); + const claudeDefault = (() => { + try { + return defaultClaude(); + } catch { + return null; + } + })(); + const codexDefault = (() => { + try { + return defaultCodex(); + } catch { + return null; + } + })(); + + const tasks: Promise[] = []; + + // Forced refresh applies to every profile: parked profiles (no creds) short- + // circuit to a parked row with zero network, so forcing them is free, while + // every profile that has a usable token gets live quota — not just the + // default. Per-profile TTL + breaker still protect each account. + for (const p of claudeProfiles) { + const isDefault = p === claudeDefault; + tasks.push( + collectClaudeRowForProfile(p, deps, force) + .then((r) => (r ? markDefaultAndSyncCache(claudeProfileStates, p, r, isDefault) : null)) + .catch(() => null) + ); + } + + for (const p of codexProfiles) { + const isDefault = p === codexDefault; + tasks.push( + collectCodexRowForProfile(p, deps, force) + .then((r) => (r ? markDefaultAndSyncCache(codexProfileStates, p, r, isDefault) : null)) + .catch(() => null) + ); + } + + const results = await Promise.all(tasks); + const rows = results.filter((r): r is BarSummaryRow => r !== null); + + // Sort by (surface, profile) for stable ordering. + return rows.sort((a, b) => { + const sa = (a.surface ?? '') + ':' + (a.profile ?? ''); + const sb = (b.surface ?? '') + ':' + (b.profile ?? ''); + return sa.localeCompare(sb); + }); +} + /** * Last-known native rows from cache, WITHOUT any fetch (instant, no network). * @@ -685,7 +1543,11 @@ export async function getNativeAccountRows( */ export function getCachedNativeAccountRows(): BarSummaryRow[] { const rows: BarSummaryRow[] = []; - if (claudeState.cachedRow) rows.push({ ...claudeState.cachedRow, cached: true }); - if (codexState.cachedRow) rows.push({ ...codexState.cachedRow, cached: true }); + for (const state of claudeProfileStates.values()) { + if (state.cachedRow) rows.push({ ...state.cachedRow, cached: true }); + } + for (const state of codexProfileStates.values()) { + if (state.cachedRow) rows.push({ ...state.cachedRow, cached: true }); + } return rows; } diff --git a/tests/unit/web-server/bar-routes.test.ts b/tests/unit/web-server/bar-routes.test.ts index 83ce1956..4890820f 100644 --- a/tests/unit/web-server/bar-routes.test.ts +++ b/tests/unit/web-server/bar-routes.test.ts @@ -1220,3 +1220,151 @@ describe('/summary native subscription rows', () => { expect(body[0].provider).toBe('agy'); }); }); + +// ============================================================================ +// GH-1595: wire contract — native rows carry surface/profile/is_subscription; +// CLIProxy pool rows OMIT all three fields. +// ============================================================================ + +describe('/summary wire contract: surface/profile/is_subscription fields (GH-1595)', () => { + /** + * Extended wire row type that includes the new optional fields. + * BarSummaryRow in the test file omits them; extend locally here. + */ + interface WireRow extends BarSummaryRow { + surface?: string; + profile?: string; + is_subscription?: boolean; + } + + function makeNativeRow( + surface: 'ccs' | 'ccsx', + profile: string, + paused = false + ): BarSummaryRow { + return { + account_id: `${surface}:${profile}`, + provider: surface === 'ccs' ? 'claude-code' : 'codex', + displayName: profile, + tier: 'pro', + paused, + quota_percentage: 55, + quotaStatus: 'ok', + next_reset: null, + is_default: !paused, + last_activity_at: null, + today_cost: null, + health: 'ok', + cached: false, + fetchedAt: '2026-06-23T20:00:00.000Z', + needsReauth: false, + // The TS interface now has these optional fields — set them explicitly. + // eslint-disable-next-line @typescript-eslint/no-explicit-any + ...(({ surface, profile, is_subscription: true }) as any), + }; + } + + async function buildWireRouter(nativeRows: BarSummaryRow[]) { + const { createBarRouter, resetForceFreshDebounce: resetDebounce } = await import( + '../../../src/web-server/routes/bar-routes' + ); + + const app = express(); + app.use(express.json()); + + const cliproxyAccount = makeAccountInfo({ id: 'pool@example.com', provider: 'agy' }); + + const router = createBarRouter({ + // eslint-disable-next-line @typescript-eslint/no-explicit-any + getAllAccountsSummary: () => ({ agy: [cliproxyAccount] }) as any, + getCachedQuota: () => makeQuotaResult(), + setCachedQuota: () => {}, + invalidateQuotaCache: () => {}, + fetchAccountQuota: async () => makeQuotaResult(), + getTodayCostByAccount: () => ({}), + loadCliproxyDetails: async () => [], + loadDailyUsage: async () => [], + loadHourlyUsage: async () => [], + runHealthChecks: async () => makeHealthReport(), + getNativeAccountRows: async () => nativeRows, + }); + + app.use('/api/bar', router); + const srv = await new Promise((resolve, reject) => { + const instance = app.listen(0, '127.0.0.1'); + instance.once('error', reject); + instance.once('listening', () => resolve(instance)); + }); + const addr = srv.address(); + if (!addr || typeof addr === 'string') throw new Error('No server address'); + resetDebounce(); + return { srv, url: `http://127.0.0.1:${(addr as { port: number }).port}` }; + } + + it('native rows include surface, profile, is_subscription=true in the JSON response', async () => { + const { srv, url } = await buildWireRouter([ + makeNativeRow('ccs', 'work', false), + makeNativeRow('ccsx', 'personal', false), + ]); + const { body } = await getJson(url, '/api/bar/summary'); + await new Promise((resolve) => srv.close(() => resolve())); + + const claudeRow = body.find((r) => r.provider === 'claude-code'); + expect(claudeRow).toBeDefined(); + expect(claudeRow?.surface).toBe('ccs'); + expect(claudeRow?.profile).toBe('work'); + expect(claudeRow?.is_subscription).toBe(true); + expect(claudeRow?.account_id).toBe('ccs:work'); + + const codexRow = body.find((r) => r.provider === 'codex'); + expect(codexRow).toBeDefined(); + expect(codexRow?.surface).toBe('ccsx'); + expect(codexRow?.profile).toBe('personal'); + expect(codexRow?.is_subscription).toBe(true); + expect(codexRow?.account_id).toBe('ccsx:personal'); + }); + + it('CLIProxy pool rows OMIT surface, profile, is_subscription', async () => { + const { srv, url } = await buildWireRouter([ + makeNativeRow('ccs', 'work', false), + ]); + const { body } = await getJson(url, '/api/bar/summary'); + await new Promise((resolve) => srv.close(() => resolve())); + + // The CLIProxy row (provider 'agy') should NOT have the new fields. + const cliproxyRow = body.find((r) => r.provider === 'agy'); + expect(cliproxyRow).toBeDefined(); + expect(cliproxyRow?.surface).toBeUndefined(); + expect(cliproxyRow?.profile).toBeUndefined(); + expect(cliproxyRow?.is_subscription).toBeUndefined(); + }); + + it('parked native row (paused:true) is present with is_subscription=true and paused=true', async () => { + const { srv, url } = await buildWireRouter([ + makeNativeRow('ccsx', 'ck', true), // parked Codex profile + ]); + const { body } = await getJson(url, '/api/bar/summary'); + await new Promise((resolve) => srv.close(() => resolve())); + + const parked = body.find((r) => r.profile === 'ck'); + expect(parked).toBeDefined(); + expect(parked?.paused).toBe(true); + expect(parked?.is_subscription).toBe(true); + expect(parked?.surface).toBe('ccsx'); + }); + + it('account_id on native rows uses the : scheme', async () => { + const { srv, url } = await buildWireRouter([ + makeNativeRow('ccs', 'ck', false), + makeNativeRow('ccsx', 'ck', true), + ]); + const { body } = await getJson(url, '/api/bar/summary'); + await new Promise((resolve) => srv.close(() => resolve())); + + const claudeRow = body.find((r) => r.surface === 'ccs'); + expect(claudeRow?.account_id).toBe('ccs:ck'); + + const codexRow = body.find((r) => r.surface === 'ccsx'); + expect(codexRow?.account_id).toBe('ccsx:ck'); + }); +}); diff --git a/tests/unit/web-server/native-quota-collector.test.ts b/tests/unit/web-server/native-quota-collector.test.ts index 344c2c7e..2cf892a8 100644 --- a/tests/unit/web-server/native-quota-collector.test.ts +++ b/tests/unit/web-server/native-quota-collector.test.ts @@ -775,3 +775,614 @@ describe('getCachedNativeAccountRows (instant, no-fetch fallback)', () => { expect(getCachedNativeAccountRows()).toEqual([]); }); }); + +// ============================================================================ +// Multi-profile path tests (GH-1595) +// +// These tests inject listClaudeProfiles / listCodexProfiles / defaultClaudeProfile +// / defaultCodexProfile so the production profile-enumeration path is exercised +// without touching real ~/.ccs or any Keychain. The readClaudeCredentialsForProfile +// and readCodexNativeAuth seams prevent fs access. +// ============================================================================ + +/** + * Build a NativeQuotaDeps for the multi-profile path. + * + * - claudeProfiles: profile names for the Claude surface (ccs) + * - codexProfiles: profile names for the Codex surface (ccsx) + * - claudeDefault / codexDefault: the active profile per surface (paused:false) + * - credsForProfile: map from profile name to credentials (null = parked) + * - claudeFetch: network fetcher for Claude (all profiles share one implementation) + * - codexNativeAuth: map from profile name to {accessToken, accountId} + * - codexNetworkFetch: network fetcher for Codex (all profiles share one impl) + */ +function makeMultiProfileDeps(opts: { + clock: { now: number }; + claudeProfiles: string[]; + codexProfiles: string[]; + claudeDefault?: string | null; + codexDefault?: string | null; + credsForProfile?: (profile: string) => ClaudeNativeCredentials | null; + claudeFetch?: (token: string, accountId?: string) => Promise; + codexNativeAuth?: (profile: string) => { accessToken: string; accountId: string } | null; + codexNetworkFetch?: (accountId: string) => Promise; + codexLocalFallback?: () => Promise; +}): NativeQuotaDeps & { + claudeFetchCount: () => number; + codexNetworkCount: () => number; +} { + let claudeFetches = 0; + let codexNetworkFetches = 0; + + const { + clock, + claudeProfiles, + codexProfiles, + claudeDefault = null, + codexDefault = null, + credsForProfile = () => null, + claudeFetch = async () => successQuota(), + codexNativeAuth = () => null, + codexNetworkFetch = async () => codexSuccessQuota(), + codexLocalFallback = async () => null, + } = opts; + + return { + // Enumeration seams + listClaudeProfiles: () => claudeProfiles, + listCodexProfiles: () => codexProfiles, + defaultClaudeProfile: () => claudeDefault, + defaultCodexProfile: () => codexDefault, + // Credential seams (file-only, no keychain) + readClaudeCredentialsForProfile: credsForProfile, + readCodexNativeAuth: codexNativeAuth, + // Fetch seams + fetchClaudeQuota: async (token: string, accountId?: string) => { + claudeFetches += 1; + return claudeFetch(token, accountId); + }, + fetchCodexNetworkQuota: async (accountId: string) => { + codexNetworkFetches += 1; + return codexNetworkFetch(accountId); + }, + getCodexQuota: codexLocalFallback, + // Disable legacy single-profile paths + readCredentials: () => null, + getDefaultCodexAccountId: () => null, + // Clock + sleep seams + now: () => clock.now, + sleep: async () => {}, + // Counters + claudeFetchCount: () => claudeFetches, + codexNetworkCount: () => codexNetworkFetches, + }; +} + +describe('multi-profile: account_id and wire fields', () => { + it('Claude profile rows carry surface="ccs", account_id="ccs:

", is_subscription=true', async () => { + const clock = { now: 1_000_000 }; + const deps = makeMultiProfileDeps({ + clock, + claudeProfiles: ['work', 'ck'], + codexProfiles: [], + claudeDefault: 'work', + // 'work' has creds; 'ck' does not (parked) + credsForProfile: (p) => (p === 'work' ? maxCreds() : null), + claudeFetch: async () => successQuota(), + }); + + const rows = await getNativeAccountRows(deps); + expect(rows.length).toBe(2); + + const work = rows.find((r) => r.profile === 'work'); + expect(work).toBeDefined(); + expect(work?.account_id).toBe('ccs:work'); + expect(work?.surface).toBe('ccs'); + expect(work?.is_subscription).toBe(true); + expect(work?.provider).toBe('claude-code'); + + const ck = rows.find((r) => r.profile === 'ck'); + expect(ck).toBeDefined(); + expect(ck?.account_id).toBe('ccs:ck'); + expect(ck?.surface).toBe('ccs'); + expect(ck?.is_subscription).toBe(true); + }); + + it('Codex profile rows carry surface="ccsx", account_id="ccsx:

", is_subscription=true', async () => { + const clock = { now: 1_000_000 }; + const deps = makeMultiProfileDeps({ + clock, + claudeProfiles: [], + codexProfiles: ['personal', 'ck'], + codexDefault: 'personal', + codexNativeAuth: (p) => ({ accessToken: `tok-${p}`, accountId: `id-${p}` }), + codexNetworkFetch: async () => codexSuccessQuota(), + }); + + const rows = await getNativeAccountRows(deps); + expect(rows.length).toBe(2); + + const personal = rows.find((r) => r.profile === 'personal'); + expect(personal?.account_id).toBe('ccsx:personal'); + expect(personal?.surface).toBe('ccsx'); + expect(personal?.is_subscription).toBe(true); + expect(personal?.provider).toBe('codex'); + + const ck = rows.find((r) => r.profile === 'ck'); + expect(ck?.account_id).toBe('ccsx:ck'); + expect(ck?.surface).toBe('ccsx'); + expect(ck?.is_subscription).toBe(true); + }); + + it('paused reflects liveness (creds present), NOT default-ness; is_default marks the default independently', async () => { + const clock = { now: 1_000_000 }; + const deps = makeMultiProfileDeps({ + clock, + // Claude: work = default + creds (live); ck = non-default + NO creds (parked). + claudeProfiles: ['work', 'ck'], + // Codex: personal = default + creds (live); ck = NON-default + creds (live). + codexProfiles: ['personal', 'ck'], + claudeDefault: 'work', + codexDefault: 'personal', + credsForProfile: (p) => (p === 'work' ? maxCreds() : null), + claudeFetch: async () => successQuota(), + codexNativeAuth: (p) => ({ accessToken: `tok-${p}`, accountId: `id-${p}` }), + codexNetworkFetch: async () => codexSuccessQuota(), + }); + + const rows = await getNativeAccountRows(deps); + + // Claude work: default + creds -> live, not dimmed. + const claudeWork = rows.find((r) => r.surface === 'ccs' && r.profile === 'work'); + expect(claudeWork?.paused).toBe(false); + expect(claudeWork?.is_default).toBe(true); + + // Claude ck: non-default + NO creds -> parked/dimmed. + const claudeCk = rows.find((r) => r.surface === 'ccs' && r.profile === 'ck'); + expect(claudeCk?.paused).toBe(true); + expect(claudeCk?.is_default).toBe(false); + + // Codex personal: default + creds -> live. + const codexPersonal = rows.find((r) => r.surface === 'ccsx' && r.profile === 'personal'); + expect(codexPersonal?.paused).toBe(false); + expect(codexPersonal?.is_default).toBe(true); + + // Codex ck: NON-default but HAS creds -> LIVE, NOT dimmed. This is the key + // correctness guarantee: a valid isolated subscription is never dimmed just + // because it is not the surface default. + const codexCk = rows.find((r) => r.surface === 'ccsx' && r.profile === 'ck'); + expect(codexCk?.paused).toBe(false); + expect(codexCk?.is_default).toBe(false); + }); + + it('N Claude + M Codex profiles produce N+M rows', async () => { + const clock = { now: 1_000_000 }; + const claudeProfiles = ['work', 'ck', 'personal']; + const codexProfiles = ['personal', 'ck']; + const deps = makeMultiProfileDeps({ + clock, + claudeProfiles, + codexProfiles, + claudeDefault: 'work', + codexDefault: 'personal', + credsForProfile: () => maxCreds(), + codexNativeAuth: (p) => ({ accessToken: `tok-${p}`, accountId: `id-${p}` }), + }); + + const rows = await getNativeAccountRows(deps); + expect(rows.length).toBe(claudeProfiles.length + codexProfiles.length); + }); + + it('rows are sorted by (surface, profile)', async () => { + const clock = { now: 1_000_000 }; + const deps = makeMultiProfileDeps({ + clock, + claudeProfiles: ['work', 'ck'], + codexProfiles: ['ck', 'personal'], + claudeDefault: 'work', + codexDefault: 'personal', + credsForProfile: () => maxCreds(), + codexNativeAuth: (p) => ({ accessToken: `tok-${p}`, accountId: `id-${p}` }), + }); + + const rows = await getNativeAccountRows(deps); + const keys = rows.map((r) => `${r.surface}:${r.profile}`); + // ccs:ck < ccs:work < ccsx:ck < ccsx:personal + expect(keys).toEqual(['ccs:ck', 'ccs:work', 'ccsx:ck', 'ccsx:personal']); + }); +}); + +describe('multi-profile: Claude file-only reader', () => { + it('profile with .credentials.json present -> live fetch row (paused:false when default)', async () => { + const clock = { now: 1_000_000 }; + const deps = makeMultiProfileDeps({ + clock, + claudeProfiles: ['work'], + codexProfiles: [], + claudeDefault: 'work', + credsForProfile: (p) => (p === 'work' ? maxCreds() : null), + claudeFetch: async () => successQuota(), + }); + + const rows = await getNativeAccountRows(deps); + expect(rows.length).toBe(1); + const row = rows[0]; + expect(row?.profile).toBe('work'); + expect(row?.quotaStatus).toBe('ok'); + expect(row?.needsReauth).toBe(false); + expect(row?.paused).toBe(false); + expect(deps.claudeFetchCount()).toBe(1); + }); + + it('profile without .credentials.json -> parked row (needsReauth:true, no live fetch)', async () => { + const clock = { now: 1_000_000 }; + const deps = makeMultiProfileDeps({ + clock, + claudeProfiles: ['ck'], + codexProfiles: [], + claudeDefault: 'ck', + credsForProfile: () => null, // no file on disk + claudeFetch: async () => successQuota(), + }); + + const rows = await getNativeAccountRows(deps); + expect(rows.length).toBe(1); + const row = rows[0]; + expect(row?.profile).toBe('ck'); + expect(row?.needsReauth).toBe(true); + expect(row?.quota_percentage).toBeNull(); + // No live network call when creds are absent + expect(deps.claudeFetchCount()).toBe(0); + }); + + it('absent creds row has quotaStatus unsupported (honest "needs auth" state)', async () => { + const clock = { now: 1_000_000 }; + const deps = makeMultiProfileDeps({ + clock, + claudeProfiles: ['ck'], + codexProfiles: [], + claudeDefault: 'ck', + credsForProfile: () => null, + }); + + const rows = await getNativeAccountRows(deps); + const row = rows[0]; + expect(row?.quotaStatus).toBe('unsupported'); + expect(row?.is_subscription).toBe(true); + }); +}); + +describe('multi-profile: per-profile circuit breaker isolation', () => { + it("one profile's 429 does not open another profile's breaker", async () => { + const MAX_COOLDOWN_JUMP_MP = 61_000; + const clock = { now: 1_000_000 }; + let workFails = true; + + const deps = makeMultiProfileDeps({ + clock, + claudeProfiles: ['work', 'ck'], + codexProfiles: [], + claudeDefault: 'work', + credsForProfile: () => maxCreds(), + claudeFetch: async (_token, accountId) => { + // 'work' (ccs:work) always 429s; 'ck' always succeeds + if (accountId?.includes('work') && workFails) { + return { + success: false, + windows: [], + coreUsage: { fiveHour: null, weekly: null }, + lastUpdated: Date.now(), + accountId: accountId ?? 'ccs:work', + httpStatus: 429, + retryable: true, + error: 'rate limited', + } as ClaudeQuotaResult; + } + return successQuota(); + }, + }); + + // Trip the work breaker with 3 consecutive 429s. + for (let i = 0; i < 3; i++) { + resetNativeQuotaState(); + clock.now += i === 0 ? 0 : MAX_COOLDOWN_JUMP_MP; + // Re-inject the multi-profile deps after reset so the state maps are fresh. + await getNativeAccountRows({ + ...deps, + listClaudeProfiles: () => ['work'], + listCodexProfiles: () => [], + defaultClaudeProfile: () => 'work', + }); + } + + // After the three 429s on 'work', check that 'ck' still succeeds. + // We reset state to have a clean run where 'ck' has no prior breaker history. + resetNativeQuotaState(); + clock.now += MAX_COOLDOWN_JUMP_MP; + workFails = false; + + const rows = await getNativeAccountRows(deps); + const ckRow = rows.find((r) => r.profile === 'ck'); + const workRow = rows.find((r) => r.profile === 'work'); + + // 'ck' should succeed — its breaker was never tripped. + expect(ckRow?.quotaStatus).toBe('ok'); + // 'work' is also fine after reset (no breaker state). + expect(workRow?.quotaStatus).toBe('ok'); + }); + + it("per-profile breaker: one profile's 429s only block that profile", async () => { + const clock = { now: 1_000_000 }; + let workCall429Count = 0; + + // 'work' returns 429 each call; 'ck' returns success. + const deps = makeMultiProfileDeps({ + clock, + claudeProfiles: ['work', 'ck'], + codexProfiles: [], + claudeDefault: 'work', + credsForProfile: () => maxCreds(), + claudeFetch: async (_token, accountId) => { + if (accountId?.includes('work')) { + workCall429Count += 1; + return { + success: false, + windows: [], + coreUsage: { fiveHour: null, weekly: null }, + lastUpdated: clock.now, + accountId: accountId ?? '', + httpStatus: 429, + retryable: true, + error: 'rate limited', + } as ClaudeQuotaResult; + } + return successQuota(); + }, + }); + + // First call: 'work' gets a 429, 'ck' succeeds. + const rows1 = await getNativeAccountRows(deps); + const ck1 = rows1.find((r) => r.profile === 'ck'); + expect(ck1?.quotaStatus).toBe('ok'); + expect(workCall429Count).toBeGreaterThanOrEqual(1); + + // Skip past cooldown for 'work' only; 'ck' is within TTL. + clock.now += 62_000; + + // Second call past 'work' cooldown: work tries again (429 again); ck cached. + const rows2 = await getNativeAccountRows(deps); + const ck2 = rows2.find((r) => r.profile === 'ck'); + // 'ck' still has a good cached row. + expect(ck2?.quotaStatus).toBe('ok'); + }); +}); + +describe('multi-profile: displayName uses profile name', () => { + it('displayName is the profile name, not "Claude Code" or "Codex"', async () => { + const clock = { now: 1_000_000 }; + const deps = makeMultiProfileDeps({ + clock, + claudeProfiles: ['my-work'], + codexProfiles: ['my-codex'], + claudeDefault: 'my-work', + codexDefault: 'my-codex', + credsForProfile: () => maxCreds(), + codexNativeAuth: (p) => ({ accessToken: `tok-${p}`, accountId: `id-${p}` }), + }); + + const rows = await getNativeAccountRows(deps); + const c = rows.find((r) => r.surface === 'ccs'); + const x = rows.find((r) => r.surface === 'ccsx'); + expect(c?.displayName).toBe('my-work'); + expect(x?.displayName).toBe('my-codex'); + }); +}); + +describe('multi-profile: getCachedNativeAccountRows reflects per-profile maps', () => { + it('returns cached rows from all profiles after a collect', async () => { + const clock = { now: 1_000_000 }; + const deps = makeMultiProfileDeps({ + clock, + claudeProfiles: ['work', 'ck'], + codexProfiles: ['personal'], + claudeDefault: 'work', + codexDefault: 'personal', + credsForProfile: () => maxCreds(), + codexNativeAuth: (p) => ({ accessToken: `tok-${p}`, accountId: `id-${p}` }), + }); + + await getNativeAccountRows(deps); + + const cached = getCachedNativeAccountRows(); + expect(cached.every((r) => r.cached === true)).toBe(true); + // Should have rows for work, ck, and personal (parked 'ck' has no cached row + // yet because it had creds in this test so it did fetch) + const profiles = cached.map((r) => r.profile); + expect(profiles).toContain('work'); + expect(profiles).toContain('personal'); + + resetNativeQuotaState(); + expect(getCachedNativeAccountRows()).toEqual([]); + }); +}); + +describe('review focus areas: reauth caching + codex local fallback', () => { + it('Claude reauth (401) profile is dimmed, cached, and not re-polled within cooldown', async () => { + resetNativeQuotaState(); + const clock = { now: 5_000_000 }; + const deps = makeMultiProfileDeps({ + clock, + claudeProfiles: ['work'], + codexProfiles: [], + claudeDefault: 'work', + credsForProfile: () => maxCreds(), + claudeFetch: async () => reauthQuota(), + }); + + const first = await getNativeAccountRows(deps); + const r1 = first.find((r) => r.profile === 'work'); + expect(r1?.needsReauth).toBe(true); + expect(r1?.paused).toBe(true); // dimmed + expect(deps.claudeFetchCount()).toBe(1); + + // A forced refresh within the cooldown serves the cached reauth row and does + // NOT re-hit the endpoint (no repeated 401 on the same account). + const second = await getNativeAccountRows(deps, { force: true }); + const r2 = second.find((r) => r.profile === 'work'); + expect(r2?.needsReauth).toBe(true); + expect(r2?.cached).toBe(true); + expect(deps.claudeFetchCount()).toBe(1); + }); + + it('Codex reauth (401) profile is dimmed, cached, and not re-polled within cooldown', async () => { + resetNativeQuotaState(); + const clock = { now: 5_000_000 }; + const deps = makeMultiProfileDeps({ + clock, + claudeProfiles: [], + codexProfiles: ['ck'], + codexDefault: 'default', + codexNativeAuth: (p) => ({ accessToken: `t-${p}`, accountId: `id-${p}` }), + codexNetworkFetch: async () => ({ success: false, needsReauth: true }) as CodexQuotaResult, + }); + + const first = await getNativeAccountRows(deps); + const r1 = first.find((r) => r.profile === 'ck'); + expect(r1?.needsReauth).toBe(true); + expect(r1?.paused).toBe(true); + expect(deps.codexNetworkCount()).toBe(1); + + const second = await getNativeAccountRows(deps, { force: true }); + expect(second.find((r) => r.profile === 'ck')?.cached).toBe(true); + expect(deps.codexNetworkCount()).toBe(1); + }); + + it('Codex named profile without on-disk auth is parked, never filled from global local data', async () => { + resetNativeQuotaState(); + const clock = { now: 5_000_000 }; + let localCalls = 0; + const deps = makeMultiProfileDeps({ + clock, + claudeProfiles: [], + codexProfiles: ['ck'], + codexDefault: 'default', + codexNativeAuth: () => null, // no auth.json for the named profile + codexLocalFallback: async () => { + localCalls += 1; + return codexLocalQuota(); + }, + }); + + const rows = await getNativeAccountRows(deps); + const ck = rows.find((r) => r.profile === 'ck'); + expect(ck).toBeDefined(); + expect(ck?.paused).toBe(true); // parked, dimmed + expect(ck?.needsReauth).toBe(true); + expect(ck?.quota_percentage).toBeNull(); + // The global ~/.codex session data is never attributed to a named profile. + expect(localCalls).toBe(0); + }); + + it('Codex default profile without auth uses the global local session fallback', async () => { + resetNativeQuotaState(); + const clock = { now: 5_000_000 }; + let localCalls = 0; + const deps = makeMultiProfileDeps({ + clock, + claudeProfiles: [], + codexProfiles: ['default'], + codexDefault: 'default', + codexNativeAuth: () => null, + codexLocalFallback: async () => { + localCalls += 1; + return codexLocalQuota(); + }, + }); + + const rows = await getNativeAccountRows(deps); + const def = rows.find((r) => r.profile === 'default'); + expect(def).toBeDefined(); + // The bare default legitimately reflects the global ~/.codex local data. + expect(localCalls).toBe(1); + expect(def?.quotaStatus).not.toBe('unsupported'); + }); + + it('cache-fallback rows keep is_default for the default profile', async () => { + resetNativeQuotaState(); + const clock = { now: 6_000_000 }; + const deps = makeMultiProfileDeps({ + clock, + claudeProfiles: ['work', 'ck'], + codexProfiles: ['default', 'ck'], + claudeDefault: 'work', + codexDefault: 'default', + credsForProfile: () => maxCreds(), + claudeFetch: async () => successQuota(), + codexNativeAuth: (p) => ({ accessToken: `t-${p}`, accountId: `id-${p}` }), + codexNetworkFetch: async () => codexSuccessQuota(), + }); + + await getNativeAccountRows(deps); // populate the per-profile caches + + // The cache-fallback path must preserve is_default so the UI still orders and + // tags the default account when /summary serves from cache. + const cached = getCachedNativeAccountRows(); + expect(cached.find((r) => r.surface === 'ccs' && r.profile === 'work')?.is_default).toBe(true); + expect(cached.find((r) => r.surface === 'ccsx' && r.profile === 'default')?.is_default).toBe( + true + ); + expect(cached.find((r) => r.surface === 'ccs' && r.profile === 'ck')?.is_default).toBe(false); + expect(cached.find((r) => r.surface === 'ccsx' && r.profile === 'ck')?.is_default).toBe(false); + }); + + it('parked (no-creds) rows use a short TTL so a fresh login is detected quickly', async () => { + resetNativeQuotaState(); + const clock = { now: 7_000_000 }; + let hasCreds = false; + const deps = makeMultiProfileDeps({ + clock, + claudeProfiles: ['work'], + codexProfiles: [], + claudeDefault: 'work', + credsForProfile: () => (hasCreds ? maxCreds() : null), + claudeFetch: async () => successQuota(), + }); + + // First poll: no creds -> parked, no network. + const first = await getNativeAccountRows(deps); + expect(first.find((r) => r.profile === 'work')?.paused).toBe(true); + expect(deps.claudeFetchCount()).toBe(0); + + // User logs in; advance past the short parked TTL (30s) but far within the + // full quota TTL (10min). The parked row must NOT be served stale. + hasCreds = true; + clock.now += 31_000; + const second = await getNativeAccountRows(deps); + const work = second.find((r) => r.profile === 'work'); + expect(work?.paused).toBe(false); // now live, not dimmed + expect(work?.quotaStatus).toBe('ok'); + expect(deps.claudeFetchCount()).toBe(1); // re-checked -> fetched + }); + + it('Codex named profile with valid auth but sparse payload stays active, not parked', async () => { + resetNativeQuotaState(); + const clock = { now: 7_000_000 }; + const deps = makeMultiProfileDeps({ + clock, + claudeProfiles: [], + codexProfiles: ['ck'], + codexDefault: 'default', + codexNativeAuth: (p) => ({ accessToken: `t-${p}`, accountId: `id-${p}` }), + // Successful response but NO core windows (sparse / changed payload). + codexNetworkFetch: async () => ({ success: true }) as CodexQuotaResult, + }); + + const rows = await getNativeAccountRows(deps); + const ck = rows.find((r) => r.profile === 'ck'); + expect(ck).toBeDefined(); + expect(ck?.paused).toBe(false); // active subscription, not parked + expect(ck?.needsReauth).toBe(false); + expect(ck?.quotaStatus).toBe('ok'); + expect(ck?.quota_percentage).toBeNull(); // no windows, but still active + }); +});