diff --git a/devlog/_plan/260904_main_card_badge_parity/000_evidence.md b/devlog/_plan/260904_main_card_badge_parity/000_evidence.md
new file mode 100644
index 0000000000..9992d11d42
--- /dev/null
+++ b/devlog/_plan/260904_main_card_badge_parity/000_evidence.md
@@ -0,0 +1,111 @@
+# 000 — Evidence: main account card is missing two badges
+
+Unit: `260904_main_card_badge_parity`
+Opened: 2026-09-04
+Branch base: `dev` @ `8b60e4c44`
+
+## Reported symptom
+
+On the Codex Auth dashboard the MAIN account card shows neither the plan badge
+(`pro`) nor the reset-credit ticket badge, while every pool card shows both.
+
+## Live evidence (read-only, port 10100)
+
+`GET /api/codex-auth/accounts` at 2026-09-04, main entry:
+
+```json
+{"id":"__main__","email":"k***1@gmail.com","plan":"pro","isMain":true,
+ "quota":{"weeklyPercent":28,"weeklyResetAt":1788749167,"updatedAt":1788490155601}}
+```
+
+A pool entry from the same response:
+
+```json
+{"id":"chatgpt-1786626108327","plan":"pro",
+ "quota":{"updatedAt":1788490159314,"weeklyPercent":26,"weeklyResetAt":1788748127,"resetCredits":2}}
+```
+
+On-disk cache `~/.opencodex/codex-quota-cache.json`, `__main__` entry:
+
+```json
+{"updatedAt":1788490155601,"weeklyPercent":28,"weeklyResetAt":1788749167,
+ "customWindows":[{"label":"GPT-5.3-Codex-Spark Weekly","percent":0,"resetAt":1789094955}],
+ "resetCredits":1}
+```
+
+So the store HAS `resetCredits: 1` for the main account, and the response DTO
+drops it. That is the whole of defect 2.
+
+## Two independent defects
+
+**D1 — plan badge absent from the main card markup.** The server sends
+`plan: "pro"`. `gui/src/components/codex-account-pool-cards.tsx:91` renders
+`{a.plan && {a.plan}}` inside
+`card-badges`. The equivalent block in
+`gui/src/components/codex-account-pool-main-card.tsx:87-99` has no such line.
+Purely a missing element; no data problem.
+
+**D2 — resetCredits never reaches the main DTO.**
+`CodexTicketBadge` (`codex-account-pool-helpers.tsx:28-51`) returns `null`
+when `account.quota` is non-null but `quota.resetCredits === undefined`. The
+main card passes `{...main, id:"__main__"}`, so it inherits whatever the DTO
+carries — and the DTO carries no `resetCredits`.
+
+## Why the two DTO paths diverge
+
+Pool path, `src/codex/auth-api.ts`:
+
+- `commitPoolQuotaResponse` writes the parsed snapshot with
+ `setAccountQuotaFromParsed(accountId, quota, writerGeneration)` (line 1195)
+ and then returns `quota: getAccountQuota(accountId)` (line 1197) — it reads
+ the value **back out of the merged store**.
+- `poolAccountDto` (line 277) serializes that store-read object, so it carries
+ every field the store merged, including a `resetCredits` that arrived on an
+ earlier partial snapshot.
+
+Main path, same file:
+
+- `fetchMainAccountInfoWhileOwned` (line 807+) parses the same WHAM payload,
+ mirrors it into the store with `setAccountQuotaFromParsed(MAIN_CODEX_ACCOUNT_ID, ...)`
+ (line 860) — and then caches and returns `result.quota`, the **pre-merge parse
+ result**, not the store value.
+- `listCodexAuthAccountsSnapshot` (line 1632-1647) builds the main DTO from
+ `mainInfo.quota`, spreading it and patching in only `updatedAt` from
+ `getAccountQuota(MAIN_CODEX_ACCOUNT_ID)`:
+
+```ts
+quota: mainInfo.quota ? {
+ ...quotaForPlan({
+ ...mainInfo.quota,
+ updatedAt: getAccountQuota(MAIN_CODEX_ACCOUNT_ID)?.updatedAt ?? Date.now(),
+ }, mainInfo.plan),
+} : null,
+```
+
+That reaches into the store for exactly one field. Every other merged field —
+`resetCredits` above all — is lost whenever the current `/wham/usage` response
+omits `rate_limit_reset_credits.available_count`.
+
+## Why the current response omits it
+
+`parseUsageQuota` (`src/codex/quota.ts:561`) only sets `resetCredits` when the
+payload carries `rate_limit_reset_credits.available_count`. `/wham/usage`
+includes that summary inconsistently, and the dedicated
+`/wham/rate-limit-reset-credits` endpoint is separately rate limited — a live
+probe for `__main__` returned `{"error":"Upstream error 429"}`. The store is
+specifically designed to survive that: `setAccountQuotaFromParsed`
+(`quota.ts:339-340`) carries `existing.resetCredits` forward when the new
+snapshot omits it. The pool DTO benefits from that carry-forward because it
+re-reads the store. The main DTO does not, because it does not.
+
+This also matches upstream Codex, where `RateLimitsWithResetCredits`
+(`codex-rs/backend-client/src/types.rs:45-48`) models the reset-credit summary
+as `Option` alongside rate limits rather than as a field guaranteed on every
+usage read.
+
+## Conclusion
+
+D2 is a server-layer bug, not a GUI bug: the main account is the only account
+whose DTO bypasses the merged quota store. Fixing it in the GUI (for example by
+reading `/api/codex-auth/quota` separately) would paper over an asymmetry that
+also affects any other consumer of the main DTO.
diff --git a/devlog/_plan/260904_main_card_badge_parity/010_server_dto_parity.md b/devlog/_plan/260904_main_card_badge_parity/010_server_dto_parity.md
new file mode 100644
index 0000000000..f5a223bb55
--- /dev/null
+++ b/devlog/_plan/260904_main_card_badge_parity/010_server_dto_parity.md
@@ -0,0 +1,210 @@
+# 010 — Phase 1: main-account DTO reads the merged quota store
+
+Work phase: `wp1`. Depends on: nothing. Consumed by: `020`.
+
+## Goal
+
+The main account's DTO quota must be the same merged store object a pool
+account's DTO quota is, so every field the store carries forward (today
+`resetCredits`, tomorrow anything else) reaches the dashboard.
+
+## Scope boundary
+
+IN: `src/codex/auth-api.ts` main DTO construction; a focused test in
+`tests/codex-auth-api.test.ts`.
+OUT: `src/codex/quota.ts` merge semantics (already correct), the pool path,
+any WHAM fetch/refresh policy, credential handling.
+
+## File change map
+
+### `src/codex/auth-api.ts` — `listCodexAuthAccountsSnapshot`, main DTO (~line 1642)
+
+Before:
+
+```ts
+quota: mainInfo.quota ? {
+ ...quotaForPlan({
+ ...mainInfo.quota,
+ updatedAt: getAccountQuota(MAIN_CODEX_ACCOUNT_ID)?.updatedAt ?? Date.now(),
+ }, mainInfo.plan),
+} : null,
+```
+
+After:
+
+```ts
+quota: mainInfo.quota ? {
+ ...quotaForPlan(mergeMainQuotaWithStore(mainInfo.quota), mainInfo.plan),
+} : null,
+```
+
+with a small local helper next to the DTO builders:
+
+```ts
+/**
+ * The main account is the only account whose DTO quota came from the raw parse
+ * result rather than the merged store, so a resetCredits the store had carried
+ * forward vanished from the response whenever the current /wham/usage payload
+ * omitted `rate_limit_reset_credits`. Pool DTOs never had that hole because
+ * commitPoolQuotaResponse re-reads getAccountQuota() after committing.
+ *
+ * Only resetCredits is filled from the store, deliberately. The window fields
+ * have *clearing* semantics -- a monthly-only snapshot must drop a stale weekly
+ * value (#382) -- so a blanket spread of the stored object would resurrect a
+ * window the parse intended to clear whenever the store write was refused by
+ * generation gating. resetCredits is the one field setAccountQuotaFromParsed
+ * itself carries forward (quota.ts:339-340), so mirroring exactly that rule
+ * here keeps the DTO consistent with the store instead of inventing a second,
+ * looser merge policy.
+ */
+function mainQuotaWithStoredResetCredits(
+ parsed: Omit,
+): StoredAccountQuota {
+ const stored = getAccountQuota(MAIN_CODEX_ACCOUNT_ID);
+ return {
+ ...parsed,
+ ...(parsed.resetCredits === undefined && stored?.resetCredits !== undefined
+ ? { resetCredits: stored.resetCredits }
+ : {}),
+ updatedAt: stored?.updatedAt ?? Date.now(),
+ };
+}
+```
+
+Call site becomes `quotaForPlan(mainQuotaWithStoredResetCredits(mainInfo.quota), mainInfo.plan)`.
+
+Precedence rationale: a freshly parsed `resetCredits` always wins, including a
+deliberate `0` (0 is defined, so it is present in `parsed` and the fill branch
+does not run). The store supplies the value only when the parse omitted the key
+entirely. Every other field is untouched, so no window-clearing behaviour
+changes.
+
+### Audit finding folded in (blocker 1)
+
+The first draft of this document proposed `{ ...stored, ...parsed }`. That is
+unsafe: `setAccountQuotaFromParsed` refuses to commit when
+`mayCommitAccountQuota` fails generation gating (`quota.ts:280`), so the store
+can legitimately hold a PRE-clear snapshot while `parsed` is monthly-only. The
+blanket spread would then re-introduce the stale `weeklyPercent` that #382
+exists to clear, and it would show up as a phantom weekly bar on the main card.
+Narrowing the merge to `resetCredits` removes that failure mode entirely.
+
+### Identity-change safety (corrected — audit blocker 3)
+
+The first two drafts claimed a swapped identity "cannot leak" a previous
+account's credits. **That claim was wrong**, and the second reviewer
+(muse-spark-1.3-contributor) refuted it with the exact path:
+
+- In-process swaps ARE safe: `reconcileMainCodexAccountRuntimeState`
+ (`account-lifecycle.ts:60-70`) purges alias-keyed `__main__` quota when it
+ observes the account id change, and `mainSnapshotLive === false` forces
+ `EMPTY_MAIN_ACCOUNT_INFO`, whose null quota short-circuits the DTO guard.
+- Across a RESTART it is not. `observedMainChatgptAccountId`
+ (`account-lifecycle.ts:21`) is memory-only, and the first observation after a
+ restart hits the `previousAccountId === undefined` early return with no purge
+ (`:67`). If `~/.codex/auth.json` was swapped while the proxy was down, the
+ disk-hydrated `__main__` quota entry still belongs to the PREVIOUS login, and
+ a store-based fill would print its ticket count on the new account's card.
+ Pool accounts never have this hole because their store key is the account id
+ itself; `__main__` is an alias.
+
+Fix: do not read the fill value from the store at all. Keep an in-process,
+identity-tagged observation of the last parsed count
+(`mainResetCreditsProvenance = { accountId, credits }`), recorded in
+`fetchMainAccountInfoWhileOwned` next to the existing `freshResetCredits`, and
+return it only when `getMainChatgptAccountId()` still matches. A restart simply
+starts with no observation, so the badge waits for the first response that
+carries the summary rather than showing a stale or foreign number.
+
+```ts
+let mainResetCreditsProvenance: { accountId: string; credits: number } | null = null;
+
+function mainResetCreditsForCurrentIdentity(): number | undefined {
+ if (!mainResetCreditsProvenance) return undefined;
+ const currentAccountId = getMainChatgptAccountId();
+ if (currentAccountId === null) return undefined;
+ if (currentAccountId !== mainResetCreditsProvenance.accountId) {
+ mainResetCreditsProvenance = null;
+ return undefined;
+ }
+ return mainResetCreditsProvenance.credits;
+}
+```
+
+`updatedAt` still comes from the store, unchanged from today's behaviour.
+
+### Consume-route interaction (audit question Q2c)
+
+`auth-api.ts:2135` deliberately refuses to report a preserved cached
+`resetCredits` as the consume response's `remaining`. That governs a
+*transactional* claim about a just-executed redeem and is a different guarantee
+from best-effort display state, so the DTO carry does not violate it. The real
+overlap, recorded rather than fixed: if the forced post-consume refresh omits
+the summary, the main card keeps showing the pre-consume count until the next
+response that carries it — exactly the staleness every pool card already has.
+
+### Upstream omission semantics (residual, non-blocking)
+
+If upstream ever omits `rate_limit_reset_credits` to MEAN zero, a carried
+non-zero would persist until the next explicit reading. Nothing in this
+repository settles that question, the risk is pre-existing in the store merge,
+and it is shared with every pool card. Named here rather than guessed at.
+
+### quotaForPlan interaction
+
+`quotaForPlan` already forwards `resetCredits` for 30-day plans
+(`auth-api.ts:272`) and `withSparkVisibility` only filters `customWindows`,
+which this helper does not touch. No change needed in either.
+
+Note on `quotaForPlan`: it already passes `resetCredits` through for 30-day
+plans (`auth-api.ts:272`), so no change is needed there.
+
+## Accept criteria
+
+1. Given a main WHAM parse result without `resetCredits` and a store entry for
+ `__main__` holding `resetCredits: 1`, the main DTO carries `resetCredits: 1`.
+2. Given a parse result WITH `resetCredits: 0` and a store entry holding
+ `resetCredits: 3`, the DTO carries `0` — fresh wins, including zero.
+3. Given no store entry, the DTO is byte-identical to today's output.
+4. `updatedAt` behaviour is unchanged (store value, else now).
+5. Window fields are never taken from the store: a monthly-only parse with a
+ stored weekly value still produces a DTO without `weeklyPercent`.
+6. A carried count is dropped when the physical main account id changes.
+
+### Activation scenario for the conditional path
+
+The new helper's store branch only runs when `getAccountQuota("__main__")`
+returns an entry. The test triggers it by calling
+`setAccountQuotaFromParsed(MAIN_CODEX_ACCOUNT_ID, { resetCredits: 1 })` before
+listing accounts, and proves it ran by asserting `resetCredits` is present in
+the returned DTO where it is absent today.
+
+## Verifier
+
+`bun test tests/codex-auth-api.test.ts` — this file already exercises the main
+DTO and the `__main__` quota store (it references
+`getAccountQuota(MAIN_CODEX_ACCOUNT_ID)?.resetCredits` at lines 2631, 2668,
+2706), so it observes the change target directly.
+
+## Field chain (PLAN-FIELD-CHAIN-01)
+
+`resetCredits: number | undefined` is not a new field; this phase changes which
+object the DTO reads. Chain for completeness:
+
+- creation: `parseUsageQuota` (`quota.ts:562`) from
+ `rate_limit_reset_credits.available_count`; also
+ `updateAccountQuota(..., resetCredits)` (`quota.ts:473`).
+- store merge: `setAccountQuotaFromParsed` (`quota.ts:294, 339-340`).
+- serialization: `poolAccountDto` (store-read) and the main DTO (this fix).
+- deserialization: `hydrateAccountQuotasFromDisk` reads
+ `codex-quota-cache.json`; N/A for the DTO, which is response-only.
+- consumers: `CodexTicketBadge` (`gui/.../codex-account-pool-helpers.tsx:29`),
+ `src/cli/account-auth.ts`, the reset-credit consume route
+ (`auth-api.ts:2135`).
+
+## Bypass record (PLAN-BYPASS-NAMED-01)
+
+This phase adds no enforcement. Tier: n/a. Executing surface: n/a. Known bypass:
+n/a. Residual risk: a future main DTO rewrite could reintroduce the raw-parse
+read; the regression test in criterion 1 is the early warning. Final enforcement
+layer: none.
diff --git a/devlog/_plan/260904_main_card_badge_parity/020_gui_plan_badge.md b/devlog/_plan/260904_main_card_badge_parity/020_gui_plan_badge.md
new file mode 100644
index 0000000000..3671a780f4
--- /dev/null
+++ b/devlog/_plan/260904_main_card_badge_parity/020_gui_plan_badge.md
@@ -0,0 +1,60 @@
+# 020 — Phase 2: main card renders the plan badge
+
+Work phase: `wp2`. Depends on: `010` (only for the ticket badge to have data;
+the plan badge itself is independent).
+
+## Goal
+
+The main account card shows its plan as a badge, exactly as pool cards do.
+
+## Scope boundary
+
+IN: `gui/src/components/codex-account-pool-main-card.tsx` badge row.
+OUT: card layout, the skeleton block, pool card markup, styling changes.
+
+## File change map
+
+### `gui/src/components/codex-account-pool-main-card.tsx` (~line 87)
+
+Inside ``, as the FIRST child — matching the pool
+card's order at `codex-account-pool-cards.tsx:91` so both cards read
+plan → paused → priority → pinned → ticket → health:
+
+```tsx
+{main?.plan && {main.plan}}
+```
+
+The existing ticket badge line moves after the pinned badge so the two cards
+share one badge order. Nothing else in the row changes.
+
+### Skeleton parity (`~line 279`) — decision recorded
+
+The load skeleton reserves a ticket slot and a `badge-primary` slot. The
+question was whether the new plan badge needs a matching muted strut.
+
+**Decision: no strut.** The skeleton already omits the priority and pinned
+badges that the ready state can render, so approximate width parity is the
+established norm for this card rather than a regression introduced here. Adding
+a strut for the plan badge alone would make the skeleton wider than the common
+ready state (an account with no plan renders no badge at all), trading one
+small shift for a different one. Recorded per the audit's blocker 2, which
+asked for the strut or a stated reason.
+
+## Accept criteria
+
+1. With `main.plan === "pro"`, the rendered main card contains
+ `pro`.
+2. With `main.plan` undefined, no plan badge element is rendered (no empty span).
+3. Badge order in the main card matches the pool card.
+
+## Verifier
+
+Rendered-DOM observation on the running dashboard (C-RENDER-GROUNDING-01) plus
+`bun run lint:gui` and `bun run typecheck`. There is no existing GUI unit-test
+harness for this component, so the DOM observation is the acceptance evidence and
+that is recorded rather than claimed as a gate.
+
+## Bypass record
+
+No enforcement added. Final enforcement layer: none; the visual check is human/DOM
+observation.
diff --git a/devlog/_plan/260904_main_card_badge_parity/030_verification_and_pr.md b/devlog/_plan/260904_main_card_badge_parity/030_verification_and_pr.md
new file mode 100644
index 0000000000..a55b4df00e
--- /dev/null
+++ b/devlog/_plan/260904_main_card_badge_parity/030_verification_and_pr.md
@@ -0,0 +1,43 @@
+# 030 — Phase 3: verification and pull request
+
+Work phase: `wp3`. Depends on: `010`, `020`.
+
+## Goal
+
+Prove both fixes on the running dashboard, pass the repository gates, and open a
+PR against `dev` that satisfies the repository's own CI gates.
+
+## Steps
+
+1. `bun run typecheck` — expect exit 0.
+2. `bun test tests/codex-auth-api.test.ts` — expect exit 0, new case passing.
+3. `bun run lint:gui` — expect exit 0.
+3b. `bun run privacy:scan` — expect exit 0 (AGENTS.md CI gate; added after audit
+ blocker 3 noted it was missing from this list).
+3c. Docs-site evaluation: this change restores badge parity that the dashboard
+ documentation already describes generically; record "no docs-site change
+ needed" in the PR unless a page names the missing badges explicitly.
+4. `bun run build:gui`, restart the local proxy from this checkout, load the
+ dashboard, and capture a screenshot of the main card showing BOTH badges.
+ The proxy on port 10100 is the user's live service: restart it only through
+ the normal `ocx` service path already used for source dogfooding, and verify
+ `/healthz` afterwards.
+5. `bun run test` — full suite, required before marking the PR review-ready
+ (AGENTS.md PR-ready gate).
+6. Branch `codex/260904-main-card-badge-parity`, commit per phase, push, open a
+ PR targeting `dev` with all three template sections and the screenshot
+ (`enforce-target` rejects a gui PR without one).
+7. `gh pr checks` at the exact head SHA; merge only on a green rollup.
+
+## Accept criteria
+
+- Screenshot shows `pro` badge and ticket badge on the main card.
+- Full suite and typecheck exit 0 at the PR head SHA.
+- `git merge-base --is-ancestor origin/dev` succeeds after merge.
+
+## Bypass record
+
+CI gates here are repository-owned (E8, GitHub Actions). Known bypass:
+`--no-verify` on local hooks does not bypass branch protection on `dev`.
+Residual risk: none beyond maintainer merge authority. Final layer: branch
+ruleset on `dev`.
diff --git a/devlog/_plan/260904_main_card_badge_parity/040_promotion_and_release.md b/devlog/_plan/260904_main_card_badge_parity/040_promotion_and_release.md
new file mode 100644
index 0000000000..eacd2c3b18
--- /dev/null
+++ b/devlog/_plan/260904_main_card_badge_parity/040_promotion_and_release.md
@@ -0,0 +1,34 @@
+# 040 — Phase 4: promotion to preview and main, release
+
+Work phase: `wp4`. Depends on: `030`.
+
+## Goal
+
+Promote the merged `dev` state to `preview` and `main` and cut a release, as
+the user explicitly authorized ("main preview 머지후 릴리즈까지 진행").
+
+## Steps
+
+1. Re-read `MAINTAINERS.md` and `scripts/release.ts` before acting; the release
+ script is the release authority and is security-reviewed surface — do not edit
+ it.
+2. Confirm `dev` carries the merge commit and CI is green at that exact SHA.
+3. Promote `dev` → `preview`, then `dev` → `main`, using the repository's
+ established promotion path (pull request or maintainer promotion as
+ `MAINTAINERS.md` prescribes; branch rulesets forbid direct pushes).
+4. Cut the release through `scripts/release.ts`.
+5. Verify: ancestry proof for both branches, the release run/tag, and the running
+ proxy's `/healthz` version after upgrade.
+
+## Escalation
+
+If promotion or the release requires an approval the user has not delegated, or
+the release script asks for a credential this session must not spend, stop and
+report `NEEDS_HUMAN` with the exact blocking step rather than improvising.
+
+## Bypass record
+
+Tier: E8 (branch rulesets + release workflow). Executing surface: GitHub Actions
+and branch protection. Known bypass: none available to this session. Residual
+risk: a maintainer could promote manually. Final layer: branch ruleset on
+`main`/`preview`.
diff --git a/devlog/_plan/260904_main_card_badge_parity/evidence/main-card-badges.png b/devlog/_plan/260904_main_card_badge_parity/evidence/main-card-badges.png
new file mode 100644
index 0000000000..50e06f9ee8
Binary files /dev/null and b/devlog/_plan/260904_main_card_badge_parity/evidence/main-card-badges.png differ
diff --git a/gui/src/components/codex-account-pool-main-card.tsx b/gui/src/components/codex-account-pool-main-card.tsx
index e42dc9536c..dba055fa5a 100644
--- a/gui/src/components/codex-account-pool-main-card.tsx
+++ b/gui/src/components/codex-account-pool-main-card.tsx
@@ -85,7 +85,7 @@ export function CodexAccountPoolMainCard({
{t("codexAuth.mainAccount")}
- {main && onOpenReset({ ...main, id: "__main__" } as CodexAccountEntry)} />}
+ {main?.plan && {main.plan}}
{main?.paused && (
{t("codexAuth.paused")}
@@ -93,6 +93,7 @@ export function CodexAccountPoolMainCard({
)}
{pinnedId === "__main__" && !main?.paused && {t("codexAuth.pinned")}}
+ {main && onOpenReset({ ...main, id: "__main__" } as CodexAccountEntry)} />}
{healthLabel && (
{healthLabel}
)}
diff --git a/src/codex/auth-api.ts b/src/codex/auth-api.ts
index 5fc6dcd657..36295027e9 100644
--- a/src/codex/auth-api.ts
+++ b/src/codex/auth-api.ts
@@ -274,6 +274,69 @@ function quotaForPlan | StoredAc
} as T;
}
+/**
+ * Last reset-credit count this process parsed for the main account, tagged with the
+ * physical ChatGPT account it was read from.
+ *
+ * It is deliberately memory-only. The quota store is keyed by the stable `__main__`
+ * ALIAS, and `~/.codex/auth.json` can be swapped for another account while the proxy is
+ * not running — `reconcileMainCodexAccountRuntimeState` only purges alias-keyed state
+ * when it observes the id CHANGE, and its first observation after a restart has nothing
+ * to compare against. A disk-hydrated `__main__` entry can therefore belong to the
+ * previous login, so filling the DTO from it would show one account's tickets on
+ * another's card. Pool accounts have no such hole because their store key IS the account
+ * id. Binding the value to `requestAccountId` keeps the fill honest: after a restart the
+ * badge simply waits for the first usage response that carries the summary.
+ */
+let mainResetCreditsProvenance: { accountId: string; credits: number } | null = null;
+
+function rememberMainResetCredits(accountId: string | null, credits: number | undefined): void {
+ if (accountId === null || credits === undefined) return;
+ mainResetCreditsProvenance = { accountId, credits };
+}
+
+/** Forget the remembered count when the physical main identity is no longer the same. */
+function mainResetCreditsForCurrentIdentity(): number | undefined {
+ if (!mainResetCreditsProvenance) return undefined;
+ const currentAccountId = getMainChatgptAccountId();
+ if (currentAccountId === null) return undefined;
+ if (currentAccountId !== mainResetCreditsProvenance.accountId) {
+ mainResetCreditsProvenance = null;
+ return undefined;
+ }
+ return mainResetCreditsProvenance.credits;
+}
+
+/**
+ * The main account is the only account whose DTO quota comes from the raw WHAM parse
+ * result instead of the merged store: `poolAccountDto` serializes what
+ * `commitPoolQuotaResponse` read back out of `getAccountQuota()`, while the main DTO
+ * spreads `mainInfo.quota` directly. `/wham/usage` carries `rate_limit_reset_credits`
+ * only intermittently, and the store exists to bridge that gap
+ * (`setAccountQuotaFromParsed` carries an existing `resetCredits` forward when the new
+ * snapshot omits it), so the main card lost its ticket badge on every response that
+ * happened to omit the summary while pool cards kept theirs.
+ *
+ * Only `resetCredits` is carried, deliberately, and only from an identity-tagged
+ * in-process observation rather than the alias-keyed store. The window fields have
+ * *clearing* semantics — a monthly-only snapshot must drop a stale weekly value (#382) —
+ * so reinstating the whole stored object would resurrect a window the parse meant to
+ * clear whenever the store write was refused by generation gating. A freshly parsed value
+ * always wins, including `0`: zero is defined, so it never takes the fill branch.
+ */
+function mainQuotaWithCarriedResetCredits(
+ parsed: Omit,
+): StoredAccountQuota {
+ const carried = parsed.resetCredits === undefined
+ ? mainResetCreditsForCurrentIdentity()
+ : undefined;
+ return {
+ ...parsed,
+ ...(carried !== undefined ? { resetCredits: carried } : {}),
+ updatedAt: getAccountQuota(MAIN_CODEX_ACCOUNT_ID)?.updatedAt ?? Date.now(),
+ };
+}
+
function poolAccountDto(
account: CodexAccount,
quotaResult: PoolQuotaResult,
@@ -836,6 +899,9 @@ async function fetchMainAccountInfoWhileOwned(
const plan = nonEmptyPlan(data.plan_type) ?? nonEmptyPlan(cached?.plan) ?? nonEmptyPlan(getMainAccountPlan());
const quota = parseUsageQuota({ ...data, ...(plan ? { plan_type: plan } : {}) });
const freshResetCredits = quota?.resetCredits;
+ // Tag the count with the identity it was read from, so a later response that omits the
+ // summary can restore the badge without ever crossing an account boundary.
+ rememberMainResetCredits(requestAccountId, freshResetCredits);
const result = {
email: data.email ?? null,
plan,
@@ -1640,10 +1706,7 @@ export async function listCodexAuthAccountsSnapshot(
hasCredential: hasMainCredential,
needsReauth: mainNeedsReauth,
quota: mainInfo.quota ? {
- ...quotaForPlan({
- ...mainInfo.quota,
- updatedAt: getAccountQuota(MAIN_CODEX_ACCOUNT_ID)?.updatedAt ?? Date.now(),
- }, mainInfo.plan),
+ ...quotaForPlan(mainQuotaWithCarriedResetCredits(mainInfo.quota), mainInfo.plan),
} : null,
...oauthAccountHealthFields("codex", MAIN_CODEX_ACCOUNT_ID, mainHealth),
};
diff --git a/tests/codex-auth-api.test.ts b/tests/codex-auth-api.test.ts
index b3a9b67abc..5aa534fbe9 100644
--- a/tests/codex-auth-api.test.ts
+++ b/tests/codex-auth-api.test.ts
@@ -2600,6 +2600,115 @@ describe("codex-auth API", () => {
}
});
+ test("the main account DTO keeps its resetCredits when a later WHAM usage omits the summary", async () => {
+ // /wham/usage carries rate_limit_reset_credits only intermittently. Pool DTOs survive
+ // that because they re-read the merged store; the main DTO used to serialize the raw
+ // parse result, so the ticket badge disappeared on every response that omitted it.
+ writeFileSync(join(TEST_CODEX_HOME, "auth.json"), JSON.stringify({
+ tokens: { access_token: "main-dto-credits", account_id: "acct-main-dto-credits" },
+ }));
+ reconcileMainCodexAccountRuntimeState();
+ const originalFetch = globalThis.fetch;
+ let includeCredits = true;
+ try {
+ globalThis.fetch = (async (input: RequestInfo | URL) => {
+ const url = String(input);
+ if (url.includes("/backend-api/wham/usage")) {
+ return Response.json({
+ email: "main@example.test",
+ plan_type: "pro",
+ rate_limit: { primary_window: { used_percent: 28, reset_at: 1788749167 } },
+ ...(includeCredits ? { rate_limit_reset_credits: { available_count: 1 } } : {}),
+ });
+ }
+ return originalFetch(input);
+ }) as typeof fetch;
+
+ const first = await listCodexAuthAccounts(makeConfig(), true);
+ expect(first.find(a => a.id === MAIN_CODEX_ACCOUNT_ID)!.quota?.resetCredits).toBe(1);
+
+ includeCredits = false;
+ const second = await listCodexAuthAccounts(makeConfig(), true);
+ const main = second.find(a => a.id === MAIN_CODEX_ACCOUNT_ID)!;
+ expect(main.quota?.resetCredits).toBe(1);
+ expect(main.quota?.weeklyPercent).toBe(28);
+ } finally {
+ globalThis.fetch = originalFetch;
+ }
+ });
+
+ test("the main account DTO never carries resetCredits across a main identity change", async () => {
+ // `__main__` is an alias: ~/.codex/auth.json can be swapped for another physical
+ // ChatGPT account, so a carried ticket count must be bound to the identity it was read
+ // from or one account's credits show up on another's card.
+ writeFileSync(join(TEST_CODEX_HOME, "auth.json"), JSON.stringify({
+ tokens: { access_token: "main-ident-a", account_id: "acct-main-ident-a" },
+ }));
+ reconcileMainCodexAccountRuntimeState();
+ const originalFetch = globalThis.fetch;
+ let includeCredits = true;
+ try {
+ globalThis.fetch = (async (input: RequestInfo | URL) => {
+ const url = String(input);
+ if (url.includes("/backend-api/wham/usage")) {
+ return Response.json({
+ email: "main@example.test",
+ plan_type: "pro",
+ rate_limit: { primary_window: { used_percent: 40, reset_at: 1788749167 } },
+ ...(includeCredits ? { rate_limit_reset_credits: { available_count: 5 } } : {}),
+ });
+ }
+ return originalFetch(input);
+ }) as typeof fetch;
+
+ const first = await listCodexAuthAccounts(makeConfig(), true);
+ expect(first.find(a => a.id === MAIN_CODEX_ACCOUNT_ID)!.quota?.resetCredits).toBe(5);
+
+ // The operator signs in as a different physical account and the next usage response
+ // happens not to carry the summary.
+ writeFileSync(join(TEST_CODEX_HOME, "auth.json"), JSON.stringify({
+ tokens: { access_token: "main-ident-b", account_id: "acct-main-ident-b" },
+ }));
+ includeCredits = false;
+ const second = await listCodexAuthAccounts(makeConfig(), true);
+ const main = second.find(a => a.id === MAIN_CODEX_ACCOUNT_ID)!;
+ expect(main.quota?.resetCredits).toBeUndefined();
+ } finally {
+ globalThis.fetch = originalFetch;
+ }
+ });
+
+ test("a freshly parsed main resetCredits of zero overrides the stored value", async () => {
+ // Zero is a real reading, not an absence: the DTO fill must never resurrect a stale
+ // non-zero ticket count over it.
+ writeFileSync(join(TEST_CODEX_HOME, "auth.json"), JSON.stringify({
+ tokens: { access_token: "main-dto-zero", account_id: "acct-main-dto-zero" },
+ }));
+ reconcileMainCodexAccountRuntimeState();
+ updateAccountQuota(MAIN_CODEX_ACCOUNT_ID, undefined, undefined, undefined, undefined, 3);
+ const originalFetch = globalThis.fetch;
+ try {
+ globalThis.fetch = (async (input: RequestInfo | URL) => {
+ const url = String(input);
+ if (url.includes("/backend-api/wham/usage")) {
+ return Response.json({
+ email: "main@example.test",
+ plan_type: "pro",
+ rate_limit: { primary_window: { used_percent: 10, reset_at: 1788749167 } },
+ rate_limit_reset_credits: { available_count: 0 },
+ });
+ }
+ return originalFetch(input);
+ }) as typeof fetch;
+
+ const accounts = await listCodexAuthAccounts(makeConfig(), true);
+ const main = accounts.find(account => account.id === MAIN_CODEX_ACCOUNT_ID)!;
+ expect(main.quota?.resetCredits).toBe(0);
+ } finally {
+ globalThis.fetch = originalFetch;
+ }
+ });
+
test("reset-credit consume omits remaining when main WHAM refresh is non-2xx", async () => {
writeFileSync(join(TEST_CODEX_HOME, "auth.json"), JSON.stringify({
tokens: { access_token: "main-reset-fail", account_id: "acct-main-reset-fail" },