From 7b31448425ec9134c96764dc42bf273eac2f6a74 Mon Sep 17 00:00:00 2001 From: ozymandiashh <234437643+ozymandiashh@users.noreply.github.com> Date: Sun, 13 Sep 2026 02:40:56 +0300 Subject: [PATCH 1/7] fix(mac): detect early quota resets for every provider, not just Claude MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The detector shipped wired to one provider. Codex's goodwill resets are the clearest instance of the thing it was built to catch — a vendor restoring a rate-limit window ahead of schedule — and the refresh lifecycle saw every one of them and dropped it, because `detectEarlyResets` was only ever called from Claude's snapshot capture. Each Capacity Dock provider now hands the monitor its own windows as its fetch succeeds, off the `QuotaSummary` the popover already draws. Claude keeps its existing call, whose window keys are the snapshot store's and must not move; every other provider identifies a window by the label its adapter already shows, slugified, which is the only stable name these adapters give one. Nothing about the thresholds or the guards moved. A window still needs a reset time and a validated length to produce anything, so Claude and Codex announce today and Antigravity, ClinePass, Copilot, Cursor, Gemini, Grok, Kimi Code and Z.ai run the same path in silence until their adapters carry a length. The monitor's record was already per provider; the band, the dedupe, the baseline and now the history summary are all scoped with it, so an early reset on one provider cannot move another's state, and a disconnect drops only its own. The 30-day pattern needs cycle times to read, and only Claude has a snapshot file, so the monitor keeps a small per-window ledger of observed cycles beside its baseline for everyone else. The field is optional, and the defaults key is untouched, so a record written by the build that shipped this still decodes and a Claude reset already announced is not announced again after the update. `usageName` no longer doubles the noun, which it would have for Codex's "Monthly usage limit" row. --- mac/Sources/CodeBurnMenubar/AppStore.swift | 221 +++++++++++++++++- .../Data/EarlyQuotaReset.swift | 68 +++++- .../Data/EarlyQuotaResetMonitor.swift | 59 ++++- 3 files changed, 331 insertions(+), 17 deletions(-) diff --git a/mac/Sources/CodeBurnMenubar/AppStore.swift b/mac/Sources/CodeBurnMenubar/AppStore.swift index 01855bc2a..fcc349972 100644 --- a/mac/Sources/CodeBurnMenubar/AppStore.swift +++ b/mac/Sources/CodeBurnMenubar/AppStore.swift @@ -180,7 +180,9 @@ final class AppStore { /// from data already on disk on the existing refresh lifecycle — no polling /// of its own, no network (#725). var earlyResetEvents: [String: EarlyQuotaResetEvent] = [:] - var earlyResetHistory: [EarlyQuotaResetHistory.Summary] = [] + /// Keyed by dock provider id too, so one provider's pattern never captions + /// another's hover card. + var earlyResetHistory: [String: [EarlyQuotaResetHistory.Summary]] = [:] @ObservationIgnored var earlyQuotaResetMonitor = EarlyQuotaResetMonitor() var codexUsage: CodexUsage? @@ -1623,9 +1625,7 @@ final class AppStore { subscriptionError = nil subscriptionLoadState = .notBootstrapped capacityEstimates = [:] - earlyResetEvents[CapacityDockProvider.claude.rawValue] = nil - earlyResetHistory = [] - earlyQuotaResetMonitor.forget(providerID: CapacityDockProvider.claude.rawValue) + forgetEarlyResets(for: .claude) Task.detached { await SubscriptionSnapshotStore.clearAll() } // Notify the AppDelegate to clear its cadence-loop anchor so the next // reconnect doesn't measure against a pre-disconnect timestamp. @@ -1642,6 +1642,13 @@ final class AppStore { codexError = nil codexLoadState = .loaded await codexBankedResetAnnouncer.observe(usage.resetCredits) + // A bootstrap is the far side of a gap, so this fetch only seeds a + // baseline — the same discipline `bootstrapSubscription` uses. + await detectEarlyResets( + provider: .codex, + summary: codexQuotaSummary(filter: .codex), + baselineIsTrusted: false + ) } catch let err as CodexSubscriptionService.FetchError { applyCodexFetchError(err) } catch { @@ -1664,6 +1671,11 @@ final class AppStore { if codexLoadState != .notBootstrapped { codexLoadState = .notBootstrapped } return false } + // Read before `beginCodexQuotaRefresh` moves the state to `.loading`; + // with a refresh already in flight the restore state is the real one. + let stateBeforeFetch = codexRefreshInFlightRequest == nil + ? codexLoadState + : (codexRefreshRestoreState ?? codexLoadState) let token = beginCodexQuotaRefresh() do { guard let usage = try await codexQuotaFetcher() else { @@ -1683,6 +1695,11 @@ final class AppStore { // side-effect of a successful fetch and must not be able to hold the // single-flight token open. await codexBankedResetAnnouncer.observe(usage.resetCredits) + await detectEarlyResets( + provider: .codex, + summary: codexQuotaSummary(filter: .codex), + baselineIsTrusted: stateBeforeFetch.earlyResetBaselineIsTrusted + ) return true } catch let err as CodexSubscriptionService.FetchError { guard isCurrentCodexQuotaRefresh(token) else { return false } @@ -1724,6 +1741,7 @@ final class AppStore { codexUsage = nil codexError = nil codexLoadState = .notBootstrapped + forgetEarlyResets(for: .codex) // Same reason the snapshot store is wiped on the Claude side: a // reconnect under a different account must baseline again rather than // announce that account's entire inventory as new grants. @@ -1763,6 +1781,11 @@ final class AppStore { kimiUsage = usage kimiError = nil kimiLoadState = .loaded + await detectEarlyResets( + provider: .kimiCode, + summary: kimiQuotaSummary(filter: .kimiCode), + baselineIsTrusted: false + ) } catch let err as KimiSubscriptionService.FetchError { guard gen == kimiRefreshGen else { return } applyKimiFetchError(err) @@ -1787,6 +1810,10 @@ final class AppStore { if kimiLoadState != .notBootstrapped { kimiLoadState = .notBootstrapped } return false } + // Read before the state moves to `.loading`: whether the stored reading + // can be compared against this fetch is a fact about the state this + // fetch started from. + let stateBeforeFetch = kimiLoadState let gen = kimiRefreshGen if kimiUsage == nil { kimiLoadState = .loading } do { @@ -1795,6 +1822,11 @@ final class AppStore { kimiUsage = usage kimiError = nil kimiLoadState = .loaded + await detectEarlyResets( + provider: .kimiCode, + summary: kimiQuotaSummary(filter: .kimiCode), + baselineIsTrusted: stateBeforeFetch.earlyResetBaselineIsTrusted + ) return true } catch let err as KimiSubscriptionService.FetchError { guard gen == kimiRefreshGen else { return false } @@ -1814,6 +1846,7 @@ final class AppStore { kimiUsage = nil kimiError = nil kimiLoadState = .notBootstrapped + forgetEarlyResets(for: .kimiCode) NotificationCenter.default.post(name: .codeBurnSubscriptionDisconnected, object: nil) } @@ -1846,6 +1879,11 @@ final class AppStore { geminiUsage = usage geminiError = nil geminiLoadState = .loaded + await detectEarlyResets( + provider: .gemini, + summary: geminiQuotaSummary(filter: .gemini), + baselineIsTrusted: false + ) } catch let err as GeminiSubscriptionService.FetchError { guard gen == geminiRefreshGen else { return } applyGeminiFetchError(err) @@ -1870,6 +1908,7 @@ final class AppStore { if geminiLoadState != .notBootstrapped { geminiLoadState = .notBootstrapped } return false } + let stateBeforeFetch = geminiLoadState let gen = geminiRefreshGen if geminiUsage == nil { geminiLoadState = .loading } do { @@ -1878,6 +1917,11 @@ final class AppStore { geminiUsage = usage geminiError = nil geminiLoadState = .loaded + await detectEarlyResets( + provider: .gemini, + summary: geminiQuotaSummary(filter: .gemini), + baselineIsTrusted: stateBeforeFetch.earlyResetBaselineIsTrusted + ) return true } catch let err as GeminiSubscriptionService.FetchError { guard gen == geminiRefreshGen else { return false } @@ -1897,6 +1941,7 @@ final class AppStore { geminiUsage = nil geminiError = nil geminiLoadState = .notBootstrapped + forgetEarlyResets(for: .gemini) NotificationCenter.default.post(name: .codeBurnSubscriptionDisconnected, object: nil) } @@ -1937,6 +1982,11 @@ final class AppStore { copilotUsage = usage copilotError = nil copilotLoadState = .loaded + await detectEarlyResets( + provider: .copilot, + summary: copilotQuotaSummary(filter: .copilot), + baselineIsTrusted: false + ) } catch let err as CopilotSubscriptionService.FetchError { guard gen == copilotRefreshGen else { return } applyCopilotFetchError(err) @@ -1972,6 +2022,7 @@ final class AppStore { if copilotLoadState != .notBootstrapped { copilotLoadState = .notBootstrapped } return false } + let stateBeforeFetch = copilotLoadState let gen = copilotRefreshGen if copilotUsage == nil { copilotLoadState = .loading } do { @@ -1980,6 +2031,11 @@ final class AppStore { copilotUsage = usage copilotError = nil copilotLoadState = .loaded + await detectEarlyResets( + provider: .copilot, + summary: copilotQuotaSummary(filter: .copilot), + baselineIsTrusted: stateBeforeFetch.earlyResetBaselineIsTrusted + ) return true } catch let err as CopilotSubscriptionService.FetchError { guard gen == copilotRefreshGen else { return false } @@ -2000,6 +2056,7 @@ final class AppStore { copilotUsage = nil copilotError = nil copilotLoadState = .notBootstrapped + forgetEarlyResets(for: .copilot) NotificationCenter.default.post(name: .codeBurnSubscriptionDisconnected, object: nil) } @@ -2036,6 +2093,11 @@ final class AppStore { antigravityUsage = usage antigravityError = nil antigravityLoadState = .loaded + await detectEarlyResets( + provider: .antigravity, + summary: antigravityQuotaSummary(filter: .antigravity), + baselineIsTrusted: false + ) } catch let err as AntigravitySubscriptionService.FetchError { guard gen == antigravityRefreshGen else { return } applyAntigravityFetchError(err) @@ -2059,6 +2121,7 @@ final class AppStore { // Only an explicit Disconnect stops the cadence probe; there is no // credential file to poll for, the probe IS the availability check. if case .notBootstrapped = antigravityLoadState { return false } + let stateBeforeFetch = antigravityLoadState let gen = antigravityRefreshGen if antigravityUsage == nil { antigravityLoadState = .loading } do { @@ -2067,6 +2130,11 @@ final class AppStore { antigravityUsage = usage antigravityError = nil antigravityLoadState = .loaded + await detectEarlyResets( + provider: .antigravity, + summary: antigravityQuotaSummary(filter: .antigravity), + baselineIsTrusted: stateBeforeFetch.earlyResetBaselineIsTrusted + ) return true } catch let err as AntigravitySubscriptionService.FetchError { guard gen == antigravityRefreshGen else { return false } @@ -2085,6 +2153,7 @@ final class AppStore { antigravityUsage = nil antigravityError = nil antigravityLoadState = .notBootstrapped + forgetEarlyResets(for: .antigravity) NotificationCenter.default.post(name: .codeBurnSubscriptionDisconnected, object: nil) } @@ -2151,10 +2220,16 @@ final class AppStore { } /// This Mac's own early-reset pattern for the provider's windows, for the - /// quota hover card. Only Claude persists the snapshots this is derived from. + /// quota hover card. Scoped to the provider asking: Claude's cycles never + /// caption Codex's card. func earlyResetHistoryCaptions(for filter: ProviderFilter) -> [String] { - guard filter == .claude else { return [] } - return earlyResetHistory.map(\.caption) + guard let provider = CapacityDockPreferences.supportedProviders + .first(where: { $0.legacyFilter == filter }) else { return [] } + return earlyResetHistoryCaptions(for: provider) + } + + func earlyResetHistoryCaptions(for provider: CapacityDockProvider) -> [String] { + (earlyResetHistory[provider.rawValue] ?? []).map(\.caption) } /// Snapshot of live quota state for a given provider. Returns nil when the user @@ -2427,6 +2502,9 @@ final class AppStore { capacityDockProviderErrors[provider.id] = nil capacityDockProvidersLoading.remove(provider.id) capacityDockProviderTransientFailures.remove(provider.id) + // A new credential can be a different account, whose cycles the old + // baseline, band and pattern say nothing about. + forgetEarlyResets(for: provider) } func disconnectCapacityDockProvider(_ provider: CapacityDockProvider) async throws { @@ -2456,6 +2534,7 @@ final class AppStore { capacityDockProviderErrors[provider.id] = nil capacityDockProvidersLoading.remove(provider.id) capacityDockProviderTransientFailures.remove(provider.id) + forgetEarlyResets(for: provider) // Drop the provider from the persisted dock selection too. A // credential-less adapter (Cursor) still selected there would be // silently reconnected by the next scheduled refresh, undoing the @@ -2485,6 +2564,11 @@ final class AppStore { } } + // A provider with no stored summary is on the far side of a gap: either + // it has never been fetched, or its last failure was terminal enough to + // clear it. A transient failure keeps the summary, and keeps the + // baseline with it. + let baselineIsTrusted = capacityDockProviderSummaries[provider.id] != nil do { let credential = try await capacityDockCredentialLoader(provider.id) let summary = try await capacityDockProviderQuotaService.fetch( @@ -2497,6 +2581,11 @@ final class AppStore { capacityDockProviderSummaries[provider.id] = summary capacityDockProviderErrors[provider.id] = nil capacityDockProviderTransientFailures.remove(provider.id) + await detectEarlyResets( + provider: provider, + summary: summary, + baselineIsTrusted: baselineIsTrusted + ) } catch { guard capacityDockProviderRefreshGenerations[provider.id, default: 0] == generation else { return @@ -2902,7 +2991,77 @@ final class AppStore { }() ) } - await earlyQuotaResetMonitor.record( + await recordEarlyResets( + provider: provider, + planLabel: planLabel, + baselineIsTrusted: baselineIsTrusted, + observations: observations, + now: now + ) + } + + /// Hand one provider's freshly fetched quota windows to the same monitor, + /// for every provider that is not Claude. Claude keeps the call above, whose + /// window keys are the snapshot store's and must not change; here the + /// window's own display label is the identity, because that is the only + /// stable name these adapters give a window. + /// + /// A window the fetch did not report is simply not passed, which reads as + /// absent and can never be a reset. A window with no `resetsAt` is passed + /// with no reading, and one with no validated duration with no duration: + /// both make the detector say nothing, which is why the providers whose + /// adapters carry neither are covered by this code and still silent. + @discardableResult + private func detectEarlyResets( + provider: CapacityDockProvider, + summary: QuotaSummary?, + baselineIsTrusted: Bool, + now: Date = Date() + ) async -> EarlyQuotaResetEvent? { + guard provider != .claude, let summary else { return nil } + // The headline window is not always in `details` — Cursor reports it + // separately — and a row repeated under two labels must not be observed + // twice under one key. + var rows = summary.details + if let primary = summary.primary, !rows.contains(primary) { rows.append(primary) } + var observations: [EarlyQuotaResetMonitor.Observation] = [] + var seen: Set = [] + for row in rows { + let key = EarlyQuotaResetFormat.windowKey(forLabel: row.label) + guard seen.insert(key).inserted else { continue } + observations.append(EarlyQuotaResetMonitor.Observation( + windowKey: key, + windowName: EarlyQuotaResetFormat.windowName(forLabel: row.label), + windowSeconds: row.windowSeconds, + // `QuotaSummary.Window` carries a 0...1 fraction; the detector + // reasons in the snapshot store's 0...100 points. + reading: row.resetsAt.map { + EarlyQuotaResetReading(percent: row.percent * 100, resetsAt: $0, observedAt: now) + } + )) + } + guard !observations.isEmpty else { return nil } + return await recordEarlyResets( + provider: provider, + planLabel: summary.planLabel, + baselineIsTrusted: baselineIsTrusted, + observations: observations, + now: now + ) + } + + /// The one path into the monitor, so every provider gets the same + /// announcement, the same dock band and the same history refresh, each + /// stored under its own provider id. + @discardableResult + private func recordEarlyResets( + provider: CapacityDockProvider, + planLabel: String?, + baselineIsTrusted: Bool, + observations: [EarlyQuotaResetMonitor.Observation], + now: Date + ) async -> EarlyQuotaResetEvent? { + let event = await earlyQuotaResetMonitor.record( providerID: provider.rawValue, providerName: provider.displayName, planLabel: planLabel, @@ -2914,6 +3073,22 @@ final class AppStore { providerID: provider.rawValue, now: now ) + // Claude's pattern comes from the snapshot store, which holds more than + // the monitor's ledger ever will; every other provider has only the + // ledger this fetch just extended. + if provider != .claude { + refreshEarlyResetHistory(provider: provider, observations: observations) + } + return event + } + + /// Drop everything the early-reset feature knows about one provider, so a + /// reconnect — possibly to another account — starts without a baseline, a + /// band, or a pattern drawn from the old account's cycles. + private func forgetEarlyResets(for provider: CapacityDockProvider) { + earlyResetEvents[provider.rawValue] = nil + earlyResetHistory[provider.rawValue] = nil + earlyQuotaResetMonitor.forget(providerID: provider.rawValue) } /// Claude's rate-limit windows are fixed lengths, the same durations the @@ -2926,8 +3101,8 @@ final class AppStore { } } - /// Re-derive the "past resets came this early" captions from the snapshots - /// already on disk. Local only: no network, no external feed. + /// Re-derive Claude's "past resets came this early" captions from the + /// snapshots already on disk. Local only: no network, no external feed. private func refreshEarlyResetHistory() async { var summaries: [EarlyQuotaResetHistory.Summary] = [] for key in ["seven_day", "seven_day_opus", "seven_day_sonnet"] { @@ -2941,7 +3116,31 @@ final class AppStore { summaries.append(summary) } } - earlyResetHistory = summaries + earlyResetHistory[CapacityDockProvider.claude.rawValue] = summaries + } + + /// The same captions for a provider with no snapshot file, read from the + /// cycle ledger the monitor keeps beside its baseline. Also local only, and + /// it starts empty: a provider says nothing about its pattern until this Mac + /// has watched two of its cycles. + private func refreshEarlyResetHistory( + provider: CapacityDockProvider, + observations: [EarlyQuotaResetMonitor.Observation] + ) { + var summaries: [EarlyQuotaResetHistory.Summary] = [] + for observation in observations { + guard let summary = EarlyQuotaResetHistory.summarize( + cycleResets: earlyQuotaResetMonitor.observedResets( + providerID: provider.rawValue, + windowKey: observation.windowKey + ), + windowKey: observation.windowKey, + windowName: observation.windowName, + windowSeconds: observation.windowSeconds + ) else { continue } + summaries.append(summary) + } + earlyResetHistory[provider.rawValue] = summaries } /// Sum effective tokens (input + 5*output + cache_creation + 0.1*cache_read) across the diff --git a/mac/Sources/CodeBurnMenubar/Data/EarlyQuotaReset.swift b/mac/Sources/CodeBurnMenubar/Data/EarlyQuotaReset.swift index dec0776fb..189234aeb 100644 --- a/mac/Sources/CodeBurnMenubar/Data/EarlyQuotaReset.swift +++ b/mac/Sources/CodeBurnMenubar/Data/EarlyQuotaReset.swift @@ -278,14 +278,30 @@ enum EarlyQuotaResetHistory { windowKey: String, windowName: String, windowSeconds: Int? + ) -> Summary? { + summarize( + cycleResets: snapshots.filter { $0.windowKey == windowKey }.map(\.resetsAt), + windowKey: windowKey, + windowName: windowName, + windowSeconds: windowSeconds + ) + } + + /// Same reading of the same evidence, from reset times held somewhere other + /// than the snapshot store. Only Claude persists quota snapshots to disk, so + /// every other provider's record of its own cycles comes from the monitor's + /// per-provider ledger. + static func summarize( + cycleResets: [Date], + windowKey: String, + windowName: String, + windowSeconds: Int? ) -> Summary? { guard let seconds = windowSeconds, seconds > 0 else { return nil } let window = TimeInterval(seconds) guard window > QuotaPace.etaSuppressionMaxSeconds else { return nil } - let resets = snapshots - .filter { $0.windowKey == windowKey } - .map(\.resetsAt) + let resets = cycleResets .filter { $0.timeIntervalSince1970.isFinite } .sorted() // Jittered timestamps of one cycle collapse to that cycle's latest. @@ -342,6 +358,45 @@ enum EarlyQuotaResetFormat { } } + /// Storage identity for a window that has no key of its own. Claude's + /// windows keep the snapshot store's keys; every other provider identifies + /// its windows by the label the adapter already shows in the popover, + /// slugified so the key survives a JSON round trip and never collides with + /// a sibling row. + /// + /// A label that changes with the window's state — Codex's credit row + /// appends "· limit reached" — changes the key with it. That costs a + /// baseline, so the next fetch is silent; it can never turn into a false + /// announcement, because a key with no stored reading has nothing to + /// compare against. + static func windowKey(forLabel label: String) -> String { + var slug = "" + var pendingSeparator = false + for scalar in label.lowercased().unicodeScalars { + if CharacterSet.alphanumerics.contains(scalar) { + if pendingSeparator { slug.append("_") } + slug.unicodeScalars.append(scalar) + pendingSeparator = false + } else if !slug.isEmpty { + pendingSeparator = true + } + } + return slug.isEmpty ? "window" : slug + } + + /// Copy noun for a window named only by its display label. A label that + /// already says what it caps ("Monthly usage limit") keeps its own noun; one + /// that names only a period ("Weekly", "5-hour") gains "limit" so the + /// notification reads as a sentence. + static func windowName(forLabel label: String) -> String { + let trimmed = label + .trimmingCharacters(in: .whitespacesAndNewlines) + .lowercased() + guard !trimmed.isEmpty else { return "quota window" } + let ownNouns = ["limit", "usage", "quota", "credits", "window"] + return ownNouns.contains(where: trimmed.hasSuffix) ? trimmed : "\(trimmed) limit" + } + /// "2d 3h", "18h", "40m" — rounded to the unit it prints, so a lead of /// 1h57m reads "2h" rather than truncating to "1h". static func lead(seconds: TimeInterval) -> String { @@ -366,7 +421,12 @@ enum EarlyQuotaResetFormat { } /// "weekly limit" -> "weekly usage": what the vendor cleared, not the cap. - static func usageName(_ name: String) -> String { "\(windowNoun(name)) usage" } + /// A noun that already says "usage" (Codex's "monthly usage limit") is left + /// alone rather than doubled. + static func usageName(_ name: String) -> String { + let noun = windowNoun(name) + return noun.hasSuffix("usage") ? noun : "\(noun) usage" + } static func capitalizedFirst(_ text: String) -> String { guard let first = text.first else { return text } diff --git a/mac/Sources/CodeBurnMenubar/Data/EarlyQuotaResetMonitor.swift b/mac/Sources/CodeBurnMenubar/Data/EarlyQuotaResetMonitor.swift index 2495f35d2..63c35b9ea 100644 --- a/mac/Sources/CodeBurnMenubar/Data/EarlyQuotaResetMonitor.swift +++ b/mac/Sources/CodeBurnMenubar/Data/EarlyQuotaResetMonitor.swift @@ -17,7 +17,14 @@ import Foundation /// the detector's own skew tolerance, so a vendor that briefly serves the old /// cycle again — possibly from a replica whose timestamp differs by a minute /// or two — cannot announce the same reset twice, across relaunches included; -/// - the latest event, for the Capacity Dock band. +/// - the latest event, for the Capacity Dock band; +/// - one reset time per observed cycle, for the 30-day history summary. +/// +/// Every key is scoped to one provider by the record it lives in, so an early +/// reset on one provider cannot move another's baseline, dedupe record, band or +/// history. The record's defaults key is unchanged from the one this feature +/// first shipped with, so a Claude event a user has already been notified about +/// is still recognised as announced after the update. @MainActor final class EarlyQuotaResetMonitor { struct Observation: Equatable { @@ -34,6 +41,13 @@ final class EarlyQuotaResetMonitor { /// Scheduled reset times already announced, per window key. var announced: [String: [Date]] var latestEvent: EarlyQuotaResetEvent? + /// One entry per observed cycle, per window key, oldest first, pruned to + /// the same 30-day horizon. The history summary is read from here for + /// every provider but Claude, which has the snapshot store on disk. + /// + /// Optional so a record written before this key existed still decodes: + /// a missing ledger is an empty one, not a corrupt state. + var observedResets: [String: [Date]]? } /// Announcements older than the snapshot store's horizon are dropped. @@ -111,7 +125,12 @@ final class EarlyQuotaResetMonitor { announced: announced .mapValues { $0.filter { $0 >= cutoff } } .filter { !$0.value.isEmpty }, - latestEvent: headline ?? stored?.latestEvent + latestEvent: headline ?? stored?.latestEvent, + observedResets: Self.recordCycles( + observations, + into: stored?.observedResets ?? [:], + cutoff: cutoff + ) ), providerID: providerID ) @@ -129,6 +148,42 @@ final class EarlyQuotaResetMonitor { return EarlyQuotaResetNotice.isVisible(event, now: now) ? event : nil } + /// The reset times this provider's window has been seen carrying, oldest + /// first, for the 30-day history summary. Empty for a window this Mac has + /// no record of. + func observedResets(providerID: String, windowKey: String) -> [Date] { + loadState(providerID: providerID)?.observedResets?[windowKey] ?? [] + } + + /// Fold this fetch's readings into the per-window cycle ledger. One entry + /// per cycle, not per fetch: a reset time inside the detector's skew + /// tolerance of the newest entry is the same cycle re-reported with a + /// jittered timestamp, and replaces it. A reset time that moved backwards is + /// skew, and is dropped rather than recorded as a cycle that never ran. + private static func recordCycles( + _ observations: [Observation], + into stored: [String: [Date]], + cutoff: Date + ) -> [String: [Date]] { + var ledger = stored + for observation in observations { + guard let reading = observation.reading, reading.isWellFormed else { continue } + var cycles = ledger[observation.windowKey] ?? [] + if let last = cycles.last { + let move = reading.resetsAt.timeIntervalSince(last) + if abs(move) < EarlyQuotaResetDetector.skewTolerance { + cycles[cycles.count - 1] = reading.resetsAt + } else if move > 0 { + cycles.append(reading.resetsAt) + } + } else { + cycles.append(reading.resetsAt) + } + ledger[observation.windowKey] = cycles.filter { $0 >= cutoff } + } + return ledger.filter { !$0.value.isEmpty } + } + /// Called on user disconnect so a reconnect, possibly to another account, /// starts without a baseline. func forget(providerID: String) { From c61785897e9d046997dedfff16f492cb1243e93b Mon Sep 17 00:00:00 2001 From: ozymandiashh <234437643+ozymandiashh@users.noreply.github.com> Date: Sun, 13 Sep 2026 02:41:10 +0300 Subject: [PATCH 2/7] test(mac): run the early-reset guards for Codex, not only Claude MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every false-positive guard from the original suite is now parameterised over Claude and Codex, alongside both signals and the copy each produces, so a change that quietly re-narrows the detector to one provider fails here. Four cases the old suite could not have: an early reset on one provider leaves the other's baseline, band and dedupe record untouched and each is announced in its own name; a Codex reset announced once is not announced again across a relaunch; a state record written in the shape and under the exact key the feature first shipped with is still read, so an event the user has already been notified about stays silent — paired with the same record carrying nothing announced, which must post, or the silence would also be produced by a record that can no longer be found or decoded; and the cycle ledger keeps one entry per cycle rather than per fetch, which is what lets a provider with no snapshot file have a 30-day pattern at all. Two more drive the AppStore rather than the detector, because that is where the Claude-only scope actually lived: a dock provider's second fetch produces a band named after it while Claude's stays empty, and a provider whose windows carry no duration stays silent. --- .../EarlyQuotaResetTests.swift | 507 ++++++++++++++++++ 1 file changed, 507 insertions(+) diff --git a/mac/Tests/CodeBurnMenubarTests/EarlyQuotaResetTests.swift b/mac/Tests/CodeBurnMenubarTests/EarlyQuotaResetTests.swift index 9b775f937..02ad3193f 100644 --- a/mac/Tests/CodeBurnMenubarTests/EarlyQuotaResetTests.swift +++ b/mac/Tests/CodeBurnMenubarTests/EarlyQuotaResetTests.swift @@ -695,3 +695,510 @@ private final class RecordingEarlyResetNotifier: UpdateNotifier { posts.append((title, body, identifier)) } } + +// MARK: - Every provider, not just Claude + +/// One provider's identity for the same weekly window, so the guards below run +/// unchanged against Claude and Codex. Codex's window has no key of its own: +/// like every provider but Claude it is identified by its display label. +private struct EarlyResetProviderCase: Sendable, CustomStringConvertible { + let providerID: String + let providerName: String + let windowKey: String + let windowName: String + let planLabel: String + + var description: String { providerName } +} + +private let claudeCase = EarlyResetProviderCase( + providerID: "claude", + providerName: "Claude", + windowKey: "seven_day", + windowName: "weekly limit", + planLabel: "Max 20x" +) + +private let codexCase = EarlyResetProviderCase( + providerID: "codex", + providerName: "Codex", + windowKey: EarlyQuotaResetFormat.windowKey(forLabel: "Weekly"), + windowName: EarlyQuotaResetFormat.windowName(forLabel: "Weekly"), + planLabel: "Plus" +) + +private let everyProviderCase = [claudeCase, codexCase] + +private func context( + _ provider: EarlyResetProviderCase, + windowSeconds: Int? = weekSeconds, + previousPlanLabel: String? = nil, + currentPlanLabel: String? = nil, + baselineIsTrusted: Bool = true +) -> EarlyQuotaResetDetector.Context { + EarlyQuotaResetDetector.Context( + providerID: provider.providerID, + providerName: provider.providerName, + windowKey: provider.windowKey, + windowName: provider.windowName, + windowSeconds: windowSeconds, + previousPlanLabel: previousPlanLabel ?? provider.planLabel, + currentPlanLabel: currentPlanLabel ?? provider.planLabel, + baselineIsTrusted: baselineIsTrusted + ) +} + +@Suite("Early quota reset detection, every provider") +struct EarlyQuotaResetProviderScopeTests { + @Test("Both signals fire for any provider and name it", arguments: everyProviderCase) + func bothSignalsFire(_ provider: EarlyResetProviderCase) throws { + let jumped = try #require(EarlyQuotaResetDetector.detect( + previous: beforeEarlyReset, current: afterEarlyReset, context: context(provider) + )) + #expect(jumped.providerID == provider.providerID) + #expect(jumped.signal == .resetMovedForward) + #expect(jumped.earlyBySeconds == eighteenHours) + #expect(jumped.notificationTitle == "\(provider.providerName) quota reset early") + #expect(jumped.notificationBody + == "\(provider.providerName)'s weekly limit reset 18h early. You're back to 100%.") + + let dropped = try #require(EarlyQuotaResetDetector.detect( + previous: reading(percent: 92, resetsIn: eighteenHours, observedAgo: 300), + current: reading(percent: 1, resetsIn: eighteenHours), + context: context(provider) + )) + #expect(dropped.providerID == provider.providerID) + #expect(dropped.signal == .usageDropped) + #expect(dropped.notificationTitle == "\(provider.providerName) quota cleared early") + #expect(dropped.notificationBody + == "\(provider.providerName) cleared your weekly usage 18h before its reset. " + + "You're back to 99%.") + for text in [dropped.notificationTitle, dropped.notificationBody, + dropped.noticeText, dropped.noticeHelpText] { + #expect(!text.contains("reset early")) + } + } + + @Test("Every false-positive guard holds for every provider", arguments: everyProviderCase) + func guardsHold(_ provider: EarlyResetProviderCase) { + func silent( + _ previous: EarlyQuotaResetReading?, + _ current: EarlyQuotaResetReading?, + _ ctx: EarlyQuotaResetDetector.Context, + _ what: Comment + ) { + #expect( + EarlyQuotaResetDetector.detect(previous: previous, current: current, context: ctx) == nil, + what + ) + } + let base = context(provider) + + // A scheduled reset, which is the common case and the one that must + // never speak. + silent(reading(percent: 96, resetsIn: -60, observedAgo: 300), + reading(percent: 0, resetsIn: week), base, "scheduled reset") + // A plan change gives capacity back legitimately. + silent(beforeEarlyReset, afterEarlyReset, + context(provider, previousPlanLabel: "Pro"), "plan change") + // Clock and data skew, in all four shapes. + silent(beforeEarlyReset, reading(percent: 0, resetsIn: -3600), base, "reset in the past") + silent(beforeEarlyReset, reading(percent: 0, resetsIn: week + 2 * 3600), base, "reset beyond a window") + silent(reading(percent: 80, resetsIn: eighteenHours, observedAgo: -600), + afterEarlyReset, base, "clock went backwards") + silent(reading(percent: 80, resetsIn: week + 2 * 3600, observedAgo: 300), + afterEarlyReset, base, "stored reset beyond a window") + // A window that came, went, or has never been seen before. + silent(nil, afterEarlyReset, base, "no baseline") + silent(beforeEarlyReset, nil, base, "window absent this fetch") + // The far side of a gap. + silent(beforeEarlyReset, afterEarlyReset, + context(provider, baselineIsTrusted: false), "untrusted baseline") + // A baseline this build cannot reason about. + for percent in [150.0, -1.0, Double.nan] { + silent(EarlyQuotaResetReading( + percent: percent, + resetsAt: now.addingTimeInterval(eighteenHours), + observedAt: now.addingTimeInterval(-300) + ), afterEarlyReset, base, "malformed baseline") + } + // No validated duration: no opinion. This is what keeps the providers + // whose adapters carry no window length silent. + silent(beforeEarlyReset, afterEarlyReset, + context(provider, windowSeconds: nil), "unknown duration") + // Rounding noise, and a fall that does not land near empty. + silent(reading(percent: 80, resetsIn: eighteenHours, observedAgo: 300), + reading(percent: 79, resetsIn: eighteenHours), base, "rounding noise") + silent(reading(percent: 95, resetsIn: eighteenHours, observedAgo: 300), + reading(percent: 40, resetsIn: eighteenHours), base, "partial drop") + silent(reading(percent: 20, resetsIn: eighteenHours, observedAgo: 300), + reading(percent: 8, resetsIn: eighteenHours), base, "small drop to near-empty") + // A stored reset further out than one window, on signal 2's shape. + silent(reading(percent: 92, resetsIn: week + 1000, observedAgo: 300), + reading(percent: 1, resetsIn: week + 500), base, "stale baseline") + // A rolling window's reset time creeping forward, and a jump that lands + // short of a cycle boundary. + silent(reading(percent: 80, resetsIn: eighteenHours, observedAgo: 300), + reading(percent: 0, resetsIn: eighteenHours + 1800), base, "rolling creep") + silent(reading(percent: 80, resetsIn: eighteenHours, observedAgo: 300), + reading(percent: 0, resetsIn: 4 * 24 * 3600), base, "partial window jump") + // A reset time moving backwards, and a new cycle that gives nothing back. + silent(reading(percent: 80, resetsIn: 30 * 3600, observedAgo: 300), + reading(percent: 0, resetsIn: eighteenHours), base, "backwards reset") + silent(reading(percent: 0, resetsIn: eighteenHours, observedAgo: 300), + afterEarlyReset, base, "nothing given back") + } + + @Test("A window named only by its display label gets a stable key and readable copy") + func labelDerivedNaming() throws { + #expect(EarlyQuotaResetFormat.windowKey(forLabel: "Weekly") == "weekly") + #expect(EarlyQuotaResetFormat.windowKey(forLabel: "5-hour") == "5_hour") + #expect(EarlyQuotaResetFormat.windowKey(forLabel: "GPT-5.3-Codex-Spark · Weekly") + == "gpt_5_3_codex_spark_weekly") + // Sibling rows must not collide, or one would overwrite the other's + // baseline inside the same provider record. + let keys = ["Weekly", "5-hour", "Monthly usage limit", "Auto", "API"] + .map(EarlyQuotaResetFormat.windowKey(forLabel:)) + #expect(Set(keys).count == keys.count) + + #expect(EarlyQuotaResetFormat.windowName(forLabel: "Weekly") == "weekly limit") + #expect(EarlyQuotaResetFormat.windowName(forLabel: "5-hour") == "5-hour limit") + // A label that already names what it caps keeps its own noun, and the + // usage phrasing must not double it into "monthly usage usage". + #expect(EarlyQuotaResetFormat.windowName(forLabel: "Monthly usage limit") + == "monthly usage limit") + let event = try #require(EarlyQuotaResetDetector.detect( + previous: reading(percent: 92, resetsIn: eighteenHours, observedAgo: 300), + current: reading(percent: 1, resetsIn: eighteenHours), + context: EarlyQuotaResetDetector.Context( + providerID: "codex", + providerName: "Codex", + windowKey: EarlyQuotaResetFormat.windowKey(forLabel: "Monthly usage limit"), + windowName: EarlyQuotaResetFormat.windowName(forLabel: "Monthly usage limit"), + windowSeconds: 30 * 24 * 3600, + previousPlanLabel: "Plus", + currentPlanLabel: "Plus", + baselineIsTrusted: true + ) + )) + #expect(event.notificationBody + == "Codex cleared your monthly usage 18h before its reset. You're back to 99%.") + #expect(event.noticeText == "Monthly usage cleared, 18h before reset") + } + + @Test("Every dock provider's display name reads as a sentence") + func displayNamesReadWell() { + for provider in CapacityDockPreferences.supportedProviders + where provider.catalogEntry.hasLiveCodeBurnQuotaAdapter { + let event = EarlyQuotaResetEvent( + providerID: provider.rawValue, + providerName: provider.displayName, + windowKey: "weekly", + windowName: "weekly limit", + signal: .resetMovedForward, + scheduledResetAt: now.addingTimeInterval(eighteenHours), + detectedAt: now, + percentBefore: 80, + percentAfter: 0 + ) + #expect(event.notificationBody + == "\(provider.displayName)'s weekly limit reset 18h early. You're back to 100%.") + // No empty or trailing-space name can reach the copy. + #expect(!provider.displayName.isEmpty) + #expect(provider.displayName.trimmingCharacters(in: .whitespaces) == provider.displayName) + } + } +} + +@Suite("Early quota reset, provider isolation and stored state") +@MainActor +struct EarlyQuotaResetProviderStateTests { + @Test("An early reset on one provider never moves another's state") + func providersAreIsolated() async throws { + try await withIsolatedMonitor { monitor, notifier, _ in + // Both providers see the same pre-reset window. + for id in ["claude", "codex"] { + await monitor.record( + providerID: id, providerName: id == "claude" ? "Claude" : "Codex", + planLabel: "Max 20x", baselineIsTrusted: true, + observations: [providerObservation(id, beforeEarlyReset)], + now: now.addingTimeInterval(-300) + ) + } + // Only Claude resets early. + let claudeEvent = await monitor.record( + providerID: "claude", providerName: "Claude", planLabel: "Max 20x", + baselineIsTrusted: true, + observations: [providerObservation("claude", afterEarlyReset)], + now: now + ) + #expect(claudeEvent?.providerID == "claude") + #expect(notifier.posts.count == 1) + #expect(notifier.posts.first?.title == "Claude quota reset early") + // Codex's band stays empty, and its baseline is untouched. + #expect(monitor.visibleEvent(providerID: "codex", now: now) == nil) + + // Codex resets an hour later, off its own baseline, and is announced + // in its own name. Claude's band is not replaced. + let codexEvent = await monitor.record( + providerID: "codex", providerName: "Codex", planLabel: "Max 20x", + baselineIsTrusted: true, + observations: [providerObservation("codex", afterEarlyReset)], + now: now + ) + #expect(codexEvent?.providerID == "codex") + #expect(notifier.posts.count == 2) + #expect(notifier.posts.last?.title == "Codex quota reset early") + #expect(monitor.visibleEvent(providerID: "claude", now: now)?.providerID == "claude") + #expect(monitor.visibleEvent(providerID: "codex", now: now)?.providerID == "codex") + + // And forgetting one leaves the other standing. + monitor.forget(providerID: "claude") + #expect(monitor.visibleEvent(providerID: "claude", now: now) == nil) + #expect(monitor.visibleEvent(providerID: "codex", now: now) != nil) + } + } + + @Test("A Codex reset announced once is not announced again") + func codexDedupeHolds() async throws { + try await withIsolatedMonitor { monitor, notifier, defaults in + await monitor.record( + providerID: "codex", providerName: "Codex", planLabel: "Plus", + baselineIsTrusted: true, + observations: [providerObservation("codex", beforeEarlyReset)], + now: now.addingTimeInterval(-300) + ) + await monitor.record( + providerID: "codex", providerName: "Codex", planLabel: "Plus", + baselineIsTrusted: true, + observations: [providerObservation("codex", afterEarlyReset)], + now: now + ) + #expect(notifier.posts.count == 1) + + let relaunched = EarlyQuotaResetMonitor(defaults: defaults, makeNotifier: { notifier }) + await relaunched.record( + providerID: "codex", providerName: "Codex", planLabel: "Plus", + baselineIsTrusted: true, + observations: [providerObservation("codex", reading(percent: 80, resetsIn: eighteenHours - 600))], + now: now.addingTimeInterval(600) + ) + await relaunched.record( + providerID: "codex", providerName: "Codex", planLabel: "Plus", + baselineIsTrusted: true, + observations: [providerObservation("codex", reading(percent: 0, resetsIn: week))], + now: now.addingTimeInterval(1200) + ) + #expect(notifier.posts.count == 1) + } + } + + @Test("A Claude record written by the build that shipped this feature still counts") + func storedClaudeRecordIsCompatible() async throws { + // Announced already: the update must not re-notify. + #expect(try await legacyRecordPostCount(announced: true) == 0) + // The same record with nothing announced does post. Without this the + // silence above would also be produced by a record the monitor can no + // longer find or decode, which is exactly the regression to catch. + #expect(try await legacyRecordPostCount(announced: false) == 1) + } + + /// Runs one fetch against a state record written in the shape, and under the + /// exact defaults key, that #1329 shipped: no cycle ledger, no latest event. + private func legacyRecordPostCount(announced: Bool) async throws -> Int { + var count = 0 + try await withIsolatedMonitor { monitor, notifier, defaults in + let scheduled = Int(now.addingTimeInterval(eighteenHours).timeIntervalSince1970) + let observed = Int(now.addingTimeInterval(-300).timeIntervalSince1970) + let announcedList = announced ? "[\(scheduled)]" : "[]" + let legacy = """ + {"planLabel":"Max 20x",\ + "windows":{"seven_day":{"percent":80,\ + "resetsAt":\(scheduled),"observedAt":\(observed)}},\ + "announced":{"seven_day":\(announcedList)}} + """ + // Spelled out, not built from the constant: a changed key must fail + // this rather than silently take the record with it. + defaults.set(Data(legacy.utf8), forKey: "codeburn.quota.earlyReset.state.claude") + + await monitor.record( + providerID: "claude", providerName: "Claude", planLabel: "Max 20x", + baselineIsTrusted: true, + observations: [weeklyObservation(afterEarlyReset)], + now: now + ) + count = notifier.posts.count + } + return count + } + + @Test("The cycle ledger keeps one entry per cycle, so any provider can have a pattern") + func ledgerFeedsTheHistorySummary() async throws { + try await withIsolatedMonitor { monitor, _, _ in + // Four weekly cycles, each landing 18h early, each seen by several + // fetches with a jittered reset timestamp. + var resetsAt = now + for _ in 0..<4 { + for fetch in 0..<3 { + let jitter = TimeInterval(fetch) * 30 + await monitor.record( + providerID: "codex", providerName: "Codex", planLabel: "Plus", + baselineIsTrusted: true, + observations: [providerObservation("codex", EarlyQuotaResetReading( + percent: 10, + resetsAt: resetsAt.addingTimeInterval(jitter), + observedAt: resetsAt.addingTimeInterval(-week + TimeInterval(fetch)) + ))], + now: resetsAt.addingTimeInterval(-week + TimeInterval(fetch)) + ) + } + resetsAt = resetsAt.addingTimeInterval(week - eighteenHours) + } + let cycles = monitor.observedResets(providerID: "codex", windowKey: "weekly") + #expect(cycles.count == 4) + + let summary = try #require(EarlyQuotaResetHistory.summarize( + cycleResets: cycles, + windowKey: "weekly", + windowName: "weekly limit", + windowSeconds: weekSeconds + )) + #expect(summary.earlyResets == 3) + #expect(summary.observedResets == 3) + #expect(summary.caption == "Last 3 weekly resets came ~18h early") + // Claude's ledger is its own, and empty here. + #expect(monitor.observedResets(providerID: "claude", windowKey: "seven_day").isEmpty) + } + } +} + +private func providerObservation( + _ providerID: String, + _ reading: EarlyQuotaResetReading? +) -> EarlyQuotaResetMonitor.Observation { + EarlyQuotaResetMonitor.Observation( + windowKey: providerID == "claude" ? "seven_day" : "weekly", + windowName: "weekly limit", + windowSeconds: weekSeconds, + reading: reading + ) +} + +@Suite("Early quota reset wiring, non-Claude providers") +@MainActor +struct EarlyQuotaResetWiringTests { + /// Grok stands in for every provider fetched through the CodeBurn-owned + /// adapter: the detector only ever saw Claude's refresh, so a reset on one + /// of these was silent however plainly the adapter reported it. + @Test("A dock provider's early reset is detected, banded and named after it") + func dockProviderGetsItsOwnEvent() async throws { + let suiteName = "codeburn.quota.earlyReset.\(UUID().uuidString)" + let defaults = try #require(UserDefaults(suiteName: suiteName)) + defaults.removePersistentDomain(forName: suiteName) + defer { defaults.removePersistentDomain(forName: suiteName) } + + let notifier = RecordingEarlyResetNotifier() + let store = AppStore() + store.earlyQuotaResetMonitor = EarlyQuotaResetMonitor( + defaults: defaults, + makeNotifier: { notifier } + ) + store.capacityDockCredentialLoader = { _ in CapacityDockProviderCredential() } + + let provider = try #require(CapacityDockProvider(rawValue: "grok")) + // Fetch one: the window still has 18 hours to run. Fetch two: a new + // weekly cycle, anchored a full window after the first look. + let responses = [ + Self.summary(percent: 0.8, resetsIn: 18 * 3600), + Self.summary(percent: 0, resetsIn: week), + ] + let index = Counter() + store.capacityDockProviderQuotaService = CapacityDockProviderQuotaService(dependencies: .init( + refreshClinePass: { _ in Self.summary(percent: 0, resetsIn: week) }, + refreshCursor: { Self.summary(percent: 0, resetsIn: week) }, + refreshGrok: { responses[await index.next()] }, + refreshZai: { _ in Self.summary(percent: 0, resetsIn: week) } + )) + + await store.refreshCapacityDockProvider(provider) + #expect(notifier.posts.isEmpty) + await store.refreshCapacityDockProvider(provider) + + #expect(notifier.posts.count == 1) + #expect(notifier.posts.first?.title == "Grok quota reset early") + let band = try #require(store.capacityDockEarlyResetNotice(for: provider)) + #expect(band.providerID == "grok") + #expect(band.noticeText == "Weekly limit reset 18h early") + // Claude's band and captions are untouched by another provider's reset. + #expect(store.capacityDockEarlyResetNotice(for: .claude) == nil) + #expect(store.earlyResetHistoryCaptions(for: .claude).isEmpty) + } + + /// `QuotaSummary.Window` carries no duration for these adapters today, and a + /// window with no validated duration is exactly what the detector refuses to + /// have an opinion about. + @Test("A provider whose windows carry no duration stays silent") + func windowWithoutDurationIsSilent() async throws { + let suiteName = "codeburn.quota.earlyReset.\(UUID().uuidString)" + let defaults = try #require(UserDefaults(suiteName: suiteName)) + defaults.removePersistentDomain(forName: suiteName) + defer { defaults.removePersistentDomain(forName: suiteName) } + + let notifier = RecordingEarlyResetNotifier() + let store = AppStore() + store.earlyQuotaResetMonitor = EarlyQuotaResetMonitor( + defaults: defaults, + makeNotifier: { notifier } + ) + store.capacityDockCredentialLoader = { _ in CapacityDockProviderCredential() } + + let provider = try #require(CapacityDockProvider(rawValue: "grok")) + let responses = [ + Self.summary(percent: 0.8, resetsIn: 18 * 3600, windowSeconds: nil), + Self.summary(percent: 0, resetsIn: week, windowSeconds: nil), + ] + let index = Counter() + store.capacityDockProviderQuotaService = CapacityDockProviderQuotaService(dependencies: .init( + refreshClinePass: { _ in Self.summary(percent: 0, resetsIn: week) }, + refreshCursor: { Self.summary(percent: 0, resetsIn: week) }, + refreshGrok: { responses[await index.next()] }, + refreshZai: { _ in Self.summary(percent: 0, resetsIn: week) } + )) + + await store.refreshCapacityDockProvider(provider) + await store.refreshCapacityDockProvider(provider) + + #expect(notifier.posts.isEmpty) + #expect(store.capacityDockEarlyResetNotice(for: provider) == nil) + } + + private static func summary( + percent: Double, + resetsIn: TimeInterval, + windowSeconds: Int? = 7 * 24 * 3600 + ) -> QuotaSummary { + let window = QuotaSummary.Window( + label: "Weekly", + percent: percent, + resetsAt: Date().addingTimeInterval(resetsIn), + windowSeconds: windowSeconds, + fetchedAt: Date() + ) + return QuotaSummary( + providerFilter: .grok, + connection: .connected, + primary: window, + details: [window], + planLabel: "Grok Build", + footerLines: [] + ) + } +} + +private actor Counter { + private var value = 0 + func next() -> Int { + defer { value += 1 } + return value + } +} From ee931bff52a9cf8e14bb717f9b55dbec961e42b7 Mon Sep 17 00:00:00 2001 From: ozymandiashh <234437643+ozymandiashh@users.noreply.github.com> Date: Sun, 13 Sep 2026 02:41:10 +0300 Subject: [PATCH 3/7] docs(mac): say which providers early-reset detection covers The design doc now records that every dock provider runs the same detector under its own record, that a window needs both a reset time and a validated length to say anything, and names the eight live adapters that report the first but not the second and are therefore silent. Claude's provider doc says its snapshot-backed history is the exception, not the rule. Refs #725 --- CHANGELOG.md | 3 +++ docs/design/capacity-dock.md | 17 +++++++++++++++-- docs/providers/claude.md | 14 ++++++++------ 3 files changed, 26 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b7aa85df3..b8550b95b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,9 @@ - **Every Copilot credential rung now follows its own GitHub host, not just the two read from files.** #1286 taught `hosts.json` and `apps.json` to carry the host their token came from, but the rungs that carried none — `COPILOT_GITHUB_TOKEN` / `GH_TOKEN` / `GITHUB_TOKEN`, `gh auth token`, and a token pasted into Settings — were still sent to `api.github.com`, so a user whose only Copilot credential is a GitHub Enterprise Cloud login got a 401 and a terminal failure with no way out. The environment rung now reads `GH_HOST` from the same environment as the token; the `gh` rung resolves the host from gh's own `hosts.yml` (`GH_CONFIG_DIR`, then `XDG_CONFIG_HOME/gh`, then `~/.config/gh`), picking it the way gh picks the host for `gh auth token` and without a second subprocess; and macOS Settings gains a GitHub host field next to the pasted token, defaulting to `github.com` and saved in the same Keychain record as the token so the two can never drift apart. Settings refuses a host no endpoint can be derived from at the field rather than storing it, the connection row no longer promises `api.github.com` before anything has been fetched, and every one of these new host sources is character-validated before a URL is built, so a crafted value such as `evil.com?.ghe.com` fails closed with no request instead of pointing the Authorization header at a host of its own choosing. `codeburn quota` is unchanged: it still reads only the editor-plugin files, which already carry their host. (#1306) - **Copilot live quota works for GitHub Enterprise Cloud enterprises on a `*.ghe.com` host.** Both readers hardcoded `https://api.github.com/copilot_internal/user` and threw away the host their credential came from, so a data-residency enterprise signed in on `.ghe.com` could only ever report `available: false` with "Temporarily unavailable". A discovered credential now carries its host — `hosts.json` is keyed by host and newer `apps.json` files key by `:` — and the request follows it: `api.github.com` for `github.com` and for any rung that carries no host of its own (an app-name `apps.json` key, `COPILOT_GITHUB_TOKEN` / `GH_TOKEN` / `GITHUB_TOKEN`, `gh auth token`, a pasted token), and `https://api..ghe.com/copilot_internal/user` for an enterprise host. The token and the host always come from the same entry, with `github.com` preferred when several hosts are signed in and otherwise the first `.ghe.com` tenant in sorted order; a host neither rule can address, such as a self-hosted GitHub Enterprise Server install, fails with a message naming that host instead of sending the credential to dotcom, and unreachable-host and HTTP failures name the host that was tried. The macOS Settings connection row now says which host answered. (#1286) +### Fixed (macOS) +- **Early quota-reset detection now watches every provider that reports a reset time, not just Claude.** The detector shipped wired to a single provider, so Codex's goodwill resets — the ones that restore a rate-limit window early and are the whole reason to watch for this — were seen by the refresh lifecycle and then dropped on the floor. Each Capacity Dock provider is now handed its own windows as its fetch succeeds: Claude and Codex announce today, because theirs are the adapters that report a validated window length, and Antigravity, ClinePass, Copilot, Cursor, Gemini, Grok, Kimi Code and Z.ai run through the same path and stay silent until their adapters carry one, which is the same rule that already kept an unvalidated window quiet. Detection, the announced-reset record, the notification, the twelve-hour dock band and the 30-day "last 3 weekly resets came ~18h early" caption are all scoped per provider and per window, so an early reset on Claude cannot move Codex's baseline or replace its band, and disconnecting one provider drops only its own record. A window with no key of its own is identified by the label its adapter already shows, which keeps sibling rows apart, and the notification names the provider it belongs to. Nothing about the thresholds or the false-positive guards changed — a scheduled reset, a plan change, skew, a window coming and going, a first observation and a reconnect are all as quiet as before — and the stored record keeps the key and shape it shipped with, so a Claude reset you have already been told about is not announced again after updating. (#725) + ## 0.9.24 - 2026-09-04 ### Added diff --git a/docs/design/capacity-dock.md b/docs/design/capacity-dock.md index 4ca8a71cc..593ce337b 100644 --- a/docs/design/capacity-dock.md +++ b/docs/design/capacity-dock.md @@ -137,6 +137,18 @@ V1 does not include: normal scheduled reset, a plan change, clock or timestamp skew, a window that appears or disappears between fetches, a window's first observation, and a provider reconnecting after a terminal failure or a fresh bootstrap. +- Every dock provider runs through the same detector as its own fetch succeeds, + and everything it keeps — the previous fetch's readings, the record of what + has been announced, the band, and the history caption — is scoped to that + provider and that window, so one provider's early reset can never move + another's state. A window needs both a reset time and a validated length to + produce anything, so today only Claude and Codex announce: the other live + adapters (Antigravity, ClinePass, Copilot, Cursor, Gemini, Grok, Kimi Code, + Z.ai) report a reset time but no window length, and are silent under the same + rule that keeps any unvalidated window quiet. They need no further wiring when + their adapters start carrying one. A window that has no key of its own is + identified by the label the adapter already shows; a label that changes with + the window's state costs a baseline, which is silence, never a false alarm. - Stale or retrying data remains visible and is labeled/dimmed. A terminal authentication/configuration failure provides a Connect/Reconnect action in the bubble itself. Network, rate-limit, parse, and provider outages remain @@ -251,8 +263,9 @@ any source-owned consent prompt is reserved for an explicit Connect action. - `CapacityDockMotion`: pure timing/easing plus edge-aware interpolation policy. - `EarlyQuotaReset`: pure early-reset detection, the bounded notice window, and the local history summariser behind the hover card's caption. -- `EarlyQuotaResetMonitor`: per-provider baseline, announcement record and - delivery through the existing `UpdateNotifier`. +- `EarlyQuotaResetMonitor`: per-provider baseline, announcement record, cycle + ledger and delivery through the existing `UpdateNotifier`. One `UserDefaults` + record per provider id, under the key the feature first shipped with. - `AppDelegate`: create/start/stop the controller and include quota/preferences in the existing observation re-arm. It must not own dock rendering details. diff --git a/docs/providers/claude.md b/docs/providers/claude.md index 7324ebaa7..24d0c893a 100644 --- a/docs/providers/claude.md +++ b/docs/providers/claude.md @@ -67,12 +67,14 @@ Anthropic sometimes resets a usage window before its scheduled time. The menubar notices on the existing refresh lifecycle — no extra request — by comparing each fetch's windows against the previous fetch's readings, which are kept per window in `UserDefaults` alongside the record of what has already been announced. -`SubscriptionSnapshotStore`'s 30 days of snapshots then give the local history -caption ("Last 3 weekly resets came ~18h early"), derived from the stored reset -times alone: a fixed window that starts at `t` ends at `t + length`, so a cycle -that ends sooner than a full window after the previous cycle's scheduled end -began early by the difference. Everything is local; nothing is fetched to -produce it. +The same detector runs for every other Capacity Dock provider off its own +fetch, under its own record; Claude's is described here because it is the one +with a snapshot file behind it. `SubscriptionSnapshotStore`'s 30 days of +snapshots give Claude the local history caption ("Last 3 weekly resets came +~18h early"), derived from the stored reset times alone: a fixed window that +starts at `t` ends at `t + length`, so a cycle that ends sooner than a full +window after the previous cycle's scheduled end began early by the difference. +Everything is local; nothing is fetched to produce it. The detection is deliberately quiet. It needs a validated window length (the fixed 5-hour and 7-day limits), a stored reset that has not yet passed, and From 7bc72380bcec75688264c33141c6edf9dc000065 Mon Sep 17 00:00:00 2001 From: ozymandiashh <234437643+ozymandiashh@users.noreply.github.com> Date: Sun, 13 Sep 2026 03:02:04 +0300 Subject: [PATCH 4/7] fix(mac): route the word early-reset copy adds to a provider's label MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #1330 translates the early-reset copy at render, keyed on the four English window names Claude produces, and lets any other name read through untouched. Before this branch no other name could reach that copy. Now every provider's window does, named by windowName(forLabel:), which composes the label with " limit" in English. That word is ours, not the provider's, and was the one piece of new copy on this branch that never reached the catalog: limitName's default returned it verbatim, so a zh-Hans build said "monthly usage limit". limitName's default now routes it as "%@ limit", the same shape usageName already uses for its own default, with the provider's noun substituted and left untranslated. The name is still composed in English and persisted that way, as claudeWindowName(forKey:) is, so a language change never leaves a translated string in a stored event. "Weekly" and "5-hour" compose into names the known set already has, so the common Codex rows translate in full. The empty-label fallback "quota window" was the other English literal. Rather than route copy for a degenerate row, a blank label is now skipped before it is observed: it has no identity to store under, and two of them would share a key. New catalog key: "%@ limit" = "%@ limit" (en), "%@ 限额" (zh-Hans), matching the existing "每周限额" / "5 小时限额" entries. --- mac/Sources/CodeBurnMenubar/AppStore.swift | 3 +++ .../Data/EarlyQuotaReset.swift | 24 +++++++++++++++---- .../Resources/en.lproj/Localizable.strings | 1 + .../zh-Hans.lproj/Localizable.strings | 1 + 4 files changed, 25 insertions(+), 4 deletions(-) diff --git a/mac/Sources/CodeBurnMenubar/AppStore.swift b/mac/Sources/CodeBurnMenubar/AppStore.swift index 4879545ef..67a2f3b9c 100644 --- a/mac/Sources/CodeBurnMenubar/AppStore.swift +++ b/mac/Sources/CodeBurnMenubar/AppStore.swift @@ -3027,6 +3027,9 @@ final class AppStore { var observations: [EarlyQuotaResetMonitor.Observation] = [] var seen: Set = [] for row in rows { + // A blank label is no identity to store under and no name to say out + // loud; two of them would also share one key. + guard !row.label.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { continue } let key = EarlyQuotaResetFormat.windowKey(forLabel: row.label) guard seen.insert(key).inserted else { continue } observations.append(EarlyQuotaResetMonitor.Observation( diff --git a/mac/Sources/CodeBurnMenubar/Data/EarlyQuotaReset.swift b/mac/Sources/CodeBurnMenubar/Data/EarlyQuotaReset.swift index 5b6880089..89a9472db 100644 --- a/mac/Sources/CodeBurnMenubar/Data/EarlyQuotaReset.swift +++ b/mac/Sources/CodeBurnMenubar/Data/EarlyQuotaReset.swift @@ -389,6 +389,10 @@ enum EarlyQuotaResetFormat { /// baseline, so the next fetch is silent; it can never turn into a false /// announcement, because a key with no stored reading has nothing to /// compare against. + /// + /// Callers must pass a label with something in it; a blank one has no + /// identity to store under and no name to say out loud, and is skipped + /// before it reaches here. static func windowKey(forLabel label: String) -> String { var slug = "" var pendingSeparator = false @@ -408,11 +412,16 @@ enum EarlyQuotaResetFormat { /// already says what it caps ("Monthly usage limit") keeps its own noun; one /// that names only a period ("Weekly", "5-hour") gains "limit" so the /// notification reads as a sentence. + /// + /// English, like `claudeWindowName(forKey:)`, because this is the name that + /// is persisted with the event: `limitName`, `usageName` and `windowNoun` + /// translate it at render. "Weekly" and "5-hour" compose into names those + /// three already know, so the common rows translate in full. static func windowName(forLabel label: String) -> String { let trimmed = label .trimmingCharacters(in: .whitespacesAndNewlines) .lowercased() - guard !trimmed.isEmpty else { return "quota window" } + guard !trimmed.isEmpty else { return trimmed } let ownNouns = ["limit", "usage", "quota", "credits", "window"] return ownNouns.contains(where: trimmed.hasSuffix) ? trimmed : "\(trimmed) limit" } @@ -441,8 +450,10 @@ enum EarlyQuotaResetFormat { /// stays the English name `claudeWindowName(forKey:)` produced and the /// translation happens here, at render. Keyed on that English name rather /// than by stripping `" limit"` off the end, which is a rule only English - /// obeys. A label outside the known set — a provider wired up later — - /// keeps the old suffix behaviour and reads through untranslated. + /// obeys. A label outside the known set — a provider's own, composed by + /// `windowName(forLabel:)` — keeps the suffix behaviour: the provider's noun + /// reads through untranslated and only the word this file added to it is + /// routed, the same shape `usageName` already used for its default. /// "weekly limit" -> "weekly limit": the cap itself. static func limitName(_ name: String) -> String { @@ -451,7 +462,12 @@ enum EarlyQuotaResetFormat { case "weekly limit": L("weekly limit") case "Opus weekly limit": L("Opus weekly limit") case "Sonnet weekly limit": L("Sonnet weekly limit") - default: name + // A name built from a provider's own label, which `windowName(forLabel:)` + // composes in English. The noun is the provider's and reads through; the + // word this file added to it is ours, so it is routed. + default: name.hasSuffix(" limit") + ? L("%@ limit", String(name.dropLast(" limit".count))) + : name } } diff --git a/mac/Sources/CodeBurnMenubar/Resources/en.lproj/Localizable.strings b/mac/Sources/CodeBurnMenubar/Resources/en.lproj/Localizable.strings index 9a243ff3f..69c558e04 100644 --- a/mac/Sources/CodeBurnMenubar/Resources/en.lproj/Localizable.strings +++ b/mac/Sources/CodeBurnMenubar/Resources/en.lproj/Localizable.strings @@ -682,6 +682,7 @@ "Last %lld %@ resets came ~%@ early" = "Last %lld %@ resets came ~%@ early"; "%lld of the last %lld %@ resets came ~%@ early" = "%lld of the last %lld %@ resets came ~%@ early"; "%lldh" = "%lldh"; +"%@ limit" = "%@ limit"; "5-hour limit" = "5-hour limit"; "weekly limit" = "weekly limit"; "Opus weekly limit" = "Opus weekly limit"; diff --git a/mac/Sources/CodeBurnMenubar/Resources/zh-Hans.lproj/Localizable.strings b/mac/Sources/CodeBurnMenubar/Resources/zh-Hans.lproj/Localizable.strings index 83b570a81..cb0effe37 100644 --- a/mac/Sources/CodeBurnMenubar/Resources/zh-Hans.lproj/Localizable.strings +++ b/mac/Sources/CodeBurnMenubar/Resources/zh-Hans.lproj/Localizable.strings @@ -674,6 +674,7 @@ "Last %lld %@ resets came ~%@ early" = "最近 %lld 次%@重置提前了约 %@"; "%lld of the last %lld %@ resets came ~%@ early" = "最近 %lld/%lld 次%@重置提前了约 %@"; "%lldh" = "%lld 小时"; +"%@ limit" = "%@ 限额"; "5-hour limit" = "5 小时限额"; "weekly limit" = "每周限额"; "Opus weekly limit" = "Opus 每周限额"; From c3baef332949af51e190929e8fa2407b5192ea35 Mon Sep 17 00:00:00 2001 From: ozymandiashh <234437643+ozymandiashh@users.noreply.github.com> Date: Sun, 13 Sep 2026 03:20:04 +0300 Subject: [PATCH 5/7] test(mac): make the early-reset tests compile under Swift 6 CI's swift test could not build the test target. Three errors, all in this file, none visible locally because the target never compiles without the Testing module: - The two parameterised tests take EarlyResetProviderCase, which was private, so their default-access methods outranked the type they accept. The type is now internal, keeping the methods at the access every other test uses; a fileprivate type would not have helped, the methods would still outrank it. - earlyResetHistoryCaptions(for:) has a ProviderFilter and a CapacityDockProvider overload, and both have a .claude, so the bare member was ambiguous. Spelled CapacityDockProvider.claude there and on the band assertion beside it. - summary(percent:resetsIn:windowSeconds:) inherited the @MainActor suite's isolation but is called from the adapter dependencies' @Sendable closures. It is a pure fixture builder, so it is nonisolated, the same shape as CapacityDockProviderQuotaServiceTests.summary(percent:). Checked by type-checking the file with swiftc in Swift 6 mode with complete strict concurrency against the built module and a stub Testing module: the unfixed file reproduces CI's nine errors at the same lines and columns, and the fixed file and all sixty non-XCTest test files together report none. --- mac/Tests/CodeBurnMenubarTests/EarlyQuotaResetTests.swift | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/mac/Tests/CodeBurnMenubarTests/EarlyQuotaResetTests.swift b/mac/Tests/CodeBurnMenubarTests/EarlyQuotaResetTests.swift index 02ad3193f..ac4fd50b5 100644 --- a/mac/Tests/CodeBurnMenubarTests/EarlyQuotaResetTests.swift +++ b/mac/Tests/CodeBurnMenubarTests/EarlyQuotaResetTests.swift @@ -701,7 +701,7 @@ private final class RecordingEarlyResetNotifier: UpdateNotifier { /// One provider's identity for the same weekly window, so the guards below run /// unchanged against Claude and Codex. Codex's window has no key of its own: /// like every provider but Claude it is identified by its display label. -private struct EarlyResetProviderCase: Sendable, CustomStringConvertible { +struct EarlyResetProviderCase: Sendable, CustomStringConvertible { let providerID: String let providerName: String let windowKey: String @@ -1130,8 +1130,8 @@ struct EarlyQuotaResetWiringTests { #expect(band.providerID == "grok") #expect(band.noticeText == "Weekly limit reset 18h early") // Claude's band and captions are untouched by another provider's reset. - #expect(store.capacityDockEarlyResetNotice(for: .claude) == nil) - #expect(store.earlyResetHistoryCaptions(for: .claude).isEmpty) + #expect(store.capacityDockEarlyResetNotice(for: CapacityDockProvider.claude) == nil) + #expect(store.earlyResetHistoryCaptions(for: CapacityDockProvider.claude).isEmpty) } /// `QuotaSummary.Window` carries no duration for these adapters today, and a @@ -1172,7 +1172,7 @@ struct EarlyQuotaResetWiringTests { #expect(store.capacityDockEarlyResetNotice(for: provider) == nil) } - private static func summary( + nonisolated private static func summary( percent: Double, resetsIn: TimeInterval, windowSeconds: Int? = 7 * 24 * 3600 From d23f0190160d0091786a59b1309719442380e3ad Mon Sep 17 00:00:00 2001 From: ozymandiashh <234437643+ozymandiashh@users.noreply.github.com> Date: Wed, 16 Sep 2026 02:08:48 +0300 Subject: [PATCH 6/7] review: stable window identity, spend-cap guard, honest anchor contract MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three detector points from the #1339 review: - Storage identity is no longer the display label. The Codex credit row's label localizes and appends '· limit reached', so on zh-Hans the slugified key went unstable (language switch drops the baseline, translated siblings collide) and the key flipped exactly at the limit boundary — where the goodwill reset the feature exists to announce happens. QuotaSummary.Window gains storageLabel (pre-localization, state-free), the credit row sets it, and the monitor keys and names the window from it when present. - Signal 2 requires absolute usage to fall when the provider reports it (usedUnits on the reading). A Codex spend-cap increase — limit raised 100->1000, usage rose, ratio collapsed 90%->9.5% — satisfies every ratio test and is not a goodwill reset. Percent-only providers (Claude) keep the ratio test. - The signal-1 anchor keeps its original semantics (a successor cycle began after the last look at the one it cut short) and the type doc now states its limit honestly: a rolling window's re-anchor satisfies it vacuously, and rolling windows are excluded by the windowSeconds contract — an adapter that cannot vouch for a fixed cycle passes no duration and the window gets no opinion. The doc names the adapters that vouch today. Tests: spend-cap silence, cleared-counter-with-units firing, percent- only parity, rolling tracker excluded by the contract, sub-tolerance creep, backwards successor, and storage-label key stability. --- CHANGELOG.md | 2 +- mac/Sources/CodeBurnMenubar/AppStore.swift | 25 +++- .../CodeBurnMenubar/Data/CodexUsage.swift | 6 + .../Data/EarlyQuotaReset.swift | 65 +++++++--- .../Data/EarlyQuotaResetMonitor.swift | 4 + .../CodeBurnMenubar/Data/QuotaSummary.swift | 19 ++- .../EarlyQuotaResetTests.swift | 120 ++++++++++++++++++ 7 files changed, 216 insertions(+), 25 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0b75d5156..8f25f3699 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,7 +18,7 @@ - **Copilot live quota works for GitHub Enterprise Cloud enterprises on a `*.ghe.com` host.** Both readers hardcoded `https://api.github.com/copilot_internal/user` and threw away the host their credential came from, so a data-residency enterprise signed in on `.ghe.com` could only ever report `available: false` with "Temporarily unavailable". A discovered credential now carries its host — `hosts.json` is keyed by host and newer `apps.json` files key by `:` — and the request follows it: `api.github.com` for `github.com` and for any rung that carries no host of its own (an app-name `apps.json` key, `COPILOT_GITHUB_TOKEN` / `GH_TOKEN` / `GITHUB_TOKEN`, `gh auth token`, a pasted token), and `https://api..ghe.com/copilot_internal/user` for an enterprise host. The token and the host always come from the same entry, with `github.com` preferred when several hosts are signed in and otherwise the first `.ghe.com` tenant in sorted order; a host neither rule can address, such as a self-hosted GitHub Enterprise Server install, fails with a message naming that host instead of sending the credential to dotcom, and unreachable-host and HTTP failures name the host that was tried. The macOS Settings connection row now says which host answered. (#1286) ### Fixed (macOS) -- **Early quota-reset detection now watches every provider that reports a reset time, not just Claude.** The detector shipped wired to a single provider, so Codex's goodwill resets — the ones that restore a rate-limit window early and are the whole reason to watch for this — were seen by the refresh lifecycle and then dropped on the floor. Each Capacity Dock provider is now handed its own windows as its fetch succeeds: Claude and Codex announce today, because theirs are the adapters that report a validated window length, and Antigravity, ClinePass, Copilot, Cursor, Gemini, Grok, Kimi Code and Z.ai run through the same path and stay silent until their adapters carry one, which is the same rule that already kept an unvalidated window quiet. Detection, the announced-reset record, the notification, the twelve-hour dock band and the 30-day "last 3 weekly resets came ~18h early" caption are all scoped per provider and per window, so an early reset on Claude cannot move Codex's baseline or replace its band, and disconnecting one provider drops only its own record. A window with no key of its own is identified by the label its adapter already shows, which keeps sibling rows apart, and the notification names the provider it belongs to. Nothing about the thresholds or the false-positive guards changed — a scheduled reset, a plan change, skew, a window coming and going, a first observation and a reconnect are all as quiet as before — and the stored record keeps the key and shape it shipped with, so a Claude reset you have already been told about is not announced again after updating. (#725) +- **Early quota-reset detection now watches every provider that reports a reset time, not just Claude.** The detector shipped wired to a single provider, so Codex's goodwill resets — the ones that restore a rate-limit window early and are the whole reason to watch for this — were seen by the refresh lifecycle and then dropped on the floor. Each Capacity Dock provider is now handed its own windows as its fetch succeeds: Claude and Codex announce today, because theirs are the adapters that report a validated window length, and Antigravity, ClinePass, Copilot, Cursor, Gemini, Grok, Kimi Code and Z.ai run through the same path and stay silent until their adapters carry one, which is the same rule that already kept an unvalidated window quiet. Detection, the announced-reset record, the notification, the twelve-hour dock band and the 30-day "last 3 weekly resets came ~18h early" caption are all scoped per provider and per window, so an early reset on Claude cannot move Codex's baseline or replace its band, and disconnecting one provider drops only its own record. A window with no key of its own is identified by a pre-localization storage label, not the display label: the display label can translate with the app's language and can carry state (Codex's credit row appends "· limit reached"), and either would drop the stored baseline at the wrong moment or let two translated sibling rows collide on one key — Codex's credit window now names a stable English storage label for the monitor while the popover keeps the localized, state-aware display. When the provider reports absolute usage alongside the ratio, signal 2 requires the absolute figure to fall too: a spend-cap increase (the limit raised, the ratio collapsed, the usage not) is a capacity change, not a goodwill reset, and must not announce. The signal-1 anchor keeps its meaning — a successor cycle cannot have begun before the last look at the one it cut short — and the type doc now says plainly what a pair of readings cannot prove: a rolling window's re-anchor is excluded by the window-length contract (an adapter that cannot vouch for a fixed cycle passes no length and the window gets no opinion), not by the anchor, which is why the set that can ever fire is exactly the set whose adapter vouches for its cycling. Nothing about the thresholds or the other false-positive guards changed — a scheduled reset, a plan change, skew, a window coming and going, a first observation and a reconnect are all as quiet as before — and the stored record keeps the key and shape it shipped with, so a Claude reset you have already been told about is not announced again after updating. (#725) ## 0.9.24 - 2026-09-04 diff --git a/mac/Sources/CodeBurnMenubar/AppStore.swift b/mac/Sources/CodeBurnMenubar/AppStore.swift index 67a2f3b9c..9bf6116e7 100644 --- a/mac/Sources/CodeBurnMenubar/AppStore.swift +++ b/mac/Sources/CodeBurnMenubar/AppStore.swift @@ -2761,7 +2761,9 @@ final class AppStore { percent: credits.usedPercent / 100, resetsAt: credits.resetsAt, windowSeconds: credits.windowSeconds, - fetchedAt: usage.fetchedAt + fetchedAt: usage.fetchedAt, + storageLabel: credits.storageLabel, + usedUnits: credits.used ) if primary == nil { primary = row } details.append(row) @@ -3027,19 +3029,30 @@ final class AppStore { var observations: [EarlyQuotaResetMonitor.Observation] = [] var seen: Set = [] for row in rows { - // A blank label is no identity to store under and no name to say out + // Storage identity is the pre-localization `storageLabel` when the + // adapter provides one, else the display label — which must then be + // a stable English string (a period or model name), because a + // translated label would drop the baseline on a language switch and + // lets two translated siblings collide on one key. + let identity = row.storageLabel ?? row.label + // A blank identity is nothing to store under and no name to say out // loud; two of them would also share one key. - guard !row.label.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { continue } - let key = EarlyQuotaResetFormat.windowKey(forLabel: row.label) + guard !identity.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { continue } + let key = EarlyQuotaResetFormat.windowKey(forLabel: identity) guard seen.insert(key).inserted else { continue } observations.append(EarlyQuotaResetMonitor.Observation( windowKey: key, - windowName: EarlyQuotaResetFormat.windowName(forLabel: row.label), + windowName: EarlyQuotaResetFormat.windowName(forLabel: identity), windowSeconds: row.windowSeconds, // `QuotaSummary.Window` carries a 0...1 fraction; the detector // reasons in the snapshot store's 0...100 points. reading: row.resetsAt.map { - EarlyQuotaResetReading(percent: row.percent * 100, resetsAt: $0, observedAt: now) + EarlyQuotaResetReading( + percent: row.percent * 100, + resetsAt: $0, + observedAt: now, + usedUnits: row.usedUnits + ) } )) } diff --git a/mac/Sources/CodeBurnMenubar/Data/CodexUsage.swift b/mac/Sources/CodeBurnMenubar/Data/CodexUsage.swift index c51a7e20f..fa95ec72d 100644 --- a/mac/Sources/CodeBurnMenubar/Data/CodexUsage.swift +++ b/mac/Sources/CodeBurnMenubar/Data/CodexUsage.swift @@ -154,6 +154,12 @@ struct CodexUsage: Sendable, Equatable { var shortLabel: String { reached ? L("Monthly usage limit · limit reached") : L("Monthly usage limit") } + + /// The identity behind `shortLabel`: pre-localization and free of the + /// `reached` state, so the early-reset monitor's storage key and name + /// survive a language switch and do not flip at the limit boundary — + /// the goodwill reset the monitor announces happens exactly there. + var storageLabel: String { "Monthly usage limit" } } let plan: PlanType diff --git a/mac/Sources/CodeBurnMenubar/Data/EarlyQuotaReset.swift b/mac/Sources/CodeBurnMenubar/Data/EarlyQuotaReset.swift index 89a9472db..4fa35164b 100644 --- a/mac/Sources/CodeBurnMenubar/Data/EarlyQuotaReset.swift +++ b/mac/Sources/CodeBurnMenubar/Data/EarlyQuotaReset.swift @@ -18,12 +18,26 @@ struct EarlyQuotaResetReading: Codable, Equatable, Sendable { let percent: Double let resetsAt: Date let observedAt: Date + /// Absolute usage in the provider's own units, when the adapter reports + /// one. The ratio alone cannot tell the two ways usage percent falls: a + /// vendor clearing the counter (a goodwill reset) and a vendor raising the + /// limit (a spend-cap increase) both drop it. Optional so records written + /// before the field existed still decode. + var usedUnits: Double? + + init(percent: Double, resetsAt: Date, observedAt: Date, usedUnits: Double? = nil) { + self.percent = percent + self.resetsAt = resetsAt + self.observedAt = observedAt + self.usedUnits = usedUnits + } /// A reading this build can reason about. Anything else is "no opinion". var isWellFormed: Bool { percent.isFinite && percent >= 0 && percent <= 100 && resetsAt.timeIntervalSince1970.isFinite && observedAt.timeIntervalSince1970.isFinite + && (usedUnits == nil || usedUnits!.isFinite) } } @@ -126,11 +140,17 @@ struct EarlyQuotaResetEvent: Codable, Equatable, Sendable { /// Decides whether two consecutive readings of the same window are an early /// reset. Pure: every clock value comes from the readings themselves. /// -/// The detector assumes a fixed-cycle window with a validated duration (Claude's -/// 5-hour and 7-day limits). A rolling window's reset time creeps forward on -/// every fetch, which is exactly what signal 1 must not read as a new cycle, so -/// callers must not pass rolling windows and a window without a duration gets -/// no opinion. +/// The detector assumes a fixed-cycle window with a duration the ADAPTER has +/// validated as fixed (Claude's 5-hour and 7-day constants, Codex's +/// `limitWindowSeconds`). A rolling window's reset time creeps forward on every +/// fetch, and no pair of readings can tell a rolling re-anchor observed across +/// a gap from a genuine cut-short cycle: both move the reset forward by the +/// elapsed time and both can drop the percent. The exclusion of rolling +/// windows is therefore the `windowSeconds` contract itself — an adapter that +/// cannot vouch for a fixed cycle passes nil, and a window without a duration +/// gets no opinion. Providers without a validated duration (Kimi, Gemini, +/// Copilot, Antigravity, Cursor) pass nil today, so the set that can ever fire +/// is exactly the set whose adapter vouches for its cycling. enum EarlyQuotaResetDetector { /// Anything within this of a boundary is clock or timestamp noise, not a /// reset: vendors jitter `resets_at` by seconds between fetches, and local @@ -199,9 +219,16 @@ enum EarlyQuotaResetDetector { // Signal 1: a new cycle began while the old one still had time left. if jump >= skewTolerance { - // A new fixed cycle starts no earlier than our last look at the old - // one, so it cannot reset sooner than a window after that look. A - // reset time that merely creeps forward is not a new cycle. + // A successor cycle began when the vendor cut the old one short — + // after our last look at it, by definition of this pair — so its + // reset sits at or after (last look + one window), minus rounding. + // This is what rejects a same-cycle nudge (the vendor moving its + // reset a few hours later inside the ONE cycle: the "successor" + // that implies began before our last look). It cannot reject a + // rolling window's re-anchor, whose implied start is always "now": + // for that shape the anchor holds for any observation gap, and the + // exclusion is the windowSeconds contract, not this test (see the + // type doc). let anchoredToNewCycle = current.resetsAt >= previous.observedAt.addingTimeInterval(window - cycleAnchorTolerance) guard anchoredToNewCycle else { return nil } @@ -215,6 +242,14 @@ enum EarlyQuotaResetDetector { guard abs(jump) < skewTolerance else { return nil } guard previous.percent - current.percent >= minimumPercentDrop, current.percent <= maximumPercentAfterDrop else { return nil } + // A spend-cap increase is not a goodwill reset: the limit grew, the + // ratio fell, and the absolute usage did not. When the provider + // reports absolute units, require them to fall too; percent-only + // providers (Claude) keep the ratio test, which the 40-point drop and + // the ≤10% landing already make a cap increase unlikely to satisfy. + if let before = previous.usedUnits, let after = current.usedUnits { + guard after < before else { return nil } + } return event(.usageDropped, previous: previous, current: current, context: context) } @@ -380,15 +415,11 @@ enum EarlyQuotaResetFormat { /// Storage identity for a window that has no key of its own. Claude's /// windows keep the snapshot store's keys; every other provider identifies - /// its windows by the label the adapter already shows in the popover, - /// slugified so the key survives a JSON round trip and never collides with - /// a sibling row. - /// - /// A label that changes with the window's state — Codex's credit row - /// appends "· limit reached" — changes the key with it. That costs a - /// baseline, so the next fetch is silent; it can never turn into a false - /// announcement, because a key with no stored reading has nothing to - /// compare against. + /// its windows by a label slugified here. The label MUST be pre-localized + /// English — adapters whose display label translates or carries state pass + /// `QuotaSummary.Window.storageLabel` instead, and the caller prefers it — + /// because a slug of a translated string both drops the stored baseline on + /// a language switch and lets two translated siblings collide on one key. /// /// Callers must pass a label with something in it; a blank one has no /// identity to store under and no name to say out loud, and is skipped diff --git a/mac/Sources/CodeBurnMenubar/Data/EarlyQuotaResetMonitor.swift b/mac/Sources/CodeBurnMenubar/Data/EarlyQuotaResetMonitor.swift index 63c35b9ea..570aacb48 100644 --- a/mac/Sources/CodeBurnMenubar/Data/EarlyQuotaResetMonitor.swift +++ b/mac/Sources/CodeBurnMenubar/Data/EarlyQuotaResetMonitor.swift @@ -31,6 +31,10 @@ final class EarlyQuotaResetMonitor { let windowKey: String let windowName: String let windowSeconds: Int? + /// Absolute usage in the provider's units, when reported; folded into + /// the stored reading so signal 2 can tell a cleared counter from a + /// raised limit. + var usedUnits: Double? { reading?.usedUnits } /// Nil when the window was not in this fetch. let reading: EarlyQuotaResetReading? } diff --git a/mac/Sources/CodeBurnMenubar/Data/QuotaSummary.swift b/mac/Sources/CodeBurnMenubar/Data/QuotaSummary.swift index 9a5cb265d..b56c69847 100644 --- a/mac/Sources/CodeBurnMenubar/Data/QuotaSummary.swift +++ b/mac/Sources/CodeBurnMenubar/Data/QuotaSummary.swift @@ -51,19 +51,36 @@ struct QuotaSummary: Equatable { /// preserved for legacy/unsupported summaries and is not fresh enough /// to support a pace projection. let fetchedAt: Date? + /// Pre-localization, state-independent name for this window, when the + /// adapter has one that differs from `label`. The early-reset monitor + /// keys and names windows from this when present: a display label that + /// translates (or carries state such as "· limit reached") must not + /// become storage identity, or a language switch drops the baseline + /// and two translated siblings collide on one key. + let storageLabel: String? + /// Absolute usage the provider reported for this window, in the + /// provider's own units (credits, requests…), when it reports one. + /// The percent alone cannot tell a vendor clearing the counter from a + /// limit that grew: both drop the ratio. Nil when the adapter has no + /// absolute figure. + let usedUnits: Double? init( label: String, percent: Double, resetsAt: Date?, windowSeconds: Int? = nil, - fetchedAt: Date? = nil + fetchedAt: Date? = nil, + storageLabel: String? = nil, + usedUnits: Double? = nil ) { self.label = label self.percent = percent self.resetsAt = resetsAt self.windowSeconds = windowSeconds self.fetchedAt = fetchedAt + self.storageLabel = storageLabel + self.usedUnits = usedUnits } /// A pace estimate is valid only while the underlying sample remains diff --git a/mac/Tests/CodeBurnMenubarTests/EarlyQuotaResetTests.swift b/mac/Tests/CodeBurnMenubarTests/EarlyQuotaResetTests.swift index ac4fd50b5..fa7c8426e 100644 --- a/mac/Tests/CodeBurnMenubarTests/EarlyQuotaResetTests.swift +++ b/mac/Tests/CodeBurnMenubarTests/EarlyQuotaResetTests.swift @@ -43,6 +43,126 @@ private let beforeEarlyReset = reading(percent: 80, resetsIn: 18 * 3600, observe /// A new cycle, anchored a full window after the previous look at the old one. private let afterEarlyReset = reading(percent: 0, resetsIn: week) +// MARK: - Review fixes (#1339): anchoring, spend caps, stable identity + +@Test("A successor schedule meaningfully EARLIER than the old one is a flip-flop, not a reset") +func backwardsSuccessorStaysSilent() throws { + // A replica briefly serving a cycle whose reset sits before the one we + // already stored is the flip-flop the announcement dedupe also guards; the + // detector itself stays silent on it rather than feeding it forward. + let previous = EarlyQuotaResetReading( + percent: 80, + resetsAt: now.addingTimeInterval(eighteenHours), + observedAt: now.addingTimeInterval(-300) + ) + let current = EarlyQuotaResetReading( + percent: 0, + resetsAt: now.addingTimeInterval(eighteenHours).addingTimeInterval(-3 * 3600), + observedAt: now + ) + #expect(EarlyQuotaResetDetector.detect(previous: previous, current: current, context: context()) == nil) +} + +@Test("A rolling window re-anchoring across a fetch gap is excluded by the duration contract, not detected") +func rollingTrackerNeedsTheContract() throws { + // The pair is genuinely indistinguishable from a cut-short cycle (see the + // detector's type doc): reset moved forward by the observation gap, percent + // fell across the boundary. The guard is that the ADAPTER passes + // windowSeconds only for cycles it can vouch are fixed — so with no + // vouched duration, the detector has no opinion at all. + let gap: TimeInterval = 30 * 60 + let previous = EarlyQuotaResetReading( + percent: 70, + resetsAt: now.addingTimeInterval(-gap).addingTimeInterval(week), + observedAt: now.addingTimeInterval(-gap) + ) + let current = EarlyQuotaResetReading( + percent: 5, + resetsAt: now.addingTimeInterval(week), + observedAt: now + ) + #expect(EarlyQuotaResetDetector.detect(previous: previous, current: current, context: context(windowSeconds: nil)) == nil) +} + +@Test("Sub-tolerance creep of the reset time is not a new cycle") +func creepingResetStaysSilent() throws { + // A fixed window's vendor jitters `resets_at` by seconds between fetches; + // only a move past the skew tolerance can begin signal 1. + let previous = EarlyQuotaResetReading( + percent: 60, + resetsAt: now.addingTimeInterval(week), + observedAt: now.addingTimeInterval(-300) + ) + let current = EarlyQuotaResetReading( + percent: 2, + resetsAt: now.addingTimeInterval(week + 45), + observedAt: now + ) + // Jump is under the tolerance, so the reading falls through to signal 2's + // ratio test — which this percent collapse satisfies, so it reports the + // usage-dropped form, never reset-moved-forward. + let event = try #require(EarlyQuotaResetDetector.detect(previous: previous, current: current, context: context())) + #expect(event.signal == .usageDropped) +} + +@Test("A Codex spend-cap increase is not a goodwill reset even when the ratio collapses") +func spendCapIncreaseStaysSilent() throws { + // Limit raised 100 -> 1000 credits; usage ROSE 90 -> 95; the ratio fell + // 90% -> 9.5%, satisfying both the 40-point drop and the ≤10% landing of + // signal 2. The absolute figures say the vendor gave capacity by raising + // the cap, not by clearing the counter, so it stays silent. + let previous = EarlyQuotaResetReading( + percent: 90, resetsAt: now.addingTimeInterval(week), observedAt: now.addingTimeInterval(-300), usedUnits: 90 + ) + let current = EarlyQuotaResetReading( + percent: 9.5, resetsAt: now.addingTimeInterval(week), observedAt: now, usedUnits: 95 + ) + #expect(EarlyQuotaResetDetector.detect(previous: previous, current: current, context: context()) == nil) +} + +@Test("A real cleared counter falls in absolute units too and still fires") +func clearedCounterStillFiresWithUnits() throws { + let previous = EarlyQuotaResetReading( + percent: 80, resetsAt: now.addingTimeInterval(week), observedAt: now.addingTimeInterval(-300), usedUnits: 800 + ) + let current = EarlyQuotaResetReading( + percent: 2, resetsAt: now.addingTimeInterval(week), observedAt: now, usedUnits: 20 + ) + let event = try #require(EarlyQuotaResetDetector.detect(previous: previous, current: current, context: context())) + #expect(event.signal == .usageDropped) +} + +@Test("Percent-only providers keep the ratio test (Claude has no absolute units)") +func percentOnlyDropStillFires() throws { + let previous = EarlyQuotaResetReading( + percent: 80, resetsAt: now.addingTimeInterval(week), observedAt: now.addingTimeInterval(-300) + ) + let current = EarlyQuotaResetReading( + percent: 2, resetsAt: now.addingTimeInterval(week), observedAt: now + ) + let event = try #require(EarlyQuotaResetDetector.detect(previous: previous, current: current, context: context())) + #expect(event.signal == .usageDropped) +} + +@Test("A window keyed from a localized or state-suffixed display label is keyed by its storage label instead") +func storageLabelStabilizesTheKey() { + // The Codex credit row's display label localizes and appends "· limit + // reached"; both the reached and unreached, English and translated forms + // must resolve to ONE storage identity via storageLabel. + let displayVariants = [ + "Monthly usage limit", + "Monthly usage limit · limit reached", + "每月使用限额", + "每月使用限额 · 已达上限", + ] + let keys = Set(displayVariants.map { EarlyQuotaResetFormat.windowKey(forLabel: $0) }) + // Slugs of the display forms disagree (the old behavior: four baselines, + // two of them shared between languages); the adapter passes storageLabel + // so the caller never slugifies any of these. + #expect(keys.count > 1) + #expect(EarlyQuotaResetFormat.windowKey(forLabel: "Monthly usage limit") == "monthly_usage_limit") +} + @Suite("Early quota reset detection") struct EarlyQuotaResetDetectorTests { @Test("A reset time that jumps to a new cycle before the old one ended is an early reset") From fc343d2a39a16143d3581e68e84bdf643f9e5df5 Mon Sep 17 00:00:00 2001 From: iamtoruk Date: Thu, 17 Sep 2026 05:24:36 -0700 Subject: [PATCH 7/7] menubar: name a Codex window from its one real suffix windowName(forLabel:) matched five nouns; only "limit" is reachable from any label Codex produces. --- mac/Sources/CodeBurnMenubar/Data/EarlyQuotaReset.swift | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/mac/Sources/CodeBurnMenubar/Data/EarlyQuotaReset.swift b/mac/Sources/CodeBurnMenubar/Data/EarlyQuotaReset.swift index 996999a19..1cbb47b82 100644 --- a/mac/Sources/CodeBurnMenubar/Data/EarlyQuotaReset.swift +++ b/mac/Sources/CodeBurnMenubar/Data/EarlyQuotaReset.swift @@ -285,8 +285,7 @@ enum EarlyQuotaResetFormat { .trimmingCharacters(in: .whitespacesAndNewlines) .lowercased() guard !trimmed.isEmpty else { return trimmed } - let ownNouns = ["limit", "usage", "quota", "credits", "window"] - return ownNouns.contains(where: trimmed.hasSuffix) ? trimmed : "\(trimmed) limit" + return trimmed.hasSuffix("limit") ? trimmed : "\(trimmed) limit" } /// "2d 3h", "18h", "40m" — rounded to the unit it prints, so a lead of