menubar: early-reset notification for Codex - #1339
Conversation
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.
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.
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 getagentseal#725
Brings in getagentseal#1330's menubar localization, which routed the early-reset notification, band and history copy through L(...). One conflict, in EarlyQuotaResetFormat.usageName, where both sides rewrote the same function. Main replaced the suffix rule with a switch on the four known English window names, each routed through its own catalog key, falling back to L("%@ usage", windowNoun(name)). This branch had stopped that fallback doubling the noun for Codex's "Monthly usage limit" row. Both survive: the four routed cases are main's, unchanged, and the default branch keeps the no-doubling rule before routing, so an unknown name whose noun already ends in "usage" reads through instead of becoming "monthly usage usage". No catalog key changes: the rule only decides whether "%@ usage" is applied. Mixing implicit and explicit returns in one switch does not compile, so every case now returns explicitly.
getagentseal#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.
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.
iamtoruk
left a comment
There was a problem hiding this comment.
Replayed all 3,274 Claude snapshots on my machine through both branches. Identical: 2 detections, both the Sep 1 incident, one notification. Claude is safe.
The rest I cannot check, because nothing but Claude keeps quota-window history on disk here. Before this merges I need your own replay numbers per provider, and the false-positive check on scheduled resets.
Three things first.
Only Codex can actually fire. Kimi, Gemini, Copilot, Antigravity and Cursor all pass windowSeconds: nil. That is a lot of code for one provider. Convince me it should not be Codex-only.
windowKey(forLabel:) slugifies a localized string. CreditLimit.shortLabel is L("Monthly usage limit"), so on zh-Hans the slug is empty and the key becomes "window". Switching language drops the baseline and the 30-day pattern. Two non-Latin sibling rows collide and the seen set silently drops one. The comment saying it never collides is not true off Latin.
shortLabel also appends "· limit reached", so the key flips exactly across at-limit to reset. That is the goodwill reset the changelog leads with, and it cannot fire.
Also: anchoredToNewCycle is vacuous when resetsAt tracks now + length, the inequality holds for any gap. Claude's four fixed windows were the guard. Now anything with a duration goes through. And a Codex spend-cap increase drops percent with resetsAt unmoved, which is signal 2. Want tests for both.
For the UI: before and after screenshots of the Capacity Dock detail popover with the early-reset band, the menubar quota hover card with the history caption, and the notification itself.
Three detector points from the getagentseal#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.
|
All three detector points are fixed in d23f019, the replay numbers follow, and the Codex-only question gets a direct answer below. 1. windowKey / shortLabel — both real, both fixed. Storage identity is no longer the display label. 2. anchoredToNewCycle — you were right that it was vacuous for that shape, and I've stopped pretending otherwise. The anchor keeps its original semantics (a successor cycle began after the last look at the one it cut short — this is what rejects a same-cycle nudge, and my first replacement attempt broke exactly those tests, which is how I learned the anchor was load-bearing). What a pair of readings cannot prove is now the type doc's first paragraph: a rolling re-anchor's implied start is always "now", so the anchor holds for any gap, and the exclusion of rolling windows is the 3. Codex spend-cap increase → signal 2 — fixed on absolutes. Your replay request — my numbers. This machine's Claude store: 1,052 snapshots (Sep 8–16), 493 five-hour readings with 14 scheduled rollovers, 559 seven-day with 1 — 0 events, 0 false positives on scheduled resets, replayed through this branch's detector. (No early reset appears in my store; your Sep 1 incident predates its horizon.) For Codex: there is no on-disk quota-window history to replay anywhere — persisting windows for other providers is what this PR adds, so pre-merge replay for them is not a thing that exists; what I can offer from this machine is that its Codex account does early-reset for real (banked-reset credits recorded Sep 14) and its zcode account gave a 5-hour early reset today. "Convince me it should not be Codex-only." Directly: the set that can ever fire is exactly the set whose adapter vouches for fixed cycling — Claude and Codex today, zcode the moment #1347 lands (its payload carries the unit/count enum a fixed duration derives from; follow-up commit there). That is three providers that demonstrably do this in practice, one of which (Claude) already shipped through this exact plumbing in #725 — a Codex-only version would delete nothing but the third provider's future. Everything provider-specific about the feature was already generic in #725's shipped shape (notification, band, hover caption, dedupe ledger); this PR's real content is handing non-Claude windows to that path and the per-provider scoping, not new surface area per provider. UI screenshots (dock band, hover card with the history caption, the notification) follow as a separate comment on a build with an injected announcement. |
…t enum The 5-hour and weekly windows are fixed cycles — the unit enum says so — so the adapter vouches for their lengths (windowSeconds from the same metadata the labels come from, never inferred from the label text). This is what the early-reset monitor's windowSeconds contract needs (getagentseal#1339): an adapter that cannot vouch for fixed cycling passes no length and its windows get no opinion. zcode accounts do early-reset for real — one did today on the machine this was tested on.
|
Thanks for the screenshots. They show a build from before 13 Sep though: the "Runs out in" and "in reserve" lines were removed from the quota rows on main that day (a858014), and your branch is cut from before it. So they do not tell me what this PR looks like on the current app. Also a decision on our side that changes the shape of this PR: the twelve-hour dock band and the 30-day "last 3 resets came early" caption are coming out of main. An early reset is a one-time event and the notification already says it. I am landing that removal today. What I want from this PR after that:
Then it is a small change and I will take it. |
|
The band and caption removal is on main now (#1452). Rebase onto that and the rest of my comment above stands. |
…entseal#1452 main dropped the early-reset dock band and history caption (getagentseal#1452), so this branch drops its per-provider band, caption and cycle-ledger plumbing too, and wires only Codex into the notification Claude already has. The detector fixes from d23f019 stay: storageLabel identity, the usedUnits spend-cap guard and the anchor contract doc.
|
Merged main (with #1452) and cut it down to what you asked for, in 1837f96:
Diff against main: +187/−18 in sources and changelog, the rest is tests. |
# Conflicts: # CHANGELOG.md
windowName(forLabel:) matched five nouns; only "limit" is reachable from any label Codex produces.


Summary
EarlyQuotaResetMonitorunder provider idcodex. A bootstrap only seeds a baseline, and disconnecting Codex clears its record. No dock band, no caption, no new polling.storageLabelwhen the row has one (the monthly credit row). Otherwise they use the display label, which is a period or model name and does not translate. That way a language switch or the· limit reachedsuffix can't drop the baseline at the moment the reset happens.windowSecondscontract, not by the anchor inequality.Testing
cd mac && swift buildclean.Testingmodule (the machine has no swift-testing): 42/42. Three new tests driverefreshCodexReportingSuccess()end to end. Mutations checked: removing the Codex refresh call or droppingstorageLabelfails them.swift test.