Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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 @@ -14,6 +14,7 @@
- Hide untouched Antigravity model families in the `codexbar serve` web dashboard, matching the menu and widgets (#3061). Thanks @urda!
- Documented the AI Usage Limits Stream Deck plugin in the README integrations list (#3066). Thanks @lenadweb!
- OpenCode Go: use the public authenticated usage API when `OPENCODE_API_KEY` is configured, overlaying authoritative rolling/weekly/monthly windows on local history with cookie fallback (#2879, #3065). Thanks @akshayprabhu200!
- 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).

## 0.54.0 — 2026-08-18

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

do {
let list = try await ClaudeSwapAccountReader.readAccountList(executablePath: executablePath)
let snapshots = ClaudeSwapAccountProjection.accountSnapshots(from: list)
let snapshots = ClaudeSwapAccountProjection.accountSnapshots(
from: list,
previousAccounts: ClaudeSwapRetainedUsageStore.previousAccounts(
inMemory: self.claudeSwapAccountSnapshots))
guard self.isCurrentClaudeSwapRefresh(executablePath: executablePath, generation: generation) else {
return
}
ClaudeSwapRetainedUsageStore.save(snapshots)
self.claudeSwapAccountSnapshots = snapshots
self.claudeSwapLastRefreshAt = Date()
self.claudeSwapLastError = nil
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 = ClaudeSwapRetainedUsageStore.previousAccounts(inMemory: 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
3 changes: 2 additions & 1 deletion Sources/CodexBarCLI/CLIServeWebUI+HTML.swift
Original file line number Diff line number Diff line change
Expand Up @@ -899,9 +899,10 @@ extension CLIServeWebUI {
card.append(identity);
}

// At-limit claude-swap cards carry both a deferred/limit note and retained
// windows; keep those bars visible instead of returning after the note.
if (account.error) {
card.append(node("p", "error-message", account.error));
return card;
}

const windows = node("div", "windows");
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.
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 }
let previousFingerprint = ClaudeSwapRetainedUsageStore.fingerprint(from: previous)
let rowFingerprint = ClaudeSwapRetainedUsageStore.fingerprint(
email: row.email,
slot: String(row.number))
guard let previousFingerprint, let rowFingerprint, 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 let resetsAt = window.resetsAt, resetsAt > now else { return nil }
return window
Comment thread
sf-jin-ku marked this conversation as 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.
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