Skip to content

feat(codex): notify when OpenAI banks a limit reset, and show it everywhere quota is shown - #1322

Closed
ozymandiashh wants to merge 3 commits into
getagentseal:mainfrom
ozymandiashh:feat/codex-banked-resets
Closed

feat(codex): notify when OpenAI banks a limit reset, and show it everywhere quota is shown#1322
ozymandiashh wants to merge 3 commits into
getagentseal:mainfrom
ozymandiashh:feat/codex-banked-resets

Conversation

@ozymandiashh

@ozymandiashh ozymandiashh commented Sep 12, 2026

Copy link
Copy Markdown
Collaborator

Summary

  • CodeBurn now tells you when OpenAI banks a limit reset on your Codex account. These grants are sometimes announced and sometimes not, and they are the one capacity signal you can act on before it is spent — but until now the only way to notice one was to go looking. The reset-credit inventory CodeBurn already reads on every Codex quota refresh is compared against the previous reading; a credit seen for the first time posts one notification naming what was granted, when it landed, and how many you have available to use. Settings → Notifications has a switch for it, on by default.
  • The awkward cases are the feature. The first reading after connecting is a baseline, not news. A credit that disappears because you spent it says nothing. A failed fetch or a malformed payload is treated as no opinion rather than as an empty account, so reconnecting cannot re-announce what you were already told, and a credit that flickers out of one response and back into the next cannot announce itself twice. The seen set persists to codex-banked-resets.json in the CodeBurn cache directory — written through SafeFile behind an actor, exactly the way subscription-snapshots.json is, 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.
  • The count and the most recent grant now show up wherever Codex quota shows up: the Plan tab row menubar: surface Codex limit-reset credits (count and next expiry) in the Plan tab #724 added, the agent-tab quota hover card, and a Limit resets · … line in codeburn 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 quota gains a notes channel for provider facts that are not a window; only Codex sets it, so every other provider's output is unchanged.
  • This adds no network polling loop and no new data source, per Epic: quota intelligence (live remaining, burn-rate forecast, pre-flight awareness, invoice reconciliation) #725's non-goals. There is no new endpoint, no new timer and no new credential: the fields read here come off a response CodeBurn already fetches on the existing refresh cadence — the rule menubar: honor Retry-After on Claude quota 429s, back off failed refreshes #702 and menubar: surface Codex limit-reset credits (count and next expiry) in the Plan tab #724 established. The macOS side reads the companion response already fetched inside the usage 200 branch; the CLI reads the rate_limit_reset_credits block of the usage response it already fetches and never calls the companion endpoint.
  • It is a notice, not an action. Nothing here ever spends, redeems, or requests a credit, and nothing logs a token or a payload.
  • One thing worth recording for whoever picks up the rest of Epic: quota intelligence (live remaining, burn-rate forecast, pre-flight awareness, invoice reconciliation) #725: nothing in the payload distinguishes a granted-but-not-yet-usable credit from a usable one. There is no available_at, no pending status, and granted_at has only ever been observed in the past. The earliest warning this source can give is "it just landed", not "it lands at 5pm". A granted_at ahead 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

  • I have tested this locally against real data (not just unit tests) — not honestly checkable: there is no test-account plumbing for chatgpt.com in the repo, so the payloads exercised are the committed live-capture fixtures and menubar: Codex Plan tab doesn't surface limit-reset credits (count and expiries) #723's documented shape, not a live account.
  • npm test passes — 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.ts94 passed. npx tsc --noEmit is clean.
  • npm run build succeeds — cd mac && swift build is clean on a local swift.org toolchain.

Detail:

  • New Swift tests (CodexBankedResetTests.swift, swift-testing, matching UpdateNotificationTests) 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 extend CodexResetCreditsTests.swift (XCTest, matching the file) over the new parser fields.
  • New TypeScript tests (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 the codeburn quota rendering path.
  • Mutation-checked. Eight Swift mutations and seven TypeScript mutations were each applied to the real source and each produced a failing test: baseline guard removed, consumed id pruned immediately, nil treated 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, minmax on expiries, floorceil on ages, future-grant wording removed, notes dropped 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 test cannot run on this machine (CLT-only, no Testing/XCTest module) — pre-existing and true on pristine main, affecting the whole mac/Tests suite, 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 standalone swiftc harness that compiles the real CodexBankedResets.swift, CodexUsage.swift and CodexSubscriptionService.swift against 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.

…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.
@iamtoruk

Copy link
Copy Markdown
Member

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.

@iamtoruk iamtoruk closed this Sep 12, 2026
ozymandiashh added a commit to ozymandiashh/codeburn that referenced this pull request Sep 12, 2026
…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'.
ozymandiashh added a commit to ozymandiashh/codeburn that referenced this pull request Sep 12, 2026
…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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants