Skip to content
Merged
Show file tree
Hide file tree
Changes from 8 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -199,6 +199,7 @@
- Codex: avoid full SQLite cost-cache rewrites for unchanged scans while preserving retention, freshness, and concurrent saves (#2852). Thanks @Yuxin-Qiao!
- Azure OpenAI: allow enough v1 completion budget for reasoning-capable deployment validation while keeping the probe bounded (#2867). Thanks @yilinxia!
- Codex: preserve request-level pricing tiers while reconciling forked usage, preventing day aggregates from triggering long-context rates (#2858). Thanks @thomaschow19!
- Claude: keep 100% claude-swap usage bars when cswap defers polling at a limit, and name the exhausted window and reset instead of showing "Usage fetch failed." (#3081).
- CLI: stop standalone version lookup from walking past the filesystem root and hanging with unbounded memory on affected macOS versions (#2856). Thanks @Manwholikespie!
- Cost store: prevent launch-time executor-assumption crashes on macOS 15 by keeping synchronous SQLite cache bridges on their validated serial queue (#2857). Thanks @Manwholikespie!

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,10 @@ extension UsageStore {

do {
let list = try await ClaudeSwapAccountReader.readAccountList(executablePath: executablePath)
let snapshots = ClaudeSwapAccountProjection.accountSnapshots(from: list)
let snapshots = ClaudeSwapAccountProjection.accountSnapshots(
from: list,
previousAccounts: self.claudeSwapAccountSnapshots)
Comment thread
sf-jin-ku marked this conversation as resolved.
Resolved
ClaudeSwapRetainedUsageStore.save(snapshots)
Comment thread
sf-jin-ku marked this conversation as resolved.
Resolved
guard self.isCurrentClaudeSwapRefresh(executablePath: executablePath, generation: generation) else {
return
}
Expand Down
10 changes: 8 additions & 2 deletions Sources/CodexBarCLI/CLIClaudeSwapCards.swift
Original file line number Diff line number Diff line change
Expand Up @@ -125,13 +125,19 @@ enum CLIClaudeSwapCards {
showSingleAccount: Bool = false,
renderOptions: CLIClaudeSwapCardsRenderOptions,
ambientFetch: @escaping AmbientFetch,
accountListReader: @escaping AccountListReader) async -> UsageCommandOutput
accountListReader: @escaping AccountListReader,
previousAccounts: [ProviderAccountUsageSnapshot] = []) async -> UsageCommandOutput
{
guard eligible else { return await ambientFetch() }

do {
let list = try await accountListReader(executablePath)
let accounts = ClaudeSwapAccountProjection.accountSnapshots(from: list, now: renderOptions.now)
let retained = previousAccounts.isEmpty ? ClaudeSwapRetainedUsageStore.load() : previousAccounts
let accounts = ClaudeSwapAccountProjection.accountSnapshots(
from: list,
previousAccounts: retained,
now: renderOptions.now)
ClaudeSwapRetainedUsageStore.save(accounts)
guard ClaudeSwapAccountProjection.shouldPresentAccounts(
accountCount: accounts.count,
showSingleAccount: showSingleAccount)
Expand Down
6 changes: 5 additions & 1 deletion Sources/CodexBarCLI/CLIDashboardCommand.swift
Original file line number Diff line number Diff line change
Expand Up @@ -139,8 +139,12 @@ struct DashboardSnapshotProducer: Sendable {
let list = try await ClaudeSwapAccountReader.readAccountList(
executablePath: path,
timeout: timeout)
let accounts = ClaudeSwapAccountProjection.accountSnapshots(
from: list,
previousAccounts: ClaudeSwapRetainedUsageStore.load())
ClaudeSwapRetainedUsageStore.save(accounts)
return DashboardClaudeSwapCollection(
accounts: ClaudeSwapAccountProjection.accountSnapshots(from: list),
accounts: accounts,
adapterError: nil)
} catch {
let diagnostic = CLIClaudeSwapText.sanitizeDiagnostic(error.localizedDescription)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,30 +8,41 @@ public enum ClaudeSwapAccountProjection {
public static let sourceLabel = "claude-swap"
static let fiveHourWindowMinutes = 5 * 60
static let sevenDayWindowMinutes = 7 * 24 * 60
static let exhaustedUsedPercent = 100.0
static let deferredPollingNote = "Polling deferred until a limit resets."

public static func shouldPresentAccounts(accountCount: Int, showSingleAccount: Bool) -> Bool {
accountCount >= (showSingleAccount ? 1 : 2)
}

public static func accountSnapshots(
from list: ClaudeSwapAccountList,
previousAccounts: [ProviderAccountUsageSnapshot] = [],
Comment thread
sf-jin-ku marked this conversation as resolved.
Resolved
now: Date = Date()) -> [ProviderAccountUsageSnapshot]
{
let previousByID = Dictionary(
previousAccounts.map { ($0.id, $0) },
uniquingKeysWith: { first, _ in first })
let ordered = list.accounts.sorted { lhs, rhs in
if lhs.isActive != rhs.isActive {
return lhs.isActive
}
return lhs.number < rhs.number
}
return ordered.map { row in
ProviderAccountUsageSnapshot(
id: ProviderAccountIdentity(source: self.sourceName, opaqueID: String(row.number)),
let id = ProviderAccountIdentity(source: self.sourceName, opaqueID: String(row.number))
let snapshot = self.usageSnapshot(
for: row,
previous: previousByID[id],
now: now)
return ProviderAccountUsageSnapshot(
id: id,
provider: .claude,
displayLabel: self.displayLabel(for: row),
isActive: row.isActive,
canActivate: !row.isActive && self.canActivate(row),
snapshot: self.usageSnapshot(for: row, now: now),
error: self.errorText(for: row),
snapshot: snapshot,
error: self.errorText(for: row, snapshot: snapshot, now: now),
sourceLabel: self.sourceLabel)
}
}
Expand All @@ -50,8 +61,85 @@ public enum ClaudeSwapAccountProjection {
row.email.isEmpty ? "Account \(row.number)" : row.email
}

private static func usageSnapshot(for row: ClaudeSwapAccountRow, now: Date) -> UsageSnapshot? {
guard row.usageStatus == .ok else { return nil }
private static func usageSnapshot(
for row: ClaudeSwapAccountRow,
previous: ProviderAccountUsageSnapshot?,
now: Date) -> UsageSnapshot?
{
switch row.usageStatus {
case .ok, .unavailable:
if let projected = self.projectedUsageSnapshot(for: row, now: now) {
if row.usageStatus == .ok {
return projected
}
if let pruned = self.prunedAtLimitSnapshot(
projected,
identity: projected.identity ?? self.identitySnapshot(for: row),
now: now)
{
return pruned
}
}
guard row.usageStatus == .unavailable else { return nil }
return self.retainedAtLimitSnapshot(previous, matching: row, now: now)
case .tokenExpired, .reloginRequired, .apiKey, .keychainUnavailable, .noCredentials, .unknown:
return nil
}
}

private static func retainedAtLimitSnapshot(
_ previous: ProviderAccountUsageSnapshot?,
matching row: ClaudeSwapAccountRow,
now: Date) -> UsageSnapshot?
{
guard let previous, let snapshot = previous.snapshot else { return nil }
guard let previousFingerprint = ClaudeSwapRetainedUsageStore.fingerprint(from: previous) else {
return nil
}
let rowFingerprint = ClaudeSwapRetainedUsageStore.fingerprint(
email: row.email,
slot: String(row.number))
guard previousFingerprint == rowFingerprint else { return nil }
return self.prunedAtLimitSnapshot(snapshot, identity: self.identitySnapshot(for: row), now: now)
}

/// Drops windows whose reset is in the past so a mixed snapshot cannot keep showing
/// an already-reset lane as "Resets now" just because a sibling is still exhausted.
private static func prunedAtLimitSnapshot(
_ snapshot: UsageSnapshot,
identity: ProviderIdentitySnapshot?,
now: Date) -> UsageSnapshot?
{
let primary = self.unexpiredWindow(snapshot.primary, now: now)
let secondary = self.unexpiredWindow(snapshot.secondary, now: now)
let extra = (snapshot.extraRateWindows ?? []).compactMap { named -> NamedRateWindow? in
guard let window = self.unexpiredWindow(named.window, now: now) else { return nil }
return NamedRateWindow(
id: named.id,
title: named.title,
window: window,
usageKnown: named.usageKnown)
}
let remaining = [primary, secondary].compactMap(\.self) + extra.map(\.window)
guard remaining.contains(where: { $0.usedPercent >= self.exhaustedUsedPercent }) else {
return nil
}
return UsageSnapshot(
primary: primary,
secondary: secondary,
extraRateWindows: extra.isEmpty ? nil : extra,
updatedAt: snapshot.updatedAt,
identity: identity,
dataConfidence: snapshot.dataConfidence)
}

private static func unexpiredWindow(_ window: RateWindow?, now: Date) -> RateWindow? {
guard let window else { return nil }
guard window.resetsAt.map({ $0 > now }) ?? true else { return nil }
Comment thread
sf-jin-ku marked this conversation as resolved.
Resolved
return window
Comment thread
sf-jin-ku marked this conversation as resolved.
Resolved
}

private static func projectedUsageSnapshot(for row: ClaudeSwapAccountRow, now: Date) -> UsageSnapshot? {
let primary = row.fiveHour.map { window in
RateWindow(
usedPercent: window.usedPercent,
Expand All @@ -73,11 +161,15 @@ public enum ClaudeSwapAccountProjection {
secondary: secondary,
extraRateWindows: scoped.isEmpty ? nil : scoped,
updatedAt: now,
identity: ProviderIdentitySnapshot(
providerID: .claude,
accountEmail: self.displayLabel(for: row),
accountOrganization: nil,
loginMethod: self.sourceLabel))
identity: self.identitySnapshot(for: row))
}

private static func identitySnapshot(for row: ClaudeSwapAccountRow) -> ProviderIdentitySnapshot {
ProviderIdentitySnapshot(
providerID: .claude,
accountEmail: self.displayLabel(for: row),
accountOrganization: nil,
loginMethod: self.sourceLabel)
}

private static func scopedRateWindows(for row: ClaudeSwapAccountRow) -> [NamedRateWindow] {
Expand All @@ -92,12 +184,10 @@ public enum ClaudeSwapAccountProjection {
})
}

private static func errorText(for row: ClaudeSwapAccountRow) -> String? {
private static func errorText(for row: ClaudeSwapAccountRow, snapshot: UsageSnapshot?, now: Date) -> String? {
switch row.usageStatus {
case .ok:
row.fiveHour == nil && row.sevenDay == nil && self.scopedRateWindows(for: row).isEmpty
? "No usage windows reported."
: nil
snapshot == nil ? "No usage windows reported." : nil
case .tokenExpired:
"Token expired. Switch to this account in claude-swap to refresh it."
case .reloginRequired:
Expand All @@ -109,12 +199,48 @@ public enum ClaudeSwapAccountProjection {
case .noCredentials:
"No stored credentials for this account slot."
case .unavailable:
"Usage fetch failed."
self.atLimitNote(from: snapshot, now: now) ?? self.deferredPollingNote
Comment thread
sf-jin-ku marked this conversation as resolved.
Resolved
case let .unknown(raw):
"Unrecognized claude-swap status: \(raw)"
}
}

private static func atLimitNote(from snapshot: UsageSnapshot?, now: Date) -> String? {
guard let snapshot else { return nil }
var parts: [String] = []
if let primary = snapshot.primary {
self.appendLimit(named: "Session", window: primary, now: now, to: &parts)
}
if let secondary = snapshot.secondary {
self.appendLimit(named: "Weekly", window: secondary, now: now, to: &parts)
}
for extra in snapshot.extraRateWindows ?? [] {
self.appendLimit(named: self.scopedLimitName(extra.title), window: extra.window, now: now, to: &parts)
}
guard !parts.isEmpty else { return nil }
return parts.joined(separator: " ")
}

private static func appendLimit(
named name: String,
window: RateWindow,
now: Date,
to parts: inout [String])
{
guard window.usedPercent >= self.exhaustedUsedPercent else { return }
if let reset = UsageFormatter.resetLine(for: window, style: .countdown, now: now) {
parts.append("\(name) limit reached. \(reset).")
} else {
parts.append("\(name) limit reached.")
}
}

private static func scopedLimitName(_ title: String) -> String {
let suffix = " only"
guard title.hasSuffix(suffix) else { return title }
return String(title.dropLast(suffix.count))
}

private static func canActivate(_ row: ClaudeSwapAccountRow) -> Bool {
switch row.usageStatus {
case .ok, .apiKey, .unavailable:
Expand Down
Loading
Loading