Skip to content

feat(codex): forecast the chance of a usage-limit reset, with its range - #1327

Closed
ozymandiashh wants to merge 13 commits into
getagentseal:mainfrom
ozymandiashh:feat/codex-reset-forecast
Closed

feat(codex): forecast the chance of a usage-limit reset, with its range#1327
ozymandiashh wants to merge 13 commits into
getagentseal:mainfrom
ozymandiashh:feat/codex-reset-forecast

Conversation

@ozymandiashh

@ozymandiashh ozymandiashh commented Sep 12, 2026

Copy link
Copy Markdown
Collaborator

Summary

  • codeburn quota now estimates the chance of an OpenAI Codex usage-limit reset landing soon, and never prints that number without its range. A Codex section reads Reset forecast: 24% chance in the next 24h (10 to 51%), 4% in 6h (1 to 14%). 12h since the last global reset; typical wait 2.2d. Working hours in SF: yes., with the numbers also carried structurally under providers[].resetForecast in --format json. The same two lines appear in the macOS Codex quota hover card and in the Plan tab, beside the pace captions that answer the same question one step nearer.

  • The model is a pure module, src/reset-forecast.ts. Empirical conditional survival over the inter-reset waits — Kaplan-Meier reduced to its uncensored form — weighting the last 120 days three times as heavily because the cadence sped up (mean wait 201h across the whole record, 116h across the last 120 days), shrunk toward a memoryless rate where the at-risk set thins to one or two waits, ranged by a Wilson interval on the unweighted counts, and tilted by the observed hour-of-day density in San Francisco. In the record shipped here nothing lands between 02:00 and 07:00 Pacific and 95% lands between 07:00 and 23:00, any day; the weekday spread is flat enough that treating weekends differently would describe a pattern the record does not show.

  • The honesty rules are the feature. The prior can tilt a probability but never takes it to exactly zero; no estimate is allowed to reach certainty, because multiplying a hazard by up to 24 otherwise manufactures a 100% that 44 events cannot support; the 24-hour figure is never reported below the six-hour one inside it; a missing or unusable record produces a stated reason rather than a zero; a record older than 14 days is called stale on the line itself; and the wording is "chance", never "expected" and never "will".

  • confidence is earned by a walk-forward backtest, and on this record it comes out low. The backtest rebuilds the model from each prefix, asks it at fixed offsets for the chance the next reset lands within 24 hours, and scores it against what happened and against the same prefix's base rate. Over 220 probes the model scores Brier 0.1324 against the base rate's 0.1288 — conditioning on elapsed time adds nothing across these 44 events. So the shipped label is low, a test asserts exactly that, and a second test proves the label can reach moderate on a record with a real cadence, so the mechanism is live rather than hard-wired. If a future refresh of the record flips the result, that test fails and somebody looks at the claim instead of quietly upgrading it.

  • The dataset is published from this repo. src/data/codex-reset-history.json carries 44 resets and 9 banked-credit grants from codex-reset.com's public timeline — a community tracker, not operated by or endorsed by OpenAI — as ids, instants, a type and a reset kind, with every text field dropped before anything is written. .github/workflows/refresh-codex-reset-history.yml pushes it to a data/codex-reset-history branch every 30 minutes and opens a weekly pull request to keep the copy bundled in releases recent. It never pushes to main, and has no push trigger, so pushing the data branch cannot retrigger it.

  • Two inputs, two freshness stories, and the second one is the one that mattered. The distribution of waits can be days old without harm: 44 waits do not change shape in a week. The last-reset clock cannot, and it was frozen at the last release. It now comes from this machine's own signals — the early-reset detector (menubar: detect early quota resets and say so #1329) and the banked-credit watcher (feat(codex): notify when OpenAI banks a limit reset, and show it everywhere quota is shown #1328) — so a reset that lands here moves the clock within one quota refresh cycle. With neither having fired, it falls back to the dataset's newest reset. On top of that, the dataset itself is refreshed at runtime:

    Epic: quota intelligence (live remaining, burn-rate forecast, pre-flight awareness, invoice reconciliation) #725 exception granted by the maintainer on 2026-09-13: a client-side, first-party, conditional fetch of the reset-history dataset from this repository on GitHub, at most hourly, carrying no user data; no request is ever made to codex-reset.com or any third party from the client.

    One host — api.github.com, which the app already contacts for update checks; raw.githubusercontent.com would have been a new one, so the contents API is used instead. A conditional GET with the stored ETag, so the usual answer is a 304 with no body and one request against the unauthenticated 60-per-hour limit. At most once an hour, on the refresh that already runs, detached so nothing in the UI waits on it, and only while Codex is connected. No credential, no cookie, no account id, no plan, no usage. A fetched record is adopted only if it passes the same validation the workflow applies — on the raw bytes, because Decodable would silently drop a text field — and is strictly newer than what is held. Offline, rate-limited, malformed or switched off, the record compiled into the build is used exactly as before. The set of hosts this client contacts is unchanged: api.github.com, and nothing else.

  • A macOS notification, opt-in and off by default — the only quota notice here that is, because the others report something that already happened and this one reports a probability. You pick a threshold (default a 50% chance within six hours). It fires at most once per crossing and re-arms only once the estimate falls back under the threshold; the fired state persists the way the subscription snapshots do, so relaunching cannot repeat it; a moved threshold or a new reset starts a new crossing; a failed refresh is no opinion rather than a re-arm; a stale record or a wait already past the end of the record never fires at all. The text says what it is before it says a number — "A statistical estimate from public reset history, not an announcement from OpenAI" — and it carries no button, no link and no side effect. Nothing in this feature spends, redeems, requests or refreshes anything.

  • The menubar mirrors the model in Swift because it cannot read this off the CLI payload. codeburn status --format menubar-json carries cost, sessions, providers and pricing but no quota block at all; the menubar's quota comes from its own native adapters. So unlike pricing, the forecast has to be computed there, over a byte-identical copy of the same committed record (SwiftPM resources must live inside the target directory, so the file exists twice and a test pins them together). The two implementations are mirrored constant for constant, and three tests pin the Swift sentences to the literal output of the TypeScript module on the same fixture.

  • Local resets are a seam, not a dependency. When this machine has seen a reset itself — the early-reset detector (menubar: detect early quota resets and say so #1320) or a banked-credit grant (feat(codex): notify when OpenAI banks a limit reset, and show it everywhere quota is shown #1322) — and it is more recent than the public record, "since last reset" counts from yours and the sentence says so. It is an input, so neither PR has to merge first; with no local events the forecast conditions on the global record exactly as it does today. Worth flagging for whoever merges: this adds a Settings section at the same two anchors in GeneralSettingsTab that menubar: detect early quota resets and say so #1320 and feat(codex): notify when OpenAI banks a limit reset, and show it everywhere quota is shown #1322 both use, so expect a textual conflict there if more than one lands — the resolution is to keep all the sections.

  • docs/codex-reset-forecast.md gives the arithmetic, the guards, the provenance, the backtest numbers and a section on what the forecast is not. Refs #725 rather than Closes: that issue is a five-part epic and this is one slice of its surfaces.

Testing

  • npx tsc --noEmit is clean.
  • npx vitest run tests/reset-forecast.test.ts tests/reset-forecast-dataset.test.ts tests/quota-codex-reset-forecast.test.ts tests/quota.test.ts tests/quota-providers.test.ts tests/quota-codex-refresh.test.ts130 passed. 78 of those are new: 47 on the model, 24 on the dataset and the refresh transform, 7 on the command wiring.
  • cd mac && swift buildBuild complete!, exit 0, no warnings from the new files. The first run failed — two static ISO8601DateFormatters tripped StrictConcurrency's non-Sendable global check — and was fixed by building parsers where they are used, the pattern every other subscription service in the target already follows.
  • I have tested this locally against real data — not honestly checkable. The dataset is the provided seed capture; codex-reset.com/api/timeline has never been called from this branch, so the exact live response shape is unverified. The normalizer is tolerant about where the upstream puts its fields and strict about what comes out, and the workflow fails loudly rather than committing a bad file.

Edge cases covered as their own tests, on both sides: empty history, a single reset, two resets, a stale record, an undated record, clock skew in both directions (now before the last reset, and a record dated in this machine's future), a wait longer than any in the record, the empty 01:00-to-08:00 Pacific window, the local-event fallbacks (absent, older, future-dated, unparseable), range containment in both directions, the probability ceiling, the recency weighting, the 24h-contains-6h rule, and every threshold-hysteresis case: first crossing, silence above, re-arm below, exactly at the threshold, a new reset, a moved threshold, a failed refresh, a stale record, and a relaunch over a round-tripped state.

swift test cannot run on this machineerror: no such module 'Testing', which reproduces on files this branch does not touch and on pristine main, the same limitation #1320 and #1322 reported. CodexResetForecastTests.swift (52 tests, swift-testing, matching the neighbouring pace-presentation suite) was syntax-checked with swiftc -parse, and the logic was executed instead through a standalone swiftc harness built from the real source files against small stubs for the app-only dependencies, mirroring every assertion in the suite: 163/163 pass, including the character-for-character parity with the TypeScript output.

Mutation-checked: 57 mutations, all 57 caught. The first pass left six survivors, each of which was a real hole and produced an additional test — a clock-skew guard masked by the guard that runs before it, a confidence floor no fixture was small enough to exercise, recency weighting that every flat-cadence fixture was blind to, a probability ceiling pinned on only one of the two sides, staleness tests that used the threshold constant symbolically and so moved with the mutation, and an upward range-widening branch that needed a burst-then-outage record to reach at all. The list covers both range widenings, the Wilson interval, the hour-prior floor, shrinkage, every clock and local-event guard, all five crossing-detector rules, the notice's disclaimer and its range, the render block and its attribution, and eight rules in the refresh script including the one that refuses a response holding fewer resets than the committed file.

Not verified: no notification was delivered and no surface was seen in a running app; the workflow has never executed, and gh pr create may be barred org-wide, in which case it falls back to a pushed branch plus a comparison URL in the step summary, the way star-history.yml already does.


---

# Follow-ups (2026-09-13)

Four rounds on top of the original five commits.

## `4de2a8e8` — the two CI failures

**macOS `swift test`.** Five pre-existing `CodexQuotaSummaryTests` asserted Codex's
`footerLines` exactly and got three lines. Fixed as a design problem, not a test
problem: `QuotaSummary` gains `forecastLines`, defaulted empty, so every other
adapter's construction site and every other provider's summary are byte-identical
to main. The hover card draws it under its own divider. The five tests are
**unchanged** and now structurally cannot be affected — the Codex `footerLines`
array is built from the credit rows alone. Three new tests pin that.

**Windows `test-platforms`.** The dataset-parity test compared `datasetPaths()`
against paths written with `/`. Fixed by building the expected side with `join`,
pinning the invariant as *tail segments* rather than a spelling, and adding a case
that runs the splitter over a Windows path. The byte-identical check also
normalizes line endings and compares parsed documents, since a Windows checkout
can hand back CRLF for a file the script wrote with `\n`.

## `6b53b99d` — the last-reset clock

The maintainer's objection was correct. `codexResetForecast(localEvents:)` defaulted
to `[]` and nothing passed anything, so "since last reset" came only from the
bundled dataset — days stale by construction.

`CodexResetForecastLocalEvents` now reads both sibling stores. Three things worth
recording, all different from what the brief assumed:

- **#1320 persists to `UserDefaults`**, one JSON record per provider under
  `codeburn.quota.earlyReset.state.<providerID>`, seconds-since-1970 — not a file
  under `CodeBurnCacheDirectory.resolve()`. Only `latestEvent` is retained.
- **`detectedAt`, not `scheduledResetAt`.** The latter is when the cut-short cycle
  *would* have reset: in the future, and not when anything happened.
- **#1322's store keeps `firstSeenAt`, not `grantedAt`.** `grantedAt` lives only on
  that branch's transient event and never reaches disk. So the timestamp errs late
  by up to one refresh cycle, never early.

**Also worth flagging: #1320 only records Claude.** `detectEarlyResets` hard-codes
`CapacityDockProvider.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 anyway — by storage key and by the id inside the record — because an
Anthropic early reset moving the Codex clock would wreck the forecast silently.

Wording now names the clock: `12h since the reset observed on this machine at 14:30`
rather than `since the last global reset`, mirrored in TypeScript.

**The CLI is untouched.** `src/quota/*` persists nothing between invocations, so
there is no local store to read; `codeburn quota` stays conditioned on the dataset.

## `d9c898c0` — the merge with `origin/main` (5110eb83)

Five conflicts, all resolved as unions, nothing from either side dropped:

| File | What collided |
| --- | --- |
| `CHANGELOG.md` | both sides' entries, kept in their sections |
| `SettingsView.swift` | main folded the notification switches into one `Section("Notifications")` (`138ef342`); the forecast's notice, its threshold picker and the refresh toggle moved **into** it and the standalone section is gone |
| `AppStore.swift` | five hunks: both announcers, both post-fetch observe calls, both disconnect cleanups, and the Codex summary carrying main's banked footer line alongside `forecastLines` |
| `HeatmapSection.swift` | main replaced `resetCreditsLabel` with `CodexBankedResetPresentation`; that deletion kept, forecast rows kept |
| `src/quota/index.ts` | main's `notes` and this branch's `resetForecast`, both kept — deliberately separate, which is what `4de2a8e8` was about |

**On the mirror types.** Switched to main's real `Codable` types
(`EarlyQuotaResetMonitor.ProviderState`, `EarlyQuotaResetEvent`,
`CodexBankedResetState`), so drift is now a compile error. **Kept the by-path read**,
because neither store exposes a usable one: `CodexBankedResetStore.load()` is
`async` and hard-wired to the real cache path with nothing injectable, while the
forecast is evaluated synchronously from view bodies; and
`EarlyQuotaResetMonitor.visibleEvent` applies the twelve-hour *dock-visibility*
window, which is the wrong filter — a reset twenty hours ago is no longer worth a
band in the dock and is still exactly what "since last reset" counts from.

The `UserDefaults` key prefix stays a literal because `EarlyQuotaResetMonitor` is
`@MainActor` and its static cannot initialise a nonisolated one; a `@MainActor` test
asserts the two strings are equal.

The trade: a record that does not satisfy their shape now reads as nothing at all
rather than being partially decoded. That is the safe direction, and it is tested.

## `c51eef32` — the runtime fetch

Exception statement, verbatim, in the workflow header, the docs page and the PR body:

> #725 exception granted by the maintainer on 2026-09-13: a client-side,
> first-party, conditional fetch of the reset-history dataset from this repository
> on GitHub, at most hourly, carrying no user data; no request is ever made to
> codex-reset.com or any third party from the client.

**Workflow.** Two crons: `*/30 * * * *` pushes to `data/codex-reset-history`
(`contents: write`, `--force-with-lease`, never `main`); `23 6 * * 1` additionally
opens or updates the pull request to `main`. No `push` trigger, so it cannot
retrigger itself. Validated: YAML parses, 7 steps, all four shell blocks pass
`bash -n`.

**Client.** `api.github.com` contents API with `Accept: application/vnd.github.raw+json`
and `If-None-Match`; hourly gate on disk; single-flight; `Retry-After` pushes the
next attempt out rather than sleeping; cookies refused; detached from the UI; only
while Codex is connected. Validation runs on the **raw bytes**, because `Decodable`
would silently drop a `text` field and "nothing beyond the four fields" is the rule
that matters most. A record is adopted only if it validates **and** is strictly
newer. The bundled copy is the floor.

**CLI.** Same fetch, same hourly disk cache and ETag, gated by
`CODEBURN_PRICING_SNAPSHOT_ONLY` — the existing knob that pins pricing to its
bundled snapshot, rather than a second one to discover. `codeburn quota` prints
`Record: <generated_at>, bundled with this build|refreshed from this repository on GitHub.`

**Hosts contacted by the client: `api.github.com`, and nothing else.** Unchanged
from before this feature. codex-reset.com is contacted only by the Action.

Settings placement was corrected in `4d6b2d59` — see below.

## `4d6b2d59` — Settings placement

The refresh toggle is a data switch, not a notification, so it moves out of
`Section("Notifications")` into its own **"Reset Forecast Data"** section, together
with the fact a user actually wants next to that switch: which copy of the record is
in use and when it was built. `CodexResetForecastPresentation.datasetLine` renders it
in the same words and the same raw ISO instant `codeburn quota` prints, so the two
surfaces cannot disagree about which record feeds the number; an undated record still
names the copy rather than showing an empty date. The opt-in notice toggle and its
threshold picker stay in Notifications, where they do post notifications. **The
preference key is unchanged**, so nothing migrates.

Docs page and CHANGELOG updated to name the new section. Verified: `swift build`
clean, harness **245/245**, three new mutations on the dataset line all caught,
`swiftc -parse` clean, `tsc --noEmit` clean.

## Verification on `c51eef32`

- `npx tsc --noEmit` — clean.
- `npx vitest run` over 8 suites (the 4 new plus `quota`, `quota-providers`,
  `quota-codex-banked-resets`, `quota-codex-refresh`) — **164 passed**.
- `cd mac && swift build` — **Build complete!**, exit 0, no warnings from the new files.
- Standalone `swiftc` harness, now built against **main's real**
  `EarlyQuotaReset`/`CodexBankedResetState` declarations rather than stubs —
  **241/241 checks pass**, including the three character-for-character
  TypeScript-parity sentences.
- `swiftc -parse` clean on both touched Swift test files (95 `@Test` functions
  across them).
- **Mutation checks, all caught:** 13 loader/wording + 17 fetch-policy on the Swift
  side, 20 on the TypeScript side, on top of the original 57. Six earlier survivors
  each produced a test; the two that went `SKIP` after the switch to real types were
  re-expressed against the new code and now fail.

## Not verified

- `swift test` still cannot run here (no `Testing` module; pre-existing on pristine
  main). **The five `CodexQuotaSummaryTests` expectations could not be executed** —
  they need `AppStore`, which the harness cannot compile. What can be said is that
  they are textually unchanged and that Codex's `footerLines` is now assembled
  without the forecast. CI is the gate.
- No running app: no notification delivered, no Settings section or hover card seen.
- The workflow has never run; `codex-reset.com/api/timeline` has never been called
  from this branch, so **the live response shape is still unverified**. The data
  branch does not exist yet, so the client's first fetch will 404 until the workflow
  runs once — which is silence, and the bundled record.
- `npm test` in full was not run; the eight relevant suites were.

## Process note

`git stash` is **not safe in this worktree**. A `stash push -u` / `pop` pair
returned another worktree's stash (muse-code files, unrelated to this branch), which
had to be unpicked by hand; the stash list is shared across worktrees of this repo
and another session is using it. The runtime-fetch work was parked on a temporary
branch and cherry-picked instead. No foreign files reached any commit here; the
strays were preserved under `scratchpad/stray-muse-code/` rather than deleted.

The reset forecast needs a record of past resets, and getagentseal#725 forbids the
client fetching one. The maintainer's exception is narrow and written
down: the dataset is refreshed by a GitHub Action in the repo, never by
the client at runtime. So the record lives here, as a file.

44 resets and 9 credit grants from codex-reset.com's public timeline, a
community tracker that is not operated by or endorsed by OpenAI. Four
fields survive the import - id, instant, type, reset kind - and every
text field upstream carries is dropped before anything is written. The
file is a table of times, not a copy of anyone's posts, and it carries
its own attribution in the header.

The refresh script is split so the transform and the guard rails are
pure and testable: it refuses a payload that is not a list of events, a
timestamp that goes backwards, a duplicate id, a field outside the four,
and - the one that matters most - a response holding fewer resets than
the committed file, so a truncated upstream cannot quietly delete
history.

SwiftPM resources have to live inside the target directory, so the
record exists twice and the script writes both copies; a test pins them
together byte for byte.

The workflow runs every six hours, validates the written file through
the client's own parser, and opens or updates a pull request. It never
pushes to main. If the organization bars Actions from opening pull
requests it falls back to the pushed branch plus a comparison URL in the
step summary, the way the star-history workflow already does.
A pure module: a history and a `now` go in, probabilities come out. No
clock of its own, no I/O, no network - the arithmetic runs over the file
the previous commit added.

The model is the empirical conditional distribution of inter-reset
waits, which for a fully observed record is what Kaplan-Meier reduces
to. Waits from the last 120 days count three times as much, because both
public trackers report the cadence sped up and the record agrees: 201
hours mean across the whole record against 116 across the last 120 days.
Deep in the tail the at-risk set thins to one or two waits, where a raw
share is 0 or 1 and reads as certainty, so the estimate is shrunk toward
a memoryless rate in proportion to how thin the set is.

The range is a Wilson interval on the unweighted at-risk counts.
Unweighted on purpose: recency weighting and shrinkage are judgements
about the data, not extra observations, and neither may narrow the
range. It is then widened, never narrowed, to contain the point.

The hour-of-day prior is the observed density in San Francisco, applied
to the hazard and averaged across the horizon. In this record nothing
lands between 02:00 and 07:00 Pacific and 95% lands between 07:00 and
23:00, any day - the weekday spread is flat enough that treating
weekends differently would describe a pattern the record does not show.
Two guards keep the prior from overclaiming: the per-hour multiplier is
floored, so a probability is never taken to exactly zero, and every
estimate is capped short of certainty, because multiplying a hazard by
up to 24 otherwise manufactures a 100% that 44 events cannot support.
Since the prior can lift a six-hour window more than the 24-hour window
containing it, the 24-hour figure is raised to at least the six-hour
one.

Local resets - the early-reset detector (getagentseal#1320) and banked credits
(getagentseal#1322) - are an input, not an import, so neither has to merge first.
With none, the forecast conditions on the global record.

The confidence label is earned, not asserted. `backtest()` rebuilds the
model from each prefix, asks it at fixed offsets for the chance the next
reset lands within 24 hours, and scores it against what happened and
against the same prefix's base rate. On the record we ship the model
does NOT beat the base rate - 0.132 Brier against 0.129 over 220 probes
- so the label is "low", and the test asserts that rather than letting a
future refresh quietly upgrade it.

Every rendered sentence carries its range, says "chance", and never says
"expected" or "will". A missing or unusable record produces a stated
reason, never a zero.
A block under the table rather than a row in it: the sentences are far
longer than any window label and would stretch the Window column past
every other provider's row.

`--format json` carries the numbers structurally under
`providers[].resetForecast`, so nothing downstream has to parse English
to get the probability and its range.

Attached only to a connected Codex provider. Every other provider comes
back untouched, and a machine that is not signed in to Codex is not told
about capacity it cannot use. `collectQuota` gains no reader, no request
and no timeout budget: the forecast is arithmetic over a bundled file.
The menubar cannot read this off the CLI payload. `codeburn status
--format menubar-json` carries cost, sessions, providers and pricing but
no quota block at all - the menubar's quota comes from its own native
adapters - so unlike pricing, the forecast has to be computed here, over
a byte-identical copy of the same committed record. The Swift model is a
deliberate mirror of `src/reset-forecast.ts`, constant for constant and
word for word, and the test suite pins the rendered sentences to the
literal output of the TypeScript module on the same fixture.

Two surfaces: the Codex quota hover card, through the footer lines it
already draws, and the Plan tab, beside the pace captions that answer
the same question one step nearer.

The notification is off by default, and it is the only quota notice here
that is. The others report something that already happened; this one
reports a probability, and a probability that arrives uninvited is a
worse trade. The user picks a threshold, default a 50% chance within six
hours. It fires at most once per crossing and re-arms only once the
estimate falls back under the threshold, so it cannot nag; the fired
state is persisted the way the subscription snapshots are, so a relaunch
does not repeat it; a moved threshold or a new reset starts a new
crossing; a failed refresh is no opinion rather than a re-arm; and a
stale record or an estimate already past the end of the record never
fires at all.

The text says what it is before it says a number - a statistical
estimate from public reset history, not an announcement from OpenAI -
and it has no button, no link and no side effect. Nothing in this
feature spends, redeems, requests or refreshes anything.
The model is only defensible if a reader can check it, so the page gives
the arithmetic, the guards, the source and its provenance, and the
backtest numbers - including the uncomfortable one: on the record we
ship, conditioning on elapsed time does not beat the base rate, which is
why the shipped confidence label is "low".

It also states the exception this feature runs under, verbatim: the
dataset is refreshed by a GitHub Action in the repo, never by the client
at runtime.

Refs getagentseal#725 rather than Closes: that issue is a five-part epic and this is
one slice of its surfaces, the same call getagentseal#1320 and getagentseal#1322 made.
…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.
getagentseal#1329 (early quota resets) and getagentseal#1328 (banked Codex resets) both landed, so
the two features this forecast reads from are now on main.

Conflicts, all resolved as unions:

- CHANGELOG.md, the usual: both sides' entries kept in their sections.
- SettingsView.swift, the seam this branch flagged. Main folded the
  notification switches into one `Section("Notifications")` (138ef34),
  so the forecast's opt-in notice and its threshold picker move into that
  section rather than adding a fourth one. The standalone "Codex Reset
  Forecast" section is gone.
- AppStore.swift, five hunks: both announcers, both post-fetch observe
  calls, both stores cleared on disconnect, and the Codex summary now
  carrying main's banked-reset footer line alongside this branch's
  `forecastLines`. Nothing from either side dropped.
- HeatmapSection.swift: main replaced `resetCreditsLabel` with
  `CodexBankedResetPresentation`; that deletion is kept and this branch's
  forecast rows stay.
- src/quota/index.ts: main's `notes` and this branch's `resetForecast`
  are both on `QuotaCommandProvider`. They stay separate on purpose -
  `notes` is for facts the provider reported, `resetForecast` is an
  estimate over a public record, and folding one into the other is what
  the footer-lines fix earlier on this branch was about.

With both features on main, the local-event loader now decodes through
their real `Codable` types - `EarlyQuotaResetMonitor.ProviderState`,
`EarlyQuotaResetEvent` and `CodexBankedResetState` - instead of the
private mirror structs it carried while they were unmerged. Drift is now
a compile error rather than a silent misread.

It still reads the two stores by address rather than calling them,
because neither exposes a read this can use: `CodexBankedResetStore.load()`
is async and hard-wired to the real cache path with nothing to inject,
and the forecast is evaluated synchronously from view bodies; and
`EarlyQuotaResetMonitor.visibleEvent` applies the twelve-hour
dock-visibility window, which is the wrong filter here - a reset twenty
hours ago is no longer worth a band in the dock and is still exactly what
"since last reset" should count from.

The `UserDefaults` key prefix is still a literal, because
`EarlyQuotaResetMonitor` is @mainactor and its static cannot initialise a
nonisolated one; a test asserts the two strings are equal, so drift fails
there instead of silently reading nothing.
The forecast's distribution of waits was frozen at whatever shipped in the
build: Action, pull request, merge, release, update. That mattered less
than the last-reset clock the previous commit fixed - 44 waits do not
change shape in a week - but it is still a record days behind a tracker
that learns of a reset in two minutes.

getagentseal#725 exception granted by the maintainer on 2026-09-13: a client-side,
first-party, conditional fetch of the reset-history dataset from this
repository on GitHub, at most hourly, carrying no user data; no request
is ever made to codex-reset.com or any third party from the client.

The workflow now publishes twice. Every 30 minutes it pushes the dataset
to `data/codex-reset-history`, which is what installed clients read.
Weekly it opens or updates the pull request to main, which keeps the copy
bundled in releases recent - the offline fallback, and the floor a fetch
can only improve on. It still never pushes to main, and it has no `push`
trigger, so pushing the data branch cannot retrigger it.

The client side, and the reasons for each choice:

- `api.github.com`, because `UpdateChecker` already contacts it every two
  days. `raw.githubusercontent.com` would have been a host this app has
  never spoken to, so the contents API is used instead with the raw media
  type.
- A conditional GET with the stored ETag. The common answer is a 304 with
  no body: one request against the unauthenticated 60-per-hour limit.
- At most once an hour, on the refresh that already runs. No new timer.
  The attempt is detached, so nothing in the UI waits on a network call,
  and it only happens while Codex is connected.
- No credential, no cookie, no account id, no plan, no usage. An Accept,
  a product User-Agent, and an ETag when there is one. Cookies are
  refused explicitly.
- A fetched record is validated against the same rules the workflow
  applies - schema, monotonic timestamps, the two event types, and
  nothing beyond the four fields - and adopted only if it is strictly
  newer than what is held. Validation runs on the raw bytes, not the
  decoded value, because `Decodable` silently drops keys it does not know
  and a record carrying post text would otherwise decode cleanly.
- Every failure is silence: offline, rate limited, 500, timeout, a
  malformed body, a body that validates but is older. `Retry-After`
  pushes the next attempt out rather than sleeping. The bundled record is
  always there, so the forecast is never worse than it was before this
  existed.

The switch is in the consolidated notifications section and defaults on,
unlike the forecast notice: this makes a number already on screen more
accurate rather than adding an interruption. The CLI honours
`CODEBURN_PRICING_SNAPSHOT_ONLY`, the knob that already pins pricing to
its bundled snapshot, rather than inventing a second offline switch, and
`codeburn quota` now prints which copy it read and when that copy was
built.

The set of hosts this client contacts is unchanged: api.github.com, and
nothing else. codex-reset.com is contacted only by the Action.
…tion

"Refresh reset history from GitHub" is a data-refresh switch, not a
notification, and it was sitting under Notifications because that is
where the consolidation on main put everything Codex-shaped. A reviewer
would flag it, and rightly.

It moves to its own "Reset Forecast Data" section, which is also the
natural place for the fact a user actually wants when they look at that
switch: which copy of the record is in use and when it was built.
`CodexResetForecastPresentation.datasetLine` produces that line, in the
same words and the same raw ISO instant `codeburn quota` prints, so the
two surfaces cannot disagree about which record is feeding the number. An
undated record still says which copy it is rather than rendering an empty
date.

The opt-in notice toggle and its threshold picker stay in Notifications,
where they belong: those do post notifications.

The preference key is unchanged, so nothing migrates and nobody's setting
moves.
**macOS `swift test` did not compile.** Three fake transports in
`CodexResetForecastTests.swift` mutated a captured `var` from inside a
`@Sendable` closure, which Swift 6 rejects outright. They record through
an actor now.

Worth saying why this got past me: `swift test` cannot run on this host
(CLT-only, no `Testing` module), so `swift build` never compiles the test
target and the whole class of bug is invisible locally. The harness now
builds with `-strict-concurrency=complete` and is itself clean under it,
so the next one is caught before push rather than by CI.

**vitest hung for the full 30s on every Node leg.** "Survives an
unwritable cache directory" pointed at `/proc/definitely/not/writable`,
which was a bad idea: that path means something different on every
platform, and on the Linux runner it did not fail - it never returned.

Two fixes, because both were wrong.

The production path is now unstallable. Cache reads and writes are
bounded by `CACHE_IO_TIMEOUT_MS` and fall back to "no cache", so a
read-only mount, a synthetic filesystem or a stalled network mount cannot
wedge a command - and `codeburn quota` is one the macOS menubar blocks
on. The timer is unref'd so it can never hold a CLI process open.

The test no longer relies on a magic path. It creates a directory this
user genuinely cannot write to, having first established that the
precondition holds - root ignores mode bits and Windows has none - and
skips with a reason when it does not, the way the permission tests in
`tests/cache-refresh-lock.test.ts` guard. It asserts the observable
outcome: an answer, from the fetch, with no throw, in under the cache
deadline. It finishes in milliseconds.
getagentseal#1330, the menubar i18n work, landed: `defaultLocalization`, en and
zh-Hans catalogs, a language picker, and a scanner test that reads the
source and fails when a user-facing literal never reaches the catalog.

One conflict, `mac/Package.swift`, resolved as a union: the dataset's
`.copy("Resources/CodexResetHistory")` and the two `.process` entries for
the `.lproj` bundles all stay, and `defaultLocalization: "en"` is kept.
The two declarations are independent - one carries data the model reads,
the others carry strings NSBundle resolves per localization.

What the merge actually implied was the rest of this commit. Main's
scanner found seven user-facing literals in the forecast's Settings
section; the forecast sentences, the notification copy and the dataset
line were not literals at a call site, so the scanner did not see them,
but they are just as visible to a zh-Hans user. All of it now goes
through `L(...)`, with 29 keys added to both catalogs.

Points worth reviewing rather than skimming:

- **The English output is byte-identical**, which is why the
  character-for-character parity checks against `src/reset-forecast.ts`
  still pass. In `en` the key IS the copy, so `L()` is the identity
  there. Parity is an `en` property and is documented as one: the CLI is
  English-only by the catalog's own stated policy.
- **No `yes`/`no` key.** The working-hours tail would have made one, and
  a translator handed "yes" has no sentence to work with. There are two
  whole-sentence keys instead, differing only in that word.
- **No pluralising suffix.** "wait"/"waits" was built with a `\(plural)`
  hole; that is an English rule. Two keys now.
- **The full stop rides the last clause** (`low confidence.`), so no bare
  "." key exists for anyone to guess at.
- **The notification body is one literal, not a `+` chain.** The scanner
  reads source rather than running it, so a concatenated key is only
  half-visible to the tooling meant to prove every key is translated -
  it reported exactly that, and it was right.
- **Specifier order is preserved in Chinese.** The first draft read more
  naturally by putting the elapsed time after the reset it counts from,
  which silently swaps two `%@` and would have substituted the wrong way
  round. The zh strings are phrased to keep English argument order.

Verified against main's own tooling: `LocalizationSourceScanner` reports
zero unrouted literals across all of `mac/Sources`, every key the sources
request resolves in the catalog, and the catalog rules the suite checks -
matching key sets, English identity, argument-specifier order, literal
`%%` counts, no empty values, no key that is only specifiers - all hold.

The Settings picker did not move the sections; "Reset Forecast Data"
still sits between Notifications and Terminal.
@ozymandiashh

Copy link
Copy Markdown
Collaborator Author

Heads-up for review order: the live "since the last reset" clock in this PR reads Codex early-reset events from the #1329 detector, which until now only ever watched Claude. #1339 extends the detector to every provider with quota windows (Claude and Codex announce today; the others are wired but stay silent until their adapters report a window duration). Without #1339 the forecast still works on the global record and on banked-credit events, but its local early-reset input stays dormant for Codex. Merge order that makes the clock fully live: #1339, then this.

Main's "no catalog entry is dead weight" test reported the two
whole-sentence forecast keys as orphaned. They exist in both catalogs;
the problem was the call site. The variant was picked into a `let
template` by a ternary over strings and then handed to `L(template, ...)`,
and the localization scanner reads source rather than running it, so it
could not see either string as a literal argument of `L(...)`.

Each variant is now written out in full as the first argument of its own
`L(...)` call, on either side of the ternary, with the seven arguments
hoisted into named locals so the two calls stay identical apart from the
key. Specifier order is unchanged, so the catalog entries and the English
output are exactly as before.

Checked the rest of this branch for the same shape: no other `L(` in the
Swift it touches takes a non-literal first argument.

Verified by running both directions of main's coverage logic through its
own `LocalizationSourceScanner`: zero unrouted user-facing literals, zero
catalog keys without a literal call site, zero requested keys without a
catalog entry, and the catalog's 656 keys match the 656 the sources
request exactly.
@iamtoruk

Copy link
Copy Markdown
Member

Closing. 6,200 lines and two parallel implementations for a forecast whose own backtest (Brier 0.1324 against a 0.1288 base rate) does not beat guessing. The always-show-a-range framing is the right instinct. If the model earns real skill on more data, a CLI-only line at a tenth of this size is the shape I would review.

@iamtoruk iamtoruk closed this Sep 13, 2026
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