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
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@

### Fixed

- Codex: retry brief credential-file publication races before reporting refresh errors, and discard the previous plan's quota baseline after a subscription change so fresh usage can appear (#3635, #3389).
- Development: restore test compilation on Xcode 26.3 / Swift 6.2 and check app, CLI, and test compatibility in CI (#4070). Thanks @RowboTony!
- Configuration: treat empty or whitespace-only config files like missing files so usage keeps working; settings saves write valid JSON, while malformed non-empty files still report errors (#4071).
- Menu bar: reject corrupt saved positions during status-item visibility changes and removal while preserving valid placement across restarts (#3355).
Expand All @@ -21,6 +22,8 @@
- Kimi Code: mark shorter Code windows as blocked when the known monthly membership pool is exhausted, without showing fresh quota or pace forecasts (#3536).
- z.ai: explain unavailable Coding Plan usage for empty or unsupported quota shapes while preserving recognized quotas and analytics (#2522).
- Grok: keep local token totals visible in Usage & Spend and shared cards across wider history views and billing outages, with consistent daily scan windows (#3716). Thanks @Chipagosfinest!
- Adaptive refresh: recognize ChatGPT's nested Codex app-server with per-scan running-process validation and update-aware signed-bundle assessment caching, avoiding repeated Gatekeeper subprocesses while keeping idle servers at the normal cadence (#4069, #4090).
- Widgets: retain each eligible provider's last-good reading and original age after failed refreshes, even when another provider is unavailable, disabled, or changes accounts (#3500).
- Token history: show observed model names when per-model totals are unavailable, including Grok local sessions (#4056). Fixes #4054. Thanks @BUKOWSKIREAL!
- TypeSafe: show the credit balance in the menu bar and layout preview instead of missing-value dashes, using shared balance-label metadata (#4050). Thanks @lg!
- Grok: retain the product usage breakdown on the grok.com billing fallback, with bounded shared protobuf decoding (#4041). Thanks @olddonkey!
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -192,7 +192,8 @@ struct CodexWeeklyResetConfirmation: Sendable {
return .publishConfirmation
}

guard initialWeekly.usedPercent <= Self.resetThreshold,
guard Self.normalizedPlan(initial) == Self.normalizedPlan(confirmation),
initialWeekly.usedPercent <= Self.resetThreshold,
let initialBoundary = Self.validResetBoundary(initialWeekly, capturedAt: initial.updatedAt),
let confirmationBoundary = Self.validResetBoundary(
confirmationWeekly,
Expand Down Expand Up @@ -404,6 +405,11 @@ struct CodexWeeklyResetConfirmation: Sendable {
return identities.allSatisfy { $0 == first }
}

static func normalizedPlan(_ snapshot: UsageSnapshot?) -> String? {
let plan = snapshot?.loginMethod(for: .codex)?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
return plan?.isEmpty == false ? plan : nil
}

private static func haveCompatiblePlans(_ snapshots: UsageSnapshot...) -> Bool {
// Codex exposes the subscription tier through loginMethod, so it is the plan identity here.
let plans = snapshots.map { snapshot in
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,18 @@ import Foundation
/// Reset-time backfill for Codex rate windows: rebuilds raw snapshot slots from cached lane data so
/// missing reset timestamps survive refreshes without disturbing fresh quota values.
extension UsageStore {
nonisolated static func codexPlanChanged(from previous: UsageSnapshot?, to current: UsageSnapshot) -> Bool {
guard let previousPlan = CodexWeeklyResetConfirmation.normalizedPlan(previous),
let currentPlan = CodexWeeklyResetConfirmation.normalizedPlan(current)
else { return false }
return previousPlan != currentPlan
}

nonisolated static func codexBackfillingResetWindows(
_ snapshot: UsageSnapshot,
from cached: UsageSnapshot) -> UsageSnapshot
{
guard !self.codexPlanChanged(from: cached, to: snapshot) else { return snapshot }
let primary = self.codexBackfilledSlotWindow(
slotWindow: snapshot.primary,
lane: .session,
Expand Down Expand Up @@ -132,3 +140,12 @@ extension UsageStore {
resetDescription: cached.resetDescription)
}
}

extension ProviderFetchOutcome {
nonisolated func backfillingCodexResetWindows(from cached: UsageSnapshot?) -> ProviderFetchOutcome {
guard let cached, case let .success(result) = self.result else { return self }
return self.replacingUsage(UsageStore.codexBackfillingResetWindows(
result.usage.scoped(to: .codex),
from: cached))
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -49,16 +49,19 @@ extension UsageStore {
return CodexWeeklyResetPublicationAdmission(outcome: initialOutcome, pendingCandidate: candidateForRetry)
}
let rawInitialSnapshot = rawInitialResult.usage.scoped(to: .codex)
let publicationBaseline = [previousSnapshot, missingWindowBackfillSnapshot]
let cachedBaseline = [previousSnapshot, missingWindowBackfillSnapshot]
.compactMap(\.self)
.max { $0.updatedAt < $1.updatedAt }
let publicationInitialOutcome = if let missingWindowBackfillSnapshot {
initialOutcome.replacingUsage(Self.codexBackfillingResetWindows(
rawInitialSnapshot,
from: missingWindowBackfillSnapshot))
} else {
initialOutcome
}
let planBaseline = previousSnapshot ?? missingWindowBackfillSnapshot
let planChanged = Self.isExactCodexOAuthResult(rawInitialResult)
&& rawInitialSnapshot.updatedAt > (cachedBaseline?.updatedAt ?? .distantFuture)
&& Self.codexPlanChanged(from: planBaseline, to: rawInitialSnapshot)
// A new subscription has a different quota baseline, not evidence of a reset on the old plan.
let previousSnapshot = planChanged ? nil : previousSnapshot
let missingWindowBackfillSnapshot = planChanged ? nil : missingWindowBackfillSnapshot
let publicationBaseline = planChanged ? nil : cachedBaseline
if planChanged { candidateForRetry = nil }
let publicationInitialOutcome = initialOutcome.backfillingCodexResetWindows(from: missingWindowBackfillSnapshot)

if CodexConsumerProjection.sourceRateWindow(for: .weekly, snapshot: rawInitialSnapshot) == nil {
return Self.codexMissingWeeklyAdmission(input: CodexMissingWeeklyAdmissionInput(
Expand Down Expand Up @@ -168,15 +171,8 @@ extension UsageStore {
trace: confirmationTrace)
switch confirmationDecision {
case .publishConfirmation:
if let missingWindowBackfillSnapshot {
return CodexWeeklyResetPublicationAdmission(
outcome: confirmationOutcome.replacingUsage(Self.codexBackfillingResetWindows(
confirmationSnapshot,
from: missingWindowBackfillSnapshot)),
pendingCandidate: nil)
}
return CodexWeeklyResetPublicationAdmission(
outcome: confirmationOutcome,
outcome: confirmationOutcome.backfillingCodexResetWindows(from: missingWindowBackfillSnapshot),
pendingCandidate: nil)
case .preservePrevious:
let candidate = Self.makeCodexDelayedCandidate(
Expand Down
2 changes: 2 additions & 0 deletions Sources/CodexBar/UsageStore+Refresh.swift
Original file line number Diff line number Diff line change
Expand Up @@ -821,6 +821,8 @@ extension UsageStore {
resetBackfillSource: UsageSnapshot?,
context: ProviderRefreshOutcomeContext) -> UsageSnapshot
{
let resetBackfillSource = provider == .codex && Self.codexPlanChanged(from: resetBackfillSource, to: snapshot)
? nil : resetBackfillSource
let profileStable = self.preservingDeepSeekProfileCatalog(in: snapshot, provider: provider)
let stabilized = Self.commandCodeSnapshotResolvingDepletionOnEnrichmentFailure(
current: profileStable,
Expand Down
4 changes: 3 additions & 1 deletion Sources/CodexBar/UsageStore+TokenAccounts.swift
Original file line number Diff line number Diff line change
Expand Up @@ -1313,7 +1313,9 @@ extension UsageStore {
}
let labeled = self.applyCodexVisibleAccountLabel(scoped, account: account)
let backfilled =
Self.codexMergedResetBackfillSnapshot(resetBackfillSnapshots)
Self.codexMergedResetBackfillSnapshot(resetBackfillSnapshots.filter {
!Self.codexPlanChanged(from: $0, to: labeled)
})
.map { Self.codexBackfillingResetWindows(labeled, from: $0) } ?? labeled
let credits = CodexMonthlyCreditPreservation.merging(
incoming: result.credits,
Expand Down
37 changes: 15 additions & 22 deletions Sources/CodexBar/UsageStore+WidgetSnapshot.swift
Original file line number Diff line number Diff line change
Expand Up @@ -38,9 +38,7 @@ extension UsageStore {
}()
let snapshot = self.makeWidgetSnapshot(previousSnapshot: previousSnapshot)
self.lastQueuedWidgetSnapshot = snapshot
self.lastQueuedWidgetSnapshotIsPreservable = snapshot.entries.allSatisfy {
!self.widgetUsagePreservationBlockedProviders.contains($0.provider)
}
self.invalidatedQueuedWidgetProviders = self.widgetUsagePreservationBlockedProviders
NotificationCenter.default.post(
name: .codexbarUsageSnapshotsDidChange,
object: UsageSnapshotsDidChangeEvent(snapshots: self.cloudSyncAccountSnapshots()))
Expand Down Expand Up @@ -191,33 +189,28 @@ extension UsageStore {
self.lastWidgetSourceSnapshots[provider.instanceID] = nil
self.widgetUsagePreservationBlockedProviders.insert(provider.instanceID)
// A successful fetch cannot make an older queued account valid again.
if self.lastQueuedWidgetSnapshot?.entries.contains(where: { $0.provider == provider.instanceID }) == true {
self.lastQueuedWidgetSnapshotIsPreservable = false
}
self.invalidatedQueuedWidgetProviders.insert(provider.instanceID)
}

private func makeWidgetSnapshot(previousSnapshot: WidgetSnapshot?) -> WidgetSnapshot {
let now = Date()
let enabledProviders = self.enabledProviders()
var entries = UsageProvider.allCases.compactMap { provider in
self.makeWidgetEntry(
let entries = UsageProvider.allCases.compactMap { provider -> WidgetSnapshot.ProviderEntry? in
if let entry = self.makeWidgetEntry(
for: provider,
now: now,
previousEntry: previousSnapshot?.entries.first { $0.provider == provider.instanceID })
}
// Only reuse this process's publication; disk entries do not establish the current account's ownership.
if entries.isEmpty, self.lastQueuedWidgetSnapshotIsPreservable,
let previousSnapshot = self.lastQueuedWidgetSnapshot,
previousSnapshot.enabledProviders.allSatisfy(enabledProviders.contains),
previousSnapshot.entries.allSatisfy({ entry in
// Provider-specific by design: Claude's owner-aware preservation above remains authoritative.
entry.provider != .claude && enabledProviders.contains(entry.provider) &&
self.errors[entry.provider] != nil &&
(entry.providerCost == nil || self.settings.showOptionalCreditsAndExtraUsage) &&
!self.widgetUsagePreservationBlockedProviders.contains(entry.provider)
})
{
entries = previousSnapshot.entries.map { self.preservedWidgetEntryForCurrentMetric($0) }
{ return entry }
// Provider-specific by design: Claude uses its owner-aware path; others require this process's publication.
guard provider != .claude, enabledProviders.contains(provider.instanceID),
self.errors[provider.instanceID] != nil,
!self.invalidatedQueuedWidgetProviders.contains(provider.instanceID),
!self.widgetUsagePreservationBlockedProviders.contains(provider.instanceID),
let entry = self.lastQueuedWidgetSnapshot?.entries
.first(where: { $0.provider == provider.instanceID }),
entry.providerCost == nil || self.settings.showOptionalCreditsAndExtraUsage
else { return nil }
return self.preservedWidgetEntryForCurrentMetric(entry)
}
return WidgetSnapshot(
entries: entries,
Expand Down
2 changes: 1 addition & 1 deletion Sources/CodexBar/UsageStore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -332,7 +332,7 @@ final class UsageStore {
TimeInterval) async throws -> Void)?
@ObservationIgnored var widgetSnapshotPersistTask: Task<Void, Never>?
@ObservationIgnored var lastQueuedWidgetSnapshot: WidgetSnapshot?
@ObservationIgnored var lastQueuedWidgetSnapshotIsPreservable = false
@ObservationIgnored var invalidatedQueuedWidgetProviders: Set<ProviderInstanceID> = []
@ObservationIgnored var lastWidgetSourceSnapshots: [ProviderInstanceID: UsageSnapshot] = [:]
@ObservationIgnored let widgetSnapshotURL: URL?
@ObservationIgnored let widgetTimelineReloader: @MainActor () -> Void
Expand Down
37 changes: 14 additions & 23 deletions Sources/CodexBarCore/AgentSession.swift
Original file line number Diff line number Diff line change
Expand Up @@ -327,34 +327,25 @@ public enum AgentPSOutputParser {
}
}

static func chatGPTCodexAppServerExecutable(
static let chatGPTCodexExecutablePaths: Set<String> = [
"/Applications/ChatGPT.app/Contents/Resources/codex",
"/Applications/ChatGPT.app/Contents/Resources/codex-cli/CodexCLI.app/Contents/MacOS/codex",
]

static func hasTrustedChatGPTCodexAppServer(
in records: [AgentProcessRecord],
homeDirectory: URL) -> String?
validator: (AgentProcessRecord) -> Bool) -> Bool
{
let allowedPaths = Set([
URL(fileURLWithPath: "/Applications/ChatGPT.app/Contents/Resources/codex")
.standardizedFileURL.path,
homeDirectory.appendingPathComponent("Applications/ChatGPT.app/Contents/Resources/codex")
.standardizedFileURL.path,
])

return records.lazy.compactMap { record -> String? in
guard record.executableBasename.lowercased() == AgentSession.Provider.codex.rawValue,
self.arguments(record).contains("app-server"),
let executable = record.arguments?.first ?? record.command.split(whereSeparator: \ .isWhitespace)
.first.map(String.init)
else { return nil }

let path = URL(fileURLWithPath: executable).standardizedFileURL.path
return allowedPaths.contains(path) ? path : nil
}.first
records.contains { record in
let executable = record.arguments?.first ?? record.command.split(whereSeparator: \ .isWhitespace)
.first.map(String.init) ?? ""
return self.chatGPTCodexExecutablePaths.contains(executable) &&
self.arguments(record).contains("app-server") && validator(record)
}
}

private static func arguments(_ record: AgentProcessRecord) -> [String] {
if let arguments = record.arguments {
return Array(arguments.dropFirst())
}
return self.arguments(record.command)
Array((record.arguments ?? record.command.split(whereSeparator: \ .isWhitespace).map(String.init)).dropFirst())
}

private static func arguments(_ command: String) -> [String] {
Expand Down
Loading
Loading