Changelog
September 15, 2026 Β· View on GitHub
All notable changes to this project will be documented in this file. Dates are ISO format (YYYY-MM-DD).
The format is based on Keep a Changelog, and this project adheres to Semantic Versioning.
[6.21.0] - 2026-09-15
Added
- The plugin reloads live account state when another process changes the accounts file. An account imported, removed, or edited by another OpenCode window or by
codex-importis adopted without a restart: the file watcher reloads the manager, retires the outgoing instance so its queued saves cannot clobber the new state, and preserves single-use refresh tokens rotated in flight. A mid-load invalidation no longer strands the change - the reload proceeds even while a fresh manager is still loading, and any load that raced the change is retired before it can write a pre-change account list back to disk. Contributed by @WarGloom. (#257) - A successful warm request clears the account's stale blocks with live-usage verification.
codex-warmclears unchanged cooldown state, the responding model's rate-limit marker, and the family's blanket marker, so a warm 200 makes the account selectable again. A subscription-quota block is only cleared after the usage endpoint confirms the quota actually recovered; a warm response can be paid for with Credits and does not prove quota came back. The recovery survives races: a concurrent clear by another process is not resurrected by a superseded manager's save, newer block writes recorded while the recovery runs always win, and a failed usage check still clears the unchanged model and cooldown state while reporting the failure separately. Contributed by @WarGloom. (#257) - Reference docs match the runtime code. The architecture guides, troubleshooting runbook, and tool reference were aligned with the current runtime - retry budget stages, fallback chains, recovery paths, quota accounting, and the Windows host auth store path - with each claim pinned by a docs-parity test. (#256)
Fixed
- A lost half-open probe no longer bricks a circuit breaker permanently. Every 4xx and every caller abort bypass the breaker's feedback methods, so a half-open probe that never reported left the
(account, family)key denied withprobe-in-flightfor the process lifetime. A half-open slot that has gone a full reset window without a verdict is now abandoned and a fresh probe is admitted. - A non-429 response can no longer inherit an uncapped multi-year rate-limit delay. A 400 whose body merely mentions
rate_limit_exceededused to inherit thex-ratelimit-resetheader or the body'sresets_atverbatim, freezing the account for as long as the stale echo claimed. Reset signals are honored only on a genuine 429, matching the authority rule thex-codex-*quota headers already follow. - An SSE event split across multiple
data:lines is recovered per the WHATWG spec. The parser used to JSON-parse eachdata:line independently, so an upstream or gateway that splits one event's payload across two lines was reported as a truncated stream (502) even though the stream terminated cleanly. Consecutive data lines now concatenate with a newline before parsing, and streams that omit the blank-line separator between events still parse. - The 10MB SSE cap is counted in bytes, not UTF-16 code units. A multibyte stream could overshoot the documented cap up to 4x before the guard tripped. The cap is enforced on the raw chunk byte length.
- A hostile reset-credits response no longer crashes the
codex-resettool. A 200 body ofnullor a non-arraycreditsfield threw a TypeError out of the credit parser. The payload is validated at the boundary and degrades to an empty summary, the same treatment the usage parser received in 6.15.0. - A finite-but-huge persisted stamp no longer strands an account for centuries. A corrupt or unit-mangled
rateLimitResetTimesentry,coolingDownUntil, orquotaExhaustedUntilof1e308blocked the account forever because nothing could expire it. Storage sanitization drops future stamps beyond the same 30-day horizon the header and stamp writers enforce; a genuine block re-stamps on the next 429. - The V1-to-V3 migration no longer drops a legacy rate-limit stamp. The migration maps the V1 global
rateLimitResetTimeonto family-keyed stamps, but the storage rebuild discarded that mapped state, so a migrated rate-limited account became immediately selectable. The mapped stamps now flow through to the rebuilt accounts. - A negative
x-codex-active-limitheader no longer renders a negative count in the quota dialog, and email masking no longer splits an astral character into a lone surrogate. A local part likeaπnow masks toaπ***@example.cominstead of emitting a replacement character.
Internal
getTopCandidates(parallel probing) no longer proposes disabled accounts; the rate-limit, cooldown, and quota filters are unchanged.- A deep audit of this release's changes added 81 invariant tests across the recovery, request-pipeline, storage, and TUI suites: hostile header and body inputs, chunk-boundary SSE splits, retry budgets, breaker lifecycle, and persisted-storage corruption.
[6.20.0] - 2026-09-14
Added
- The requested model falls back to a cheaper one when every account is quota-blocked. Before, a pool whose every enabled account carried an upstream block for the requested model waited out the block or failed with 429, even though a cheaper model in the default fallback chain was fully available. When the block is upstream β a spent subscription quota or a server rate limit on every enabled account β the request now degrades down the configured
unsupportedCodexFallbackChainthe same way an entitlement miss does: at most 3 model switches per request, no model tried twice, the chain terminating atgpt-5.2. Local token depletion and cooldowns alone never trigger the downgrade, strict pools still refuse it withstrict_pool_unavailable, and directly selected non-entry models stay strict. Opt out per family with the existingCODEX_AUTH_DISABLE_*_AUTO_FALLBACK=1switches. Contributed by @WarGloom. (#255) - Spent subscription quota is tracked account-wide, separate from transient rate limits. A fully spent 5-hour or weekly window used to be recorded as a blanket per-family rate limit that blocked every model and was reported as "rate limited". It now lands on a dedicated
quotaExhaustedUntilstamp written only by authoritative sources β the usage poller and quota-429 response headers, both horizon-guarded so a bad header cannot strand an account. Rotation, the TUI status line,codex-list,codex-statusandcodex-limitsall report it as quota exhaustion with its own reset countdown, never as a rate limit. Contributed by @WarGloom. (#255) oc-codex-multi-auth doctor --fixrepairs accounts outside OpenCode. The standalone doctor refreshes every enabled account's token, clears stale cooldown, rate-limit and quota-exhaustion markers after successful verification, and persists rotated credentials, including keychain-backed pools. An explicit--config-pathrepairs only the selected JSON pool and bypasses keychain routing; without it, repair preserves enabled keychain routing. Contributed by @WarGloom. (#255)
Fixed
- Corrupt storage files no longer report success from the standalone CLI.
doctor --fixon a corrupt default storage file used to exit 0 with "No accounts configured." whiledoctoralone exited 1;warmandlimitsdid the same for any unreadable file, and a JSON file of the wrong shape β an array, a scalar, a missingaccountsarray β was accepted as a healthy empty pool by every command. All standalone commands now validate shape, parse and schema version and exit nonzero with the exact error, including the "written by a newer version" upgrade hint a future-schema file carries. (#255) - Non-finite numbers in a hand-edited accounts file no longer poison rotation.
JSON.parseturns1e400intoInfinity, and anInfinityrate-limit stamp blocked an account forever while feedingNaNinto retry math that could spin hot retries or an endless countdown. Numeric timing fields are sanitized at the storage boundary on every load and every save, so a poisoned file heals itself on the next persist. - An all-blocked pool waits out the longest block, not the shortest. With a legacy all-family weekly stamp and a newer 2-hour quota stamp on the same account β the exact coexistence the quota separation creates on upgraded pools β the wait loop was promised a 2-hour wait for an account that stayed blocked six days, waking to a still-blocked pool in a cycle. The pool wait is now the earliest moment any single account becomes selectable: the minimum across accounts of each account's longest active block.
- A doctor-cleared quota stamp stays cleared across processes. A second OpenCode process still holding the stamp in memory used to write it back on its next save, undoing the repair, and nothing in-tree ever corrected it since the usage poller only writes stamps. The doctor clear now dates itself and every authoritative stamp write dates itself, so a save can tell a stale snapshot from fresh evidence: a stamp older than the clear stays suppressed, while a fresh 429 or poller stamp recorded after the clear displaces the tombstone and wins.
codex-healthno longer tells a user with a real week-long quota block that it is stale state. Quota-exhaustion stamps are written only by authoritative sources, so they are not the recoverable dark statecodex-doctor --fixtargets; they are reported under their own "Quota exhausted" finding with wording that says the stamp re-establishes after--fixclears it.- Spent subscription quota is recorded once per account, not once per model family. The usage poller used to forge a per-family rate-limit block for every model on a spent shared window; it now records the single account-wide stamp. Contributed by @WarGloom. (#255)
- The TUI status line no longer badges every account as rate-limited. Contributed by @WarGloom. (#255)
Internal
- The hybrid selector's last-resort retry β send a blocked account rather than nothing β is overridden on the request path, so an all-blocked pool waits out or fails on the block instead of retrying it. This is also what allows the model fallback above to run. The selector's docstring documents the override. (#255)
- Cross-process quota-stamp merges are monotonic in both save paths, including the disposed-manager path, and the doctor repair workflow moved out of
codex-doctorinto a sharedlib/tools/doctor-repair.tsused by both the plugin tool and the standalone CLI. The unusedctx.getQuotaExhaustedUntiltool-context surface was removed. (#255)
[6.19.0] - 2026-09-08
Added
- The compact status line says which day a quota resets. A reset today still shows the time alone (
02:25), one inside the coming week gains a weekday (Tue 02:25), and anything further out gains a date (Sep 15 02:25). A weekly window previously rendered as a bare time, which reads as "today" for a reset six days away. Day distance is counted in calendar days rather than elapsed milliseconds, so the week containing a daylight-saving change, 167 or 169 hours rather than 168, is not misread as six days and does not repeat today's weekday. The per-tier character budgets rise from about 40% to about 54% of each tier's minimum width (32/40/48 to 42/52/64) so the longer labels fit rather than displacing a quota reading, and the narrowest tier is clamped to the terminal width so a line can never be wider than the terminal it prints into. Contributed by @dhaern. (#251) - The status line tells two masked accounts apart. With
maskEmailon, the account hint renders[us***@example.com]instead of a flat[*****], which was identical for every account and so identified none of them. Masking still refuses to keep anything it cannot prove is safe: a value holding two addresses, or free text containing an@, flattens to[*****]rather than being partially masked. The hint replaces the whole value with the masked address rather than substituting in place, so a name or a second address next to it is dropped rather than preserved. Where the longer hint does not fit the terminal, the line falls back to the flat mask before it drops the account entirely. Contributed by @dhaern. (#252) codex-limitsreports the banked rate-limit resets an account can redeem.rate_limit_reset_creditsalready rides in the/wham/usageresponse the tool fetches, so this costs no additional request and no additional token;codex-resethad to call a second endpoint to say the same thing. Output readsResets: 2 banked, or2 banked (1 applicable now)when only part of the balance is redeemable against a currently exhausted window. Before this, an account with a spent purchase balance and two full resets waiting printedCredits: 0, which reads as "you have nothing" at exactly the moment a reset would have unblocked it. Contributed by @Nowaker. (#254)
Fixed
- Accounts no longer carry the name of an unrelated API-platform organization. Builds up to 6.17 named a ChatGPT account after an organization read from the
id_token_add_organizationsclaims, storing a personal subscription asDreamHost API (role:owner) [id:c487c4]. 6.18 stopped generating that label and cleared it on login, but a refresh token rotates for months without a login, so a pool written by an older build kept showing the wrong organization indefinitely. It is dropped now on the first read or write by a build that no longer generates it, with no re-authentication. Only the generated shape goes: the trailing[id:β¦]marker has to hold that account's own id suffix, which is what every generator emits, so a name set withcodex-labelsurvives even when it ends in[id:mine]. The drop is logged. Contributed by @Nowaker. (#253) - The standalone CLI names accounts it can no longer label. With the organization label gone,
status,list,health,doctoranddashboardshowed rows readingAccount 1andAccount 2with nothing to tell them apart, which matters because one subscription can hold several workspaces. Each row now carries a masked email and an id suffix. The suffix is only ever as long as the maskedaccountIdbeside it already discloses, and is omitted entirely when that field is a flat mask. (#253) - Short identities are no longer printed in full by diagnostics. The head/tail mask keeps eight characters, so below thirteen it concealed nothing: an email such as
me@x.ioprinted verbatim andme@x.io12printed all but its middle character, throughdoctoroutput and--jsonalike. Values that short are replaced outright now, and input is trimmed first, so padding cannot clear the cutoff on its own. (#253) - The status line could render wider than the terminal. The narrowest character budget was a flat 12, which is wider than the terminal it describes below twelve columns, so the line wrapped and pushed the prompt. Pre-existing rather than a regression from this release: the flat 12 is byte-identical at 6.18.0. Found by a width sweep from 1 to 130 columns, which now guards it.
Internal
- Both reset-credit surfaces read the server's counter through one
normalizeResetCreditCount, which rejects a negative, fractional or non-numeric count rather than truncating it. Each keeps its own fallback, because the list endpoint sends the credits it can count and the usage endpoint does not. An unreadableapplicable_available_countnow reports only that field as unknown instead of discarding the banked total that arrived beside it. (#254) codex-limits --jsonkeeps raw counts inresetCreditsand moves the rendered summary line toresetCreditsSummary, so a consumer never parses English to reach a number sitting next to it. (#254)
[6.18.0] - 2026-09-04
Added
- Paid Credits are protected from a spent subscription quota. Every enabled account is polled through the existing bounded quota monitor, 30 minutes by default, with no per-request usage call added. When a 5-hour or weekly subscription quota reads as exhausted, a rotation block is persisted across all model families so round-robin skips that account before a later request can spend Credits at Credit rates. Usage-endpoint errors and rate limits fail open and simply retry on the next interval, so throttling on that endpoint can never turn into a routing block. Stale account-manager caches are invalidated across processes at the moment a rotation persists. Manual
codex-limitschecks remain immediate protection. This is on by default; setautoProtectCredits: falseorCODEX_AUTH_AUTO_PROTECT_CREDITS=0to opt out. Contributed by @PENEKhun. (#245)
Fixed
- A model id that names an
Object.prototypemember crashed the request path.resolveUnsupportedCodexFallbackModellooks the fallback chain up aschain[currentModel], andcurrentModelcomes from the caller'sbody.model. On a plain objectchain["constructor"]returns the Object constructor rather thanundefined: truthy, with a.lengthof 1, so thetargets.length === 0guard passed it through andfor (const target of targets)threwTypeError: targets is not iterableinside the request path.model: "__proto__"did the same. The chain is built with a null prototype now, and the guard checksArray.isArrayrather than a length, which also covers acustomChainvalue that is not an array. AcustomChainkey named__proto__previously reassigned a plain object's prototype instead of adding a row; the null prototype fixes that too. Reproduced against 6.17.0 before the fix. (#250) - Astra's request shape is read from the Codex catalog rather than inferred. 6.17.0 shipped hours before openai/codex published a
gpt-6-astraentry, souse_responses_litehad to be guessed and was kept behindCODEX_AUTH_ASTRA_RESPONSES_LITE. The entry landed in commited391d4dand confirms the guess:use_responses_lite: true,tool_mode: "code_mode_only",multi_agent_version: "v2", effortslowthroughultra. Membership is read now and the environment variable is removed, since it existed only to hedge an unverifiable value. Two fields in that entry corrected the 6.17.0 documentation:base_instructionsis an empty string where every sibling carries 11k to 21k characters, so Astra keeps reading its prompt file rather than picking up catalog text as the release notes claimed, andvisibility: "hide"puts Astra behind the same flag used to keep the Daybreak tiers out of the shipped templates. Astra stays in them because the outcomes differ rather than the flag: Astra has an auto-fallback chain, so an unentitled account costs one round trip and lands on a working model, where a Daybreak request hard-fails by design. (#248) - The quota monitor hung any test that drains fake timers. With credit protection on by default the monitor polls in every context that constructs the plugin, and its timer re-arms itself, so
vi.runAllTimersAsync()never emptied the queue and Vitest aborted at 10000 timers. Four pre-existing retry tests failed as a result. Production behaviour is unchanged: a self-rescheduling unref'd 30-minute timer is only pathological when a test advances the clock artificially. (#245)
Security
- Five dependency advisories cleared, four of them high.
toml4.1.1 to 4.3.0 (uncontrolled recursion, prototype pollution),browserslist4.28.2 to 4.28.8 (unbounded memory growth, prototype write via untrusted input), andfflate0.8.2 to 0.8.3 (infinite loop on a malformed ZIP64 archive, dev-only through@vitest/ui). All were transitive and every patched version already satisfied the range its parent requested, so the fix is a lockfile refresh with nooverridesentry and no direct-dependency bump.npm auditreports zero vulnerabilities. (#249)
Internal
- The documentation count check derived nothing: it asserted literal strings against the installer, so a template change left it green while the installer advertised counts the templates no longer had. It derives every count from the two config JSONs now, and checks each count quoted anywhere in current documentation against them, across the qualifier-plus-noun phrasings the docs actually use. (#246, #248)
[6.17.0] - 2026-09-04
Added
- GPT-6 Astra, and the Daybreak cyber tiers.
gpt-6-astraroutes through the same layers the GPT-5.6 tiers use, with effortslowthroughultraper OpenAI's Codex model list, and ships in the config templates. It is opt-in: neither thegpt-5alias nor the plugin default resolves to it. Astra reached a limited set of organizations before Plus/Pro/Business/Enterprise, so an account outside the rollout auto-degradesgpt-6-astra β gpt-5.6-sol β gpt-5.6-terra β gpt-5.6-luna β gpt-5.5 β gpt-5.2, disabled withCODEX_AUTH_DISABLE_GPT6_AUTO_FALLBACK=1. Baregpt-6is a plugin-side alias; OpenAI publishes no such id.gpt-6-astra-prois not a routable Codex id (its docs page 404s, and it appears in neither OpenAPI model enum) so it collapses ontogpt-6-astrarather than putting an unknown slug on the wire, the same treatmentgpt-5.5-progets. (#246) - Routing for the Daybreak-gated cyber models.
gpt-daybreak-blue-latest(defensive),gpt-daybreak-red-latest(cyber-permissive, for authorized security research), andgpt-5.6-cyber, OpenAI's published alias fronting them. All three are deliberately absent from the shipped config templates: they need Daybreak program approval and the two-latestids arevisibility: "hide"in the Codex catalog, so listing them in every model picker would cause the avoidable startup failures the templates already keepgpt-5.3-codex-sparkout for. An entitled user adds the id by hand. None of the three has a fallback chain, deliberately: degrading a cyber-specialty request onto a general model would answer a security-research prompt with a model nobody asked for.gpt-5.6-cyberbelongs to the 5.6 generation rather than GPT-6, and is matched ahead of the baregpt-5.6alias, which would otherwise have claimed it and answered from Sol. (#246) - Astra's request shape is responses-lite by default, overridable with
CODEX_AUTH_ASTRA_RESPONSES_LITE=0(classic) or=1(force lite). Unlike every other lite model this is inferred rather than read: the public Codex catalog has carried nogpt-6-astraentry since its 2026-08-20 refresh, and every catalog model from 5.6 onward isuse_responses_lite: truewithtool_mode: "code_mode_only". Astra is registered as a catalog slug anyway, so it picks up real catalog instructions on the first Codex release that publishes them, with no further change. (#246)
Fixed
- Every default fallback tail degraded onto models Codex had retired.
gpt-5.4andgpt-5.4-miniwere withdrawn on 2026-08-31T19:00:00Z; the catalog marks bothvisibility: "hide"and carries anupgradedirective naming their replacements, andgpt-5.4-nanohas no catalog entry at all. Every tail ended in those three, so an entitlement failure ongpt-5.5orgpt-5-codexspent its whole attempt budget on withdrawn ids and hard-failed without ever trying a live model. Warming was worse: it starts atgpt-5.5, so all three of its hops were the retired ones. The tails now run throughgpt-5.6-terra,gpt-5.6-luna, thengpt-5.2, and each retired id leads with the successor its own catalog entry names.gpt-5.4-nanogains a chain of its own, having previously been a terminal failure for anyone who selected it.gpt-5.4-miniremains a shipped base because removing it would break saved configs that name it; selecting it now costs one round trip before it upgrades togpt-5.6-luna. (#246) - A capitalized reasoning effort bypassed every clamp.
reasoningEffortis read straight offopencode.json, where its TypeScript union constrains nothing at runtime, and every clamp compares against lowercase literals. SoULTRAwent to the wire asULTRAinstead of collapsing tomax, which a Codex client-side tier must never do;NONEreached models that rejectnone;MINIMALreached the Codex families that reject it; andMAXreachedgpt-5.1, which tops out athigh. Efforts are case-folded before clamping now, assanitizeReasoningSummaryalready did for the sibling field. (#247) - An unrecognized reasoning effort shipped verbatim, so a typo produced a backend
400rather than a working request.gpt-5-codex-mininever had this problem because its branch ends in a catch-all; no other family had one. Unknown tokens now fall back to that family's default effort and log a warning. (#247)
Internal
- The documentation count check derived nothing: it asserted the literal strings
12 base OAuth modelsand53 explicit selector entriesagainst the installer, so growing the templates left it green while the installer advertised counts the templates no longer had. It now derives every count from the two config JSONs and checks each count quoted anywhere in current documentation against them, across the qualifier-plus-noun phrasings the docs actually use rather than two hardcoded ones. (#246)
[6.16.0] - 2026-09-03
Added
- A second browser login method, and a default browser login that no longer gives up.
Codex OAuth (Open URL Manually)binds the localhost callback listener first, prints the authorization URL, and never launches a browser; sign-in completes through the callback on port 1455, which suits SSH with-L 1455:localhost:1455. The default method now keeps the login alive when the browser cannot be launched: it prints the URL and keeps waiting, where before a failed launch closed the listener and returned a failure with the URL never shown. On a host with noxdg-openon PATH that was every login. (#244) - The ChatGPT plan tier is reported per account.
Free,Plus,Pro,Business, andBusiness Premiumare read from the access token at login, re-read on every token refresh so an upgrade is not pinned stale, and shown bycodex-listandcodex-status.codex-limitsand the TUI read the plan live from the usage endpoint and now name it the same way, so one seat no longer readsBusinesson one surface andteamon another. An unrecognized plan is reported as it arrived rather than renamed. (#243)
Fixed
- A replaced account manager could delete accounts. Disposing an
AccountManagerleft its queued debounced save armed, and that save takes account membership from the dead manager's snapshot wholesale, so it deleted every account the successor had since loaded or added. Cancelling the timer was not the answer either: the only save a cancel can still reach is one armed after the caller's flush, which is the rate-limit block an in-flight request just recorded, and dropping it hands an exhausted account straight back to rotation. A disposed manager now writes through a volatile-only merge instead: membership, credentials, and active-index routing come from disk, and only rate-limit blocks, cooldowns, and last-used stamps merge in. That also covers a save already in flight when disposal ran, which no timer cancel could reach. (#242) - A quota alert could describe a quota no account had. The 5-hour and weekly lines took the highest remaining percentage and the earliest reset from different accounts, so a pool with 60% left on one account and an exhausted account resetting sooner rendered as
5h: 60% | resets 22:30and the reader waited for relief that belonged to the exhausted account. The percentage and the reset printed beside it come from one account again; when a different account recovers sooner, that reset is appended asanother account resets ...rather than replacing the paired one. (#241) - A generated account label leaked the email past
maskEmail. Accounts were labelled<email> id:<suffix>, and every display surface prints the label verbatim while masking only theemailfield, so with masking on a line readAccount 1 (user@example.com id:c487c4, us***@example.com, id:c487c4). No label is generated from a ChatGPT credential now: the email and account id are stored as their own fields and rendered from there, with masking applied. A login clears a stale label left behind by an API-platform organization name, and a label set withcodex-labelis kept. (#243) - Quarantining an account dropped its plan. The flagged-account normalizer rebuilds records field by field and did not carry
planType, so an account restored from quarantine reported an unknown plan until the next login. (#243) - A bare authorization code was accepted with no state check. The manual URL paste flow stopped comparing the OAuth
statewhen the input was a raw code, which removed the only binding the plugin performs between the pasted value and the login attempt in progress. The full callback URL, including itsstate, is required again for every input. (#244) noBrowserwas ignored. A caller passingnoBrowser=trueorno-browser=trueentered the multi-account browser loop, tried to launch a browser, and bound port 1455, which is the opposite of what the input asks for on a headless host. It selects the paste flow again. (#244)- A hostile
plan_typecould inject lines into terminal output. An unrecognized plan slug was rendered as it arrived, control characters included, andplan_typecomes from/wham/usage, which is cast without validation and whichOPENAI_BASE_URLlets a user put an arbitrary gateway in front of. A value ofpro\nPlan: Enterprisesplit acodex-listrow in two, and the smuggled half read as a line the tool had emitted; an ESC byte reached the same place. Control characters are dropped and the rendered plan is bounded to 32 characters, which alters no real plan name.
Internal
- Two test suites bind the real OAuth callback port 1455, and Vitest schedules files in parallel, so they raced and whichever lost failed an assertion the other file's existence had broken. They take a cross-worker lock now. (#244)
[6.15.0] - 2026-08-31
Added
- Desktop quota notifications for macOS. Set
quotaNotifications.enabled: true(orCODEX_AUTH_QUOTA_NOTIFICATIONS=1) and the plugin polls every enabled account on an interval and alerts through Notification Center when the pool's 5-hour or weekly quota crosses 25%, 10%, or 0%. Each line reports the account with the most headroom in that window together with that same account's reset, so the percentage and the reset always describe one real account. A window the plan has switched off is skipped rather than counted as a full quota. Account identities are never shown or persisted.notifyEveryCheck: truedelivers the same summary after every successful poll instead of only on a crossing,thresholds: []turns threshold alerts off, andintervalMsdefaults to 30 minutes with a 30-second floor. Delivery state is shared across processes working in the same account scope, so concurrent instances produce one routine alert per interval. Delivery uses the built-inosascript, with no extra dependency, and the feature is off by default and unavailable on Windows and Linux. (#239)
Fixed
- An unattended quota refresh could kill an account. The monitor refreshes any account whose access token is near expiry, and a refresh rotates a single-use refresh token durably to disk. The in-process
AccountManagercache kept the old token and its debounced save overwrote the rotation, after which the next refresh returnedrefresh_token_reusedand the account stayed dead until a fresh login. The monitor now drops that cache at the moment a rotation persists, before the account's usage call, rather than after the whole check. The 500 ms save debounce is far shorter than a check over several accounts, so invalidating at the end of the check left the window open. (#239, #240) - A malformed usage document threw instead of reading as empty.
fetchCodexUsagecastsresponse.json()straight to its declared type, so a200carrying the bodynull, a non-arrayadditional_rate_limits, or a non-stringlimit_namereached dereferences that assumed the declared shape. The gateway in front of/wham/usageis user-configurable throughOPENAI_BASE_URL, so those are reachable responses. A non-object payload now reads as an empty document and renders asunavailable; every field nested inside the payload was already null-tolerant. - A disabled quota window was scored as a full quota. A window the plan has switched off still reports
used_percent: 0, so without thehasUsageWindowguard it counted as 100% remaining and masked every other account's low quota, and the alert never fired for that user. (#239) - The alert could describe a quota no account had. The aggregate paired the highest remaining percentage with the earliest reset across different accounts, so a pool could be told it had 60% left and would recover at a time that restores nothing. (#239)
- Concurrent processes dropped whole quota checks. Notification delivery was awaited inside the cross-process state lease, whose retry budget is roughly 660 ms against a 10-second delivery timeout, so a second process exhausted its budget and abandoned a check whose network fetches it had already paid for. Delivery now claims the slot under the lease, delivers outside it, and releases the claim if delivery failed. (#239)
- The quota monitor outlived shutdown. Its only teardown was the
server.instance.disposedevent, so the timer survivedSIGINT/SIGTERMand any host that does not emit it. It now registers with the shared shutdown drain, and it stops polling entirely once it observes the feature switched off, an unsupported platform, or a configuration that can never deliver. (#239, #240) quotaNotifications.thresholds: []was ignored. An explicitly empty list was replaced by the[25, 10, 0]default, so there was no configuration that rannotifyEveryCheckon its own. The default now applies only when the key is absent. (#239)- Quota state could leak between projects. The in-memory threshold state was not keyed by state path, and the path was resolved after the network fetches rather than beside the account load that produced the aggregate, so switching projects mid-check could write one project's thresholds into another project's file. (#239, #240)
Changed
- Quota reset times stay on the 24-hour clock. A change to 12-hour formatting would have altered every quota display in the product, left the TUI status line rendering
22:30wherecodex-limitsrendered10:30 PM, and forced a 12-hour clock on locales that do not use one. (#239)
Internal
test/quota-notifications-fetch.test.tscovers the monitor's default fetch path, which previously had none because every test injected a fake. That gap is how the token-cache ordering defect above reachedmain. (#240)quotaNotificationsis documented indocs/development/CONFIG_FIELDS.md, the notification state file inAGENTS.md, and the three new modules inlib/AGENTS.md, which the repo treats as a documented contract. (#239)
[6.14.4] - 2026-08-30
Fixed
- Manually pasted authorization input reached the token exchange mangled, or not at all. The
Codex OAuth (Manual URL Paste)flow is the escape hatch when the browser callback cannot be captured, and its parser derived every result fromnew URL(), which normalises rather than preserves. Thecode#stateshorthand round-tripped the code throughURL.pathname: a space became%20, a backslash became a forward slash, a..segment was collapsed away, a leading//was absorbed as a host and left no code at all, and everything after a?was dropped. Its state came fromURL.hash, which is percent-encoded, so a state carrying any character the URL serializer escapes could never equal the state generated for that login attempt. The URL branch returned unconditionally, so an opaque code thatnew URL()accepts because it contains a colon (ac:1a2b3c) was reported as a callback with no code, and the user was told to paste a URL they do not have. Any raw value carrying astate=pair was reinterpreted as a query string and its code discarded, and a bare#valuefragment was filed under state, throwing away the code it held. Each of these posted a wrong code, or no code, to the token endpoint, where the only feedback is an opaqueinvalid_grant. The shorthand and raw paths now split on the pasted bytes. A value is read as a URL only when it carried a code or state or opens withscheme://, and as a query only when it opens with a parameter name. A callback pasted without its scheme (127.0.0.1:1455/auth/callback?code=...) is now read as a URL instead of losing its code. (#238) - An unexpected
URLrejection could escape the interactive login prompt. The parser rethrew anything that was not aTypeError, andinstanceofis realm-sensitive, so a subclassed or cross-realm rejection propagated out of the prompt'svalidate()andcallback()instead of degrading to "treat the value as a raw code". Every rejection now falls back to the raw path, which is what the preceding barecatchdid. (#238) - A bare authorization code and a callback missing its state now get different instructions. Both previously produced "Missing OAuth state. Paste the full callback URL including both code and state parameters", which does not tell someone who pasted only the code what they got wrong. The state requirement itself is unchanged: state is what binds a pasted callback to the login attempt that generated it. (#238)
Internal
- The parser's result type collapsed from four structurally identical union members to two, and the orphaned
ParsedAuthInputinterface, whose last import the same change removed, was deleted fromlib/types.ts. (#238) test/index.test.tsno longer hand-writes a stand-in forparseAuthorizationInput. Avi.mockfactory is not type-checked against the real module, so the stand-in still returned the pre-change shape with nosourcefield, and the manual-flow tests would have passed vacuously onceindex.tsbegan branching on it. The mock now delegates to the real parser, and the deleted query-over-fragment precedence test is restored. (#238)
[6.14.3] - 2026-08-27
Fixed
- An implausible quota-reset header took an account out of rotation permanently. The
x-codex-*reset headers were parsed with no upper bound:-reset-after-secondswas multiplied out and added to the clock as-is, and-reset-atwas accepted verbatim as an epoch stamp or an ISO date. A garbled value therefore resolved to an arbitrary point in the future β a measured-reset-after-seconds: 4000000000produced a reset 127 years out, and-reset-at: 9999-12-31T00:00:00Zone 2,912,203 days out. That is not merely a long backoff: the value is written into the persistedrateLimitResetTimesmap through a deliberately monotonic writer, and expiry only ever drops resets that are already in the past, so nothing in the product could shorten it again. One malformed header β from the backend, an intermediary, or a configuredOPENAI_BASE_URLgateway β removed the account from rotation until the user edited the state file by hand. The same value also reached the retry-after delay derived from those headers. Reset times further out than any real Codex window are now rejected rather than clamped: an implausible header says nothing about when the account actually recovers, so recording no reset at all (and letting the next request rediscover the truth) is safer than recording a month-long block on the strength of a garbled number. Plausible values are unaffected β a 5h window, a full 7d weekly window, and a reset exactly on the horizon are all still honored. - A backwards clock jump turned account health recovery into a penalty. Passive recovery scaled the elapsed time since an account's last health update, without clamping it at zero.
lastUpdatedis stamped from the local clock, so an NTP correction or a resumed VM makes that interval negative and the recovery term subtracts instead of adding, driving the score arbitrarily below its floor (a measured -17440 against a minimum of 0) and leaving the account ranked last in selection long after the clock settled. NaNandInfinityreached users verbatim in wait-time messages. A non-finite duration rendered as the literal stringsNaNsandInfinitym NaNsin toasts, status lines and log warnings. Non-finite input now reads as zero. The same formatter also had no unit above minutes, so a weekly quota block and process uptime both printed as a five-figure minute count (10080m 0s); hours and days are now split out.- Stale quota headers on an entitlement error blocked healthy accounts. A response refused for a subscription entitlement problem can still carry the account's last-known
x-codex-*quota snapshot. Those headers were consumed unconditionally, so an entitlement failure recorded a quota block against an account that had quota left. Quota headers are now believed only on a response the backend actually served, or one it refused with a confirmed usage limit β HTTP 429, including a 404 remapped to one. Entitlement failures, auth failures, 5xx, and upstream overloads dressed up as a 429 are all ignored for this purpose. (#237)
Internal
- The decision about whether a response's quota headers are authoritative is made once, in the error classifier, and reported to the caller as
quotaHeadersAuthoritative. It previously lived at a single call site, which left the two other consumers of the same headers on the old behavior: the retry-after parser still read the exhausted-window reset uncapped β in two separate places β and the TUI quota snapshot was still written from headers the router had just discarded, so the status line could report "0% left" for an account rotation considered healthy. The classifier derives the flag from the same overload verdict the caller branches on, so the durable block and the retry delay can no longer disagree. (#237) - The short 429 retry no longer replays a request on an account whose quota window it has just blocked. That block is monotonic, so a retry that happened to succeed could not walk it back, leaving the account serving traffic while rotation still considered it blocked. (#237)
- The rotation selectors' contracts are documented accurately.
getCurrentOrNextForFamilyStickyclaimed to match "the other selectors' contract" in returning null when no account is available;getCurrentOrNextForFamilyHybriddeliberately does the opposite, falling back to the least-recently-used account rather than hard-failing β which is what keeps a single-account pool usable when its only account is cooling down. Both now state the difference, and the hybrid selector documents that a returned account is not a promise that it is selectable.
[6.14.2] - 2026-08-25
Fixed
- Pool-exhaustion diagnostics could contradict themselves, and undercounted Business seats. The identity these counters are keyed on was not stable for the lifetime of a request: it was derived in part from the refresh token, which is single-use and rotates on exchange, and a rotation propagates to every sibling account that shared the old grant. An account fetched earlier in the traversal therefore stopped matching its own recorded identity as soon as any sibling refreshed, and the strict-pool message could report "the model was unsupported on 2 of 2 attempted pooled account(s)" immediately followed by "1 pooled account(s) were never attempted". The key is now built from identity that does not rotate, so two legacy Business seats sharing one workspace id still count as two accounts while a mid-request token rotation leaves the counts alone. Accounts that were never attempted are also counted against the accounts still present rather than by subtracting attempts from a total, so an account removed mid-request no longer hides a live account that never got a turn. (#236)
Changed
- Terminal routing diagnostics no longer degrade silently when account state is unreadable. The strict-pool and exhaustion messages guarded their account lookup and fell back to an empty account list, which made every configured pool entry look like it matched no known account β turning a message that should explain an exhausted pool into one that blames the operator's configuration. The lookup is now unconditional. (#236)
[6.14.1] - 2026-08-25
Fixed
- A rate-limited strict model pool answered
503with no retry hint instead of429. The pool wait-time lookup resolved its members by comparing configured pool entries against an account's raw account id, while routing resolves them through the member-scoped seat identity thatcodex-poolactually writes. For any pool whose accounts have a resolvable member id the two never matched, so the lookup saw an empty pool, reported no wait at all, and the request failed with a503telling the operator to checkcodex-healthrather than a429carryingTry again in <time>β costing clients both the retry-after signal and the correct status class for backoff. Pools configured with legacy workspace-wide entries were unaffected, which is why it went unnoticed. (#235) - Pool-exhaustion diagnostics could describe the wrong accounts, or a previous request. The counts behind these messages were keyed on rotation index, which is reassigned when an account is removed mid-request, so a count recorded before a removal referred to a different account afterwards. Pool size was read off the number of configured entries, which is a different quantity again: one legacy workspace-wide entry can resolve to several Business seats, and two entries can resolve to one seat. The strict-pool message now reports configured entries and resolved accounts separately, names entries that match no known account instead of folding them into an "unavailable" total, and distinguishes accounts that were never attempted from ones that were tried and failed. The general exhaustion message no longer inherits an earlier request's "model not supported" verdict β that state is plugin-scoped, so a request that never reached an account could report a rejection on "0 of 0 attempted account(s)" β and it again states how many accounts are configured. (#234)
[6.14.0] - 2026-08-21
Added
- Trusted OpenAI-compatible gateways for ChatGPT OAuth inference.
OPENAI_BASE_URLis now honored for ChatGPT OAuth requests, but only whenCODEX_AUTH_ALLOW_OPENAI_BASE_URL=1explicitly opts in β a pre-existingOPENAI_BASE_URLset for an unrelated tool cannot silently capture ChatGPT OAuth credentials. The override is fail-closed: remote gateways require HTTPS; cleartext HTTP is accepted only for literal loopback addresses β any address in127.0.0.0/8,::1, and their IPv4-mapped spellings, which WHATWG serializes in hex (::ffff:127.0.0.2becomes::ffff:7f00:2) β and never for hostnames such aslocalhost, because a host file or resolver can point those at a remote peer and leak the access token in cleartext; embedded credentials, query strings and fragments are rejected; and redirects are not followed, so a3xxfrom the gateway yields a502naming the redirect origin rather than replaying the OAuth token to an endpoint the operator never configured. A rejected value fails the auth loader with a[oc-codex-multi-auth]-prefixed reason and an error toast instead of silently falling back to the default endpoint, and the value itself is never echoed β it can carry a token in its query string. A one-time warning records the gateway origin. (#232)
Fixed
- Two OpenCode processes sharing one account file could burn each other's refresh tokens. OpenAI refresh tokens are single-use and rotate on exchange, so when two processes exchanged the same token one of them received
refresh_token_reusedand that account was dead until the user logged in again. Refreshes now run under a cross-process lease that serializes the exchange itself, with an authoritative reload before it and a durable commit after it. A process that finds a rotation another process already committed adopts it instead of spending a second token. The guarantee is same-host and local-filesystem: cross-host or network-filesystem coordination still needs an external coordinator, and a process killed between the provider accepting a token and the replacement being committed still requires reauthentication. (#233) - Unrelated storage writes no longer queue behind a network round trip. The refresh lease is deliberately separate from the storage transaction lease, so
codex-note,codex-tag, account enable/disable, rotation stamps and TUI quota writes never wait on a multi-second OAuth exchange. Holding one lease across both would have turned ordinary contention into user-visibleStorageTransactionContentionErrorfailures. The storage acquisition budget was also widened from roughly half a second to roughly five, and the lock directory is created up front βproper-lockfilecreates its lockfile with a non-recursivemkdir, so the first mutation on a fresh per-project profile previously failed with a bareENOENT. (#233) - A consumed refresh token could be written back over a newer one, in four separate paths. The
codex-healthmerge, the startup email-hydration merge, refresh-target resolution, and flagged-account cleanup each restored or misrouted a credential that another process had already rotated β every one of them costing the user a re-login. Credential writes are now gated on thetokenRotatedAtrotation stamp so a stale snapshot can never overwrite a newer commit; hydration updates are keyed by stable workspace identity rather than by the refresh token, which had let a sibling record in a different organization receive another workspace's access token; refresh-target resolution never widens a seat key to a workspace-level key and refuses an ambiguousorganizationId:match rather than exchanging against another member's credentials; and flagged-account cleanup deletes only records it positively restored, instead of dropping one whose token a sibling had rotated in place. (#233) - A live OAuth access token was written to
flagged-accounts.json. Quarantined records are credential-light by design andnormalizeFlaggedStoragediscards those fields on read, so the token was persisted only to be thrown away β while sitting on disk in the meantime. The flagged refresh path no longer writes it. The rotation stamp is now persisted there, so rotation ordering survives a round trip through that file. (#233) - A lost refresh lease no longer lets the exchange proceed. If the lease heartbeat is starved past its stale window another process can reclaim it, and continuing would put two processes on the same single-use token. The lease is now asserted valid immediately before the token is spent, and the resulting error is classified retryable so the caller retries with a fresh lease. (#233)
Internal
- The refresh lease and the storage transaction lease take their locks on distinct target paths.
proper-lockfilekeys its in-process registry by the target path rather than bylockfilePath, so nesting two leases on one target made the inner release delete the outer's registry entry; the outer release then failed withENOTACQUIREDand leaked its lockfile until it went stale, stalling every other process on the host for the full stale window. (#233) persistRefreshResultwas removed. Its concurrent-rotation guard is subsumed by the coordinator, which owns the reload, exchange and commit, and the function was reachable only from its own test. (#233)test/index.test.tsstubs the refresh lease.getStoragePath()is mocked there to a path that does not exist, so acquiring a real cross-process lease created a directory outside the test sandbox on every run. (#233)
[6.13.0] - 2026-08-18
Added
- Business workspace seats are now first-class identities. Every OAuth access token issued for a ChatGPT Business workspace carries a per-member
chatgpt_account_user_idalongside the workspace-widechatgpt_account_id. That member id is now extracted, persisted asaccountUserId, and participates in account identity, deduplication, model-pool routing, quota accounting and diagnostics. Records written by earlier releases are backfilled from their stored access token wherever it still decodes; a record whose token has expired or is opaque keeps no member id and behaves exactly as it did before. (#230, #231) codex-doctorandcodex-healthreport colliding Business credentials. A newbusiness-member-credential-conflictfinding, and abusinessMemberConflictSlotsfield incodex-health --json, flag account slots whose tokens resolve to the same member of the same workspace β records that cannot consume separate quotas and must be re-authenticated independently. Distinct workspace variants of a single OAuth grant, separated byorganizationId, are legitimate and are not reported. (#231)
Fixed
- Logging in as a second member of a Business workspace overwrote the first member's credential instead of adding a separate account. Every affected record ended up carrying the last logged-in user's email, access token and refresh token, so one seat was billed for the whole workspace and the displaced member's single-use refresh token was lost. Business members share one
chatgpt_account_id, and account identity keyed on that value alone, so every member of a workspace resolved to the same stored record β the collision was silent because the shared id is a legitimate value, not a missing one. Identity now keys on the workspace and the token'schatgpt_account_user_id, so each seat occupies its own slot, keeps its own refresh token, and is metered against its own quota. Reported by @proamo, who traced it to the host auth fallback; fixed by @lubshad. (#230, #231) - A model pool could not target an individual Business seat.
codex-poolstored the workspace-wide account id, which matches every member of that workspace, so a pool the operator scoped to one seat silently routed to all of them. Pool entries are now member-scopedseat:keys. Legacy workspace-wide entries deliberately keep matching every seat in their workspace, and are migrated to seat keys on the nextcodex-pool add/removeβ except while project-scoped account storage is active, sincemodelAccountPoolslives in the global config and expanding it against one project's visible seats would rewrite routing that other projects depend on. (#231) - A per-account circuit breaker could be inherited by an unrelated account. The breaker key embedded the positional account index, which
removeAccountreassigns to survivors. Unlike the health, token and rate-limit trackers β all explicitly remapped after a removal β the breaker map is not, so whichever account shifted into a removed slot inherited that slot's OPEN breaker and was short-circuited out of rotation until the breaker half-opened. The key is now derived from the account's stable workspace identity, which no removal can change. (#231) - Three advisories reached consumers through the production dependency tree, and
npm run audit:cifailed. Two werehonoadvisories (ReDoS in the CORS middleware, plus the same advisory reached transitively).@openauthjs/openauthwas in the production tree for exactly one function βgeneratePKCE, called once fromcreateAuthorizationFlowβ and declaredhonoas a peer dependency, which was the only reasonhonowas a direct dependency and an override at all; nothing imports it. PKCE generation now lives inlib/auth/auth.tswith the wire format preserved exactly: 64 random bytes base64url-encoded to an 86-character verifier (RFC 7636 allows 43-128), challenge =base64url(SHA-256(ASCII(verifier))), both encoders unpadded. The upstream helper also returnedmethod: "S256", which nothing read β the authorize request already hardcodescode_challenge_method=S256. Removing the package takes 11 packages out of the tree; the gate now reports 0. (#229)
Internal
Review follow-ups on the seat-identity work, each a correctness defect in the new code rather than a change of intent (#231):
- The bare
accountUserId:identity key is ranked beloworganizationId:andaccountId:. One OAuth grant can back several workspace variants that all carry the same member id, andfindAccountIndexByIdentityKeysreturns the first key that matches, so at rank 2 the member key could resolve a single-use refresh-token write onto another workspace's record and leave that workspace holding a consumed token β a permanent auth failure. - A memberless legacy record is merged into its workspace's seat record when that workspace has exactly one seat. Because
toAccountIdentityKeynow returns aseat:key, a legacy twin whose access token no longer decodes kept the olderorganizationId:key and stopped deduplicating against its own newer entry, surviving as a live rotation slot with a dead refresh token. With two or more seats the record is left alone: there is no way to tell which member it belongs to, and a wrong merge is worse than a duplicate. - accountId-only fallback matching is retained when the fallback token carries a member id. The candidate set already excludes every record with a different member id, so matching on accountId cannot bind two seats together; refusing to match at all stranded records that predate member ids and pushed a duplicate slot for a credential that should have been hydrated in place.
accountUserIdis guarded the wayaccountIdalready was, in bothupdateFromAuthand the Codex CLI cache hydration. A manually- or org-pinned record must not be re-identified by a token minted for a different workspace, which would move its pool key, usage dedupe key and workspace identity key while the accountId pin appeared to hold. The CLI cache is keyed by email, so one person's personal account and their Business seat both resolve there.getModelPoolAccountKeyfalls back to the bearer token for the member id, matching every other member-id read in the codebase. Without the fallback, a record that bypassed the normalize backfill produced the bare workspace key, which then matched every seat in that workspace instead of the one the operator selected.- The usage-quota dedupe key retains
organizationId. Replacing workspace identity with seat identity collapsed one member's two workspaces into a single quota row, contradicting both the rule that key had always documented and the per-workspace binding added in #227; the seat id now disambiguates members within a workspace rather than replacing it. The test that pinned the collapse was inverted, with the genuine split and collapse cases pinned alongside it. findConflictingBusinessMemberCredentialsno longer requires the grouped records to carry differing emails. #230 reports that every affected record ends up with the last login's email, so the differing-email gate stayed silent on exactly the corruption the scan exists to surface; distinct workspace variants, separated byorganizationId, remain excluded.updateModelAccountPoolreports the account ids that were actually on disk.previousAccountIdsmay be expanded from legacy workspace keys purely to compute the next set, which madecodex-poolprint a "previous" count, and emit apreviousConfiguredCount, that the config file never contained.accountUserIdis declared onAccountMetadataV3SchemaandAccountMetadataV1Schema. Both interfaces persist it but zod strips undeclared keys, so any loader, import validator or migration routed through those schemas would have silently erased every seat identity and collapsed Business members back to the pre-fix behaviour.- The test suite must be run against a built tree.
test/standalone-cli.test.tsexercises the packaged CLI and requiresdist/, so its 12 cases fail in a clean checkout untilnpm run buildhas run.
Notes
- Model pools are not rewritten on upgrade. A legacy workspace-wide pool entry keeps matching every seat in its workspace until the next
codex-pool add/removemigrates it, and that migration is skipped entirely while project-scoped account storage is active. - Records already corrupted by #230 share one credential across several slots. They are reported by
codex-doctorandcodex-healthbut are deliberately not auto-collapsed: merging stored account records risks discarding a single-use refresh token, which would permanently break the account β the same reasoning applied to the duplicate rows left in place by #227. Remove the affected slots and re-login each member separately.
[6.12.1] - 2026-08-12
Fixed
- A pool mutation that had already been written to disk could be reported as a fatal lock error.
updateModelAccountPoolclassified only lock acquisition failures, so therelease()in itsfinallyblock could still reject and replace the mutation's return value β proper-lockfile rejects withERELEASEDwhen the lock was compromised mid-mutation, withENOTACQUIREDwhen the entry was dropped, andremoveLockpropagates any non-ENOENTerror straight fromrmdir, which on Windows means a lock directory held open by an antivirus scanner or the search indexer surfaces asEPERM/EBUSY. The change landed, the tool said it had not. Release failures are now downgraded to a warning: the config is already durable at that point, and a leftover lock directory is reclaimed bystalewithin ten seconds. Reported by @AceRothstein71. (#224, #225) - A lock going stale mid-mutation killed the plugin process.
lock()was called without anonCompromisedhandler, so proper-lockfile's default(err) => { throw err }fired from inside an fs callback. Nothing in this process installs anuncaughtExceptionhandler, so a blocked event loop or another process reclaiming the entry took the whole plugin down instead of failing one call β the "afterwards it completely stalls the sub tasks" half of the report. The mutation is already in flight and cannot be rolled back, so a compromised lock is now recorded and the subsequentERELEASEDtolerated. (#224, #225) - Windows lock contention was not recognised as contention at all.
ELOCKEDwas the sole classifier, but proper-lockfile forwards raw fs errors from the lock directory'smkdir/stat/rmdirinto its retry loop, so the error that finally escapes is not alwaysELOCKED. On Windows a lock directory held open by another process surfaces asEPERM/EBUSYrather thanEEXISTβ the same classrenameWithWindowsRetryalready tolerates one layer down β so the retry-guidance path was skipped entirely and users saw a hard error. Now classified with the existingisWindowsLockErrorpredicate, guarded to win32 so a POSIXEPERMstays fatal. (#224, #228) - Concurrent
codex-poolcallers each paid the full retry budget, one after another. Every mutation serializes behind an in-process queue, and each queued caller independently waited out roughly three seconds of retries against the same foreign holder β ten parallel calls blocked for ten times the budget and then all failed anyway. Once one call establishes that the lock is held externally, callers arriving within the next second use a short probe budget and degrade immediately; any successful acquisition restores the full budget, so an isolated collision still gets the patient path. (#224, #228) - The lock-contention response broke callers harder than the error it replaced. The degraded JSON dropped
pool,dryRun,restartRequired,previousConfiguredCountandpreviousPoolMode, so a consumer readingpool.accountsgot aTypeErrorwhere it had previously seen a plain lock error. Nothing is mutated on that path, so the response now reports the pool still on disk, in the same shape as a successful mutation. It also emittederror: "config_locked"while the class wasConfigLockContentionErrorand the codeCODEX_CONFIG_LOCK_CONTENTIONβ three spellings of one condition, none of which could be traced from tool output to the codebase or the logs. The wire format now carriesCODEX_CONFIG_LOCK_CONTENTION. (#228) - Lock contention was modelled as a configuration error.
ConfigLockContentionErrorextendedConfigError, whose documented meaning is non-retryable bad user configuration β missing TTY, malformed CLI input, bad format flags. Any handler catchingConfigErrorto advise "fix your configuration" and stop retrying would have given exactly the wrong advice for a condition that resolves on its own. It now sits with the transient family and carriesretryable: true. (#228) - The multi-worktree collision warning never actually throttled. The throttle keyed on the foreign process's
pidandstartedAt, so a peer that restarted β or a series of short-lived sessions β minted a fresh identity on every probe and the log spam the throttle exists to suppress continued unabated. Those dead identities also evicted live ones from the bounded map, which could stop a genuinely recurring collision from ever being deduped. Now keyed on storage path and host. Separately, the check recorded the warning as delivered before the caller emitted it, so a logger that threw silently suppressed the next sixty seconds of collisions; the check and the record are now separate, with the record after the emit. (#228) - One ChatGPT login holding two workspace subscriptions collapsed onto a single quota pool. With one email or Apple ID on, say, Team and Plus,
codex-limitsreported the same plan and percentage for every entry,codex-switchkept draining the same pool, and logging in under the other workspace appeared to overwrite every entry. The OAuth flow requestsid_token_add_organizations=true, so the id_token lists every organization the login belongs to, and account selection persisted one entry per organization β but all of those entries shared the login's single OAuth token. The Codex backend meters quota by thechatgpt-account-idheader and ignores organization ids, so an entry whose id was an organization id did not fail loudly; it silently fell back to the token's default subscription. Each workspace subscription is a distinct ChatGPT account with its ownchatgpt_account_idclaim, so separate tokens are what produce separate quotas. One login now persists exactly one account, bound to the token-scoped account id and labelled with the workspace that was selected; a secondopencode auth loginunder the other workspace appends a separate account with its own token, while re-login under the same workspace still updates in place. Entries persisted by an earlier release keep their stored organization id but are now routed through the token's account id, so they reach a real pool instead of being silently mis-billed. Reported by @JackTheCoconut, who identified the root cause empirically against/backend-api/wham/usage. (#226, #227)
Internal
- Duplicate account rows written by the previous one-entry-per-organization behaviour are deliberately left in place rather than auto-collapsed: merging stored account records risks discarding a single-use refresh token, which would permanently break the account.
docs/troubleshooting.mddocuments the fresh-login path to a clean pool. (#227) - Regression coverage for every fix above is pinned against the pre-fix build rather than merely asserting the fixed behaviour: each fix was reverted in isolation and the corresponding test confirmed to fail. Three tests that shipped with the original lock work did not discriminate β one passed unchanged against
main, one asserted a hardcoded forward-slash path and failed on every Windows run, and the contention suite mockedproper-lockfileentirely, so nothing verified the single assumption the whole degrade path rests on. A real, unmocked foreign lock that outlasts the retry budget now covers it. (#228) - The four tests that asserted one account entry per organization encoded the behaviour #226 corrects, so they were rewritten to the new contract rather than deleted, and the
persistAccountPooldeduplication they incidentally covered is now pinned directly. (#227)
6.12.0 (2026-08-08)
Added
Fixed
- release: preserve unprefixed release tags (3c5f71d)
[6.11.4] - 2026-08-04
Fixed
- An account with no weekly quota left was tried again on every prompt. The request failed, the plugin rotated away, and the cycle repeated on the next prompt β the account was never remembered as spent. Two independent defects on the same path produced it. First, nothing consumed the quota headers the backend puts on every response:
x-codex-secondary-used-percent: 100and its reset time were parsed only for the TUI status line, so the rotation layer could rediscover exhaustion only by failing another request, however recently the server had reported it. Second, when a429did arrive,parseRetryAfterMscollapsed the primary (5h) and secondary (weekly) reset-at headers withMath.min; the 5h reset is always the sooner one, so the persisted block expired with the wrong window and the spent account walked straight back into rotation. A body-suppliedretry-afterwas worse still, being capped at five minutes. A shared parser for thex-codex-{primary,secondary}-*headers now backs both the request path and the TUI cache, so the two cannot drift on what "exhausted" means, and it accepts the-reset-after-secondsand ISO-8601-reset-atforms the request path previously ignored. A window reportingused-percent >= 100now outranks every other signal, uncapped, and when several windows are spent the latest reset wins rather than the soonest. The block is applied on every response, success or failure, so an account that reports 0% left leaves rotation before the next prompt instead of after another failed request. Windows the plan has switched off (window-minutes: 0, which still report a used percent) are excluded, so they cannot block an account that has quota. Reported by @Grelo4ka. (#218, #219) - A short rate limit could shorten a week-long quota block. The new block preserved the longer reset only within its own method, while
markRateLimitedWithReasonstill assigned the samerateLimitResetTimeskeys unconditionally β so a concurrent in-flight request landing an ordinary429with a 30-second retry-after overwrote a weekly block and made the account selectable again almost immediately, reproducing the original bug through a second door. Every writer now goes through one helper that keeps whichever block runs longer. A zero-length retry keeps its existing meaning of "the window has elapsed" and clears the keys explicitly; the previous code wrotenowMs()and letclearExpiredRateLimitsdrop it, which a monotonic write would otherwise defeat. Nothing in the request path passes zero β every server-derived delay is at least 1ms. (#219) - An ordinary throttle ignored two of the three reset formats. When no window reported exhaustion, the fallback read only numeric
x-codex-*-reset-atvalues, so a429carrying just-reset-after-secondsor an ISO stamp produced no candidate at all and fell back to the 60-second default β retrying before the reset the backend had actually given. It now reads the windows through the shared parser, skipping plan-disabled windows, and keepsx-ratelimit-resetas its own candidate. (#219) - A second opencode process could erase a weekly quota block.
saveToDiskblind-overwroterateLimitResetTimesfrom the saving process's snapshot; rate-limit state was deliberately last-writer-wins, which is fine for a 5h window both processes rediscover within minutes and not fine for a block worth days. A process holding a stale snapshot saved over the block another had just recorded, and the exhausted account returned to rotation on the next reload. Saves now merge the on-disk resets, keeping the longer block per quota key, inside the storage transaction that already adopts newer credentials β running before it, because for records without workspace ids the refresh token participates in the identity key and adopting a rotated token first would change which disk record an account matches. Only blocks still in the future are adopted, so an expired entry another process has not pruned cannot be resurrected. Live in-memory state is deliberately untouched: unlike a consumed refresh token, a missing block is self-correcting, because the next response re-applies it from the quota headers.codex-doctor --fixis unaffected β it persists through its own transaction, so an explicit repair still clears blocks. (#219)
Internal
- Regression coverage for every fix above is pinned against the pre-fix build rather than merely asserting the fixed behavior: each new assertion was run against the preceding commit and confirmed to fail there. The proactive gate is additionally covered end-to-end through the request path, which is where a wiring defect would hide β the first attempt at that test passed against a fully mocked
AccountManagerthat had nomarkQuotaExhaustedat all, with the resultingTypeErrorswallowed by the surrounding bookkeeping guard. - A known limitation of the cross-process merge is pinned rather than papered over. A record carrying neither
organizationIdnoraccountIdis identified by its refresh token, so once another process rotates that token the two records share no identifier and the merge cannot match them. Verified empirically: the merge no-ops and the on-disk block is dropped β never mis-assigned to a different account β which is also why a positional fallback would be worse, since account order is not stable across processes. The same identity miss makesadoptNewerDiskCredentialsoverwrite a newly rotated single-use refresh token, which is the higher-severity failure that method exists to prevent; it is pre-existing, tracked separately, and asserted in the same test so both expectations flip together when storage carries a rotation-invariant account id. (#221) ci.ymlgained aworkflow_dispatchtrigger, so the full gate can be started on demand from the Actions tab instead of by pushing a throwaway commit. The existing concurrency block needed no change:cancel-in-progressevaluatesgithub.event_name == 'pull_request', which is false for a manual run. (#220)
[6.11.3] - 2026-08-02
Fixed
- A successful
opencode auth logincould add an account that was already disabled, annotatedRe-auth required for missing OAuth scope(s): openid, profile, email, offline_access.All four required scopes reported missing at once is the signature of scope metadata being absent, not denied βgetMissingRequiredOAuthScopes(undefined)returns the entire required set.initializeFromStorageenforced the requirement asymmetrically: the stored-account path guarded on whether a scope had actually been recorded, but the twoauthFallbackpaths did not, so a scope-less host credential was read as "nothing was granted" and the account was pushed withenabled: false. A scope-less credential is normal rather than suspicious βrefreshAccessTokendeliberately omitsscopewhen the token response does, because callers resolve it asresult.scope ?? existing.oauthScope, and the host OpenAI backfill wrote itsauth.jsonentry without one; either is enough to reach the fallback path with no scope. Enforcement now fires only when the granted scope is genuinely known, and the stored pool scope and the matching host credential are weighed together, since they describe one grant and a partial value on either side alone must not strand an account the other already vouches for. An explicit partial grant still disables and still annotates, unchanged. Accounts wrongly disabled by 6.11.2 are re-enabled and their generated note stripped, in memory and flushed to disk so the TUI, the CLI, and every other direct storage reader stop reporting stale state β re-login alone could not clear it, because the disabled state was persisted. An account disabled by hand, carrying no generated note, is left disabled.scopeis also normalized at every boundary where it enters the system: a blank value previously survivedjson.scope ?? SCOPEand then overwrote known-good metadata through the?? existing.oauthScopechains, and the host backfill now carries the pool's scope across so a restored credential is not scope-less on the next load. Reported by @Grelo4ka. (#213, #214) - A record could carry two contradictory re-auth notes, the stale one first. Note de-duplication matched on an exact sentence, so a record whose missing-scope set had changed since the note was written kept both β the 6.11.2 population claims all four scopes are missing, so the moment a real but partial scope became known the sets differed and both sentences stuck, telling the user to re-authenticate for scopes that were not in fact missing. The note is now replaced rather than appended to, preserving any operator-authored text ahead of it. (#215)
- One transient storage read failure disabled the plugin until OpenCode was restarted.
loader()assigns its account-manager promise to the module-level cache before awaiting it, and nothing cleared that cache when the load rejected βinvalidateAccountManagerCache()only runs on explicit account mutations. A rejected promise therefore stayed parked in the cache and every later request re-awaited the same rejection, so the failure outlived its own cause: a momentary Windows file lock or a partially-written save during a concurrent write was enough to break every subsequent request indefinitely. This repo already treats Windows lock contention as expected βlib/storage.tsretries renames for exactly that reason β so the trigger was realistic rather than theoretical. The cached promise is now evicted on rejection, guarded by identity so a concurrent reload's newer promise is not discarded. The failing call still rejects; only the next one gets a fresh attempt. (#216)
Internal
- Regression coverage for the scope paths is pinned against the pre-fix build rather than merely asserting the fixed behavior. An initial integration test seeded the pool with a scope and asserted the account stayed enabled β which passed against the broken code too, since that path resolved the stored scope, found it complete, and left the account alone. The account in the report is added, which places it in the host-fallback branch, reached only when the credential matches nothing in the pool; the suite now drives that branch, and 6 of its 11 cases fail against the previous release while the other 5 are deliberate controls that must pass on both sides. (#214)
eslint.config.jsignoreddist/but notcoverage/, soeslint . --max-warnings=0β what the pre-commit hook runs β failed on vitest's generated HTML report and its vendored JavaScript after anynpm run test:coverage.coverage/is gitignored, so this was purely a lint-configuration gap. (#216)
[6.11.2] - 2026-07-31
Fixed
warmstill failed every account withHTTP 400, and the cause was never the model. The warm ping sent its JSON body without acontent-typeheader, sofetchapplied its default for a string body βtext/plain;charset=UTF-8β and the backend rejected the request with{"detail":"Unsupported content type"}before it ever read the model. This was invisible on the live request path, which wraps OpenCode's ownRequestInitand therefore inherits a content type;warmAccountWindowis the only caller that builds its headers from nothing and sends a body, so it was the only one affected. The header is now set explicitly, matchingcodex-reset.ts, the one other bodied POST built oncreateCodexHeaders. Verified against the live API: the identical request body returns400 {"detail":"Unsupported content type"}without the header and200with it, on an account fully entitled togpt-5.5β confirming this was never account-, plan-, or entitlement-specific. Reported by @Grelo4ka. (#210)- A warm
400that was not an entitlement error was reported as a model problem. The400branch classified every response asunsupported-model, so a transport-level failure entered the entitlement fallback path and surfaced as though the account lacked the model. It now gates ongetUnsupportedCodexModelInfo, the same predicateresolveUnsupportedCodexFallbackModelalready applies internally, so entitlement400s keep the 6.11.1 fallback behavior unchanged and everything else fails immediately carrying its real upstream message. (#210)
Internal
- Warm request tests now pin the outgoing content type by materializing a real
Requestfrom the captured init. Asserting on the header object alone could not catch the defect, because thetext/plaindefault is applied byfetchat send time rather than by the header builder β the suite passed against a mock while the shipped request was rejected. The new assertion fails against 6.11.1 withexpected 'text/plain;charset=UTF-8' to be 'application/json'. The 6.11.1 note attributing #210 to the retiredgpt-5.4entry point was corrected in-code; that change was still worth keeping on its own merits, but it was not what #210 was.
[6.11.1] - 2026-07-30
Fixed
warmfailed every account withHTTP 400, from two independent causes. The warm ping was pinned togpt-5.4, an id no longer present in the installer's shipped model catalog and listed in the installer's stale managed keys, so it is actively removed from user config β accounts without that entitlement were being pinged with a model they could not use. The entry point is nowgpt-5.5, the generally-available anchor that every GPT-5.6 preview tier already degrades toward in the shared fallback chain. Separately,warmAccountWindowclassified only429and dead-ended every other status, so an entitlement400could not recover the way live chat traffic does: amodel_not_supported_with_chatgpt_accountresponse now walks the shared unsupported-model chain (gpt-5.5βgpt-5.4βgpt-5.4-miniβgpt-5.4-nano), bounded by an attempt budget derived from the chain itself rather than hardcoded, sincewarmfans out across accounts concurrently. Because the warm body is built outside the request transformer, it now asks the transformer's canonical clamp what"none"resolves to on the target model instead of keeping a private copy of that rule β a fallback hop onto a model that rejects"none"would otherwise be a fresh400. Warm failures now report the sanitized upstream response body instead of a bare status code. Reported by @Grelo4ka. (#210)limitsprinted the account list and no limits at all. The command computed rate-limit state into its payload but the printer never rendered that field, so its output was byte-identical tolist. Rendering it alone would not have been sufficient: the persistedrateLimitResetTimesstays empty until an account has already been rate-limited, and it holds reset timestamps rather than the weekly and 5-hour usage the command advertises.limitsnow reads the model-independent/wham/usageendpoint through the same compiled runtime the in-conversationcodex-limitstool uses, with matching workspace deduplication, window titles, and summaries, and reports per-account failures inline with a non-zero exit. This makeslimitsa network call that can refresh a token where it was previously a purely local read;rateLimitResetTimesis retained in the--jsonpayload for existing consumers, and--tagnow gates which accounts are contacted rather than only which are displayed, so an untagged account is neither billed a usage fetch nor has its credentials refreshed. Reported by @Grelo4ka. (#209)
Security
- Per-account
limitserrors are redacted before output. The error path formatted messages with a helper that performs no redaction, andensureCodexUsageAccessTokencan surface a raw OAuth refresh response body β truncation alone does not protect bearer, JWT, API-key, or refresh-token material from stdout,--jsonoutput, terminal history, or CI logs. Messages now pass through the logger's token patterns.
Internal
- CI runs
npm run buildbeforenpm test.dist/is gitignored and the standalone CLI tests load the compiled warm/limits runtime out of it, so the previous step order could not have passed on a clean checkout; removingdist/fails 12 of the 14 standalone tests, confirming the ordering was load-bearing rather than cosmetic.
[6.11.0] - 2026-07-28
Added
- Added a cache-only
updatecommand and provider-preservinginstall --plugin-onlymode. Updating no longer requires invoking the provider/model installer, and manual update notifications now recommend the config-safe command. Contributed by @lubshad. (#207)
Changed
- Default install now manages only the OpenCode/TUI plugin entries and preserves
provider.openai; model catalogs require explicit--modern,--full, or--legacy. Installer writes and backups are skipped when merged configuration is semantically unchanged, plugin-only mode rejects non-object JSON roots, dry-run diffs report changed paths without values, and managed cache cleanup covers bare and@latestlayouts with retries for transient Windows cache locks. Note that the--variantreasoning presets andgpt-5.5-fastare defined only by the shipped catalogs, so a flagless install leaves model definitions entirely to OpenCode β install with--modernif you want them. Contributed by @lubshad. (#207)
Fixed
- Terminal quota checks no longer send a synthetic model request. Checking quotas from the account menu previously POSTed a "quota ping" completion to
/codex/responses, walking a list of candidate models until one was accepted, purely to scrapex-codex-*rate-limit headers off the response. It now reads the model-independent/wham/usageendpoint directly and formats the shared usage windows, plan type, credits, code-review limit, and any additional limits. Free-plan accounts are handled without selecting a model at all. Deactivated-workspace and invalidated-token responses are still normalized to the canonical errors that flag an account forcodex-doctor --fix, and the sanitizedcodex-limitserror path is unchanged. Contributed by @lubshad. (#208) - Corrected the install documentation for the new plugin-only default: the getting-started quickstart now leads with
--modernso the--variantpresets it demonstrates actually exist, andconfig/README.md,CONFIG_FIELDS.md,troubleshooting.md, and theARCHITECTURE.mdCLI diagram no longer attribute the base OAuth catalog to a flagless install or implyupdateaccepts--no-cache-clear.
Security
- Cleared every outstanding dependency advisory;
npm run audit:cinow reports 0 vulnerabilities.honomoved to 4.12.32, resolving ahono/jsxcross-request context disclosure, a server-side XSS via thecx()escaping bypass, and a header de-duplication defect β this also cleared the advisory inherited by@openauthjs/openauth.seroval/seroval-pluginsmoved to 1.5.6, resolving a criticalfromJSON()promise-resolver type confusion that could invoke attacker-controlled methods during deserialization (CVSS 9.8) reached throughsolid-js.brace-expansionandpostcsswere also pinned to patched releases.
[6.10.1] - 2026-07-23
Fixed
- Account verification consumed single-use refresh tokens without persisting the rotation, so
codex-health,codex-doctor --fix, andcodex-refreshbricked the accounts they checked: verifying an account exchanges its refresh token, which OpenAI rotates and invalidates on use, but health and doctor treated the check as read-only and never saved the new credential. The consumed token stayed on disk and the next load returnedrefresh_token_reusedfor every verified account until re-login. All three tools now persist the rotated credential in a storage transaction before reporting, through a shared refresh/persist path that skips intentionally-disabled accounts without touching their token, propagates a rotated token shared by workspace-sibling records, and reconciles concurrent storage changes by stable account identity (organizationId β accountId β refreshToken) rather than list position.codex-doctor --fixreloads diagnostics after applying fixes so the reported health can never contradict the live verification result, and marks refresh-verification failures as blocked with a re-login next action. (#205) - The cached account-manager reload leaked a shutdown handler on every
codex-health/codex-refreshcall and could overwrite freshly rotated tokens. It now flushes the outgoing manager's pending debounced save and disposes its shutdown handler before installing the reloaded instance β mirroringinvalidateAccountManagerCacheβ and is error-guarded so a reload failure degrades gracefully instead of crashing an already-successful response. Theaccount.selectevent handler reuses the same safe reload. A duplicaterefresh-verification-failedfinding incodex-doctoroutput was also removed. (#205) - The OAuth callback success page was broken by the strict callback Content-Security-Policy: it depended on inline scripts, external Google Fonts, and inline styles the CSP blocked, so it rendered as an unstyled white page exposing raw unicode escape sequences. It is now a compact static page whose only stylesheet is bound to a per-request CSP nonce, with the policy tightened to
default-src 'none'; script-src 'none'; frame-ancestors 'none'; base-uri 'none'; form-action 'none'andCache-Control: no-store/Referrer-Policy: no-referreradded to the response. No external font or script is loaded. (#206)
[6.10.0] - 2026-07-20
Added
accountToastsβ opt out of the account-selection toast: a new boolean config field (defaulttrue) with env overrideCODEX_AUTH_ACCOUNT_TOASTSgates only the informationalUsing <account> (N/N)toast the plugin renders when it selects or rotates to an account. Because OpenCode draws that toast over the output rather than reserving layout space, during a large diff it could cover several lines until it faded, and there was previously no way to disable it βCODEX_AUTH_TOAST_DURATION_MShas a 1000 ms floor and the debounce only suppresses repeats. SettingaccountToasts: false(orCODEX_AUTH_ACCOUNT_TOASTS=0) hides just that informational toast while every warning and error toast β rate-limit switches, expired-auth recovery, unsupported-model and retry notices β stays visible. Resolution follows the standard env-over-config precedence ("1"enables,"0"disables), and like the other plugin settings it lives inopenai-codex-auth-config.json, which the installer never rewrites, so the opt-out persists across upgrades. Reported by @aic0d3r. (#203)
[6.9.1] - 2026-07-18
Fixed
gpt-5.6-solstill rejected through the plugin after the 6.8.2 identity fix (model_not_supported_with_chatgpt_accounton every pooled account while terra/luna pass, and sol works in the Codex TUI and in plain opencode on the same accounts): the remaining difference was who the plugin claims to be, not the request version or payload. The upstream model catalog gates sol, terra, and luna identically (minimal_client_version: 0.144.0,use_responses_lite: true), so no version- or shape-level cause can produce a sol-only failure; and plain opencode's native ChatGPT-Codex path does not imitate the Codex CLI at all β it sendsoriginator: opencodewith anopencode/<version> (<platform> <release>; <arch>)User-Agentto the same/backend-api/codex/responsesendpoint. The backend evaluates sol entitlement per originator, and for some account cohorts thecodex_cli_rsclaim from a non-Codex client fails that check while the host identity passes. The GPT-5.6 (responses-lite) tiers therefore now present the host (opencode) identity by default β the identity affected accounts are proven to pass sol with β while every other model keeps the Codex CLI identity from 6.8.2. Verified live on a sol-entitled account: both identities return 200 there, so accounts wherecodex_cli_rsalready works are unaffected.CODEX_AUTH_CLIENT_IDENTITY=codex|opencode(aliashost) forces one identity for all models. (#196, #201)- The advertised opencode version self-syncs with the real host build: when the host runtime injects its own
opencode/<version>User-Agenton the incoming request, that version is reused in the emitted identity instead of a baked-in constant;CODEX_AUTH_HOST_VERSIONoverrides both. (#201) CODEX_AUTH_CLIENT_VERSIONandCODEX_AUTH_HOST_VERSIONvalues are sanitized to safe product-token characters (whitespace and junk stripped, empty results fall back to the default), so a badly quoted environment value can no longer split theUser-Agentproduct token and silently break the version the backend parses. (#201)
[6.9.0] - 2026-07-17
Added
- Model-specific account pools: a new
modelAccountPoolsconfig field maps an effective model ID to a preferred set of accounts, so a model can be routed through the accounts entitled to it (e.g. pingpt-5.6-solto the accounts inside the Sol preview) instead of burning rotation attempts on accounts that will reject it. All three rotation strategies (sticky,round-robin,hybrid) restrict selection to healthy, selectable accounts in the preferred pool while one is available; existing quota, cooldown, and token-health rules still apply within the pool. If every preferred account is unavailable β disabled, unknown in this project, cooling down, or rate-limited β selection falls back transparently to the healthy general pool rather than failing the request. Model keys match case-insensitively against the effective model after request-model normalization; unmapped models and empty lists use the general pool directly. Pool references are stable account IDs, not indexes, so adding, removing, or reordering accounts never silently changes a model's routing. Contributed by @lubshad. (#200) codex-pooltool (the 24thcodex-*tool): inspects and mutates those mappings with ordinary 1-based account numbers (status,set,add,remove,clear, plusdryRun=truepreviews) while resolving and persisting only stable IDs. Config writes are atomic and serialized β an in-process promise queue plus aproper-lockfilecross-process file lock (new runtime dependency) β preserve every unrelated raw config key, and refuse to replace malformed JSON or an invalid existing pool rather than clobbering it. JSON output redacts stable account IDs unlessincludeSensitive=true. Because the plugin config is global while account storage is per-project by default, references that don't resolve in the current project are reported but never automatically pruned β they may be valid elsewhere. Mutations require an OpenCode restart to take effect. (#200)- Routing diagnostics (
codex-status,codex-dashboard,codex-metricstext and TUI views) now reportaccountPoolModeβgeneral,preferred, orgeneral-fallbackβ andconfiguredAccountPoolSize, so a fallback out of a configured pool is visible instead of silent. (#200)
[6.8.2] - 2026-07-16
Fixed
gpt-5.6-solrejected through the plugin while working in the Codex CLI/TUI for the same account: two request-identity mismatches versus upstream Codex could make the backend evaluate a sol request against the wrong client or workspace context and returnmodel_not_supported_with_chatgpt_accountfor an entitled account. First, the plugin declaredoriginator: codex_cli_rsbut sent the host runtime'sUser-Agent, while the backend reads the client version from the UA product token and the model catalog gates the 5.6 tiers onminimal_client_version: 0.144.0; requests now carry a Codex CLIUser-Agent(codex_cli_rs/<version> (<os>; <arch>)), withCODEX_AUTH_DISABLE_CODEX_USER_AGENT=1to opt out andCODEX_AUTH_CLIENT_VERSIONto override the advertised version. Second, the plugin pinnedopenai-organizationon every request for accounts whose token carries an organization claim β a header upstream Codex never sends on ChatGPT-Codex requests (workspace routing is carried entirely bychatgpt-account-id), and one that can shift the backend's entitlement evaluation to a workspace outside the narrow sol preview while the broader terra/luna preview still passes. The header is no longer sent by default; multi-org setups that relied on it can restore it withCODEX_AUTH_SEND_ORGANIZATION_HEADER=1. Follow-up to the #196 auto-fallback fix in 6.8.1, prompted by the report that sol works in the Codex TUI and in opencode without the plugin but not through it; needs verification by an affected preview account. (#196)
[6.8.1] - 2026-07-15
Fixed
gpt-5.6-sol(and the other 5.6 tiers) no longer hard-fails withmodel not supportedwhen the account is outside the GPT-5.6 preview. 6.7.0 documented that an account without access "degradesgpt-5.6-solβgpt-5.6-terraβgpt-5.6-lunaβgpt-5.5through the unsupported-model fallback chain", but the chain was only traversed underunsupportedCodexPolicy: "fallback": the default-selector auto-fallback allowlist listed onlygpt-5.5andgpt-5-codex, so under the defaultstrictpolicy a Sol request burned through every pooled account and returned an entitlement error. The three 5.6 tiers are now on the same auto-fallback path asgpt-5.5/gpt-5-codex, so the documented degradation works out of the box; opt out withCODEX_AUTH_DISABLE_GPT56_AUTO_FALLBACK=1. Baregpt-5.6is also canonicalized togpt-5.6-solinside the fallback resolver, matching the request path, so custom chains keyed asgpt-5.6resolve correctly. (#196)- Multi-process refresh-token clobber: a process persisting its in-memory account pool blind-overwrote the accounts file, including a refresh token another process had rotated after this process loaded its snapshot. Refresh tokens are single-use, so the clobbered token was dead on arrival β the next refresh with it failed and the auth-failure path eventually removed a still-valid workspace. Accounts now carry a persisted
tokenRotatedAtstamp (written on rotation and propagated to token-sharing siblings), and every save runs as a read-modify-write transaction under the storage lock that adopts newer on-disk credentials into both the persisted payload and live memory. Files from older builds have no stamp and keep the previous behavior. - Refresh queue burned consumed tokens after settlement: in-flight deduplication protected concurrent callers, but a caller that captured the pre-rotation token just before a rotation and refreshed after it settled re-consumed the single-use token and took a spurious 401. Settled rotations are now remembered for the queue's 30s entry TTL and served to late callers.
- Mutating tools clobbered concurrent rotation state:
codex-switch,codex-remove,codex-label, andcodex-refreshloaded the accounts file and saved a mutated snapshot as two independent lock acquisitions, silently overwriting rate-limit/cooldown/active-index state persisted in between. They now mutate and persist inside a single storage transaction against a freshly re-read snapshot (codex-refreshapplies only the refreshed credential fields after its unlocked network calls). codex-keychainmigrate/rollback TOCTOU: neither ran under the storage lock its docstring claimed; rollback's existence check and rename could silently overwrite a rotation save landing in between. Both now run as one critical section, and the keychain entry is deleted only after the rollback rename succeeds.- Reasoning effort leaked onto fallback models: the unsupported-model fallback carried the original model's
reasoning.effortonto the target, so agpt-5.6-sol-maxrequest degrading togpt-5.5sentmaxto a model that rejects it, turning the graceful degrade into a hard 400. Effort is re-clamped per fallback hop through the transformer's own family rules. - Truncated SSE streams misreported as success: a non-streaming response whose SSE stream ended without any terminal event was returned as the raw SSE text at the original 2xx status β the rotation loop recorded an account success for a failed turn and the client got an unparseable body. Such streams now surface as a 502
incomplete_streamerror; bodies with no SSE framing still pass through as plain JSON. - Uncapped
retry-afterheaders: the bodyretry_after_ms/retry_afterfields were capped at 5 minutes but the equivalent headers were not, so a bogus header (e.g.retry-after: 86400) benched a healthy account for hours, persisted across processes. Header values now get the same cap; quota reset-at headers remain uncapped since those windows legitimately reset hours out. - TUI status line trusted idle-stale quota snapshots: the 5-minute refresh returned any fingerprint-matching shared snapshot as current with no age check, so once the file existed the
/wham/usagefetch never ran again and an hours-old percentage (with a reset time already in the past) rendered as fresh. Snapshots older than one refresh interval now trigger a live re-fetch and render as stale only as a fallback. codex-resetidempotency key regenerated per invocation: the documented double-spend protection was inert because each attempt sent a fresh randomredeem_request_id. The key is now derived deterministically from the credit id, so a retry of the same credit is recognizable to the backend; a failed consume POST also reports the redemption outcome as unknown (redeemed: null) instead offalse, since the request may have reached the backend.- Proactive refresh skipped tokenless accounts: the missing-access-token check was unreachable when no expiry was recorded, so such an account only recovered through the reactive path.
[6.8.0] - 2026-07-14
Added
codex-resettool: view banked Codex rate-limit reset credits and redeem one to clear the current usage windows. OpenAI grants eligible plans a small number of reset credits, but exposes redemption only in the Codex desktop app, the IDE extensions, and the Codex CLI/usagescreen β so users of this plugin, Linux users in particular, had no way to spend a credit they already own without switching tools. The tool wraps the same two endpoints those clients use (GET /wham/rate-limit-reset-credits,POST /wham/rate-limit-reset-credits/consume), which authenticate exactly like the existing/wham/usagecall and therefore reuse its credentials, timeout, and error-body sanitization. Redeeming is irreversible and spends a finite credit, so it is never implicit:action="consume"only issues the POST whenconfirm=true, and otherwise renders the same previewdryRundoes. Each redemption carries a freshredeem_request_idso a retry cannot spend two credits, a credit id that is not currently available is refused rather than posted, and once the POST returns, a failure of the follow-up usage read is surfaced asusageErroralongsideredeemed: truerather than reporting a spent credit as unredeemed. The listing path is verified against the live backend; the redeem path is covered by tests against a mockedfetch. (#193, #195)
Fixed
- A rate-limit window the server reports as disabled is no longer rendered as a full quota. OpenAI encodes a switched-off window as
window-minutes: 0/limit_window_seconds: 0withused-percent: 0rather than omitting it, and both quota paths retained that window becauseused-percentwas numeric β surfacing a phantomquota 100%segment in the TUI status line next to the real weekly window (7d 77% Β· quota 100%). A window is now rejected on its explicit zero length; a window whose length header is absent is merely unknown and is still shown under the genericquotalabel. Two sibling defects of the same cause are fixed alongside the reported one: the/wham/usagepath rounded a zero-second window up to one minute viaMath.max(1, β¦), surfacing a disabled window as a real1mlimit, andcodex-limitsprinted both windows unconditionally, so it showed the same phantom row. Snapshots already written by an older build are filtered on read, so a poisonedoc-codex-multi-auth-tui-quota.jsonheals without the user deleting it. Reported by @aic0d3r, correlated with OpenAI temporarily disabling the 5-hour Codex limit for some paid plans. (#194, #195)
[6.7.1] - 2026-07-10
Fixed
- GPT-5.6 requests no longer fail with HTTP 400. The backend rejects any request carrying the
x-openai-internal-codex-responses-liteheader that does not also setreasoning.context = "all_turns", so everygpt-5.6-sol/gpt-5.6-terra/gpt-5.6-lunaturn in 6.7.0 failed withunsupported_valueonreasoning.context. Because that error is notmodel_not_supported_with_chatgpt_account, thesol β terra β luna β gpt-5.5degradation never triggered and every turn hard-failed. This matches upstreamcodex-rs/core/src/client.rs(build_reasoning), wherecontextis set toAllTurnsexactly whenuse_responses_liteis true and omitted otherwise. The field is written inside the responses-lite reshape, which is applied to astructuredClonefor lite models only, so the canonical body and the 5.6 β 5.5 fallback remain free ofcontext. Reported and fixed by @UnknOownU, verified against the live Codex backend. (#191, #192)
[6.7.0] - 2026-07-10
Added
- GPT-5.6 support:
gpt-5.6-sol,gpt-5.6-terra, andgpt-5.6-luna, plus baregpt-5.6as an alias for the Sol flagship tier. Reasoning-effort support follows the Codex model catalog (codex-rs/models-manager/models.json) rather than the launch coverage: Sol and Terra exposelow/medium/high/xhigh/max/ultra, Luna stops atmax, and no tier acceptsnoneorminimal(both floor tolow). Requestingmaxorultraon a pre-5.6 model steps down toxhigh, then tohighwherexhighis unsupported.ultrais accepted as an alias but never reaches the backend β Codex treats it as a client-side tier and rewrites it tomaxbefore sending (codex-rs/core/src/client.rs,reasoning_effort_for_request), and the subagent orchestration that distinguishes it lives in the Codex client, not the request body. It is excluded fromReasoningConfig["effort"]so the invariant is enforced at compile time. Each tier gets its own model family, keeping per-family rotation and rate-limit state isolated. (#189) - 5.6 is opt-in: the legacy
gpt-5alias and the plugin default still resolve togpt-5.5/gpt-5.4. Because GPT-5.6 shipped as a limited preview, an account without access degradesgpt-5.6-solβgpt-5.6-terraβgpt-5.6-lunaβgpt-5.5through the unsupported-model fallback chain instead of failing every request. (#189)
Fixed
- GPT-5.6 models are now served over the responses-lite request path. Their catalog entry sets
use_responses_lite: trueandtool_mode: "code_mode_only", and Codex sends those models a materially different body: tool definitions move intoinputas a leadingadditional_toolsdeveloper item, the base instructions follow as a developer message, top-levelinstructionsbecomes""andtoolsis omitted,parallel_tool_callsis forced off, imagedetailfields are stripped, and anx-openai-internal-codex-responses-lite: trueheader is sent. Sending the classic shape to acode_mode_onlymodel hands it tools in a field it does not read. The lite shape is applied at serialization, per request attempt, against the model actually being sent β never to the canonical transformed body β so agpt-5.6-solrequest that falls back togpt-5.5is re-serialized in the classic shape and keeps its tools rather than stranding them in anadditional_toolsitem. (#189) - System instructions now come from the Codex model catalog instead of the legacy
*_prompt.mdfiles, for every model the catalog covers (gpt-5.2,gpt-5.4,gpt-5.4-mini,gpt-5.5, and the three 5.6 tiers). Modern Codex carries a fullbase_instructionsstring per model and sends that; the plugin was sendinggpt_5_2_prompt.mdβ which opens "You are GPT-5.2 running in the Codex CLI" β togpt-5.2,gpt-5.4,gpt-5.4-mini, andgpt-5.5, so those models received the wrong system prompt and a false identity. Models absent from the catalog (gpt-5-codex,gpt-5.1*,gpt-5.2-codex,gpt-5.4-nano,gpt-5.4-pro) keep their prompt file, and a catalog miss falls back to the family prompt file. This changes the system prompt for existinggpt-5.2/gpt-5.4/gpt-5.4-mini/gpt-5.5users. (#190) - Catalog instructions cache per model id rather than per family.
gpt-5.5andgpt-5.4share thegpt-5.4family but carry different catalog text, so a family-keyed cache let one serve the other's prompt. Cache keys are additionally namespaced (catalog:/family:) because slug-space and family-space overlap:gpt-5.4-nanohas no catalog entry but belongs to thegpt-5.4family, which is itself a catalog slug β without namespacing the two shared amemoryCache/refreshPromiseskey and served each other's instructions inside the 15-minute TTL, reachable throughprewarmCodexInstructions. Disk paths gained acatalog-prefix, which also stops a pre-upgrade prompt-file cache from being read as catalog content, so no migration step is needed. (#190) models.json(~300KB) is fetched once per release tag and shared across models.fetchCatalogTextmemoizes the result and shares the in-flight promise, so the concurrent fan-out inprewarmCodexInstructionscollapses to a single download instead of one per catalog model. (#190)minimalreasoning effort no longer reaches the backend for GPT-5.6. It was clamped only inside the Codex branch, which keys off the model name containingcodexβ 5.6 slugs do not, sogpt-5.6-solatminimalsent an effort no 5.6 tier accepts. It now floors tolow, matching the existingnoneβlowrule. (#189)- Consolidated four duplicated reasoning-effort suffix regexes into a single helper.
gpt-5.1-codex-maxis a model id that ends in-max, not amax-effort variant ofgpt-5.1-codex; the newmaxsuffix is guarded by a negative lookbehind scoped to that branch alone, sogpt-5-codex-lowandgpt-5.1-codex-max-xhighstill parse correctly. (#189)
[6.6.0] - 2026-07-09
Fixed
- The plugin no longer calls
process.exit()from itsSIGINT/SIGTERMhandler while running inside the opencode host process. Because the handler hard-exited after cleanup, it won the race against opencode's own asynchronous shutdown, so pressing Ctrl+C exited without opencode printing the session id. Process termination is now owned explicitly rather than inferred from the signal: it defaults to off, so the plugin runs its cleanup and leaves termination to the host, and only an entrypoint that is the process opts in. The standalonewarmCLI β the onebinpath that installs the handler, because refreshing a token persists credentials under the storage lock β opts in viasetShutdownOwnsProcess(true)and now exits130/143(128 + signo) on a signal instead of reporting0, which masked an interrupt as success. The debounced-save flush is still awaited on shutdown in both modes, so the no-lost-rotations guarantee from #110 holds. (#187) runCleanup()no longer drops work when a drain overlaps. It emptied the cleanup queue before awaiting it, so abeforeExitfiring during an in-flight signal drain returned immediately against an already-empty queue rather than awaiting the real cleanup. Concurrent callers now share the in-flight promise, which is cleared once settled so sequential calls still re-drain βAccountManagerre-registers its flush handler after an externalrunCleanup(), and that contract is preserved. (#187)
Added
setShutdownOwnsProcess(boolean)is exported fromlib/shutdown.ts(and thelib/index.tsbarrel) so a standalone entrypoint can claim ownership of process termination. The flag is read at signal time, not captured when the handlers are installed, so an entrypoint may opt in after the firstregisterCleanup(). (#187)
[6.5.0] - 2026-06-30
Added
oc-codex-multi-auth warmstandalone CLI command runs the account warm-up directly β in plain Node via the packagebin, with no agent/model in the loop and therefore no token cost. It opens every enabled account's rolling usage window (one minimalPOST /codex/responseseach), skips disabled accounts, classifies a quota/usage-limit429as a distinct failure rather than "warmed", supports--json, and exits non-zero if any account failed. This addresses the request to run the warm-up as a direct command instead of an agent-invoked tool; the in-conversationcodex-warmtool remains for users who want it mid-session. (#182)
[6.4.1] - 2026-06-30
Fixed
- Local token-bucket depletion no longer leaks into persisted, cross-process state. 6.4.0 made a depleted account rotate by writing a short synthetic window into
rateLimitResetTimesβ but that field is saved to the shared accounts file and reloaded by every process, so one process exhausting its own in-memory proactive limiter could spuriously mark a server-healthy account as rate-limited in OTHER concurrent processes (the multi-process/PID-offset deployment this tool targets). Account selection (sticky,hybrid,round-robin) andgetMinWaitTimeForFamilyare now token-bucket-aware directly: a locally-depleted account is skipped in-memory with no persisted state, and an all-depleted pool waits for token refill instead of returning a spurious 503. The local skip also no longer records a server-429-style health penalty, so a busy-but-healthy account is not deprioritized inhybridscoring. (#183) codex-warmno longer reports a quota-exhausted account as "warmed". A429is now classified by reason: aquota/usage_limit429 (the window is already spent) is surfaced as a distinct failure, while a transienttokens/concurrent429 (window active) still counts as warmed. (#182)
[6.4.0] - 2026-06-30
Added
rotationStrategyconfig (envCODEX_AUTH_ROTATION_STRATEGY) selects the account load-balancing algorithm:hybrid(default, unchanged β stick to the current account while healthy, else score-select the next),sticky(drain-first β stay on one account until it is rate-limited/cooling down, then move to the lowest-indexed available account so load concentrates and the other accounts keep their quota windows in reserve), orround-robin(advance through accounts in order). Sticky directly addresses the "all accounts share an initiation time and hit weekly-quota cooldown together" problem under round-robin. (#183)codex-warmtool primes every enabled account's usage window by sending one minimal billable request (POST /codex/responses, the same shape the live request path uses for its quota probe) to each, so the rolling ~5h quota windows start at session start instead of only when rotation eventually lands on each account. A read-onlyGET /wham/usageonly reports server-side windows and does not open one, so warming must send a real inference request; the ping is deliberately tiny (reasoning effortnone, verbositylow, no stored conversation) to keep the quota cost negligible. Disabled accounts are skipped; per-account failures are reported without aborting the batch. Complementscodex-switch index=N, which already switches the active account on demand. (#182)
Fixed
- An account whose local client-side token bucket (the proactive rate limiter) is depleted is now given a short, auto-expiring rate-limit window so account selection rotates off it. Previously the request loop drained the bucket and skipped the account, but the drain-first
stickystrategy (and thehybridfast-path) re-selected the same depleted account on the next traversal iteration β theattempted-set guard then terminated the loop and returned a spurious 503 while other accounts still had quota. The window also feedsgetMinWaitTimeForFamily, so an all-depleted pool waits for token refill instead of failing fast. (#183) codex-doctor --fixnow clears stale account-health state on accounts whose token refresh succeeds during the repair: anauth-failure/network-errorcooldown and anyrateLimitResetTimesmarkers are removed once the refresh proves the credential is alive. Previously--fixrefreshed the token and tried to switch to the healthiest account, but left the stale cooldown/rate-limit state in place, so no account was eligible and the dead routing persisted β the only recovery was hand-editingoc-codex-multi-auth-accounts.json. The stale TUI quota cache is also cleared so diagnostics no longer reference an account index/count that no longer matches the pool. (fixes #171)codex-doctornow surfaces a finding when a disabledaccountIdSource: "token"entry shadows an enabled, org-backed account that shares its email (a leftover a fresh re-login can mint instead of updating the org account). It is flagged with acodex-removehint rather than auto-removed, because the only link between the two records is email and email-only merges must not collapse distinct multi-org accounts (#64). (#171)codex-doctor --fixno longer fails silently when a stored credential is genuinely dead: a failed token refresh now reportsN account(s) need re-loginand points the user atopencode auth login, instead of leaving an all-dark pool unrepaired with no surfaced cause. (#171)codex-healthnow surfaces the same recovery diagnostics ascodex-doctor(read-only): it flags accounts blocked only by a stale cooldown/rate-limit (pointing atcodex-doctor --fix) and disabled token-source duplicates (pointing atcodex-remove), and includesstaleRecoverableSlots/disabledDuplicateSlotsin JSON output. This addresses the part of the issue that namedcodex-healthexplicitly. (#171)- A disabled
accountIdSource: "token"duplicate (a re-login artifact) merging into the real org-source account by email no longer disables the canonical account. Storage dedup now lets the org account's ownenabledstate govern the merge, so a single-account pool can no longer end up dark and unrecoverable; fail-closed is preserved for genuinely user-disabled accounts. (#171) - Storage dedup now compares account emails case-insensitively, matching the
codex-doctor/codex-healthduplicate detectors. PreviouslyUser@Example.com(org) anduser@example.com(token re-login) escaped dedup yet were still flagged as removable, so the two layers disagreed on identity. (#171) codex-doctorandcodex-healthnow surface a disabled account that holds a fresh login credential β the fingerprint of a recent re-login that landed on a deliberately-disabled slot β so the user is told to re-enable it if intended instead of getting no signal at all. (#171)- Caller-cancellation during a retry/backoff wait now surfaces as a proper
AbortErrorcarrying the caller'ssignal.reason, instead of an opaquenew Error("Aborted")that dropped the cause. This aligns the retry-wait path with the fetch path and theisAbortErrorconvention inlib/codex-usage.ts, improving diagnosability of theError: Abortedsymptom. (#176) - Fixed a flaky email-masking property test (#163) that intermittently failed CI: the assertion used a fragile
masked.includes(local)substring check that false-positived when the local part repeats in the preserved domain (abc@abc.com) or contains the mask character itself (a.*@a.aaβa.***). It now asserts the exact masking contract (masked local = first β€2 chars +***), with deterministic regression cases. No production code change.
Security
- Bumped
honoto 4.12.26, resolving a high-severity Windowsserve-staticpath traversal via encoded backslash (%5C) and four moderate advisories (GHSA-88fw-hqm2-52qc, GHSA-j6c9-x7qj-28xf, GHSA-rv63-4mwf-qqc2, GHSA-wgpf-jwqj-8h8p, GHSA-wwfh-h76j-fc44). This also clears the transitive@openauthjs/openauthadvisory inherited via hono. - Overrode
viteto ^7.3.5, resolving a high-severityserver.fs.denybypass on Windows alternate paths and a moderate advisory (GHSA-fx2h-pf6j-xcff, GHSA-v6wh-96g9-6wx3) in the dev/test toolchain. - Overrode
@babel/coreto ^7.29.6 (transitive via@opentui/solid), resolving a low-severity arbitrary file read viasourceMappingURL(GHSA-4x5r-pxfx-6jf8) without a major version bump. - Added a
brace-expansion@>=5.0.0 <5.0.6override to ^5.0.6, resolving a moderate ReDoS-class advisory in the 5.x line.npm auditnow reports 0 vulnerabilities.
[6.3.3] - 2026-06-17
Fixed
- A stored OAuth account whose access token is invalidated server-side returns HTTP 401 (
Your authentication token has been invalidated. Please try signing in again.), but the request pipeline had no 401 handler, so persisted family routing kept pinning every request to the dead account slot. A request-path 401 is now treated as an account-health failure: the consumed token is refunded, the auth-failure counter is incremented, the refresh-token group is cooled down (or removed pastMAX_AUTH_FAILURES_BEFORE_REMOVAL), and the request rotates to the next healthy account. The counter is cleared on a successful request so a recovered account does not accumulate stale failures. (#172, fixes #171) codex-health/codex-doctornow flagtoken-invalidon an invalidated-token error (including a generic401 Unauthorizedbody), socodex-doctor --fixrepairs the active routing without manualactiveIndexJSON edits. (#172)
[6.3.2] - 2026-06-10
Fixed
gpt-5.3-codex-spark,gpt-5.3-codex, andgpt-5.2-codexare no longer collapsed togpt-5-codexbefore sending requests. Accounts where only the versioned model is available (not the basegpt-5-codex) no longer receivemodel_not_supported_with_chatgpt_accounterrors. (#170, fixes #169)- Added
gpt-5.4-fastandgpt-5.4-mini-fastas explicit model map entries so OpenCode fast-variant selectors resolve correctly. - Reasoning effort
-nonesuffix is intentionally absent for the three Codex families above;getReasoningConfig()coerces anynonerequest tolowfor these models as before.
[6.1.8] - 2026-04-29
Fixed
- Local
npm linkinstalls now run the CLI wrapper correctly by resolving symlinked bin paths before direct-execution detection. - Current audit validation follow-ups are resolved, including refreshed docs parity coverage.
- Request filtering now defaults missing or null
function_call.argumentsvalues to{}before forwarding.
[6.1.7] - 2026-04-25
Added
- OpenCode TUI prompt status plugin that shows the active Codex quota during sessions, including real response-header quota updates, account-aware display, color thresholds, and a quota details command.
- Daily npm update detection now clears the OpenCode-managed plugin cache on exit when a newer package version is available, so restarting OpenCode installs the latest plugin automatically.
Changed
- The installer now manages OpenCode
tui.jsonalongside the main plugin config so the TUI status module is available from the published package. - TUI startup keeps the home prompt clean and only shows quota status inside active sessions.
- Added an
autoUpdateconfig option andCODEX_AUTH_AUTO_UPDATE=0environment override for users who prefer manual update prompts.
Fixed
- Quota status cache writes no longer block the request response path and coalesce rapid duplicate writes.
- Account switching clears stale TUI quota state so the next session reflects the selected account.
- Multi-account quota status now follows the actual account used by the latest request, including non-
codexmodel families, so real response-header quota snapshots are not filtered out as stale.
[6.1.6] - 2026-04-24
Added
- OpenCode TUI prompt status plugin that shows the active Codex quota during sessions, including real response-header quota updates, account-aware display, color thresholds, and a quota details command.
Changed
- The installer now manages OpenCode
tui.jsonalongside the main plugin config so the TUI status module is available from the published package. - TUI startup keeps the home prompt clean and only shows quota status inside active sessions.
Fixed
- Quota status cache writes no longer block the request response path and coalesce rapid duplicate writes.
- Account switching clears stale TUI quota state so the next session reflects the selected account.
- Multi-account quota status now follows the actual account used by the latest request, including non-
codexmodel families, so real response-header quota snapshots are not filtered out as stale.
[6.1.5] - 2026-04-24
Changed
- Default installer mode now writes the compact OAuth model catalog so OpenCode's model picker shows base models only; reasoning depth is selected through the variant picker.
- Added
--fullinstaller mode for users who still want explicit selector IDs such asgpt-5.5-mediumandgpt-5.5-fast-mediuminstalled into the model picker. - Compact/default installs now prune explicit preset IDs and stale base model IDs from earlier catalogs so rerunning the installer actually cleans up the model picker.
[6.1.4] - 2026-04-24
Fixed
- Ship the
gpt-5.5-fastmodern config entry and explicitgpt-5.5-fast-{none,low,medium,high,xhigh}legacy selectors so OpenCode resolvesopenai/gpt-5.5-fast-mediumbefore plugin routing. - Clear OpenCode's newer package cache layout at
~/.cache/opencode/packages/{oc-codex-multi-auth,oc-chatgpt-multi-auth}@latestduring installer cache refresh. - Normalize stale managed file-path and
file:///.../node_modules/...plugin entries back to the officialoc-codex-multi-authpackage name when the installer runs.
[6.1.3] - 2026-04-24
Added
- Explicit
gpt-5.5-fast/gpt-5.5-fast-{none,low,medium,high,xhigh}entries in the model map, normalizing togpt-5.5. Without the explicit map entry, picking OpenCode's built-inGPT-5.5 Fastcatalog item fell through the regex fallback with no per-model config lookup, which contributed to theAll N account(s) failed (server errors or auth issues)symptom. - Scoped auto-fallback for GPT-5.5: when the backend returns
model_not_supported_with_chatgpt_accountforgpt-5.5, the plugin now routes the retry togpt-5.4automatically, even withoutunsupportedCodexPolicy: "fallback"orCODEX_AUTH_UNSUPPORTED_MODEL_POLICY=fallback. Opt out withCODEX_AUTH_DISABLE_GPT55_AUTO_FALLBACK=1. Legacy family fallback behavior is unchanged.
Removed
- GPT-5.5 Pro model map entries (
gpt-5.5-pro,gpt-5.5-pro-{medium,high,xhigh},gpt-5.5-pro-20260423*), config template entries inconfig/opencode-modern.jsonandconfig/opencode-legacy.json, theGPT_55_PRO_MODEL_IDconstant, thegpt-5.5-pro -> gpt-5.5fallback chain edge, and the related request-transformer / prompt-family branches. Per OpenAI's 2026-04-23 launch, GPT-5.5 Pro ships to ChatGPT only, not Codex; routinggpt-5.5-pro*through the Codex OAuth pipeline was producingmodel_not_supported_with_chatgpt_accounton every pooled account. Any user-typedgpt-5.5-pro*still canonicalizes togpt-5.5so the scoped auto-fallback chain can rescue it.
Fixed
- The terminal aggregator message in
index.tsno longer misreports across-the-pool entitlement 400s asserver errors or auth issues. WhenlastErrorCategory === "unsupported-model"at exhaustion, the response now names the model and points to the fallback env var. - Pre-existing
lib/request/fetch-helpers.tstypecheck regression from the 6.1.2 release:shouldRefreshToken(auth: Auth, ...)referenced anAuthtype that had been removed from the SDK import. Re-importedAuthfrom@opencode-ai/sdk.
[6.1.2] - 2026-04-24
Added
- GPT-5.5 2026-04-23 release presets in the shipped OpenCode config templates.
Changed
- Activate GPT-5.5 2026-04-23 across runtime model routing and align the runtime model mapping with the new release family.
Fixed
- Handle GPT-5.5 gating by falling back cleanly when the requested release is unavailable upstream.
[6.1.1] - 2026-04-22
Fixed
- Retry structured
service_unavailable_error/server_is_overloadedpayloads as server faults even on non-5xx responses, while preserving overloadretry_afterbackoff when the account pool is exhausted. - Retry live upstream
server_errorpayloads that arrive on non-5xx responses instead of falling straight through as unrecoverable failures. - Stabilize merged retry regression coverage so the overload and live
server_errorfetch-loop cases do not leak module state between tests.
[6.1.0] - 2026-04-17
Added
codex-keychainopt-in OS-keychain credential backend viaCODEX_KEYCHAIN=1(macOS Keychain / Windows Credential Manager / Linux libsecret) (#132, #133, #134)codex-diagredacted diagnostics snapshot tool for bug reports (#126)codex-diffredacted config/account comparator (#129)NO_COLORandFORCE_COLORenvironment-variable support in UI rendering (#126)- Multi-worktree collision detection with non-blocking warning (#130)
- Circuit-breaker half-open gate wired into request pipeline (#123)
- 20-scenario chaos fault-injection test suite (#128)
- Contract tests pinning OpenAI OAuth, Codex chat, and Codex SSE response shapes (#131)
- Dependabot, OpenSSF Scorecard, commit-msg hook, and release-please automation (#125, #127)
- CI matrix: Node 18/20/22 on Ubuntu + Node 20 on Windows (#111)
- Typed error hierarchy (BaseError + domain classes) in
lib/errors.ts(#120)
Changed
- Refactor:
index.tsreduced from 5975 to 3425 lines; all 18 tools extracted tolib/tools/*(#115, #121) - Refactor:
lib/storage.tssplit from 1419 to 79 lines across 12 submodules underlib/storage/(#116) - Refactor:
AccountManagersplit into 4 domain services (state, persistence, rotation, recovery) (#122) - Refactor:
lib/recovery.tsconsolidated to barrel pattern (#117) - Refactor: renamed
lib/runtime-contracts.tsintolib/oauth-constants.ts+lib/error-sentinels.ts(#118) - Refactor: Zod-validate remaining process boundaries (#119)
- Removed dead modules
lib/auth-rate-limit.tsandlib/audit.ts(854 lines total) (#109)
Fixed
- CRITICAL: Serialize
incrementAuthFailuresvia per-refresh-token promise chain to prevent lost auth-failure counts across shared refresh tokens (#108) - Destructive defaults:
importAccountsdefaults to timestamped backup;exportAccountsdefaults toforce: false;codex-removetool requires explicitconfirm: true(#108) - Shutdown SIGINT/SIGTERM now awaits debounced
flushPendingSave, preventing lost rotations (#110) schemaVersion > 3now throwsStorageError(UNSUPPORTED_SCHEMA_VERSION)instead of silently nulling data (#110)- V2 storage files are detected and either migrated or rejected explicitly (no more silent drop) (#113)
- Credential merge:
||Γ’β β??prevents empty-string tokens resurrecting stale older values (#112) REDIRECT_URIuses127.0.0.1literal for RFC 8252 compliance (#112)- Codex-CLI cross-process JSON now Zod-validated before merging (#112)
- Logger
TOKEN_PATTERNSextended to cover OpenAI opaque refresh/access/id tokens (#112, #126) - Installer
scripts/install-oc-codex-multi-auth-core.jsdeep-mergesprovider.openaiinstead of clobbering user customizations; added--dry-run(#114) - F1 keychain post-merge: partial-migration staleness +
clearAccountsordering + rollback silent-clobber + lexicographic-sort bug (#133, #134)
Documentation
- Full-repository audit delivered in
docs/audits/(#107) - README: added CI, Node, Scorecard, npm, license badges; new
Credential Storagesection (#124, #132) - CONTRIBUTING: local development, contract-fixture update, real-keychain testing sections (#124, #131, #132)
- SECURITY: backend threat-model update (#132)
- ARCHITECTURE.md refreshed to reflect v6 module layout (#124)
- CHANGELOG: restructured to Keep-a-Changelog v1.1.0 (#124)
Internal
- Per-file coverage floor (70%) for
lib/**andindex.tsinvitest.config.ts(#125) - Test count: 2088 Γ’β β 2234 (+146 regression + chaos + contract tests)
[6.0.0] - 2026-04-06
Added
- beginner operations toolkit: added
codex-help,codex-setup(withwizardmode + fallback),codex-doctor(fixmode), andcodex-nextfor guided onboarding and recovery. - account metadata commands: added
codex-tagandcodex-note, pluscodex-listtag filtering. - interactive account pickers:
codex-switch,codex-label, andcodex-removenow support optional index with interactive selection in compatible terminals. - backup/import safety controls:
codex-exportnow supports auto timestamped backup paths;codex-importaddsdryRunpreview and automatic pre-import backup on apply. - beginner safe mode config: new
beginnerSafeModeconfig key andCODEX_AUTH_BEGINNER_SAFE_MODEenv override for conservative retry behavior. - startup preflight summary: one-time startup health summary with recommended next action.
- breaking rebrand migration: current runtime storage now uses package-aligned files (
oc-codex-multi-auth-accounts.json,oc-codex-multi-auth-flagged-accounts.json) with automatic migration from legacy package-era and pre-package storage names on first load.
Changed
- account storage schema: V3 account metadata now includes optional
accountTagsandaccountNote. - docs refresh for operational flows: README + docs portal/development guides updated to reflect beginner commands, safe mode, interactive picker behavior, and backup/import safeguards.
- repository presentation refresh: rewrote the README as a landing page, added a public FAQ and code of conduct, refreshed package metadata, and removed stale CI/test claims from public docs surfaces.
- test matrix expansion: coverage now includes beginner UI helpers, safe-fix diagnostics edge cases, tag/note command behavior, and timestamped backup/import preview utilities.
- package line renamed: the supported package, repo, plugin entry, installer surface, and docs now use
oc-codex-multi-authinstead ofoc-chatgpt-multi-auth. - codex-first auth wording: OAuth options, installer guidance, and onboarding docs now describe the Codex-first flow directly instead of the older MULTI-branded labels.
Fixed
- non-interactive command guidance: optional-index commands provide explicit usage guidance when interactive menus are unavailable.
- doctor safe-fix edge path:
codex-doctor fixnow reports a clear non-crashing message when no eligible account is available for auto-switch. - first-time import flow:
codex-importno longer fails withNo accounts to exportwhen storage is empty; pre-import backup is skipped cleanly in zero-account setups. - installer cache hygiene: the installer now removes both the old and new package names from OpenCode cache metadata so cutover installs do not stay pinned to stale artifacts.
[5.4.8] - 2026-03-24
Added
- json codex-ops automation surfaces: read-only Codex ops now support
format="json"and expose routing visibility across status, metrics, dashboard, and doctor flows. - device-code login flow: added a first-party ChatGPT device-code auth path for SSH, WSL, and other headless environments.
Changed
- login finalization parity: regular OAuth, manual fallback, and device-code flows now share the same account-selection and persistence helpers.
- runtime contract parity hardening: centralized timeout, deactivated-workspace, and OAuth callback constants with dedicated runtime/doc parity coverage.
- dependency audit cleanup: refreshed the shipped dependency tree with updated
honoand pinned audit overrides for deterministic audit resolution.
Fixed
- storage import contract drift: preview and apply import flows now share one analysis path, keeping deduplication and count reporting aligned while preserving redacted backup failure reporting.
- deactivated workspace rotation: grouped refresh-token variants are removed together, traversal restarts onto healthy accounts, and the zero-removal fallback cools down the affected account safely.
[5.4.3] - 2026-03-06
Added
- gpt-5.4 snapshot alias normalization: added support for
gpt-5.4-2026-03-05*andgpt-5.4-pro-2026-03-05*model IDs (including effort suffix variants).
Changed
- legacy GPT-5 alias target updated:
gpt-5,gpt-5-mini, andgpt-5-nanonow normalize togpt-5.4as the default general family. - gpt-5.4-pro family isolation: prompt-family detection now keeps
gpt-5.4-proseparate fromgpt-5.4for independent prompt/cache handling while preserving fallback policy behavior (gpt-5.4-pro -> gpt-5.4). - OpenCode 5.4 template limits updated: shipped OpenCode config templates now set
gpt-5.4*context to1,000,000(output remains128,000) and docs now include optionalmodel_context_window/model_auto_compact_token_limittuning guidance.
Fixed
- 5.4.3 regression/test coverage alignment: expanded and corrected normalization, family-routing, and prompt-mapping tests for snapshot aliases, pro-family separation, and legacy alias behavior.
[5.4.2] - 2026-03-05
Added
- gpt-5.4 + gpt-5.4-pro runtime support: added model-map normalization and request-transform coverage for
gpt-5.4(general) and optionalgpt-5.4-pro. - gpt-5.4-pro fallback edge: default unsupported-model fallback chain now includes
gpt-5.4-pro -> gpt-5.4when fallback policy is enabled.
Changed
- template defaults updated to gpt-5.4: modern + legacy config templates now use
gpt-5.4variants as the default general-purpose family. - docs refresh for 5.4 rollout: README, getting-started, configuration, troubleshooting, docs index, and config docs now reflect
gpt-5.4defaults and optionalgpt-5.4-prousage. - test matrix expanded for 5.4: unit, integration, and property tests now explicitly cover
gpt-5.4andgpt-5.4-pronormalization/reasoning/fallback paths.
Fixed
- quota probe model order: quota snapshot probing now includes
gpt-5.4first before legacy Codex probe models.
[5.4.0] - 2026-02-28
Changed
- organization/account identity matching hardening: org-scoped matching and collision pruning now enforce accountId-aware compatibility to preserve distinct same-org workspace identities.
- id-token organization binding source strictness: id-token candidate org binding now prioritizes
idToken['https://api.openai.com/auth'].organizations[0].id.
Fixed
- organization-scoped account preservation: account restoration now preserves organization/workspace identity across token refresh and flagged-account recovery paths.
- no-org duplicate collapse alignment: fallback no-org duplicates now collapse consistently across storage, authorize, and prune operations.
- active-index remap stability: index remapping during collision pruning/dedupe maintains stable active-index selection after account deduplication.
[5.3.0] - 2026-02-22
Added
- workspace-aware account persistence: oauth workspace candidates are preserved as distinct account entries to keep per-workspace routing stable across multi-account sessions.
Fixed
- organization identity reconciliation: account restoration now preserves organization/workspace identity across token refresh and flagged-account recovery paths.
- verify-flagged restore identity loss: flagged-account restore no longer drops
organizationIdwhen anaccountIdalready exists.
Changed
- documentation alignment with current runtime structure: refreshed README and docs portal/architecture guides to reflect native-vs-legacy request transforms, workspace-aware identity behavior, and current preset/test counts.
[5.2.3] - 2026-02-21
Fixed
- tool-call compatibility with current OpenCode runtime: default request handling now preserves native OpenCode payload/tool definitions, avoiding bridge-side alias rewrites that could trigger invalid tool-call schemas.
- bridge/tool-name drift failures: Codex bridge instructions now anchor on the runtime-provided tool manifest and explicitly avoid translating/inventing tool names.
Changed
- request transform mode control: added
requestTransformMode(nativedefault,legacyopt-in) plusCODEX_AUTH_REQUEST_TRANSFORM_MODE=legacyfor compatibility fallback. - legacy codex-mode scope: Codex compatibility rewrites and bridge prompt shaping are now legacy-mode behavior; native mode keeps host request shape unchanged.
[5.2.1] - 2026-02-20
Fixed
- tool mapping conflicts in codex bridge/remap prompts: removed contradictory guidance that treated
patchas forbidden and aligned instructions soapply_patchintent maps topatch(preferred) oreditfor targeted replacements. - OpenCode codex prompt source brittleness: prompt fetch now retries across multiple upstream source URLs instead of relying on a single path that could return 404.
Changed
- prompt fetch configurability: added
OPENCODE_CODEX_PROMPT_URLoverride support and source-aware cache metadata so ETag conditional requests stay bound to the same source. - regression coverage + docs wording: updated prompt assertions/tests for the new
patch+editpolicy and refreshed architecture documentation text to match.
[5.2.0] - 2026-02-13
Added
- gpt-5.3-codex-spark normalization + routing: added internal model mapping/family support for
gpt-5.3-codex-sparkand Spark reasoning variants. - generic unsupported-model fallback engine: entitlement rejections now support configurable per-model fallback chains via
fallbackOnUnsupportedCodexModelandunsupportedCodexFallbackChain.
Changed
- unsupported-model policy defaults: introduced
unsupportedCodexPolicy(strict/fallback) with strict mode as default; legacyfallbackOnUnsupportedCodexModelnow maps to policy behavior. - entitlement handling flow: on unsupported-model errors, plugin now tries remaining accounts/workspaces before model fallback, improving Spark entitlement discovery across multi-account setups.
- fast-session reasoning summary: fast mode now uses
reasoning.summary = "auto"(invalid/legacy summary values sanitize toauto). - legacy fallback compatibility:
fallbackToGpt52OnUnsupportedGpt53/CODEX_AUTH_FALLBACK_GPT53_TO_GPT52now act as a legacy edge toggle inside the generic fallback flow. - documentation refresh: README, configuration, getting-started, troubleshooting, and config template docs now describe strict/fallback controls, Spark entitlement gating, and optional manual Spark template additions.
[5.1.1] - 2026-02-08
Fixed
- provider-prefixed model config resolution:
openai/<model>ids now correctly resolve to their base model config instead of falling back to global defaults. - codex variant option merging: variant suffixes like
-xhighnow applymodels.<base>.variants.<variant>options during request transformation.
[5.1.0] - 2026-02-08
Changed
- workspace candidate selection hardened: OAuth workspace auto-selection now prefers org defaults, id-token-selected workspace IDs, and non-personal org candidates before falling back to token-derived personal IDs.
Fixed
- business workspace routing: explicit org/manual workspace bindings are now preserved at request time and no longer overwritten by token
chatgpt_account_idvalues. - gpt-5.3-codex on Business accounts: fixed a dual-workspace path where requests could be routed to personal/free workspace IDs and fail with unsupported-model errors.
[5.0.0] - 2026-02-08
Changed (BREAKING)
- auth login interaction redesigned:
opencode auth loginnow defaults to the Codex-style dashboard flow (actions/accounts/danger zone) instead of the legacy add/fresh-only prompt. - styled codex tool output default:
codex-list,codex-status,codex-health,codex-switch,codex-remove,codex-refresh,codex-export, andcodex-importnow default to the new Codex TUI formatting; scripts parsing legacy plain output should update or setcodexTuiV2: false.
Added
- codex tui runtime controls: new config + env options for UI behavior:
codexTuiV2,codexTuiColorProfile,codexTuiGlyphMode,CODEX_TUI_V2,CODEX_TUI_COLOR_PROFILE, andCODEX_TUI_GLYPHS. - full account dashboard actions: interactive login now supports add/check/deep-check/verify-flagged/start-fresh, plus account-level actions (enable/disable, refresh, delete).
- dedicated flagged storage: introduced
openai-codex-flagged-accounts.jsonwith automatic migration from legacyopenai-codex-blocked-accounts.json. - ui architecture + coverage: added shared terminal UI runtime/theme/format modules and parity documentation (
TUI_PARITY_CHECKLIST.md) with focused tests.
Fixed
- disabled account safety: disabled accounts are now excluded from active/current selection and rotation paths.
- enabled-flag migration:
enabledaccount state now survives v1->v3 storage migration and persists reliably across save/load cycles.
[4.14.2] - 2026-02-08
Changed
- gpt-5.3 fallback default: fallback from
gpt-5.3-codextogpt-5.2-codexon ChatGPT entitlement rejection is now enabled by default for all users. - strict-mode opt-out: strict behavior is now opt-out via
fallbackToGpt52OnUnsupportedGpt53: falseorCODEX_AUTH_FALLBACK_GPT53_TO_GPT52=0.
Fixed
- unsupported-model handling: normalized the upstream 400 (
"not supported when using Codex with a ChatGPT account") to a clear entitlement-style error instead of generic bad-request handling.
[4.14.1] - 2026-02-07
Added
- fast session mode: optional low-latency tuning (
fastSession) withhybrid/alwaysstrategies and configurable history window (fastSessionMaxInputItems).
Changed
- prompt caching: codex + opencode bridge prompts now use stale-while-revalidate + in-memory caching; startup prewarms instruction caches to reduce first-turn latency.
- request parsing: fetch pipeline now normalizes
Requestinputs and supports non-string bodies (Uint8Array/ArrayBuffer/Blob) without failing request transformations.
Fixed
- trivial-turn overhead: in fast session mode, trivial one-liners can omit tool definitions and compact instructions to reduce roundtrip time.
[4.14.0] - 2026-02-05
Added
- gpt-5.3-codex model support: added end-to-end normalization and routing for
gpt-5.3-codexwithlow,medium,high, andxhighvariants. - new codex family key: account rotation/storage now tracks
gpt-5.3-codexindependently inactiveIndexByFamily.
Changed
- reasoning defaults:
gpt-5.3-codexnow defaults toxhigheffort (matching the current codex-family behavior), andnone/minimalare normalized to supported codex levels. - prompt fetch/cache mapping: prompt family detection now β¦92 tokens truncatedβ¦atency counters for the current plugin process.
- 401 diagnostics payload: normalized 401 errors now include
diagnostics(for examplerequestId,cfRay,correlationId,threadId) to speed up debugging. - stream watchdog controls: new
fetchTimeoutMsandstreamStallTimeoutMsconfig options (and env overrides) for upstream timeout tuning.
Changed
- request correlation: each upstream fetch now sets a correlation id, reuses
CODEX_THREAD_ID/prompt_cache_keywhen available, and clears scope after each request. - plan-mode tool gating:
request_user_inputis automatically stripped from tool definitions when collaboration mode is Default (kept in Plan mode). - safety prompt hardening: bridge/remap prompts now explicitly block destructive git commands unless the user asks for them.
- gpt-5.2-codex default effort: default reasoning now prefers
xhighwhen no explicit effort/variant is provided. - gitignore hygiene: local planning/release scratch artifacts are now ignored to keep working trees clean.
Fixed
- non-stream SSE hangs: non-streaming SSE parsing now aborts stalled reads instead of waiting indefinitely.
[4.12.5] - 2026-02-04
Changed
- per-project storage location: project-scoped account files now live under
~/.opencode/projects/<project-key>/openai-codex-accounts.jsoninstead of writing into<project>/.opencode/.
Added
- legacy migration: when the new project-scoped path is empty, the plugin now auto-migrates legacy
<project>/.opencode/openai-codex-accounts.jsondata on first load.
[4.12.4] - 2026-02-03
Added
- Empty response retry - Automatically retries when the API returns empty/malformed responses. Configurable via
emptyResponseMaxRetries(default: 2) andemptyResponseRetryDelayMs(default: 1000ms) - PID offset for parallel agents - When multiple OpenCode instances run in parallel, each process now gets a deterministic offset for account selection, reducing contention. Enable with
pidOffsetEnabled: true
Changed
{
"emptyResponseMaxRetries": 2,
"emptyResponseRetryDelayMs": 1000,
"pidOffsetEnabled": false
}
-
Environment variables:
-
CODEX_AUTH_EMPTY_RESPONSE_MAX_RETRIES -
CODEX_AUTH_EMPTY_RESPONSE_RETRY_DELAY_MS -
CODEX_AUTH_PID_OFFSET_ENABLED -
Test coverage - 1516 tests across 49 files (up from 1498)
Fixed
- PID offset formula - Fixed bug where all accounts received the same offset (now uses
account.index * 0.131 + pidBonusfor unique distribution) - Empty response detection - Hardened
isEmptyResponse()to correctly identify empty choice objects ([{}]) and whitespace-only content as empty - Test mocks - Fixed
index.test.tsmocks forcreateLoggerand new config getters (55 tests were failing)
Notes
- npm publish status: not published on npm (tag/release only).
[4.12.3] - 2026-02-03
Changed
- Test coverage - Up to 89% coverage (1498 tests)
- Code quality - Various improvements from audit
Fixed
- Account persistence fix - Accounts were being saved to the wrong location when
perProjectAccountswas enabled (default). The issue was thatsetStoragePath()only ran in the loader, but authorize runs before that. So accounts got written to the global path, then the loader looked in the per-project path and found nothing. Both OAuth methods (browser and manual URL paste) now init storage path before saving. (#19)
[4.12.2] - 2026-01-30
Fixed
- TUI crash on workspace prompt - Removed redundant workspace selection prompt (auto-selects default now). Added
isNonInteractiveMode()to detect TUI/Desktop environments. (#17) - Web UI validation error - Added validate function to manual OAuth flow for proper error messages instead of
[object Object].
[4.12.1] - 2026-01-30
Changed
-
Audit logging - Rotating file audit log with structured entries
-
Auth rate limiting - Token bucket rate limiting (5 req/min/account)
-
Proactive token refresh - Refreshes tokens 5 minutes before expiry
-
Zod schemas - Runtime validation as single source of truth
-
Stats
-
Tests: 580 ΓΒ’Γ’β¬Β Γ’β¬β’ 631 (+51)
-
All passing on Windows with
--pool=forks
Fixed
- Business plan workspace fix - Fixed the "usage not included" errors some Business plan users were hitting. Turns out we were sending a stale stored accountId instead of pulling the fresh one from the token - problematic when you've got multiple workspaces. (#17, h/t @alanzchen for the detailed trace)
- Persistence errors actually visible now - Storage failures used to fail silently unless you had debug mode on. Now you get a proper error toast with actionable hints (antivirus exclusions on Windows, chmod suggestions on Unix). (#19)
- Atomic writes for account storage - Switched to temp file + rename to avoid corrupted state if a write gets interrupted mid-flight.
- Fixed a reader lock leak - The SSE response handler wasn't releasing its lock in the finally block. Small thing but could cause issues over time.
- Debug logging for rotation - Added some visibility into which account gets picked and why during rotation.
[4.12.0] - 2026-01-30
Changed (BREAKING)
- tool rename: all
openai-accounts-*tools renamed to shortercodex-*prefix:openai-accountsΓ’β βcodex-listopenai-accounts-switchΓ’β βcodex-switchopenai-accounts-statusΓ’β βcodex-statusopenai-accounts-healthΓ’β βcodex-healthopenai-accounts-refreshΓ’β βcodex-refreshopenai-accounts-removeΓ’β βcodex-remove
Added
- codex-export: export all accounts to a portable JSON file for backup or migration
- codex-import: import accounts from a JSON file, merges with existing accounts (skips duplicates)
[4.11.2] - 2026-01-30
Fixed
- windows account persistence: fixed silent failure when saving accounts on Windows. errors are now logged at WARN level with storage path in message, and a toast notification appears if persistence fails.
[4.11.1] - 2026-01-29
Changed
-
This plugin provides 6 built-in tools for managing your OpenAI accounts. Just ask the agent or type the tool name directly.
-
| Tool | What It Does | Example Prompt |
-
|------|--------------|----------------|
-
|
openai-accounts| List all accounts | "list my accounts" | -
|
openai-accounts-switch| Switch active account | "switch to account 2" | -
|
openai-accounts-status| Show rate limits & health | "show account status" | -
|
openai-accounts-health| Validate tokens (read-only) | "check account health" | -
|
openai-accounts-refresh| Refresh & save tokens | "refresh my tokens" | -
|
openai-accounts-remove| Remove an account | "remove account 3" |
Fixed
- Zod validation error - Fixed crash when calling
openai-accounts-statuswith no accounts configured
[4.11.0] - 2026-01-29
Added
- Subdirectory detection - Per-project accounts now work from subdirectories. The plugin walks up the directory tree to find the project root (identified by
.git,package.json,pyproject.toml, etc.) - Live countdown timer - Rate limit waits now show a live countdown that updates every 5 seconds:
Waiting for rate limit reset (2m 35s remaining) - Auto-remove on auth failure - Accounts are automatically removed after 3 consecutive auth failures, with a notification explaining what happened. No more manual cleanup of dead accounts.
- openai-accounts-refresh tool - Manually refresh all OAuth tokens to verify they're still valid
[4.10.0] - 2026-01-29
Added
- per-project accounts: each project gets its own account storage now. no more conflicts when working across different repos with different chatgpt accounts. auto-detects project directories (looks for .git, package.json, etc). falls back to global storage if you're not in a project folder.
- configurable toast duration: rate limit notifications stick around longer now (5s default). set
toastDurationMsin config if you want them longer/shorter. - account removal tool: new
openai-accounts-removetool to delete accounts by index. finally. - token masking in logs: all tokens, api keys, and bearer headers are now masked in debug logs. no more accidentally leaking creds.
Changed
- account limit bumped to 20: was 10, now 20. add more accounts if you need them.
- per-project accounts default on:
perProjectAccountsdefaults totruenow. disable withperProjectAccounts: falsein config if you want the old global behavior.
Fixed
- token refresh race condition: added
tokenRotationMapto prevent concurrent refresh requests from stepping on each other. - rate limit retry jitter: 20% jitter on retry delays to prevent thundering herd.
- apply_patch infinite loop: removed apply_patch references from codex bridge that caused loops in some edge cases.
Notes
new options in ~/.opencode/openai-codex-auth-config.json:
{
"perProjectAccounts": true,
"toastDurationMs": 5000
}
env vars:
CODEX_AUTH_PER_PROJECT_ACCOUNTS=1- enable per-project accountsCODEX_AUTH_TOAST_DURATION_MS=8000- set toast duration in ms
[4.9.7] - 2026-01-29
Fixed
- business/team workspace selection: detect multiple workspace account IDs from oauth tokens and prompt for the correct one.
- prevent refresh/hydration from overwriting selected workspace ids (org/manual choices remain stable).
- persist workspace labels and sources for clearer account listings.
Added
CODEX_AUTH_ACCOUNT_IDoverride to force a specific workspace id (non-interactive login).- troubleshooting guidance for "usage not included in your plan".
[4.9.6] - 2026-01-27
Changed
- tui auth gating: non-tty/ui auth attempts now return a clear instruction to run
opencode auth loginin a terminal shell. - error-mapping simplification: consolidated entitlement/rate-limit mapping in fetch helpers for a single handling path.
[4.9.5] - 2026-01-28
Changed
-
When your ChatGPT subscription didn't include Codex access, the plugin kept rotating through all accounts and retrying forever because it thought it was a temporary rate limit.
-
You get an immediate, clear error: "This model is not included in your ChatGPT subscription."
Fixed
- Account error handling - Fixes infinite retry loop when account doesn't have access to Codex models.
usage_not_includederrors now return 403 Forbidden instead of being treated as rate limits. Clear error message explaining the subscription issue. Prevents pointless account rotation for non-recoverable errors. (#16, thanks @rainmeter33-jpg!)
[4.9.4] - 2026-01-27
Added
- TUI auth flow disabled - We now strictly enforce using
opencode auth loginin the terminal for authentication. The UI-based 'Connect' flow is disabled with a clear message to prevent issues with non-interactive environments.
Changed
- Strict tool schema validation - Added filtering of required fields, flattening enums for compatibility with strict models like Claude/Gemini
Fixed
- Manual login fixed - Parsing of OAuth URLs with fragments (
#code=) is fixed - Account switching - Manual selection is now strictly prioritized over rotation logic
- apply_patch enabled - The bridge prompt now allows the
apply_patchtool
[4.9.3] - 2026-01-27
Changed
- Strict schema validation - Ported robust tool cleaning logic from
antigravity-auth. Automatically normalizes tool definitions to prevent errors with strict models (like Claude or Gemini):- Filters out
requiredfields that are not defined inproperties - Flattens
anyOfschemas withconstvalues into standardenumarrays - Converts nullable array types into single types with a description note
- Injects placeholder properties for empty object parameters
- Filters out
- Enabled apply_patch - Updated the Codex bridge prompt to allow the
apply_patchtool
Fixed
- Manual login fixed - The plugin now correctly parses OAuth redirect URLs that use fragments (e.g.,
#code=...). Previously, it only looked for query parameters, which caused manual copy-paste logins to fail with a redirection error. - Account switching logic - Changed account selection logic to strictly respect your manual choice. Before this fix, the hybrid rotation algorithm would sometimes override your selection based on account health or token scores.
- TUI integration - Implemented the missing event handler for the TUI. When you click an account in the interface, it now triggers the
openai.account.selectevent, saves the new active index to disk, and shows a confirmation toast. - Removed API key option - Removed the 'API Key' authentication method from the list because this plugin is designed for OAuth only.
[4.9.2] - 2026-01-27
Fixed
- Auth prompts moved to TUI - Avoids readline input conflicts
- Error payload normalization - Improves rate-limit handling and rotation
Notes
- npm publish status: not published on npm (tag/release only).
[4.9.1] - 2026-01-26
Changed
- When
opencode auth logincalled the authorize function,inputswasundefined. The code had a conditional check that only entered the multi-account while loop ifinputsexisted with keys. This caused only single-account flow to run.
Fixed
-
Multi-account flow always runs - authorize() now always uses multi-account flow regardless of inputs parameter. (#12)
-
Removed the conditional check so multi-account flow always runs, allowing users to add multiple ChatGPT accounts.
[4.9.0] - 2026-01-26
breaking: package renamed from opencode-openai-codex-auth-multi to oc-chatgpt-multi-auth
Changed
- package renamed to bypass opencode's plugin blocking. opencode skips any plugin with
opencode-openai-codex-authin the name. the new nameoc-chatgpt-multi-authworks correctly. - updated all documentation, configs, and references to use new package name.
- added
multiAccountflag check in loader to coexist with opencode's built-in auth.
Fixed
- removed debug console.log statements from loader.
- plugin now properly detects when it should handle auth vs deferring to built-in.
Notes
update your ~/.config/opencode/opencode.json:
{
"plugin": ["oc-chatgpt-multi-auth@latest"]
}
Legacy 4.8.2 (Package-Only) - 2026-01-25
Changed
- fix node esm plugin load by importing tool from
@opencode-ai/plugin/tooland ensuring runtime dependency is installed. - correct package metadata (repository links, update-check package name) and add troubleshooting guidance for plugin install/load.
Notes
- npm package line: published under
opencode-openai-codex-auth-multi(legacy package), notoc-chatgpt-multi-auth.
[4.7.0] - 2026-01-25
feature release: full session recovery system ported from opencode-antigravity-auth.
Added
- session recovery system: automatic recovery from common api errors that would previously crash sessions:
tool_result_missing: handles interrupted tool executions (esc during tool run)thinking_block_order: fixes corrupted thinking blocks in message historythinking_disabled_violation: strips thinking blocks when switching to non-thinking models
- new recovery module (
lib/recovery/):types.ts- type definitions for stored messages, parts, and recoveryconstants.ts- storage paths (xdg-compliant) and type setsstorage.ts- filesystem operations for reading/writing opencode session dataindex.ts- module re-exports
- main recovery logic (
lib/recovery.ts):detectErrorType()- identifies recoverable error patterns from api responsesisRecoverableError()- quick check for recovery eligibilitycreateSessionRecoveryHook()- creates hook for session-level error recovery- toast notifications during recovery attempts
- new configuration options:
sessionRecovery(default:true) - enable/disable session recoveryautoResume(default:true) - auto-resume session after thinking block recovery- environment variables:
CODEX_AUTH_SESSION_RECOVERY,CODEX_AUTH_AUTO_RESUME
- 26 new unit tests for recovery system
Changed
- account label format: changed from
Account N (email)toN. emailfor cleaner display - error response handling:
handleErrorResponse()now returnserrorBodyfor recovery detection - enhanced error logging with recoverable error detection in fetch flow
Notes
- npm package line: published under
opencode-openai-codex-auth-multi(legacy package), notoc-chatgpt-multi-auth.
[4.6.0] - 2026-01-25
feature release: context overflow handling and missing tool result injection.
Added
- context overflow handler: gracefully handles "prompt too long" / context length exceeded errors:
- returns synthetic sse response with helpful instructions instead of raw 400 error
- suggests
/compact,/clear, or/undocommands to reduce context size - prevents opencode session from getting locked on context overflow
- new module:
lib/context-overflow.ts
- missing tool result injection: automatically handles cancelled tool calls (esc mid-execution):
- detects orphaned
function_callitems (calls without matching outputs) - injects synthetic output:
"Operation cancelled by user" - prevents "missing tool_result" api errors when user cancels mid-tool
- new function:
injectMissingToolOutputs()inlib/request/helpers/input-utils.ts
- detects orphaned
- 34 new unit tests for context overflow and tool injection
Notes
- npm package line: published under
opencode-openai-codex-auth-multi(legacy package), notoc-chatgpt-multi-auth.
[4.5.0] - 2026-01-24
Added
- strict tool validation: automatically cleans tool schemas for compatibility with strict models (claude, gemini)
- auto-update notifications: get notified when a new version is available
- 22 model presets: full variant system with reasoning levels (none/low/medium/high/xhigh)
Changed
- health-aware account rotation with automatic failover
- hybrid selection prefers healthy accounts with available tokens
Notes
- npm package line: published under
opencode-openai-codex-auth-multi(legacy package), notoc-chatgpt-multi-auth.
Legacy 4.4.0 (Package-Only) - 2026-01-23
Added
- health scoring: tracks success/failure per account
- token bucket: prevents hitting rate limits
- always retries when all accounts are rate-limited (waits for reset)
Notes
new retry options:
retryAllAccountsRateLimited(default:true)retryAllAccountsMaxWaitMs(default:0= unlimited)retryAllAccountsMaxRetries(default:Infinity)
Notes
- npm publish status: not published on npm (tag/release only).
[4.3.1] - 2026-01-23
Added
- openai-accounts-status --json - Scriptable status output with email/ID labels
Changed
-
Account labels - Now prefer email and show ID suffix when available; list/status outputs are columnized for readability
-
Email normalization - Stored account emails are trimmed/lowercased when present
-
@opencode-ai plugin/sdk 1.1.34
-
hono 4.11.5
-
vitest 4.0.18
-
@types/node 25.0.10
-
@typescript-eslint 8.53.1
-
@andremxmx for reporting the multi-account ID issue (#4)
Notes
- npm package line: published under
opencode-openai-codex-auth-multi(legacy package), notoc-chatgpt-multi-auth.