feat(codex): notify when OpenAI banks a limit reset, and show it everywhere quota is shown - #1322
feat(codex): notify when OpenAI banks a limit reset, and show it everywhere quota is shown#1322ozymandiashh wants to merge 3 commits into
Conversation
…eady carries The reset-credits decoder kept only `available_count` and the soonest expiry, and the inline block on the usage payload kept only `available_count`. The response has always carried more: `id`, `reset_type` and `granted_at` per credit, and `applicable_available_count` alongside the total. Those are exactly the fields needed to tell a newly granted credit from one the account was already holding, so they are decoded now instead of discarded. Identity comes first: a credit is reduced to a grant only if it carries an `id` or, failing that, a `granted_at` to be named by. One that carries neither still counts toward the server's authoritative total but never becomes a grant, because it could not be told apart from the next one and would re-announce itself on every refresh. Only `status == "available"` credits become grants, so a redeemed one is spent history rather than news. `applicable_available_count` is unknown when absent, never zero. The companion endpoint does not always send it and the usage payload sometimes does, so the usage response we are already holding fills it in when — and only when — the authoritative source said nothing. No extra request either way.
A banked reset is the one capacity signal a user can act on before it is spent, and it arrives silently. CodeBurn already sees it: the reset-credit inventory rides every successful Codex quota refresh. Comparing that inventory against the previous reading is all it takes to notice a grant, so there is no new endpoint, no new cadence and no new data source here. The detection is a pure function over an observation and the state it is judged against, which is what makes the awkward cases testable rather than hopeful: - The first-ever observation is a baseline. Everything the account already holds is history, not a burst of notifications. - A credit that disappears was spent or expired, and is never an event. Its id is kept for the retention window so a payload that lists it again — a flicker, not a grant — cannot re-announce it. - An absent or malformed credits payload is *no opinion*: no event, and no state change at all. Treating it as an empty account would re-baseline on every failed fetch and re-announce the whole inventory on reconnect. Seen ids persist to `codex-banked-resets.json` in the CodeBurn cache directory, written through SafeFile behind an actor exactly the way SubscriptionSnapshotStore writes its snapshots — the same mechanism, not a second one — so a relaunch does not repeat a notice. State is written before the post, not after: a notice we drop is a smaller failure than one that repeats forever. Disconnecting clears the file, so a reconnect under another account baselines again. The notification goes out on the existing UpdateNotifier path, one per new credit, naming what was granted, when, and how many are usable. Never the credit id, never a token. Settings gains a toggle, default on; with it off the state is still recorded, so turning it back on does not release a backlog. Nothing here ever spends a credit. It is a notice.
The Plan tab row getagentseal#724 added showed a count and an expiry. It now also shows how many of those credits can be applied right now and when the most recent one landed, which is what a user deciding whether to push through or throttle back actually needs. The same sentence appears in the agent-tab quota hover card and in `codeburn quota`. One formatter produces it, mirrored line for line in Swift and TypeScript, down to the compact-age rules — deliberately not RelativeDateTimeFormatter or Intl.RelativeTimeFormat, because the two surfaces have to print the same characters, and a locale-aware formatter would not. `codeburn quota` grows a `notes` channel for provider facts that are not a window; only Codex sets it, so every other provider's output is byte-identical to before. The CLI reads the credits off the `rate_limit_reset_credits` block of the usage response it already fetches and never calls the companion endpoint, so it prints the count without the grant and expiry captions that only the per-credit list carries. Refs getagentseal#725. No new polling loop and no new data source: this reads fields from a response already fetched on the existing cadence.
|
Landed as #1328 (66ea03e) with your three commits and authorship intact, rebased onto current main, plus one fix in the shared notification delegate: since #1283, tapping any notification ran a full app update, so a tap on the new Codex alert would have installed one; the delegate now acts only on the update notice's identifier prefix. Verified: no new endpoint or timer (the companion fetch already existed on main), Swift and TypeScript formatters byte-identical on 34 well-formed cases, quota --format json unchanged for every provider, 569 Swift and 4090 root tests pass, six mutations killed. This Free account carries no reset credit, so the non-zero path rests on fixtures. Two things worth a follow-up: the typed Swift parser now hides the row on a wrong-typed field where main rendered it, and retention keys off first-seen rather than last-seen. Thank you. |
…IX paths Two CI failures on getagentseal#1327, both ours. macOS `swift test`: five tests in CodexQuotaSummaryTests assert Codex's `footerLines` exactly, and got three lines instead of one. Appending the forecast there was the wrong call, not a stale expectation. `QuotaSummary.footerLines` is the adapter's normalized output, consumed verbatim by the Capacity Dock, the hover card and those tests; an estimate derived from a public record is not something the account reported, and stacking it in there puts words in the adapter's mouth and couples every consumer to a feature none of them asked for. getagentseal#1322 adds Codex lines to the same array, so the collision was going to get worse. `QuotaSummary` gains `forecastLines`, defaulted empty, so every other adapter's construction site and every other provider's summary are untouched. The hover card draws it under its own divider, below the provider's own facts. The five existing tests are unchanged and now cannot be affected: the Codex `footerLines` array is built from the credit rows alone. Windows `test-platforms`: the dataset-parity test compared `datasetPaths()` against paths written with '/'. The separator is '\' there, so two identical path lists compared unequal - getagentseal#1291's lesson again. The expected side is now built with `join`, the invariant is pinned as tail segments rather than as a spelling, and a case with a Windows path exercises the splitter on both separators. The byte-identical check also normalizes line endings and compares the parsed documents, because a Windows checkout can hand back CRLF for a file the refresh script wrote with '\n'.
…ecord The forecast has two inputs with very different freshness needs, and only one of them was being met. The distribution of waits comes from the bundled record and is stale by a release cycle - Action, pull request, merge, release, update. That is fine: 44 waits do not change shape in a week and a 3.6-day median does not move because the file is four days old. The last-reset clock is the other input, and it was frozen at the last release. A forecast that still believes the last reset was six days ago when one landed this morning is not slightly wrong; it is wrong in the direction that matters, and it is the direction that tells someone to spend down quota that is not coming back. The public trackers learn of a reset in about two minutes. This machine can learn of it in one refresh cycle, and now does. `CodexResetForecastLocalEvents` reads what the two sibling features already persist and maps them onto the `LocalResetEvent` seam the model has had since it was written. It reads rather than imports: neither branch is on main, so a code dependency would make this one unmergeable until they land. The persisted records are decoded through private mirror types carrying only the two fields the forecast needs, and a missing file, a missing key, a wrong shape or an unreadable date is no opinion rather than an error - with neither feature installed the loader returns nothing and the forecast conditions on the global record exactly as before. Worth recording, because both differ from what you would guess: - getagentseal#1320 persists to UserDefaults, one JSON record per provider under `codeburn.quota.earlyReset.state.<providerID>`, seconds-since-1970, not a file in the cache directory. Only `latestEvent` is retained, which is the one this needs. Its `detectedAt` is used, never `scheduledResetAt` - the latter is when the cut-short cycle would have reset, which is in the future and is not when anything happened. - getagentseal#1322's store keeps `firstSeenAt`, not `grantedAt`; `grantedAt` only exists on that branch's transient event and never reaches disk. So the timestamp errs late by up to one refresh cycle, never early. - getagentseal#1320 currently only records Claude. Until it also records Codex, the early-reset half of this is correct and dormant; the banked half works today. The Codex filter is enforced twice regardless, by storage key and by the id inside the record, because an Anthropic early reset moving the Codex clock would wreck the forecast silently. The sentence now names the clock it is reading: "12h since the reset observed on this machine at 14:30" rather than "since the last global reset", mirrored in the TypeScript module so the two stay identical. The CLI is untouched: `src/quota/*` persists nothing between invocations, so there is no local store for it to read and `codeburn quota` stays conditioned on the bundled record.
Summary
codex-banked-resets.jsonin the CodeBurn cache directory — written throughSafeFilebehind an actor, exactly the waysubscription-snapshots.jsonis, not a second persistence mechanism — so a relaunch does not repeat a notice. Detection is a pure function over an observation and the state it is judged against; all of the above is unit-tested and mutation-checked.Limit resets · …line incodeburn quota(text and--format json). One formatter produces the sentence, mirrored line-for-line in Swift and TypeScript down to the compact-age rules, so the CLI and the menubar print the same characters.codeburn quotagains anoteschannel for provider facts that are not a window; only Codex sets it, so every other provider's output is unchanged.rate_limit_reset_creditsblock of the usage response it already fetches and never calls the companion endpoint.available_at, no pending status, andgranted_athas only ever been observed in the past. The earliest warning this source can give is "it just landed", not "it lands at 5pm". Agranted_atahead of our clock is worded as "lands in 2h" defensively, but no observed payload produces it. Refs Epic: quota intelligence (live remaining, burn-rate forecast, pre-flight awareness, invoice reconciliation) #725; no issue is closed by this PR.Testing
npm testpasses — the relevant suites were run rather than the full sweep:npx vitest run tests/quota-codex-banked-resets.test.ts tests/quota-providers.test.ts tests/quota.test.ts tests/quota-codex-refresh.test.ts app/electron/quota/codex.test.ts→ 94 passed.npx tsc --noEmitis clean.npm run buildsucceeds —cd mac && swift buildis clean on a local swift.org toolchain.Detail:
CodexBankedResetTests.swift, swift-testing, matchingUpdateNotificationTests) cover new-credit detection, first-observation baseline, consumed-credit silence, the flicker case, reconnect-no-refire, relaunch-no-repeat over a shared store, malformed/missing payload as no-opinion at both cold and warm state, one post per credit when two land together, toggle-off silence with no backlog on re-enable, denied authorization, retention pruning, and that the credit id never reaches the notification text. Seven more cases extendCodexResetCreditsTests.swift(XCTest, matching the file) over the new parser fields.tests/quota-codex-banked-resets.test.ts, 14) cover the wording parity with the Swift expectations, the usable-now clause, soonest-expiry, the available-status filter, the identity fallback, six malformed shapes leaving the quota read connected, and thecodeburn quotarendering path.niltreated as an empty inventory, usable-now guard removed, zero no longer hiding the row, unidentifiable credit given a constant id, redeemed credits accepted as grants, applicable-count merge removed,min→maxon expiries,floor→ceilon ages, future-grant wording removed,notesdropped from the command provider. Two of these initially survived and exposed weak tests (a single-credit expiry case where min and max agreed; exact-multiple ages where floor and ceil agreed); both tests were strengthened.swift testcannot run on this machine (CLT-only, noTesting/XCTestmodule) — pre-existing and true on pristine main, affecting the wholemac/Testssuite, the same limitation menubar: surface Codex limit-reset credits (count and next expiry) in the Plan tab #724 reported. The pure logic was instead verified by a standaloneswiftcharness that compiles the realCodexBankedResets.swift,CodexUsage.swiftandCodexSubscriptionService.swiftagainst small stubs for the app-only dependencies and re-runs every assertion from both test files: 59/59 pass. CI is the gate for the test-runner itself.Not covered: a live end-to-end fetch against chatgpt.com (no test-account plumbing in the repo). The payload shape used is the one #723 documented and the committed live-capture fixtures show; new test payloads are inline JSON with fabricated credit ids and no account identifiers.