diff --git a/.gitignore b/.gitignore
index 00394f7dee..1973231f05 100644
--- a/.gitignore
+++ b/.gitignore
@@ -67,3 +67,6 @@ tests/**/.tmp-*
# `git add` three separate times and reached `dev` once — see
# tests/ci-workflows/repo-hygiene.test.ts, which fails if any path here becomes tracked again.
go/
+
+# Rust native helpers keep their reproducible sources and lockfile in git, never local artifacts.
+native/**/target/
diff --git a/.npmignore b/.npmignore
index acf3a0c4d0..cfbe1d3750 100644
--- a/.npmignore
+++ b/.npmignore
@@ -19,6 +19,7 @@ gui/eslint.config.*
gui/bun.lock
# misc
+native/remote-workspace-helper/target/
*.test.ts
*.map
.DS_Store
diff --git a/devlog/_plan/260912_accounts/000_plan.md b/devlog/_plan/260912_accounts/000_plan.md
index df54929c99..ed3e4574c1 100644
--- a/devlog/_plan/260912_accounts/000_plan.md
+++ b/devlog/_plan/260912_accounts/000_plan.md
@@ -46,3 +46,5 @@ Two design follow-ups encountered inherited-model capacity errors; one same-hand
## Roadmap cycle outcome
Independent design reflection and A re-audit passed with the source restrictions in 001_roadmap_audit.md. B freezes the contracts as documentation only. C checks document paths/numbering and git whitespace; local product suites NOT RUN. D next direction: execute 010_callback.md independently, then the remaining dependency-ordered cycles. Runtime behavior has not improved yet; the rejected hypotheses were native history identity by sentinel alone, attempt timing inferred from untimed attempts, and one-shot implying one physical request through a retrying primitive.
+
+History P split:048_history_identity.md supplies stable publication identity and fenced writer capture before050 history. This is a new foundation cycle, registered in the same goalplan; intended manual chain history-identity → history → capacity. It is independent of reset-first. Staged login samples are omitted until a fenced post-publication observation; native history remains nondurable and excluded from capacity.
diff --git a/devlog/_plan/260912_accounts/020_eligibility.md b/devlog/_plan/260912_accounts/020_eligibility.md
index 94719a4f58..734bc42d89 100644
--- a/devlog/_plan/260912_accounts/020_eligibility.md
+++ b/devlog/_plan/260912_accounts/020_eligibility.md
@@ -17,3 +17,5 @@ Field chain: existing excludedPlans config create/save/load → same normalized
MODIFY existing `tests/codex-integration/codex-pool-plan-exclusion.test.ts`: replace last-account soft fallback test with none/preview none; test normalized plan update and explicit fixed route. Extend account API/CLI and card tests for reason and renewal clearing. Sync ownership docs and providers configuration pages that describe the old soft exception. Retain source attribution of #4238; no recarry of already-landed commits. Local tests/build/typecheck NOT RUN. Hosted CI plus rendered artifact from final tip supplies execution proof.
Exclusion reason derives from the routing config plan, not a display-only freshly observed plan if persistence failed. This preserves truth between selection and explanation.
+
+P revalidation on dev d6fb87197a: keep exported existing predicate in routing.ts; pass runtimeConfig into both poolAccountDto calls. Alongside closed selectionExcludedReason include selectionExcludedPlan from the same routing config when excluded, so a display-only fresh WHAM tier cannot mislabel the reason. CLI/card render this policy plan. Exact GUI type owner is hooks/useCodexAccountPool.ts; component type file re-exports it. Docs source is guides/codex-integration.md in every locale; revise all-excluded fallback paragraphs there. Callback D delivered PR4352 and left hosted acceptance open; this cycle is independent from current dev.
diff --git a/devlog/_plan/260912_accounts/021_eligibility_delivery.md b/devlog/_plan/260912_accounts/021_eligibility_delivery.md
new file mode 100644
index 0000000000..ed4405ac17
--- /dev/null
+++ b/devlog/_plan/260912_accounts/021_eligibility_delivery.md
@@ -0,0 +1,9 @@
+# Plan exclusion completion
+
+Built on already-landed #4238, independently from current dev d6fb87197a. Existing normalized predicate is shared with the account DTO; both preview and real automatic fallback reject excluded plans when no eligible account remains. Explicit account-qualified routes retain normal auth, pause and entitlement checks. Native main remains exempt.
+
+CLI and dashboard display the policy's routing-plan reason separately from credential health and a possibly newer display-only plan. The automatic Set-as-next action is suppressed for excluded rows because pinning does not bypass this policy; explicit account-qualified routes remain available. All nine UI locale catalogs and eight affected integration guides are synchronized. Source ownership docs link the canonical plan-exclusion contract.
+
+Regression sources cover all-excluded preview/resolve, renewal, explicit route with pause/reauth, API reasons, CLI normalization and card display/renewal. No new test file or dependency. Local suites/build/typecheck/install: NOT RUN. Hosted CI and rendered preview remain pending. Source searches: isCodexAccountPlanExcluded, getPoolAccountPlan, poolAccountDto, CodexAccountEntry, selection guards and excludedPlans docs; reused the existing predicate rather than a parallel policy.
+
+Prior callback cycle delivered PR4352 and remains pending hosted verification. This is an independent dev PR, with no callback code and no manual chain dependency.
diff --git a/devlog/_plan/260912_accounts/022_eligibility_ci_repair.md b/devlog/_plan/260912_accounts/022_eligibility_ci_repair.md
new file mode 100644
index 0000000000..eb33329f4c
--- /dev/null
+++ b/devlog/_plan/260912_accounts/022_eligibility_ci_repair.md
@@ -0,0 +1,7 @@
+# Eligibility hosted regression repair
+
+Exact-head run34680496052 at a1f24df5ed90848f32d2499303b22620d91eed42 failed in Linux test4/4 job103523074988 and macOS2/2 job103523074889. The reset-ticket source oracle still required the old next-session guard without plan exclusion. The implementation correctly retained all health guards and added plan exclusion.
+
+The oracle now also requires the plan-exclusion guard, preserving ticket co-render and all health checks. A rendered regression fixture confirms eligible accounts show next-session and tickets together; excluded accounts retain tickets and omit next-session. No production code changed. No assertion was removed or loosened. Local suites/build/typecheck/install NOT RUN; remote final-head verification follows.
+
+Other failures in these runs concern Cline registry/localization/asset/test-layout and native history restoration. They are recorded in task scratch with exact job logs for owner integration; no other-lane files were changed. The parent-updated branch was fast-forwarded without rebase or merge commit. Hostgoal remains blocked and FSMB is unchanged; no new completed PABCD cycle is claimed for this source repair.
diff --git a/devlog/_plan/260912_accounts/030_reset.md b/devlog/_plan/260912_accounts/030_reset.md
index 875a6dccc5..be34c48373 100644
--- a/devlog/_plan/260912_accounts/030_reset.md
+++ b/devlog/_plan/260912_accounts/030_reset.md
@@ -14,3 +14,11 @@ Additional MODIFY `src/oauth/pool-settings-capability.ts` and `src/server/manage
Field chain: CLI/GUI strategy creation → canonical PUT parser → config.accountPoolStrategy write → config load + canonical GET parser → pool rotation/preview/failover, CLI and GUI display. Audit every existing strategy comparison/default, not just the union. No schema migration or new dependency. Exact contributor diff remains `.tmp/accounts-20260912/pr4080.diff` during planning; changes are adapted to current callers before B.
Extend regression sources for canonical PUT/GET/save/reload, legacy endpoint, non-Codex rejection, tied/missing/elapsed resets, threshold zero, priorities, affinity and failover. Existing #4080 test cases are retained/adapted. Update all source ownership docs; screenshot of final rendered strategy control is included with PR. Local suites/build/typecheck/install NOT RUN; final head hosted CI supplies proof. #3376 remains partial until history/capacity; monthly/Anthropic/latest-first scope is reported separately.
+
+P revalidation: #4080 head unchanged. Current pool-rotation.ts is a compatibility facade, so Codex parser/normalizer live in existing src/oauth/pool-kernel.ts leaf and are reexported. Canonical GET DTO and PUT parser use Codex-specific parser only for kind=codex. Use existing resetAtToMs for both seconds/milliseconds before comparing future deadlines. Existing manualPreferenceBlocks remains at promotion; reset-first affinity calls mayRebindAffinityForQuota so pool.cacheAffinity retains a healthy bound account until genuine exhaustion. Current config parser preserves accountPoolStrategy through passthrough, so canonical save/reload regression is required. User limits unchanged; previous eligibility D delivered PR4361 with hosted/render pending, reset-first remains independent.
+
+A1 accepted: independent spark/reserve quota scopes use the existing quota strategy consistently for initial selection, preview, affinity and alternates; shared 5h/weekly reset timestamps are not their evidence. Add private `accountPoolStrategyForScope(config, quotaScope)` in routing.ts: normalize the configured Codex strategy, then return quota when reset-first and isIndependentCodexQuotaScope(scope), otherwise the normalized strategy. Use it in pickUnboundStrategyAccount, pickAlternateCodexAccount, previewReusableAffinityAccount and reevaluateAffinityQuota. Shared promotion remains scope-guarded and uses configured normalized strategy. Config remains reset-first, DTO shows configured value and docs explain effective independent-scope fallback. Tests oppose shared reset versus usage order, include scoped cooldown and unchanged shared cursor.
+
+Config decision: retain existing passthrough compatibility rather than add an unrelated disk-validation policy in this carry. Canonical/legacy management writes validate through Codex parser, and all runtime consumers normalize malformed direct config values to quota as before. Explicit invalid parser/API and save/reload tests verify this boundary; no whole-config reset is introduced.
+
+C source audit found threshold=0/cacheAffinity=true could still rebind at100%. Accepted and fixed with early disabled-threshold return before reset-first affinity evaluation; new preview/resolve/all100 fixtures cover both cache settings. Failure recovery stays separate. Local suites NOT RUN; source re-audit and hosted CI pending.
diff --git a/devlog/_plan/260912_accounts/031_reset_delivery.md b/devlog/_plan/260912_accounts/031_reset_delivery.md
new file mode 100644
index 0000000000..4b20ad2950
--- /dev/null
+++ b/devlog/_plan/260912_accounts/031_reset_delivery.md
@@ -0,0 +1,13 @@
+# Reset-first carry follows the current pool contract
+
+Adapts #4080 ecf6b4e48a4c2992c296fada2caf6a8132313eaa by Terry Tan. The Codex parser now lives in the existing shared kernel leaf, canonical and legacy settings round-trip the configured strategy, and the GUI offers it only on Codex. Existing runtime priority, manual preference and cache-affinity behavior is preserved. Mixed reset units are normalized before ordering; independent model quota scopes retain existing quota selection.
+
+Regression sources include original reset-first cases plus mixed units, cacheAffinity on/off, scoped fallback/health/shared cursor, canonical and legacy persistence, non-Codex rejection and GUI empty-response normalization. UI hints reflect current cache-affinity and scope semantics. Local tests/build/typecheck/install: NOT RUN. git diff --check is whitespace evidence only; independent source review and hosted final-tip CI/render evidence follow.
+
+Source search: accountPoolStrategy, normalizeAccountPoolStrategy, resetAtToMs, pool/settings, mayRebindAffinityForQuota, manualPreferenceBlocks and all strategy consumers. Existing pool-kernel and routing owners extended; no new dependency or separate pool implementation. Config passthrough behavior preserved deliberately; write routes validate through the Codex-specific parser.
+
+Co-authored-by: Terry Tan
+
+C source audit found threshold=0/cacheAffinity=true could still rebind at100%. Accepted and fixed with early disabled-threshold return before reset-first affinity evaluation; new preview/resolve/all100 fixtures cover both cache settings. Failure recovery stays separate. Local suites NOT RUN; source re-audit and hosted CI pending.
+
+Independent C re-audit PASS at eddc8c7b08; nonblocking oracle improvement accepted: assert actual alternate b, excluding null as a false recovery result. Hosted/runtime acceptance remains pending.
diff --git a/devlog/_plan/260912_accounts/048_history_identity.md b/devlog/_plan/260912_accounts/048_history_identity.md
new file mode 100644
index 0000000000..d8273fab4e
--- /dev/null
+++ b/devlog/_plan/260912_accounts/048_history_identity.md
@@ -0,0 +1,19 @@
+# Bind quota history to credential publication identity
+
+New foundation cycle history-identity, C4 credential metadata, before history and capacity. Current source saveCodexAccountCredential publishes a new generation, while normal refresh CAS also increments generation and preserves replacedAt. Neither generation equality nor a millisecond timestamp alone establishes durable quota-history continuity. Reuse the credential store and its mutation lock; no new store or token-derived fingerprint.
+
+MODIFY src/types/accounts.ts CodexAccountCredentialRecord: optional private quotaHistoryIdentity UUID, not credential material and never projected to API/CLI. MODIFY src/codex/account-store.ts: every explicit save creates a fresh UUID; saveCodexAccountCredentialIfGeneration and commitRefreshedCodexCredentialWithAliases preserve each record's own UUID, including aliases. Deletes retain no old history identity. Existing credential projection excludes metadata automatically.
+
+Add PoolQuotaWriter type in dependency-free src/codex/quota-types.ts:
+```ts
+export interface PoolQuotaWriter { accountId: string; credentialGeneration: number; historyIdentity: string }
+```
+Add capturePoolQuotaWriter(accountId, dispatched:{accessToken,chatgptAccountId,generation}) in account-store.ts. Under existing withCredentialMutationLockSync, read record and require exact dispatched credential and generation, live/nondeleted state. For a legacy/malformed missing UUID initialize one once and persist under that lock without changing credential generation; do not mint on normal reads. A mismatch returns undefined. Lock/persistence failures at this optional evidence boundary return undefined, never fail the request. Credentials remain transient and never enter returned proof. Existing valid UUID capture needs only read matching record, no mutation lock or rewrite; legacy slow path rechecks under lock.
+
+Add isPoolQuotaWriterLive(writer): compare current live record's UUID and generation. Add poolQuotaHistoryIdentity(accountId): read valid current UUID only, never initialize or mutate. These separate append admission from retention, which matches UUID across ordinary refresh. Both are narrow production interfaces for the next history layer, not public management capabilities.
+
+Tests extend existing codex-account-store.test.ts: new saves unique; same-millisecond explicit replacement changes UUID; refresh preserves; alias refresh preserves distinct destination identities; stale dispatched access/generation/account cannot capture; legacy initialization stable and does not advance generation; metadata omitted from getCodexAccountCredential/load compatibility projection; delete/recreate invalidates old writer. Local tests/build/typecheck/install NOT RUN. Hosted cumulative history/capacity tip verifies these regression sources. Source security review separate from runtime proof.
+
+Field chain: explicit save/legacy capture creates UUID → existing atomic credential record serialization → existing read with UUID validity checked at history boundary → capture/live/retention helpers → next cycle's auth-context/WHAM/header history admission. All explicit record reconstructions are enumerated: save at161, validation spreads186/234 preserve, refresh279/338 preserve, alias366 preserves its own, deletion387 drops. Source ownership docs updated with private metadata semantics. No credential/token/string values enter docs or log output.
+
+A implementation checks accepted: legacy tag init uses plain persist, preserving both generation and credentialMutationEpoch. UUID validation stays at history boundary; malformed optional metadata never discards usable credentials. Catch read/hardening failures as well as lock/write failures and return no optional proof. Capture excludes the reserved native-main sentinel. If a CAS caller supplies a different upstream account identity, rotate the history UUID instead of treating that as ordinary same-account refresh.
diff --git a/devlog/_plan/260912_accounts/049_history_identity_delivery.md b/devlog/_plan/260912_accounts/049_history_identity_delivery.md
new file mode 100644
index 0000000000..6af02c6026
--- /dev/null
+++ b/devlog/_plan/260912_accounts/049_history_identity_delivery.md
@@ -0,0 +1,7 @@
+# Quota history identity foundation
+
+Adds a private random publication UUID to pool credential records. Explicit saves rotate it, refresh CAS preserves it for the same upstream account, and aliases retain distinct identities. Captured writer proofs require exact dispatched credential generation and access/account pairing; legacy identity initialization occurs under the existing lock without changing the credential generation or mutation epoch. Read/lock/write failure yields no optional proof. Metadata never enters credential-only projection.
+
+Regression sources cover refresh versus same-time replacement, aliases, deletion/recreation, legacy stable initialization, stale capture, malformed metadata, secret-free proof and identity-changing CAS. The latter rotates owner history and does not propagate the new identity into old aliases. No new test file/dependency. Local suites/build/typecheck/install NOT RUN. Source checks are not runtime proof; hosted final cumulative history/capacity tip remains required.
+
+Structural decision: proof type stays in quota-types.ts (type-only), credential record/lock/persistence stay in account-store.ts, future pure history leaf consumes plain evidence. Rejected generation-only retention because ordinary refresh increments it; rejected timestamp identity because publication can share a millisecond. The small foundation is the first ordinary manual-chain PR, then history, then capacity. No merge.
diff --git a/devlog/_plan/260912_accounts/050_history.md b/devlog/_plan/260912_accounts/050_history.md
index fab9e603be..1e870236fc 100644
--- a/devlog/_plan/260912_accounts/050_history.md
+++ b/devlog/_plan/260912_accounts/050_history.md
@@ -20,4 +20,30 @@ Field chain: creation is guarded quota commit; serialization is existing atomic
Reflection REF-04: fixed aggregate bounds: 64 account identities, 4096 rows, 2 MiB serialized history payload and 4 MiB whole cache read bound. During append/hydrate evict oldest observed rows, tie-break account key; prune accounts absent from authoritative roster. Never include dynamic raw account identities in logs. History retains actual per-window provenance (response-header or WHAM where available), reset boundary and window family; partial inherited values do not count. Overlarge/malformed cache read fails to empty history without blocking newest quota. Tests include many-account overflow, byte overflow, deterministic ties and remove/restart.
-A1 accepted: native main history is deliberately NOT hydrated from disk in this slice. It can be sampled in-process only after identity observation and cleared on identity change; persistence omits __main__. Pool history envelopes bind stable configured account identity and stored credential generation, pruning mismatches on hydrate. This avoids attributing offline identity replacements to an old main label. Acceptance explicitly covers main replacement while stopped and account-id reuse. Main cross-restart history remains a documented limitation; bounded durable history is provided for stored pool accounts.
+A1 accepted: native main history is deliberately NOT hydrated from disk in this slice. It can be sampled in-process only after identity observation and cleared on identity change; persistence omits __main__. Pool history envelopes bind a stable private publication UUID; hydration prunes identity mismatches, while ordinary generation changes on refresh retain prior observations. This avoids attributing offline identity replacements to an old main label. Acceptance explicitly covers main replacement while stopped and account-id reuse. Main cross-restart history remains a documented limitation; bounded durable history is provided for stored pool accounts.
+
+P refinement depends on new048 history-identity cycle. Adopt HIST-01..06: generation gates each physical sample; private random publication UUID persists through refresh and changes on explicit save. Capture PoolQuotaWriter before upstream calls, refresh it after replay token resolution, and forward through every WHAM/WS/HTTP/compact/warmup path. Omit staged login/reauth samples until first post-publication fenced observation; do not retrofit ambient provenance. Native main is excluded from durable endpoint/capacity in this slice. Raw QuotaObservation carries observedAt, wham|response-header source, bounded windows with account|spark family and short|weekly|monthly name, percentage/resetAtMs/duration/primary provenance; no arbitrary upstream label. Envelope private identity binds samples but is omitted from read DTO. Retain best-effort single-writer atomic cache semantics; no multi-process merge/durability claim. Read endpoint GET /api/codex-auth/quota/history?accountId=&limit=<1..200>; CLI ocx account history openai [--limit N] [--json]. Unknown/deleted404, invalid/duplicate selector400, emptyhistory200. No upstream call on reads.
+
+## Executable history child contract after identity foundation D
+
+Parent PR4375/e9007429c5 provides PoolQuotaWriter and store capture/live/retention helpers. This child depends on that branch; the capacity child follows this one. Previous D delivered only identity and deferred hosted proof.
+
+NEW src/codex/quota-history.ts, pure leaf (imports quota types and pure account-id only): closed HistoryWindow family account|spark, window short|weekly|monthly, usedPercent, optional resetAtMs/windowSeconds/monthlyIsPrimaryWindow; HistorySample observedAt/source/credentialGeneration/windows; private envelope identity/samples. CodexQuotaHistory owns append/hydrate/read/clear/reconcile/serialize. Keep 200 samples/account,30days,64accounts,4096samples,2MiB conservative serialized-byte budget; max5 windows/sample. Track per-sample byte costs incrementally, evict by observedAt then accountId and insertion order. Hydration rejects an over-limit envelope before admitting rows (>64 accounts, >200 rows/account, >4096 total samples or >2MiB serialized payload); accepted rows are validated and sorted by timestamp before age retention. Unknown fields/labels never survive. Read returns deep copies; private identity never reaches API. No filesystem/config/store import in the leaf.
+
+MODIFY quota.ts: own the history instance and optional history:{version:1,accounts:{...}} in existing quota-cache version1. Hydrate history before latest-quota six-hour TTL filtering; native-main never hydrates/records in this durable layer. Replace unbounded file allocation with a local fd/readSync loop capped at4MiB+1; oversized/corrupt cache is a cache miss, never an inference failure. Keep latest in-memory state untouched. Existing debounced atomic persistence serializes bounded history, so no new timer/store and no multi-process merge claim. Clear and roster reconcile remove history-only identities too; read compares current store UUID before returning, even after offline replacement.
+
+setAccountQuotaFromParsed gains optional sixth QuotaObservationEvidence {writer,observedAt,source,raw}. After config/main write guards, append only when writer.accountId matches and isPoolQuotaWriterLive. Convert only fresh raw percentages into closed history windows, normalizing resets with resetAtToMs. Account short/weekly/monthly map directly; Spark uses existing short label plus a new canonical weekly-label constant shared with the WHAM parser. No arbitrary custom labels. Credits-only/metadata-only updates append nothing. The legacy latest-snapshot merge remains unchanged. applyAccountQuotaFromUpstreamHeaders options adds poolWriter; builds evidence from original parse result BEFORE custom-window carry. Missing writer/evidence preserves latest cache but appends no trusted sample.
+
+MODIFY auth-context.ts pool union with poolQuotaWriter?:PoolQuotaWriter, capture immediately after getValidCodexToken before dispatch. MODIFY core.ts WS closure, rejected-first response, ordinary HTTP, and refreshedAuthCtx to forward/re-capture exact serving writer; compact.ts refresh/rejection follows same rule. MODIFY quota-auto-refresh.ts pool warmup captures before I/O. MODIFY auth-api.ts WHAM initial and refreshed replay capture before fetch, commitPoolQuotaResponse carries writer and sends raw parsed result with observedAt after JSON read; keep all current generation/mayPublish checks. Staged login quota writes intentionally omit history until a post-publication observation; native main and legacy updateAccountQuota omit it. No token material is added to response objects/logs.
+
+GET /api/codex-auth/quota/history?accountId=&limit=<1..200> is read-only cached data, no upstream/auth refresh/warmup. Add before existing /quota handler; registry entry+capability map. Validate exactly one accountId, optional single numeric limit and no unknown query fields. Invalid/main400, unknown configured pool404, known account200 even empty. DTO: {accountId,observations:[{observedAt,source,windows}],retention:{maxObservations:200,maxAgeDays:30},truncated:boolean}; omit UUID and credential generation. Public array follows ascending observed time, limit chooses newest rows. Capacity is added only in next child.
+
+NEW src/cli/account-history.ts exports cmdAccountHistory(args,deps). Shape `ocx account history openai [--limit N] [--json]`; reject other provider/main/extraargs before any network. Use resolveBaseUrl/apiJson/apiError/proxyUnreachable from account-api owner. JSON prints DTO; human output prints observed time/source/window/percent/reset and no-observation state. Wire lazy dispatcher and help/capabilities; source-only skill surface generator allowed (not product suite).
+
+Tests: new pure codex-quota-history.test.ts (register both layout maps), existing quota-store integration hydration harness for raw-vs-carried, writer mismatch/refresh/replacement, stage omission, native omission, clear/reconcile and disk limits; authenticated server route tests+CLI transport fixture. No local runtime execution. All touched source-area ownership docs and English+Korean account command docs synchronized; other translations must not contradict additions.
+
+Read unavailability refinement: undefined current identity (legacy/missing/unreadable) returns empty/unavailable evidence without deleting a retained envelope. Only a confirmed different UUID or authoritative roster removal clears it; this avoids transient permission/read errors destroying history. Restored matching identity may expose retained valid rows again. Cache eviction/expiry remains bounded.
+
+Deferred history-plan review findings (actual A entry was refused because persisted active work phase is tun): reuse pre-clamp invalid-percentage checking for all WHAM primary/secondary/tertiary and additional Spark windows, and response-header raw usage fields; any invalid numeric/nonfinite/out-of-range percentage omits the ENTIRE trusted observation while leaving legacy display behavior unchanged. Add before-clamp history parser/evidence guard so clamped values cannot masquerade as measured percentages. Hydration rejects an entire over-limit history payload (>64accounts,>200rows/account,>4096rows,>2MiB) instead of slicing by lexical key/array position; bounded accepted rows are sorted by observedAt before retention. Tests include65th-newestaccount and unordered rows. These need fresh independent A review when history resumes.
+
+Implementation review HIST-IMPL-01 accepted: compact final response now records actualoutcomeCtx poolwriter beforebuffering, coveringordinary/401replay/alternate; rejectedfirstaccount retains its separateexistingwrite, so everyresponse contributesonce. Add compactregression withquotaheaders onoriginalsuccess andA429→Bsuccess. This sourcework is user-authorized whilehostgoal remainsblocked; no FSM A/B/C/D advancement claimed.
diff --git a/devlog/_plan/260912_accounts/051_history_delivery.md b/devlog/_plan/260912_accounts/051_history_delivery.md
new file mode 100644
index 0000000000..0e38a408c8
--- /dev/null
+++ b/devlog/_plan/260912_accounts/051_history_delivery.md
@@ -0,0 +1,9 @@
+# Bounded raw quota history implementation
+
+Extends publication identity foundation #4375 with a pure bounded history leaf, existing quota-cache persistence, fenced WHAM/HTTP/WS/compact/warmup producers, a management read route and account history CLI. Invalid upstream percentages never become trusted samples after display clamping. Native-main, staged-login and legacy unproven setters are omitted.
+
+Regression sources cover chronological retention, limits/corrupt disk, private-field stripping, generation/identity changes, raw-versus-carried windows, cached API auth/validation, CLI argument rejection and compact serving-account attribution. Independent source review identified missing compact final-response capture; it was added with ordinary/alternate regressions. CLI skill surface regenerated by its source-only generator, not a product build or suite. Local suites/build/typecheck/install NOT RUN.
+
+This child targets the existing history-identity branch at19cbe826d8. The pending plan-only commit was rebased onto the parent-updated branch; foundation product bytes were unchanged. Host goal remains blocked; actual FSMB(tun) remains untouched under explicit user instruction. These are authorized source implementation and independent reviews, not a claimed new persisted PABCD cycle. Complete hosted verification belongs to the eventual cumulative history/capacity tip; no merge or issue closure.
+
+Review corrections: human CLI formats out-of-range dates as unknown; byte-limit fixtures now carry valid populated data and exercise append-byte eviction before row limits; authenticated API returns a populated sanitized history; WHAM refresh/replay, HTTP/WS and real warmup producer fixtures assert history including stale WS replacement rejection. Local suites remain NOT RUN.
diff --git a/devlog/_plan/260912_accounts/060_capacity.md b/devlog/_plan/260912_accounts/060_capacity.md
index 931e0b6646..d222802811 100644
--- a/devlog/_plan/260912_accounts/060_capacity.md
+++ b/devlog/_plan/260912_accounts/060_capacity.md
@@ -2,17 +2,31 @@
Cycle capacity depends on history. Source: `src/usage/log.ts` already persists accountLogLabel, timestamp, reported/estimated usage and per-attempt attribution; `src/codex/account-label.ts` owns safe labels. Use those existing records instead of storing credentials or duplicating request attribution.
-NEW `src/codex/quota-capacity.ts`: a pure estimator receives copied raw history and account-attributed reported usage observations. For each short/weekly/monthly window, pair adjacent fresh percentage observations only when reset identity matches, time increases and percentage delta is positive. Sum reported token usage in that interval, count per-attempt records once, exclude estimated/local/unattributed usage and reset/refund crossings. Estimate tokens per full window as observedTokens * 100 / percentageDelta; aggregate defensible intervals with median and report sampleCount plus observed-token lower-bound caveat. No valid interval returns null, never zero or a fabricated capacity. Bounded scan is invoked on management request, never routing; estimation is informational and does not overrule live quota.
+NEW `src/codex/quota-capacity.ts`: a pure estimator receives copied raw history and account-attributed reported usage observations. For each short/weekly/monthly window, pair adjacent fresh percentage observations only when reset identity matches, time increases and percentage delta is positive. Sum reported token usage in that interval, count per-attempt records once, exclude estimated/local/unattributed usage and reset/refund crossings. Estimate tokens per full window as observedTokens * 100 / percentageDelta; aggregate defensible intervals with median and report sampleCount plus an explicit low-confidence inference caveat. No valid interval returns null, never zero or a fabricated capacity. Bounded scan is invoked on management request, never routing; estimation is informational and does not overrule live quota.
```ts
export type CodexCapacityEstimate = {
window: "short" | "weekly" | "monthly";
estimatedTokens: number;
sampleCount: number;
- confidence: "observed-lower-bound";
+ confidence: "low";
};
```
MODIFY history read API/CLI projection to attach per-window estimates with sample count and caveat; expose an existing account-card detail surface only if it can be honestly rendered and verified. Field chain: pure estimator creation; API JSON serialization; existing typed CLI/client deserialization; explicit informational display consumers. No persisted estimate schema needed. Tests feed independently hand-calculated intervals, 0% delta, reset rollover, missing timestamps/identity, cross-account records, retries, estimated usage, and extreme numeric input. Sync quota/usage ownership docs and user configuration guidance. Full closure of #3376 requires both history and meaningful capacity; reset-first alone stays partial. Local suites NOT RUN; hosted final cumulative tip is the verifier.
A2 accepted: use readUsageSnapshotForManagement; if truncatedPrefixBytes>0, entriesTruncated, entriesDropped>0, missing revision, or invalid timing then return insufficient-evidence with no estimate. Treat each request as interval [timestamp, timestamp+durationMs] (request-log.ts:1039/1072); include only requests wholly contained in a quota-observation interval. Boundary-spanning requests contribute nothing. For included requests count reported physical attempts matching the exact pool label once; do not count both request total and attempts. Without attempts accept request-level reported usage only with matching label and no recovery ambiguity. Native main is excluded from token capacity because its historical label cannot establish identity after replacement. Current pool logLabel must be unique; legacy fallback labels/id reuse require insufficient evidence unless continuity is proven by history generation. Same-reset positive deltas only. Hand-worked boundary-spanning, truncation, missing identity and retry rows are mandatory regression fixtures.
+
+P future refinement from history sidecar: do not call estimate a mathematical lower bound. It is an observed effective token estimate under rounded/delayed quota and local coverage assumptions. Admit only single-send reported nonestimated attempts; present-but-empty attempt arrays cannot fall back to parent totals. Deduplicate requestId+ordinal and reject conflicting duplicates. Use interval (left,right] with whole request containment to avoid zero-duration double counting. Existing parser can skip malformed rows without a rejected counter: report retained-valid-ledger-only assumption explicitly or add rejected-row metadata before claiming complete coverage. Loglabel alone is not history identity; history publication UUID and current stable unique configured label must bind sample period. All source tests remain hosted-only.
+
+## Resumed capacity contract
+
+Depends on history PR4404/0d98205fcd. Add pure quota-capacity.ts estimator receiving public sanitized observations, validated usage rows and the current explicit unique random pool logLabel; no native-main/fallback labels. Per account short/weekly/monthly, pair adjacent raw observations only with same source/reset boundary, increasing localtime and percentage delta>=1. Count only whole requests within (left,right], single-send reported nonestimated nonlocal attempts matching that label. Presence of an empty attempts array never falls back to request totals. Deduplicate requestId; conflicting duplicates yield insufficient evidence. No inferred absolute attempt start. Exclude boundary-spanning requests and unknown/multisend usage; no valid pair yields insufficient-evidence.
+
+Use reported totalTokens or input+output exactly once, not reasoning/cache detail additions. Median effective tokens per100percentage over defensible intervals, sampleCount explicit. Output confidence low and assumptions array: rounded/delayed quota, only retained valid proxy ledger rows, label continuity assumed inside the observation interval, external usage not observed. This is an observed effective estimate, never a provider token limit or proven lower bound. The private credential publication UUID must match before/after async ledger read; current explicit logLabel and uniqueness must still match config. Any mismatch yields insufficient-evidence, not mixed identity. No estimate is used for scheduling.
+
+Extend existing history GET result with capacity:{status:estimated|insufficient-evidence,estimates:[{window,estimatedTokens,sampleCount,confidence:low}],reason?,assumptions}. Cached history remains visible on ledger read failure. Use readUsageSnapshotForManagement; reject truncatedPrefixBytes/entriesTruncated/entriesDropped, missingrevision and >10000 retainedrows before estimator scan. This deliberately does not attest missing/rejected historical ledger lines; assumptions state that limitation. CLI history humanoutput renders estimates and sample counts/caveat; JSON carries fullobject. No new config, timer, persistence, GUI surface or inference call.
+
+Tests handcomputed10→20% plus1000reportedtokens→10000estimate; mixed sources/reset/refund/0delta/rounding/timestampintervals, duplicate request IDs, absent-vs-empty attempts, multisend, local/estimated/unattributed tokens, nonfiniteoutput, truncatedledger andidentitychangedawait. Sample storage/read provides current publication evidence; retrospective label continuity is explicitly low-confidence inference, not independently verified identity. This clarification replaces earlier mathematically unprovable lower-bound wording without reducing raw-data/identity fences. Local suites/build/typecheck/install NOTRUN. Independent source design/review plus final cumulative tip hostedCI required; hostFSMblockedB remains unchanged.
+
+Implementation refinements: reject absent physicalattempts, deduplicate ordinals, countonlyaccountfamily/sharedmodelscope and matchingwindowduration/primaryprovenance withresetnotelapsed. Preserve locallyAnswered duringexistingusagenormalization so capacitycanexcludeit. CaptureUUIDbeforehistoryread and recheckbefore/afterasyncledgerread; usefullboundedhistory forestimationindependentofdisplaylimit. Labels re-read fromcurrentruntimeconfig.
diff --git a/devlog/_plan/260912_accounts/061_capacity_delivery.md b/devlog/_plan/260912_accounts/061_capacity_delivery.md
new file mode 100644
index 0000000000..da434e3e12
--- /dev/null
+++ b/devlog/_plan/260912_accounts/061_capacity_delivery.md
@@ -0,0 +1,7 @@
+# Informational effective quota capacity
+
+This child of #4404 estimates observed reported tokens per100percentage from bounded raw observation intervals. It preserves private publication UUID checks and requires an explicit unique pool log label. The estimate is low-confidence with disclosed rounding, retained-valid-row, external-usage and label-continuity assumptions; it is not a provider limit or scheduling policy.
+
+Regression sources cover a hand-computed1000tokens/10points=10000, duplicates, single-send evidence, provenance/reset/interval/independent-model conditions, numeric overflow, bounded ledger rejection, populated API/CLI output and identity replacement during async usage read. Existing local-answer provenance now survives attempt normalization. No local suite/build/typecheck/install was run. Independent design source audit passed; implementation source review and final cumulative hostedCI remain pending. Actual hostgoal blocked/FSMB untouched; no persisted capacity PABCD cycle is claimed.
+
+Source review corrections: API accepts only explicit shared quota scope, excluding blank/undefined model identity through an actual populated API regression. CLI prints insufficient-evidence reasons through the closed reason parser, with estimated/insufficient human+JSON fixtures. A positive fraction that rounds to zero yields no estimate. Local suites remain NOTRUN.
diff --git a/devlog/_plan/260912_accounts/070_tun.md b/devlog/_plan/260912_accounts/070_tun.md
index d37cd09632..77f46a02cc 100644
--- a/devlog/_plan/260912_accounts/070_tun.md
+++ b/devlog/_plan/260912_accounts/070_tun.md
@@ -21,3 +21,9 @@ MODIFY GUI `components/provider-workspace/types.ts`, `hooks/useProviderAccountPo
Field chain: private probe→transient cache→account results→authenticated API JSON→enum-normalized client/CLI→current/all-account quota text. Ranking/health/history do not consume it. Tests: each enum trigger, summary failure/fallback success, final-attempt precedence, stale bars, recovery, cross-account isolation, stale-config, late response, unknown wire code, and secret-free projection. Existing provider account quota fixtures supply transport injection; new files require both layout entries. Fix inventory's stale IPv6 proxy-only sentence and update every touched area owner. Local suites/build NOT RUN; hosted backend/GUI checks and rendered final-tip artifact. Authenticated TUN observation remains unmet until an authorized operator supplies exact SHA, proxy/TUN mode and sanitized successful refresh; no network/account changes here.
Reflection TUN-R01/R02 accepted. HTTP 300–399→redirect_blocked, 401/403→access_denied, 429→rate_limited, other non-2xx→upstream_error; success with unusable quota→response_unusable. Keep providerRedirectError cancellation and discard its message. Neither status establishes plan or reauth. fetchAntigravityQuota may reuse the private probe preserving null/rejection and success source; ProviderQuota/ProviderQuotaReport gain no diagnostic field, report-only views remain generic. getCachedProviderAccountQuota returns last-good quota only.
+
+P revalidation on489af939: parent added explicit account readers and Combo quota evidence. Classification stays limited to Antigravity account probe/cache; provider report remains its existing report(...) projection with no inference authority or diagnosticfield. Current functions moved but contract unchanged. Active tun cursor honored after history A mismatch; history source implementation remains pending on its own branch. Local tests/build/typecheck/install still NOT RUN; authenticated field acceptance untouched.
+
+A1 credential-currentness accepted. Reuse the existing private explicitQuotaIdentity hash recipe via a pure quotaCredentialIdentity(provider,id,capturedCredential,target) helper; existing explicit readers keep byte-identical hashes. Antigravity diagnostic capture uses its fixed canonical target and the credential whose access token matches the resolved probe token, captured before I/O. Preparation failure may use a pre-resolution identity only if it still matches. Record an epoch-bound private isQuotaFailureCurrent callback; recheck before cache publication, cached reads, result assembly and API projection. A changed/missing/unreadable identity omits the diagnostic only, preserving current last-good quota/unavailable semantics. No private digest or callback serialized. Add optional nonenumerable quotaFailureIsCurrent to the internal ProviderAccountQuota result instead of using isCurrent, whose existing API branch invalidates the entire quota row. Cache/result quotaFailure appears only while unavailable and current. Tests reauth during delayed probe and cached failure after same-id replacement; stale diagnosis is omitted, not attached to replacement credentials.
+
+TUN-D source proposal labels refer to the read-only Faraday design outputs recorded in this task. The executable source of truth is this document's full type/flow contract, not an absent external file. No inference-wide authority is added.
diff --git a/devlog/_plan/260912_accounts/071_tun_delivery.md b/devlog/_plan/260912_accounts/071_tun_delivery.md
new file mode 100644
index 0000000000..cc5de963c4
--- /dev/null
+++ b/devlog/_plan/260912_accounts/071_tun_delivery.md
@@ -0,0 +1,9 @@
+# Safe Antigravity quota diagnostics
+
+The existing canonical transport stays intact. A private probe result preserves exported null/rejection compatibility and classifies the final attempted endpoint. Account failures carry only an allowlisted code, bound to the probed credential/project and invalidated independently from last-good bars. API, CLI and both dashboard quota views consume it; unknown values and local management failures stay generic. All nine locale dictionaries updated.
+
+Regression sources exercise status/typed transport categories, fallback recovery, old bars, same-id replacement during and after a probe, private-value serialization, API projection, GUI normalization/recovery and CLI code filtering. Local suites/build/typecheck/install: NOT RUN. Text whitespace checks only. Backend source reviewer Rawls found no material scoped defect; full consumer/security review and final hosted CI/render proof remain pending. Authenticated TUN field acceptance remains open for #3781; no live credential or network configuration was changed.
+
+Resume binding verified: session01a093dc-e20d-79a1-8d46-231ced9ee05b, currentaccounts-tun branch489af939 baseline, actual FSMB. Host goal is blocked and has not been edited or described as active. Existing successful callback CI34673984380/fa4226a9ba is retained and not rerun. Other original PRs have changed heads and require current evidence; no merged PR is recreated.
+
+Full source review found a diagnostic-specific merge regression gap. Added initial-roster-failure clearing, roster-only same-mode retention/mode-change clearing, and delayed failure/recovery after newer selection with surviving membership. No product behavior changed in this correction. Local suites NOT RUN.
diff --git a/devlog/_plan/260912_accounts/screenshots/010_usage-incomplete.png b/devlog/_plan/260912_accounts/screenshots/010_usage-incomplete.png
new file mode 100644
index 0000000000..a1b96ec48d
Binary files /dev/null and b/devlog/_plan/260912_accounts/screenshots/010_usage-incomplete.png differ
diff --git a/devlog/_plan/260912_accounts/screenshots/011_usage-custom-range.png b/devlog/_plan/260912_accounts/screenshots/011_usage-custom-range.png
new file mode 100644
index 0000000000..ed3589cb20
Binary files /dev/null and b/devlog/_plan/260912_accounts/screenshots/011_usage-custom-range.png differ
diff --git a/devlog/_plan/260912_accounts/screenshots/020_codex-plan-exclusion-reset-first.png b/devlog/_plan/260912_accounts/screenshots/020_codex-plan-exclusion-reset-first.png
new file mode 100644
index 0000000000..63d7c9229a
Binary files /dev/null and b/devlog/_plan/260912_accounts/screenshots/020_codex-plan-exclusion-reset-first.png differ
diff --git a/devlog/_plan/260912_operations/030_totals.md b/devlog/_plan/260912_operations/030_totals.md
index 71d2615772..e3baa47109 100644
--- a/devlog/_plan/260912_operations/030_totals.md
+++ b/devlog/_plan/260912_operations/030_totals.md
@@ -2,10 +2,14 @@
Class C3; dependency roadmap. Adopt public #4111 final diff (2f07acb58b3e73f48cea38334f301b430a8634cd) after current-base and latest-review audit; preserve luvs01 credit. Source diff and metadata are in ignored .tmp/operations/pr-4111.diff/json, fetched directly from GitHub.
-MODIFY src/server/management/usage-aggregate-cache.ts: replace four oversizedRows throws with retained usageIncomplete boolean, set on full scan, OR on append, preserve in resultFrom; cache API-key snapshots with diagnostic. MODIFY api-key-usage.ts: keep readable accumulator output and attach usageIncomplete:true / usageIncompleteReason:oversized_rows instead of throwing. MODIFY logs-usage-routes.ts: serialize diagnostics on filtered and unfiltered summaries. MODIFY usage-summary-cache.ts CachedUsageSummary and oauth-account-routes.ts GET /api/keys to retain/serialize flags. Other IO/mutation errors still fail.
+MODIFY src/server/management/usage-aggregate-cache.ts: replace four oversizedRows throws with retained usageIncomplete boolean, set on full scan, OR on append, preserve in resultFrom; cache API-key snapshots with diagnostic. MODIFY api-key-usage.ts: keep readable accumulator output and attach usageIncomplete:true / usageIncompleteReason:oversized_rows instead of throwing. MODIFY logs-usage-routes.ts: serialize diagnostics on filtered and unfiltered summaries. MODIFY usage-summary-cache.ts CachedUsageSummary and oauth-account-routes.ts GET /api/keys to retain/serialize flags. Preserve existing non-oversized failure behavior: API-key rollups still return their existing zero fallback on IO failure, and /api/usage retains error:read_failed.
MODIFY src/cli/usage-report.ts: warnings precede totals/no-match branch; incomplete no-match says skipped records may match. MODIFY gui/src/usage-summary-resource.ts shared optional diagnostic type; NEW components/usage-incomplete-notice.tsx; extend consumers Usage, dashboard overview, Models, AddProviderModal, ProviderWorkspaceShell, ApiKeysWorkspace/ListPanel and ApiKeys. Incomplete keys do not claim inactivity; warnings survive consumer caching. Add all locale keys. Full field chain: scanner oversizedRows -> retained aggregate boolean/API key snapshot -> route JSON/cache -> shared GUI/CLI input types -> every totals/ranking/key activity consumer.
-MODIFY existing tests/cli/cli-usage-report.test.ts, tests/server/api-usage.test.ts, tests/server/api-key-attribution.test.ts, tests/usage/usage-aggregate-cache.test.ts and GUI usage/custom-range/model-picker/key-workspace tests; NEW gui/tests/usage-incomplete-consumers.test.tsx. Activation: good + oversized + good rows yields readable sums and warning; append oversized sticky flag, full clean rewrite clears it, missing filter matches stays uncertain, loading/stale consumers retain warning. Do not turn IO errors into zero totals.
+MODIFY existing tests/cli/cli-usage-report.test.ts, tests/server/api-usage.test.ts, tests/server/api-key-attribution.test.ts, tests/usage/usage-aggregate-cache.test.ts and GUI usage/custom-range/model-picker/key-workspace tests; NEW gui/tests/usage-incomplete-consumers.test.tsx. Activation: good + oversized + good rows yields readable sums and warning; append oversized sticky flag, full clean rewrite clears it, missing filter matches stays uncertain, loading/stale consumers retain warning. Do not introduce any new IO-error fallback.
MODIFY structure/gui-and-management-api.md and relevant mapped contract pointers; public management API, CLI agents and web-dashboard guides in all existing translated paths from original diff. Hosted full CI and dashboard evidence certify final tip; local suites/build/typecheck NOT RUN. This does not implement hub client-scoped CLI usage (#4205).
+
+Design reflection OPS-TOTALS01..05 accepted: positive-only flags do not prove completeness when absent; invalidRows is not sticky. Models rejects most-used ordering before PUT when usage is incomplete and keeps other modes available. Include dashboard-shared.ts as type carrier, keys-first/usage-first seeding, unfinished suffix without duplicates, empty/no-attribution and incomplete-to-clean recovery cases. Reuse existing Notice warn presentation and all locale modules, without new visual tokens or motion (ops dashboard, variance2/motion1, existing density). Remote build preview supplies rendered evidence; local product build/tests NOT RUN.
+
+A amendment: replace the newly carried fixed25ms wait in gui/tests/usage-incomplete-consumers.test.tsx with bounded condition-driven completion (act and event-loop turns, asserting rendered expected condition before return). No timer delay is accepted as proof of rendering.
diff --git a/devlog/_plan/260912_operations/040_client_usage.md b/devlog/_plan/260912_operations/040_client_usage.md
index 389c303f8b..e582767c44 100644
--- a/devlog/_plan/260912_operations/040_client_usage.md
+++ b/devlog/_plan/260912_operations/040_client_usage.md
@@ -8,6 +8,22 @@ MODIFY existing client/hub API owner only where the read contract requires it; e
MODIFY public connected-client/CLI usage guide and structure/runtime.md / gui-and-management-api.md canonical scope. Any pre-disclosure details stay in scratch. Hosted regressions only; local execution NOT RUN.
-Accepted design OPS-USAGE-02/03/04. NEW tests/server/hub-usage.test.ts and tests/clients/hub-usage.test.ts with entries in scripts/test-layout/layout.json explicit and tests/fixtures/test-layout-expected.json; NEW tests/cli/cli-usage-hub.test.ts. Tests use two client keys, loopback and remote admissions, invalid state, custom window, unsupported endpoint, bad response, expired/revoked credentials. Full implementation follows source confirmation before B.
+Accepted design OPS-USAGE-02/03/04. NEW tests/server/hub-usage.test.ts and tests/clients/client-hub-usage.test.ts with entries in scripts/test-layout/layout.json explicit and tests/fixtures/test-layout-expected.json; NEW tests/cli/cli-usage-hub.test.ts. Tests use two client keys, loopback and remote admissions, invalid state, custom window, unsupported endpoint, bad response, expired/revoked credentials. Full implementation follows source confirmation before B.
Reflection amendments: getFilteredUsageAggregate in src/server/management/usage-aggregate-cache.ts is the aggregation owner. Client DTO preserves #4111 incomplete flags; CLI suppresses advice to remove filters for account totals because that scope never exports accounts. Public files: docs-site/src/content/docs/guides/remote-hub.md and reference/cli/agents.md. All three new tests register in scripts/test-layout/layout.json and tests/fixtures/test-layout-expected.json.
+
+P revalidation after totals28c13d0c09: this slice depends on its usageIncomplete aggregate contract, so publish an ordinary child PR based on operations-totals. Retire4343 review follow-up was prioritized by explicit user steering; it is now source-reviewed and resolved.
+
+Concrete DTO: version1/source hub/scope client; range/surface/since/until/customWindow/generatedAt, numeric summary fields consumed by CLI, provider/model/day cost rows, and provider/model/matched/comboOverlap filter echo. No accounts, raw entries, apiKeyId or arbitrary spread fields cross the wire. NEW remote/hub-usage.ts owns a stripping Zod schema, capped arrays/string sizes and1MiB response bound; server projects through it and client parses through it. Incomplete flags retain positive-only semantics. No persistence or cache on client. Existing getFilteredUsageAggregate owns server cache keyed by authenticated key; no route-global cache.
+
+Handler accepts GET/v1/usage only, requires dedicated data key and configured admission even for loopback; no management/API env key. Reject unknown or duplicate query keys, caller apiKeyId, invalid range/surface/window, and noncanonical (blank/padded) authenticated key IDs before aggregation because the existing filtered cache trims IDs. Check origin and hub role. Recheck matched current credential/key identity after awaited scan before returning; revocation/rotation changes cannot publish a stale authority response. Serialize bounded allowlist DTO or explicit error, never a partial silent result.
+
+CLI reads connection state and matching service token fingerprint, sends only data credential to configured serverUrl via existing fetchBounded/boundedText helpers, retains redirect refusal and deadlines. Invalid/mismatched connection/token fails with no local fallback. Confirm owner remains the same after the read before printing. Human header names hub source/client scope and suppresses account-total advice; standalone runtimeRequest remains unchanged.
+
+Hosted tests: actual server two keysA/B and loopback auth; caller keyID rejected; absent/environment/admin/bad keys rejected; unknown/duplicate params and invalid window; provider filters/custom window; malformed response/too-large/redirect/oldHub/offline; CLI connected versus standalone and token mismatch. Test paths in this doc register in both layout files. No local tests. Unpublished security analysis remains .tmp/operations/040_client_usage_private.md.
+
+Reflection closure: post-read CLI validates both owner triple AND current connection/file token fingerprints; sameClientConnectionOwner alone omits fingerprint. Every nested DTO object strips unknown fields; the1MiB check uses serialized UTF-8 bytes in addition to array/string caps.
+
+B scope refinement: reuse resolveDataPlaneAdmissionSecret directly; no resolver logic change necessary; auth-cors.ts AUTH_MATRIX gets the new endpoint row and tests/server/api-key-attribution.test.ts drives its real GET cells. Client test basename is client-hub-usage.test.ts to avoid the registry basename collision with server/hub-usage.test.ts.
+
+Follow-up100: new fetchHubUsage requires HTTPS or supported loopback HTTP before credential headers, uses request cache:no-store, and retains server cache-control:no-store.
diff --git a/devlog/_plan/260912_operations/100_usage_transport.md b/devlog/_plan/260912_operations/100_usage_transport.md
new file mode 100644
index 0000000000..ac09181261
--- /dev/null
+++ b/devlog/_plan/260912_operations/100_usage_transport.md
@@ -0,0 +1,7 @@
+# Client usage credential transport
+
+User intake4353 plan comments3995195164 and3995195169. The new usage source is in4373, not in4353. Previous listener-diagnosticD directs this separate repair before pairing. Class C4; same scoped HOTL/tool/write bounds, no resource cap, no local suites/build/typecheck/install. Do not change unrelated hub-client operations or publish speculative analysis.
+
+MODIFY src/client/hub-client.ts fetchHubUsage only: after normalizeHubOrigin, use existing isPairingTransportPermitted before constructing credential headers or invoking fetchBounded. Accept HTTPS and supported numeric/localhost loopback HTTP; reject plain HTTP remote origins with an actionable static error. Add cache:no-store on the authenticated request. The server already sends cache-control:no-store; preserve it.
+
+MODIFY tests/clients/client-hub-usage.test.ts: remote HTTP and credentials-bearing malformed origins fail before injected transport invocation; HTTPS and each supported loopback form remain accepted; request cache mode is no-store. Existing real route test proves response header. Modify040 and publichub usage guide to state transport constraint. Independent source re-audit, reply/resolve the two authorized plan threads with4373 exacthead. Hosted cumulativetip acceptance remainsOPEN.
diff --git a/devlog/_plan/260912_remote_workspace_carry/020_executor_runtime.md b/devlog/_plan/260912_remote_workspace_carry/020_executor_runtime.md
index 550b2a5bb5..e20200c7c3 100644
--- a/devlog/_plan/260912_remote_workspace_carry/020_executor_runtime.md
+++ b/devlog/_plan/260912_remote_workspace_carry/020_executor_runtime.md
@@ -82,3 +82,25 @@ REMOTE-ARCH-003: Separate persisted enrollment capabilities from current connect
REMOTE-ARCH-006: Use existing required private-file/Windows ACL primitives for new identity and bearer stores. Check permission setup failures and refuse loading/saving secrets when enforcement fails. Do not change global config-store behavior. Record exact selected existing helper in phase-2 P after reading the owner; no best-effort function is accepted as proof.
REMOTE-ARCH-007: Codex real App Server tests depend on OCX_CODEX_BIN; Claude real integration on OCX_CLAUDE_BIN; Pi on OCX_PI_BIN. The Linux confinement case can return without execution unless OCX_REQUIRE_LINUX_REMOTE_WORKSPACE_CONFINEMENT=1 or bwrap is available. Current generic CI alone does not prove those paths. Mock tests prove lifecycle and tool-routing contracts only; native Hub isolation and executor confinement stay explicit final acceptance gaps when not activated. For each adapter separately record denied local tools, inherited plugins/hooks/config, offline refusal and teardown; inspect source plus hosted mocks, no claims of live CLI confinement from flags alone.
+
+## Phase-2 revalidation and exact owner choices
+
+Previous D: wp1 inactive foundation source cycle complete at 726ddc7fc0; final hosted proof remains wp4. Continue in child branch codex/260912-60plus-remote-runtime. Existing public exports and added host-negative coverage are retained.
+
+REMOTE-ARCH-004: storage modules import atomicWriteFile directly from src/config/atomic-write.ts and getConfigDir from src/config/paths.ts, avoiding the broad config.ts barrel. Device CLI orchestration retains explicit runner construction because it computes actual availability after root approval; no import-time probe exists. This is intentional sequential coupling. Server seams in phase 3 use narrow structural connection/session interfaces rather than pulling concrete remote classes into shared request types. No remote module imports server surfaces.
+
+REMOTE-ARCH-006 exact helpers: NEW src/remote-control/workspace-secret-store.ts owns prepareWorkspaceSecretDirectory(directory) and hardenWorkspaceSecretFile(path). On POSIX use chmodSync with propagated failure and lstat directory/file identity/type checks. On Windows call existing src/lib/windows-secret-acl.ts hardenSecretDir/hardenSecretPath with required:true. Reject symlink state targets. All three stores use this before reads and before atomicWriteFile. Existing atomic-write.ts already creates an empty private descriptor, hardens before writing bytes, and scrubs failures; retain it. Tests: NEW tests/clients/remote-workspace-secret-store.test.ts covers owner-only POSIX file mode, unexpected path types/symlinks and failed reads; hosted Windows ACL owner tests remain applicable. No global config behavior changes.
+
+src/lib/windows-atomic-replace.ts change is the new ReplacePublisher literal remote-workspace (the function is already exported). Use existing counter serialization/consumers unchanged: creation at executor write, diagnostic key serialization, dynamic record readers; no closed switch to extend.
+
+NEW tests/clients/remote-workspace-session-binding.test.ts covers session/device/root/capability mismatches with zero execution and a valid positive control, using encrypted messages and independent fixtures. MODIFY agent-wire, hub, sessions and device tests to assert subset negotiation and presence intersection. Platform runner source retains existing fail-closed native paths; remove stale comment claiming supported macOS commands.
+
+### Audit amendment: store-level failure propagation
+
+Hub/Device/Session file-store constructors accept an optional narrow permissions dependency containing prepareDirectory and hardenFile, defaulting to the required production helper. Load returns null for absent files; existing files require directory and file checks before secret reads. Save prepares directory, hardens an existing target, then invokes the existing private atomic writer. For each store, injected directory/file hardening throws must propagate, preserve existing bytes and prevent secret IO. New-state first-run controls return null then save/load valid fixtures. Add all three store cases to remote-workspace-secret-store.test.ts; this injection observes caller ordering rather than relying on ACL-owner tests alone.
+
+### Native containment amendment
+
+Independent source review requires a protected Linux bubblewrap executable outside writable roots, with identity revalidation before use. Custom executable files and their parent chain must not be writable by group/other; canonical system symlinks are resolved before checking. Workspace roots cannot contain the executable; every invocation rechecks. Add source/runner regression fixtures without claiming a local run.
+
+Windows command availability remains disabled in this carry: nativeRemoteWorkspaceCommandRunnerAvailable returns false before invoking the helper, and the official Windows helper rejects public probe/run without allocating OS resources. The candidate Windows implementation remains in original PR history; do not retain callable unverified entrypoints. This matches the fail-closed macOS policy and preserves independently authorized file tools. Update native denial tests and docs; Windows working-command acceptance stays OPEN. A future lifecycle owner and hosted cancellation/cleanup evidence are required before re-enablement. This is a safety limitation, not completion of Windows commands.
diff --git a/devlog/_plan/260912_remote_workspace_carry/030_integration.md b/devlog/_plan/260912_remote_workspace_carry/030_integration.md
index 5c126f5987..4cf079534d 100644
--- a/devlog/_plan/260912_remote_workspace_carry/030_integration.md
+++ b/devlog/_plan/260912_remote_workspace_carry/030_integration.md
@@ -67,3 +67,23 @@ Local tests/build/typecheck/install NOT RUN by user instruction. Text comparison
NEW src/remote-control/workspace-activation.ts exports a side-effect-free guard requiring runtimeRole=hub AND process.env.OCX_REMOTE_WORKSPACE_ENABLED === "1". This guard imports only the config type. Pair and agent branches call it before dynamic import; disabled requests return 404. Management namespace returns a disabled status before importing runtime. Shutdown uses already retained workspace references or initialized-only lazy import only when explicitly enabled; a disabled Hub never creates identity or probes model CLIs. CLI pairing remains explicit Executor-local authorization and never modifies server environment. Document the opt-in variable and require an explicit environment choice to enable the feature. Test disabled Hub, non-Hub with flag, and enabled Hub, with no ambient inheritance in fixtures.
Existing-file conflicts observed by git apply --check: management-api.ts, management/context.ts and ws-bridge.ts. Port the namespace-dispatch addition into current management handler, append only type/dependency seam fields after current imports, and extend current WebSocket discriminator/handlers without replacing newer fields. The check was text applicability only, not a product test.
+
+## Phase-3 revalidation
+
+Previous D: runtime source cycle closed at a3182185f0 after corrected whitespace receipt. Final executable/native proof remains open; Windows commands unsupported. Continue integration from that exact parent. Carry current React resource/Select/Notice/icon conventions with no dependency additions. All locales inherit original translations with the unavailable-state opt-in message added consistently.
+
+Server adaptation: preserve current quota-reset and Grok coupon lazy dispatch. Add remote namespace handler before normal configuration routes. It answers disabled GET status with available:false and empty collections before loading workspace runtime; mutations when disabled refuse. Pair/agent paths require explicit guard before lazy imports and existing Origin/device-token validation. WebSocket data stores only structural receive/open/close callbacks; no concrete Hub class imports in ws-bridge. Upgrade closure owns hub/device association and close cleanup. Management dependency seams use structural Pick projections of only public Hub/session operations; all are import type and erased at runtime. Runtime modules use narrow config imports from phase 2, eliminating the prior broad runtime cycle.
+
+Shutdown: a promise-local initialized workspace module reference is set only on actual workspace route activation; shutdown calls initialized service getters only when that reference exists. It never dynamically imports remote runtime merely because runtimeRole is hub. Management-only activation also needs lifecycle-owned shutdown registration or a retained optional shutdown callback; resolve before B and test both paths.
+
+NEW tests/clients/remote-workspace-activation.test.ts covers hub+flag guard, disabled management status without store writes and unauthorized principal refusal before dependency construction. Existing server tests get explicit isolated flag setup/restore; no real devices. CLI capabilities list pair/agent/status, no Hub-status automation introduced. Regenerate skills/ocx reference surface through its existing generator (documentation only). Docs state OCX_REMOTE_WORKSPACE_ENABLED=1 opt-in, default read-only sessions, Linux conditional exec and both desktop native helpers refusing commands.
+
+Rendering: this worktree has no node_modules or gui/node_modules. Do not install or run a local build. Prefer final hosted package artifacts for a local static render with synthetic API responses; if no artifact exists, retain rendering as unmet acceptance and attach no historical screenshot as current evidence.
+
+### Awaited per-server cleanup decision
+
+The existing optional-shutdown registry is synchronous best-effort and cannot prove awaited Remote Workspace shutdown. Reuse server.stop's existing runListenerShutdown array instead. Add a per-server retained shutdown callback and a ManagementApiDeps onRemoteWorkspaceShutdown callback setter. Workspace management resolves its already-loaded services then registers an initialized-only cleanup closure through that setter; pair/agent loader registers the same kind of closure. server.stop calls the retained callback if present. No callback means no remote import/work. Keep registration idempotent and closure references scoped to the current config/server; tests cover management-only initialization and explicit stop. Do not change the global optional-shutdown API.
+
+In-flight initialization refinement: management checks per-server stopping before and after module import, creates Hub/session services synchronously in one turn, then registers initialized-only teardown. Pair/upgrade paths check stopping after lazy load. SessionService rejects create/resume after shutdown even when an availability promise completes later; a regression holds availability across shutdown. This prevents request initialization from creating resources after stop.
+
+Source-audit follow-up: pending creation promises and late handle cleanup are owned by shutdown, which settles all session cleanups before propagating failure. Pair-body completion rechecks stop admission. UI derives enrollment commands from the actual shared Hub origin, keeps drafts per session, preserves newer input on failure, shares submission eligibility between keyboard/button, warns on stale snapshots, and permits Stop during pairing. Controlled runtime/body/draft/Stop regressions accompany these adaptations; execution remains hosted-only.
diff --git a/devlog/_plan/260912_remote_workspace_carry/041_resume_ci_repair.md b/devlog/_plan/260912_remote_workspace_carry/041_resume_ci_repair.md
new file mode 100644
index 0000000000..0b8bcdec90
--- /dev/null
+++ b/devlog/_plan/260912_remote_workspace_carry/041_resume_ci_repair.md
@@ -0,0 +1,23 @@
+# Resumed final-tip verification
+
+The integration branch was fast-forwarded from 5c462fec1a to existing remote head 4ad1d1ce54 without rewriting the six intervening commits. PRs #4344, #4362 and #4372 remain open. CI run 34683454076 at 4ad1d1ce54359ee25fbb078344a22bdb16f7e304 failed; cancelled and skipped jobs are not passing evidence.
+
+## Scoped repair
+
+MODIFY tests/clients/remote-workspace-activation.test.ts: provide the HTTP Host precondition that managementRequestOrigin requires. Preserve 200/404 expectations and assert the admin-token denial message so a generic origin failure cannot satisfy the consent regression.
+
+MODIFY tests/clients/remote-workspace-sessions.test.ts: observe pending rejection using native Promise settlement, release the held start/availability gate, then assert rejection. Keep late runtime cleanup, one-stop/one-close checks, terminal failure state, and assert zero model prompts. Production cleanup code is unchanged. The source dependency order is verified; Bun matcher scheduling remains a hypothesis until repaired-head hosted execution confirms it.
+
+The host goal is observed blocked and the native cycle remains C; no direct goal/FSM edits or reset were performed. This repair continues the incomplete integration Check under explicit resume authorization. It is not claimed as an additional completed cycle. Local suites/build/typecheck/install are NOT RUN. Independent inherited-model review and final cumulative-tip hosted CI remain required.
+
+## Render provenance
+
+Dashboard artifact 10294424393 from run 34683454076 records merge-ref commit 5270d422166eb56692d1fde1513d5ff4f40533d4 and GUI tree 69b1bce039ee18cecea2586bc484c2afe0523a13, equal to candidate 4ad1d1ce54:gui. It is rendered with synthetic example state on a loopback static server; mutations are disabled. This proves the captured layout only, not real pairing or runtime execution.
+
+Observed screenshots: assets/041_desktop_read_only.png and assets/042_desktop_files_only.png at 1440x913 CSS pixels. The actual access picker changes from Read only to Edit files only and displays the command-unavailable notice. No live enrollment or model operation was invoked. A requested390px native-window resize stopped at500px; that is not390px proof, and no narrower layout claim is made. The installed browser CLI does not expose the documented script command; no driver was installed to work around it.
+
+## Windows subprocess fixtures
+
+Run34693137770 at d1a922ae reports three remote-owned fixture failures on Windows: two native response cases cannot execute a POSIX shebang file, and the cwd case times out while starting PowerShell. The intended contracts are bounded stdin/response decoding and real child cwd; neither requires a shell. Replace only test subprocess fixtures with the exact current Bun executable, preserve helper-path checks, timeout/output bounds and success/error assertions. Native Windows command support remains disabled. Local suites remain NOT RUN; independent source review and repaired-head hosted proof are required.
+
+The Windows native fixture keeps descriptor keys separate from its injected spawn function, verifies all pipe options before forwarding them unchanged, and never executes authority from the input JSON. Cwd checks compare decoded stdout with the canonical executor directory and exclude the canonical coordinator directory. An exact exit-code/empty-stderr assertion accompanies the unchanged deadline. At predecessor d1a922ae, hosted Windows2 job103552067478 passed the corrected disabled management status case; that narrow result is not an all-suite pass.
diff --git a/devlog/_plan/260912_remote_workspace_carry/assets/041_desktop_read_only.png b/devlog/_plan/260912_remote_workspace_carry/assets/041_desktop_read_only.png
new file mode 100644
index 0000000000..9075e975be
Binary files /dev/null and b/devlog/_plan/260912_remote_workspace_carry/assets/041_desktop_read_only.png differ
diff --git a/devlog/_plan/260912_remote_workspace_carry/assets/042_desktop_files_only.png b/devlog/_plan/260912_remote_workspace_carry/assets/042_desktop_files_only.png
new file mode 100644
index 0000000000..c99e88e29f
Binary files /dev/null and b/devlog/_plan/260912_remote_workspace_carry/assets/042_desktop_files_only.png differ
diff --git a/devlog/_plan/260912_remote_workspace_carry/screenshots/remote-workspace-hub-dashboard.png b/devlog/_plan/260912_remote_workspace_carry/screenshots/remote-workspace-hub-dashboard.png
new file mode 100644
index 0000000000..98972b8e75
Binary files /dev/null and b/devlog/_plan/260912_remote_workspace_carry/screenshots/remote-workspace-hub-dashboard.png differ
diff --git a/devlog/_plan/260912_remote_workspace_carry/screenshots/remote-workspace-pairing-code.png b/devlog/_plan/260912_remote_workspace_carry/screenshots/remote-workspace-pairing-code.png
new file mode 100644
index 0000000000..3fc3c97e08
Binary files /dev/null and b/devlog/_plan/260912_remote_workspace_carry/screenshots/remote-workspace-pairing-code.png differ
diff --git a/devlog/_plan/260912_unimplemented_trio_stack/000_plan.md b/devlog/_plan/260912_unimplemented_trio_stack/000_plan.md
new file mode 100644
index 0000000000..611dbac17c
--- /dev/null
+++ b/devlog/_plan/260912_unimplemented_trio_stack/000_plan.md
@@ -0,0 +1,108 @@
+# Trio stack: WS stage instrumentation, native-main device reauth, paginated history recovery
+
+Unit 260912_unimplemented_trio_stack. HOTL loop goalplan slug
+`implement-three-unimplemented-opencodex-backlog` (session
+01a09616-38e6-72e0-b5bf-99eb10ce58a6). Bottom-up manual stacked-PR chain
+against `dev` (lidge-jun/opencodex). No merges, no GitHub native-stack
+registration. Every push uses `git push --no-verify`; local product
+suite/build/typecheck/install NOT RUN; each PR relies on hosted exact-head
+CI and says so in its Verification section.
+
+## Objective
+
+Close the three implementable unimplemented backlog items identified in the
+2026-09-12 inventory:
+
+1. Issue #4191 — WS 1006 / response-prelude-timeout diagnosis has no durable
+ content-free evidence. Ship stage instrumentation only; no fix, no
+ auto-retransmit fallback.
+2. Issue #3898 — headless hub cannot reauth native `__main__` because
+ deviceauth is pool-only. Ship the native-main device reauth API/CLI, then
+ the main-card Re-login GUI on top of it.
+3. Issue #4311 residual — paginated history still has no writer support and
+ no recovery for ordinal-corrupted rollouts. Ship the offline recovery
+ tool with preservation proofs; live writes stay refused.
+
+## Sources
+
+- #4191 body: content-free stage diagnostics list; A/B evidence that the
+ failure is proxy-path-specific; related #2471, #4083, #3976.
+- #3898 body: suggested contract (reuse OpenAI deviceauth, persist to native
+ main slot, keep `__main__` out of `/api/codex-auth/login`, no codex
+ binary/keyring requirement, secret-free DTOs).
+- #4311 body: ordinal-0 clone defect (now guarded), incident recovery by
+ ordinal-digit rewrite while Codex was closed, prohibition of N+1 guessing
+ and live rewrites.
+- devlog/_plan/260912_accounts/080_reauth_api.md and 090_reauth_ui.md —
+ accepted Accounts-lane design drafts this unit adopts for L2/L3.
+- devlog/_plan/260912_history_containment/ — refusal contract this unit
+ must preserve.
+
+## Constraints (hard)
+
+- L1 logs stay content-free: create-frame byte count, send completion,
+ close code (numeric), elapsed/first-frame timings, frame counters, OCX and
+ Bun versions. No conversation text, no headers, no close-reason text, no
+ account identifiers in the new records.
+- L1 adds no `responseCommitted === false` auto-retransmit: turn
+ duplication risk is documented in #4191 discussion.
+- L2 keeps `/api/codex-auth/login` rejecting `__main__` (400), keeps pool
+ Add/Re-login semantics unchanged, and must not route the native flow
+ through `startLoginFlow("chatgpt")` (scratch-slot overwrite + pool
+ singleflight collision, src/oauth/index.ts:1899-1973).
+- L2 commit to `$CODEX_HOME/auth.json` only under an exclusive claim with
+ path/hash/inode assertion and same-identity verification; never retains
+ old identity token beside new credentials; fails safe
+ (`native_main_unavailable`) when no fence can be established.
+- L3 must not reuse `AddCodexAccountModal` or `openReauth("__main__")`;
+ dedicated hook and dedicated backend namespace only (the pool login route
+ rejects `__main__` at src/codex/account-id.ts:15-20).
+- L4 must not invent last-ordinal+1, must not write to a live rollout, must
+ not weaken `history_paginated_requires_native_writer` refusal in
+ preflight/apply/restore paths, and must preserve every non-ordinal byte.
+- All layers: focused tests land with the layer; every new test file gets
+ layout.json `explicit` + tests/fixtures/test-layout-expected.json
+ entries in the same PR.
+- structure/ ownership: any owned source area changed by a layer updates
+ its structure doc in the same PR (structure/AGENTS.md).
+
+## Work-phase map (dependency order = stack order, bottom first)
+
+| WP | Layer | Branch | PR base | Decade doc |
+|----|-------|--------|---------|------------|
+| wp2 | L1 #4191 WS stage instrumentation | codex/260912-ws-stage-instrumentation | dev | 010 |
+| wp3 | L2 #3898 native-main reauth API/CLI | codex/260912-native-main-reauth-api | wp2 branch | 020 |
+| wp4 | L3 #3898 main-card Re-login GUI | codex/260912-native-main-reauth-ui | wp3 branch | 030 |
+| wp5 | L4 #4311 paginated history recovery | codex/260912-native-paginated-writer | wp4 branch | 040 |
+
+Dependency logic: L2 and L3 are one feature split at the API/UI seam
+(030 depends on 020's route). L1 is independent but touches the shared
+request-log schema, so it sits at the bottom where later layers rebase onto
+a stable log contract. L4 is the riskiest (user data) and rides on top so
+lower layers can land without waiting for it. There is no functional
+dependency between L1/L2 and L4; the chain exists to serialize review.
+
+## Verification policy per layer
+
+- Red-first focused tests, then implementation, then green.
+- `bun test tests//` (or `cd gui && bun test tests/`
+ for L3) fresh at C, captured via `cxc receipt test`.
+- Full local suite/build/typecheck/install: NOT RUN (standing rule); each
+ PR Verification section labels this and names the hosted exact-head CI
+ run as the integration evidence. Cancelled/skipped CI never counts as
+ passing.
+- L4 additionally: privacy-relevant paths (rollout bytes) stay in tests
+ with synthetic fixtures only.
+
+## Open decisions carried to audit
+
+1. L2 hub fence: on a headless hub the native owner never activates
+ (src/server/index.ts:1026-1046 + src/codex/desired-state.ts:79-81).
+ 020 resolves how commit fencing works there without weakening the
+ exclusive-claim contract; audit must confirm the chosen fence.
+2. L4 scope: true live-write support needs a Codex-owned writer API that
+ does not exist in this tree. This unit ships the offline recovery tool
+ and keeps live refusal; the PR description must say so explicitly.
+3. L3 screenshot evidence: obtained from hosted CI artifacts or recorded
+ exemption, per repo PR gate (title/body mentions of gui need a
+ screenshot).
diff --git a/devlog/_plan/260912_unimplemented_trio_stack/001_baseline_revalidation.md b/devlog/_plan/260912_unimplemented_trio_stack/001_baseline_revalidation.md
new file mode 100644
index 0000000000..327dc4285c
--- /dev/null
+++ b/devlog/_plan/260912_unimplemented_trio_stack/001_baseline_revalidation.md
@@ -0,0 +1,22 @@
+# Baseline revalidation (wp1 B-phase)
+
+Independent main-session spot check of the citations folded in by the wp1
+audit (0268727f82), re-run against the working tree at B. Every folded
+reference was opened and read; results below. All verified TRUE.
+
+| Claim | Where verified | Result |
+|-------|----------------|--------|
+| `assertNativeMainOwner` throws without a held owner entry | src/codex/native-main-owner.ts:302-314 — throws NATIVE_MAIN_OWNER_UNAVAILABLE/BUSY (503) unless snapshot held | TRUE |
+| Exclusive claim is owner-independent (FS/SQLite lock) | src/codex/native-main-claim.ts:167 — `withNativeMainExclusiveClaim(context, operation, options)`, claim/release around operation, no owner lookup | TRUE |
+| `shouldSyncCodexOnStart` is composed at desired-state.ts:130 | src/codex/desired-state.ts:130 — exported function; comment names the hub rule | TRUE |
+| Pool login writes `isMain: false` | src/codex/auth-api.ts:2934,2939 — both update and add paths set `isMain: false` | TRUE |
+| Paginated guard throws the structured reason | src/codex/history-provider.ts:1172 — `CodexHistoryIntegrityError("history_paginated_requires_native_writer")` on `ordinal` key or `history_mode === "paginated"` | TRUE |
+| State DB resolution | src/codex/paths.ts:106-109 — `resolveCodexStateDbPath` joins sqlite root + state_5.sqlite | TRUE |
+| `startLoginFlow` location | src/oauth/index.ts:1899 — export begins | TRUE |
+| GUI modal state | gui/src/components/CodexAccountPool.tsx:75 (`showAdd`), :94 (`reauthId`), :651-654 (modal mount) | TRUE |
+
+Consequence for implementation cycles: 020's hub-fence resolution stands
+as amended — the native-main reauth fence MUST NOT call
+`assertNativeMainOwner`; the owner-independent exclusive claim plus
+path/hash/inode and recovery/admission rechecks is the whole fence, and
+claim/admission failure alone maps to `native_main_unavailable`.
diff --git a/devlog/_plan/260912_unimplemented_trio_stack/010_l1_ws_stage_instrumentation.md b/devlog/_plan/260912_unimplemented_trio_stack/010_l1_ws_stage_instrumentation.md
new file mode 100644
index 0000000000..72cfd8c3c2
--- /dev/null
+++ b/devlog/_plan/260912_unimplemented_trio_stack/010_l1_ws_stage_instrumentation.md
@@ -0,0 +1,142 @@
+# L1: content-free Codex WS upstream stage instrumentation (#4191)
+
+Class C2. Stack bottom, base `dev`. Branch
+`codex/260912-ws-stage-instrumentation`. Diagnosis instrumentation only:
+no behavior change to success paths, no retry/fallback change.
+Second revision: folds the wp2 A-audit FAIL (2 blockers, 2 majors, 1
+minor) into the design. First revision's `recordCodexWsStage`-on-context
+design is retracted — the exchange has no `RequestLogContext`
+(codex-ws-exchange.ts:11-18,85) and cannot get one without inverting
+layers.
+
+## Problem
+
+#4191 fails as WS 1006 or "response prelude timed out" only through the
+proxy. The content-free stage record already exists as
+`CodexWsFailureStage` (src/server/responses/codex-ws-wire.ts:100-144) and
+`failureStage()` (src/server/responses/codex-ws-exchange.ts:148-159), but
+it is only interpolated into failure message strings. Durable logs keep
+neither the message nor a typed code: the eager relay collapses stream
+errors to `upstream_reset` + `streamAborted` (wire.ts:218-229 comment;
+relay.ts:1417-1430), and the 504 pre-response JSON path never reaches the
+relay at all. `/api/logs` and usage.jsonl are explicit per-field copies,
+so a field added only to `RequestLogContext` is dropped on write and on
+restart hydrate.
+
+## Contract (from #4191 + maintainer bounds)
+
+Record, per upstream exchange: create-frame bytes, send completion,
+close code (numeric only), elapsed ms and first-frame ms, frame counters
+(upstream/control/relayed, pings/pongs), pool reuse boolean, OCX version,
+Bun runtime version. Never record conversation text, headers, close-reason
+text, or account identifiers. No `responseCommitted === false`
+auto-retransmit fallback. Client CLI version is not on the handshake
+(`user-agent` is not in FORWARD_HEADERS,
+src/adapters/openai-responses.ts:43-61) — the limitation is documented in
+the PR, not worked around by parsing `frameText`.
+
+## Changes
+
+MODIFY `src/server/responses/codex-ws-wire.ts`
+- New exported type `CodexWsStageRecord =
+ Omit & {
+ requestBytes: number | null; closeCode: number | null; reused: boolean;
+ ocxVersion: string; bunVersion: string }` (Omit, not an intersection —
+ an intersection cannot widen `requestBytes`). Extend the privacy
+ comment: numeric/boolean/semver fields only; close-reason text stays out
+ of every durable record.
+- New `markCodexWsStage(response, record)` / `readCodexWsStage(response)`
+ over a `WeakMap` — the same
+ Response-marker seam `markCodexWsResponse` already uses.
+- `ocxVersion` comes from a module-local package.json IIFE, the exact
+ pattern already duplicated in management-api.ts:87-93, gui-static.ts:6-9,
+ client/machine-listener.ts:21, update/index.ts:147. Do NOT import
+ management-api (layer inversion + cycle).
+
+MODIFY `src/server/responses/codex-ws-exchange.ts`
+- `ExchangeOptions` gains optional `bunVersion?: string` and nothing
+ else; no context, no callback registry.
+- Snapshot once in `failStream` (the funnel every failure site already
+ calls: armSilence :206, connect-deadline :256, onClose :426, onError
+ :437, and the onMessage sites :330-402) and once in `commitResponse`
+ (:160). After the existing settle decision, call
+ `markCodexWsStage(response, record)` on the Response being resolved —
+ both the SSE 200 and the `codexWsPreResponseFailure` JSON paths resolve
+ a Response, so one marker covers success and failure.
+- `requestBytes`: computed at failure time only (current deferred
+ behavior). On the committed-success record it is `null` — the happy
+ path must not byte-count megabyte replay frames (the deferral comment at
+ :143-147 is the contract).
+- `closeCode` is captured in `onClose` from the event (numeric only) and
+ carried into the `failStream` call it makes; other sites pass `null`.
+- `reused` is `session.reused`; `bunVersion` from the new option.
+- No control-flow change at any site: emissions happen after the settle
+ decision, never instead of it.
+
+MODIFY `src/server/responses/ws-upstream.ts`
+- Pass `bunVersion: typeof runtime === "string" ? runtime : runtime.version`
+ (the gate input at :62-64 may be a plain string) through
+ `codexWsUpstreamFetch` into `codexWsExchange`. Signature gain is one
+ optional field.
+
+MODIFY `src/server/responses/core.ts`
+- Adopt the stage onto the attempt at the handleResponses send path, not
+ only at `retryCodexPoolOnAlternateAccount` (:1532-1556 is the pool
+ retry, not the primary send): `readCodexWsStage(upstreamResponse)`;
+ when present assign `logCtx.activeAttempt.codexWsStage`. Apply at every
+ adopted `upstreamResponse`: the primary send (:5304-5320), the
+ post-retry assignment (:5824) — or once on the final response after the
+ ladder (~5758); B picks the single funnel that covers every adopted
+ response and tests it. This covers the 504/502 pre-response JSON path
+ that never reaches relay.ts, and needs no relay.ts change: the relay
+ collapse only sets `streamAborted` alongside the stage. (First
+ revision's relay.ts MODIFY is retracted.)
+
+MODIFY `src/usage/log.ts`
+- `PersistedUsageAttempt` gains `codexWsStage?: CodexWsStageRecord`
+ with a comment naming #4191 and the content-free invariant.
+- Attempt serializer allowlist (:445-480 region): carry `codexWsStage`
+ through a `normalizeCodexWsStageRecord` guard (numeric fields via
+ isNonNegativeFiniteNumber-style checks, booleans strictly, versions as
+ capped semver strings, `requestBytes: number | null`) so a hand-edited
+ row cannot inject strings into the DTO.
+- `normalizeUsageEntry` (:527-612) carries it via the attempts
+ normalization above; no entry-level copy (stage is per-attempt).
+
+MODIFY `src/server/request-log.ts`
+- `RequestLogEntry` needs no new field: `attempts` already projects.
+ `requestLogEntryFromPersistedUsage` (:280-330) keeps copying
+ `attempts` wholesale. Verify `addFinalRequestLog` (:1037-1086) passes
+ the attempt objects (with the stage) into `addLog` — if it re-derives
+ attempt rows field-by-field, add `codexWsStage` there instead. B
+ confirms which of the two attempt paths is authoritative and tests it.
+
+## Tests (red-first)
+
+MODIFY `tests/responses/ws-upstream.test.ts`
+- Through `handleResponses` (the :399-408 pattern — the only path that
+ owns a logCtx): upstream 1006 persists `codexWsStage` on the logged
+ attempt with `closeCode: 1006` and `sent: true`; prelude-timeout
+ persists `firstFrameMs: null`, `upstreamFrames: 0`; a committed
+ success records exactly one stage with `requestBytes: null`.
+MODIFY `tests/responses/ws-failure-stage.test.ts`
+- Record carries closeCode/reused/versions; the serialized record never
+ contains reason text, header names, or body substrings.
+NEW `tests/usage/usage-log-ws-stage.test.ts`
+- Round trip: `normalizeUsageEntry` + attempt serializer keep a valid
+ stage; corrupt stage shapes (string frames, object closeCode) are
+ dropped, not passed through. layout.json explicit +
+ tests/fixtures/test-layout-expected.json entries (domain `usage`).
+
+## Docs / ownership
+
+L1 touches owned `src/server/responses/*`, `src/usage/log.ts`, and
+`src/server/request-log.ts`: sync structure/transports/responses.md and
+structure/runtime.md in this PR (structure:check must stay green).
+
+## Out of scope
+
+Any WS behavior fix, SSE-fallback policy change, prelude-timeout tuning
+(#3976/#4083), pool policy, inbound client-socket metrics
+(codexWebSocketAdmissionMetrics is the client side — do not touch),
+auto-retransmit on `responseCommitted === false`.
diff --git a/devlog/_plan/260912_unimplemented_trio_stack/020_l2_native_main_reauth_api.md b/devlog/_plan/260912_unimplemented_trio_stack/020_l2_native_main_reauth_api.md
new file mode 100644
index 0000000000..bde091a235
--- /dev/null
+++ b/devlog/_plan/260912_unimplemented_trio_stack/020_l2_native_main_reauth_api.md
@@ -0,0 +1,138 @@
+# L2: native-main device reauth API/CLI (#3898)
+
+Class C4 (auth boundary). Stack layer 2, base the L1 branch. Branch
+`codex/260912-native-main-reauth-api`. Adopts the accepted Accounts-lane
+design devlog/_plan/260912_accounts/080_reauth_api.md; this doc is the
+diff-level revalidation of that draft against current `dev` plus the
+deltas the code map surfaced. 080 remains the contract source; anything
+here overrides stale details of 080, not its invariants.
+
+## Problem
+
+Headless hub (`runtimeRole=hub`, `oauthOpenBrowser: false`, no codex
+binary, no keyring) cannot reauth native `__main__`:
+`/api/codex-auth/login` is pool-only and rejects `__main__`
+(src/codex/account-id.ts:15-20; src/codex/auth-api.ts:2733-2748);
+`ocx account main add` requires official `codex login` + OS keyring
+(src/cli/account-main.ts:73-90,214-260). WHAM `token_revoked` on the main
+grant is then unrecoverable from the hub.
+
+## Changes (080 contract, revalidated)
+
+MODIFY `src/oauth/chatgpt-device.ts`
+- Factor the private grant exchange so a native-only result retains the
+ raw validated token payload: new `loginChatGPTNativeDevice` returns
+ `{ credential, idToken }` in-process only; reject missing
+ access/refresh/id token or mismatched account identity. Existing
+ `loginChatGPTDevice` behavior unchanged (still projects
+ OAuthCredentials, no id_token).
+- Delta from 080 (explorer-confirmed gap): the usercode/poll/token fetches
+ (84-90, 121-127, 152-163) have no per-request timeout — only the 15-min
+ poll deadline and abort. Add a service-owned per-fetch deadline (fetch +
+ body) so a stuck TCP cannot hold the flow until TTL. This is the Kuhn
+ blocker "poll timer does not bound fetch/body deadlines".
+ Audit-folded: one FRESH 30s timeout per fetch attempt inside the poll
+ loop (AbortSignal.any([ctrl.signal, AbortSignal.timeout(30_000)]), the
+ main-account.ts:239-241 pattern) — a single 30s signal across the whole
+ poll would kill the 15-minute grant. Abort-timeout maps to
+ device_authorization_failed. The shared helper also bounds hung POOL
+ device logins at 30s per fetch — an intended improvement, called out in
+ the PR.
+
+MODIFY `src/codex/main-account.ts`
+- New `beginNativeMainReauth`: captures the existing
+ `MainAuthJsonCredential` snapshot (103-136) into a private closure;
+ returned commit accepts complete native device tokens and, only after
+ human authorization, acquires `withNativeMainExclusiveClaim`
+ (src/codex/native-main-claim.ts:167), rechecks recovery/admission fence,
+ asserts original path/hash/inode before atomic rename, requires same
+ chatgpt account identity, writes access+refresh+id token + account_id
+ together, advances the mutation epoch, and reconciles runtime/quota
+ state. Old identity token is never retained beside new credentials. No
+ claim held during human polling.
+ Audit-folded: do NOT reuse persistRefreshedMainAuthJson (:190-195) — it
+ spreads expected.tokens and never writes id_token, so the old identity
+ token would survive beside the new grant. The commit uses a SIBLING
+ persist that sets access_token/refresh_token/id_token/account_id
+ together and overwrites any prior id_token (adding the key is safe:
+ readMainAuthJsonCredential :122 tolerates it and
+ native-profile-store.ts:476-481 expects it).
+
+NEW `src/codex/main-device-reauth.ts`
+- One process-owned active flow (opaque UUID, AbortController, bounded
+ terminal retention 5 min, grant deadline 15 min). Start/status/cancel
+ return only flowId, status, verificationUrl, deviceCode, and closed safe
+ failure codes per the 080 `MainDeviceReauthStatus` union. Injectable
+ login/commit dependencies for tests. Superseded/cancelled completions
+ never publish. No tokens/emails/raw account ids in DTO/log/error.
+- Dedicated abort controller and direct `loginChatGPTNativeDevice` call:
+ MUST NOT use `startLoginFlow("chatgpt")` (would overwrite the chatgpt
+ scratch slot and 409 against pool logins, src/oauth/index.ts:1899-1973).
+
+NEW `src/codex/main-device-reauth-api.ts`
+- `POST/GET/DELETE /api/codex-auth/main/reauth-device` with exact opaque
+ flow query, strict request keys, safe 400/404/409. Registered at the
+ management dispatch boundary (src/server/management-api.ts:385-407
+ region); existing management auth/origin/session controls stay
+ authoritative. No CLI direct account-file write.
+
+MODIFY `src/cli/account-main.ts`
+- `ocx account main reauth --device [--no-wait]`,
+ `reauth status --flow `, `reauth cancel --flow ` via the
+ management API; reject extra args before start. Register capability/help;
+ regenerate skill surface with `bun run skill:surface` if the capability
+ registry changes (tests/ci-workflows/skill-ocx.test.ts gates this).
+ Audit-folded: the native-main CLI branch point is account-main.ts (:181
+ region, beside add/switch) with USAGE in src/cli/account.ts:64; the
+ management route-registry (src/server/management/route-registry.ts
+ MANAGEMENT_ROUTES) must gain the POST/GET/DELETE rows or
+ management-route-registry.test.ts and the capabilities ratchet go red —
+ do NOT grow UNDECLARED_ROUTES_2026_08_28.
+
+## Hub fence resolution (open decision 1, resolved here for audit)
+
+On a headless hub the native owner lifecycle is a no-op
+(src/server/index.ts:1026-1046 binds the no-op when
+`shouldSyncCodexOnStart` is false; the gate is composed at
+src/codex/desired-state.ts:130 — :79-81 is `localClientSyncAllowed`).
+The reauth commit therefore MUST NOT depend on owner activation and MUST
+NOT widen `shouldSyncCodexOnStart` (that gate covers client-config sync,
+not credential rewrite).
+
+Audit-folded correction to 080: 080's `assertNativeMainOwner` at
+preparation/commit is RETRACTED for this layer. That assert throws without
+a held owner entry (src/codex/native-main-owner.ts:302-314), which would
+make hub reauth always fail. The exclusive claim is owner-independent
+(src/codex/native-main-claim.ts:167, FS/SQLite lock only). The fence is
+pinned to: `withNativeMainExclusiveClaim` + in-process admission fence +
+path/hash/inode assertion + recovery/admission snapshot recheck, exactly
+as on workstations. Only claim/admission failure maps to
+`native_main_unavailable`; no write occurs without the full fence — an
+unfenced write is a C4 violation, not a fallback.
+
+## Tests (red-first; domain tests/codex-integration, tests/oauth, tests/cli)
+
+NEW `tests/codex-integration/main-device-reauth.test.ts` — same-account
+success without codex/keyring; wrong identity refused; missing token
+fields; cancelled/superseded late result cannot publish; concurrent file
+replace/refresh/profile switch; atomic write failure; claim unavailable →
+native_main_unavailable with zero writes; no pool-row mutation; DTO/log
+secret scan.
+NEW `tests/codex-integration/main-device-reauth-api.test.ts` — route
+contract: strict keys, 400/404/409 shapes, unauthorized rejected,
+`__main__` still refused by `/api/codex-auth/login`.
+MODIFY `tests/oauth/chatgpt-device-auth.test.ts` — native result retains
+idToken in-process; per-fetch deadline fires on a hung stub fetch.
+Audit-folded: native-main CLI tests land in
+tests/cli/cli-native-profile.test.ts (native-main CLI); the pool
+cli-account.test.ts keeps only the __main__ login rejection cases.
+MODIFY `tests/cli/cli-native-profile.test.ts` — reauth --device surface,
+status, cancel, arg rejection.
+All NEW files: layout.json explicit + expected-fixture entries.
+
+## Docs / ownership
+
+structure/ ownership docs for src/codex, src/oauth, src/cli, src/server
+synced in this PR (structure:check must stay green). Headless recovery
+instructions updated (docs-site) in the same PR. Security draft stays in
+scratch; only the implementation + regression diff is published.
diff --git a/devlog/_plan/260912_unimplemented_trio_stack/030_l3_main_card_relogin_ui.md b/devlog/_plan/260912_unimplemented_trio_stack/030_l3_main_card_relogin_ui.md
new file mode 100644
index 0000000000..41c1196409
--- /dev/null
+++ b/devlog/_plan/260912_unimplemented_trio_stack/030_l3_main_card_relogin_ui.md
@@ -0,0 +1,104 @@
+# L3: main-card Re-login with device code (#3898 GUI)
+
+Class C3 (auth-adjacent GUI). Stack layer 3, base the L2 branch. Branch
+`codex/260912-native-main-reauth-ui`. Adopts
+devlog/_plan/260912_accounts/090_reauth_ui.md, revalidated against current
+`dev` by the GUI code map. Depends on L2's
+`/api/codex-auth/main/reauth-device` contract.
+
+## Problem
+
+The main card is a locked App-login identity: expired state shows only
+`codexAuth.mainTokenExpired` ("sign in again via Codex App login",
+gui/src/components/codex-account-pool-main-card.tsx:183-185) and no
+Re-login control (props at 21-56 have no `onReauth`). Pool rows have the
+full device-code modal; the main card has nothing.
+
+## Constraints (090 + code map)
+
+- MUST NOT reuse `AddCodexAccountModal` / `openReauth("__main__")` /
+ `reauthAccountId=__main__`: the pool login route rejects `__main__`
+ (src/codex/account-id.ts:15-20; src/codex/auth-api.ts:221-224,2736-2748)
+ and a successful pool login writes `isMain: false` rows
+ (src/codex/auth-api.ts:2934-2939) — wrong credential store.
+- DTO field chain: backend DTO → hook-validated state → main card only;
+ no device code in browser storage; verification URL accepted only from
+ the backend contract, never from arbitrary payloads.
+- New copy lands in ALL locale files (en, de, fr, ja, ko, ru, tr, zh,
+ zh-TW) per gui/AGENTS.md "Text and i18n".
+- `tests/gui/provider-workspace-auth.test.ts:248` currently requires
+ `codexAuth.mainTokenExpired` on the main card; updating that copy is
+ part of this layer.
+
+## Changes
+
+NEW `gui/src/components/use-main-device-reauth.ts`
+- Dedicated hook mirroring the pool OAuth hook's start/poll/cancel shape
+ (gui/src/components/use-add-codex-account-oauth.ts:27) against
+ `/api/codex-auth/main/reauth-device`: `start()` POST, `poll(flowId)`
+ with visibility polling (2s tick, 10s per-tick timeout, stop on terminal
+ status), `cancel(flowId)` DELETE, unmount/abort cleanup.
+- Normalizes closed status/error payloads; ignores late responses from a
+ replaced flow (flowId ownership); never accepts token/account-id fields;
+ renders only verificationUrl + deviceCode + status.
+
+MODIFY `gui/src/components/codex-account-pool-main-card.tsx`
+- New optional `onReauthDevice` prop. When `showReauth` (83) is true,
+ render a "Re-login with device code" CTA beside the existing copy; after
+ start, show verification URL + human code + pending status + cancel;
+ success triggers the existing parent refresh.
+- Layout stays consistent with the current card; pool Add/Re-login and the
+ native profile picker are untouched.
+
+MODIFY `gui/src/components/CodexAccountPool.tsx`
+- Own main-reauth modal state separate from `showAdd`/`reauthId`
+ (75,94; openReauth at 189-192); wire `onReauthDevice` at the main-card render (515-533);
+ pause pool refresh while the main flow is active, same as the existing
+ modal pause (174-178).
+
+MODIFY `gui/src/i18n/{en,de,fr,ja,ko,ru,tr,zh,zh-TW}.ts`
+- New `codexAuth.*` keys: CTA label, pending status, cancel, terminal
+ failure copy (actionable, safe; no auto-retry wording). Revise
+ `mainTokenExpired` so it no longer claims App login is the only path.
+
+## Audit folds (wp4 A)
+
+- Start POSTs an EMPTY body (the route rejects any body with 400); poll
+ immediately until verificationUrl/deviceCode arrive (they are empty in the
+ start response), and keep the last url/code through the committing state.
+- Map the full MainDeviceReauthStatus union + HTTP error shapes: committing
+ (no url/code), failed.code (identity_mismatch, credential_changed,
+ native_main_unavailable, device_authorization_failed,
+ publication_failed, reconciliation_failed), 409 flow_in_progress, 503
+ native_main_unavailable; when credentialUpdated is true the copy never
+ claims the file was unchanged; the verification URL is allowlisted to
+ https://auth.openai.com/codex/device.
+- structure claim lands in structure/gui-and-management-api.md (the
+ Codex-accounts row :312), not overview.md.
+
+## Tests (red-first)
+
+NEW `gui/tests/main-device-reauth.test.tsx` — happy-dom mount per
+gui/tests convention: CTA starts the dedicated route (never
+`/api/codex-auth/login`), code/URL display, cancel ownership, stale-poll
+ignore, success refresh, keyboard and error states.
+MODIFY `tests/gui/provider-workspace-auth.test.ts` — main-card contract
+updated for the new CTA + copy.
+MODIFY `tests/gui/codex-auth-modal-status.test.ts` if locale-key
+assertions enumerate codexAuth keys.
+The happy-dom file lives under `gui/tests/`, outside the `tests/` layout
+map — layout.json explicit + expected-fixture entries are needed only for
+any NEW `tests/gui/*` source-contract file, not for `gui/tests/*`.
+
+## Docs / ownership
+
+L3 touches owned `gui/`: sync structure/overview.md and
+structure/gui-and-management-api.md in this PR (structure:check must stay
+green).
+
+## Verification
+
+`cd gui && bun test tests/main-device-reauth.test.tsx` plus the touched
+suites; `bun run lint:i18n` for copy. Local GUI build NOT RUN; PR
+screenshot evidence comes from hosted CI built artifacts, or an explicit
+recorded exemption (repo gate: gui-mentioning PRs need a screenshot).
diff --git a/devlog/_plan/260912_unimplemented_trio_stack/040_l4_native_paginated_writer.md b/devlog/_plan/260912_unimplemented_trio_stack/040_l4_native_paginated_writer.md
new file mode 100644
index 0000000000..0b6e879460
--- /dev/null
+++ b/devlog/_plan/260912_unimplemented_trio_stack/040_l4_native_paginated_writer.md
@@ -0,0 +1,91 @@
+# L4: paginated history — offline ordinal recovery, live refusal preserved (#4311)
+
+Class C4 (user data). Stack top, base the L3 branch. Branch
+`codex/260912-native-paginated-writer`.
+
+## Problem and scope decision (open decision 2, resolved here for audit)
+
+#4311's live defect (ordinal-0 `session_meta` clone) is already guarded:
+`updateSessionMeta` throws for paginated records before writing
+(throw at src/codex/history-provider.ts:1172), and preflight refuses
+`history_paginated_requires_native_writer`
+(structured field src/codex/inject.ts:899; preflight closure
+src/codex/inject.ts:1182-1194). The residual acceptance is
+(a) native paginated writer support and (b) corrupted-rollout recovery.
+
+(a) needs a Codex-owned writer API/IPC. None exists in this tree: Codex
+owns ordinals and the live projection cursor
+(structure/codex-home.md:232-234), `appendRolloutLine` deliberately does
+not allocate ordinals (src/codex/history-provider.ts:77,248), and H
+serializes only OpenCodex writes (src/codex/history-lock.ts;
+src/codex/internal/history-writer.ts:86,107). Inventing N+1 is explicitly
+forbidden by the issue (concurrent native writer / stale cursor). This
+layer therefore ships (b) the offline recovery tool, keeps (a) refused
+with the same structured reason, and says so in the PR. A follow-up
+native-writer integration needs a Codex-side write API first — reported,
+not faked.
+
+## Changes
+
+NEW `src/codex/history-ordinal-recovery.ts`
+- Offline repairer for the #4311 corruption shape: an unprojected suffix
+ whose ordinals regress (projector error `expected N, got 0`).
+- Preconditions, all enforced before any write:
+ - Codex fully closed (no running Codex process holds the home; detect
+ via the same process/home inspection the service uses, fail safe when
+ undecidable).
+ - Target resolution follows `resolveCodexStateDbPath` and
+ `threads.rollout_path` (src/codex/paths.ts:107-108; the column is
+ read through history-provider, not paths.ts) — never assume
+ `~/.codex/sessions`.
+ - Suffix shape verified: ordinals strictly increase before the boundary,
+ regress at the boundary, and the suffix parses cleanly. Anything else
+ refuses.
+ - Byte-identical backup written before mutation (manifest beside the
+ existing backup convention, src/codex/history-provider.ts:30).
+- Rewrite: only ordinal digits in the unprojected suffix, renumbered to
+ continue the pre-boundary sequence; message text, ids, timestamps, and
+ all earlier bytes preserved. Exact readback verification before
+ reporting success. Dry-run (verify-only) is the default; `--write`
+ applies.
+
+MODIFY `src/cli/` (doctor/dispatch surface per existing conventions)
+- `ocx doctor history repair-paginated-ordinals [--thread ]`
+ [--write]: runs the recovery, prints boundary, counts, backup path, and
+ readback result. Register capability/help; regenerate skill surface if
+ the registry changes.
+
+MODIFY `structure/codex-home.md`
+- Record the recovery tool's ownership of offline ordinal repair and
+ restate that live paginated writes stay refused (structure:check gate).
+
+Explicitly unchanged (regression-tested, not edited):
+`preflightCodexHistoryInjection` (history-provider.ts:307),
+`appendRolloutLine` (77), `updateSessionMeta` paginated guard (1172),
+inject pre/postflight (inject.ts:1182,1295,1332), catalog-only sync
+(src/codex/sync.ts:216).
+
+## Tests (red-first; tests/codex-integration)
+
+NEW `tests/codex-integration/history-ordinal-recovery.test.ts`
+- Synthetic fixture: session_meta ordinal 0 followed by event ordinal 1
+ (the issue's minimal shape) behind a healthy increasing prefix.
+- Dry-run reports and writes nothing (byte-identical file).
+- Applied repair renumbers only the suffix; every non-ordinal byte
+ identical; readback passes; backup exists and matches the original.
+- Refusals: Codex process detected / undecidable; suffix shape mismatch
+ (no regression, gap, unparsable line); missing backup space; absolute
+ rollout_path outside CODEX_HOME via sqlite_home.
+- Preservation invariants red-first: run the preservation assertions
+ against the unimplemented command first (red), then implement (green).
+MODIFY `tests/codex-integration/codex-history-provider.test.ts`
+- Assert preflight refusal reason unchanged for paginated rows (the
+ recovery tool must not become a live writer).
+NEW files: layout.json explicit + expected-fixture entries.
+
+## Out of scope
+
+Live paginated writes, ordinal allocation, native-writer IPC, any change
+to the authless/compaction relabel fork (inject.ts:1098), provider-table
+lifetime policy (separate #4311 sub-thread, tracked by containment unit),
+in-app repair while Codex runs.
diff --git a/docs-site/astro.config.mjs b/docs-site/astro.config.mjs
index b25586f7a6..b76a01cdd5 100644
--- a/docs-site/astro.config.mjs
+++ b/docs-site/astro.config.mjs
@@ -86,6 +86,7 @@ export default defineConfig({
translations: { fr: "Guides", ko: "가이드", "zh-CN": "指南", "zh-TW": "指南", ru: "Руководства", ja: "ガイド", tr: "Kılavuzlar" },
items: [
{ label: "Remote Hub Deployment", translations: { fr: "Déploiement Remote Hub", ko: "Remote Hub 배포", "zh-CN": "Remote Hub 部署", "zh-TW": "Remote Hub 部署", ru: "Развёртывание Remote Hub", ja: "Remote Hub のデプロイ", tr: "Remote Hub Dağıtımı" }, slug: "guides/remote-hub" },
+ { label: "Remote Workspace", translations: { fr: "Espace de travail distant", ko: "원격 워크스페이스", "zh-CN": "远程工作区", "zh-TW": "遠端工作區", ru: "Удалённая рабочая область", ja: "リモートワークスペース", tr: "Uzak Çalışma Alanı" }, slug: "guides/remote-workspace" },
{ label: "Providers", translations: { fr: "Fournisseurs", ko: "프로바이더", "zh-CN": "提供商", "zh-TW": "供應商", ru: "Провайдеры", ja: "プロバイダー", tr: "Sağlayıcılar" }, slug: "guides/providers" },
{ label: "Factory Droid Bridge", translations: { fr: "Pont Factory Droid", ko: "Factory Droid 브리지" }, slug: "guides/factory-droid" },
{ label: "Cursor Private Inference", translations: { ko: "Cursor Private Inference" }, slug: "guides/cursor-private-inference" },
diff --git a/docs-site/src/content/docs/fr/guides/codex-integration.md b/docs-site/src/content/docs/fr/guides/codex-integration.md
index 57b5b63fde..4f07ffaced 100644
--- a/docs-site/src/content/docs/fr/guides/codex-integration.md
+++ b/docs-site/src/content/docs/fr/guides/codex-integration.md
@@ -399,8 +399,7 @@ ocx config set codexPool '{"excludedPlans":["free"]}'
C'est une politique de sélection, pas un blocage. Un compte écarté conserve ses identifiants, son historique de quota et son affinité de thread, reste visible dans la liste des comptes et demeure joignable par sélection explicite comme `work/gpt-5.5`. Seule la rotation automatique cesse de le choisir, y compris lorsqu'il est déjà le compte actif ou déjà lié à un thread — l'état exact que laisse un abonnement expiré.
-Deux limites volontaires. Le compte Codex principal n'est jamais écarté par forfait, car le routage en mode sélection seule ne lit pas son forfait dans les identifiants natifs protégés ; une règle le couvrant se contredirait. Et lorsqu'il ne reste aucun compte non écarté, le compte écarté répond quand même au lieu d'échouer : mettre tous les comptes en pause reste le moyen d'arrêter complètement le service. Il n'existe pas de `minimumPlan`, car classer les forfaits ChatGPT entre eux exige un ordre total qui n'existe pas ici.
-
+Le compte Codex principal reste exempt de l’exclusion par forfait : le routage en mode sélection seule ne lit pas ses identifiants natifs protégés. Si tous les comptes éligibles du pool sont exclus, la sélection automatique ne renvoie aucun compte. Les routes désignant explicitement un compte restent disponibles, avec les contrôles de pause, d’authentification et de droits du modèle. La carte et le CLI affichent le forfait exclu séparément de l’état des identifiants. Il n’existe pas de réglage `minimumPlan`, faute d’ordre total des forfaits.
## Restauration de Codex natif
`ocx stop` arrête le proxy et le service d'arrière-plan installé, puis tente de restaurer Codex natif. OpenCodex retire les éléments de routage dont il peut vérifier la propriété et signale une restauration incomplète si les fichiers de configuration ne peuvent pas être récupérés en toute sécurité.
diff --git a/docs-site/src/content/docs/fr/guides/web-dashboard.md b/docs-site/src/content/docs/fr/guides/web-dashboard.md
index 2e333e39d8..2d4d753bb5 100644
--- a/docs-site/src/content/docs/fr/guides/web-dashboard.md
+++ b/docs-site/src/content/docs/fr/guides/web-dashboard.md
@@ -57,6 +57,8 @@ gestionnaire de mots de passe.
| **Stockage** | Consultez en lecture seule la répartition du disque de CODEX_HOME — sessions, archives, bases de données et pièces jointes. Pour le nettoyage facultatif des archives, prévisualisez les N % les plus anciennes, puis placez-les en quarantaine dans `CODEX_HOME/.trash` (par défaut) ou supprimez-les définitivement après avoir coché une case explicite. **La stratégie de nettoyage automatique** est facultative et **désactivée par défaut** (`storageCleanupPolicy.enabled`) ; configurez son seuil, sa cible, sa planification et son mode sur la page **Stockage**, ou lancez **Exécuter maintenant**. Les entrées mises en quarantaine peuvent être restaurées depuis cette page (JSONL et fils). Les sessions actives restent en lecture seule. Le nettoyage et la restauration sont refusés tant que Codex verrouille le fichier `state_*.sqlite` le plus récent ou actif. |
| **Arrêter** | Arrêtez proprement le proxy et le service d'arrière-plan installé, restaurez Codex natif et quittez (`POST /api/stop`). Sur Windows avec le backend Planificateur de tâches, le tableau de bord refuse et vous demande d'exécuter `ocx stop` : le wrapper peut relancer le proxy après la fin de la tâche, et seul un stop exécuté hors du proxy peut vérifier cette fenêtre de redémarrage avant de restaurer votre configuration client. Rien n'est modifié en cas de refus. |
+Les vues Utilisation, Tableau de bord, Fournisseurs, Catalogue des fournisseurs et Clés API signalent les enregistrements exclus, même sans résultat lisible. Les décomptes, les dates et les classements reposent uniquement sur les lignes lisibles. L’enregistrement de l’ordre des modèles par utilisation est refusé si l’historique est incomplet : choisissez un autre ordre ou réparez l’historique avant de réessayer.
+
### Filtrer les requêtes
Les filtres combinent interface, requêtes interceptées, fournisseur, modèle exact, statut, période, vitesse et identifiant de conversation dans le journal chargé. Les choix incluent les tentatives de repli ; les modèles ignorent la casse et les espaces externes, sans correspondance partielle. Un choix disparu revient à Tous.
diff --git a/docs-site/src/content/docs/fr/reference/cli/agents.md b/docs-site/src/content/docs/fr/reference/cli/agents.md
index 0d781042d9..69d4ac39d0 100644
--- a/docs-site/src/content/docs/fr/reference/cli/agents.md
+++ b/docs-site/src/content/docs/fr/reference/cli/agents.md
@@ -98,6 +98,8 @@ Inspectez les requêtes de proxy, l’utilisation, le stockage, la mémoire et l
ocx observe usage --range 30d --json
```
+Si certains enregistrements ne peuvent pas être inclus, la sortie lisible affiche un avertissement, même sans ligne lisible. Les totaux affichés ne reflètent que les enregistrements lisibles. Si un filtre ne trouve aucune correspondance lisible, la sortie affiche l'avertissement et des indications au lieu des lignes de totaux ; les enregistrements ignorés peuvent contenir des correspondances. `--json` préserve le diagnostic `usageIncomplete` et sa raison.
+
### `ocx debug `
Lisez ou modifiez les remplacements de débogage d'exécution via la gestion du proxy en cours d'exécution API.
diff --git a/docs-site/src/content/docs/fr/reference/cli/providers-accounts.md b/docs-site/src/content/docs/fr/reference/cli/providers-accounts.md
index b28642e0ca..b531e1b307 100644
--- a/docs-site/src/content/docs/fr/reference/cli/providers-accounts.md
+++ b/docs-site/src/content/docs/fr/reference/cli/providers-accounts.md
@@ -315,6 +315,9 @@ ocx account main doctor [--json]
ocx account main list [--json]
ocx account main register [--json]
ocx account main add
+ocx account main reauth --device [--no-wait] [--json]
+ocx account main reauth status --flow [--json]
+ocx account main reauth cancel --flow [--json]
ocx account main switch --yes [--json]
ocx account main recover [--rollback --yes] [--json]
```
diff --git a/docs-site/src/content/docs/fr/reference/configuration/providers.md b/docs-site/src/content/docs/fr/reference/configuration/providers.md
index 576b13c1bb..e642700298 100644
--- a/docs-site/src/content/docs/fr/reference/configuration/providers.md
+++ b/docs-site/src/content/docs/fr/reference/configuration/providers.md
@@ -39,7 +39,7 @@ Après une inscription ou une connexion OAuth dans l’interface, une boîte de
| `codexAccountPriorities?` | `Record` | — | Ordre de sélection par compte pour le pool Codex : identifiant de compte → entier de `-100` à `100`, **les valeurs élevées sont prioritaires**, une valeur absente équivaut à `0`. Cette limite porte sur le classement, et non sur l'admissibilité : la sélection retient, parmi les comptes déjà admissibles, le niveau prioritaire le plus élevé qui dispose encore d'une marge de quota, puis `accountPoolStrategy` choisit un compte dans ce niveau. Un niveau est ignoré uniquement lorsque chacun de ses membres dépasse `autoSwitchThreshold`, est en temporisation, est temporairement évité, est suspendu ou doit être réauthentifié ; un quota inconnu ne suffit jamais à considérer un niveau comme épuisé. L'ordre ne rend jamais admissible un compte qui ne l'est pas et ne réaffecte jamais une tâche déjà liée à un compte. Le compte principal `__main__` participe selon les mêmes règles ; la connexion Codex Desktop peut ainsi être configurée pour être utilisée en dernier. Sans entrée, le pool se comporte exactement comme auparavant. Un mappage mal formé est ignoré avec un avertissement dans la console : l'ordre est désactivé et la configuration n'est pas réparée. Ce champ est géré par `ocx account priority` et la page Codex Auth. |
| `activeCodexAccountPinned?` | `string` | — | Identifiant du compte du dernier opérateur sélectionné manuellement. Lorsqu'il est défini, un niveau `codexAccountPriorities` supérieur ne peut pas le préempter jusqu'à ce que la broche soit libérée par drainage, exclusion, suppression ou un failover/promotion explicite. Un mouvement circulaire ordinaire à l’intérieur du niveau plafonné ne le libère pas. L'écriture d'une entrée `codexAccountPriorities` libère également le pin, donc un pin créé avant qu'un ordre n'existe ne peut pas surpasser un ensemble par la suite. `GET /api/codex-auth/active` indique à la fois si le compte effectif est épinglé (`pinned`) et le compte portant le plafond (`pinnedAccountId`). |
| `autoSwitchThreshold?` | `number` | `80` | Seuil d'utilisation pour la commutation proactive. `quota` peut réévaluer les requêtes non liées lors de leur prochaine requête et, par défaut, réévalue aussi les tâches liées une fois ce seuil franchi. Avec `pool.cacheAffinity` activé, une tâche liée conserve son compte au-delà du seuil jusqu'à ce que ce compte soit épuisé ou ne puisse plus servir. `fill-first` ne l'utilise que comme seuil d'évacuation pour l'affectation des requêtes non liées ; la sélection `round-robin` normale ne l'utilise pas. Le score retient la plus élevée des fenêtres de quota connues sur 5 heures, une semaine ou 30 jours. `0` désactive uniquement la commutation proactive fondée sur l'utilisation, pas l'affectation des requêtes non liées ni la récupération après incident. |
-| `accountPoolStrategy?` | `"quota" \| "round-robin" \| "fill-first"` | `"quota"` | Stratégie d'affectation des requêtes Codex nouvelles ou non liées. Une requête est non liée lorsqu'elle ne possède aucune affinité active, définie par l'identifiant de la tâche parente et la portée du quota ; une tâche existante visible peut perdre son lien après le redémarrage du proxy ou la réinitialisation de l'affinité. `quota` sélectionne le compte admissible le moins utilisé lorsqu'aucun compte actif n'existe, conserve un compte actif admissible sous `autoSwitchThreshold` et, une fois le seuil franchi, peut déplacer une requête non liée. Sauf si `pool.cacheAffinity` est activé, il peut aussi relier de manière proactive une tâche liée à un compte admissible moins utilisé. Avec ce drapeau, la tâche liée reste jusqu'à ce que son compte soit épuisé (utilisation connue à 100 %) ou ne puisse plus servir. `round-robin` répartit équitablement les requêtes non liées ; `fill-first` continue de les attribuer au compte actif jusqu'à sa temporisation, son indisponibilité ou le seuil d'évacuation configuré. |
+| `accountPoolStrategy?` | `"quota" \| "round-robin" \| "fill-first" \| "reset-first"` | `"quota"` | Stratégie d'affectation des requêtes Codex nouvelles ou non liées. Une requête est non liée lorsqu'elle ne possède aucune affinité active, définie par l'identifiant de la tâche parente et la portée du quota ; une tâche existante visible peut perdre son lien après le redémarrage du proxy ou la réinitialisation de l'affinité. `quota` sélectionne le compte admissible le moins utilisé lorsqu'aucun compte actif n'existe, conserve un compte actif admissible sous `autoSwitchThreshold` et, une fois le seuil franchi, peut déplacer une requête non liée. Sauf si `pool.cacheAffinity` est activé, il peut aussi relier de manière proactive une tâche liée à un compte admissible moins utilisé. Avec ce drapeau, la tâche liée reste jusqu'à ce que son compte soit épuisé (utilisation connue à 100 %) ou ne puisse plus servir. `round-robin` répartit équitablement les requêtes non liées ; `fill-first` continue de les attribuer au compte actif jusqu'à sa temporisation, son indisponibilité ou le seuil d'évacuation configuré. `reset-first`: Parmi les comptes sous le seuil, privilégier le prochain reset de 5 heures ou hebdomadaire. Les tâches liées suivent la politique d’affinité configurée. Les quotas de modèles indépendants suivent l’ordre de consommation. Les resets mensuels ne déterminent pas cet ordre. |
| `pool.cacheAffinity?` | `boolean` | `false` | Ordre d'affinité de cache optionnel pour les threads Codex liés, indépendant de `pool.kernel`. Désactivé par défaut ; une valeur mal formée est lue comme désactivée. Une fois activé, une liaison active prime sur la marge de quota : `quota` ne déplace pas le thread simplement parce que l'utilisation a franchi `autoSwitchThreshold`. Le thread quitte encore le compte s'il ne peut plus servir — suspendu, inutilisable, ou réellement épuisé (utilisation connue à 100 %) — l'affinité est donc un réordonnancement, pas un verrouillage. |
| `accountPoolStickyLimit?` | `number` | `1` | Nombre d'affectations de tâches nouvelles ou non liées conservées sur une même sélection tournante avant de passer à la suivante ; le compteur avance lorsqu'une tâche est liée, et non après une réponse réussie en amont. Plage : 1–100. |
| `upstreamFailoverThreshold?` | `number` | `3` | Nombre d'échecs transitoires consécutifs avant le basculement des futures nouvelles sessions. Réglez `0` pour désactiver ce mécanisme. Pour les requêtes Responses ordinaires et les envois compacts natifs, les échecs avérés d'accessibilité DNS/TCP avant connexion sont suivis au niveau du couple fournisseur-hôte : ils n'affectent jamais l'état ni la temporisation du compte, l'affinité de tâche ou de session, la sélection du compte actif ou le routage du pool, et ne sont jamais comptabilisés dans ce seuil. |
diff --git a/docs-site/src/content/docs/fr/reference/management-api.md b/docs-site/src/content/docs/fr/reference/management-api.md
index d50b11a50c..e7da3b7bfb 100644
--- a/docs-site/src/content/docs/fr/reference/management-api.md
+++ b/docs-site/src/content/docs/fr/reference/management-api.md
@@ -155,6 +155,8 @@ Voir [Combos](/fr/guides/combos/) pour les stratégies cibles, les temps de rech
| `POST /api/storage/cleanup-policy/run` | Démarrer une exécution manuelle de la politique de nettoyage | 409 `already_running` ; 500 `cleanup_failed` |
| `GET /api/storage/cleanup-policy/test-stream` | Point d'ancrage du flux de stratégie réservé aux tests | 404 `not_found` en cas d'indisponibilité |
+Si une ligne dépasse la limite de taille du parseur, `GET /api/usage` et `GET /api/keys` conservent les agrégats lisibles et ajoutent `usageIncomplete: true` avec `usageIncompleteReason: "oversized_rows"` au niveau de la réponse. Ce diagnostic reste présent dans le cache et après les ajouts incrémentaux, même sans résultat ni correspondance de filtre ; une reconstruction le recalcule. Les identifiants de fournisseur, de modèle et de clé API ne sont pas raccourcis. L’absence du champ ne prouve pas la validité de toutes les lignes. Ce signal est distinct de `historyTruncated`, `entriesTruncated` et de la couverture de mesure des tokens.
+
Pour `GET /api/usage?range=30d&surface=codex`, `accounts` contient une ligne par libellé de pool Codex
observé. Chaque ligne indique `accountLogLabel`, le total de jetons, `usageCoverageRatio` et une valeur facultative
`estimatedCostUsd` calculée selon les tarifs d'affichage actuellement configurés. Les substitutions `modelCosts` actives de l'utilisateur
diff --git a/docs-site/src/content/docs/guides/codex-integration.md b/docs-site/src/content/docs/guides/codex-integration.md
index ed495de3be..ef237a3be2 100644
--- a/docs-site/src/content/docs/guides/codex-integration.md
+++ b/docs-site/src/content/docs/guides/codex-integration.md
@@ -820,8 +820,7 @@ ocx config set codexPool '{"excludedPlans":["free"]}'
This is a selection policy, not a block. An excluded account keeps its credential, quota history, and thread affinity, stays visible on the account surface, and is still reachable by explicit account selection such as `work/gpt-5.5`. What changes is that automatic rotation stops choosing it, including when it is already the active account or already bound to a thread — which is the state a lapsed subscription leaves behind.
-Two deliberate limits. The main Codex account is never excluded by plan, because selection-only routing withholds its plan rather than reading the fenced native credential, so a rule covering it would disagree with itself. And when no unexcluded account remains, the excluded one still answers rather than failing closed; pausing every account is still the way to stop serving entirely. There is no `minimumPlan` counterpart, because ranking ChatGPT plans against each other needs a total ordering that does not exist here.
-
+The main Codex account remains exempt from plan exclusion; selection-only routing does not read its fenced native credential. If every eligible pool account is excluded, automatic selection returns no account. Explicit account-qualified routes remain available and still enforce pause, authentication and model entitlement. The account card and CLI show the excluded routing plan separately from credential health. There is no `minimumPlan` setting because the plan names do not define a total order.
## Restoring native Codex
`ocx stop` stops the proxy and any installed background service, then attempts to restore native Codex. OpenCodex removes verified routing artifacts and reports an incomplete restore when it cannot safely recover configuration files.
diff --git a/docs-site/src/content/docs/guides/providers.md b/docs-site/src/content/docs/guides/providers.md
index 0a333acd0b..892dc045d9 100644
--- a/docs-site/src/content/docs/guides/providers.md
+++ b/docs-site/src/content/docs/guides/providers.md
@@ -1080,6 +1080,11 @@ no quota bars rather than a fabricated one, and windows the plan does not report
absent instead of rendering as 0%.
A provider using a non-canonical `baseUrl` is never sent the key for this probe.
+### Diagnosing an Antigravity quota refresh
+
+The account quota view and `ocx account list google-antigravity --quota --refresh` distinguish access denial, rate limiting, blocked destinations or redirects, DNS/connection/timeouts, and unusable quota data. Last-known bars remain visible with their observation time when a refresh fails. Reauthentication retires diagnoses from the previous credential; a successful refresh clears the failure.
+
+An access-denied result does not by itself prove an expired login or an ineligible plan. A blocked destination is a network-policy decision, not proof of a Fake-IP defect. Canonical Google quota destinations retain TLS verification and redirect/private-address restrictions. Authenticated TUN behavior must be checked in the affected environment; injected transport fixtures alone do not establish that field result.
## Large inline images on Chat providers
diff --git a/docs-site/src/content/docs/guides/remote-hub.md b/docs-site/src/content/docs/guides/remote-hub.md
index 445d5355ef..1f2db4486c 100644
--- a/docs-site/src/content/docs/guides/remote-hub.md
+++ b/docs-site/src/content/docs/guides/remote-hub.md
@@ -13,7 +13,9 @@ the hub's own processes dial `127.0.0.1:` with no credential, thr
companion listener. Start from [the recipe below](#linux-systemd-or-macos-launchd), then hand a
second machine a ready-made command with [`ocx hub invite`](#inviting-another-machine).
-The management ingress never serves `/v1/*`, `/healthz`, `/readyz`, or WebSockets. Do not publish its
+The management ingress never serves `/v1/*`, `/healthz`, or `/readyz`. When explicitly enabled,
+Remote Workspace admits only its paired bearer-authenticated agent WebSocket and one-time pairing
+exchange; see [Remote Workspace](/guides/remote-workspace/). Do not publish its
port directly, do not add a cloud-firewall rule for it, and do not use Tailscale Funnel. Funnel is a
public-internet surface and is outside this deployment model.
@@ -722,6 +724,13 @@ For a service rollback, stop the branch service and repair the prior release aga
session, not a client data key.
- **Outstanding revocation after disconnect:** use the hub dashboard's **Integrations → API Keys**
page. It is the sole post-disconnect revocation path.
+### Usage from a connected client
+
+`ocx usage` reads the connected hub with this client's enrolled data key. Human output identifies the hub source and client-key scope; `--json` returns the same scoped data. Range, surface, provider/model filters and custom `--since`/`--until` bounds remain available. Account breakdowns and other clients' records are not shared. An old or unavailable hub produces an explicit error instead of substituting local usage; upgrade the hub if it does not support this read.
+
+The read-only data-plane endpoint is `GET /v1/usage`, using `x-opencodex-api-key` with a configured client key. Environment-wide and admin keys are refused. It accepts `range`, `surface`, `provider`, `model`, `since`, and `until`; unknown/repeated options and caller-selected key IDs are rejected. Oversized skipped rows retain the explicit incomplete-history warning.
+
+Client usage credentials are sent only over HTTPS or loopback HTTP. Both the request and response disable caching.
### Pairing this browser with a hub
diff --git a/docs-site/src/content/docs/guides/remote-workspace.md b/docs-site/src/content/docs/guides/remote-workspace.md
new file mode 100644
index 0000000000..4d31a6dee0
--- /dev/null
+++ b/docs-site/src/content/docs/guides/remote-workspace.md
@@ -0,0 +1,195 @@
+---
+title: Remote Workspace
+description: Keep Codex, Claude Code, Pi, and their logins on one OCX Hub while OCX-only computers provide the workspace and build environment.
+---
+
+Remote Workspace lets one OpenCodex Hub run your coding agents while another computer supplies the
+project files, commands, tests, and build compute. A phone or third computer can control the session
+through the Hub dashboard.
+
+```text
+Phone browser -> Computer 1 OCX Hub -> encrypted channel -> Computer 2 OCX Executor
+ Codex / Claude / Pi project and commands
+ logins and sessions no coding CLI login
+```
+
+The Executor needs OpenCodex only. It does not need Codex, Claude Code, Pi, a ChatGPT login, or a
+provider API key. It opens an outbound WebSocket to the Hub, so the Executor needs no public port or
+router port-forward.
+
+:::caution[Experimental foundation]
+Remote Workspace is opt-in and not a production rollout. Linux offers file tools and conditional
+bubblewrap command execution. Windows and macOS offer file tools only: their official native
+helpers reject probe and command requests. Windows commands remain unsupported until a verified
+lifecycle owner can retain cleanup authority through cancellation. Missing command support never
+falls back to executing on the Hub.
+:::
+
+## Set up the Hub
+
+Computer 1 owns every coding-agent login and model session. Install and log in to whichever agents
+you want to use there, then run OpenCodex as a Hub:
+
+```bash
+ocx config set runtimeRole hub
+OCX_REMOTE_WORKSPACE_ENABLED=1 ocx start
+ocx gui
+```
+
+Set `OCX_REMOTE_WORKSPACE_ENABLED=1` on the Hub process itself; setting it only for a dashboard
+command does not enable an already-running service. A Hub with no explicit opt-in returns disabled
+status without creating workspace keys or probing coding-agent runtimes.
+
+Use an authenticated HTTPS deployment when opening the dashboard from a phone or another computer.
+See [Remote Hub Deployment](/guides/remote-hub/) for the supported management-ingress and Tailscale
+pattern. Do not publish an unauthenticated local dashboard port.
+
+Codex Remote Workspace uses current App Server permission profiles. If the Hub's selected Codex
+configuration still sets legacy `sandbox_mode` or `sandbox_workspace_write`, the dashboard reports
+Codex as unavailable instead of starting with a weaker boundary. Migrate that Codex profile before
+using the feature; do not configure both the legacy sandbox and a permission profile.
+
+## Pair an Executor
+
+1. Open **Remote Workspace** in the Hub dashboard.
+2. Select **Create pairing code**.
+3. On Computer 2, change into the project directory you want to expose.
+4. Copy the generated **Linux / macOS terminal** or **Windows PowerShell** command for that computer.
+ It pairs the current directory and keeps
+ `ocx remote-workspace agent` connected in that terminal.
+
+The equivalent manual flow is:
+
+```bash
+cd /path/to/project
+printf '%s\n' 'ONE-TIME-CODE' | ocx remote-workspace pair 'https://your-hub.example' \
+ --pairing-code-stdin --root "$PWD"
+ocx remote-workspace agent
+```
+
+On Windows PowerShell, use the command shown in the dashboard. The equivalent manual form is:
+
+```powershell
+$pairingCode = 'ONE-TIME-CODE'
+$pairingCode | ocx remote-workspace pair 'https://your-hub.example' `
+ --pairing-code-stdin --root (Get-Location).Path
+if ($LASTEXITCODE -eq 0) { ocx remote-workspace agent }
+```
+
+The current OCX Bun executable is added as one read-only file to the Linux sandbox automatically. If
+the project needs a user-installed toolchain outside the system paths, pair it explicitly without
+exposing the rest of the home directory:
+
+```bash
+printf '%s\n' 'ONE-TIME-CODE' | ocx remote-workspace pair 'https://your-hub.example' \
+ --pairing-code-stdin --root "$PWD" \
+ --toolchain-root "$HOME/.nvm/versions/node/v24/bin"
+```
+
+The native helper source is packaged for review. Building it does not enable Windows or macOS
+commands in this carry. `--executor-helper` remains a reviewed-helper selector; binary existence
+or a configured path does not prove command support.
+
+The one-time code is read from standard input, not command-line arguments. Pairing creates a local
+device signing key and a device-scoped bearer. The Hub stores only its hash and never receives the
+real Executor path. Stop the foreground agent with Ctrl+C; running it again reconnects the same
+device.
+
+Check local enrollment without printing secrets:
+
+```bash
+ocx remote-workspace status
+```
+
+## Start a remote coding session
+
+In the dashboard choose:
+
+1. the online computer;
+2. one locally approved workspace folder;
+3. Codex, Claude Code, or Pi from the Hub; and
+4. an access mode.
+
+**Read only** is the default and exposes directory listing and file reading. The write option is
+shown as **Edit files and run commands** only when that Executor passed a command-sandbox probe;
+otherwise it is shown as **Edit files only**. The dashboard shows two separate locations so it is
+clear that the model and login remain on the Hub while workspace operations run on the selected
+computer.
+
+Send prompts from the Hub dashboard on Computer 1, Computer 3, or a phone. The session cannot switch
+to another computer or folder silently. If the Executor disconnects, the session enters
+**Executor offline** and never falls back to the Hub's filesystem.
+
+Prompt submission acknowledges acceptance immediately; the dashboard polls the session for progress and completion. If the acknowledgement is lost, the draft remains visible with an unknown-submission notice. Check session progress before sending it again; the dashboard never retries a prompt automatically.
+
+**Stop** remains available while a prompt is running. It interrupts the Hub coding-agent turn,
+cancels an active Executor command, and prevents a late response from reopening the stopped
+session.
+
+## Restart and reconnect behavior
+
+The Hub persists bounded session metadata and a small recent event snapshot. After a Hub restart,
+an unfinished session waits for its original Executor. Once that device reconnects, the next prompt
+resumes the original Codex thread, Claude Code session, or Pi session ID.
+
+Claude Code creates its durable history on the first completed prompt. If the Hub stops before a
+new Claude session has completed any prompt, there is no conversation to resume; start a new
+session instead.
+
+A changed capability manifest does not silently weaken an existing session. Start a new session if
+the Executor loses command containment or its available tools change. Revoking a computer closes its
+socket and stops sessions bound to it.
+
+## Security boundaries
+
+- Provider credentials and coding-agent history remain on the Hub.
+- Executor private keys, device bearer, and real root paths remain in its owner-only OCX state.
+- Pairing-code failures are limited per kernel-observed peer on every listener. Ten failed codes in
+ ten minutes return a generic `429` with `Retry-After`; the Hub retains only bounded, expiring
+ hashes of those source identities. Tailscale Serve users share the management listener's loopback
+ bucket because a direct local caller could forge its identity header.
+- Each work session uses an Ed25519-signed ephemeral P-256 ECDH handshake and ordered
+ AES-256-GCM messages.
+- A socket is not shown as online until both sides agree on its current capability manifest.
+- Reconnection may remove a capability when its local sandbox is unavailable, but never adds a
+ capability outside the grant recorded at pairing.
+- Every request is bound to one model thread, device, root, access mode, and capability set.
+- Paths are relative, canonicalized, bounded, and rejected on symlink, junction, or parent-directory
+ escape. Windows device names, alternate data streams, and trailing-dot/space aliases are denied.
+- Executor operations are serialized, opened file identities are rechecked, and write hashes are
+ checked again immediately before atomic replacement. Replacing an approved root requires pairing
+ it again, and toolchain roots are revalidated before each command.
+- File reads/writes reject hard-linked files. Before command execution, OCX scans at most 250,000
+ workspace entries and disables the command path if any non-directory entry has multiple links;
+ path sandboxes cannot prove whether the other name for that inode is outside the approved root.
+- Linux commands run through bubblewrap with one writable workspace, cleared environment, private
+ process namespaces, the current OCX Bun executable as one read-only file, bounded output
+ and timeout, and network disabled by default. Dedicated confinement tests require an explicitly
+ configured hosted environment; a green generic suite does not prove they ran.
+- macOS advertises file tools only. A process group cannot contain a descendant after it calls
+ `setsid()`, and importing a broad Apple Seatbelt system profile merely to start a command would
+ expose unrelated host-service authority. The native helper therefore rejects both its probe and
+ direct command requests until OCX has a narrow, revocable descendant-containment owner.
+- Windows and macOS native command requests fail closed. Their direct-helper refusal tests must be
+ distinguished from functioning command-confinement evidence; Windows command acceptance is open.
+- The pinned native helper must be outside every approved writable workspace. OCX checks this both
+ before advertising command support and immediately before each command, so workspace code cannot
+ replace the binary that enforces its next sandbox.
+- Stopping a session cancels an active Executor command and cleans up the Hub model process and
+ loopback tool bridge. Windows stops the owned npm-wrapper process tree rather than leaving its
+ Node child behind; Linux and macOS force-stop a CLI only if it ignores the graceful stop window.
+
+The Hub intentionally sees prompts and model output because it runs the coding agent. End-to-end
+encryption protects Executor RPC payloads. The paired Hub is trusted to select approved roots over
+authenticated WSS; it is not blind to its own model conversation.
+
+## Current scope
+
+Remote Workspace does not copy or synchronize credentials to other computers. It is separate from
+Remote Hub provider routing and from any future hosted compute or Super Sync product. A production
+release still requires signed Windows helper packaging, native CI proof on the exact binaries,
+independent maintainer review, and a real three-computer acceptance run.
+
+## Prompt acceptance API
+
+`POST /api/remote-workspace/sessions/:id/prompt` returns HTTP 202 with the accepted session snapshot. Its session ID and monotonic event sequence identify the acceptance snapshot; 202 does not mean that the model turn completed. Poll `GET /api/remote-workspace/sessions` for later events and terminal status. Reconnect and runtime resume remain busy while that turn is active. A lost acknowledgement leaves acceptance unknown, so clients must poll before choosing whether to submit again.
diff --git a/docs-site/src/content/docs/guides/web-dashboard.md b/docs-site/src/content/docs/guides/web-dashboard.md
index 67361c52fa..18954df175 100644
--- a/docs-site/src/content/docs/guides/web-dashboard.md
+++ b/docs-site/src/content/docs/guides/web-dashboard.md
@@ -96,6 +96,11 @@ badge or the version value to read the full value.
| **Storage** | Read-only CODEX_HOME disk breakdown (sessions, archives, DBs, attachments). Optional archived cleanup: preview the oldest N%, then quarantine to `CODEX_HOME/.trash` (default) or permanently delete behind an explicit checkbox. **Auto-cleanup policy** is opt-in and **default OFF** (`storageCleanupPolicy.enabled`); configure threshold/target/schedule/mode on the Storage page, or trigger **Run now**. Quarantined entries can be restored from the Storage page (JSONL + threads). Active sessions stay read-only. Cleanup and restore are refused while Codex holds the newest/active `state_*.sqlite` locked. |
| **Stop** | Gracefully stop the proxy and installed background service, restore native Codex, and exit (`POST /api/stop`). On Windows with the Task Scheduler backend the dashboard refuses and asks you to run `ocx stop` instead: that wrapper can respawn the proxy after the task ends, and only a stop running outside this process can verify the restart window before restoring your client config. Nothing is changed when it refuses. |
+If some usage records cannot be included, the Usage page, Dashboard, provider workspace, provider
+catalog, and API key views show a warning even when no readable records remain. Counts, dates, and
+usage rankings reflect readable records only. **Models → Most used snapshot → Apply order** refuses
+to save an incomplete snapshot; choose another order or repair the history before retrying.
+
### Account selection
Account selection is shared with request routing. Selecting an OAuth account takes effect on the
diff --git a/docs-site/src/content/docs/ja/guides/codex-integration.md b/docs-site/src/content/docs/ja/guides/codex-integration.md
index 5425e9e193..e621f1fd40 100644
--- a/docs-site/src/content/docs/ja/guides/codex-integration.md
+++ b/docs-site/src/content/docs/ja/guides/codex-integration.md
@@ -265,8 +265,7 @@ ocx config set codexPool '{"excludedPlans":["free"]}'
これはブロックではなく選択ポリシーです。除外されたアカウントも資格情報・使用量履歴・スレッドアフィニティを保持し、アカウント一覧に表示され、`work/gpt-5.5` のような明示的な指定では引き続き利用できます。変わるのは自動ローテーションが選ばなくなる点で、すでにアクティブなアカウントやスレッドに紐づいている場合も含みます。サブスクリプションが失効した直後は、まさにその状態です。
-意図的な制限が2つあります。メインの Codex アカウントはプランによって除外されません。選択のみのルーティングは保護されたネイティブ資格情報を読まずにプランを伏せるため、メインを対象にすると挙動が食い違うからです。また、除外されていないアカウントが1つも残らない場合は、失敗させずに除外済みのアカウントが応答します。完全に停止したい場合は従来どおり全アカウントを一時停止してください。`minimumPlan` に相当する設定はありません。ChatGPT のプランを順位付けするには、ここに存在しない全順序が必要になるためです。
-
+メイン Codex アカウントはプラン除外の対象外です。選択のみのルーティングは保護されたネイティブ資格情報を読みません。利用可能なプールアカウントがすべて除外されると、自動選択はアカウントを返しません。アカウントを明示したルートは引き続き利用でき、一時停止・認証・モデル権限の検査は維持されます。カードと CLI は資格情報の状態とは別に、除外されたルーティングプランを表示します。プランに全順序がないため `minimumPlan` 設定はありません。
## ネイティブ Codexの復元
`ocx stop` はプロキシとインストール済みのバックグラウンドサービスを停止し、ネイティブ Codex の復元を試みます。OpenCodex は所有を確認できるルーティング設定を削除し、設定ファイルを安全に復元できない場合は未完了として報告します。
diff --git a/docs-site/src/content/docs/ja/guides/web-dashboard.md b/docs-site/src/content/docs/ja/guides/web-dashboard.md
index 310baa4a5a..304c9646b8 100644
--- a/docs-site/src/content/docs/ja/guides/web-dashboard.md
+++ b/docs-site/src/content/docs/ja/guides/web-dashboard.md
@@ -48,6 +48,8 @@ bun run dev:gui
| **ストレージ** | CODEX_HOME のディスク内訳(セッション、アーカイブ、DB、添付)を読み取り専用で表示。任意のアーカイブクリーンアップ: 最古 N% をプレビューし、既定では `CODEX_HOME/.trash` へ隔離、または明示チェックで完全削除。**自動クリーンアップ方針**はオプトインで**既定 OFF**(`storageCleanupPolicy.enabled`)。Storage ページでしきい値/目標/スケジュール/モードを設定するか **今すぐ実行**。隔離エントリは Storage ページから復元可能(JSONL + スレッド)。アクティブセッションは読み取り専用。最新/アクティブな `state_*.sqlite` がロック中はクリーンアップと復元を拒否。 |
| **停止** | プロキシとインストールされたバックグラウンドサービスを正常終了しネイティブ Codex を復元した後終了します(`POST /api/stop`)。ただし Windows のタスク スケジューラ バックエンドではダッシュボードが拒否し、`ocx stop` の実行を促します。タスク終了後もラッパーがプロキシを再起動しうるため、クライアント設定を戻す前にその再起動区間を確認できるのはプロキシの外で動く stop だけです。拒否されたときは何も変更されません。 |
+使用量、ダッシュボード、プロバイダー画面、プロバイダーカタログ、API キー画面は、読み取れる記録がなくても除外された使用履歴の警告を表示します。回数、日付、使用順位は読み取れる記録のみを反映します。履歴が不完全な場合はモデルの使用回数順の保存を拒否します。別の順序を選ぶか、履歴を修復してから再試行してください。
+
### リクエストログの絞り込み
Logsではサーフェス、インターセプトされたリクエスト、プロバイダー、完全なモデル名、ステータス、時間、速度、会話IDを組み合わせて、読み込み済みログを絞り込みます。選択肢にはフォールバック試行も含まれます。モデル名は大文字小文字と前後の空白を無視しますが、部分一致ではありません。ログから消えた選択肢は全件に戻ります。
diff --git a/docs-site/src/content/docs/ja/reference/cli/agents.md b/docs-site/src/content/docs/ja/reference/cli/agents.md
index a74f20a817..e2431ff41f 100644
--- a/docs-site/src/content/docs/ja/reference/cli/agents.md
+++ b/docs-site/src/content/docs/ja/reference/cli/agents.md
@@ -69,6 +69,8 @@ ocx route combo set reliable --targets ark/model-a:2,openai/gpt-5.5
ocx observe usage --range 30d --json
```
+一部の使用履歴を集計できない場合、人向けの出力は読み取れる行がない場合も警告を表示します。表示される合計値は読み取れる記録のみを反映します。フィルターに一致する読み取れる記録がない場合は、合計欄の代わりに警告と案内を表示します。除外した記録には一致するものが含まれる可能性があります。`--json` は応答の `usageIncomplete` 診断と理由をそのまま保持します。
+
### `ocx debug `
実行中のプロキシの管理 API を通じて、ランタイム デバッグ オーバーライドを読み取りまたは変更します。
diff --git a/docs-site/src/content/docs/ja/reference/cli/providers-accounts.md b/docs-site/src/content/docs/ja/reference/cli/providers-accounts.md
index a13a5b310e..4130980693 100644
--- a/docs-site/src/content/docs/ja/reference/cli/providers-accounts.md
+++ b/docs-site/src/content/docs/ja/reference/cli/providers-accounts.md
@@ -243,6 +243,9 @@ ocx account main doctor [--json]
ocx account main list [--json]
ocx account main register [--json]
ocx account main add
+ocx account main reauth --device [--no-wait] [--json]
+ocx account main reauth status --flow [--json]
+ocx account main reauth cancel --flow [--json]
ocx account main switch --yes [--json]
ocx account main recover [--rollback --yes] [--json]
```
diff --git a/docs-site/src/content/docs/ja/reference/configuration/providers.md b/docs-site/src/content/docs/ja/reference/configuration/providers.md
index 5523430c80..31451fa18c 100644
--- a/docs-site/src/content/docs/ja/reference/configuration/providers.md
+++ b/docs-site/src/content/docs/ja/reference/configuration/providers.md
@@ -37,7 +37,7 @@ GUI で登録または OAuth ログインが完了すると、Models ページ
| `activeCodexAccountId?` | `string` | — |次のリクエスト用に手動で選択されたプール アカウント。選択するとスレッドのアフィニティがクリアされます。実行中のリクエストでは、取得された資格情報が保持されます。 |
| `codexAccountPriorities?` | `Record` | — | Codex pool のアカウント別選択順。アカウント ID → `-100` から `100` の整数で、**大きいほど先に使われ**、未設定は `0` です。これは eligibility ではなく順序の境界です。選択は適格なアカウントを、まだ quota に余裕がある最上位 tier に絞り込み、その tier の中を `accountPoolStrategy` が選びます。tier が飛ばされるのは、そのメンバー全員が `autoSwitchThreshold` 超過、cooldown 中、soft-avoid、一時停止、または再認証待ちのときだけで、usage 不明が tier を drain させることはありません。順序付けが不適格なアカウントを選択可能にすることはなく、すでにアカウントが結び付いた thread を再 bind することもありません。メインの `__main__` も同じ条件で参加するため、Codex Desktop ログインを最後に使わせられます。エントリが 1 つもなければ挙動は従来どおりです。map が不正な場合は警告を出して順序付けを無効にします(config の修復処理は走りません)。`ocx account priority` と Codex Auth ページで管理します。 |
| `autoSwitchThreshold?` | `number` | `80` | 使用量ベースのプロアクティブ切り替えしきい値。`quota` は未紐付けタスクの次のリクエストを再評価でき、既定では使用量がこのしきい値を超えると紐付け済みタスクも再評価します。`pool.cacheAffinity` がオンなら、紐付け済みタスクはアカウントが使い切られるか処理できなくなるまでしきい値超過後も同じアカウントを維持します。`fill-first` は未紐付け割り当ての使い切り基準としてのみ使用し、通常の `round-robin` 選択は使用しません。既知の 5 時間、週次、30 日 quota window の最大スコアを使います。`0` は使用量ベースの切り替えだけを無効にし、未紐付け割り当てや障害回復は無効にしません。 |
-| `accountPoolStrategy?` | `"quota" \| "round-robin" \| "fill-first"` | `"quota"` | 新規/未紐付け Codex リクエストの割り当て戦略。live な `(parent thread id, quota scope)` affinity がなければ未紐付けで、プロキシ再起動や affinity リセット後は既存の表示タスクも未紐付けになり得ます。`quota` はアクティブアカウントがなければ既知 usage 最小の適格アカウントを選び、適格なアクティブアカウントが `autoSwitchThreshold` 未満なら維持します。しきい値到達後は未紐付けリクエストを移せます。`pool.cacheAffinity` がオフなら紐付け済みタスクの次のリクエストも usage の低い適格アカウントへ移せます。オンなら紐付け済みタスクはアカウントが使い切られるか(既知 usage 100%)処理できなくなるまで維持されます。`round-robin` は未紐付けリクエストを均等分散し、`fill-first` は cooldown、使用不可、または drain threshold までアクティブアカウントへ割り当てます。 |
+| `accountPoolStrategy?` | `"quota" \| "round-robin" \| "fill-first" \| "reset-first"` | `"quota"` | 新規/未紐付け Codex リクエストの割り当て戦略。live な `(parent thread id, quota scope)` affinity がなければ未紐付けで、プロキシ再起動や affinity リセット後は既存の表示タスクも未紐付けになり得ます。`quota` はアクティブアカウントがなければ既知 usage 最小の適格アカウントを選び、適格なアクティブアカウントが `autoSwitchThreshold` 未満なら維持します。しきい値到達後は未紐付けリクエストを移せます。`pool.cacheAffinity` がオフなら紐付け済みタスクの次のリクエストも usage の低い適格アカウントへ移せます。オンなら紐付け済みタスクはアカウントが使い切られるか(既知 usage 100%)処理できなくなるまで維持されます。`round-robin` は未紐付けリクエストを均等分散し、`fill-first` は cooldown、使用不可、または drain threshold までアクティブアカウントへ割り当てます。 `reset-first`: 使用率のしきい値未満から、次の5時間枠または週次枠のリセットが最も近いアカウントを選びます。紐付け済みタスクは設定されたアフィニティ方針に従います。独立したモデル枠は使用率順です。 月次リセットはこの順序に使用しません。 |
| `pool.cacheAffinity?` | `boolean` | `false` | 紐付け済み Codex スレッド向けのオプトイン cache-affinity 順序。`pool.kernel` とは独立で、既定はオフです。不正な値はオフとして読みます。オンにすると live な紐付けが quota 余裕より優先されます。`quota` は使用量が `autoSwitchThreshold` を超えたという理由だけではスレッドを移しません。一時停止、使用不可、または実際に使い切られたアカウント(既知 usage 100%)では離れるので、affinity は固定ではなく並べ替えです。 |
| `accountPoolStickyLimit?` | `number` | `1` | 1 回の round-robin 選択で次へ進む前に保持する新規/未紐付けタスク割り当て数。カウンターは上流の成功後ではなくタスクの紐付け時に増えます。範囲 1–100。`accountPoolStrategy` が `round-robin` のときのみ。 |
| `upstreamFailoverThreshold?` | `number` | `3` |今後の新しいセッションがフェイルオーバーする前に一時的なエラーが連続して発生する。 `0` を無効に設定します。通常のResponses送信とネイティブcompact送信では、実証済みの接続前DNS/TCP到達不能障害はprovider-host単位で記録され、アカウントの健全性、アカウントのクールダウン、スレッド/セッションの親和性、アクティブアカウントの選択、Poolルーティングには影響せず、この閾値にもカウントされません。 |
diff --git a/docs-site/src/content/docs/ja/reference/management-api.md b/docs-site/src/content/docs/ja/reference/management-api.md
index 35ebec9131..4398ff8118 100644
--- a/docs-site/src/content/docs/ja/reference/management-api.md
+++ b/docs-site/src/content/docs/ja/reference/management-api.md
@@ -134,6 +134,8 @@ Authorization: Bearer
| `POST /api/storage/cleanup-policy/run` |手動クリーンアップ ポリシーの実行を開始します。 409 `already_running`; 500`cleanup_failed` |
| `GET /api/storage/cleanup-policy/test-stream` |テスト専用ポリシー ストリーム フック | 404 `not_found` 利用できない場合 |
+行が既存のパーサーのサイズ上限を超えた場合、`GET /api/usage` と `GET /api/keys` は読み取れる行の集計を維持し、応答全体に `usageIncomplete: true` と `usageIncompleteReason: "oversized_rows"` を追加します。この診断はキャッシュや増分追記後も維持され、結果が空または一致なしでも返されます。再構築時には再計算されます。プロバイダー、モデル、API キーの識別子は短縮しません。フラグがないことは全行が有効だった証明にはなりません。`historyTruncated`、`entriesTruncated`、トークン測定カバレッジとは別の情報です。
+
`models`、`providers`、および `days[].models` の各行にも `cacheHitRate` が含まれます。これは、プロバイダーのプロンプト キャッシュから供給された入力トークンの割合で、`[0, 1]` の範囲に制限されます。プロバイダーがキャッシュ テレメトリを報告しなかった場合、または行に入力トークンがない場合は、`0` ではなく `null` になります。「キャッシュ データなし」と「実際のヒット率 0%」は異なる事実であり、それらを同じように描画するチャートは誤解を招くためです。
:::caution
diff --git a/docs-site/src/content/docs/ko/guides/codex-integration.md b/docs-site/src/content/docs/ko/guides/codex-integration.md
index f0c9d87848..88675c21fc 100644
--- a/docs-site/src/content/docs/ko/guides/codex-integration.md
+++ b/docs-site/src/content/docs/ko/guides/codex-integration.md
@@ -361,8 +361,7 @@ ocx config set codexPool '{"excludedPlans":["free"]}'
차단이 아니라 선택 정책입니다. 제외된 계정도 자격 증명과 사용량 기록, 스레드 어피니티를 그대로 유지하고 계정 목록에도 계속 보이며 `work/gpt-5.5` 같은 명시적 지정으로는 여전히 쓸 수 있습니다. 달라지는 것은 자동 로테이션이 그 계정을 고르지 않는다는 점이고, 이미 활성 계정이거나 스레드에 묶여 있는 경우도 포함합니다. 구독이 만료된 계정이 바로 그 상태입니다.
-의도한 제한이 두 가지 있습니다. 메인 Codex 계정은 플랜으로 제외하지 않습니다. 선택 전용 라우팅은 보호된 네이티브 자격 증명을 읽지 않고 플랜을 감추기 때문에, 메인까지 적용하면 상황에 따라 판정이 어긋납니다. 그리고 제외되지 않은 계정이 하나도 남지 않으면 실패시키지 않고 제외된 계정이 그대로 응답합니다. 완전히 멈추려면 지금처럼 모든 계정을 일시 중지하면 됩니다. `minimumPlan`에 해당하는 설정은 없습니다. ChatGPT 플랜에 순위를 매기려면 여기 존재하지 않는 전순서가 필요합니다.
-
+메인 Codex 계정에는 플랜 제외 정책을 적용하지 않습니다. 선택 전용 라우팅은 보호된 네이티브 자격 증명을 읽지 않습니다. 풀의 모든 사용 가능한 계정이 제외되면 자동으로 계정을 선택하지 않습니다. 계정을 직접 지정한 경로는 계속 사용할 수 있으며 일시 중지·인증·모델 사용 권한 검사는 그대로 적용됩니다. 계정 카드와 CLI에는 자격 증명 상태와 별도로 제외된 플랜이 표시됩니다. 플랜 사이에 정해진 순위가 없으므로 `minimumPlan` 설정은 없습니다.
## 네이티브 Codex 복원
`ocx stop`은 proxy와 설치된 background service를 중지한 뒤 네이티브 Codex 복원을 시도합니다. OpenCodex 소유로 확인된 라우팅 항목을 제거하며, 설정 파일을 안전하게 복구할 수 없으면 미완료로 보고합니다.
diff --git a/docs-site/src/content/docs/ko/guides/providers.md b/docs-site/src/content/docs/ko/guides/providers.md
index 887fdc8b0f..2c285b3cf7 100644
--- a/docs-site/src/content/docs/ko/guides/providers.md
+++ b/docs-site/src/content/docs/ko/guides/providers.md
@@ -446,3 +446,10 @@ opencodex를 로컬 OpenAI 호환 서버로 향하게 하세요 — 보통은
**Custom**을 선택하거나 `ocx init`에서 `custom`을 선택한 뒤 베이스 URL을 입력하세요. 모든 프로바이더 필드
(`headers`, `noReasoningModels`, `noVisionModels`, `models`, …)는
[설정 레퍼런스](/ko/reference/configuration/)를 참고하세요.
+
+
+### Antigravity 쿼터 조회 실패 확인
+
+계정 쿼터 화면과 `ocx account list google-antigravity --quota --refresh`는 접근 거부, 요청 한도, 목적지·리디렉션 차단, DNS·연결·시간 초과, 읽을 수 없는 응답을 구분합니다. 조회가 실패해도 마지막 관측 막대와 시각은 유지합니다. 재로그인하면 이전 자격 증명의 진단을 버리고, 조회에 성공하면 오류 표시를 지웁니다.
+
+접근 거부만으로 로그인 만료나 플랜 사용 불가를 단정하지 않습니다. 목적지 차단도 Fake-IP 결함의 증거는 아닙니다. Google의 고정 쿼터 주소에는 TLS 인증서 확인과 리디렉션·사설 주소 제한이 유지됩니다. 인증된 TUN 환경의 동작은 해당 환경에서 별도로 확인해야 합니다.
diff --git a/docs-site/src/content/docs/ko/guides/remote-hub.md b/docs-site/src/content/docs/ko/guides/remote-hub.md
index a48b53df2c..72e96ae263 100644
--- a/docs-site/src/content/docs/ko/guides/remote-hub.md
+++ b/docs-site/src/content/docs/ko/guides/remote-hub.md
@@ -336,6 +336,9 @@ docker compose up -d
- `/v1/catalog`가 `403 origin_rejected`인데 `/readyz`가 `200`이면 데이터 리스너가 TLS 프런트엔드 뒤에서 루프백에 바인드되어 있습니다. [데이터 리스너에 TLS 붙이기](#데이터-리스너에-tls-붙이기)를 보세요.
- 브라우저 로그아웃/만료는 해당 원격 세션만 끊습니다. 데이터 키와는 별개입니다.
- 연결 해제 후 남은 키는 허브의 **Integrations → API Keys**에서만 폐기할 수 있습니다.
+### 연결된 클라이언트의 사용량
+
+`ocx usage`는 등록된 데이터 키로 허브에서 이 클라이언트의 사용량만 읽습니다. 출력에는 허브 출처와 키 범위가 표시됩니다. 기간·모델·공급자 필터와 `--since`/`--until`, `--json`을 그대로 사용할 수 있습니다. 계정별 내역과 다른 클라이언트 기록은 반환하지 않습니다. 허브가 응답하지 않거나 이 기능을 지원하지 않으면 오류를 알립니다. 로컬 기록으로 대신 표시하지 않습니다. 구형 허브라면 허브를 업데이트하세요.
### 이 브라우저를 허브에 인증하기
diff --git a/docs-site/src/content/docs/ko/guides/web-dashboard.md b/docs-site/src/content/docs/ko/guides/web-dashboard.md
index f07c387385..fc3fa67f80 100644
--- a/docs-site/src/content/docs/ko/guides/web-dashboard.md
+++ b/docs-site/src/content/docs/ko/guides/web-dashboard.md
@@ -48,6 +48,8 @@ bun run dev:gui
| **Storage** | CODEX_HOME 디스크 사용량(세션, 보관, DB, 첨부)을 읽기 전용으로 표시합니다. 선택적 보관 정리: 가장 오래된 N%를 미리본 뒤 기본으로 `CODEX_HOME/.trash`에 격리하거나, 명시 체크 후 영구 삭제합니다. **자동 정리 정책**은 opt-in이며 **기본 OFF**(`storageCleanupPolicy.enabled`)입니다. Storage 페이지에서 임계값/목표/일정/모드를 설정하거나 **지금 실행**하세요. Storage 페이지에서 격리 항목을 복원할 수 있습니다(JSONL + 스레드). 활성 세션은 읽기 전용입니다. Codex가 최신/활성 `state_*.sqlite`를 잠그면 정리와 복원을 거절합니다. |
| **Stop** | 프록시와 설치된 백그라운드 서비스를 정상 종료하고 네이티브 Codex를 복원한 뒤 끝냅니다(`POST /api/stop`). 단, Windows 작업 스케줄러로 관리되는 경우에는 대시보드가 거절하고 `ocx stop`을 안내합니다. 작업이 끝나도 래퍼가 프록시를 다시 띄울 수 있어서, 클라이언트 설정을 되돌리기 전에 그 재시작 구간을 확인할 수 있는 건 프록시 바깥에서 도는 stop뿐입니다. 거절될 때는 아무것도 바뀌지 않습니다. |
+Usage, Dashboard, 공급자 작업 화면·카탈로그, API 키 화면은 읽을 수 있는 기록이 없어도 일부 기록 제외 경고를 표시합니다. 횟수·날짜·사용 순위는 읽을 수 있는 기록만 반영합니다. 이력이 불완전하면 모델의 ‘많이 사용한 순서’ 저장을 거절합니다. 다른 순서를 선택하거나 이력을 복구한 뒤 다시 시도하세요.
+
### 요청 로그 필터
Logs에서는 클라이언트 종류, 가로챈 요청, 공급자, 정확한 모델명, 상태, 시간,
diff --git a/docs-site/src/content/docs/ko/reference/cli/agents.md b/docs-site/src/content/docs/ko/reference/cli/agents.md
index 82f776b782..073df754a2 100644
--- a/docs-site/src/content/docs/ko/reference/cli/agents.md
+++ b/docs-site/src/content/docs/ko/reference/cli/agents.md
@@ -94,6 +94,8 @@ ocx route combo set reliable --targets ark/model-a:2,openai/gpt-5.5
ocx observe usage --range 30d --json
```
+일부 사용량 기록을 집계하지 못하면 일반 출력은 읽을 수 있는 행이 없어도 경고합니다. 표시되는 합계는 읽을 수 있는 기록만 반영합니다. 필터에 일치하는 읽을 수 있는 기록이 없으면 합계 항목 대신 경고와 안내를 표시하며, 제외된 기록에는 일치하는 항목이 있을 수 있습니다. `--json`은 응답의 `usageIncomplete` 진단과 사유를 그대로 유지합니다.
+
### `ocx debug `
실행 중인 프록시의 관리 API를 통해 런타임 디버그 override를 읽거나 변경합니다.
@@ -224,3 +226,7 @@ ocx system codex-cli-update check --json
### `ocx config ...`
검증된 OpenCodex configuration을 검사하고 안전하게 수정합니다. `show`와 `get`은 비밀 값을 가립니다. import는 쓰기 전에 검증하며 `--yes`가 필요합니다.
+
+### 연결된 클라이언트의 사용량
+
+`ocx usage`는 등록된 데이터 키로 허브에서 이 클라이언트의 사용량만 읽습니다. 출력에는 허브 출처와 키 범위가 표시됩니다. 기간·모델·공급자 필터와 `--since`/`--until`, `--json`을 그대로 사용할 수 있습니다. 계정별 내역과 다른 클라이언트 기록은 반환하지 않습니다. 허브가 응답하지 않거나 이 기능을 지원하지 않으면 오류를 알립니다. 로컬 기록으로 대신 표시하지 않습니다. 구형 허브라면 허브를 업데이트하세요.
diff --git a/docs-site/src/content/docs/ko/reference/cli/providers-accounts.md b/docs-site/src/content/docs/ko/reference/cli/providers-accounts.md
index 0fc4bc2608..519fbf27c8 100644
--- a/docs-site/src/content/docs/ko/reference/cli/providers-accounts.md
+++ b/docs-site/src/content/docs/ko/reference/cli/providers-accounts.md
@@ -308,6 +308,9 @@ ocx account main doctor [--json]
ocx account main list [--json]
ocx account main register [--json]
ocx account main add
+ocx account main reauth --device [--no-wait] [--json]
+ocx account main reauth status --flow [--json]
+ocx account main reauth cancel --flow [--json]
ocx account main switch --yes [--json]
ocx account main recover [--rollback --yes] [--json]
```
@@ -364,3 +367,11 @@ ocx models remove deepseek/deepseek-v4 --yes
슬래시가 있는 모델 선택기는 라우팅됩니다(`anthropic/claude-opus-5`). 슬래시가 없는 id는 native OpenAI 모델로 취급되므로, 라우팅된 것처럼 보일 수 있는 id에 대해 그 읽기를 강제하려면 `--native`가 필요합니다.
`--modalities`는 `text`, `image`, `audio`만 허용합니다. Codex는 이 필드를 닫힌 enum으로 해석하고 다른 값이 하나라도 있으면 카탈로그 전체를 거부하므로, `add`, `edit`, 관리 API는 나중에 카탈로그 작성기가 정리해야 할 값을 저장하지 않도록 잘못된 값을 바로 거부합니다(#759).
+
+### 저장된 쿼터 기록
+
+`ocx account history openai [--limit 1-200] [--json]`은 제공자에게 요청하지 않고 저장된 관측을 읽습니다. 관측 시각, WHAM·응답 헤더 출처, 한도 종류와 사용률을 구분해 표시합니다. 계정마다 최대 200개를 30일간 보관하며 전체 저장량에도 제한이 있습니다.
+
+일반 토큰 갱신은 기록을 유지합니다. 재로그인·삭제·계정 교체는 이전 기록과 분리합니다. 네이티브 메인 계정과 로그인 저장 전 조회는 포함하지 않습니다. 기록이 없다는 것은 관측 부족이며 사용량 0을 뜻하지 않습니다. 이 명령은 쿼터를 소비하지 않습니다. 관측을 바탕으로 한 용량 추정에는 아래 한계가 적용됩니다.
+
+같은 초기화 구간의 관측과 계정별 사용 기록이 있으면 보고된 토큰 기준 용량 추정도 표시합니다. 표본 수와 낮은 신뢰도를 함께 표시하며, 쿼터 반올림·외부 사용량·로그 라벨 유지 여부 때문에 제공자의 실제 토큰 한도와 다를 수 있습니다. 기록이 없거나 잘렸으면 근거 부족으로 표시합니다. `--limit`은 표시할 기록 수만 제한하며 추정 입력은 전체 보관 범위입니다.
diff --git a/docs-site/src/content/docs/ko/reference/configuration/providers.md b/docs-site/src/content/docs/ko/reference/configuration/providers.md
index c63dfc9bc7..dae4836490 100644
--- a/docs-site/src/content/docs/ko/reference/configuration/providers.md
+++ b/docs-site/src/content/docs/ko/reference/configuration/providers.md
@@ -37,7 +37,7 @@ GUI에서 등록이나 OAuth 로그인을 마치면 Models 페이지로 이동
| `activeCodexAccountId?` | `string` | — | 다음 요청에 수동으로 선택한 Pool 계정입니다. 선택하면 thread 결속이 해제되며, 진행 중인 요청은 캡처한 자격 증명을 유지합니다. |
| `codexAccountPriorities?` | `Record` | — | Codex pool의 계정별 선택 순서. 계정 ID → `-100`부터 `100`까지의 정수이며 **값이 클수록 먼저** 쓰이고, 항목이 없으면 `0`입니다. 이는 eligibility 경계가 아니라 순서 경계입니다. 선택은 이미 적격한 계정들을 quota 여유가 남은 최상위 tier로 좁히고, 그 tier 안에서 `accountPoolStrategy`가 계정을 고릅니다. tier를 건너뛰는 경우는 그 구성원 전부가 `autoSwitchThreshold` 초과, cooldown, soft-avoid, 일시 중지 또는 재인증 대기일 때뿐이며, usage를 알 수 없다고 해서 tier가 소진되지는 않습니다. 순서는 부적격 계정을 선택 가능하게 만들지 않고, 이미 계정에 묶인 thread를 다시 bind하지도 않습니다. 메인 `__main__` 계정도 동일한 조건으로 참여하므로 Codex Desktop 로그인을 마지막에 쓰도록 둘 수 있습니다. 항목이 하나도 없으면 동작은 이전과 같습니다. map이 잘못된 경우 경고를 출력하고 순서 지정을 끕니다(config 복구는 하지 않습니다). `ocx account priority`와 Codex Auth 페이지에서 관리합니다. |
| `autoSwitchThreshold?` | `number` | `80` | 사용량 기반 선제 전환 임계값입니다. `quota`는 바인딩 없는 작업의 다음 요청을 재평가할 수 있고, 기본값에서는 사용량이 이 임계값을 넘으면 바인딩된 작업도 재평가합니다. `pool.cacheAffinity`가 켜져 있으면 바인딩된 작업은 해당 계정이 소진되었거나 더 이상 처리할 수 없을 때까지 임계값을 넘어도 계정을 유지합니다. `fill-first`는 바인딩 없는 작업 배정의 소진 기준으로만 사용하며, 기본 `round-robin` 선택은 이 값을 사용하지 않습니다. 알려진 5시간, 주간, 30일 quota window 중 가장 높은 점수를 씁니다. `0`은 사용량 기반 전환만 끄며 바인딩 없는 작업 배정이나 실패 복구는 끄지 않습니다. |
-| `accountPoolStrategy?` | `"quota" \| "round-robin" \| "fill-first"` | `"quota"` | 새 작업/바인딩 없는 Codex 요청의 계정 배정 전략입니다. `(parent thread id, quota scope)`의 live affinity가 없으면 바인딩 없는 요청이며, 프록시 재시작이나 affinity 초기화 뒤에는 기존에 보이던 작업도 바인딩이 없어질 수 있습니다. `quota`는 활성 계정이 없을 때 알려진 usage가 가장 낮은 적격 계정을 선택하고, 적격 활성 계정이 `autoSwitchThreshold` 미만이면 유지합니다. 임계값 도달 뒤에는 바인딩 없는 요청을 옮길 수 있고, `pool.cacheAffinity`가 꺼져 있으면 바인딩된 작업의 다음 요청도 usage가 더 낮은 적격 계정으로 옮길 수 있습니다. `pool.cacheAffinity`가 켜져 있으면 바인딩된 작업은 계정이 소진되었거나(알려진 usage 100%) 더 이상 처리할 수 없을 때까지 유지됩니다. `round-robin`은 바인딩 없는 요청을 균등 분배하고, `fill-first`는 cooldown, 사용 불가 또는 drain threshold까지 활성 계정에 배정합니다. |
+| `accountPoolStrategy?` | `"quota" \| "round-robin" \| "fill-first" \| "reset-first"` | `"quota"` | 새 작업/바인딩 없는 Codex 요청의 계정 배정 전략입니다. `(parent thread id, quota scope)`의 live affinity가 없으면 바인딩 없는 요청이며, 프록시 재시작이나 affinity 초기화 뒤에는 기존에 보이던 작업도 바인딩이 없어질 수 있습니다. `quota`는 활성 계정이 없을 때 알려진 usage가 가장 낮은 적격 계정을 선택하고, 적격 활성 계정이 `autoSwitchThreshold` 미만이면 유지합니다. 임계값 도달 뒤에는 바인딩 없는 요청을 옮길 수 있고, `pool.cacheAffinity`가 꺼져 있으면 바인딩된 작업의 다음 요청도 usage가 더 낮은 적격 계정으로 옮길 수 있습니다. `pool.cacheAffinity`가 켜져 있으면 바인딩된 작업은 계정이 소진되었거나(알려진 usage 100%) 더 이상 처리할 수 없을 때까지 유지됩니다. `round-robin`은 바인딩 없는 요청을 균등 분배하고, `fill-first`는 cooldown, 사용 불가 또는 drain threshold까지 활성 계정에 배정합니다. `reset-first`: 사용량 임계값 미만인 계정 중 다음 5시간·주간 초기화가 가장 가까운 계정을 고릅니다. 연결된 작업은 설정된 어피니티 정책을 따릅니다. 독립 모델 한도에는 사용량 순서를 적용합니다. 월간 초기화는 이 순서에 사용하지 않습니다. |
| `pool.cacheAffinity?` | `boolean` | `false` | 바인딩된 Codex 스레드의 선택적 cache-affinity 순서입니다. `pool.kernel`과는 별개이며 기본값은 꺼짐입니다. 잘못된 값은 꺼진 것으로 읽습니다. 켜면 live 바인딩이 quota 여유보다 우선합니다. `quota`는 사용량이 `autoSwitchThreshold`를 넘었다는 이유만으로 스레드를 옮기지 않습니다. 해당 계정이 일시 중지되었거나 사용할 수 없거나 실제로 소진된 경우(알려진 usage 100%)에는 여전히 떠나므로, affinity는 고정이 아니라 재정렬입니다. |
| `accountPoolStickyLimit?` | `number` | `1` | 한 round-robin 선택이 다음으로 넘어가기 전에 유지하는 새 작업/바인딩 없는 작업 배정 수입니다. 카운터는 업스트림 성공 뒤가 아니라 작업을 바인딩할 때 증가합니다. 범위 1–100이며 `accountPoolStrategy`가 `round-robin`일 때만 적용됩니다. |
| `upstreamFailoverThreshold?` | `number` | `3` | 연속된 일시적 실패가 이 횟수에 도달하면 이후 새 세션은 failover됩니다. `0`으로 두면 비활성화됩니다. 일반 Responses와 네이티브 compact 전송에서 입증된 연결 전 DNS/TCP 도달 불가 실패는 provider-host 범위로 기록되며 계정 상태, 계정 쿨다운, 스레드/세션 선호도, 활성 계정 선택 또는 Pool 라우팅에 영향을 주지 않고 이 임계값에도 집계되지 않습니다. |
diff --git a/docs-site/src/content/docs/ko/reference/management-api.md b/docs-site/src/content/docs/ko/reference/management-api.md
index c0056af61d..b806e10d17 100644
--- a/docs-site/src/content/docs/ko/reference/management-api.md
+++ b/docs-site/src/content/docs/ko/reference/management-api.md
@@ -138,6 +138,8 @@ Authorization: Bearer
| `POST /api/storage/cleanup-policy/run` | 수동 cleanup-policy 실행을 시작합니다 | 409 `already_running`; 500 `cleanup_failed` |
| `GET /api/storage/cleanup-policy/test-stream` | 테스트 전용 policy stream 훅입니다 | 사용할 수 없으면 404 `not_found` |
+행이 기존 파서의 크기 제한을 넘으면 `GET /api/usage`와 `GET /api/keys`는 읽을 수 있는 행의 집계를 유지하고 응답 전체에 `usageIncomplete: true`, `usageIncompleteReason: "oversized_rows"`를 추가합니다. 이 진단은 캐시와 증분 추가에서도 유지되며, 빈 결과나 필터 일치 결과가 없는 경우에도 반환됩니다. 재구축 시에는 다시 계산합니다. 행을 맞추기 위해 공급자·모델·API 키 식별자를 줄이지 않습니다. 플래그가 없다고 모든 기록이 유효했다는 뜻은 아닙니다. `historyTruncated`, `entriesTruncated`, 토큰 측정 커버리지와는 별개입니다.
+
`models`, `providers`, `days[].models`의 행에도 `cacheHitRate`가 포함됩니다. 이 값은 공급자의 프롬프트 캐시에서
제공된 입력 토큰의 비율이며 `[0, 1]` 범위로 제한됩니다. 공급자가 캐시 텔레메트리를 보고하지 않았거나 행에 입력
토큰이 없으면 `0`이 아니라 항상 `null`입니다. "캐시 데이터 없음"과 "실제 적중률 0%"는 서로 다른 사실이며,
diff --git a/docs-site/src/content/docs/reference/cli.md b/docs-site/src/content/docs/reference/cli.md
index e39d0cc018..d91e6c1865 100644
--- a/docs-site/src/content/docs/reference/cli.md
+++ b/docs-site/src/content/docs/reference/cli.md
@@ -18,6 +18,19 @@ opencodex state.
`ocx alias list [--json]` shows effective user and built-in aliases. Use `ocx alias set [/] ` and `ocx alias rm [/]` to edit them. Native model ids may contain additional slashes because the selector splits only at the first slash. Enable shipped defaults with `ocx alias defaults on|off [--provider ]`.
+### `ocx remote-workspace`
+
+`ocx remote-workspace pair --pairing-code-stdin --root ` enrolls the local
+computer as an OCX-only Executor. Repeat `--root` to approve more folders and use `--name` to
+override the hostname. Repeat `--toolchain-root ` to expose a user-installed
+Node, Rust, Go, or other toolchain directory read-only inside the command sandbox. On macOS and
+Windows private-dogfood builds, `bun run build:remote-workspace-helper` creates the Rust helper that
+the pair command discovers automatically; `--executor-helper ` selects another
+explicitly reviewed build and pins its digest in local Executor state.
+`ocx remote-workspace agent` maintains the outbound encrypted connection;
+`ocx remote-workspace status [--json]` reports the Hub, device, roots, and advertised capabilities
+without printing its bearer or private key. See [Remote Workspace](/guides/remote-workspace/).
+
- [Lifecycle](/reference/cli/lifecycle/) — setup, proxy and service lifecycle, health, diagnostics,
catalog sync, the dashboard, and updates.
- [Providers, accounts, and models](/reference/cli/providers-accounts/) — provider configuration,
diff --git a/docs-site/src/content/docs/reference/cli/agents.md b/docs-site/src/content/docs/reference/cli/agents.md
index 32399c59df..9baeed283d 100644
--- a/docs-site/src/content/docs/reference/cli/agents.md
+++ b/docs-site/src/content/docs/reference/cli/agents.md
@@ -162,6 +162,11 @@ separately, and requests with no matching price row are counted as
ocx usage --range today --provider xai
```
+When some usage records cannot be included, human output warns, including when there are zero readable rows.
+Any displayed totals reflect readable records only. If a filter has no readable matches, the output shows
+the warning and guidance instead of total lines; skipped records may contain matches.
+`--json` preserves the response-level `usageIncomplete` diagnostic and reason.
+
### `ocx debug `
Read or change runtime debug overrides through the running proxy's management API.
@@ -370,3 +375,9 @@ and are never classified as managed.
Inspect and safely modify validated OpenCodex configuration. `show` and `get` mask secrets. Import
validates before writing and requires `--yes`.
+
+### Usage from a connected client
+
+`ocx usage` reads the connected hub with this client's enrolled data key. Human output identifies the hub source and client-key scope; `--json` returns the same scoped data. Range, surface, provider/model filters and custom `--since`/`--until` bounds remain available. Account breakdowns and other clients' records are not shared. An old or unavailable hub produces an explicit error instead of substituting local usage; upgrade the hub if it does not support this read.
+
+The read-only data-plane endpoint is `GET /v1/usage`, using `x-opencodex-api-key` with a configured client key. Environment-wide and admin keys are refused. It accepts `range`, `surface`, `provider`, `model`, `since`, and `until`; unknown/repeated options and caller-selected key IDs are rejected. Oversized skipped rows retain the explicit incomplete-history warning.
diff --git a/docs-site/src/content/docs/reference/cli/providers-accounts.md b/docs-site/src/content/docs/reference/cli/providers-accounts.md
index 5c9a8b6769..a44101a558 100644
--- a/docs-site/src/content/docs/reference/cli/providers-accounts.md
+++ b/docs-site/src/content/docs/reference/cli/providers-accounts.md
@@ -486,6 +486,9 @@ ocx account main doctor [--json]
ocx account main list [--json]
ocx account main register [--json]
ocx account main add
+ocx account main reauth --device [--no-wait] [--json]
+ocx account main reauth status --flow [--json]
+ocx account main reauth cancel --flow [--json]
ocx account main switch --yes [--json]
ocx account main recover [--rollback --yes] [--json]
```
@@ -501,6 +504,8 @@ successful switch preserves local tasks and history, then requires Codex to be r
`doctor` to inspect profile state and `recover` to finish or roll back an interrupted transition.
`switch` accepts either the profile ID or its label.
+`reauth` re-authenticates the *existing* native main identity with an OpenAI device code (#3898) instead of enrolling a new profile. It is the headless-hub recovery path: no local Codex App, no `codex` binary, and no OS keyring are required. The device login must complete for the same ChatGPT account that already holds the native main slot; the credential write is fenced by the exclusive claim and a path/hash/inode snapshot, and the command output carries only the flow id, the verification URL, the device code, and status. The pool login route stays pool-only and keeps rejecting `__main__`; the equivalent dashboard surface is the Codex Auth main card's Re-login with device code control.
+
The v1 recovery matrix covers an OpenCodex process exiting after a transaction file has been
published by rename. It does not claim durability across an OS or kernel crash or sudden power
loss: `atomicWriteFileAsync()` does not `fsync` either the file or its parent directory.
@@ -585,3 +590,11 @@ all refuse the bad value rather than storing something the catalog writer would
### Mark one model text-only
Use `ocx provider add mine --adapter openai-chat --base-url https://example.com/v1 --default-model model-a --text-only` when registering a provider, or `ocx provider edit mine --model model-a --text-only` for an existing provider. Add can use `--model` or its default model; edit requires `--model`. The flag updates only that exact model's `modelCapabilities.inputModalities` to `["text"]`, preserving other models and axes.
+
+### Cached quota history
+
+`ocx account history openai [--limit 1-200] [--json]` reads stored observations without contacting the provider. The output separates actual observation time, WHAM or response-header source, window family and usage percentage. At most 200 observations per account are retained for 30 days, with global storage bounds.
+
+Ordinary token refresh preserves history. Reauthentication, removal or account replacement retires the old publication. Native main and probes performed before a login is published are not included. Missing history means insufficient observations, not zero usage. This command does not spend quota. Effective estimates, when supported by observations, carry the limitations below.
+
+The history output also includes effective reported-token estimates when same-window observations and attributable usage support them. Each estimate includes a sample count and low confidence. Quota rounding, external usage and assumed log-label continuity limit the inference; it is not your provider’s token allowance. Missing or truncated ledger evidence returns insufficient evidence. `--limit` controls displayed history, not the bounded estimate input.
diff --git a/docs-site/src/content/docs/reference/configuration/providers.md b/docs-site/src/content/docs/reference/configuration/providers.md
index a832326d59..b2c490a2c1 100644
--- a/docs-site/src/content/docs/reference/configuration/providers.md
+++ b/docs-site/src/content/docs/reference/configuration/providers.md
@@ -52,7 +52,7 @@ separate. Full request URLs such as `/api/v1/responses` are not provider base UR
| `codexAccountPriorities?` | `Record` | — | Per-account selection order for the Codex pool: account id → integer from `-100` to `100`, **higher is used earlier**, absent means `0`. This is an ordering boundary, not an eligibility one: selection narrows the already-eligible accounts to the highest tier that still has quota headroom, and `accountPoolStrategy` then picks within that tier. A tier is skipped only when every member is over `autoSwitchThreshold`, cooling down, soft-avoided, paused, or needs reauthentication — unknown quota never drains a tier. Ordering never makes an ineligible account selectable and never re-binds a thread that already has an account. The main `__main__` account participates on equal terms, which is how the Codex Desktop login can be set to drain last. With no entries the pool behaves exactly as before. A malformed map is ignored with a console warning (ordering off, no config repair). Managed by `ocx account priority` and the Codex Auth page. |
| `activeCodexAccountPinned?` | `string` | — | Account id the operator last selected by hand. While set, a higher `codexAccountPriorities` tier cannot preempt it until the pin is released by drain, exclusion, deletion, or an explicit failover/promotion away. Ordinary round-robin movement inside the capped tier does not release it. Writing any `codexAccountPriorities` entry also releases the pin, so a pin made before an order existed cannot outrank one set afterward. `GET /api/codex-auth/active` reports both whether the effective account is pinned (`pinned`) and the account carrying the ceiling (`pinnedAccountId`). |
| `autoSwitchThreshold?` | `number` | `80` | Usage threshold for proactive switching. `quota` can re-evaluate unbound tasks on their next request, and by default also re-evaluates bound tasks once usage crosses this threshold. With `pool.cacheAffinity` on, a bound task keeps its account past the threshold until that account is exhausted or otherwise cannot serve. `fill-first` uses it only as the drain point for unbound assignment; normal `round-robin` selection does not use it. The score uses the hottest known 5h, weekly, or 30d quota window. `0` disables usage-based proactive switching only, not unbound assignment or failure recovery. |
-| `accountPoolStrategy?` | `"quota" \| "round-robin" \| "fill-first"` | `"quota"` | Assignment strategy for new/unbound Codex requests. A request is unbound when it has no live (parent thread id, quota scope) affinity; a visible existing task can become unbound after proxy restart or affinity reset. `quota` picks the lowest-usage eligible account when no active account exists, keeps an eligible active account below `autoSwitchThreshold`, and after the threshold may move an unbound request or — unless `pool.cacheAffinity` is on — proactively rebind a bound task to a lower-usage eligible account. With `pool.cacheAffinity` on, a bound task stays until its account is exhausted (known usage at 100%) or otherwise cannot serve. `round-robin` distributes unbound requests evenly; `fill-first` keeps assigning unbound requests to the active account until cooldown, unavailability, or the configured drain threshold. |
+| `accountPoolStrategy?` | `"quota" \| "round-robin" \| "fill-first" \| "reset-first"` | `"quota"` | Assignment strategy for new/unbound Codex requests. A request is unbound when it has no live (parent thread id, quota scope) affinity; a visible existing task can become unbound after proxy restart or affinity reset. `quota` picks the lowest-usage eligible account when no active account exists, keeps an eligible active account below `autoSwitchThreshold`, and after the threshold may move an unbound request or — unless `pool.cacheAffinity` is on — proactively rebind a bound task to a lower-usage eligible account. With `pool.cacheAffinity` on, a bound task stays until its account is exhausted (known usage at 100%) or otherwise cannot serve. `round-robin` distributes unbound requests evenly; `fill-first` keeps assigning unbound requests to the active account until cooldown, unavailability, or the configured drain threshold. `reset-first`: Prefer the nearest future 5-hour or weekly reset among accounts below the usage threshold. Bound tasks follow the configured affinity policy. Independent model quotas use quota ordering. Monthly resets do not determine this ordering. |
| `pool.cacheAffinity?` | `boolean` | `false` | Opt-in cache-affinity ordering for bound Codex threads, independent of `pool.kernel`. Off by default; a malformed value reads as off. With it on, a live binding outranks quota headroom: `quota` does not move the thread merely because usage crossed `autoSwitchThreshold`. The thread still leaves if that account cannot serve — paused, unusable, or genuinely exhausted (known usage at 100%) — so affinity is a reordering, not a pin. |
| `accountPoolStickyLimit?` | `number` | `1` | New/unbound task assignments retained on one round-robin selection before advancing; the counter advances when a task is bound, not after an upstream success. Range 1–100. |
| `upstreamFailoverThreshold?` | `number` | `3` | Consecutive transient failures before future new sessions fail over. Set `0` to disable. For regular Responses and native compact sends, proven pre-connection DNS/TCP reachability failures are tracked at the provider-host level: they never affect account health, account cooldowns, thread/session affinity, active-account selection, or Pool routing, and never count toward this threshold. |
diff --git a/docs-site/src/content/docs/reference/management-api.md b/docs-site/src/content/docs/reference/management-api.md
index cd8b2450c2..7fee801715 100644
--- a/docs-site/src/content/docs/reference/management-api.md
+++ b/docs-site/src/content/docs/reference/management-api.md
@@ -145,6 +145,34 @@ should use the dedicated paths above so an older proxy cannot ignore a profile s
See [Aside profile controls](/guides/integrations/#aside-profile-controls) for CLI commands and
the proxy upgrade, restart, and retry sequence.
+### Remote Workspace
+
+Requires Hub mode and `OCX_REMOTE_WORKSPACE_ENABLED=1` on the Hub process. Disabled status is
+readable; mutations refuse without initializing workspace services.
+
+| Method and path | Purpose | Notable errors |
+| --- | --- | --- |
+| `GET /api/remote-workspace` | Read paired computers, current capabilities, Hub runtimes, and session snapshots | Disabled status when Hub role or explicit opt-in is absent |
+| `POST /api/remote-workspace/pairing` | Create a ten-minute one-use Executor enrollment code | GUI session only; 429 pairing capacity |
+| `GET /api/remote-workspace/runtimes` | Read Codex, Claude Code, and Pi availability on the Hub | — |
+| `GET, POST /api/remote-workspace/sessions` | List sessions or start one bound to a device, root, runtime, and access mode | POST is GUI session only; 409 offline/unavailable/invalid target |
+| `POST /api/remote-workspace/sessions/{id}/prompt` | Continue the bound model session | GUI session only; 409 active turn, offline Executor, or resume failure |
+| `DELETE /api/remote-workspace/sessions/{id}` | Stop the model runtime and encrypted Executor session | GUI session only; 404 unknown session |
+| `DELETE /api/remote-workspace/devices/{id}` | Revoke one computer and stop its sessions | GUI session only; 404 unknown device |
+
+Executor enrollment exchanges a one-use code at `POST /remote-workspace/pair` and then opens
+`/remote-workspace/agent` as a bearer-authenticated outbound WebSocket. Those two machine endpoints
+are not general management API authority. The bearer is device-scoped, and each work session adds a
+signed E2EE handshake. Ten failed pairing codes from one kernel-observed peer return `429` with
+`Retry-After` for the remainder of the fixed ten-minute window. Tailscale Serve clients share the
+management listener's loopback peer bucket; the identity header is not used for throttling because
+a direct local process could forge it. See [Remote Workspace](/guides/remote-workspace/) for the
+end-user flow and trust boundaries.
+
+Session snapshots include `resumable`. It becomes true only after the selected coding-agent runtime
+has durable history; notably, a new Claude Code session remains false until its first prompt
+completes.
+
### Combos
| Method and path | Purpose | Notable errors |
@@ -191,7 +219,7 @@ by the current window size.
| `GET /api/debug/usage-logs` | Read bounded usage-debug entries | — |
| `GET /api/debug/injection-logs` | Read bounded guidance-injection debug entries | — |
| `GET /api/claude/inbound-debug` | Read Claude inbound debug state and entries | — |
-| `GET /api/usage` | Stream the complete usage ledger into compact aggregates, then incrementally fold verified appends; summarize by preset or inclusive custom window and client surface, with a Codex `accounts` breakdown keyed by stable non-PII log labels | 400 invalid custom bounds; returns an `error: "read_failed"` summary if storage cannot be read |
+| `GET /api/usage` | Scan the usage ledger into compact aggregates of readable rows, then incrementally fold verified appends; summarize by preset or inclusive custom window and client surface, with a Codex `accounts` breakdown keyed by stable non-PII log labels | 400 invalid custom bounds; returns an `error: "read_failed"` summary if storage cannot be read |
| `GET /api/storage` | Scan Codex storage usage by bucket | Returns an `error: "scan_failed"` payload on scan failure |
| `POST /api/storage/cleanup/preview` | Preview archived-session cleanup and return a binding digest | 400 `invalid_json` or `invalid_percent` |
| `POST /api/storage/cleanup` | Quarantine or permanently remove the previewed archived set | 400 invalid input; 409 stale/busy/referenced state; 500 filesystem/database failure |
@@ -202,6 +230,14 @@ by the current window size.
| `POST /api/storage/cleanup-policy/run` | Start a manual cleanup-policy run | 409 `already_running`; 500 `cleanup_failed` |
| `GET /api/storage/cleanup-policy/test-stream` | Test-only policy stream hook | 404 `not_found` when unavailable |
+If a scanned row exceeds the existing parser size limit, `GET /api/usage` and `GET /api/keys`
+keep the readable-row aggregates and add `usageIncomplete: true` with
+`usageIncompleteReason: "oversized_rows"` at response level. This diagnostic survives cached
+responses and incremental appends, including empty or unmatched results; a rebuild recalculates it.
+No provider, model, or API-key identifier is shortened to make a row fit. An absent flag is not proof
+that every ledger record was valid. This is separate from `historyTruncated`, `entriesTruncated`,
+and token measurement coverage.
+
New xAI attempts in `usage.jsonl` include a request-time `credentialSource`: `grok-oauth`
for the resolved Grok CLI OAuth transport, or `xai-api-key` for the public xAI API key
transport. This fixed label contains no credential or account identifier. It belongs to
@@ -214,7 +250,7 @@ The log reports usage, not subscription invoice amounts.
snapshot on a cold start. It processes fixed 1 MiB chunks and retains compact aggregate state rather
than every normalized request row. Later refreshes validate the previous line boundary and fold only
newly appended complete rows. Concurrent callers share the same refresh. Range and surface predicates
-are applied to the complete aggregate, so the former read-byte window and parsed-row cap cannot omit
+are applied to the readable-row aggregate, so the former read-byte window and parsed-row cap cannot omit
an earlier file prefix from 7-day, 30-day, or all-history totals. `managementUsageMaxReadBytes` remains
accepted for compatibility with bounded legacy readers, but changing it no longer expands or reduces
the history summarized by this endpoint.
diff --git a/docs-site/src/content/docs/ru/guides/codex-integration.md b/docs-site/src/content/docs/ru/guides/codex-integration.md
index d2f135be2f..2a2087c994 100644
--- a/docs-site/src/content/docs/ru/guides/codex-integration.md
+++ b/docs-site/src/content/docs/ru/guides/codex-integration.md
@@ -392,8 +392,7 @@ ocx config set codexPool '{"excludedPlans":["free"]}'
Это политика выбора, а не блокировка. Исключённый аккаунт сохраняет учётные данные, историю квот и привязку к треду, остаётся видимым в списке и по-прежнему доступен при явном выборе вроде `work/gpt-5.5`. Меняется только то, что автоматическая ротация перестаёт его выбирать — в том числе когда он уже активен или уже привязан к треду, а именно это состояние остаётся после истёкшей подписки.
-Два намеренных ограничения. Основной аккаунт Codex никогда не исключается по тарифу: маршрутизация в режиме «только выбор» скрывает его тариф, чтобы не читать защищённые нативные учётные данные, и правило для него противоречило бы само себе. А если не осталось ни одного неисключённого аккаунта, исключённый всё равно отвечает вместо отказа; чтобы остановить обслуживание полностью, по-прежнему нужно поставить на паузу все аккаунты. Аналога `minimumPlan` нет: чтобы ранжировать тарифы ChatGPT, нужен полный порядок, которого здесь не существует.
-
+Основной аккаунт Codex не исключается по тарифу: маршрутизация только для выбора не читает защищённые нативные учётные данные. Если все доступные аккаунты пула исключены, автоматический выбор не возвращает аккаунт. Явные маршруты к аккаунту доступны, но проверки паузы, аутентификации и прав на модель сохраняются. Карточка и CLI показывают исключённый тариф отдельно от состояния учётных данных. Настройки `minimumPlan` нет, поскольку тарифы не имеют полного порядка.
## Восстановление нативного Codex
`ocx stop` останавливает прокси и установленную фоновую службу, затем пытается восстановить нативный Codex. OpenCodex удаляет настройки маршрутизации, принадлежность которых может подтвердить, и сообщает о неполном восстановлении, если файлы конфигурации нельзя безопасно восстановить.
diff --git a/docs-site/src/content/docs/ru/guides/web-dashboard.md b/docs-site/src/content/docs/ru/guides/web-dashboard.md
index 05b11a8780..2ea633b175 100644
--- a/docs-site/src/content/docs/ru/guides/web-dashboard.md
+++ b/docs-site/src/content/docs/ru/guides/web-dashboard.md
@@ -48,6 +48,8 @@ bun run dev:gui
| **Storage** | Только чтение разбивки диска CODEX_HOME (сессии, архивы, БД, вложения). Опциональная очистка архива: предпросмотр самых старых N%, затем карантин в `CODEX_HOME/.trash` (по умолчанию) или безвозвратное удаление по явному флажку. **Политика автоочистки** — opt-in и **по умолчанию ВЫКЛ** (`storageCleanupPolicy.enabled`); порог/цель/расписание/режим на странице Storage или **Запустить сейчас**. Записи карантина можно восстановить со страницы Storage (JSONL + threads). Активные сессии только для чтения. Очистка и восстановление отклоняются, пока Codex держит блокировку новейшего/активного `state_*.sqlite`. |
| **Stop** | Корректная остановка прокси и установленного фонового сервиса, восстановление нативного Codex и выход (`POST /api/stop`). На Windows с бэкендом планировщика заданий дашборд отказывает и просит выполнить `ocx stop`: обёртка может перезапустить прокси после завершения задачи, и проверить это окно перезапуска до восстановления клиентской конфигурации способен только stop, работающий вне прокси. При отказе ничего не изменяется. |
+Страницы использования, дашборда, провайдеров, каталога провайдеров и API-ключей предупреждают об исключённых записях, даже если читаемых строк нет. Счётчики, даты и рейтинги основаны только на читаемых записях. Сохранение порядка моделей по частоте использования отклоняется при неполной истории: выберите другой порядок или восстановите историю перед повтором.
+
### Фильтрация запросов
Фильтры объединяют источник, перехваченные запросы, провайдера, точную модель, статус, время, скорость и ID диалога в загруженном журнале. Варианты включают резервные попытки; модель сравнивается без учёта регистра и крайних пробелов, но не по подстроке. Исчезнувший вариант сбрасывается на все записи.
diff --git a/docs-site/src/content/docs/ru/reference/cli/agents.md b/docs-site/src/content/docs/ru/reference/cli/agents.md
index eadeb03d61..3cb310a90a 100644
--- a/docs-site/src/content/docs/ru/reference/cli/agents.md
+++ b/docs-site/src/content/docs/ru/reference/cli/agents.md
@@ -79,6 +79,8 @@ ocx route combo set reliable --targets ark/model-a:2,openai/gpt-5.5
ocx observe usage --range 30d --json
```
+Если часть записей нельзя учесть, человекочитаемый вывод показывает предупреждение, даже если нет читаемых строк. Отображаемые итоги учитывают только читаемые записи. Если фильтр не находит читаемых совпадений, вместо строк итогов выводятся предупреждение и подсказки; пропущенные записи могут содержать совпадения. `--json` сохраняет диагностику `usageIncomplete` и её причину из ответа.
+
### `ocx debug `
Прочитать или изменить runtime debug-override'ы через management API работающего прокси.
diff --git a/docs-site/src/content/docs/ru/reference/cli/providers-accounts.md b/docs-site/src/content/docs/ru/reference/cli/providers-accounts.md
index 76791eb148..928082fe6d 100644
--- a/docs-site/src/content/docs/ru/reference/cli/providers-accounts.md
+++ b/docs-site/src/content/docs/ru/reference/cli/providers-accounts.md
@@ -302,6 +302,9 @@ ocx account main doctor [--json]
ocx account main list [--json]
ocx account main register [--json]
ocx account main add
+ocx account main reauth --device [--no-wait] [--json]
+ocx account main reauth status --flow [--json]
+ocx account main reauth cancel --flow [--json]
ocx account main switch --yes [--json]
ocx account main recover [--rollback --yes] [--json]
```
diff --git a/docs-site/src/content/docs/ru/reference/configuration/providers.md b/docs-site/src/content/docs/ru/reference/configuration/providers.md
index 9d132b0e94..d471f31c25 100644
--- a/docs-site/src/content/docs/ru/reference/configuration/providers.md
+++ b/docs-site/src/content/docs/ru/reference/configuration/providers.md
@@ -38,7 +38,7 @@ ocx models provider openrouter on
| `activeCodexAccountId?` | `string` | — | Вручную выбранный аккаунт Pool для следующего запроса. Выбор очищает thread affinity; in-flight-запросы сохраняют уже захваченные credential'ы. |
| `codexAccountPriorities?` | `Record` | — | Порядок выбора для каждого аккаунта пула Codex: id аккаунта → целое число от `-100` до `100`, **больше — используется раньше**, отсутствие означает `0`. Это граница порядка, а не пригодности: выбор сужает уже подходящие аккаунты до самого высокого уровня, у которого ещё есть запас квоты, а внутри этого уровня аккаунт выбирает `accountPoolStrategy`. Уровень пропускается, только когда все его аккаунты превысили `autoSwitchThreshold`, находятся в cooldown, под soft-avoid, на паузе или требуют повторной аутентификации; неизвестный usage никогда не исчерпывает уровень. Порядок не делает выбираемым непригодный аккаунт и не перепривязывает поток, у которого аккаунт уже есть. Основной аккаунт `__main__` участвует на равных — именно так логин Codex Desktop можно оставить на самый конец. Без записей поведение остаётся прежним. Некорректная map игнорируется с предупреждением в консоли (порядок отключается, восстановление config не запускается). Управляется через `ocx account priority` и страницу Codex Auth. |
| `autoSwitchThreshold?` | `number` | `80` | Порог проактивного переключения по использованию. `quota` может повторно оценить следующий непривязанный запрос, а по умолчанию — и привязанную задачу, когда usage пересекает этот порог. При включённом `pool.cacheAffinity` привязанная задача сохраняет аккаунт после порога, пока он не исчерпан и ещё может обслуживать запрос. `fill-first` использует его только как точку исчерпания для непривязанных назначений; обычный `round-robin` его не использует. Оценка берёт самое горячее из окон 5 часов, недели и 30 дней. `0` отключает только переключение по использованию, но не назначение непривязанных задач и не восстановление после сбоев. |
-| `accountPoolStrategy?` | `"quota" \| "round-robin" \| "fill-first"` | `"quota"` | Стратегия назначения для новых/непривязанных запросов Codex. Запрос непривязан, если у него нет live affinity `(parent thread id, quota scope)`; видимая существующая задача может стать непривязанной после перезапуска прокси или сброса affinity. `quota` выбирает подходящий аккаунт с наименьшим известным usage, когда активного аккаунта нет, сохраняет подходящий активный аккаунт ниже `autoSwitchThreshold`, а после порога может перевести непривязанный запрос. Если `pool.cacheAffinity` выключен, следующий запрос привязанной задачи тоже может перейти на подходящий аккаунт с меньшим usage. Если флаг включён, привязанная задача остаётся, пока аккаунт не исчерпан (известный usage 100%) или не может обслуживать запрос. `round-robin` равномерно распределяет непривязанные запросы; `fill-first` назначает их активному аккаунту до cooldown, недоступности или порога исчерпания. |
+| `accountPoolStrategy?` | `"quota" \| "round-robin" \| "fill-first" \| "reset-first"` | `"quota"` | Стратегия назначения для новых/непривязанных запросов Codex. Запрос непривязан, если у него нет live affinity `(parent thread id, quota scope)`; видимая существующая задача может стать непривязанной после перезапуска прокси или сброса affinity. `quota` выбирает подходящий аккаунт с наименьшим известным usage, когда активного аккаунта нет, сохраняет подходящий активный аккаунт ниже `autoSwitchThreshold`, а после порога может перевести непривязанный запрос. Если `pool.cacheAffinity` выключен, следующий запрос привязанной задачи тоже может перейти на подходящий аккаунт с меньшим usage. Если флаг включён, привязанная задача остаётся, пока аккаунт не исчерпан (известный usage 100%) или не может обслуживать запрос. `round-robin` равномерно распределяет непривязанные запросы; `fill-first` назначает их активному аккаунту до cooldown, недоступности или порога исчерпания. `reset-first`: Среди аккаунтов ниже порога выбирается ближайший сброс 5-часовой или недельной квоты. Привязанные задачи следуют настроенной политике привязки. Независимые квоты моделей упорядочиваются по использованию. Месячный сброс не определяет этот порядок. |
| `pool.cacheAffinity?` | `boolean` | `false` | Опциональный порядок cache-affinity для привязанных потоков Codex, независимый от `pool.kernel`. По умолчанию выключен; некорректное значение читается как выключенное. Когда флаг включён, живая привязка важнее запаса квоты: `quota` не переносит поток только потому, что usage пересёк `autoSwitchThreshold`. Поток всё равно уходит, если аккаунт не может обслуживать запрос — на паузе, непригоден или реально исчерпан (известный usage 100%). Affinity меняет порядок, а не закрепляет учётные данные. |
| `accountPoolStickyLimit?` | `number` | `1` | Число назначений новых/непривязанных задач на одном выборе round-robin перед переходом дальше. Счётчик растёт при привязке задачи, а не после успеха upstream. Диапазон 1–100; только при `accountPoolStrategy` = `round-robin`. |
| `upstreamFailoverThreshold?` | `number` | `3` | Сколько подряд transient failure допустить, прежде чем новые сессии начнут делать failover. `0` отключает эту логику. Для обычных Responses-запросов и нативных compact-отправок доказанные ошибки доступности DNS/TCP до соединения учитываются на уровне пары «провайдер, хост» и не влияют на здоровье аккаунта, кулдауны аккаунта, привязку потока/сессии, выбор активного аккаунта или маршрутизацию пула, а также не учитываются в этом пороге. |
diff --git a/docs-site/src/content/docs/ru/reference/management-api.md b/docs-site/src/content/docs/ru/reference/management-api.md
index e91a12222a..978744bbb0 100644
--- a/docs-site/src/content/docs/ru/reference/management-api.md
+++ b/docs-site/src/content/docs/ru/reference/management-api.md
@@ -156,6 +156,8 @@ GUI-сессия в стиле loopback не выпускается.
| `POST /api/storage/cleanup-policy/run` | Запустить manual cleanup-policy run | 409 `already_running`; 500 `cleanup_failed` |
| `GET /api/storage/cleanup-policy/test-stream` | Тестовый policy-stream hook | 404 `not_found`, когда недоступен |
+Если строка превышает существующий лимит размера парсера, `GET /api/usage` и `GET /api/keys` сохраняют агрегаты читаемых строк и добавляют в ответ `usageIncomplete: true` и `usageIncompleteReason: "oversized_rows"`. Диагностика сохраняется в кеше и при инкрементальных добавлениях, в том числе для пустых результатов и отсутствующих совпадений; при перестроении она вычисляется заново. Идентификаторы провайдеров, моделей и API-ключей не сокращаются. Отсутствие флага не доказывает корректность всех строк. Это отдельный сигнал от `historyTruncated`, `entriesTruncated` и покрытия измерений токенов.
+
Строки в `models`, `providers` и `days[].models` также содержат `cacheHitRate` — долю входных
токенов, полученных из кэша промптов провайдера и ограниченную диапазоном `[0, 1]`. Значение равно
`null`, а не `0`, если провайдер не передал телеметрию кэша или в строке нет входных токенов: отсутствие
diff --git a/docs-site/src/content/docs/tr/guides/codex-integration.md b/docs-site/src/content/docs/tr/guides/codex-integration.md
index a601a524c6..a36594e577 100644
--- a/docs-site/src/content/docs/tr/guides/codex-integration.md
+++ b/docs-site/src/content/docs/tr/guides/codex-integration.md
@@ -449,8 +449,7 @@ ocx config set codexPool '{"excludedPlans":["free"]}'
Bu bir engelleme değil, seçim politikasıdır. Dışarıda bırakılan hesap kimlik bilgisini, kota geçmişini ve iş parçacığı bağını korur, hesap listesinde görünmeye devam eder ve `work/gpt-5.5` gibi açık bir seçimle hâlâ erişilebilir. Değişen tek şey, otomatik rotasyonun onu artık seçmemesidir; hesap zaten etkin olsa ya da bir iş parçacığına bağlı olsa bile. Süresi dolan bir abonelik tam olarak bu durumu bırakır.
-İki kasıtlı sınır var. Ana Codex hesabı plana göre hiçbir zaman dışarıda bırakılmaz: yalnızca-seçim yönlendirmesi korunan yerel kimlik bilgisini okumamak için planını saklar, dolayısıyla ana hesabı kapsayan bir kural kendisiyle çelişirdi. Ayrıca dışarıda bırakılmamış hiçbir hesap kalmadığında, dışarıda bırakılan hesap başarısız olmak yerine yine yanıt verir; hizmeti tamamen durdurmak için hâlâ tüm hesapları duraklatmak gerekir. `minimumPlan` karşılığı yoktur, çünkü ChatGPT planlarını sıralamak burada bulunmayan bir tam sıralama gerektirir.
-
+Ana Codex hesabı plan hariç tutma politikasından muaftır; yalnızca seçim yapan yönlendirme korunan yerel kimlik bilgilerini okumaz. Kullanılabilir tüm havuz hesapları hariç tutulursa otomatik seçim hesap döndürmez. Açıkça hesap belirten yollar kullanılabilir; duraklatma, kimlik doğrulama ve model yetkisi denetimleri korunur. Hesap kartı ve CLI, hariç tutulan yönlendirme planını kimlik bilgisi durumundan ayrı gösterir. Planların tam sıralaması olmadığından `minimumPlan` ayarı yoktur.
## Yerel Codex'i geri yükleme
`ocx stop`, proxy'yi ve kurulu arka plan servisini durdurur, ardından yerel Codex'i geri yüklemeyi dener. OpenCodex yalnızca sahipliğini doğrulayabildiği yönlendirme öğelerini kaldırır; yapılandırma dosyaları güvenle geri yüklenemiyorsa işlemin tamamlanmadığını bildirir.
diff --git a/docs-site/src/content/docs/tr/guides/web-dashboard.md b/docs-site/src/content/docs/tr/guides/web-dashboard.md
index ebdf946ffd..cfc04ca1df 100644
--- a/docs-site/src/content/docs/tr/guides/web-dashboard.md
+++ b/docs-site/src/content/docs/tr/guides/web-dashboard.md
@@ -60,6 +60,8 @@ kararıdır.
| **Depolama** | Salt okunur CODEX_HOME disk dökümü (oturumlar, arşivler, DB'ler, ekler). İsteğe bağlı arşivlenmiş temizleme: en eski %N'yi önizleyin, ardından `CODEX_HOME/.trash` konumuna karantinaya alın (varsayılan) veya açık bir onay kutusu arkasında kalıcı olarak silin. **Otomatik temizleme politikası** isteğe bağlıdır ve **varsayılan olarak KAPALIDIR** (`storageCleanupPolicy.enabled`); Depolama sayfasında eşik/hedef/zamanlama/mod yapılandırın veya **Şimdi çalıştır (Run now)**'ı tetikleyin. Karantinaya alınan girdiler Depolama sayfasından geri yüklenebilir (JSONL + iş parçacıkları). Aktif oturumlar salt okunur kalır. Codex en yeni/aktif `state_*.sqlite` dosyasını kilitli tuttuğu sürece temizleme ve geri yükleme reddedilir. |
| **Durdur** | Proxy'yi ve kurulu arka plan servisini zarif bir şekilde durdurun, yerel Codex'i geri yükleyin ve çıkın (`POST /api/stop`). Windows'ta Görev Zamanlayıcı arka ucunda panel reddeder ve `ocx stop` çalıştırmanızı ister: görev bittikten sonra sarmalayıcı proxy'yi yeniden başlatabilir ve bu yeniden başlatma penceresini istemci yapılandırmanız geri yüklenmeden önce yalnızca proxy dışında çalışan bir stop doğrulayabilir. Reddedildiğinde hiçbir şey değiştirilmez. |
+Kullanım, panel, sağlayıcı çalışma alanı, sağlayıcı kataloğu ve API anahtarı görünümleri, okunabilir kayıt kalmasa bile dışlanan kayıtlar için uyarı gösterir. Sayılar, tarihler ve kullanım sıralamaları yalnızca okunabilir kayıtlara dayanır. Geçmiş eksikse en çok kullanılan model sırası kaydedilmez; başka bir sıra seçin veya yeniden denemeden önce geçmişi onarın.
+
### İstek günlüklerini filtreleme
Filtreler yüklü günlükte yüzey, yakalanan istekler, sağlayıcı, tam model adı, durum, zaman, hız ve konuşma kimliğini birleştirir. Seçenekler yedek denemeleri de içerir; model eşleşmesi büyük/küçük harfi ve dış boşlukları yok sayar, kısmi adları eşleştirmez. Kaybolan seçenek tüm kayıtlara döner.
diff --git a/docs-site/src/content/docs/tr/reference/cli/agents.md b/docs-site/src/content/docs/tr/reference/cli/agents.md
index 9f38ae6495..f4564d3bba 100644
--- a/docs-site/src/content/docs/tr/reference/cli/agents.md
+++ b/docs-site/src/content/docs/tr/reference/cli/agents.md
@@ -111,6 +111,8 @@ verilerini inceleyin. Doğrudan takma adlar şunlardır:
ocx observe usage --range 30d --json
```
+Bazı kullanım kayıtları dahil edilemiyorsa okunabilir çıktı, okunabilir satır olmadığında da uyarı gösterir. Gösterilen toplamlar yalnızca okunabilir kayıtları yansıtır. Filtreyle eşleşen okunabilir kayıt yoksa toplam satırları yerine uyarı ve yönlendirme gösterilir; atlanan kayıtlar eşleşme içerebilir. `--json`, yanıttaki `usageIncomplete` tanısını ve nedenini korur.
+
### `ocx debug `
Çalışan proxy'nin yönetim API'si aracılığıyla çalışma zamanı hata ayıklama
diff --git a/docs-site/src/content/docs/tr/reference/cli/providers-accounts.md b/docs-site/src/content/docs/tr/reference/cli/providers-accounts.md
index 073156cf1a..cc1f49e105 100644
--- a/docs-site/src/content/docs/tr/reference/cli/providers-accounts.md
+++ b/docs-site/src/content/docs/tr/reference/cli/providers-accounts.md
@@ -360,6 +360,9 @@ ocx account main doctor [--json]
ocx account main list [--json]
ocx account main register [--json]
ocx account main add
+ocx account main reauth --device [--no-wait] [--json]
+ocx account main reauth status --flow [--json]
+ocx account main reauth cancel --flow [--json]
ocx account main switch --yes [--json]
ocx account main recover [--rollback --yes] [--json]
```
diff --git a/docs-site/src/content/docs/tr/reference/configuration/providers.md b/docs-site/src/content/docs/tr/reference/configuration/providers.md
index 3d72f129a7..05cb47c56e 100644
--- a/docs-site/src/content/docs/tr/reference/configuration/providers.md
+++ b/docs-site/src/content/docs/tr/reference/configuration/providers.md
@@ -39,7 +39,7 @@ Arayüzde kayıt veya OAuth girişi tamamlanınca Models sayfasını açan bir b
| `codexAccountPriorities?` | `Record` | — | Codex havuzu için hesap başına seçim sırası: hesap kimliği → `-100` ile `100` arası tam sayı, **daha yüksek olan daha önce kullanılır**, yoksa `0` anlamına gelir. Bu bir öncelik sırası sınırıdır, bir uygunluk sınırı değildir: seçim, zaten uygun olan hesapları hala kota payı bulunan en yüksek katmana daraltır ve `accountPoolStrategy` daha sonra bu katman içinde seçim yapar. Bir katman, yalnızca her üye `autoSwitchThreshold` üzerinde olduğunda, soğumada olduğunda, yumuşak kaçınıldığında, duraklatıldığında veya yeniden kimlik doğrulama gerektiğinde atlanır — bilinmeyen kota asla bir katmanı boşaltmaz. Sıralama asla uygun olmayan bir hesabı seçilebilir yapmaz ve zaten bir hesabı olan bir iş parçacığını asla yeniden bağlamaz. Ana `__main__` hesap eşit şartlarda katılır, bu sayede Codex Desktop girişi en son tükenecek şekilde ayarlanabilir. Hiçbir girdi olmadığında havuz tam olarak eskisi gibi davranır. Hatalı biçimlendirilmiş bir harita bir konsol uyarısıyla yok sayılır (sıralama kapalı, yapılandırma onarımı yok). `ocx account priority` ve Codex Auth sayfası tarafından yönetilir. |
| `activeCodexAccountPinned?` | `string` | — | Operatörün en son elle seçtiği hesap kimliği. Ayarlandığı sürece, pin tükenme, hariç tutma, silme veya açık bir yük devretme/yükseltme ile serbest bırakılana kadar daha yüksek bir `codexAccountPriorities` katmanı onu öncelikleyemez. Sınırlı katman içindeki sıradan round-robin hareketi onu serbest bırakmaz. Herhangi bir `codexAccountPriorities` girdisi yazmak da pini serbest bırakır, böylece bir sıra var olmadan önce yapılan bir pin daha sonra ayarlanan bir pinin önüne geçemez. `GET /api/codex-auth/active`, hem geçerli hesabın sabitlenip sabitlenmediğini (`pinned`) hem de tavanı taşıyan hesabı (`pinnedAccountId`) bildirir. |
| `autoSwitchThreshold?` | `number` | `80` | Proaktif geçiş için kullanım eşiği. `quota`, bağımsız görevlerin bir sonraki isteğini yeniden değerlendirebilir ve varsayılan olarak kullanım bu eşiği geçince bağlı görevleri de yeniden değerlendirir. `pool.cacheAffinity` açıkken bağlı bir görev, hesap tükenene veya hizmet veremez hale gelene kadar eşiğin ötesinde hesabını korur. `fill-first` bunu yalnızca bağımsız atama için tükenme noktası olarak kullanır; normal `round-robin` seçimi bunu kullanmaz. Puan, bilinen en sıcak 5 saatlik, haftalık veya 30 günlük kota penceresini kullanır. `0`, yalnızca kullanıma dayalı proaktif geçişi devre dışı bırakır, bağımsız atamayı veya arıza kurtarmayı devre dışı bırakmaz. |
-| `accountPoolStrategy?` | `"quota" \| "round-robin" \| "fill-first"` | `"quota"` | Yeni/bağımsız Codex istekleri için atama stratejisi. Bir istek, canlı (üst iş parçacığı kimliği, kota kapsamı) bağlılığı olmadığında bağımsızdır; görünür mevcut bir görev, proxy yeniden başlatmasından veya bağlılık sıfırlamasından sonra bağımsız hale gelebilir. `quota`, aktif bir hesap olmadığında en düşük kullanımlı uygun hesabı seçer, `autoSwitchThreshold` altında uygun bir aktif hesabı tutar ve eşikten sonra bağımsız bir isteği taşıyabilir. `pool.cacheAffinity` kapalıysa bağlı bir görevi proaktif olarak daha düşük kullanımlı uygun bir hesaba yeniden bağlayabilir. Bayrak açıkken bağlı görev, hesabı tükenene (bilinen kullanım %100) veya hizmet veremez hale gelene kadar kalır. `round-robin`, bağımsız istekleri eşit olarak dağıtır; `fill-first`, soğuma, kullanılamama veya yapılandırılmış tükenme eşiğine kadar bağımsız istekleri aktif hesaba atamaya devam eder. |
+| `accountPoolStrategy?` | `"quota" \| "round-robin" \| "fill-first" \| "reset-first"` | `"quota"` | Yeni/bağımsız Codex istekleri için atama stratejisi. Bir istek, canlı (üst iş parçacığı kimliği, kota kapsamı) bağlılığı olmadığında bağımsızdır; görünür mevcut bir görev, proxy yeniden başlatmasından veya bağlılık sıfırlamasından sonra bağımsız hale gelebilir. `quota`, aktif bir hesap olmadığında en düşük kullanımlı uygun hesabı seçer, `autoSwitchThreshold` altında uygun bir aktif hesabı tutar ve eşikten sonra bağımsız bir isteği taşıyabilir. `pool.cacheAffinity` kapalıysa bağlı bir görevi proaktif olarak daha düşük kullanımlı uygun bir hesaba yeniden bağlayabilir. Bayrak açıkken bağlı görev, hesabı tükenene (bilinen kullanım %100) veya hizmet veremez hale gelene kadar kalır. `round-robin`, bağımsız istekleri eşit olarak dağıtır; `fill-first`, soğuma, kullanılamama veya yapılandırılmış tükenme eşiğine kadar bağımsız istekleri aktif hesaba atamaya devam eder. `reset-first`: Eşiğin altındaki hesaplar arasından sonraki 5 saatlik veya haftalık sıfırlaması en yakın olanı seçer. Bağlı görevler yapılandırılmış bağlılık politikasını izler. Bağımsız model kotaları kullanıma göre sıralanır. Aylık sıfırlamalar bu sıralamayı belirlemez. |
| `pool.cacheAffinity?` | `boolean` | `false` | Bağlı Codex iş parçacıkları için isteğe bağlı önbellek bağlılığı sıralaması; `pool.kernel`'dan bağımsızdır. Varsayılan olarak kapalıdır; hatalı bir değer kapalı okunur. Açıkken canlı bağlama kota payından öndedir: `quota`, kullanımın `autoSwitchThreshold`'u geçmesi nedeniyle iş parçacığını taşımaz. Hesap duraklatılmış, kullanılamaz veya gerçekten tükenmişse (bilinen kullanım %100) iş parçacığı yine ayrılır; bağlılık bir sabitleme değil yeniden sıralamadır. |
| `accountPoolStickyLimit?` | `number` | `1` | İlerlemeden önce bir round-robin seçiminde tutulan yeni/bağımsız görev atamaları; sayaç yukarı akış başarısından sonra değil, bir görev bağlandığında ilerler. Aralık 1–100. |
| `upstreamFailoverThreshold?` | `number` | `3` | Gelecekteki yeni oturumların yük devretmesinden önceki ardışık geçici arızalar. Devre dışı bırakmak için `0` ayarlayın. Düzenli Responses ve yerel sıkıştırma gönderimleri için kanıtlanmış bağlantı öncesi DNS/TCP erişilebilirlik arızaları sağlayıcı-ana bilgisayar düzeyinde izlenir: hesap sağlığını, hesap soğuma sürelerini, iş parçacığı/oturum bağlılığını, aktif hesap seçimini veya Havuz yönlendirmesini asla etkilemez ve bu eşiğe asla sayılmaz. |
diff --git a/docs-site/src/content/docs/tr/reference/management-api.md b/docs-site/src/content/docs/tr/reference/management-api.md
index bfdd83dcd9..354310f01e 100644
--- a/docs-site/src/content/docs/tr/reference/management-api.md
+++ b/docs-site/src/content/docs/tr/reference/management-api.md
@@ -163,6 +163,8 @@ Hedef stratejileri, soğuma süreleri, takma adlar ve yönlendirme hataları iç
| `POST /api/storage/cleanup-policy/run` | Manuel bir temizleme politikası çalıştırması başlatın | 409 `already_running`; 500 `cleanup_failed` |
| `GET /api/storage/cleanup-policy/test-stream` | Yalnızca test amaçlı politika akış kancası | Kullanılamadığında 404 `not_found` |
+Bir satır mevcut ayrıştırıcı boyut sınırını aşarsa `GET /api/usage` ve `GET /api/keys` okunabilir satır toplamlarını korur ve yanıt düzeyinde `usageIncomplete: true` ile `usageIncompleteReason: "oversized_rows"` ekler. Bu tanı, boş veya eşleşmeyen sonuçlar dahil önbellekte ve artımlı eklemelerde korunur; yeniden oluşturma sırasında tekrar hesaplanır. Sağlayıcı, model ve API anahtarı kimlikleri kısaltılmaz. Bayrağın bulunmaması tüm kayıtların geçerli olduğunu kanıtlamaz. Bu bilgi `historyTruncated`, `entriesTruncated` ve token ölçüm kapsamından ayrıdır.
+
`GET /api/usage?range=30d&surface=codex` için `accounts`, gözlemlenen her Codex
havuz etiketi için bir satır içerir. Her satır `accountLogLabel`, belirteç
toplamları, `usageCoverageRatio` ve geçerli olarak yapılandırılmış görüntüleme
diff --git a/docs-site/src/content/docs/zh-cn/guides/codex-integration.md b/docs-site/src/content/docs/zh-cn/guides/codex-integration.md
index 52c32ba2da..b19d35b00c 100644
--- a/docs-site/src/content/docs/zh-cn/guides/codex-integration.md
+++ b/docs-site/src/content/docs/zh-cn/guides/codex-integration.md
@@ -336,8 +336,7 @@ ocx config set codexPool '{"excludedPlans":["free"]}'
这是选择策略,不是封禁。被排除的账号保留凭据、用量历史和线程亲和性,仍显示在账号列表中,也仍可通过 `work/gpt-5.5` 这类显式选择使用。改变的只是自动轮换不再选它,包括它已经是活跃账号或已绑定线程的情况——订阅到期后留下的正是这种状态。
-有两处刻意的限制。主 Codex 账号不会因套餐被排除:仅选择模式的路由不读取受保护的原生凭据而隐去其套餐,覆盖主账号的规则会自相矛盾。另外,当没有未被排除的账号时,被排除的账号仍会应答而不是失败;要彻底停止服务,仍然是暂停全部账号。没有对应的 `minimumPlan`,因为给 ChatGPT 套餐排序需要一个这里并不存在的全序。
-
+主 Codex 账号不受套餐排除策略影响;仅选择模式不会读取受保护的原生凭据。如果所有可用的池账号都被排除,自动选择不返回账号。明确指定账号的路由仍可使用,并继续检查暂停、认证和模型权限。账号卡片与 CLI 将被排除的路由套餐与凭据健康状态分开显示。套餐没有全序关系,因此不提供 `minimumPlan` 设置。
## 恢复原生 Codex
`ocx stop` 会停止 proxy 和已安装的后台服务,然后尝试恢复原生 Codex。OpenCodex 只移除能够确认归属的路由配置;如果无法安全恢复配置文件,会报告恢复未完成。
diff --git a/docs-site/src/content/docs/zh-cn/guides/web-dashboard.md b/docs-site/src/content/docs/zh-cn/guides/web-dashboard.md
index 62eaad0bc2..fe3c9f1f22 100644
--- a/docs-site/src/content/docs/zh-cn/guides/web-dashboard.md
+++ b/docs-site/src/content/docs/zh-cn/guides/web-dashboard.md
@@ -47,6 +47,8 @@ bun run dev:gui
| **Storage** | 只读查看 CODEX_HOME 磁盘占用(会话、归档、数据库、附件)。可选归档清理:预览最旧 N%,默认隔离到 `CODEX_HOME/.trash`,或勾选后永久删除。**自动清理策略**为可选且**默认关闭**(`storageCleanupPolicy.enabled`);可在 Storage 页配置阈值/目标/计划/模式,或点「立即运行」。可在 Storage 页从隔离区恢复(JSONL + 线程)。活动会话保持只读。Codex 锁定最新/活动的 `state_*.sqlite` 时拒绝清理与恢复。 |
| **Stop** | 优雅地停止代理和已安装的后台服务,恢复原生 Codex 并退出(`POST /api/stop`)。在使用任务计划程序后端的 Windows 上,仪表板会拒绝并提示改用 `ocx stop`:任务结束后包装器仍可能重新拉起代理,只有运行在代理之外的 stop 才能在恢复客户端配置前确认这个重启窗口。被拒绝时不会做任何更改。 |
+用量、仪表板、供应商工作区、供应商目录和 API 密钥页面会提示部分记录被排除,即使没有可读取的记录。次数、日期和使用排名仅反映可读取的记录。历史不完整时,无法保存模型的最常用排序;请选择其他排序或修复历史后重试。
+
### 筛选请求日志
Logs 可组合界面、被拦截请求、提供商、完整模型名、状态、时间、速度和会话 ID,筛选当前已加载的日志。选项包含回退尝试;模型匹配忽略大小写及首尾空格,但不做部分匹配。日志中消失的选项恢复为全部。
diff --git a/docs-site/src/content/docs/zh-cn/reference/cli/agents.md b/docs-site/src/content/docs/zh-cn/reference/cli/agents.md
index f141dee649..2153f895c5 100644
--- a/docs-site/src/content/docs/zh-cn/reference/cli/agents.md
+++ b/docs-site/src/content/docs/zh-cn/reference/cli/agents.md
@@ -75,6 +75,8 @@ API key,且绝不会回退到 native alias。启用这组兼容选项前,请
ocx observe usage --range 30d --json
```
+部分用量记录无法计入时,人类可读输出会显示警告,即使没有可读取的记录也是如此。显示的总数仅反映可读取的记录。如果筛选条件没有匹配到可读取的记录,输出将显示警告和提示,而不显示总数行;被跳过的记录可能包含匹配项。`--json` 原样保留响应中的 `usageIncomplete` 诊断及原因。
+
### `ocx debug `
通过正在运行的代理的管理 API 读取或更改运行时调试覆盖项。
diff --git a/docs-site/src/content/docs/zh-cn/reference/cli/providers-accounts.md b/docs-site/src/content/docs/zh-cn/reference/cli/providers-accounts.md
index 9d099c8c1b..59645d6356 100644
--- a/docs-site/src/content/docs/zh-cn/reference/cli/providers-accounts.md
+++ b/docs-site/src/content/docs/zh-cn/reference/cli/providers-accounts.md
@@ -273,6 +273,9 @@ ocx account main doctor [--json]
ocx account main list [--json]
ocx account main register [--json]
ocx account main add
+ocx account main reauth --device [--no-wait] [--json]
+ocx account main reauth status --flow [--json]
+ocx account main reauth cancel --flow [--json]
ocx account main switch --yes [--json]
ocx account main recover [--rollback --yes] [--json]
```
diff --git a/docs-site/src/content/docs/zh-cn/reference/configuration/providers.md b/docs-site/src/content/docs/zh-cn/reference/configuration/providers.md
index 9d0ed2dd76..62e8d4447c 100644
--- a/docs-site/src/content/docs/zh-cn/reference/configuration/providers.md
+++ b/docs-site/src/content/docs/zh-cn/reference/configuration/providers.md
@@ -37,7 +37,7 @@ ocx models provider openrouter on
| `activeCodexAccountId?` | `string` | — | 为下一次请求手动选定的 Pool 账户。选择会清除线程亲和性;进行中的请求会保留捕获到的凭据。 |
| `codexAccountPriorities?` | `Record` | — | Codex pool 各账号的选择顺序:账号 ID → `-100` 到 `100` 的整数,**数值越大越先使用**,未设置即为 `0`。这是顺序边界而非资格边界:选择会把已经合格的账号收窄到仍有 quota 余量的最高 tier,再由 `accountPoolStrategy` 在该 tier 内挑选。只有当某个 tier 的所有成员都超过 `autoSwitchThreshold`、处于 cooldown、被 soft-avoid、已暂停或需要重新认证时,该 tier 才会被跳过;usage 未知不会让 tier 耗尽。顺序不会让不合格的账号变得可选,也不会重新绑定已经绑定账号的 thread。主账号 `__main__` 同样参与排序,因此可以让 Codex Desktop 登录账号最后才被用到。没有任何条目时,行为与以往完全一致。映射格式非法时会打印警告并关闭排序(不会触发 config 修复)。可通过 `ocx account priority` 和 Codex Auth 页面管理。 |
| `autoSwitchThreshold?` | `number` | `80` | 基于用量的主动切换阈值。`quota` 可在下一次请求中重新评估未绑定任务;默认在用量越过该阈值时也会重新评估已绑定任务。开启 `pool.cacheAffinity` 后,已绑定任务在越过阈值后仍会保留账号,直到该账号耗尽或无法继续服务。`fill-first` 仅把它用作未绑定分配的耗尽点;正常 `round-robin` 不使用它。分数取已知 5 小时、周或 30 天 quota window 的最高值。`0` 只关闭基于用量的主动切换,不关闭未绑定任务分配或故障恢复。 |
-| `accountPoolStrategy?` | `"quota" \| "round-robin" \| "fill-first"` | `"quota"` | 新建/未绑定 Codex 请求的分配策略。没有 live `(parent thread id, quota scope)` affinity 的请求属于未绑定;代理重启或 affinity 重置后,已有可见任务也可能未绑定。`quota` 在没有活跃账号时选择已知 usage 最低的合格账号;活跃账号合格且低于 `autoSwitchThreshold` 时继续使用;达到阈值后,可把未绑定请求切换到 usage 更低的合格账号;未开启 `pool.cacheAffinity` 时,也可把已绑定任务的下一次请求切走。开启后,已绑定任务会保留到账号耗尽(已知 usage 为 100%)或无法继续服务。`round-robin` 均匀分配未绑定请求;`fill-first` 在 cooldown、不可用或耗尽阈值前持续分配给活跃账号。 |
+| `accountPoolStrategy?` | `"quota" \| "round-robin" \| "fill-first" \| "reset-first"` | `"quota"` | 新建/未绑定 Codex 请求的分配策略。没有 live `(parent thread id, quota scope)` affinity 的请求属于未绑定;代理重启或 affinity 重置后,已有可见任务也可能未绑定。`quota` 在没有活跃账号时选择已知 usage 最低的合格账号;活跃账号合格且低于 `autoSwitchThreshold` 时继续使用;达到阈值后,可把未绑定请求切换到 usage 更低的合格账号;未开启 `pool.cacheAffinity` 时,也可把已绑定任务的下一次请求切走。开启后,已绑定任务会保留到账号耗尽(已知 usage 为 100%)或无法继续服务。`round-robin` 均匀分配未绑定请求;`fill-first` 在 cooldown、不可用或耗尽阈值前持续分配给活跃账号。 `reset-first`: 在低于用量阈值的账号中,优先选择下次5小时或周额度重置最早的账号。已绑定任务遵循配置的亲和策略。独立模型额度按用量排序。 此排序不使用月额度重置时间。 |
| `pool.cacheAffinity?` | `boolean` | `false` | 已绑定 Codex 线程的可选 cache-affinity 排序,独立于 `pool.kernel`。默认关闭;非法值视为关闭。开启后,live 绑定优先于 quota 余量:`quota` 不会仅因用量越过 `autoSwitchThreshold` 就移动线程。账号暂停、不可用或真正耗尽(已知 usage 为 100%)时仍会离开,因此 affinity 是重排而非钉死。 |
| `accountPoolStickyLimit?` | `number` | `1` | 一次 round-robin 选择在推进前保留的新建/未绑定任务分配数。计数在任务绑定时增加,而不是在上游成功后增加。范围 1–100;仅当 `accountPoolStrategy` 为 `round-robin` 时生效。 |
| `upstreamFailoverThreshold?` | `number` | `3` | 连续发生多少次瞬态故障后,后续新会话会切换到备用上游。设为 `0` 可禁用。对于常规 Responses 和原生 compact 发送,已证明的连接前 DNS/TCP 不可达故障按 provider-host 粒度记录,不影响账户健康、账户冷却、线程/会话亲和性、活动账户选择或 Pool 路由,也不会计入此阈值。 |
diff --git a/docs-site/src/content/docs/zh-cn/reference/management-api.md b/docs-site/src/content/docs/zh-cn/reference/management-api.md
index 84afe751a3..480accdf33 100644
--- a/docs-site/src/content/docs/zh-cn/reference/management-api.md
+++ b/docs-site/src/content/docs/zh-cn/reference/management-api.md
@@ -138,6 +138,8 @@ Authorization: Bearer
| `POST /api/storage/cleanup-policy/run` | 启动一次手动清理策略运行 | 409 `already_running`;500 `cleanup_failed` |
| `GET /api/storage/cleanup-policy/test-stream` | 仅测试用的策略流钩子 | 不可用时返回 404 `not_found` |
+如果某行超过现有解析器的大小限制,`GET /api/usage` 和 `GET /api/keys` 会保留可读取行的汇总,并在响应级别添加 `usageIncomplete: true` 和 `usageIncompleteReason: "oversized_rows"`。缓存和增量追加会保留该诊断,即使结果为空或没有筛选匹配;重建时会重新计算。不会缩短供应商、模型或 API 密钥标识来容纳该行。没有此标记不代表所有记录均有效。它与 `historyTruncated`、`entriesTruncated` 及 token 测量覆盖率相互独立。
+
`models`、`providers` 和 `days[].models` 中的记录也带有 `cacheHitRate`:它表示由提供方提示缓存提供的输入 token 比例,并限制在 `[0, 1]` 范围内。当提供方未报告缓存遥测数据或该记录没有输入 token 时,其值为 `null`,绝不会是 `0`,因为“没有缓存数据”与“实际命中率为 0%”是不同的事实,将两者显示为相同结果的图表会产生误导。
:::caution
diff --git a/docs-site/src/content/docs/zh-tw/guides/codex-integration.md b/docs-site/src/content/docs/zh-tw/guides/codex-integration.md
index 0bf6a37426..38744c8e13 100644
--- a/docs-site/src/content/docs/zh-tw/guides/codex-integration.md
+++ b/docs-site/src/content/docs/zh-tw/guides/codex-integration.md
@@ -343,8 +343,7 @@ ocx config set codexPool '{"excludedPlans":["free"]}'
這是選擇策略,不是封鎖。被排除的帳號保留憑證、用量紀錄與執行緒親和性,仍顯示在帳號清單中,也仍可透過 `work/gpt-5.5` 這類明確選擇使用。改變的只是自動輪換不再挑它,包括它已經是使用中帳號或已綁定執行緒的情況——訂閱到期後留下的正是這種狀態。
-有兩處刻意的限制。主 Codex 帳號不會因方案被排除:僅選擇模式的路由不讀取受保護的原生憑證而隱去其方案,涵蓋主帳號的規則會自相矛盾。此外,當沒有未被排除的帳號時,被排除的帳號仍會回應而不是失敗;要完全停止服務,仍然是暫停所有帳號。沒有對應的 `minimumPlan`,因為為 ChatGPT 方案排序需要一個這裡並不存在的全序。
-
+主 Codex 帳號不受方案排除策略影響;僅選擇模式不會讀取受保護的原生憑證。如果所有可用的池帳號都被排除,自動選取不會回傳帳號。明確指定帳號的路由仍可使用,並繼續檢查暫停、認證及模型權限。帳號卡片與 CLI 將被排除的路由方案與憑證健康狀態分開顯示。方案沒有全序關係,因此不提供 `minimumPlan` 設定。
## 恢復原生 Codex
`ocx stop` 會停止 proxy 與已安裝的背景服務,然後嘗試恢復原生 Codex。OpenCodex 只移除能確認歸屬的路由設定;若無法安全恢復設定檔,會回報恢復未完成。
diff --git a/docs-site/src/content/docs/zh-tw/guides/web-dashboard.md b/docs-site/src/content/docs/zh-tw/guides/web-dashboard.md
index 3b7005fd76..76e7fcecb9 100644
--- a/docs-site/src/content/docs/zh-tw/guides/web-dashboard.md
+++ b/docs-site/src/content/docs/zh-tw/guides/web-dashboard.md
@@ -51,6 +51,8 @@ GUI session 簽發到服務的頁面中,並在到期或代理重啟時靜默
| **Usage / Debug** | 檢視 token usage 覆蓋率與趨勢,或啟用可選的 provider transport 和 usage 提取診斷。 |
| **Stop** | 優雅地停止代理和已安裝的後臺服務,恢復原生 Codex 並退出(`POST /api/stop`)。在使用工作排程器後端的 Windows 上,儀表板會拒絕並提示改用 `ocx stop`:工作結束後包裝程序仍可能重新啟動 Proxy,只有執行在 Proxy 之外的 stop 才能在還原用戶端設定前確認這個重啟視窗。被拒絕時不會做任何變更。 |
+用量、儀表板、供應商工作區、供應商目錄和 API 金鑰頁面會提示部分記錄被排除,即使沒有可讀取的記錄。次數、日期和使用排名僅反映可讀取的記錄。歷史不完整時,無法儲存模型的最常用排序;請選擇其他排序或修復歷史後重試。
+
### 篩選請求日誌
Logs 可組合介面、被攔截請求、供應商、完整模型名稱、狀態、時間、速度和對話 ID,篩選目前已載入的日誌。選項包含回退嘗試;模型比對忽略大小寫及頭尾空白,但不做部分比對。日誌中消失的選項恢復為全部。
diff --git a/docs-site/src/content/docs/zh-tw/reference/cli/agents.md b/docs-site/src/content/docs/zh-tw/reference/cli/agents.md
index d9585b2520..6a724b7d16 100644
--- a/docs-site/src/content/docs/zh-tw/reference/cli/agents.md
+++ b/docs-site/src/content/docs/zh-tw/reference/cli/agents.md
@@ -70,6 +70,8 @@ ocx route combo set reliable --targets ark/model-a:2,openai/gpt-5.5
ocx observe usage --range 30d --json
```
+部分用量記錄無法納入時,人類可讀輸出會顯示警告,即使沒有可讀取的記錄也是如此。顯示的總數僅反映可讀取的記錄。如果篩選條件沒有符合的可讀取記錄,輸出將顯示警告和提示,而不顯示總數列;被略過的記錄可能包含符合項目。`--json` 原樣保留回應中的 `usageIncomplete` 診斷及原因。
+
### `ocx debug `
透過執行中代理的管理 API 讀取或變更執行階段除錯覆寫。
diff --git a/docs-site/src/content/docs/zh-tw/reference/cli/providers-accounts.md b/docs-site/src/content/docs/zh-tw/reference/cli/providers-accounts.md
index 0e3bbe4b78..e1ad7b7653 100644
--- a/docs-site/src/content/docs/zh-tw/reference/cli/providers-accounts.md
+++ b/docs-site/src/content/docs/zh-tw/reference/cli/providers-accounts.md
@@ -222,6 +222,9 @@ ocx account main doctor [--json]
ocx account main list [--json]
ocx account main register [--json]
ocx account main add
+ocx account main reauth --device [--no-wait] [--json]
+ocx account main reauth status --flow [--json]
+ocx account main reauth cancel --flow [--json]
ocx account main switch --yes [--json]
ocx account main recover [--rollback --yes] [--json]
```
diff --git a/docs-site/src/content/docs/zh-tw/reference/configuration/providers.md b/docs-site/src/content/docs/zh-tw/reference/configuration/providers.md
index ff67793990..a5ca056c18 100644
--- a/docs-site/src/content/docs/zh-tw/reference/configuration/providers.md
+++ b/docs-site/src/content/docs/zh-tw/reference/configuration/providers.md
@@ -35,7 +35,7 @@ ocx models provider openrouter on
| `codexAccountNamespaces?` | `Record` | — | 公開模型選擇器命名空間到已儲存 Codex 帳號目標。這會驗證並持久化映射,但不會自行新增 picker 列或變更路由。 |
| `activeCodexAccountId?` | `string` | — | 為下一個請求手動選擇的池帳號。選擇清除執行緒親和性;進行中的請求保留擷取的憑證。 |
| `autoSwitchThreshold?` | `number` | `80` | 主動切換的用量閾值。`quota` 可在下一個請求時重新評估未綁定任務,且預設在用量越過此閾值時也會重新評估綁定任務。開啟 `pool.cacheAffinity` 後,綁定任務在越過閾值後仍會保留帳號,直到該帳號耗盡或無法繼續服務。`fill-first` 僅將其用作未綁定指派的排空點;一般 `round-robin` 選擇不使用它。分數使用最熱的已知 5h、週或 30d 配額視窗。`0` 僅停用基於用量的主動切換,而非未綁定指派或失敗復原。 |
-| `accountPoolStrategy?` | `"quota" \| "round-robin" \| "fill-first"` | `"quota"` | 新/未綁定 Codex 請求的指派策略。當請求沒有即時(父執行緒 id、配額 scope)親和性時即為未綁定;可見的既有任務在代理重啟或親和性重置後可變為未綁定。`quota` 在無現用帳號時選擇最低用量的合格帳號,將合格現用帳號保持在 `autoSwitchThreshold` 以下,且在閾值後可將未綁定請求移至較低用量的合格帳號;未開啟 `pool.cacheAffinity` 時,也可主動重新綁定綁定任務。開啟後,綁定任務會保留到帳號耗盡(已知用量 100%)或無法繼續服務。`round-robin` 均勻分配未綁定請求;`fill-first` 持續將未綁定請求指派到現用帳號直到冷卻、不可用或設定的排空閾值。 |
+| `accountPoolStrategy?` | `"quota" \| "round-robin" \| "fill-first" \| "reset-first"` | `"quota"` | 新/未綁定 Codex 請求的指派策略。當請求沒有即時(父執行緒 id、配額 scope)親和性時即為未綁定;可見的既有任務在代理重啟或親和性重置後可變為未綁定。`quota` 在無現用帳號時選擇最低用量的合格帳號,將合格現用帳號保持在 `autoSwitchThreshold` 以下,且在閾值後可將未綁定請求移至較低用量的合格帳號;未開啟 `pool.cacheAffinity` 時,也可主動重新綁定綁定任務。開啟後,綁定任務會保留到帳號耗盡(已知用量 100%)或無法繼續服務。`round-robin` 均勻分配未綁定請求;`fill-first` 持續將未綁定請求指派到現用帳號直到冷卻、不可用或設定的排空閾值。 `reset-first`: 在低於用量門檻的帳號中,優先選擇下次5小時或週額度重設最早的帳號。已綁定任務遵循設定的親和策略。獨立模型額度按用量排序。 此排序不使用月額度重設時間。 |
| `pool.cacheAffinity?` | `boolean` | `false` | 綁定 Codex 執行緒的選擇性 cache-affinity 排序,獨立於 `pool.kernel`。預設關閉;格式錯誤視為關閉。開啟後,即時綁定優先於配額餘裕:`quota` 不會只因用量越過 `autoSwitchThreshold` 就移動執行緒。帳號暫停、無法使用或真正耗盡(已知用量 100%)時仍會離開,因此親和性是重排而非釘死。 |
| `accountPoolStickyLimit?` | `number` | `1` | 在前進一個 round-robin 選擇前保留的新/未綁定任務指派;計數器在任務綁定時前進,而非在上游成功後。範圍 1–100。 |
| `upstreamFailoverThreshold?` | `number` | `3` | 未來新 session 容錯移轉前的連續暫時性失敗。設 `0` 停用。 |
diff --git a/docs-site/src/content/docs/zh-tw/reference/management-api.md b/docs-site/src/content/docs/zh-tw/reference/management-api.md
index 8ea6cd79dd..4342a698e0 100644
--- a/docs-site/src/content/docs/zh-tw/reference/management-api.md
+++ b/docs-site/src/content/docs/zh-tw/reference/management-api.md
@@ -134,6 +134,8 @@ Session 簽發在需要 data-plane 認證時停用,這包含遠端綁定。遠
| `POST /api/storage/cleanup-policy/run` | 啟動手動清理政策執行 | 409 `already_running`;500 `cleanup_failed` |
| `GET /api/storage/cleanup-policy/test-stream` | 僅測試的政策串流 hook | 不可用時 404 `not_found` |
+如果某行超過現有解析器的大小限制,`GET /api/usage` 和 `GET /api/keys` 會保留可讀取行的彙總,並在回應層級加入 `usageIncomplete: true` 和 `usageIncompleteReason: "oversized_rows"`。快取和增量附加會保留此診斷,即使結果為空或沒有篩選符合項目;重建時會重新計算。不會縮短供應商、模型或 API 金鑰識別碼來容納該行。沒有此標記不代表所有記錄均有效。它與 `historyTruncated`、`entriesTruncated` 及 token 測量覆蓋率相互獨立。
+
`models`、`providers` 及 `days[].models` 中的列也帶有 `cacheHitRate`:表示由供應商提示快取提供的輸入權杖比例,並限制在 `[0, 1]`。當供應商未回報快取遙測資料,或該列沒有輸入權杖時,其值為 `null`,絕不會是 `0`;因為「沒有快取資料」與「確實為 0% 的命中率」是不同事實,若圖表將兩者呈現為相同狀態,便會造成誤導。
:::caution
diff --git a/gui/src/App.tsx b/gui/src/App.tsx
index 6b2e1d4c32..489cb4e7f6 100644
--- a/gui/src/App.tsx
+++ b/gui/src/App.tsx
@@ -10,6 +10,7 @@ import Storage from "./pages/Storage";
import CodexSet from "./pages/CodexSet";
import Integrations from "./pages/Integrations";
import Startup from "./pages/Startup";
+import RemoteWorkspace from "./pages/RemoteWorkspace";
import ErrorBoundary from "./components/ErrorBoundary";
import { SidebarGithubRow } from "./components/sidebar-github-row";
import { IconGrid, IconServer, IconBoxes, IconBot, IconList, IconActivity, IconHardDrive, IconCodex, IconMenu, IconSun, IconMoon, IconMonitor, IconGlobe, IconPower, IconX, IconRefresh} from "./icons";
@@ -35,6 +36,7 @@ const PAGE_TKEY: Record = {
logs: "nav.logs",
usage: "nav.usage",
storage: "nav.storage",
+ remote: "nav.remote",
"codex-set": "nav.codexSet",
integrations: "nav.integrations",
};
@@ -68,6 +70,7 @@ const NAV: NavEntry[] = [
{ id: "logs", tkey: "nav.logs", Icon: IconList },
{ id: "usage", tkey: "nav.usage", Icon: IconActivity },
{ id: "storage", tkey: "nav.storage", Icon: IconHardDrive },
+ { id: "remote", tkey: "nav.remote", Icon: IconMonitor },
{ id: "integrations", tkey: "nav.integrations", Icon: IconGlobe },
];
@@ -447,6 +450,7 @@ export default function App() {
{page === "logs" && }
{page === "usage" && }
{page === "storage" && }
+ {page === "remote" && }
{page === "codex-set" && }
{page === "integrations" && }
>
diff --git a/gui/src/account-pool-strategy.ts b/gui/src/account-pool-strategy.ts
index b2532b0fc7..ef2de0a670 100644
--- a/gui/src/account-pool-strategy.ts
+++ b/gui/src/account-pool-strategy.ts
@@ -1,9 +1,10 @@
-export type AccountPoolStrategy = "quota" | "round-robin" | "fill-first";
+export type AccountPoolStrategy = "quota" | "round-robin" | "fill-first" | "reset-first";
export const ACCOUNT_POOL_STRATEGIES: readonly AccountPoolStrategy[] = [
"quota",
"round-robin",
"fill-first",
+ "reset-first",
] as const;
/** Which cached usage bar the `quota` strategy scores. Mirrors `OcxAccountPoolQuotaWindow`. */
diff --git a/gui/src/app-routing.ts b/gui/src/app-routing.ts
index e2184af3d2..9dbf4d28bc 100644
--- a/gui/src/app-routing.ts
+++ b/gui/src/app-routing.ts
@@ -11,6 +11,7 @@ export type Page =
| "logs"
| "usage"
| "storage"
+ | "remote"
| "codex-set"
| "integrations";
@@ -23,6 +24,7 @@ export const VALID_PAGES = new Set([
"logs",
"usage",
"storage",
+ "remote",
"codex-set",
"integrations",
]);
diff --git a/gui/src/components/AccountPoolStrategyControls.tsx b/gui/src/components/AccountPoolStrategyControls.tsx
index d5023ca43f..2a813bcc4e 100644
--- a/gui/src/components/AccountPoolStrategyControls.tsx
+++ b/gui/src/components/AccountPoolStrategyControls.tsx
@@ -8,12 +8,14 @@ import { NumberStepper } from "./NumberStepper";
import { Select } from "../ui";
const STRATEGY_LABEL_KEYS = {
+ "reset-first": "accountPool.strategyResetFirst",
quota: "accountPool.strategyQuota",
"round-robin": "accountPool.strategyRoundRobin",
"fill-first": "accountPool.strategyFillFirst",
} as const;
const STRATEGY_HINT_KEYS = {
+ "reset-first": "accountPool.strategyHintResetFirst",
quota: "accountPool.strategyHintQuota",
"round-robin": "accountPool.strategyHintRoundRobin",
"fill-first": "accountPool.strategyHintFillFirst",
@@ -21,6 +23,7 @@ const STRATEGY_HINT_KEYS = {
export interface AccountPoolStrategyControlsProps {
strategy: AccountPoolStrategy;
+ codex?: boolean;
stickyDraft: string;
disabled?: boolean;
strategySelectId?: string;
@@ -41,6 +44,7 @@ export interface AccountPoolStrategyControlsProps {
*/
export default function AccountPoolStrategyControls({
strategy,
+ codex = false,
stickyDraft,
disabled = false,
strategySelectId = "account-pool-strategy",
@@ -50,7 +54,7 @@ export default function AccountPoolStrategyControls({
onStickyCommit,
}: AccountPoolStrategyControlsProps) {
const t = useT();
- const strategyOptions = ACCOUNT_POOL_STRATEGIES.map((value) => ({
+ const strategyOptions = ACCOUNT_POOL_STRATEGIES.filter(value => codex || value !== "reset-first").map((value) => ({
value,
label: t(STRATEGY_LABEL_KEYS[value]),
}));
diff --git a/gui/src/components/AddProviderModal.tsx b/gui/src/components/AddProviderModal.tsx
index f19abbb836..5daa45e2cb 100644
--- a/gui/src/components/AddProviderModal.tsx
+++ b/gui/src/components/AddProviderModal.tsx
@@ -1,4 +1,5 @@
-import { usageSummary30dResourceKey } from "../usage-summary-resource";
+import { usageSummary30dResourceKey, type UsageReadMetadata } from "../usage-summary-resource";
+import { UsageIncompleteNotice } from "./usage-incomplete-notice";
import { useEffect, useMemo, useReducer, useRef, useState } from "react";
import { IconX } from "../icons";
import { useT } from "../i18n/shared";
@@ -97,7 +98,7 @@ export default function AddProviderModal({
async (signal) => {
const res = await fetch(`${apiBase}/api/usage?range=30d`, { signal });
if (!res.ok) throw new Error(String(res.status));
- return await res.json() as { providers?: Array<{ provider: string; requests: number }> };
+ return await res.json() as UsageReadMetadata & { providers?: Array<{ provider: string; requests: number }> };
},
{ deadlineMs: 60_000 }, // shared usage-summary key: all four subscribers raise the deadline together
);
@@ -270,6 +271,7 @@ export default function AddProviderModal({
+ {!preset && }
{!preset ? (
(null);
const { beginServerRead, acceptServerRead, rejectServerRead, hydrateServerValue } = autoSwitch;
// A hook cannot be called conditionally, so the fallback instance is always created
@@ -71,6 +72,12 @@ export default function CodexAccountPool({ apiBase, accountModeState = null, ban
const ownController = useCodexAccountPool(apiBase, !injectedController);
const controller = injectedController ?? ownController;
const { accounts, activeId, loadState, switchingId, pauseUpdatingId, priorityUpdatingId, pausingExhausted, activePinnedId, load } = controller;
+ // #3898: the native-main device reauth drives the dedicated namespace; a
+ // completed flow refreshes the account list so the card leaves reauth state.
+ const mainReauth = useMainDeviceReauth(apiBase, () => { void load(); });
+ const mainReauthActive = mainReauth.state.phase === "starting"
+ || mainReauth.state.phase === "pending"
+ || mainReauth.state.phase === "committing";
const [confirm, setConfirm] = useState(null);
const [showAdd, setShowAdd] = useState(false);
const [modelsNotice, setModelsNotice] = useState<{ catalogRefreshPending: boolean } | null>(null);
@@ -172,10 +179,10 @@ export default function CodexAccountPool({ apiBase, accountModeState = null, ban
}, [readLastThreshold, hydrateServerValue]);
useEffect(() => {
- if (!showAdd) return;
+ if (!showAdd && !mainReauthActive) return;
const token = controller.pauseRefresh();
return () => controller.resumeRefresh(token);
- }, [controller, showAdd]);
+ }, [controller, showAdd, mainReauthActive]);
const activePoolAccount = activeId && activeId !== "__main__"
? accounts.find(a => a.id === activeId)
@@ -531,6 +538,7 @@ export default function CodexAccountPool({ apiBase, accountModeState = null, ban
onCopyDoctor={showDoctorCopy ? copyDoctor : undefined}
doctorCopyOutcomeFor={showDoctorCopy ? doctorCopy.outcomeFor : undefined}
onManageMainHardLock={hasMainHardLockSetting ? manageMainHardLock : undefined}
+ mainReauth={mainReauth}
/>
diff --git a/gui/src/components/CodexAutoSwitchSetting.tsx b/gui/src/components/CodexAutoSwitchSetting.tsx
index 76d10825d5..cf1bcfcdf2 100644
--- a/gui/src/components/CodexAutoSwitchSetting.tsx
+++ b/gui/src/components/CodexAutoSwitchSetting.tsx
@@ -7,6 +7,10 @@ import { NumberStepper } from "./NumberStepper";
export type AutoSwitchFeedback = { tone: "ok" | "err"; message: string } | null;
const AUTO_SWITCH_DESCRIPTION_KEYS = {
+ "reset-first": {
+ on: "accountPool.strategyHintResetFirst",
+ off: "codexAuth.autoSwitchQuotaOffDesc",
+ },
quota: {
on: "codexAuth.autoSwitchQuotaDesc",
off: "codexAuth.autoSwitchQuotaOffDesc",
diff --git a/gui/src/components/CodexPoolStrategySetting.tsx b/gui/src/components/CodexPoolStrategySetting.tsx
index e575baed0c..6e44d793ce 100644
--- a/gui/src/components/CodexPoolStrategySetting.tsx
+++ b/gui/src/components/CodexPoolStrategySetting.tsx
@@ -213,6 +213,7 @@ export default function CodexPoolStrategySetting({
)}
{!loadError && (
+
{keysLoading ? (
) : keys.length === 0 ? (
@@ -83,7 +88,7 @@ export default function ApiKeysListPanel({
? "—"
: k.usage.lastUsedAt
? formatCreatedDate(k.usage.lastUsedAt, localeTag)
- : t("api.attribution.neverUsed")}
+ : t(usageMetadata?.usageIncomplete ? "api.attribution.noRecordedUse" : "api.attribution.neverUsed")}
))}
diff --git a/gui/src/components/apikeys-workspace/ApiKeysWorkspace.tsx b/gui/src/components/apikeys-workspace/ApiKeysWorkspace.tsx
index ff885ff616..623e970ea3 100644
--- a/gui/src/components/apikeys-workspace/ApiKeysWorkspace.tsx
+++ b/gui/src/components/apikeys-workspace/ApiKeysWorkspace.tsx
@@ -24,6 +24,8 @@ import {
} from "../../pages/api-keys-panels";
import ClientConfigPanel from "./ClientConfigPanel";
import ApiKeysListPanel from "./ApiKeysListPanel";
+import type { UsageReadMetadata } from "../../usage-summary-resource";
+import { UsageIncompleteNotice } from "../usage-incomplete-notice";
import { DictationPanel, LiveVoicePanel } from "./AudioApiPanel";
export interface ApiKeysWorkspaceProps {
@@ -35,6 +37,7 @@ export interface ApiKeysWorkspaceProps {
* statement from a key whose counters read zero. */
attributionSince?: string;
historyTruncated?: boolean;
+ usageMetadata?: UsageReadMetadata;
authMatrix: ApiAuthMatrixRow[];
keysLoading: boolean;
keysLoadFailed: boolean;
@@ -83,6 +86,7 @@ export default function ApiKeysWorkspace({
active = true,
attributionSince,
historyTruncated,
+ usageMetadata,
authMatrix,
keysLoading,
keysLoadFailed,
@@ -402,6 +406,7 @@ export default function ApiKeysWorkspace({
{t("api.attribution.title")}
+
{/* Branch on the DATASET field, not on `usage`: a key with zero
requests under a live dataset really was used zero times,
which is not the same as having nothing to attribute. */}
@@ -416,17 +421,17 @@ export default function ApiKeysWorkspace({
{selected.usage.requests7d.toLocaleString(localeTag)}
-
{historyTruncated ? t("api.attribution.totalRequestsAvailable") : t("api.attribution.totalRequests")}
+ {historyTruncated || usageMetadata?.usageIncomplete ? t("api.attribution.totalRequestsAvailable") : t("api.attribution.totalRequests")}
{selected.usage.totalRequests.toLocaleString(localeTag)}
{t("api.attribution.lastUsed")}
{selected.usage.lastUsedAt
? formatCreatedDate(selected.usage.lastUsedAt, localeTag)
- : t("api.attribution.neverUsed")}
+ : t(usageMetadata?.usageIncomplete ? "api.attribution.noRecordedUse" : "api.attribution.neverUsed")}
-
{historyTruncated ? t("api.attribution.sinceAvailable") : t("api.attribution.since")}
+ {historyTruncated || usageMetadata?.usageIncomplete ? t("api.attribution.sinceAvailable") : t("api.attribution.since")}
{formatCreatedDate(attributionSince, localeTag)}
@@ -470,6 +475,7 @@ export default function ApiKeysWorkspace({
keysLoading={keysLoading}
keysLoadFailed={keysLoadFailed}
attributionSince={attributionSince}
+ usageMetadata={usageMetadata}
localeTag={localeTag}
busy={mutationPending}
onSelect={id => {
diff --git a/gui/src/components/codex-account-pool-cards.tsx b/gui/src/components/codex-account-pool-cards.tsx
index 619ec2ecf2..72da4b774c 100644
--- a/gui/src/components/codex-account-pool-cards.tsx
+++ b/gui/src/components/codex-account-pool-cards.tsx
@@ -78,6 +78,7 @@ export function CodexAccountPoolCards({
<>
{pool.map(a => {
const healthStatus = a.health?.status;
+ const planExcluded = a.selectionExcludedReason === "plan_excluded";
const showReauth = Boolean(a.needsReauth) || oauthHealthShowsReauth(healthStatus);
const inCooldown = oauthHealthIsCooldown(healthStatus);
const validationPending = a.health?.reason === "validation_pending";
@@ -90,6 +91,11 @@ export function CodexAccountPoolCards({
{a.alias ?? a.email}
{a.plan && {a.plan} }
+ {planExcluded && (
+
+ {t("codexAuth.planExcluded")}
+
+ )}
{a.paused && (
{t("codexAuth.paused")}
@@ -102,13 +108,13 @@ export function CodexAccountPoolCards({
{healthLabel}
)}
{showReauth && !healthLabel && {t("codexAuth.needsReauth")} }
- {isNext(a) && !showReauth && !inCooldown && !validationPending && (
+ {isNext(a) && !planExcluded && !showReauth && !inCooldown && !validationPending && (
{t(accountModeState === "direct" ? "codexAuth.poolPrepared" : "codexAuth.nextSession")}
)}
- {!a.paused && (!isNext(a) || pinnedId !== a.id) && !showReauth && !inCooldown && !validationPending && (
+ {!a.paused && !planExcluded && (!isNext(a) || pinnedId !== a.id) && !showReauth && !inCooldown && !validationPending && (
onSwitch(a)}>
{switchActionLabel}
diff --git a/gui/src/components/codex-account-pool-main-card.tsx b/gui/src/components/codex-account-pool-main-card.tsx
index f90756afe0..9f6f58a882 100644
--- a/gui/src/components/codex-account-pool-main-card.tsx
+++ b/gui/src/components/codex-account-pool-main-card.tsx
@@ -6,6 +6,7 @@ import { CodexPauseToggleLabel, CodexTicketBadge } from "./codex-account-pool-he
import type { CodexAccountEntry } from "./codex-account-pool-types";
import type { CodexAccountModeState } from "../codex-multi-state";
import type { TFn } from "../i18n/shared";
+import type { MainDeviceReauthState } from "./use-main-device-reauth";
import type { NoticeTone } from "../ui";
import { navigateHash } from "../hash-routing";
import {
@@ -37,6 +38,7 @@ export function CodexAccountPoolMainCard({
onCopyDoctor,
doctorCopyOutcomeFor,
onManageMainHardLock,
+ mainReauth,
}: {
t: TFn;
main: CodexAccountEntry | undefined;
@@ -62,6 +64,12 @@ export function CodexAccountPoolMainCard({
onCopyDoctor?: (accountId: string) => void;
doctorCopyOutcomeFor?: (accountId: string) => "copied" | "unavailable" | null;
onManageMainHardLock?: () => void;
+ /** #3898: native-main device reauth flow state and controls (dedicated namespace). */
+ mainReauth?: {
+ state: MainDeviceReauthState;
+ start: () => Promise;
+ cancel: () => Promise;
+ } | undefined;
}) {
const mainFallbackLabel = t("codexAuth.codexApp");
const mainId = main?.id ?? "__main__";
@@ -182,7 +190,47 @@ export function CodexAccountPoolMainCard({
{t("pws.healthCooldownHint")}
)}
{showReauth
- ? {t("codexAuth.mainTokenExpired")}
+ ?
+
{t("codexAuth.mainTokenExpired")}
+ {mainReauth && (mainReauth.state.phase === "idle" || mainReauth.state.phase === "failed") && (
+ <>
+
{ void mainReauth.start(); }}
+ >
+ {t("codexAuth.mainReauthDevice")}
+
+ {mainReauth.state.phase === "failed" && (
+
{t("codexAuth.mainReauthFailed")}: {mainReauth.state.code}
+ )}
+ >
+ )}
+ {mainReauth && mainReauth.state.phase === "starting" && (
+
{t("codexAuth.mainReauthPending")}
+ )}
+ {mainReauth && (mainReauth.state.phase === "pending" || mainReauth.state.phase === "committing") && (
+
+ {mainReauth.state.verificationUrl && (
+ {t("codexAuth.mainReauthOpen")}: {mainReauth.state.verificationUrl}
+ )}
+ {mainReauth.state.deviceCode && (
+ {t("codexAuth.mainReauthCode")}: {mainReauth.state.deviceCode}
+ )}
+ {t("codexAuth.mainReauthPending")}
+ { void mainReauth.cancel(); }}
+ >
+ {t("codexAuth.mainReauthCancel")}
+
+
+ )}
+ {mainReauth && mainReauth.state.phase === "succeeded" && (
+
{t("codexAuth.mainReauthSucceeded")}
+ )}
+
: !inCooldown && <>
{t("pws.quotaUnsupported")} ;
}
+ const failure = quotaMode === "probe" && quotaUnavailable ? parseQuotaFailureCode(quotaFailure) : undefined;
const pending = quotaMode === "probe" && quotaPending === true;
const state = quotaUnavailable ? "unavailable" : pending ? "pending" : quota ? "ready" : quotaMode === "passive" ? "unobserved" : "unknown";
return
- {quotaUnavailable &&
{t("pws.accountQuotaUnavailable")}
}
+ {quotaUnavailable &&
{t(failure ? `pws.quotaFailure.${failure}` : "pws.accountQuotaUnavailable")}
}
{quota || pending ? (
+ quotaUnavailable={account.quotaUnavailable} quotaPending={account.quotaPending} quotaFailure={account.quotaFailure} />
);
@@ -657,7 +657,7 @@ export default function ProviderAuthPanel({
+ quotaUnavailable={entry.quotaUnavailable} quotaPending={entry.quotaPending} quotaFailure={entry.quotaFailure} />
))}
diff --git a/gui/src/components/provider-workspace/ProviderWorkspaceShell.tsx b/gui/src/components/provider-workspace/ProviderWorkspaceShell.tsx
index 73f0aad616..e478656baa 100644
--- a/gui/src/components/provider-workspace/ProviderWorkspaceShell.tsx
+++ b/gui/src/components/provider-workspace/ProviderWorkspaceShell.tsx
@@ -7,7 +7,8 @@
import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react";
import { useKeyedClientResource } from "../../client-resource";
import { createBoundedFetch } from "../../bounded-fetch";
-import { usageSummary30dResourceKey } from "../../usage-summary-resource";
+import { readUsageMetadata, usageSummary30dResourceKey, type UsageReadMetadata } from "../../usage-summary-resource";
+import { UsageIncompleteNotice } from "../usage-incomplete-notice";
import { useT } from "../../i18n/shared";
import { IconFilter, IconSearch, IconBoxes, IconGlobe, IconLock, IconKey, IconTrash } from "../../icons";
import {
@@ -150,6 +151,9 @@ export default function ProviderWorkspaceShell({
const [modelsLoadFailed, setModelsLoadFailed] = useState(false);
const quotasCacheKey = `ocx.providers.quotas.v1:${apiBase}`;
const usageCacheKey = `ocx.providers.usage.v2:${apiBase}`;
+ const [usageMetadata, setUsageMetadata] = useState(() => (
+ readUsageMetadata(readSessionListCache(usageCacheKey))
+ ));
const [usageTotals, setUsageTotals] = useState>(() => (
readSessionListCache<{ totals: Record }>(usageCacheKey)?.totals ?? {}
));
@@ -235,7 +239,9 @@ export default function ProviderWorkspaceShell({
setUsageTotals(byProvider);
const byProviderModels = buildProviderModelUsage(data.models ?? [], byProvider);
setUsageModels(byProviderModels);
- writeSessionListCache(usageCacheKey, { totals: byProvider, models: byProviderModels });
+ const metadata = readUsageMetadata(data);
+ setUsageMetadata(metadata);
+ writeSessionListCache(usageCacheKey, { totals: byProvider, models: byProviderModels, ...metadata });
setUsageLoading(false);
}, 0);
return () => { cancelled = true; window.clearTimeout(timeout); };
@@ -561,6 +567,7 @@ export default function ProviderWorkspaceShell({
+ {!jsonEditor?.open && }
{jsonEditor?.open ? (
{t("usage.incomplete")}
+ : null;
+}
diff --git a/gui/src/components/use-main-device-reauth.ts b/gui/src/components/use-main-device-reauth.ts
new file mode 100644
index 0000000000..9914908c9e
--- /dev/null
+++ b/gui/src/components/use-main-device-reauth.ts
@@ -0,0 +1,186 @@
+import { useCallback, useEffect, useRef, useState } from "react";
+
+/**
+ * Main-card device reauth (#3898 L3): drives the dedicated native-main
+ * namespace /api/codex-auth/main/reauth-device. Deliberately NOT the pool
+ * AddCodexAccountModal/openReauth path — /api/codex-auth/login rejects
+ * __main__ and would write the wrong credential store.
+ *
+ * DTO hygiene: the hook only ever reads flowId, status, verificationUrl,
+ * deviceCode, and the closed failure-code set; token fields are never
+ * accepted even if a payload carried them. The verification URL is
+ * allowlisted to the known device page. Polling owns its flowId: late
+ * responses from a replaced flow are ignored, and nothing persists to
+ * browser storage.
+ */
+
+const DEVICE_VERIFICATION_URL = "https://auth.openai.com/codex/device";
+const POLL_INTERVAL_MS = 2_000;
+const POLL_TICK_TIMEOUT_MS = 10_000;
+
+export type MainDeviceReauthFailureCode =
+ | "identity_mismatch"
+ | "credential_changed"
+ | "native_main_unavailable"
+ | "device_authorization_failed"
+ | "publication_failed"
+ | "reconciliation_failed"
+ | "flow_in_progress"
+ | "request_failed";
+
+export type MainDeviceReauthState =
+ | { phase: "idle" }
+ | { phase: "starting" }
+ | { phase: "pending"; flowId: string; verificationUrl: string; deviceCode: string }
+ | { phase: "committing"; flowId: string; verificationUrl: string; deviceCode: string }
+ | { phase: "succeeded" }
+ | { phase: "cancelled" }
+ | { phase: "failed"; code: MainDeviceReauthFailureCode };
+
+type FlowDto = {
+ flowId?: unknown;
+ status?: unknown;
+ verificationUrl?: unknown;
+ deviceCode?: unknown;
+ code?: unknown;
+ error?: unknown;
+};
+
+const FAILURE_CODES = new Set([
+ "identity_mismatch",
+ "credential_changed",
+ "native_main_unavailable",
+ "device_authorization_failed",
+ "publication_failed",
+ "reconciliation_failed",
+ "flow_in_progress",
+]);
+
+function failureCode(value: unknown): MainDeviceReauthFailureCode {
+ return typeof value === "string" && FAILURE_CODES.has(value as MainDeviceReauthFailureCode)
+ ? value as MainDeviceReauthFailureCode
+ : "request_failed";
+}
+
+function allowedVerificationUrl(value: unknown): string {
+ return typeof value === "string" && value.startsWith(DEVICE_VERIFICATION_URL) ? value : "";
+}
+
+function humanCode(value: unknown): string {
+ return typeof value === "string" && /^[A-Z0-9-]{1,16}$/i.test(value) ? value : "";
+}
+
+export function useMainDeviceReauth(apiBase: string, onCompleted: () => void) {
+ const [state, setState] = useState({ phase: "idle" });
+ const flowRef = useRef(null);
+ const abortRef = useRef(null);
+ const unmountedRef = useRef(false);
+
+ const stopPolling = useCallback(() => {
+ abortRef.current?.abort();
+ abortRef.current = null;
+ }, []);
+
+ const cancel = useCallback(async () => {
+ const flowId = flowRef.current;
+ stopPolling();
+ flowRef.current = null;
+ if (!flowId) {
+ setState({ phase: "idle" });
+ return;
+ }
+ try {
+ await fetch(`${apiBase}/api/codex-auth/main/reauth-device?flowId=${encodeURIComponent(flowId)}`, { method: "DELETE" });
+ } catch { /* best-effort: the flow expires on its own */ }
+ setState({ phase: "cancelled" });
+ }, [apiBase, stopPolling]);
+
+ const start = useCallback(async () => {
+ stopPolling();
+ flowRef.current = null;
+ const ctrl = new AbortController();
+ abortRef.current = ctrl;
+ setState({ phase: "starting" });
+ let flowId: string;
+ try {
+ // Empty body by contract: the route rejects any request keys with 400.
+ const res = await fetch(`${apiBase}/api/codex-auth/main/reauth-device`, { method: "POST", signal: ctrl.signal });
+ if (!res.ok) {
+ const failed = await res.json().catch(() => ({})) as FlowDto;
+ setState({ phase: "failed", code: failureCode(failed.code) });
+ return;
+ }
+ const dto = await res.json().catch(() => ({})) as FlowDto;
+ if (typeof dto.flowId !== "string" || !dto.flowId) {
+ setState({ phase: "failed", code: "request_failed" });
+ return;
+ }
+ flowId = dto.flowId;
+ } catch {
+ if (!ctrl.signal.aborted) setState({ phase: "failed", code: "request_failed" });
+ return;
+ }
+ flowRef.current = flowId;
+ let lastUrl = "";
+ let lastCode = "";
+ // Poll immediately: the start response predates the usercode reply, so the
+ // URL and human code only arrive through status reads.
+ while (!ctrl.signal.aborted) {
+ if (ctrl.signal.aborted || unmountedRef.current || flowRef.current !== flowId) return;
+ try {
+ const res = await fetch(
+ `${apiBase}/api/codex-auth/main/reauth-device?flowId=${encodeURIComponent(flowId)}`,
+ { signal: AbortSignal.any([ctrl.signal, AbortSignal.timeout(POLL_TICK_TIMEOUT_MS)]) },
+ );
+ if (!res.ok) {
+ const failed = await res.json().catch(() => ({})) as FlowDto;
+ setState({ phase: "failed", code: failureCode(failed.code) });
+ return;
+ }
+ const dto = await res.json().catch(() => ({})) as FlowDto;
+ lastUrl = allowedVerificationUrl(dto.verificationUrl) || lastUrl;
+ lastCode = humanCode(dto.deviceCode) || lastCode;
+ if (dto.status === "pending" || dto.status === "committing") {
+ setState({
+ phase: dto.status,
+ flowId,
+ verificationUrl: lastUrl,
+ deviceCode: lastCode,
+ });
+ } else if (dto.status === "succeeded") {
+ flowRef.current = null;
+ setState({ phase: "succeeded" });
+ onCompleted();
+ return;
+ } else if (dto.status === "cancelled") {
+ flowRef.current = null;
+ setState({ phase: "cancelled" });
+ return;
+ } else if (dto.status === "failed") {
+ flowRef.current = null;
+ setState({ phase: "failed", code: failureCode(dto.code) });
+ return;
+ }
+ } catch {
+ if (ctrl.signal.aborted || unmountedRef.current) return;
+ // A tick failure is transient: the service flow keeps its own deadline.
+ }
+ await new Promise(resolve => setTimeout(resolve, POLL_INTERVAL_MS));
+ }
+ }, [apiBase, onCompleted, stopPolling]);
+
+ useEffect(() => {
+ return () => {
+ unmountedRef.current = true;
+ stopPolling();
+ const flowId = flowRef.current;
+ flowRef.current = null;
+ if (flowId) {
+ void fetch(`${apiBase}/api/codex-auth/main/reauth-device?flowId=${encodeURIComponent(flowId)}`, { method: "DELETE" })
+ .catch(() => {});
+ }
+ };
+ }, [apiBase, stopPolling]);
+
+ return { state, start, cancel };
+}
diff --git a/gui/src/hooks/useCodexAccountPool.ts b/gui/src/hooks/useCodexAccountPool.ts
index 2d909b29bd..87aca93743 100644
--- a/gui/src/hooks/useCodexAccountPool.ts
+++ b/gui/src/hooks/useCodexAccountPool.ts
@@ -54,6 +54,8 @@ export interface CodexAccountEntry {
};
mainAccountHardLock?: MainAccountHardLockStatus;
needsReauth?: boolean;
+ selectionExcludedReason?: "plan_excluded";
+ selectionExcludedPlan?: string;
health?: { status: "healthy" | "cooldown" | "reauth_required" | "warning"; reason?: string; until?: string };
healthLabel?: string;
healthSummary?: string;
diff --git a/gui/src/hooks/useProviderAccountPools.ts b/gui/src/hooks/useProviderAccountPools.ts
index 0197a547d5..393727d094 100644
--- a/gui/src/hooks/useProviderAccountPools.ts
+++ b/gui/src/hooks/useProviderAccountPools.ts
@@ -1,3 +1,4 @@
+import { parseQuotaFailureCode } from "../../../src/providers/quota-types";
import { useCallback, useEffect, useMemo, useRef, useState, type MutableRefObject } from "react";
import type { AccountLoadState, AccountQuotaReading } from "../components/provider-workspace/types";
import { createBoundedFetch } from "../bounded-fetch";
@@ -36,7 +37,9 @@ function mergeRosterRows(rows: T[], previous: T[]): T[] {
return mergeQuotaRows(rows, previous, false).map(row => supportsQuotaRead(row) ? {
...row,
quotaPending: prior.get(row.id)?.quotaPending ?? false,
- quotaUnavailable: prior.get(row.id)?.quotaUnavailable ?? false,
+ quotaUnavailable: prior.get(row.id)?.quotaMode === row.quotaMode ? prior.get(row.id)?.quotaUnavailable ?? false : false,
+ quotaFailure: row.quotaMode === "probe" && prior.get(row.id)?.quotaMode === row.quotaMode && prior.get(row.id)?.quotaUnavailable
+ ? parseQuotaFailureCode(prior.get(row.id)?.quotaFailure) : undefined,
} : row);
}
@@ -47,7 +50,7 @@ function mergeLateQuotaRows(rows: T[], enriched: T[]): T[] {
const incoming = byId.get(row.id);
if (!incoming || incoming.quotaMode !== row.quotaMode) return row;
const quota = mergeQuotaRows([incoming], [row], true)[0];
- return { ...row, quota: quota.quota, quotaPending: quota.quotaPending, quotaUnavailable: quota.quotaUnavailable };
+ return { ...row, quota: quota.quota, quotaPending: quota.quotaPending, quotaUnavailable: quota.quotaUnavailable, quotaFailure: quota.quotaFailure };
});
}
@@ -60,7 +63,7 @@ function mergeQuotaRows(rows: T[], previous: T[], enriched:
const supported = supportsQuotaRead(row);
// Legacy/unknown mode must not acquire synthetic flags that would override
// a provider report or imply that a quota probe is supported.
- if (!supported && row.quotaMode !== "unsupported") return { ...row, quotaMode: undefined, quotaPending: undefined };
+ if (!supported && row.quotaMode !== "unsupported") return { ...row, quotaMode: undefined, quotaPending: undefined, quotaFailure: undefined };
// Only surviving credential IDs can retain omitted data. Explicit null is an
// authoritative invalidation, including failed/expired credential readings.
const retain = supported && (!enriched || row.quotaUnavailable === true);
@@ -69,6 +72,8 @@ function mergeQuotaRows(rows: T[], previous: T[], enriched:
quota: row.quotaMode === "unsupported" ? null : row.quota !== undefined ? row.quota : retain ? prior.get(row.id)?.quota : undefined,
quotaPending: !enriched && row.quotaMode === "probe",
quotaUnavailable: enriched ? row.quotaUnavailable === true : false,
+ quotaFailure: enriched && row.quotaMode === "probe" && row.quotaUnavailable === true
+ ? parseQuotaFailureCode(row.quotaFailure) : undefined,
};
});
}
@@ -76,7 +81,7 @@ function mergeQuotaRows(rows: T[], previous: T[], enriched:
function unavailableQuotaRows(rows: T[], attempted?: T[]): T[] {
const attemptedModes = attempted && new Map(attempted.map(row => [row.id, row.quotaMode]));
return rows.map(row => supportsQuotaRead(row) && (!attemptedModes || attemptedModes.get(row.id) === row.quotaMode)
- ? { ...row, quotaUnavailable: true, quotaPending: false }
+ ? { ...row, quotaUnavailable: true, quotaPending: false, quotaFailure: undefined }
: row);
}
diff --git a/gui/src/i18n/de.ts b/gui/src/i18n/de.ts
index 4a7833a0b4..0f4e0ae591 100644
--- a/gui/src/i18n/de.ts
+++ b/gui/src/i18n/de.ts
@@ -5,6 +5,9 @@ import type { TKey } from "./en";
* German i18n catalog, generated from en.ts. Must match the `TKey` set (compile-checked).
*/
export const de: Record = {
+ "usage.incomplete": "Einige Nutzungsdatensätze konnten nicht berücksichtigt werden. Anzahlen, Datumsangaben und Ranglisten beruhen nur auf lesbaren Datensätzen.",
+ "models.pickerOrder.usageIncomplete": "Die Reihenfolge nach Nutzung kann wegen unvollständiger Nutzungsdaten nicht gespeichert werden. Wählen Sie eine andere Reihenfolge oder reparieren Sie zuerst den Verlauf.",
+ "api.attribution.noRecordedUse": "Keine Nutzung in lesbaren Datensätzen",
"audio.dictation": "Diktat",
"audio.liveVoice": "Live-Sprache",
"audio.configured": "Konfiguriert, nicht geprüft",
@@ -1361,6 +1364,8 @@ export const de: Record = {
"codexAuth.pause": "Pausieren",
"codexAuth.resume": "Fortsetzen",
"codexAuth.paused": "PAUSIERT",
+ "codexAuth.planExcluded": "Nicht automatisch gewählt",
+ "codexAuth.planExcludedHint": "Tarif {plan} ist von der automatischen Auswahl ausgeschlossen. Explizite Kontorouten bleiben verfügbar.",
"codexAuth.pauseSucceeded": "{email} ist pausiert",
"codexAuth.resumeSucceeded": "{email} ist wieder im Pool verfügbar",
"codexAuth.pauseFailed": "{email} konnte nicht pausiert werden. Es wurde nichts geändert.",
@@ -1441,6 +1446,8 @@ export const de: Record = {
"accountPool.strategy": "Rotationsstrategie",
"accountPool.strategyDesc": "Wie OpenCodex einer neuen/ungebundenen Aufgabe ein Konto zuweist.",
+ "accountPool.strategyResetFirst": "Nächste Rücksetzung zuerst",
+ "accountPool.strategyHintResetFirst": "Unterhalb der Nutzungsschwelle wird die nächste 5-Stunden- oder Wochenrücksetzung bevorzugt. Gebundene Aufgaben folgen der konfigurierten Affinitätsregel. Unabhängige Modellkontingente werden nach Nutzung geordnet.",
"accountPool.strategyQuota": "Kontingent",
"accountPool.strategyRoundRobin": "Round-Robin",
"accountPool.strategyFillFirst": "Fill-first",
@@ -1509,7 +1516,14 @@ export const de: Record = {
"codexAuth.needsReauth": "Erneut anmelden",
"codexAuth.reauthenticate": "Re-authenticate",
"codexAuth.tokenExpired": "Token abgelaufen — dieses Konto erneut authentifizieren",
- "codexAuth.mainTokenExpired": "Token abgelaufen — erneut über Codex-App-Login anmelden",
+ "codexAuth.mainTokenExpired": "Token abgelaufen — unten mit Gerätecode oder über Codex-App-Login erneut anmelden",
+ "codexAuth.mainReauthSucceeded": "Angemeldet",
+ "codexAuth.mainReauthFailed": "Anmeldung fehlgeschlagen",
+ "codexAuth.mainReauthCancel": "Abbrechen",
+ "codexAuth.mainReauthCode": "Code",
+ "codexAuth.mainReauthOpen": "Öffnen",
+ "codexAuth.mainReauthPending": "Warte auf Anmeldung…",
+ "codexAuth.mainReauthDevice": "Erneut mit Gerätecode anmelden",
"codexAuth.emailCollision": "Dieses Konto entspricht deinem Haupt-Codex-Login. Nutze ein anderes Konto.",
"codexAuth.resetCreditsTitle": "Gutschriften zurücksetzen",
"codexAuth.resetCreditsAvailable": "Du hast {count} Reset-Gutschrift(en) verfügbar.",
@@ -2102,6 +2116,16 @@ export const de: Record = {
"pws.rateLimits": "Limits",
"pws.quotaUnavailable": "Keine Kontingentdaten für diesen Provider.",
"pws.accountQuotaUnavailable": "Ratenlimit-Daten vorübergehend nicht verfügbar; falls vorhanden, werden zuletzt bekannte Werte angezeigt.",
+ "pws.quotaFailure.account_unavailable": "Kontodaten für die Kontingentprüfung sind nicht verfügbar.",
+ "pws.quotaFailure.access_denied": "Der Anbieter verweigert den Zugriff auf Kontingentdaten.",
+ "pws.quotaFailure.rate_limited": "Die Kontingentprüfung wurde durch ein Anfragelimit begrenzt.",
+ "pws.quotaFailure.upstream_error": "Der Anbieter konnte die Kontingentprüfung nicht abschließen.",
+ "pws.quotaFailure.redirect_blocked": "Eine Weiterleitung des Kontingentendpunkts wurde blockiert.",
+ "pws.quotaFailure.destination_blocked": "Die Netzwerkrichtlinie blockiert das Kontingentziel.",
+ "pws.quotaFailure.dns_failed": "Der Hostname des Kontingentservers konnte nicht aufgelöst werden.",
+ "pws.quotaFailure.timeout": "Die Kontingentanfrage hat das Zeitlimit überschritten.",
+ "pws.quotaFailure.transport_error": "Die Verbindung zum Kontingentserver ist fehlgeschlagen.",
+ "pws.quotaFailure.response_unusable": "Der Anbieter lieferte unbrauchbare Kontingentdaten.",
"pws.selected": "Ausgewählt",
"pws.copyModelId": "ID kopieren",
"pws.modelCopied": "Kopiert!",
@@ -2753,4 +2777,65 @@ export const de: Record = {
"models.pickerOrder.saveDraft": "Entwurf speichern",
"models.pickerOrder.reloadDraft": "Neu laden und Entwurf verwerfen",
"models.pickerOrder.catalogRequired": "Modellidentitäten fehlen oder sind mehrdeutig. Laden Sie die Modellseite neu, um den Katalog vor der Bearbeitung zu aktualisieren.",
+ "nav.remote": "Remote-Arbeitsbereich",
+ "remote.title": "Remote-Arbeitsbereich",
+ "remote.subtitle": "Codex, Claude Code oder Pi laufen auf diesem Hub; Dateien, Befehle, Tests und Builds bleiben auf dem ausgewählten Computer.",
+ "remote.loading": "Remote-Arbeitsbereich wird geladen…",
+ "remote.loadFailed": "Remote-Arbeitsbereich konnte nicht geladen werden.",
+ "remote.hubRequired": "Starten Sie den Hub im Hub-Modus mit OCX_REMOTE_WORKSPACE_ENABLED=1, um Remote Workspace zu aktivieren.",
+ "remote.refresh": "Aktualisieren",
+ "remote.addComputer": "Computer hinzufügen",
+ "remote.addComputerHint": "Gib lokal Ordner frei und halte den reinen OCX-Executor mit diesem Hub verbunden.",
+ "remote.createPairing": "Kopplungscode erstellen",
+ "remote.pairingCode": "Einmaliger Kopplungscode",
+ "remote.pairingExpires": "Läuft um {time} ab",
+ "remote.pairingCommand": "Auf dem hinzuzufügenden Computer ausführen",
+ "remote.pairingCommandPosix": "Linux- / macOS-Terminal",
+ "remote.pairingCommandWindows": "Windows PowerShell",
+ "remote.copyCommand": "Befehl kopieren",
+ "remote.copied": "Kopiert",
+ "remote.devices": "Computer",
+ "remote.noDevices": "Noch keine Computer gekoppelt.",
+ "remote.online": "Online",
+ "remote.offline": "Offline",
+ "remote.revoke": "Computer widerrufen",
+ "remote.revokeConfirm": "{name} widerrufen? Aktive Sitzungen auf diesem Computer werden beendet.",
+ "remote.newSession": "Neue Remote-Sitzung",
+ "remote.device": "Computer",
+ "remote.folder": "Arbeitsordner",
+ "remote.runtime": "Coding-Agent",
+ "remote.access": "Workspace-Zugriff",
+ "remote.access.readOnly": "Nur lesen",
+ "remote.access.workspace": "Dateien bearbeiten und Befehle ausführen",
+ "remote.access.workspaceFilesOnly": "Nur Dateien bearbeiten",
+ "remote.unavailable": "Nicht verfügbar",
+ "remote.capability.full": "Dateien + isolierte Befehle",
+ "remote.capability.files": "Nur Dateiwerkzeuge",
+ "remote.runsOnHub": "Modell und Anmeldung bleiben auf diesem Hub",
+ "remote.runsReadOnly": "Dateien können auf diesem Computer nur gelesen werden",
+ "remote.runsFilesCommands": "Dateien, Builds und Befehle laufen hier",
+ "remote.runsFilesOnly": "Dateiwerkzeuge laufen hier; Befehls-Sandbox nicht verfügbar",
+ "remote.execUnavailable": "Dieser Computer kann Dateien bearbeiten, aber Builds und Terminalbefehle sind ohne unterstützte Betriebssystem-Sandbox deaktiviert.",
+ "remote.notResumable": "Diese Sitzung wurde beendet, bevor der Coding-Agent einen dauerhaften Verlauf erstellt hat. Starten Sie eine neue Remote-Sitzung.",
+ "remote.startSession": "Remote-Sitzung starten",
+ "remote.sessionStarted": "Remote-Sitzung ist bereit.",
+ "remote.sessions": "Sitzungen",
+ "remote.noSessions": "Wähle einen Online-Computer, Ordner und Coding-Agenten.",
+ "remote.events": "Aktivität der Remote-Sitzung",
+ "remote.noEvents": "Noch keine Aktivität.",
+ "remote.prompt": "Nachricht",
+ "remote.promptPlaceholder": "Bitte den Hub-Agenten, im ausgewählten Remote-Ordner zu arbeiten…",
+ "remote.send": "Senden",
+ "remote.stop": "Sitzung stoppen",
+ "remote.requestFailed": "Remote-Workspace-Anfrage fehlgeschlagen.",
+ "remote.submissionUnknown": "Der Übermittlungsstatus ist unbekannt. Prüfen Sie die Sitzung, bevor Sie erneut senden.",
+ "remote.status.starting": "Startet",
+ "remote.status.ready": "Bereit",
+ "remote.status.running": "Läuft",
+ "remote.status.waiting": "Executor offline",
+ "remote.status.failed": "Fehlgeschlagen",
+ "remote.status.stopped": "Gestoppt",
+ "remote.event.status": "Status",
+ "remote.event.tool": "Remote-Werkzeug",
+ "remote.event.error": "Fehler",
};
diff --git a/gui/src/i18n/en.ts b/gui/src/i18n/en.ts
index 96e30de557..c892f87f59 100644
--- a/gui/src/i18n/en.ts
+++ b/gui/src/i18n/en.ts
@@ -6,6 +6,9 @@
* `{var}` are plain interpolations.
*/
export const en = {
+ "usage.incomplete": "Some usage records could not be included. Counts, dates, and rankings reflect readable records only.",
+ "models.pickerOrder.usageIncomplete": "Cannot save most-used order because usage history is incomplete. Choose another order or repair the history first.",
+ "api.attribution.noRecordedUse": "No use in readable records",
"audio.dictation": "Dictation",
"audio.liveVoice": "Live Voice",
"audio.configured": "Configured, not verified",
@@ -1314,6 +1317,16 @@ export const en = {
"pws.rateLimits": "Rate limits",
"pws.quotaUnavailable": "No quota data for this provider.",
"pws.accountQuotaUnavailable": "Rate-limit data temporarily unavailable; showing last known values when present.",
+ "pws.quotaFailure.account_unavailable": "Account details are unavailable for this quota check.",
+ "pws.quotaFailure.access_denied": "The provider denied access to quota data.",
+ "pws.quotaFailure.rate_limited": "The provider rate-limited the quota check.",
+ "pws.quotaFailure.upstream_error": "The provider could not complete the quota check.",
+ "pws.quotaFailure.redirect_blocked": "The quota endpoint returned a blocked redirect.",
+ "pws.quotaFailure.destination_blocked": "The quota destination was blocked by network policy.",
+ "pws.quotaFailure.dns_failed": "The quota hostname could not be resolved.",
+ "pws.quotaFailure.timeout": "The quota request timed out.",
+ "pws.quotaFailure.transport_error": "The quota connection failed.",
+ "pws.quotaFailure.response_unusable": "The provider returned unusable quota data.",
"pws.selected": "Selected",
"pws.copyModelId": "Copy ID",
"pws.modelCopied": "Copied!",
@@ -1937,6 +1950,8 @@ export const en = {
"codexAuth.pause": "Pause",
"codexAuth.resume": "Resume",
"codexAuth.paused": "PAUSED",
+ "codexAuth.planExcluded": "Not auto-selected",
+ "codexAuth.planExcludedHint": "Plan {plan} is excluded from automatic selection. Explicit account routes remain available.",
"codexAuth.pauseSucceeded": "{email} is paused",
"codexAuth.resumeSucceeded": "{email} is available to the pool again",
"codexAuth.pauseFailed": "Could not pause {email}. Nothing was changed.",
@@ -2017,6 +2032,8 @@ export const en = {
"accountPool.strategy": "Rotation strategy",
"accountPool.strategyDesc": "How OpenCodex assigns an account to a new/unbound task.",
+ "accountPool.strategyResetFirst": "Soonest reset first",
+ "accountPool.strategyHintResetFirst": "Prefer the nearest future 5-hour or weekly reset among accounts below the usage threshold. Bound tasks follow the configured affinity policy. Independent model quotas use quota ordering.",
"accountPool.strategyQuota": "Quota",
"accountPool.strategyRoundRobin": "Round-robin",
"accountPool.strategyFillFirst": "Fill-first",
@@ -2093,7 +2110,14 @@ export const en = {
"codexAuth.needsReauth": "Re-login",
"codexAuth.reauthenticate": "Re-authenticate",
"codexAuth.tokenExpired": "Token expired — re-authenticate this account",
- "codexAuth.mainTokenExpired": "Token expired — sign in again via Codex App login",
+ "codexAuth.mainTokenExpired": "Token expired — re-login with a device code below, or via Codex App login",
+ "codexAuth.mainReauthSucceeded": "Signed in",
+ "codexAuth.mainReauthFailed": "Re-login failed",
+ "codexAuth.mainReauthCancel": "Cancel",
+ "codexAuth.mainReauthCode": "Code",
+ "codexAuth.mainReauthOpen": "Open",
+ "codexAuth.mainReauthPending": "Waiting for sign-in…",
+ "codexAuth.mainReauthDevice": "Re-login with device code",
"codexAuth.emailCollision": "This account matches your main Codex login. Use a different account.",
"codexAuth.resetCreditsTitle": "Reset Credits",
@@ -2787,6 +2811,67 @@ export const en = {
"models.pickerOrder.saveDraft": "Save draft",
"models.pickerOrder.reloadDraft": "Reload and discard draft",
"models.pickerOrder.catalogRequired": "Model identities are missing or ambiguous. Reload the Models page to refresh its catalog before editing Custom.",
+ "nav.remote": "Remote Workspace",
+ "remote.title": "Remote Workspace",
+ "remote.subtitle": "Run Codex, Claude Code, or Pi from this Hub while files, commands, tests, and builds stay on the computer you select.",
+ "remote.loading": "Loading Remote Workspace…",
+ "remote.loadFailed": "Could not load Remote Workspace.",
+ "remote.hubRequired": "Use Hub mode and start the Hub with OCX_REMOTE_WORKSPACE_ENABLED=1 to enable Remote Workspace.",
+ "remote.refresh": "Refresh",
+ "remote.addComputer": "Add a computer",
+ "remote.addComputerHint": "Approve one or more folders locally, then keep the OCX-only executor connected to this Hub.",
+ "remote.createPairing": "Create pairing code",
+ "remote.pairingCode": "One-time pairing code",
+ "remote.pairingExpires": "Expires at {time}",
+ "remote.pairingCommand": "Run on the computer you are adding",
+ "remote.pairingCommandPosix": "Linux / macOS terminal",
+ "remote.pairingCommandWindows": "Windows PowerShell",
+ "remote.copyCommand": "Copy command",
+ "remote.copied": "Copied",
+ "remote.devices": "Computers",
+ "remote.noDevices": "No computers are paired yet.",
+ "remote.online": "Online",
+ "remote.offline": "Offline",
+ "remote.revoke": "Revoke computer",
+ "remote.revokeConfirm": "Revoke {name}? Active sessions on this computer will stop.",
+ "remote.newSession": "New remote session",
+ "remote.device": "Computer",
+ "remote.folder": "Workspace folder",
+ "remote.runtime": "Coding agent",
+ "remote.access": "Workspace access",
+ "remote.access.readOnly": "Read only",
+ "remote.access.workspace": "Edit files and run commands",
+ "remote.access.workspaceFilesOnly": "Edit files only",
+ "remote.unavailable": "Unavailable",
+ "remote.capability.full": "Files + sandboxed commands",
+ "remote.capability.files": "File tools only",
+ "remote.runsOnHub": "Model and login stay on this Hub",
+ "remote.runsReadOnly": "Files can only be read on this computer",
+ "remote.runsFilesCommands": "Files, builds, and commands run here",
+ "remote.runsFilesOnly": "File tools run here; command sandbox unavailable",
+ "remote.execUnavailable": "This computer can edit files, but builds and terminal commands are disabled because a supported OS sandbox is not available.",
+ "remote.notResumable": "This session stopped before the coding agent created durable history. Start a new remote session.",
+ "remote.startSession": "Start remote session",
+ "remote.sessionStarted": "Remote session is ready.",
+ "remote.sessions": "Sessions",
+ "remote.noSessions": "Choose an online computer, folder, and coding agent to start.",
+ "remote.events": "Remote session activity",
+ "remote.noEvents": "No activity yet.",
+ "remote.prompt": "Message",
+ "remote.promptPlaceholder": "Ask the Hub agent to work inside the selected remote folder…",
+ "remote.send": "Send",
+ "remote.stop": "Stop session",
+ "remote.requestFailed": "Remote Workspace request failed.",
+ "remote.submissionUnknown": "Submission status is unknown. Check the session before sending again.",
+ "remote.status.starting": "Starting",
+ "remote.status.ready": "Ready",
+ "remote.status.running": "Running",
+ "remote.status.waiting": "Executor offline",
+ "remote.status.failed": "Failed",
+ "remote.status.stopped": "Stopped",
+ "remote.event.status": "Status",
+ "remote.event.tool": "Remote tool",
+ "remote.event.error": "Error",
} as const;
export type TKey = keyof typeof en;
diff --git a/gui/src/i18n/fr.ts b/gui/src/i18n/fr.ts
index 0dc86dbaae..4598823f78 100644
--- a/gui/src/i18n/fr.ts
+++ b/gui/src/i18n/fr.ts
@@ -4,6 +4,9 @@ import type { TKey } from "./en";
* French i18n catalog. Must match the `TKey` set.
*/
export const fr: Record = {
+ "usage.incomplete": "Certains enregistrements d’utilisation n’ont pas pu être inclus. Les totaux, dates et classements reposent uniquement sur les enregistrements lisibles.",
+ "models.pickerOrder.usageIncomplete": "Impossible d’enregistrer l’ordre par utilisation : l’historique est incomplet. Choisissez un autre ordre ou réparez d’abord l’historique.",
+ "api.attribution.noRecordedUse": "Aucune utilisation dans les enregistrements lisibles",
"audio.dictation": "Dictée",
"audio.liveVoice": "Voix en direct",
"audio.configured": "Configuré, non vérifié",
@@ -1287,6 +1290,16 @@ export const fr: Record = {
"pws.rateLimits": "Limites de débit",
"pws.quotaUnavailable": "Aucune donnée de quota pour ce fournisseur.",
"pws.accountQuotaUnavailable": "Données de limite de débit temporairement indisponibles ; affichage des dernières valeurs connues, le cas échéant.",
+ "pws.quotaFailure.account_unavailable": "Les données du compte sont indisponibles pour cette vérification.",
+ "pws.quotaFailure.access_denied": "Le fournisseur a refusé l’accès aux données de quota.",
+ "pws.quotaFailure.rate_limited": "Le fournisseur a limité la fréquence des vérifications de quota.",
+ "pws.quotaFailure.upstream_error": "Le fournisseur n’a pas pu vérifier le quota.",
+ "pws.quotaFailure.redirect_blocked": "La redirection du service de quota a été bloquée.",
+ "pws.quotaFailure.destination_blocked": "La politique réseau a bloqué la destination du quota.",
+ "pws.quotaFailure.dns_failed": "Le nom du serveur de quota n’a pas pu être résolu.",
+ "pws.quotaFailure.timeout": "La requête de quota a expiré.",
+ "pws.quotaFailure.transport_error": "La connexion au service de quota a échoué.",
+ "pws.quotaFailure.response_unusable": "Les données de quota reçues sont inutilisables.",
"pws.selected": "Sélectionné",
"pws.copyModelId": "Copier l’ID",
"pws.modelCopied": "Copié !",
@@ -1869,6 +1882,8 @@ export const fr: Record = {
"codexAuth.pause": "Suspendre",
"codexAuth.resume": "Reprendre",
"codexAuth.paused": "SUSPENDU",
+ "codexAuth.planExcluded": "Exclu du choix automatique",
+ "codexAuth.planExcludedHint": "Le forfait {plan} est exclu de la sélection automatique. Les routes explicites vers ce compte restent disponibles.",
"codexAuth.pauseSucceeded": "{email} est suspendu",
"codexAuth.resumeSucceeded": "{email} est de nouveau disponible dans le groupe",
"codexAuth.pauseFailed": "Impossible de suspendre {email}. Aucune modification apportée.",
@@ -1947,6 +1962,8 @@ export const fr: Record = {
"anthropicPool.off": "Désactivé",
"accountPool.strategy": "Stratégie de rotation",
"accountPool.strategyDesc": "Méthode utilisée par OpenCodex pour affecter un compte à une tâche nouvelle/non liée.",
+ "accountPool.strategyResetFirst": "Réinitialisation la plus proche",
+ "accountPool.strategyHintResetFirst": "Parmi les comptes sous le seuil, privilégier le prochain reset de 5 heures ou hebdomadaire. Les tâches liées suivent la politique d’affinité configurée. Les quotas de modèles indépendants suivent l’ordre de consommation.",
"accountPool.strategyQuota": "Quota",
"accountPool.strategyRoundRobin": "Rotation",
"accountPool.strategyFillFirst": "Remplissage prioritaire",
@@ -2015,7 +2032,14 @@ export const fr: Record = {
"codexAuth.needsReauth": "Se reconnecter",
"codexAuth.reauthenticate": "Se réauthentifier",
"codexAuth.tokenExpired": "Jeton expiré — réauthentifiez ce compte",
- "codexAuth.mainTokenExpired": "Jeton expiré — reconnectez-vous depuis l’application Codex",
+ "codexAuth.mainTokenExpired": "Jeton expiré — reconnectez-vous avec un code appareil ci-dessous ou via la connexion Codex App",
+ "codexAuth.mainReauthSucceeded": "Connecté",
+ "codexAuth.mainReauthFailed": "Échec de la reconnexion",
+ "codexAuth.mainReauthCancel": "Annuler",
+ "codexAuth.mainReauthCode": "Code",
+ "codexAuth.mainReauthOpen": "Ouvrir",
+ "codexAuth.mainReauthPending": "En attente de connexion…",
+ "codexAuth.mainReauthDevice": "Reconnectez-vous avec un code appareil",
"codexAuth.emailCollision": "Ce compte correspond à votre connexion Codex principale. Utilisez un autre compte.",
"codexAuth.resetCreditsTitle": "Crédits de réinitialisation",
"codexAuth.resetCreditsAvailable": "Vous disposez de {count} crédit(s) de réinitialisation.",
@@ -2741,4 +2765,65 @@ export const fr: Record = {
"models.pickerOrder.saveDraft": "Enregistrer le brouillon",
"models.pickerOrder.reloadDraft": "Recharger et supprimer le brouillon",
"models.pickerOrder.catalogRequired": "Les identités des modèles sont manquantes ou ambiguës. Rechargez la page Modèles pour actualiser le catalogue avant de personnaliser l’ordre.",
+ "nav.remote": "Espace distant",
+ "remote.title": "Espace de travail distant",
+ "remote.subtitle": "Codex, Claude Code ou Pi s'exécutent sur ce Hub tandis que fichiers, commandes, tests et builds restent sur l'ordinateur choisi.",
+ "remote.loading": "Chargement de l'espace distant…",
+ "remote.loadFailed": "Impossible de charger l'espace distant.",
+ "remote.hubRequired": "Démarrez le Hub en mode Hub avec OCX_REMOTE_WORKSPACE_ENABLED=1 pour activer Remote Workspace.",
+ "remote.refresh": "Actualiser",
+ "remote.addComputer": "Ajouter un ordinateur",
+ "remote.addComputerHint": "Autorisez localement un ou plusieurs dossiers, puis gardez l'exécuteur OCX connecté à ce Hub.",
+ "remote.createPairing": "Créer un code d'association",
+ "remote.pairingCode": "Code d'association à usage unique",
+ "remote.pairingExpires": "Expire à {time}",
+ "remote.pairingCommand": "À exécuter sur l'ordinateur à ajouter",
+ "remote.pairingCommandPosix": "Terminal Linux / macOS",
+ "remote.pairingCommandWindows": "Windows PowerShell",
+ "remote.copyCommand": "Copier la commande",
+ "remote.copied": "Copié",
+ "remote.devices": "Ordinateurs",
+ "remote.noDevices": "Aucun ordinateur associé.",
+ "remote.online": "En ligne",
+ "remote.offline": "Hors ligne",
+ "remote.revoke": "Révoquer l'ordinateur",
+ "remote.revokeConfirm": "Révoquer {name} ? Ses sessions actives seront arrêtées.",
+ "remote.newSession": "Nouvelle session distante",
+ "remote.device": "Ordinateur",
+ "remote.folder": "Dossier de travail",
+ "remote.runtime": "Agent de code",
+ "remote.access": "Accès à l’espace de travail",
+ "remote.access.readOnly": "Lecture seule",
+ "remote.access.workspace": "Modifier les fichiers et exécuter des commandes",
+ "remote.access.workspaceFilesOnly": "Modifier uniquement les fichiers",
+ "remote.unavailable": "Indisponible",
+ "remote.capability.full": "Fichiers + commandes isolées",
+ "remote.capability.files": "Outils de fichiers uniquement",
+ "remote.runsOnHub": "Le modèle et la connexion restent sur ce Hub",
+ "remote.runsReadOnly": "Les fichiers de cet ordinateur sont accessibles en lecture seule",
+ "remote.runsFilesCommands": "Les fichiers, builds et commandes s’exécutent ici",
+ "remote.runsFilesOnly": "Les outils de fichiers s’exécutent ici ; bac à sable indisponible",
+ "remote.execUnavailable": "Cet ordinateur peut modifier les fichiers, mais les builds et commandes de terminal sont désactivés faute de bac à sable système pris en charge.",
+ "remote.notResumable": "Cette session s’est arrêtée avant que l’agent de code ne crée un historique durable. Démarrez une nouvelle session distante.",
+ "remote.startSession": "Démarrer la session distante",
+ "remote.sessionStarted": "La session distante est prête.",
+ "remote.sessions": "Sessions",
+ "remote.noSessions": "Choisissez un ordinateur en ligne, un dossier et un agent de code.",
+ "remote.events": "Activité de la session distante",
+ "remote.noEvents": "Aucune activité pour le moment.",
+ "remote.prompt": "Message",
+ "remote.promptPlaceholder": "Demandez à l'agent du Hub de travailler dans le dossier distant choisi…",
+ "remote.send": "Envoyer",
+ "remote.stop": "Arrêter la session",
+ "remote.requestFailed": "La requête d'espace distant a échoué.",
+ "remote.submissionUnknown": "Le statut de l’envoi est inconnu. Vérifiez la session avant de renvoyer.",
+ "remote.status.starting": "Démarrage",
+ "remote.status.ready": "Prêt",
+ "remote.status.running": "En cours",
+ "remote.status.waiting": "Exécuteur hors ligne",
+ "remote.status.failed": "Échec",
+ "remote.status.stopped": "Arrêté",
+ "remote.event.status": "État",
+ "remote.event.tool": "Outil distant",
+ "remote.event.error": "Erreur",
};
diff --git a/gui/src/i18n/ja.ts b/gui/src/i18n/ja.ts
index 97b5dff281..f9c1ff9102 100644
--- a/gui/src/i18n/ja.ts
+++ b/gui/src/i18n/ja.ts
@@ -4,6 +4,9 @@ import type { TKey } from "./en";
* Japanese i18n catalog; must match the `TKey` set (compile-checked).
*/
export const ja: Record = {
+ "usage.incomplete": "一部の使用履歴を集計できませんでした。回数、日付、順位は読み取れる記録のみを反映しています。",
+ "models.pickerOrder.usageIncomplete": "使用履歴が不完全なため、使用回数順を保存できません。別の順序を選ぶか、履歴を修復してください。",
+ "api.attribution.noRecordedUse": "読み取れる記録に使用履歴なし",
"audio.dictation": "音声入力",
"audio.liveVoice": "リアルタイム音声",
"audio.configured": "設定済み・接続未確認",
@@ -1212,6 +1215,16 @@ export const ja: Record = {
"pws.rateLimits": "レート制限",
"pws.quotaUnavailable": "このプロバイダーのクォータデータがありません。",
"pws.accountQuotaUnavailable": "レート制限データを一時的に取得できません。前回の値がある場合はそれを表示します。",
+ "pws.quotaFailure.account_unavailable": "割り当て確認に必要なアカウント情報を利用できません。",
+ "pws.quotaFailure.access_denied": "プロバイダーが割り当てデータへのアクセスを拒否しました。",
+ "pws.quotaFailure.rate_limited": "割り当て確認のレート制限に達しました。",
+ "pws.quotaFailure.upstream_error": "プロバイダーが割り当て確認を完了できませんでした。",
+ "pws.quotaFailure.redirect_blocked": "割り当てエンドポイントのリダイレクトをブロックしました。",
+ "pws.quotaFailure.destination_blocked": "ネットワークポリシーが割り当ての接続先をブロックしました。",
+ "pws.quotaFailure.dns_failed": "割り当てサーバーのホスト名を解決できませんでした。",
+ "pws.quotaFailure.timeout": "割り当てリクエストがタイムアウトしました。",
+ "pws.quotaFailure.transport_error": "割り当てサーバーへの接続に失敗しました。",
+ "pws.quotaFailure.response_unusable": "割り当てデータを読み取れませんでした。",
"pws.selected": "選択中",
"pws.copyModelId": "ID をコピー",
"pws.modelCopied": "コピーしました!",
@@ -1794,6 +1807,8 @@ export const ja: Record = {
"codexAuth.pause": "一時停止",
"codexAuth.resume": "再開",
"codexAuth.paused": "一時停止中",
+ "codexAuth.planExcluded": "自動選択の対象外",
+ "codexAuth.planExcludedHint": "プラン {plan} は自動選択の対象外です。アカウントを明示的に指定すると利用できます。",
"codexAuth.pauseSucceeded": "{email} を一時停止しました",
"codexAuth.resumeSucceeded": "{email} をアカウントプールに戻しました",
"codexAuth.pauseFailed": "{email} を一時停止できませんでした。変更はありません。",
@@ -1874,6 +1889,8 @@ export const ja: Record = {
"accountPool.strategy": "ローテーション戦略",
"accountPool.strategyDesc": "OpenCodex が新規/未紐付けタスクへアカウントを割り当てる方法です。",
+ "accountPool.strategyResetFirst": "リセットが近い順",
+ "accountPool.strategyHintResetFirst": "使用率のしきい値未満から、次の5時間枠または週次枠のリセットが最も近いアカウントを選びます。紐付け済みタスクは設定されたアフィニティ方針に従います。独立したモデル枠は使用率順です。",
"accountPool.strategyQuota": "クォータ",
"accountPool.strategyRoundRobin": "ラウンドロビン",
"accountPool.strategyFillFirst": "フィルファースト",
@@ -1944,7 +1961,14 @@ export const ja: Record = {
"codexAuth.needsReauth": "再ログイン",
"codexAuth.reauthenticate": "再認証",
"codexAuth.tokenExpired": "トークンが期限切れ — このアカウントを再認証してください",
- "codexAuth.mainTokenExpired": "トークンが期限切れ — Codex アプリログインから再度サインインしてください",
+ "codexAuth.mainTokenExpired": "トークンの有効期限切れ — 下のデバイスコードまたは Codex アプリログインで再ログインしてください",
+ "codexAuth.mainReauthSucceeded": "サインインしました",
+ "codexAuth.mainReauthFailed": "再ログインに失敗しました",
+ "codexAuth.mainReauthCancel": "キャンセル",
+ "codexAuth.mainReauthCode": "コード",
+ "codexAuth.mainReauthOpen": "開く",
+ "codexAuth.mainReauthPending": "サインインを待っています…",
+ "codexAuth.mainReauthDevice": "デバイスコードで再ログイン",
"codexAuth.emailCollision": "このアカウントはメインの Codex ログインと一致します。別のアカウントを使用してください。",
"codexAuth.resetCreditsTitle": "リセットクレジット",
@@ -2774,4 +2798,65 @@ export const ja: Record = {
"models.pickerOrder.saveDraft": "下書きを保存",
"models.pickerOrder.reloadDraft": "下書きを破棄して再読み込み",
"models.pickerOrder.catalogRequired": "モデルの識別情報が不足しているか曖昧です。モデルページを再読み込みしてカタログを更新してからカスタム順序を編集してください。",
+ "nav.remote": "リモートワークスペース",
+ "remote.title": "リモートワークスペース",
+ "remote.subtitle": "Codex、Claude Code、Pi はこの Hub で実行し、ファイル、コマンド、テスト、ビルドは選択したコンピューターで処理します。",
+ "remote.loading": "リモートワークスペースを読み込み中…",
+ "remote.loadFailed": "リモートワークスペースを読み込めませんでした。",
+ "remote.hubRequired": "Hub モードで OCX_REMOTE_WORKSPACE_ENABLED=1 を設定して Hub を起動すると、Remote Workspace を有効にできます。",
+ "remote.refresh": "更新",
+ "remote.addComputer": "コンピューターを追加",
+ "remote.addComputerHint": "ローカルでフォルダーを承認し、OCX 専用エグゼキューターをこの Hub に接続したままにします。",
+ "remote.createPairing": "ペアリングコードを作成",
+ "remote.pairingCode": "ワンタイムペアリングコード",
+ "remote.pairingExpires": "{time} に期限切れ",
+ "remote.pairingCommand": "追加するコンピューターで実行",
+ "remote.pairingCommandPosix": "Linux / macOS ターミナル",
+ "remote.pairingCommandWindows": "Windows PowerShell",
+ "remote.copyCommand": "コマンドをコピー",
+ "remote.copied": "コピー済み",
+ "remote.devices": "コンピューター",
+ "remote.noDevices": "ペアリング済みのコンピューターはありません。",
+ "remote.online": "オンライン",
+ "remote.offline": "オフライン",
+ "remote.revoke": "コンピューターを解除",
+ "remote.revokeConfirm": "{name} を解除しますか?このコンピューターの実行中セッションは停止します。",
+ "remote.newSession": "新しいリモートセッション",
+ "remote.device": "コンピューター",
+ "remote.folder": "ワークスペースフォルダー",
+ "remote.runtime": "コーディングエージェント",
+ "remote.access": "ワークスペース権限",
+ "remote.access.readOnly": "読み取り専用",
+ "remote.access.workspace": "ファイル編集とコマンド実行",
+ "remote.access.workspaceFilesOnly": "ファイル編集のみ",
+ "remote.unavailable": "利用不可",
+ "remote.capability.full": "ファイル + 分離されたコマンド",
+ "remote.capability.files": "ファイルツールのみ",
+ "remote.runsOnHub": "モデルとログインはこの Hub に保持",
+ "remote.runsReadOnly": "このコンピューターのファイルは読み取りのみ",
+ "remote.runsFilesCommands": "ファイル、ビルド、コマンドはここで実行",
+ "remote.runsFilesOnly": "ファイルツールのみここで実行、コマンド分離は未対応",
+ "remote.execUnavailable": "このコンピューターではファイル編集はできますが、対応する OS サンドボックスがないためビルドとターミナルコマンドは無効です。",
+ "remote.notResumable": "コーディングエージェントが永続的な履歴を作成する前にセッションが停止しました。新しいリモートセッションを開始してください。",
+ "remote.startSession": "リモートセッションを開始",
+ "remote.sessionStarted": "リモートセッションの準備ができました。",
+ "remote.sessions": "セッション",
+ "remote.noSessions": "オンラインのコンピューター、フォルダー、エージェントを選択してください。",
+ "remote.events": "リモートセッションのアクティビティ",
+ "remote.noEvents": "まだアクティビティはありません。",
+ "remote.prompt": "メッセージ",
+ "remote.promptPlaceholder": "選択したリモートフォルダーでの作業を Hub エージェントに依頼…",
+ "remote.send": "送信",
+ "remote.stop": "セッションを停止",
+ "remote.requestFailed": "リモートワークスペースの要求に失敗しました。",
+ "remote.submissionUnknown": "送信状況を確認できません。再送信する前にセッションを確認してください。",
+ "remote.status.starting": "開始中",
+ "remote.status.ready": "準備完了",
+ "remote.status.running": "実行中",
+ "remote.status.waiting": "エグゼキューターがオフライン",
+ "remote.status.failed": "失敗",
+ "remote.status.stopped": "停止済み",
+ "remote.event.status": "状態",
+ "remote.event.tool": "リモートツール",
+ "remote.event.error": "エラー",
};
diff --git a/gui/src/i18n/ko.ts b/gui/src/i18n/ko.ts
index 0957274d6b..6ead1fb54c 100644
--- a/gui/src/i18n/ko.ts
+++ b/gui/src/i18n/ko.ts
@@ -4,6 +4,9 @@ import type { TKey } from "./en";
* Korean i18n catalog; must match the `TKey` set (compile-checked).
*/
export const ko: Record = {
+ "usage.incomplete": "일부 사용량 기록을 집계하지 못했습니다. 횟수, 날짜, 순위는 읽을 수 있는 기록만 반영합니다.",
+ "models.pickerOrder.usageIncomplete": "사용량 이력이 불완전해 많이 사용한 순서를 저장할 수 없습니다. 다른 순서를 선택하거나 이력을 복구하세요.",
+ "api.attribution.noRecordedUse": "읽을 수 있는 기록에 사용 내역 없음",
"audio.dictation": "받아쓰기",
"audio.liveVoice": "실시간 음성",
"audio.configured": "설정됨 · 연결 미확인",
@@ -1397,6 +1400,8 @@ export const ko: Record = {
"codexAuth.pause": "일시 중지",
"codexAuth.resume": "재개",
"codexAuth.paused": "일시 중지됨",
+ "codexAuth.planExcluded": "자동 선택 제외",
+ "codexAuth.planExcludedHint": "{plan} 플랜은 자동 선택에서 제외됩니다. 계정을 직접 지정하면 사용할 수 있습니다.",
"codexAuth.pauseSucceeded": "{email} 계정을 일시 중지했습니다",
"codexAuth.resumeSucceeded": "{email} 계정을 풀에서 다시 사용할 수 있습니다",
"codexAuth.pauseFailed": "{email} 계정을 일시 중지하지 못했습니다. 변경 사항이 없습니다.",
@@ -1477,6 +1482,8 @@ export const ko: Record = {
"accountPool.strategy": "로테이션 전략",
"accountPool.strategyDesc": "OpenCodex가 새 작업/바인딩 없는 작업에 계정을 배정하는 방식입니다.",
+ "accountPool.strategyResetFirst": "가장 가까운 초기화 우선",
+ "accountPool.strategyHintResetFirst": "사용량 임계값 미만인 계정 중 다음 5시간·주간 초기화가 가장 가까운 계정을 고릅니다. 연결된 작업은 설정된 어피니티 정책을 따릅니다. 독립 모델 한도에는 사용량 순서를 적용합니다.",
"accountPool.strategyQuota": "할당량",
"accountPool.strategyRoundRobin": "라운드로빈",
"accountPool.strategyFillFirst": "필 퍼스트",
@@ -1545,7 +1552,14 @@ export const ko: Record = {
"codexAuth.needsReauth": "재로그인",
"codexAuth.reauthenticate": "Re-authenticate",
"codexAuth.tokenExpired": "토큰 만료 — 이 계정을 다시 인증하세요",
- "codexAuth.mainTokenExpired": "토큰 만료 — Codex 앱 로그인으로 다시 로그인하세요",
+ "codexAuth.mainTokenExpired": "토큰 만료 — 아래 디바이스 코드 또는 Codex 앱 로그인으로 다시 로그인하세요",
+ "codexAuth.mainReauthSucceeded": "로그인됨",
+ "codexAuth.mainReauthFailed": "다시 로그인 실패",
+ "codexAuth.mainReauthCancel": "취소",
+ "codexAuth.mainReauthCode": "코드",
+ "codexAuth.mainReauthOpen": "열기",
+ "codexAuth.mainReauthPending": "로그인을 기다리는 중…",
+ "codexAuth.mainReauthDevice": "디바이스 코드로 다시 로그인",
"codexAuth.emailCollision": "이 계정은 메인 Codex 로그인과 동일합니다. 다른 계정을 사용하세요.",
"codexAuth.resetCreditsTitle": "리셋 크레딧",
@@ -2141,6 +2155,16 @@ export const ko: Record = {
"pws.rateLimits": "요청 한도",
"pws.quotaUnavailable": "이 프로바이더의 쿼터 데이터가 없습니다.",
"pws.accountQuotaUnavailable": "요금 한도 데이터를 일시적으로 가져올 수 없습니다. 이전 값이 있으면 그대로 표시합니다.",
+ "pws.quotaFailure.account_unavailable": "쿼터 조회에 필요한 계정 정보를 사용할 수 없습니다.",
+ "pws.quotaFailure.access_denied": "제공자가 쿼터 데이터 접근을 거부했습니다.",
+ "pws.quotaFailure.rate_limited": "제공자의 쿼터 조회 요청 한도에 도달했습니다.",
+ "pws.quotaFailure.upstream_error": "제공자가 쿼터 조회를 처리하지 못했습니다.",
+ "pws.quotaFailure.redirect_blocked": "쿼터 주소의 리디렉션이 차단됐습니다.",
+ "pws.quotaFailure.destination_blocked": "네트워크 정책이 쿼터 조회 목적지를 차단했습니다.",
+ "pws.quotaFailure.dns_failed": "쿼터 서버의 호스트 이름을 확인하지 못했습니다.",
+ "pws.quotaFailure.timeout": "쿼터 조회 시간이 초과됐습니다.",
+ "pws.quotaFailure.transport_error": "쿼터 서버에 연결하지 못했습니다.",
+ "pws.quotaFailure.response_unusable": "제공자가 반환한 쿼터 데이터를 읽을 수 없습니다.",
"pws.selected": "선택됨",
"pws.copyModelId": "ID 복사",
"pws.modelCopied": "복사됨!",
@@ -2775,4 +2799,65 @@ export const ko: Record = {
"models.pickerOrder.saveDraft": "초안 저장",
"models.pickerOrder.reloadDraft": "초안 버리고 다시 불러오기",
"models.pickerOrder.catalogRequired": "모델 식별 정보가 없거나 모호합니다. 모델 페이지를 새로고침해 목록을 갱신한 뒤 사용자 지정 순서를 편집하세요.",
+ "nav.remote": "원격 워크스페이스",
+ "remote.title": "원격 워크스페이스",
+ "remote.subtitle": "Codex, Claude Code, Pi는 이 Hub에서 실행하고 파일·명령·테스트·빌드는 선택한 컴퓨터에서 처리합니다.",
+ "remote.loading": "원격 워크스페이스 불러오는 중…",
+ "remote.loadFailed": "원격 워크스페이스를 불러오지 못했습니다.",
+ "remote.hubRequired": "허브 모드에서 OCX_REMOTE_WORKSPACE_ENABLED=1로 허브를 시작하면 원격 작업 공간을 사용할 수 있습니다.",
+ "remote.refresh": "새로고침",
+ "remote.addComputer": "컴퓨터 추가",
+ "remote.addComputerHint": "추가할 컴퓨터에서 폴더를 승인하고 OCX 전용 실행기를 이 Hub에 계속 연결하세요.",
+ "remote.createPairing": "페어링 코드 만들기",
+ "remote.pairingCode": "일회용 페어링 코드",
+ "remote.pairingExpires": "{time}에 만료",
+ "remote.pairingCommand": "추가할 컴퓨터에서 실행",
+ "remote.pairingCommandPosix": "Linux / macOS 터미널",
+ "remote.pairingCommandWindows": "Windows PowerShell",
+ "remote.copyCommand": "명령어 복사",
+ "remote.copied": "복사됨",
+ "remote.devices": "컴퓨터",
+ "remote.noDevices": "아직 페어링된 컴퓨터가 없습니다.",
+ "remote.online": "온라인",
+ "remote.offline": "오프라인",
+ "remote.revoke": "컴퓨터 연결 해제",
+ "remote.revokeConfirm": "{name} 연결을 해제할까요? 이 컴퓨터의 활성 세션이 중지됩니다.",
+ "remote.newSession": "새 원격 세션",
+ "remote.device": "컴퓨터",
+ "remote.folder": "워크스페이스 폴더",
+ "remote.runtime": "코딩 에이전트",
+ "remote.access": "워크스페이스 권한",
+ "remote.access.readOnly": "읽기 전용",
+ "remote.access.workspace": "파일 편집 및 명령 실행",
+ "remote.access.workspaceFilesOnly": "파일 편집만",
+ "remote.unavailable": "사용 불가",
+ "remote.capability.full": "파일 + 격리된 명령 실행",
+ "remote.capability.files": "파일 도구만 지원",
+ "remote.runsOnHub": "모델과 로그인은 이 Hub에서 유지",
+ "remote.runsReadOnly": "이 컴퓨터의 파일은 읽기만 가능",
+ "remote.runsFilesCommands": "파일, 빌드, 명령은 이 컴퓨터에서 실행",
+ "remote.runsFilesOnly": "파일 도구만 이 컴퓨터에서 실행, 명령 격리 미지원",
+ "remote.execUnavailable": "이 컴퓨터의 파일은 편집할 수 있지만, 지원되는 OS 격리 기능이 없어 빌드와 터미널 명령은 비활성화됩니다.",
+ "remote.notResumable": "코딩 에이전트가 세션 기록을 만들기 전에 중단되었습니다. 새 원격 세션을 시작하세요.",
+ "remote.startSession": "원격 세션 시작",
+ "remote.sessionStarted": "원격 세션이 준비되었습니다.",
+ "remote.sessions": "세션",
+ "remote.noSessions": "온라인 컴퓨터, 폴더, 코딩 에이전트를 선택해 시작하세요.",
+ "remote.events": "원격 세션 활동",
+ "remote.noEvents": "아직 활동이 없습니다.",
+ "remote.prompt": "메시지",
+ "remote.promptPlaceholder": "Hub 에이전트에게 선택한 원격 폴더에서 작업을 요청하세요…",
+ "remote.send": "보내기",
+ "remote.stop": "세션 중지",
+ "remote.requestFailed": "원격 워크스페이스 요청에 실패했습니다.",
+ "remote.submissionUnknown": "전송 여부를 확인하지 못했습니다. 다시 보내기 전에 세션 상태를 확인하세요.",
+ "remote.status.starting": "시작 중",
+ "remote.status.ready": "준비됨",
+ "remote.status.running": "실행 중",
+ "remote.status.waiting": "실행기 오프라인",
+ "remote.status.failed": "실패",
+ "remote.status.stopped": "중지됨",
+ "remote.event.status": "상태",
+ "remote.event.tool": "원격 도구",
+ "remote.event.error": "오류",
};
diff --git a/gui/src/i18n/ru.ts b/gui/src/i18n/ru.ts
index 464c71d7ac..aa093090c2 100644
--- a/gui/src/i18n/ru.ts
+++ b/gui/src/i18n/ru.ts
@@ -4,6 +4,9 @@ import type { TKey } from "./en";
* Russian i18n catalog; must match the `TKey` set (compile-checked).
*/
export const ru: Record = {
+ "usage.incomplete": "Часть записей об использовании не удалось учесть. Счётчики, даты и рейтинги основаны только на читаемых записях.",
+ "models.pickerOrder.usageIncomplete": "Нельзя сохранить порядок по частоте использования: история неполная. Выберите другой порядок или сначала восстановите историю.",
+ "api.attribution.noRecordedUse": "В читаемых записях использование не найдено",
"audio.dictation": "Диктовка",
"audio.liveVoice": "Голос в реальном времени",
"audio.configured": "Настроено, не проверено",
@@ -1282,6 +1285,16 @@ export const ru: Record = {
"pws.rateLimits": "Лимиты запросов",
"pws.quotaUnavailable": "Нет данных о квоте для этого провайдера.",
"pws.accountQuotaUnavailable": "Данные о лимитах временно недоступны; при наличии показываются последние известные значения.",
+ "pws.quotaFailure.account_unavailable": "Данные аккаунта для проверки квоты недоступны.",
+ "pws.quotaFailure.access_denied": "Провайдер запретил доступ к данным квоты.",
+ "pws.quotaFailure.rate_limited": "Превышен лимит запросов квоты.",
+ "pws.quotaFailure.upstream_error": "Провайдер не смог проверить квоту.",
+ "pws.quotaFailure.redirect_blocked": "Перенаправление сервиса квоты заблокировано.",
+ "pws.quotaFailure.destination_blocked": "Сетевая политика заблокировала адрес сервиса квоты.",
+ "pws.quotaFailure.dns_failed": "Не удалось разрешить имя сервера квоты.",
+ "pws.quotaFailure.timeout": "Время ожидания запроса квоты истекло.",
+ "pws.quotaFailure.transport_error": "Не удалось подключиться к сервису квоты.",
+ "pws.quotaFailure.response_unusable": "Провайдер вернул непригодные данные квоты.",
"pws.selected": "Выбрана",
"pws.copyModelId": "Копировать ID",
"pws.modelCopied": "Скопировано!",
@@ -1864,6 +1877,8 @@ export const ru: Record = {
"codexAuth.pause": "Приостановить",
"codexAuth.resume": "Возобновить",
"codexAuth.paused": "ПРИОСТАНОВЛЕН",
+ "codexAuth.planExcluded": "Не выбирается автоматически",
+ "codexAuth.planExcludedHint": "Тариф {plan} исключён из автоматического выбора. Явная маршрутизация на аккаунт доступна.",
"codexAuth.pauseSucceeded": "Аккаунт {email} приостановлен",
"codexAuth.resumeSucceeded": "Аккаунт {email} снова доступен в пуле",
"codexAuth.pauseFailed": "Не удалось приостановить {email}. Изменений нет.",
@@ -1944,6 +1959,8 @@ export const ru: Record = {
"accountPool.strategy": "Стратегия ротации",
"accountPool.strategyDesc": "Как OpenCodex назначает аккаунт новой/непривязанной задаче.",
+ "accountPool.strategyResetFirst": "Ближайший сброс первым",
+ "accountPool.strategyHintResetFirst": "Среди аккаунтов ниже порога выбирается ближайший сброс 5-часовой или недельной квоты. Привязанные задачи следуют настроенной политике привязки. Независимые квоты моделей упорядочиваются по использованию.",
"accountPool.strategyQuota": "Квота",
"accountPool.strategyRoundRobin": "Round-robin",
"accountPool.strategyFillFirst": "Fill-first",
@@ -2014,7 +2031,14 @@ export const ru: Record = {
"codexAuth.needsReauth": "Повторный вход",
"codexAuth.reauthenticate": "Переавторизоваться",
"codexAuth.tokenExpired": "Токен истёк — переавторизуйте этот аккаунт",
- "codexAuth.mainTokenExpired": "Токен истёк — повторите вход через приложение Codex",
+ "codexAuth.mainTokenExpired": "Токен истёк — войдите снова по коду устройства ниже или через вход в Codex App",
+ "codexAuth.mainReauthSucceeded": "Вход выполнен",
+ "codexAuth.mainReauthFailed": "Не удалось войти снова",
+ "codexAuth.mainReauthCancel": "Отмена",
+ "codexAuth.mainReauthCode": "Код",
+ "codexAuth.mainReauthOpen": "Открыть",
+ "codexAuth.mainReauthPending": "Ожидание входа…",
+ "codexAuth.mainReauthDevice": "Войти снова по коду устройства",
"codexAuth.emailCollision": "Этот аккаунт совпадает с вашим основным входом Codex. Используйте другой аккаунт.",
"codexAuth.resetCreditsTitle": "Кредиты сброса",
@@ -2776,4 +2800,65 @@ export const ru: Record = {
"models.pickerOrder.saveDraft": "Сохранить черновик",
"models.pickerOrder.reloadDraft": "Перезагрузить и сбросить черновик",
"models.pickerOrder.catalogRequired": "Идентификаторы моделей отсутствуют или неоднозначны. Перезагрузите страницу моделей, чтобы обновить каталог перед редактированием порядка.",
+ "nav.remote": "Удалённое рабочее пространство",
+ "remote.title": "Удалённое рабочее пространство",
+ "remote.subtitle": "Codex, Claude Code или Pi работают на этом Hub, а файлы, команды, тесты и сборки остаются на выбранном компьютере.",
+ "remote.loading": "Загрузка удалённого рабочего пространства…",
+ "remote.loadFailed": "Не удалось загрузить удалённое рабочее пространство.",
+ "remote.hubRequired": "Для включения Remote Workspace запустите Hub в режиме Hub с OCX_REMOTE_WORKSPACE_ENABLED=1.",
+ "remote.refresh": "Обновить",
+ "remote.addComputer": "Добавить компьютер",
+ "remote.addComputerHint": "Разрешите локальные папки и держите исполнитель только с OCX подключённым к этому Hub.",
+ "remote.createPairing": "Создать код сопряжения",
+ "remote.pairingCode": "Одноразовый код сопряжения",
+ "remote.pairingExpires": "Истекает в {time}",
+ "remote.pairingCommand": "Запустите на добавляемом компьютере",
+ "remote.pairingCommandPosix": "Терминал Linux / macOS",
+ "remote.pairingCommandWindows": "Windows PowerShell",
+ "remote.copyCommand": "Копировать команду",
+ "remote.copied": "Скопировано",
+ "remote.devices": "Компьютеры",
+ "remote.noDevices": "Сопряжённых компьютеров пока нет.",
+ "remote.online": "В сети",
+ "remote.offline": "Не в сети",
+ "remote.revoke": "Отозвать компьютер",
+ "remote.revokeConfirm": "Отозвать {name}? Активные сеансы на этом компьютере будут остановлены.",
+ "remote.newSession": "Новый удалённый сеанс",
+ "remote.device": "Компьютер",
+ "remote.folder": "Папка рабочего пространства",
+ "remote.runtime": "Агент программирования",
+ "remote.access": "Доступ к рабочей области",
+ "remote.access.readOnly": "Только чтение",
+ "remote.access.workspace": "Изменять файлы и выполнять команды",
+ "remote.access.workspaceFilesOnly": "Только изменять файлы",
+ "remote.unavailable": "Недоступно",
+ "remote.capability.full": "Файлы + изолированные команды",
+ "remote.capability.files": "Только файловые инструменты",
+ "remote.runsOnHub": "Модель и вход остаются на этом Hub",
+ "remote.runsReadOnly": "Файлы на этом компьютере доступны только для чтения",
+ "remote.runsFilesCommands": "Файлы, сборки и команды выполняются здесь",
+ "remote.runsFilesOnly": "Здесь работают только файловые инструменты; песочница команд недоступна",
+ "remote.execUnavailable": "На этом компьютере можно редактировать файлы, но сборки и команды терминала отключены без поддерживаемой системной песочницы.",
+ "remote.notResumable": "Сеанс остановился до создания постоянной истории агентом. Запустите новый удалённый сеанс.",
+ "remote.startSession": "Запустить удалённый сеанс",
+ "remote.sessionStarted": "Удалённый сеанс готов.",
+ "remote.sessions": "Сеансы",
+ "remote.noSessions": "Выберите компьютер в сети, папку и агента программирования.",
+ "remote.events": "Активность удалённого сеанса",
+ "remote.noEvents": "Активности пока нет.",
+ "remote.prompt": "Сообщение",
+ "remote.promptPlaceholder": "Попросите агент Hub работать в выбранной удалённой папке…",
+ "remote.send": "Отправить",
+ "remote.stop": "Остановить сеанс",
+ "remote.requestFailed": "Запрос удалённого рабочего пространства завершился ошибкой.",
+ "remote.submissionUnknown": "Статус отправки неизвестен. Проверьте сеанс перед повторной отправкой.",
+ "remote.status.starting": "Запуск",
+ "remote.status.ready": "Готово",
+ "remote.status.running": "Выполняется",
+ "remote.status.waiting": "Исполнитель не в сети",
+ "remote.status.failed": "Ошибка",
+ "remote.status.stopped": "Остановлено",
+ "remote.event.status": "Состояние",
+ "remote.event.tool": "Удалённый инструмент",
+ "remote.event.error": "Ошибка",
};
diff --git a/gui/src/i18n/tr.ts b/gui/src/i18n/tr.ts
index 3e3c12358f..78b572a477 100644
--- a/gui/src/i18n/tr.ts
+++ b/gui/src/i18n/tr.ts
@@ -5,6 +5,9 @@ import type { TKey } from "./en";
* Turkish i18n catalog. Must match the `TKey` set (compile-checked).
*/
export const tr: Record = {
+ "usage.incomplete": "Bazı kullanım kayıtları dahil edilemedi. Sayılar, tarihler ve sıralamalar yalnızca okunabilir kayıtlara dayanır.",
+ "models.pickerOrder.usageIncomplete": "Kullanım geçmişi eksik olduğundan en çok kullanılan sıralaması kaydedilemiyor. Başka bir sıralama seçin veya önce geçmişi onarın.",
+ "api.attribution.noRecordedUse": "Okunabilir kayıtlarda kullanım yok",
"audio.dictation": "Dikte",
"audio.liveVoice": "Canlı Ses",
"audio.configured": "Yapılandırıldı, doğrulanmadı",
@@ -1301,6 +1304,16 @@ export const tr: Record = {
"pws.rateLimits": "Oran limitleri",
"pws.quotaUnavailable": "Bu sağlayıcı için kota verisi yok.",
"pws.accountQuotaUnavailable": "Oran limiti verisi geçici olarak kullanılamıyor.",
+ "pws.quotaFailure.account_unavailable": "Kota denetimi için hesap bilgileri kullanılamıyor.",
+ "pws.quotaFailure.access_denied": "Sağlayıcı kota verilerine erişimi reddetti.",
+ "pws.quotaFailure.rate_limited": "Kota denetimi istek sınırına ulaştı.",
+ "pws.quotaFailure.upstream_error": "Sağlayıcı kota denetimini tamamlayamadı.",
+ "pws.quotaFailure.redirect_blocked": "Kota uç noktasının yönlendirmesi engellendi.",
+ "pws.quotaFailure.destination_blocked": "Ağ ilkesi kota hedefini engelledi.",
+ "pws.quotaFailure.dns_failed": "Kota sunucusunun ana bilgisayar adı çözümlenemedi.",
+ "pws.quotaFailure.timeout": "Kota isteği zaman aşımına uğradı.",
+ "pws.quotaFailure.transport_error": "Kota bağlantısı başarısız oldu.",
+ "pws.quotaFailure.response_unusable": "Sağlayıcı kullanılamayan kota verileri döndürdü.",
"pws.selected": "Seçilen",
"pws.copyModelId": "ID Kopyala",
"pws.modelCopied": "Kopyalandı!",
@@ -1894,6 +1907,8 @@ export const tr: Record = {
"codexAuth.pause": "Duraklat",
"codexAuth.resume": "Devam Ettir",
"codexAuth.paused": "DURAKLATILDI",
+ "codexAuth.planExcluded": "Otomatik seçilmez",
+ "codexAuth.planExcludedHint": "{plan} planı otomatik seçimden hariç tutulur. Açık hesap yönlendirmeleri kullanılabilir.",
"codexAuth.pauseSucceeded": "{email} duraklatıldı",
"codexAuth.resumeSucceeded": "{email} tekrar havuza alındı",
"codexAuth.pauseFailed": "{email} duraklatılamadı.",
@@ -1963,6 +1978,8 @@ export const tr: Record = {
"accountPool.strategy": "Rotasyon stratejisi",
"accountPool.strategyDesc": "OpenCodex'in yeni bir göreve nasıl hesap atayacağı.",
+ "accountPool.strategyResetFirst": "En yakın sıfırlama önce",
+ "accountPool.strategyHintResetFirst": "Eşiğin altındaki hesaplar arasından sonraki 5 saatlik veya haftalık sıfırlaması en yakın olanı seçer. Bağlı görevler yapılandırılmış bağlılık politikasını izler. Bağımsız model kotaları kullanıma göre sıralanır.",
"accountPool.strategyQuota": "Kota",
"accountPool.strategyRoundRobin": "Round-robin",
"accountPool.strategyFillFirst": "İlk doldurma",
@@ -2034,7 +2051,14 @@ export const tr: Record = {
"codexAuth.needsReauth": "Tekrar Giriş Yap",
"codexAuth.reauthenticate": "Yeniden Doğrula",
"codexAuth.tokenExpired": "Jeton süresi doldu — hesabı yeniden doğrulayın",
- "codexAuth.mainTokenExpired": "Jeton süresi doldu — tekrar giriş yapın",
+ "codexAuth.mainTokenExpired": "Belirteç süresi doldu — aşağıdan cihaz koduyla veya Codex App girişiyle yeniden giriş yapın",
+ "codexAuth.mainReauthSucceeded": "Giriş yapıldı",
+ "codexAuth.mainReauthFailed": "Yeniden giriş başarısız",
+ "codexAuth.mainReauthCancel": "İptal",
+ "codexAuth.mainReauthCode": "Kod",
+ "codexAuth.mainReauthOpen": "Aç",
+ "codexAuth.mainReauthPending": "Giriş bekleniyor…",
+ "codexAuth.mainReauthDevice": "Cihaz koduyla yeniden giriş yap",
"codexAuth.emailCollision": "Bu hesap ana girişinizle eşleşiyor.",
"codexAuth.resetCreditsTitle": "Kredileri Sıfırla",
@@ -2776,4 +2800,65 @@ export const tr: Record = {
"models.pickerOrder.saveDraft": "Taslağı kaydet",
"models.pickerOrder.reloadDraft": "Yeniden yükle ve taslağı sil",
"models.pickerOrder.catalogRequired": "Model kimlikleri eksik veya belirsiz. Özel sırayı düzenlemeden önce kataloğu yenilemek için Modeller sayfasını yeniden yükleyin.",
+ "nav.remote": "Uzak Çalışma Alanı",
+ "remote.title": "Uzak Çalışma Alanı",
+ "remote.subtitle": "Codex, Claude Code veya Pi bu Hub üzerinde çalışır; dosyalar, komutlar, testler ve derlemeler seçtiğiniz bilgisayarda kalır.",
+ "remote.loading": "Uzak çalışma alanı yükleniyor…",
+ "remote.loadFailed": "Uzak çalışma alanı yüklenemedi.",
+ "remote.hubRequired": "Remote Workspace özelliğini açmak için Hub modunda OCX_REMOTE_WORKSPACE_ENABLED=1 ile Hub başlatın.",
+ "remote.refresh": "Yenile",
+ "remote.addComputer": "Bilgisayar ekle",
+ "remote.addComputerHint": "Klasörleri yerel olarak onaylayın ve yalnızca OCX kurulu yürütücüyü bu Hub'a bağlı tutun.",
+ "remote.createPairing": "Eşleştirme kodu oluştur",
+ "remote.pairingCode": "Tek kullanımlık eşleştirme kodu",
+ "remote.pairingExpires": "{time} saatinde sona erer",
+ "remote.pairingCommand": "Eklenecek bilgisayarda çalıştırın",
+ "remote.pairingCommandPosix": "Linux / macOS terminali",
+ "remote.pairingCommandWindows": "Windows PowerShell",
+ "remote.copyCommand": "Komutu kopyala",
+ "remote.copied": "Kopyalandı",
+ "remote.devices": "Bilgisayarlar",
+ "remote.noDevices": "Henüz eşleştirilmiş bilgisayar yok.",
+ "remote.online": "Çevrimiçi",
+ "remote.offline": "Çevrimdışı",
+ "remote.revoke": "Bilgisayarı iptal et",
+ "remote.revokeConfirm": "{name} iptal edilsin mi? Bu bilgisayardaki etkin oturumlar durur.",
+ "remote.newSession": "Yeni uzak oturum",
+ "remote.device": "Bilgisayar",
+ "remote.folder": "Çalışma alanı klasörü",
+ "remote.runtime": "Kodlama aracısı",
+ "remote.access": "Çalışma alanı erişimi",
+ "remote.access.readOnly": "Salt okunur",
+ "remote.access.workspace": "Dosyaları düzenle ve komut çalıştır",
+ "remote.access.workspaceFilesOnly": "Yalnızca dosyaları düzenle",
+ "remote.unavailable": "Kullanılamıyor",
+ "remote.capability.full": "Dosyalar + yalıtılmış komutlar",
+ "remote.capability.files": "Yalnızca dosya araçları",
+ "remote.runsOnHub": "Model ve oturum bu Hub üzerinde kalır",
+ "remote.runsReadOnly": "Bu bilgisayardaki dosyalar yalnızca okunabilir",
+ "remote.runsFilesCommands": "Dosyalar, derlemeler ve komutlar burada çalışır",
+ "remote.runsFilesOnly": "Burada yalnızca dosya araçları çalışır; komut yalıtımı yok",
+ "remote.execUnavailable": "Bu bilgisayar dosyaları düzenleyebilir; ancak desteklenen bir işletim sistemi yalıtımı olmadığı için derlemeler ve terminal komutları devre dışıdır.",
+ "remote.notResumable": "Kodlama aracısı kalıcı geçmiş oluşturmadan önce oturum durdu. Yeni bir uzak oturum başlatın.",
+ "remote.startSession": "Uzak oturumu başlat",
+ "remote.sessionStarted": "Uzak oturum hazır.",
+ "remote.sessions": "Oturumlar",
+ "remote.noSessions": "Çevrimiçi bir bilgisayar, klasör ve kodlama aracısı seçin.",
+ "remote.events": "Uzak oturum etkinliği",
+ "remote.noEvents": "Henüz etkinlik yok.",
+ "remote.prompt": "Mesaj",
+ "remote.promptPlaceholder": "Hub aracısından seçili uzak klasörde çalışmasını isteyin…",
+ "remote.send": "Gönder",
+ "remote.stop": "Oturumu durdur",
+ "remote.requestFailed": "Uzak çalışma alanı isteği başarısız oldu.",
+ "remote.submissionUnknown": "Gönderim durumu bilinmiyor. Yeniden göndermeden önce oturumu kontrol edin.",
+ "remote.status.starting": "Başlatılıyor",
+ "remote.status.ready": "Hazır",
+ "remote.status.running": "Çalışıyor",
+ "remote.status.waiting": "Yürütücü çevrimdışı",
+ "remote.status.failed": "Başarısız",
+ "remote.status.stopped": "Durduruldu",
+ "remote.event.status": "Durum",
+ "remote.event.tool": "Uzak araç",
+ "remote.event.error": "Hata",
};
diff --git a/gui/src/i18n/zh-TW.ts b/gui/src/i18n/zh-TW.ts
index a0d0767dc3..0dfa0cd8b7 100644
--- a/gui/src/i18n/zh-TW.ts
+++ b/gui/src/i18n/zh-TW.ts
@@ -2,6 +2,9 @@ import type { TKey } from "./en";
/** Traditional Chinese (Taiwan) UI strings — keys must match `en.ts` 1:1. */
export const zhTW: Record = {
+ "usage.incomplete": "部分用量記錄無法納入。次數、日期和排名僅反映可讀取的記錄。",
+ "models.pickerOrder.usageIncomplete": "用量歷史不完整,無法儲存最常用排序。請選擇其他排序或先修復歷史記錄。",
+ "api.attribution.noRecordedUse": "可讀取的記錄中沒有使用記錄",
"audio.dictation": "聽寫",
"audio.liveVoice": "即時語音",
"audio.configured": "已設定,未驗證",
@@ -1079,6 +1082,16 @@ export const zhTW: Record = {
"pws.rateLimits": "速率限制",
"pws.quotaUnavailable": "此供應商暫無配額資料。",
"pws.accountQuotaUnavailable": "速率限制資料暫時不可用;若有上次已知值則繼續顯示。",
+ "pws.quotaFailure.account_unavailable": "無法取得配額查詢所需的帳號資訊。",
+ "pws.quotaFailure.access_denied": "供應商拒絕存取配額資料。",
+ "pws.quotaFailure.rate_limited": "配額查詢觸發了請求頻率限制。",
+ "pws.quotaFailure.upstream_error": "供應商未能完成配額查詢。",
+ "pws.quotaFailure.redirect_blocked": "配額端點的重新導向已被封鎖。",
+ "pws.quotaFailure.destination_blocked": "網路政策封鎖了配額查詢目的地。",
+ "pws.quotaFailure.dns_failed": "無法解析配額伺服器的主機名稱。",
+ "pws.quotaFailure.timeout": "配額請求逾時。",
+ "pws.quotaFailure.transport_error": "配額連線失敗。",
+ "pws.quotaFailure.response_unusable": "供應商回傳的配額資料無法使用。",
"pws.selected": "已選擇",
"pws.copyModelId": "複製 ID",
"pws.modelCopied": "已複製!",
@@ -1482,6 +1495,8 @@ export const zhTW: Record = {
"codexAuth.pause": "暫停",
"codexAuth.resume": "恢復",
"codexAuth.paused": "已暫停",
+ "codexAuth.planExcluded": "不自動選取",
+ "codexAuth.planExcludedHint": "方案 {plan} 已排除自動選取。仍可明確指定此帳號。",
"codexAuth.pauseSucceeded": "已暫停 {email}",
"codexAuth.resumeSucceeded": "{email} 已重新加入帳號池",
"codexAuth.pauseFailed": "無法暫停 {email},未做任何變更。",
@@ -1503,6 +1518,8 @@ export const zhTW: Record = {
"anthropicPool.off": "關",
"accountPool.strategy": "輪換策略",
"accountPool.strategyDesc": "新會話如何從帳號池中選擇帳號。",
+ "accountPool.strategyResetFirst": "額度即將重設優先",
+ "accountPool.strategyHintResetFirst": "在低於用量門檻的帳號中,優先選擇下次5小時或週額度重設最早的帳號。已綁定任務遵循設定的親和策略。獨立模型額度按用量排序。",
"accountPool.strategyQuota": "配額",
"accountPool.strategyRoundRobin": "輪詢",
"accountPool.strategyFillFirst": "填滿優先",
@@ -1553,7 +1570,14 @@ export const zhTW: Record = {
"codexAuth.needsReauth": "重新登入",
"codexAuth.reauthenticate": "重新認證",
"codexAuth.tokenExpired": "權杖已過期 — 請重新認證此帳號",
- "codexAuth.mainTokenExpired": "權杖已過期 — 請透過 Codex 應用登入重新登入",
+ "codexAuth.mainTokenExpired": "權杖已過期 — 請使用下方裝置碼或透過 Codex 應用程式登入重新登入",
+ "codexAuth.mainReauthSucceeded": "已登入",
+ "codexAuth.mainReauthFailed": "重新登入失敗",
+ "codexAuth.mainReauthCancel": "取消",
+ "codexAuth.mainReauthCode": "驗證碼",
+ "codexAuth.mainReauthOpen": "開啟",
+ "codexAuth.mainReauthPending": "等待登入…",
+ "codexAuth.mainReauthDevice": "使用裝置碼重新登入",
"codexAuth.emailCollision": "此帳號與您的主 Codex 登入相同。請使用其他帳號。",
"codexAuth.resetCreditsTitle": "重設額度",
"codexAuth.resetCreditsAvailable": "您有 {count} 個可用重設額度。",
@@ -2739,4 +2763,65 @@ export const zhTW: Record = {
"models.pickerOrder.saveDraft": "儲存草稿",
"models.pickerOrder.reloadDraft": "捨棄草稿並重新載入",
"models.pickerOrder.catalogRequired": "模型識別資訊缺失或不明確。請重新載入模型頁面以更新目錄,再編輯自訂順序。",
+ "nav.remote": "遠端工作區",
+ "remote.title": "遠端工作區",
+ "remote.subtitle": "Codex、Claude Code 或 Pi 在此 Hub 執行,檔案、命令、測試與建置則留在所選電腦上處理。",
+ "remote.loading": "正在載入遠端工作區…",
+ "remote.loadFailed": "無法載入遠端工作區。",
+ "remote.hubRequired": "請在 Hub 模式下使用 OCX_REMOTE_WORKSPACE_ENABLED=1 啟動 Hub,以啟用遠端工作區。",
+ "remote.refresh": "重新整理",
+ "remote.addComputer": "新增電腦",
+ "remote.addComputerHint": "在本機核准一個或多個資料夾,並讓僅安裝 OCX 的執行端持續連線此 Hub。",
+ "remote.createPairing": "建立配對碼",
+ "remote.pairingCode": "一次性配對碼",
+ "remote.pairingExpires": "{time} 到期",
+ "remote.pairingCommand": "在要新增的電腦上執行",
+ "remote.pairingCommandPosix": "Linux / macOS 終端機",
+ "remote.pairingCommandWindows": "Windows PowerShell",
+ "remote.copyCommand": "複製命令",
+ "remote.copied": "已複製",
+ "remote.devices": "電腦",
+ "remote.noDevices": "尚未配對電腦。",
+ "remote.online": "上線",
+ "remote.offline": "離線",
+ "remote.revoke": "撤銷電腦",
+ "remote.revokeConfirm": "撤銷 {name}?此電腦上的作用中工作階段將停止。",
+ "remote.newSession": "新增遠端工作階段",
+ "remote.device": "電腦",
+ "remote.folder": "工作區資料夾",
+ "remote.runtime": "程式設計代理",
+ "remote.access": "工作區權限",
+ "remote.access.readOnly": "唯讀",
+ "remote.access.workspace": "編輯檔案並執行命令",
+ "remote.access.workspaceFilesOnly": "僅編輯檔案",
+ "remote.unavailable": "無法使用",
+ "remote.capability.full": "檔案 + 沙箱命令",
+ "remote.capability.files": "僅檔案工具",
+ "remote.runsOnHub": "模型與登入保留在此 Hub",
+ "remote.runsReadOnly": "此電腦上的檔案僅可讀取",
+ "remote.runsFilesCommands": "檔案、建置與命令在此電腦執行",
+ "remote.runsFilesOnly": "僅檔案工具在此執行;命令沙箱無法使用",
+ "remote.execUnavailable": "此電腦可以編輯檔案,但因沒有支援的作業系統沙箱,建置與終端命令已停用。",
+ "remote.notResumable": "程式設計代理尚未建立持久歷史記錄時工作階段就已停止。請啟動新的遠端工作階段。",
+ "remote.startSession": "啟動遠端工作階段",
+ "remote.sessionStarted": "遠端工作階段已就緒。",
+ "remote.sessions": "工作階段",
+ "remote.noSessions": "請選擇上線電腦、資料夾與程式設計代理。",
+ "remote.events": "遠端工作階段活動",
+ "remote.noEvents": "尚無活動。",
+ "remote.prompt": "訊息",
+ "remote.promptPlaceholder": "請 Hub 代理在所選遠端資料夾中工作…",
+ "remote.send": "傳送",
+ "remote.stop": "停止工作階段",
+ "remote.requestFailed": "遠端工作區要求失敗。",
+ "remote.submissionUnknown": "無法確認傳送狀態。請先檢查工作階段,再決定是否重新傳送。",
+ "remote.status.starting": "正在啟動",
+ "remote.status.ready": "就緒",
+ "remote.status.running": "執行中",
+ "remote.status.waiting": "執行端離線",
+ "remote.status.failed": "失敗",
+ "remote.status.stopped": "已停止",
+ "remote.event.status": "狀態",
+ "remote.event.tool": "遠端工具",
+ "remote.event.error": "錯誤",
};
diff --git a/gui/src/i18n/zh.ts b/gui/src/i18n/zh.ts
index da66e4f8da..e30cca65a6 100644
--- a/gui/src/i18n/zh.ts
+++ b/gui/src/i18n/zh.ts
@@ -4,6 +4,9 @@ import type { TKey } from "./en";
* Chinese i18n catalog; must match the `TKey` set (compile-checked).
*/
export const zh: Record = {
+ "usage.incomplete": "部分用量记录无法计入。次数、日期和排名仅反映可读取的记录。",
+ "models.pickerOrder.usageIncomplete": "用量历史不完整,无法保存最常用排序。请选择其他排序或先修复历史记录。",
+ "api.attribution.noRecordedUse": "可读取的记录中没有使用记录",
"audio.dictation": "听写",
"audio.liveVoice": "实时语音",
"audio.configured": "已配置,未验证",
@@ -1378,6 +1381,8 @@ export const zh: Record = {
"codexAuth.pause": "暂停",
"codexAuth.resume": "恢复",
"codexAuth.paused": "已暂停",
+ "codexAuth.planExcluded": "不自动选择",
+ "codexAuth.planExcludedHint": "套餐 {plan} 已从自动选择中排除。仍可明确指定此账号。",
"codexAuth.pauseSucceeded": "已暂停 {email}",
"codexAuth.resumeSucceeded": "{email} 已重新加入账号池",
"codexAuth.pauseFailed": "无法暂停 {email},未做任何更改。",
@@ -1458,6 +1463,8 @@ export const zh: Record = {
"accountPool.strategy": "轮换策略",
"accountPool.strategyDesc": "OpenCodex 如何为新建/未绑定任务分配账号。",
+ "accountPool.strategyResetFirst": "额度即将刷新优先",
+ "accountPool.strategyHintResetFirst": "在低于用量阈值的账号中,优先选择下次5小时或周额度重置最早的账号。已绑定任务遵循配置的亲和策略。独立模型额度按用量排序。",
"accountPool.strategyQuota": "配额",
"accountPool.strategyRoundRobin": "轮询",
"accountPool.strategyFillFirst": "填满优先",
@@ -1526,7 +1533,14 @@ export const zh: Record = {
"codexAuth.needsReauth": "重新登录",
"codexAuth.reauthenticate": "重新认证",
"codexAuth.tokenExpired": "令牌已过期 — 请重新认证此账号",
- "codexAuth.mainTokenExpired": "令牌已过期 — 请通过 Codex 应用登录重新登录",
+ "codexAuth.mainTokenExpired": "令牌已过期 — 请使用下方设备码或通过 Codex 应用登录重新登录",
+ "codexAuth.mainReauthSucceeded": "已登录",
+ "codexAuth.mainReauthFailed": "重新登录失败",
+ "codexAuth.mainReauthCancel": "取消",
+ "codexAuth.mainReauthCode": "验证码",
+ "codexAuth.mainReauthOpen": "打开",
+ "codexAuth.mainReauthPending": "等待登录…",
+ "codexAuth.mainReauthDevice": "使用设备码重新登录",
"codexAuth.emailCollision": "此账号与您的主 Codex 登录相同。请使用其他账号。",
"codexAuth.resetCreditsTitle": "重置额度",
@@ -2122,6 +2136,16 @@ export const zh: Record = {
"pws.rateLimits": "速率限制",
"pws.quotaUnavailable": "此提供商暂无配额数据。",
"pws.accountQuotaUnavailable": "速率限制数据暂时不可用;若有上次已知值则继续显示。",
+ "pws.quotaFailure.account_unavailable": "无法获取配额查询所需的账号信息。",
+ "pws.quotaFailure.access_denied": "提供商拒绝访问配额数据。",
+ "pws.quotaFailure.rate_limited": "配额查询触发了请求频率限制。",
+ "pws.quotaFailure.upstream_error": "提供商未能完成配额查询。",
+ "pws.quotaFailure.redirect_blocked": "配额端点的重定向已被阻止。",
+ "pws.quotaFailure.destination_blocked": "网络策略阻止了配额查询目标。",
+ "pws.quotaFailure.dns_failed": "无法解析配额服务器的主机名。",
+ "pws.quotaFailure.timeout": "配额请求超时。",
+ "pws.quotaFailure.transport_error": "配额连接失败。",
+ "pws.quotaFailure.response_unusable": "提供商返回的配额数据不可用。",
"pws.selected": "已选择",
"pws.copyModelId": "复制 ID",
"pws.modelCopied": "已复制!",
@@ -2774,4 +2798,65 @@ export const zh: Record = {
"models.pickerOrder.saveDraft": "保存草稿",
"models.pickerOrder.reloadDraft": "丢弃草稿并重新加载",
"models.pickerOrder.catalogRequired": "模型标识信息缺失或不明确。请重新加载模型页面以刷新目录,再编辑自定义顺序。",
+ "nav.remote": "远程工作区",
+ "remote.title": "远程工作区",
+ "remote.subtitle": "Codex、Claude Code 或 Pi 在此 Hub 上运行,文件、命令、测试和构建则留在所选电脑上执行。",
+ "remote.loading": "正在加载远程工作区…",
+ "remote.loadFailed": "无法加载远程工作区。",
+ "remote.hubRequired": "请在 Hub 模式下使用 OCX_REMOTE_WORKSPACE_ENABLED=1 启动 Hub,以启用远程工作区。",
+ "remote.refresh": "刷新",
+ "remote.addComputer": "添加电脑",
+ "remote.addComputerHint": "在本机批准一个或多个文件夹,并让仅安装 OCX 的执行端持续连接此 Hub。",
+ "remote.createPairing": "创建配对码",
+ "remote.pairingCode": "一次性配对码",
+ "remote.pairingExpires": "{time} 过期",
+ "remote.pairingCommand": "在要添加的电脑上运行",
+ "remote.pairingCommandPosix": "Linux / macOS 终端",
+ "remote.pairingCommandWindows": "Windows PowerShell",
+ "remote.copyCommand": "复制命令",
+ "remote.copied": "已复制",
+ "remote.devices": "电脑",
+ "remote.noDevices": "尚未配对电脑。",
+ "remote.online": "在线",
+ "remote.offline": "离线",
+ "remote.revoke": "撤销电脑",
+ "remote.revokeConfirm": "撤销 {name}?该电脑上的活动会话将停止。",
+ "remote.newSession": "新建远程会话",
+ "remote.device": "电脑",
+ "remote.folder": "工作区文件夹",
+ "remote.runtime": "编程代理",
+ "remote.access": "工作区权限",
+ "remote.access.readOnly": "只读",
+ "remote.access.workspace": "编辑文件并运行命令",
+ "remote.access.workspaceFilesOnly": "仅编辑文件",
+ "remote.unavailable": "不可用",
+ "remote.capability.full": "文件 + 沙箱命令",
+ "remote.capability.files": "仅文件工具",
+ "remote.runsOnHub": "模型和登录保留在此 Hub",
+ "remote.runsReadOnly": "此电脑上的文件仅可读取",
+ "remote.runsFilesCommands": "文件、构建和命令在此电脑运行",
+ "remote.runsFilesOnly": "仅文件工具在此运行;命令沙箱不可用",
+ "remote.execUnavailable": "此电脑可以编辑文件,但由于没有受支持的操作系统沙箱,构建和终端命令已禁用。",
+ "remote.notResumable": "编码代理尚未创建持久历史记录时会话就已停止。请启动新的远程会话。",
+ "remote.startSession": "启动远程会话",
+ "remote.sessionStarted": "远程会话已就绪。",
+ "remote.sessions": "会话",
+ "remote.noSessions": "请选择在线电脑、文件夹和编程代理。",
+ "remote.events": "远程会话活动",
+ "remote.noEvents": "暂无活动。",
+ "remote.prompt": "消息",
+ "remote.promptPlaceholder": "让 Hub 代理在所选远程文件夹中工作…",
+ "remote.send": "发送",
+ "remote.stop": "停止会话",
+ "remote.requestFailed": "远程工作区请求失败。",
+ "remote.submissionUnknown": "无法确认发送状态。请先检查会话,再决定是否重新发送。",
+ "remote.status.starting": "正在启动",
+ "remote.status.ready": "就绪",
+ "remote.status.running": "运行中",
+ "remote.status.waiting": "执行端离线",
+ "remote.status.failed": "失败",
+ "remote.status.stopped": "已停止",
+ "remote.event.status": "状态",
+ "remote.event.tool": "远程工具",
+ "remote.event.error": "错误",
};
diff --git a/gui/src/pages/ApiKeys.tsx b/gui/src/pages/ApiKeys.tsx
index ca45d8b940..833c40814a 100644
--- a/gui/src/pages/ApiKeys.tsx
+++ b/gui/src/pages/ApiKeys.tsx
@@ -1,5 +1,6 @@
import { useCallback, useMemo, useRef, useState } from "react";
import { Notice } from "../ui";
+import { readUsageMetadata, type UsageReadMetadata } from "../usage-summary-resource";
import { useI18n, LOCALES } from "../i18n/shared";
import { formatProviderDisplayName } from "../provider-icons";
import { readJsonIfOk, readJsonOrThrow } from "../fetch-json";
@@ -28,7 +29,7 @@ import {
type ModelTests,
} from "./api-keys-utils";
-interface KeysResponse {
+interface KeysResponse extends UsageReadMetadata {
// `usage` is optional on the wire only so a malformed payload lands in
// fetchKeys' validator rather than at the type boundary. A row without it is
// rejected, not defaulted: zeroes would assert "never used" about data we
@@ -55,7 +56,7 @@ interface StartRotationResponse extends CreateKeyResponse {
rotationId?: unknown;
}
-type CachedKeysShape = {
+type CachedKeysShape = UsageReadMetadata & {
keys: ApiKeyEntry[];
endpoints: ApiEndpointInfo;
claudeCodeEnabled: boolean;
@@ -166,6 +167,7 @@ export default function ApiKeys({ apiBase, active = true }: { apiBase: string; a
claudeCodeEnabled: data.claudeCodeEnabled !== false,
...(data.attributionSince ? { attributionSince: data.attributionSince } : {}),
...(data.historyTruncated === true ? { historyTruncated: true } : {}),
+ ...readUsageMetadata(data),
authMatrix: data.authMatrix,
};
// Prefixes only — never the secret key material.
@@ -508,6 +510,7 @@ export default function ApiKeys({ apiBase, active = true }: { apiBase: string; a
apiBase={apiBase}
attributionSince={attributionSince}
historyTruncated={historyTruncated}
+ usageMetadata={readUsageMetadata(keysData)}
authMatrix={authMatrix}
keysLoading={false}
keysLoadFailed={keysState.showError}
diff --git a/gui/src/pages/Models.tsx b/gui/src/pages/Models.tsx
index 0150cd29b0..3a4fb03db6 100644
--- a/gui/src/pages/Models.tsx
+++ b/gui/src/pages/Models.tsx
@@ -1920,8 +1920,9 @@ export default function Models({ apiBase, restartEpoch = 0 }: { apiBase: string;
if (mode === "most-used") {
const response = await fetch(`${apiBase}/api/usage?range=all&surface=all`, { signal: bounded.signal });
if (!current()) return;
- const payload = await readJsonOrThrow<{ models?: unknown }>(response, t("models.pickerOrder.usageFailed"));
+ const payload = await readJsonOrThrow<{ models?: unknown; usageIncomplete?: unknown }>(response, t("models.pickerOrder.usageFailed"));
if (!current()) return;
+ if (payload?.usageIncomplete === true) throw new Error(t("models.pickerOrder.usageIncomplete"));
if (!isModelPickerUsage(payload?.models)) throw new Error(t("models.pickerOrder.usageFailed"));
usage = payload.models;
}
diff --git a/gui/src/pages/RemoteWorkspace.tsx b/gui/src/pages/RemoteWorkspace.tsx
new file mode 100644
index 0000000000..d6eebb3258
--- /dev/null
+++ b/gui/src/pages/RemoteWorkspace.tsx
@@ -0,0 +1,411 @@
+import { useMemo, useRef, useState } from "react";
+import { useKeyedClientResource } from "../client-resource";
+import { readJsonOrThrow } from "../fetch-json";
+import { IconLink, IconMonitor, IconPlus, IconRefresh, IconTerminal, IconTrash } from "../icons";
+import { type TKey, useT } from "../i18n/shared";
+import { Notice, Select } from "../ui";
+import { remoteWorkspacePairingCommands } from "../remote-workspace-command";
+
+type RuntimeProfile = "codex" | "claude" | "pi";
+type RemoteCapability = "workspace.read" | "workspace.write" | "workspace.exec";
+type RemoteAccessMode = "read-only" | "workspace";
+type SessionStatus = "starting" | "ready" | "running" | "waiting_for_executor" | "failed" | "stopped";
+
+interface RemoteRoot { id: string; label: string }
+interface RemoteDevice {
+ id: string;
+ name: string;
+ platform: string;
+ capabilities: RemoteCapability[];
+ roots: RemoteRoot[];
+ online: boolean;
+ createdAt: string;
+ lastSeenAt: string | null;
+}
+interface RuntimeAvailability { available: boolean; version?: string; reason?: string }
+interface SessionEvent { sequence: number; at: string; type: "status" | "assistant" | "tool" | "error"; text: string }
+interface RemoteSession {
+ id: string;
+ profile: RuntimeProfile;
+ accessMode: RemoteAccessMode;
+ deviceId: string;
+ deviceName: string;
+ rootId: string;
+ rootLabel: string;
+ capabilities: RemoteCapability[];
+ tools: string[];
+ threadId: string | null;
+ resumable: boolean;
+ status: SessionStatus;
+ createdAt: string;
+ updatedAt: string;
+ events: SessionEvent[];
+}
+interface RemoteWorkspaceState {
+ available: boolean;
+ reason?: string;
+ devices: RemoteDevice[];
+ runtimes: Record;
+ sessions: RemoteSession[];
+}
+interface PairingGrant { code: string; expiresAt: string }
+
+const PROFILES: RuntimeProfile[] = ["codex", "claude", "pi"];
+const PROFILE_LABEL: Record = { codex: "Codex", claude: "Claude Code", pi: "Pi" };
+const STATUS_TKEY: Record = {
+ starting: "remote.status.starting",
+ ready: "remote.status.ready",
+ running: "remote.status.running",
+ waiting_for_executor: "remote.status.waiting",
+ failed: "remote.status.failed",
+ stopped: "remote.status.stopped",
+};
+const EVENT_TKEY: Record, TKey> = {
+ status: "remote.event.status",
+ tool: "remote.event.tool",
+ error: "remote.event.error",
+};
+
+function isRuntimeProfile(value: string): value is RuntimeProfile {
+ return value === "codex" || value === "claude" || value === "pi";
+}
+
+function isRemoteAccessMode(value: string): value is RemoteAccessMode {
+ return value === "read-only" || value === "workspace";
+}
+
+async function copyText(text: string): Promise {
+ try {
+ await navigator.clipboard.writeText(text);
+ return true;
+ } catch {
+ return false;
+ }
+}
+
+export default function RemoteWorkspace({ apiBase, hubOrigin }: { apiBase: string; hubOrigin: string }) {
+ const t = useT();
+ const resource = useKeyedClientResource(
+ `remote-workspace:${apiBase}`,
+ [apiBase],
+ async signal => {
+ const response = await fetch(`${apiBase}/api/remote-workspace`, { signal, cache: "no-store" });
+ return await readJsonOrThrow(response, t("remote.loadFailed"));
+ },
+ { pollMs: 3_000, deadlineMs: 10_000 },
+ );
+ const state = resource.data;
+ const [selectedDeviceId, setSelectedDeviceId] = useState("");
+ const [selectedRootId, setSelectedRootId] = useState("");
+ const [selectedProfile, setSelectedProfile] = useState("codex");
+ const [selectedAccessMode, setSelectedAccessMode] = useState("read-only");
+ const [selectedSessionId, setSelectedSessionId] = useState("");
+ const [localSession, setLocalSession] = useState(null);
+ const [pairing, setPairing] = useState(null);
+ const [drafts, setDrafts] = useState>({});
+ const [busy, setBusy] = useState<"pair" | "session" | "revoke" | null>(null);
+ const [promptPending, setPromptPending] = useState(false);
+ const [stopPending, setStopPending] = useState(false);
+ const stoppedSessionId = useRef(null);
+ const [notice, setNotice] = useState<{ tone: "ok" | "err"; text: string } | null>(null);
+ const [copiedCommand, setCopiedCommand] = useState<"posix" | "powershell" | null>(null);
+
+ const devices = state?.devices ?? [];
+ const effectiveDevice = devices.find(device => device.id === selectedDeviceId)
+ ?? devices.find(device => device.online)
+ ?? devices[0]
+ ?? null;
+ const effectiveRoot = effectiveDevice?.roots.find(root => root.id === selectedRootId)
+ ?? effectiveDevice?.roots[0]
+ ?? null;
+ const selectedCanExecute = selectedAccessMode === "workspace"
+ && (effectiveDevice?.capabilities.includes("workspace.exec") ?? false);
+ const workspaceAccessLabel = effectiveDevice && !effectiveDevice.capabilities.includes("workspace.exec")
+ ? t("remote.access.workspaceFilesOnly")
+ : t("remote.access.workspace");
+ const availableProfiles = PROFILES.filter(profile => state?.runtimes?.[profile]?.available);
+ const effectiveProfile = availableProfiles.includes(selectedProfile)
+ ? selectedProfile
+ : availableProfiles[0] ?? selectedProfile;
+ const remoteSessions = state?.sessions ?? [];
+ const fallbackSession = [...remoteSessions].reverse().find(session => session.status !== "stopped");
+ const candidate = remoteSessions.find(session => session.id === selectedSessionId)
+ ?? (localSession?.id === selectedSessionId ? localSession : null)
+ ?? fallbackSession
+ ?? localSession;
+ const wantedSessionId = candidate?.id;
+ const polledSession = remoteSessions.find(session => session.id === wantedSessionId);
+ const selectedLocal = localSession?.id === wantedSessionId ? localSession : null;
+ const selectedSession = selectedLocal && (!polledSession
+ || (selectedLocal.events.at(-1)?.sequence ?? 0) > (polledSession.events.at(-1)?.sequence ?? 0))
+ ? selectedLocal : polledSession;
+ const effectiveSession = selectedSession
+ ?? fallbackSession
+ ?? localSession;
+
+ const prompt = drafts[effectiveSession?.id ?? ""] ?? "";
+ const setPrompt = (value: string | ((current: string) => string)) => {
+ const id = effectiveSession?.id;
+ if (!id) return;
+ setDrafts(current => ({ ...current, [id]: typeof value === "function" ? value(current[id] ?? "") : value }));
+ };
+ const stale = Boolean(state && !resource.lastAttemptOk);
+ const canSend = Boolean(effectiveSession && prompt.trim() && busy === null && !promptPending && !stopPending && !stale
+ && effectiveSession.status !== "running" && effectiveSession.status !== "starting"
+ && effectiveSession.status !== "stopped"
+ && !(effectiveSession.status === "failed" && effectiveSession.resumable === false)
+ && !(effectiveSession.status === "waiting_for_executor" && !devices.find(device => device.id === effectiveSession.deviceId)?.online));
+
+ const pairingCommands = useMemo(() => {
+ if (!pairing) return { posix: "", powershell: "" };
+ return remoteWorkspacePairingCommands(pairing.code, hubOrigin);
+ }, [pairing, hubOrigin]);
+
+ const mutate = async (path: string, init: RequestInit, fallback: string): Promise => {
+ const response = await fetch(`${apiBase}${path}`, init);
+ const body = await readJsonOrThrow(response, fallback);
+ if (body === undefined) throw new Error(fallback);
+ return body;
+ };
+
+ const createPairing = async () => {
+ setBusy("pair");
+ setNotice(null);
+ try {
+ const grant = await mutate("/api/remote-workspace/pairing", { method: "POST" }, t("remote.requestFailed"));
+ setPairing(grant);
+ setCopiedCommand(null);
+ } catch (error) {
+ setNotice({ tone: "err", text: error instanceof Error ? error.message : t("remote.requestFailed") });
+ } finally { setBusy(null); }
+ };
+
+ const createSession = async () => {
+ if (!effectiveDevice || !effectiveRoot) return;
+ setBusy("session");
+ setNotice(null);
+ try {
+ const session = await mutate("/api/remote-workspace/sessions", {
+ method: "POST",
+ headers: { "content-type": "application/json" },
+ body: JSON.stringify({
+ profile: effectiveProfile,
+ deviceId: effectiveDevice.id,
+ rootId: effectiveRoot.id,
+ accessMode: selectedAccessMode,
+ }),
+ }, t("remote.requestFailed"));
+ setLocalSession(session);
+ setSelectedSessionId(session.id);
+ setNotice({ tone: "ok", text: t("remote.sessionStarted") });
+ void resource.refresh();
+ } catch (error) {
+ setNotice({ tone: "err", text: error instanceof Error ? error.message : t("remote.requestFailed") });
+ } finally { setBusy(null); }
+ };
+
+ const sendPrompt = async () => {
+ if (!effectiveSession || !canSend) return;
+ const target = effectiveSession;
+ const submitted = prompt;
+ setPromptPending(true);
+ setNotice(null);
+ let responseStatus: number | undefined;
+ try {
+ const response = await fetch(`${apiBase}/api/remote-workspace/sessions/${target.id}/prompt`, {
+ method: "POST",
+ headers: { "content-type": "application/json" },
+ body: JSON.stringify({ prompt: submitted }),
+ });
+ responseStatus = response.status;
+ const session = await readJsonOrThrow(response, t("remote.requestFailed"));
+ if (!session) throw new Error(t("remote.requestFailed"));
+ setPrompt(current => current === submitted ? "" : current);
+ if (stoppedSessionId.current !== target.id) setLocalSession(session);
+ void resource.refresh();
+ } catch (error) {
+ if (stoppedSessionId.current !== target.id) {
+ const rejected = responseStatus !== undefined && responseStatus >= 400 && responseStatus < 500;
+ setNotice({ tone: "err", text: rejected
+ ? error instanceof Error ? error.message : t("remote.requestFailed")
+ : t("remote.submissionUnknown") });
+ void resource.refresh();
+ }
+ } finally { setPromptPending(false); }
+ };
+
+ const stopSession = async () => {
+ if (!effectiveSession || stopPending) return;
+ const target = effectiveSession;
+ setStopPending(true);
+ try {
+ await mutate(`/api/remote-workspace/sessions/${target.id}`, { method: "DELETE" }, t("remote.requestFailed"));
+ stoppedSessionId.current = target.id;
+ setLocalSession({ ...target, status: "stopped" });
+ void resource.refresh();
+ } catch (error) {
+ setNotice({ tone: "err", text: error instanceof Error ? error.message : t("remote.requestFailed") });
+ } finally { setStopPending(false); }
+ };
+
+ const revokeDevice = async (device: RemoteDevice) => {
+ if (!confirm(t("remote.revokeConfirm", { name: device.name }))) return;
+ setBusy("revoke");
+ try {
+ await mutate(`/api/remote-workspace/devices/${device.id}`, { method: "DELETE" }, t("remote.requestFailed"));
+ if (selectedDeviceId === device.id) setSelectedDeviceId("");
+ void resource.refresh();
+ } catch (error) {
+ setNotice({ tone: "err", text: error instanceof Error ? error.message : t("remote.requestFailed") });
+ } finally { setBusy(null); }
+ };
+
+ const copyPairingCommand = async (kind: "posix" | "powershell", command: string) => {
+ setCopiedCommand(await copyText(command) ? kind : null);
+ };
+
+ if (resource.loading && !state) return {t("remote.loading")}
;
+ if (resource.error && !state) {
+ return <>{t("remote.loadFailed")} void resource.refresh()}>{t("common.retry")} >;
+ }
+ if (state?.available === false) return {t("remote.hubRequired")} ;
+
+ return (
+
+
+
+
{t("remote.title")}
+
{t("remote.subtitle")}
+
+
void resource.refresh()} disabled={resource.refreshing}>
+ {t("remote.refresh")}
+
+
+
+ {stale ? {t("remote.loadFailed")} : null}
+ {notice ? {notice.text} : null}
+
+
+
+
+
+
+
{t("remote.addComputer")} {t("remote.addComputerHint")}
+
+ void createPairing()} disabled={busy !== null}>
+ {t("remote.createPairing")}
+
+ {pairing ? (
+
+
{t("remote.pairingCode")}
+
{pairing.code}
+
{t("remote.pairingExpires", { time: new Date(pairing.expiresAt).toLocaleTimeString() })}
+
{t("remote.pairingCommandPosix")}
+
{pairingCommands.posix}
+
void copyPairingCommand("posix", pairingCommands.posix)}>
+ {copiedCommand === "posix" ? t("remote.copied") : t("remote.copyCommand")}
+
+
{t("remote.pairingCommandWindows")}
+
{pairingCommands.powershell}
+
void copyPairingCommand("powershell", pairingCommands.powershell)}>
+ {copiedCommand === "powershell" ? t("remote.copied") : t("remote.copyCommand")}
+
+
+ ) : null}
+
+
+
+
{t("remote.devices")} {devices.length}
+ {devices.length === 0 ? {t("remote.noDevices")}
: (
+
+ {devices.map(device => (
+
+ { setSelectedDeviceId(device.id); setSelectedRootId(device.roots[0]?.id ?? ""); }}
+ aria-pressed={effectiveDevice?.id === device.id}
+ >
+
+ {device.name} {device.platform} · {device.online ? t("remote.online") : t("remote.offline")} · {device.capabilities.includes("workspace.exec") ? t("remote.capability.full") : t("remote.capability.files")}
+
+ void revokeDevice(device)}
+ >
+
+ ))}
+
+ )}
+
+
+
+
+
+
{t("remote.newSession")}
+
+ {t("remote.device")} ({ value: device.id, label: device.name }))} onChange={value => { setSelectedDeviceId(value); setSelectedRootId(devices.find(device => device.id === value)?.roots[0]?.id ?? ""); }} label={t("remote.device")} disabled={devices.length === 0} />
+ {t("remote.folder")} ({ value: root.id, label: root.label }))} onChange={setSelectedRootId} label={t("remote.folder")} disabled={!effectiveDevice} />
+ {t("remote.runtime")} ({ value: profile, label: state?.runtimes?.[profile]?.available ? PROFILE_LABEL[profile] : `${PROFILE_LABEL[profile]} · ${t("remote.unavailable")}` }))} onChange={value => { if (isRuntimeProfile(value)) setSelectedProfile(value); }} label={t("remote.runtime")} />
+ {t("remote.access")} { if (isRemoteAccessMode(value)) setSelectedAccessMode(value); }} label={t("remote.access")} />
+
+ {effectiveDevice ? (
+
+ {PROFILE_LABEL[effectiveProfile]} {t("remote.runsOnHub")}
+ {effectiveDevice.name} {selectedAccessMode === "read-only" ? t("remote.runsReadOnly") : selectedCanExecute ? t("remote.runsFilesCommands") : t("remote.runsFilesOnly")}
+
+ ) : null}
+ {selectedAccessMode === "workspace" && !selectedCanExecute && effectiveDevice ? {t("remote.execUnavailable")} : null}
+ {!state?.runtimes?.[effectiveProfile]?.available && state?.runtimes?.[effectiveProfile]?.reason
+ ? {state.runtimes[effectiveProfile].reason}
+ : null}
+ void createSession()} disabled={!effectiveDevice?.online || !effectiveRoot || !state?.runtimes?.[effectiveProfile]?.available || busy !== null || promptPending || stopPending}>
+ {t("remote.startSession")}
+
+
+
+
+
+
{t("remote.sessions")} {effectiveSession ? {PROFILE_LABEL[effectiveSession.profile]} · {effectiveSession.deviceName}/{effectiveSession.rootLabel} · {effectiveSession.accessMode === "read-only" ? t("remote.access.readOnly") : effectiveSession.capabilities.includes("workspace.exec") ? t("remote.access.workspace") : t("remote.access.workspaceFilesOnly")} : null}
+ {effectiveSession ?
{t(STATUS_TKEY[effectiveSession.status])} : null}
+
+ {remoteSessions.length > 1 ? (
+ ({ value: session.id, label: `${PROFILE_LABEL[session.profile]} · ${session.deviceName}/${session.rootLabel}` }))} onChange={setSelectedSessionId} label={t("remote.sessions")} />
+ ) : null}
+ {!effectiveSession ? {t("remote.noSessions")}
: (
+ <>
+
+ {effectiveSession.events.length === 0 ?
{t("remote.noEvents")}
: effectiveSession.events.map(event => (
+
+
{event.type === "assistant" ? PROFILE_LABEL[effectiveSession.profile] : t(EVENT_TKEY[event.type])}
+
{event.text}
+
+ ))}
+
+ {effectiveSession.status === "failed" && effectiveSession.resumable === false
+ ? {t("remote.notResumable")}
+ : null}
+
+ {t("remote.prompt")}
+
+
+ void sendPrompt()} disabled={!canSend}>{t("remote.send")}
+ void stopSession()} disabled={stopPending || effectiveSession.status === "stopped"}>{t("remote.stop")}
+
+ >
+ )}
+
+
+
+
+ );
+}
diff --git a/gui/src/pages/Usage.tsx b/gui/src/pages/Usage.tsx
index 96f0f1db0c..900ab6eac0 100644
--- a/gui/src/pages/Usage.tsx
+++ b/gui/src/pages/Usage.tsx
@@ -1,5 +1,7 @@
import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react";
import { useI18n, type TFn, type Locale } from "../i18n/shared";
+import type { UsageReadMetadata } from "../usage-summary-resource";
+import { UsageIncompleteNotice } from "../components/usage-incomplete-notice";
import { formatProviderDisplayName } from "../provider-icons";
import { formatTokens } from "../format-tokens";
import { formatEstimatedUsdValue as formatUsdEstimate } from "../intl-formatters";
@@ -79,7 +81,7 @@ interface UsageProvider {
class UsageWindowMismatchError extends Error {}
-interface UsageResponse {
+interface UsageResponse extends UsageReadMetadata {
range: Range;
surface: UsageSurface;
since: number | null;
@@ -989,6 +991,7 @@ export default function Usage({ apiBase, connected = false, apiKeyId }: { apiBas
) : (
<>
{state.showError && {t(connected ? "usage.hubOffline" : "usage.loadError")} }
+
{data?.historyTruncated && (
// Naming the loaded window is the point: without it, `30d` and "Available history"
// look identical on a busy installation even though both may cover far less than
diff --git a/gui/src/pages/dashboard-overview-head.tsx b/gui/src/pages/dashboard-overview-head.tsx
index 44b3f8ac3c..c956ca1f4b 100644
--- a/gui/src/pages/dashboard-overview-head.tsx
+++ b/gui/src/pages/dashboard-overview-head.tsx
@@ -4,6 +4,7 @@ import { formatTokens } from "../format-tokens";
import { formatUptime } from "../formatUptime";
import { navigateHash } from "../hash-routing";
import type { useDashboardData } from "./use-dashboard-data";
+import { UsageIncompleteNotice } from "../components/usage-incomplete-notice";
type Dash = ReturnType;
@@ -113,6 +114,7 @@ export function DashboardOverviewHead({
+
{projectConfigWarnings.length > 0 && (
diff --git a/gui/src/pages/dashboard-shared.ts b/gui/src/pages/dashboard-shared.ts
index 029e39b2da..ae24f861a2 100644
--- a/gui/src/pages/dashboard-shared.ts
+++ b/gui/src/pages/dashboard-shared.ts
@@ -123,7 +123,7 @@ export interface SidecarPatch {
};
}
export interface ShadowCallData { enabled: boolean; model: string; sourceModels?: string[] }
-export interface UsageSummary30d { summary: { requests: number; totalTokens: number; coverageRatio: number } }
+export type UsageSummary30d = import("../usage-summary-resource").UsageReadMetadata & { summary: { requests: number; totalTokens: number; coverageRatio: number } };
export type UpdateChannel = "latest" | "preview";
export type Installer = "npm" | "bun" | "source";
export type UpdateJobStatus = "running" | "restarting" | "succeeded" | "failed";
diff --git a/gui/src/remote-workspace-command.ts b/gui/src/remote-workspace-command.ts
new file mode 100644
index 0000000000..050950d1c7
--- /dev/null
+++ b/gui/src/remote-workspace-command.ts
@@ -0,0 +1,18 @@
+function posixQuote(value: string): string {
+ return `'${value.replace(/'/g, `'"'"'`)}'`;
+}
+
+function powershellQuote(value: string): string {
+ return `'${value.replace(/'/g, "''")}'`;
+}
+
+/** Technical shell samples; kept outside JSX/i18n because command syntax is not prose. */
+export function remoteWorkspacePairingCommands(code: string, hubOrigin: string): {
+ posix: string;
+ powershell: string;
+} {
+ return {
+ posix: `printf '%s\\n' ${posixQuote(code)} | ocx remote-workspace pair ${posixQuote(hubOrigin)} --pairing-code-stdin --root "$PWD" && ocx remote-workspace agent`,
+ powershell: `$pairingCode = ${powershellQuote(code)}; $pairingCode | ocx remote-workspace pair ${powershellQuote(hubOrigin)} --pairing-code-stdin --root (Get-Location).Path; if ($LASTEXITCODE -eq 0) { ocx remote-workspace agent }`,
+ };
+}
diff --git a/gui/src/styles-remote-workspace.css b/gui/src/styles-remote-workspace.css
new file mode 100644
index 0000000000..10adef67e2
--- /dev/null
+++ b/gui/src/styles-remote-workspace.css
@@ -0,0 +1,75 @@
+.remote-workspace-page .page-head { align-items: flex-start; }
+.remote-workspace-page .page-sub { margin-bottom: 0; }
+.remote-workspace-grid { display: grid; grid-template-columns: minmax(280px, .82fr) minmax(420px, 1.35fr); gap: 14px; align-items: start; }
+.remote-workspace-column { display: grid; gap: 14px; min-width: 0; }
+.remote-panel-head { display: flex; align-items: flex-start; gap: 12px; }
+.remote-panel-head h3, .remote-section-title h3 { margin: 0; font-size: var(--text-section); }
+.remote-panel-head p { margin: 3px 0 0; color: var(--muted); font-size: var(--text-label); line-height: var(--leading-body); }
+.remote-icon { display: grid; place-items: center; width: 34px; height: 34px; flex: 0 0 auto; border-radius: var(--radius-sm); color: var(--accent); background: var(--accent-soft); }
+.remote-icon svg { width: 18px; height: 18px; }
+.remote-pair-card { display: grid; gap: 14px; }
+.remote-pair-card > .btn { justify-self: start; }
+.remote-pairing-result { display: grid; gap: 7px; padding-top: 12px; border-top: 1px solid var(--border-soft); }
+.remote-pairing-code { font-family: var(--font-code); font-size: clamp(21px, 3vw, 28px); font-weight: 700; letter-spacing: .11em; color: var(--text); }
+.remote-expiry { margin: -3px 0 4px; color: var(--muted); font-size: var(--text-caption); }
+.remote-pairing-result pre { max-width: 100%; margin: 0; padding: 11px; overflow: auto; border: 1px solid var(--border); border-radius: var(--radius-sm); background: var(--raised); font-size: var(--text-caption); line-height: 1.5; white-space: pre-wrap; overflow-wrap: anywhere; }
+.remote-section-title { display: flex; align-items: center; justify-content: space-between; gap: 12px; margin-bottom: 12px; }
+.remote-section-title > span { color: var(--muted); font-size: var(--text-label); }
+.remote-section-title svg { width: 18px; height: 18px; color: var(--muted); }
+.remote-section-title small { display: block; margin-top: 3px; color: var(--muted); font-size: var(--text-caption); }
+.remote-device-list { display: grid; gap: 6px; }
+.remote-device { display: flex; width: 100%; min-width: 0; align-items: center; gap: 4px; padding: 3px; border: 1px solid transparent; border-radius: var(--radius-sm); background: var(--raised); color: var(--text); }
+.remote-device:hover { background: var(--raised-hover); }
+.remote-device.selected { border-color: var(--accent-ring); background: var(--accent-soft); }
+.remote-device-main { display: flex; flex: 1; min-width: 0; align-items: center; gap: 10px; padding: 7px; border: 0; background: transparent; color: inherit; cursor: pointer; text-align: left; }
+.remote-device-main:focus-visible { outline: 2px solid var(--accent-ring); outline-offset: -1px; border-radius: var(--radius-xs); }
+.remote-online-dot { width: 9px; height: 9px; flex: 0 0 auto; border-radius: 50%; background: var(--faint); box-shadow: 0 0 0 3px color-mix(in srgb, var(--faint) 14%, transparent); }
+.remote-online-dot.online { background: var(--green); box-shadow: 0 0 0 3px color-mix(in srgb, var(--green) 14%, transparent); }
+.remote-device-copy { display: grid; flex: 1; min-width: 0; }
+.remote-device-copy strong, .remote-device-copy small { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+.remote-device-copy small { margin-top: 2px; color: var(--muted); font-size: var(--text-caption); }
+.remote-revoke { display: grid; place-items: center; width: 30px; height: 30px; flex: 0 0 auto; border-radius: var(--radius-xs); color: var(--muted); }
+.remote-revoke:hover, .remote-revoke:focus-visible { color: var(--red); background: var(--red-soft); outline: none; }
+.remote-revoke svg { width: 15px; height: 15px; }
+.remote-empty { margin: 12px 0 0; color: var(--muted); font-size: var(--text-label); text-align: center; }
+.remote-empty--console { display: grid; min-height: 260px; place-items: center; margin: 0; }
+.remote-launch-fields { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 10px; }
+.remote-launch-fields label { min-width: 0; }
+.remote-launch-fields .select { width: 100%; }
+.remote-execution-map { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 8px; margin-top: 12px; }
+.remote-execution-map > span { display: grid; gap: 2px; min-width: 0; padding: 9px 10px; border: 1px solid var(--border-soft); border-radius: var(--radius-sm); background: var(--raised); color: var(--muted); font-size: var(--text-caption); }
+.remote-execution-map strong { overflow: hidden; color: var(--text); font-size: var(--text-control); text-overflow: ellipsis; white-space: nowrap; }
+.remote-runtime-reason { margin: 10px 0 0; color: var(--red); font-size: var(--text-caption); line-height: var(--leading-body); }
+.remote-launch-panel > .notice { margin-top: 10px; }
+.remote-start { width: 100%; margin-top: 12px; }
+.remote-console-panel { min-height: 440px; }
+.remote-status { padding: 4px 8px; border-radius: var(--radius-pill); background: var(--raised); color: var(--muted); font-size: var(--text-caption); font-weight: var(--weight-semibold); white-space: nowrap; }
+.remote-status--ready { color: var(--green); background: color-mix(in srgb, var(--green) 11%, transparent); }
+.remote-status--running, .remote-status--starting { color: var(--accent); background: var(--accent-soft); }
+.remote-status--failed { color: var(--red); background: var(--red-soft); }
+.remote-status--waiting_for_executor { color: var(--amber); background: color-mix(in srgb, var(--amber) 12%, transparent); }
+.remote-events { display: flex; min-height: 240px; max-height: min(48vh, 520px); flex-direction: column; gap: 8px; margin-top: 10px; padding: 10px; overflow: auto; border: 1px solid var(--border); border-radius: var(--radius-sm); background: var(--bg); content-visibility: auto; }
+.remote-console-panel > .notice { margin-top: 10px; }
+.remote-event { max-width: 88%; padding: 8px 10px; border-radius: var(--radius-sm); background: var(--raised); }
+.remote-event > span { color: var(--muted); font-size: var(--text-caption); font-weight: var(--weight-semibold); }
+.remote-event > p { margin: 3px 0 0; white-space: pre-wrap; overflow-wrap: anywhere; font-size: var(--text-control); line-height: var(--leading-body); }
+.remote-event--assistant { align-self: flex-start; border-left: 2px solid var(--accent); }
+.remote-event--tool { align-self: center; max-width: 96%; background: color-mix(in srgb, var(--accent) 6%, var(--raised)); }
+.remote-event--error { border-left: 2px solid var(--red); background: var(--red-soft); }
+.remote-composer { display: block; margin-top: 12px; }
+.remote-composer textarea { min-height: 92px; font-family: var(--font-sans); }
+.remote-console-actions { display: flex; justify-content: flex-end; gap: 8px; margin-top: 9px; }
+
+@media (max-width: 980px) {
+ .remote-workspace-grid { grid-template-columns: 1fr; }
+}
+@media (max-width: 620px) {
+ .remote-workspace-page .page-head { align-items: stretch; flex-direction: column; }
+ .remote-workspace-page .page-head > .btn { width: 100%; }
+ .remote-launch-fields { grid-template-columns: 1fr; }
+ .remote-execution-map { grid-template-columns: 1fr; }
+ .remote-console-actions { display: grid; grid-template-columns: 1fr 1fr; }
+ .remote-console-actions .btn { min-height: 44px; }
+ .remote-event { max-width: 100%; }
+ .remote-pairing-code { letter-spacing: .05em; }
+}
diff --git a/gui/src/styles.css b/gui/src/styles.css
index 1a51a2f40e..0899d77859 100644
--- a/gui/src/styles.css
+++ b/gui/src/styles.css
@@ -24,6 +24,7 @@
@import "./styles-apikeys-workspace.css";
@import "./styles-codex-set.css";
@import "./styles-compatibility-matrix.css";
+@import "./styles-remote-workspace.css";
:root {
/* default: follow the OS; [data-theme] below pins color-scheme so light-dark() obeys it */
diff --git a/gui/src/usage-summary-resource.ts b/gui/src/usage-summary-resource.ts
index 568860230c..e427258194 100644
--- a/gui/src/usage-summary-resource.ts
+++ b/gui/src/usage-summary-resource.ts
@@ -1,3 +1,18 @@
+/** Positive diagnostics only: an older response without the flag proves no completeness. */
+export interface UsageReadMetadata {
+ usageIncomplete?: true;
+ usageIncompleteReason?: "oversized_rows";
+}
+
+export function readUsageMetadata(value: unknown): UsageReadMetadata {
+ if (!value || typeof value !== "object" || !("usageIncomplete" in value) || value.usageIncomplete !== true) return {};
+ return {
+ usageIncomplete: true,
+ ...("usageIncompleteReason" in value && value.usageIncompleteReason === "oversized_rows"
+ ? { usageIncompleteReason: "oversized_rows" as const } : {}),
+ };
+}
+
export function usageSummary30dResourceKey(apiBase: string, surface: "all" | "codex" = "all"): string {
return surface === "codex"
? ["usage-summary-30d", apiBase, "codex"].join(":")
diff --git a/gui/tests/account-pool-strategy.test.tsx b/gui/tests/account-pool-strategy.test.tsx
index 5f98969f99..1eb9ebcb5d 100644
--- a/gui/tests/account-pool-strategy.test.tsx
+++ b/gui/tests/account-pool-strategy.test.tsx
@@ -1,4 +1,4 @@
-import { putCodexPoolStrategy } from "../src/pool-settings";
+import { getPoolSettings, putPoolSettings, putCodexPoolStrategy } from "../src/pool-settings";
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
import { Window } from "happy-dom";
import { act } from "react";
@@ -91,6 +91,7 @@ describe("account pool strategy helpers", () => {
expect(normalizeAccountPoolStrategy("quota")).toBe("quota");
expect(normalizeAccountPoolStrategy("round-robin")).toBe("round-robin");
expect(normalizeAccountPoolStrategy("fill-first")).toBe("fill-first");
+ expect(normalizeAccountPoolStrategy("reset-first")).toBe("reset-first");
expect(normalizeAccountPoolStrategy("weighted")).toBe(DEFAULT_ACCOUNT_POOL_STRATEGY);
expect(normalizeAccountPoolStrategy(undefined)).toBe("quota");
});
@@ -180,6 +181,19 @@ describe("AccountPoolStrategyControls", () => {
expect(rr).toContain('value="2"');
});
+ test("reset-first renders the dual-window threshold explanation", () => {
+ const markup = renderToStaticMarkup(
+
+ {}} onStickyDraftChange={() => {}} onStickyCommit={() => {}} />
+ ,
+ );
+ expect(markup).toContain("Soonest reset first");
+ expect(markup).toContain("nearest future 5-hour or weekly reset");
+ expect(markup).toContain("Bound tasks follow the configured affinity policy");
+ expect(markup).not.toContain("New/unbound assignments before rotate");
+ });
+
test("renders a canonical setting row: visible name, control beside it, no sr-only label", () => {
const markup = renderToStaticMarkup(
@@ -518,3 +532,14 @@ describe("CodexPoolStrategySetting optimistic strategy select", () => {
expect(select?.getAttribute("aria-label")).toBe("Rotation strategy");
});
});
+
+
+test("canonical reset-first settings survive a read and an empty successful write", async () => {
+ const read = await getPoolSettings("", "openai", async () => Response.json({ provider: "openai", kind: "codex", strategy: "reset-first", stickyLimit: 1 }));
+ expect(read?.strategy).toBe("reset-first");
+ const written = await putPoolSettings("", "openai", { strategy: "reset-first" }, async (_url, init) => {
+ expect(JSON.parse(String(init?.body))).toMatchObject({ provider: "openai", strategy: "reset-first" });
+ return new Response(null, { status: 204 });
+ });
+ expect(written?.strategy).toBe("reset-first");
+});
diff --git a/gui/tests/apikeys-workspace.test.tsx b/gui/tests/apikeys-workspace.test.tsx
index 56ffeacaf1..4695e09430 100644
--- a/gui/tests/apikeys-workspace.test.tsx
+++ b/gui/tests/apikeys-workspace.test.tsx
@@ -135,6 +135,26 @@ function keyButton(container: HTMLElement, name: string): HTMLButtonElement {
.find(el => el.textContent === name)!;
}
+test("incomplete usage qualifies key list and detail without asserting never used", async () => {
+ const { root, container, rerender } = await mountWorkspace({
+ usageMetadata: { usageIncomplete: true, usageIncompleteReason: "oversized_rows" },
+ });
+ try {
+ expect(container.textContent).toContain("Some usage records could not be included");
+ expect(container.textContent).toContain("No use in readable records");
+ await act(async () => { keyButton(container, "beta").click(); });
+ expect(container.textContent).toContain("Some usage records could not be included");
+ expect(container.textContent).toContain("Requests in available history");
+ expect(container.textContent).toContain("No use in readable records");
+ await rerender({ attributionSince: undefined });
+ expect(container.textContent).toContain("Some usage records could not be included");
+ await rerender({ keys: [] });
+ expect(container.textContent).toContain("Some usage records could not be included");
+ await rerender({ usageMetadata: {} });
+ expect(container.textContent).not.toContain("Some usage records could not be included");
+ } finally { await act(async () => { root.unmount(); }); }
+});
+
test("workspace overview navigation preserves pending secret and resets delete confirm", async () => {
const { root, container } = await mountWorkspace({
newKey: FULL_SECRET,
diff --git a/gui/tests/codex-account-pool-pinned-badge.test.tsx b/gui/tests/codex-account-pool-pinned-badge.test.tsx
index 9702d7aff9..c622a5f793 100644
--- a/gui/tests/codex-account-pool-pinned-badge.test.tsx
+++ b/gui/tests/codex-account-pool-pinned-badge.test.tsx
@@ -280,3 +280,40 @@ test("healthy account cards omit log-label and 30-day usage copy", async () => {
expect(main.textContent).not.toContain("Log label: main");
expect(hasPinnedHint(main)).toBe(false);
});
+
+
+test("plan exclusion is visible without presenting the account as the next automatic selection", async () => {
+ await mountPool(makeController({
+ accounts: [mainAccount, { ...account, plan: "plus", selectionExcludedReason: "plan_excluded", selectionExcludedPlan: "free" }],
+ activeId: account.id,
+ }));
+ const card = cardFor(account.email);
+ const excluded = [...card.querySelectorAll(".badge")].find(el => el.textContent === en["codexAuth.planExcluded"]);
+ expect(excluded).toBeTruthy();
+ expect(excluded!.getAttribute("title")).toContain("free");
+ expect([...card.querySelectorAll(".badge")].some(el => el.textContent === en["codexAuth.nextSession"])).toBe(false);
+ expect(card.textContent).not.toContain(en["codexAuth.paused"]);
+ expect(switchAction(card)).toBeNull();
+ await act(async () => {
+ root!.render( );
+ });
+ expect(cardFor(account.email).textContent).not.toContain(en["codexAuth.planExcluded"]);
+});
+
+
+test("eligible next-session badge coexists with reset tickets while plan exclusion only removes selection", async () => {
+ const eligible = { ...account, plan: "plus", quota: { weeklyPercent: 10, resetCredits: 2, updatedAt: Date.now() } };
+ await mountPool(makeController({ accounts: [mainAccount, eligible], activeId: eligible.id }));
+ const current = cardFor(account.email);
+ expect([...current.querySelectorAll(".badge")].some(el => el.textContent === en["codexAuth.nextSession"])).toBe(true);
+ expect(current.querySelector(".badge-clickable")).not.toBeNull();
+ await act(async () => {
+ root!.render( );
+ });
+ const excluded = cardFor(account.email);
+ expect([...excluded.querySelectorAll(".badge")].some(el => el.textContent === en["codexAuth.nextSession"])).toBe(false);
+ expect(excluded.querySelector(".badge-clickable")).not.toBeNull();
+});
diff --git a/gui/tests/fr-localization.test.ts b/gui/tests/fr-localization.test.ts
index 353cd2b670..689a1b8672 100644
--- a/gui/tests/fr-localization.test.ts
+++ b/gui/tests/fr-localization.test.ts
@@ -22,6 +22,10 @@ const INTENTIONAL_ENGLISH = new Set([
// than prose. Translating it would invent a difference the UI does not have.
"models.aliasAuto",
"common.github",
+ // Product names and ordinary French words whose correct spelling is identical to English.
+ "remote.pairingCommandWindows",
+ "remote.sessions",
+ "remote.prompt",
// A filename and a product name. "AGENTS.md" is the literal file Codex reads,
// and translating "Plugins" would invent a difference French does not have.
"codexSet.layer.agents-md",
@@ -138,6 +142,10 @@ const INTENTIONAL_ENGLISH = new Set([
"claudeDesktop.supports1m",
"claudeDesktop.effort.supported",
// Correct French words whose spelling is identical to English.
+ // "Code" is the same word in French, and the surrounding device-reauth copy already
+ // uses it ("code appareil", "Code de l'appareil"). Inventing a different label just
+ // to make the strings differ would be worse copy for a French reader.
+ "codexAuth.mainReauthCode",
"routing.exclusions",
"routing.score",
"dash.actions",
diff --git a/gui/tests/locale-parity.test.ts b/gui/tests/locale-parity.test.ts
index 745440fcc8..cc602a8a9c 100644
--- a/gui/tests/locale-parity.test.ts
+++ b/gui/tests/locale-parity.test.ts
@@ -112,6 +112,8 @@ const ZH_TW_KEEP_ENGLISH: ReadonlySet = new Set([
"codexAuth.codexApp",
"codexAuth.creditNextBadge",
"common.github",
+ // Windows PowerShell is a product name, not an untranslated prose placeholder.
+ "remote.pairingCommandWindows",
"grok.title",
// Integration tabs: client/product proper nouns kept in English
"integrations.tab.codex",
diff --git a/gui/tests/main-device-reauth.test.tsx b/gui/tests/main-device-reauth.test.tsx
new file mode 100644
index 0000000000..ab883686c4
--- /dev/null
+++ b/gui/tests/main-device-reauth.test.tsx
@@ -0,0 +1,169 @@
+import { afterEach, beforeEach, expect, test } from "bun:test";
+import { Window } from "happy-dom";
+import { act, useEffect } from "react";
+import type { Root } from "react-dom/client";
+import { LanguageProvider } from "../src/i18n/provider";
+import { CodexAccountPoolMainCard } from "../src/components/codex-account-pool-main-card";
+import { useMainDeviceReauth, type MainDeviceReauthState } from "../src/components/use-main-device-reauth";
+import type { CodexAccountEntry } from "../src/components/codex-account-pool-types";
+
+/**
+ * #3898 L3: the main card gets a device-code Re-login that drives ONLY the
+ * dedicated native-main namespace. Pool Add/Re-login and the native profile
+ * picker stay on their own paths.
+ */
+
+const DEVICE_URL = "https://auth.openai.com/codex/device";
+const DEVICE_CODE = "ABCD-1234";
+
+const globals = ["document", "window", "navigator", "localStorage", "IS_REACT_ACT_ENVIRONMENT"] as const;
+let previous: Record<(typeof globals)[number], unknown>;
+let win: Window;
+let host: HTMLElement;
+let root: Root | null = null;
+let originalFetch: typeof globalThis.fetch;
+let requests: Array<{ method: string; url: string }>;
+
+const t = ((key: string) => key) as never;
+
+function reauthMain(): CodexAccountEntry {
+ return { id: "__main__", isMain: true, needsReauth: true } as unknown as CodexAccountEntry;
+}
+
+function cardProps(state: MainDeviceReauthState, calls: { starts: number; cancels: number }) {
+ return {
+ t,
+ main: reauthMain(),
+ isMainActive: false,
+ accountModeState: null,
+ threshold: 80,
+ switchActionLabel: "Switch",
+ onSwitch: () => {},
+ onTogglePause: () => {},
+ pauseUpdatingId: null,
+ pauseBusy: false,
+ onPriorityChange: () => {},
+ priorityUpdatingId: null,
+ switchingId: null,
+ mainReauth: {
+ state,
+ start: async () => { calls.starts += 1; },
+ cancel: async () => { calls.cancels += 1; },
+ },
+ } as never;
+}
+
+beforeEach(() => {
+ previous = Object.fromEntries(globals.map((k) => [k, Reflect.get(globalThis, k)])) as typeof previous;
+ win = new Window({ url: "http://localhost/" });
+ Object.defineProperty(win.navigator, "language", { configurable: true, value: "en-US" });
+ Object.defineProperties(globalThis, {
+ document: { configurable: true, value: win.document },
+ window: { configurable: true, value: win },
+ navigator: { configurable: true, value: win.navigator },
+ localStorage: { configurable: true, value: win.localStorage },
+ });
+ (globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
+ originalFetch = globalThis.fetch;
+ requests = [];
+ host = win.document.createElement("div");
+ win.document.body.appendChild(host);
+});
+
+afterEach(async () => {
+ if (root) { const r = root; root = null; await act(async () => r.unmount()); }
+ host.remove();
+ Object.defineProperty(globalThis, "fetch", { configurable: true, value: originalFetch });
+ for (const k of globals) Object.defineProperty(globalThis, k, { configurable: true, value: previous[k] });
+});
+
+async function mount(ui: Parameters[1], state: MainDeviceReauthState): Promise {
+ const { createRoot } = await import("react-dom/client");
+ const { createElement } = await import("react");
+ await act(async () => {
+ root = createRoot(host);
+ root.render(createElement(LanguageProvider, null, createElement(CodexAccountPoolMainCard, cardProps(state, ui))));
+ });
+}
+
+test("expired main card shows the device Re-login CTA and starts the flow", async () => {
+ const calls = { starts: 0, cancels: 0 };
+ await mount(calls, { phase: "idle" });
+ // The pause control ships the same class and renders first, so select the CTA by its
+ // label the way the cancel test below already does.
+ const actions = Array.from(host.querySelectorAll("button.codex-auth-action-btn"));
+ const button = actions.find(b => b.textContent?.includes("mainReauthDevice"));
+ expect(button).toBeDefined();
+ expect(host.textContent).toContain("codexAuth.mainTokenExpired");
+ expect(host.textContent).toContain("codexAuth.mainReauthDevice");
+ await act(async () => { (button as HTMLButtonElement).click(); });
+ expect(calls.starts).toBe(1);
+ expect(calls.cancels).toBe(0);
+});
+
+test("a pending flow shows the URL and human code and cancel owns the flow", async () => {
+ const calls = { starts: 0, cancels: 0 };
+ await mount(calls, { phase: "pending", flowId: "f1", verificationUrl: DEVICE_URL, deviceCode: DEVICE_CODE });
+ expect(host.textContent).toContain(DEVICE_URL);
+ expect(host.textContent).toContain(DEVICE_CODE);
+ const buttons = Array.from(host.querySelectorAll("button.codex-auth-action-btn"));
+ const cancel = buttons.find(b => b.textContent?.includes("mainReauthCancel"));
+ expect(cancel).toBeDefined();
+ await act(async () => { (cancel as HTMLButtonElement).click(); });
+ expect(calls.cancels).toBe(1);
+});
+
+test("the hook POSTs an empty body to the dedicated route and polls to success", async () => {
+ Object.defineProperty(globalThis, "fetch", {
+ configurable: true,
+ value: async (input: RequestInfo | URL, init?: RequestInit) => {
+ const url = String(input);
+ requests.push({ method: init?.method ?? "GET", url });
+ if (url.endsWith("/api/codex-auth/main/reauth-device") && init?.method === "POST") {
+ expect(init?.body).toBeUndefined();
+ return Response.json({ flowId: "f1", status: "pending", verificationUrl: "", deviceCode: "" });
+ }
+ if (url.includes("/api/codex-auth/main/reauth-device?flowId=") && init?.method !== "DELETE") {
+ return Response.json({ flowId: "f1", status: "pending", verificationUrl: DEVICE_URL, deviceCode: DEVICE_CODE });
+ }
+ return Response.json({ flowId: "f1", status: "cancelled" });
+ },
+ });
+ let completed = 0;
+ let captured: { state: MainDeviceReauthState; start: () => Promise; cancel: () => Promise } | null = null;
+ const Probe = () => {
+ const value = useMainDeviceReauth("", () => { completed += 1; });
+ // Publish from an effect, not during render: assigning an outer binding while
+ // rendering is exactly what the React compiler rejects. act() flushes effects,
+ // so every assertion below still reads the latest committed value.
+ useEffect(() => { captured = value; });
+ return null;
+ };
+ const { createRoot } = await import("react-dom/client");
+ const { createElement } = await import("react");
+ await act(async () => {
+ root = createRoot(host);
+ root.render(createElement(LanguageProvider, null, createElement(Probe)));
+ });
+ expect(captured).not.toBeNull();
+ // start() owns the flow until a terminal status, and this mock stays pending forever,
+ // so drive it and wait for the first poll to land instead of awaiting completion.
+ await act(async () => {
+ void captured!.start();
+ const deadline = Date.now() + 2000;
+ while (captured!.state.phase !== "pending" && Date.now() < deadline) {
+ await new Promise(resolve => setTimeout(resolve, 5));
+ }
+ });
+ expect(requests[0]).toEqual({ method: "POST", url: "/api/codex-auth/main/reauth-device" });
+ expect(JSON.stringify(requests)).not.toContain("/api/codex-auth/login");
+ const state = captured!.state;
+ expect(state.phase).toBe("pending");
+ if (state.phase === "pending") {
+ expect(state.verificationUrl).toBe(DEVICE_URL);
+ expect(state.deviceCode).toBe(DEVICE_CODE);
+ }
+ await act(async () => { await captured!.cancel(); });
+ expect(captured!.state.phase).toBe("cancelled");
+ expect(requests.some(r => r.method === "DELETE")).toBe(true);
+});
diff --git a/gui/tests/model-picker-order-editor.test.tsx b/gui/tests/model-picker-order-editor.test.tsx
index ea9f808cb7..0789264470 100644
--- a/gui/tests/model-picker-order-editor.test.tsx
+++ b/gui/tests/model-picker-order-editor.test.tsx
@@ -388,3 +388,39 @@ test("Models pins cache-inferred Custom across late parent GET publication, then
await act(async () => { root!.render( ); });
expect(host.querySelector(".picker-order-editor")).toBeNull();
});
+
+test("Models refuses an incomplete most-used snapshot before PUT and accepts a later readable snapshot", async () => {
+ const modelRows = ids.map(row => ({ ...row, disabled: false }));
+ const catalog = { models: modelRows, providers: [{ name: "p" }], selectedModels: {}, disabled: [], contextCaps: {}, contextCapValue: 350_000 };
+ const settings = { ...initial(), pickerOrderMode: "most-used" };
+ win.sessionStorage.setItem("ocx.models.catalog.v1:/a", JSON.stringify(catalog));
+ win.sessionStorage.setItem("ocx.models.catalog.v1:/a:picker-order", JSON.stringify(settings));
+ const deferredFetch = globalThis.fetch;
+ Object.defineProperty(globalThis, "fetch", { configurable: true, value: (input: RequestInfo | URL, init?: RequestInit) => {
+ const path = String(input);
+ if (path.includes("/api/usage?") || init?.method === "PUT") return deferredFetch(input, init);
+ const payload = path.endsWith("/api/subagent-models") ? settings
+ : path.endsWith("/api/models") ? modelRows
+ : path.endsWith("/api/providers") ? catalog.providers
+ : path.endsWith("/api/provider-context-caps") ? { caps: {} }
+ : path.endsWith("/api/selected-models") ? { selected: {} }
+ : path.endsWith("/api/aliases") ? { providers: {}, models: {}, defaults: { global: false, providers: {} } }
+ : undefined;
+ return Promise.resolve(payload === undefined ? new Response(null, { status: 404 }) : Response.json(payload));
+ } });
+ const { createRoot } = await import("react-dom/client");
+ await act(async () => { root = createRoot(host); root.render( ); });
+ await click("Apply order");
+ expect(requests[0]?.url).toBe("/a/api/usage?range=all&surface=all");
+ const models = [{ provider: "p", model: "b", requests: 3 }];
+ await reply(0, { models, usageIncomplete: true, usageIncompleteReason: "oversized_rows" });
+ expect(host.textContent).toContain("Cannot save most-used order because usage history is incomplete");
+ expect(requests.map(r => r.method)).toEqual(["GET"]);
+ expect(button("Apply order").disabled).toBe(false);
+ await click("Apply order");
+ await reply(1, { models });
+ expect(requests[2]?.url).toBe("/a/api/subagent-models");
+ expect(requests[2]?.method).toBe("PUT");
+ expect(requests[2]?.body).toEqual({ pickerOrder: ["p/b", "p/a", "p/c", "p/f"], pickerOrderMode: "most-used" });
+ await reply(2, { ok: true, pickerOrder: ["p/b", "p/a", "p/c", "p/f"], pickerOrderMode: "most-used" });
+});
diff --git a/gui/tests/provider-account-quota-loading.test.tsx b/gui/tests/provider-account-quota-loading.test.tsx
index bfd811ae7f..d13fc9040a 100644
--- a/gui/tests/provider-account-quota-loading.test.tsx
+++ b/gui/tests/provider-account-quota-loading.test.tsx
@@ -417,3 +417,93 @@ test("a hanging fetch reaches its deadline, preserves last-good and clears probe
else Reflect.deleteProperty(AbortSignal, "timeout");
}
});
+
+
+test("quota diagnostics normalize and clear on recovery or management failure", async () => {
+ let failure: string | undefined = "dns_failed";
+ let localFailure = false;
+ respond = async url => url.includes("quota=1") && localFailure ? new Response(null, { status: 503 }) : Response.json({
+ accounts: [{ id: "account", active: true, quotaMode: "probe", quota: reading,
+ ...(url.includes("quota=1") ? { quotaUnavailable: failure !== undefined, quotaFailure: failure } : {}),
+ }],
+ });
+ await act(async () => { await pools.fetchAccountSets(["oauth"], true); });
+ expect(pools.accountSets.oauth.accounts[0].quotaFailure).toBe("dns_failed");
+ failure = "private-unrecognized-error";
+ await act(async () => { await pools.fetchAccountSets(["oauth"], true); });
+ expect(pools.accountSets.oauth.accounts[0].quotaFailure).toBeUndefined();
+ failure = "rate_limited";
+ await act(async () => { await pools.fetchAccountSets(["oauth"], true); });
+ expect(pools.accountSets.oauth.accounts[0].quotaFailure).toBe("rate_limited");
+ localFailure = true;
+ await act(async () => { await pools.fetchAccountSets(["oauth"], true); });
+ expect(pools.accountSets.oauth.accounts[0]).toMatchObject({ quotaUnavailable: true, quota: reading });
+ expect(pools.accountSets.oauth.accounts[0].quotaFailure).toBeUndefined();
+ localFailure = false;
+ failure = undefined;
+ await act(async () => { expect(await pools.fetchAccountSets(["oauth"], true)).toBe(true); });
+ expect(pools.accountSets.oauth.accounts[0]).toMatchObject({ quotaUnavailable: false, quota: reading });
+ expect(pools.accountSets.oauth.accounts[0].quotaFailure).toBeUndefined();
+});
+
+test("initial roster failure clears a settled diagnosis while retaining bars", async () => {
+ await act(async () => {
+ pools.setAccountSets({ fixture: { activeAccountId: "a", accounts: [
+ { id: "a", active: true, quotaMode: "probe", quota: reading, quotaUnavailable: true, quotaFailure: "dns_failed" },
+ ] } });
+ });
+ respond = async () => new Response(null, { status: 503 });
+ await act(async () => { expect(await pools.fetchAccountSets(["fixture"], true)).toBe(false); });
+ expect(pools.accountSets.fixture.accounts[0]).toMatchObject({ quota: reading, quotaUnavailable: true });
+ expect(pools.accountSets.fixture.accounts[0].quotaFailure).toBeUndefined();
+});
+
+test("roster-only refresh retains a matching diagnosis and clears it after mode change", async () => {
+ await act(async () => {
+ pools.setAccountSets({ fixture: { activeAccountId: "a", accounts: [
+ { id: "a", active: true, quotaMode: "probe", quota: reading, quotaUnavailable: true, quotaFailure: "rate_limited" },
+ ] } });
+ });
+ let mode = "probe";
+ respond = async () => Response.json({ activeAccountId: "a", accounts: [{ id: "a", active: true, quotaMode: mode }] });
+ await act(async () => { await pools.refreshAccountRosters({ provider: "fixture", kind: "oauth" }); });
+ expect(pools.accountSets.fixture.accounts[0].quotaFailure).toBe("rate_limited");
+ mode = "passive";
+ await act(async () => { await pools.refreshAccountRosters({ provider: "fixture", kind: "oauth" }); });
+ expect(pools.accountSets.fixture.accounts[0].quotaFailure).toBeUndefined();
+ expect(requests.every(request => !request.url.includes("quota=1"))).toBe(true);
+});
+
+test.each([true, false])("late quota failure=%s preserves newer selection and matching membership", async failure => {
+ const response = deferred();
+ const started = deferred();
+ const original = ["a", "b", "removed"].map(id => ({ id, active: id === "a", quotaMode: "probe" }));
+ respond = async url => {
+ if (url.includes("quota=1")) { started.resolve(); return response.promise; }
+ return Response.json({ activeAccountId: "a", accounts: original });
+ };
+ let full!: Promise;
+ await act(async () => { full = pools.fetchAccountSets(["fixture"], true); await started.promise; });
+ const latest = original.filter(row => row.id !== "removed").map(row => ({ ...row, active: row.id === "b" }));
+ respond = async () => Response.json({ activeAccountId: "b", accounts: latest });
+ await act(async () => { await pools.refreshAccountRosters({ provider: "fixture", kind: "oauth" }); });
+ if (!failure) {
+ await act(async () => {
+ pools.setAccountSets(current => ({ ...current, fixture: { ...current.fixture, accounts: current.fixture.accounts.map(row => ({
+ ...row, quotaUnavailable: true, quotaFailure: "dns_failed",
+ })) } }));
+ });
+ }
+ await act(async () => {
+ response.resolve(Response.json({ activeAccountId: "a", accounts: original.map(row => ({
+ ...row, quota: reading, quotaUnavailable: failure, ...(failure ? { quotaFailure: "access_denied" } : {}),
+ })) }));
+ await full;
+ });
+ expect(pools.accountSets.fixture.accounts.map(row => row.id)).toEqual(["a", "b"]);
+ expect(pools.accountSets.fixture.accounts.find(row => row.active)?.id).toBe("b");
+ for (const row of pools.accountSets.fixture.accounts) {
+ expect(row.quotaFailure).toBe(failure ? "access_denied" : undefined);
+ expect(row.quotaUnavailable).toBe(failure);
+ }
+});
diff --git a/gui/tests/provider-current-quota.test.tsx b/gui/tests/provider-current-quota.test.tsx
index 4acb16fc5f..b36c7fa33f 100644
--- a/gui/tests/provider-current-quota.test.tsx
+++ b/gui/tests/provider-current-quota.test.tsx
@@ -100,3 +100,19 @@ test("unsupported, passive unobserved, explicit loading and failed last-good are
expect(failed).toContain("12% used");
expect(failed).toContain("Quota updated");
});
+
+
+test("safe account failure categories reach both current and all-account quota views", () => {
+ const quota = { weeklyPercent: 12, updatedAt: observedAt };
+ const reading = { quotaMode: "probe" as const, quota, quotaUnavailable: true, quotaFailure: "dns_failed" as const };
+ for (const view of [ , ]) {
+ const markup = render(view);
+ expect(markup).toContain("The quota hostname could not be resolved.");
+ expect(markup).toContain("12% used");
+ }
+ const unknown = render( );
+ expect(unknown).not.toContain("private-error");
+ expect(unknown).not.toContain("hostname");
+ const recovered = render( );
+ expect(recovered).not.toContain("hostname");
+});
diff --git a/gui/tests/remote-workspace.test.tsx b/gui/tests/remote-workspace.test.tsx
new file mode 100644
index 0000000000..6d409570fa
--- /dev/null
+++ b/gui/tests/remote-workspace.test.tsx
@@ -0,0 +1,373 @@
+import { afterEach, beforeEach, expect, test } from "bun:test";
+import { Window } from "happy-dom";
+import type { Root } from "react-dom/client";
+import { readPageFromHash } from "../src/app-routing";
+import { remoteWorkspacePairingCommands } from "../src/remote-workspace-command";
+
+const DEVICE_ID = "11111111-1111-4111-8111-111111111111";
+const ROOT_ID = "22222222-2222-4222-8222-222222222222";
+const SESSION_ID = "33333333-3333-4333-8333-333333333333";
+
+test("#remote resolves to the Remote Workspace page", () => {
+ expect(readPageFromHash("#remote")).toBe("remote");
+});
+
+test("pairing commands use native POSIX and PowerShell syntax", () => {
+ const commands = remoteWorkspacePairingCommands("ABCD-EFGH-JKLM", "https://hub.example.test/a'b");
+ expect(commands.posix).toContain(`'https://hub.example.test/a'"'"'b'`);
+ expect(commands.posix).toContain('--root "$PWD"');
+ expect(commands.powershell).toContain("'https://hub.example.test/a''b'");
+ expect(commands.powershell).toContain("--root (Get-Location).Path");
+ expect(commands.powershell).toContain("$LASTEXITCODE -eq 0");
+});
+
+let win: Window;
+let root: Root | null = null;
+let previous: Record;
+const globals = ["window", "document", "navigator", "localStorage", "fetch", "IS_REACT_ACT_ENVIRONMENT"] as const;
+
+beforeEach(() => {
+ previous = Object.fromEntries(globals.map(key => [key, Reflect.get(globalThis, key)]));
+ win = new Window({ url: "http://localhost/#remote" });
+ Object.defineProperties(globalThis, {
+ window: { configurable: true, value: win },
+ document: { configurable: true, value: win.document },
+ navigator: { configurable: true, value: win.navigator },
+ localStorage: { configurable: true, value: win.localStorage },
+ IS_REACT_ACT_ENVIRONMENT: { configurable: true, value: true },
+ });
+});
+
+afterEach(async () => {
+ if (root) {
+ const current = root;
+ const { act } = await import("react");
+ await act(async () => current.unmount());
+ root = null;
+ }
+ for (const key of globals) Object.defineProperty(globalThis, key, { configurable: true, value: previous[key] });
+});
+
+test("pairs a computer and starts a Hub-owned session for its selected folder", async () => {
+ const calls: Array<{ url: string; method: string; body?: unknown }> = [];
+ let session: Record | null = null;
+ let releaseLongPrompt: (() => void) | null = null;
+ const snapshot = () => ({
+ available: true,
+ devices: [{
+ id: DEVICE_ID,
+ name: "Computer 2",
+ platform: "win32-x64",
+ capabilities: ["workspace.read", "workspace.write"],
+ roots: [{ id: ROOT_ID, label: "Project" }],
+ online: true,
+ createdAt: "2026-01-01T00:00:00.000Z",
+ lastSeenAt: "2026-01-01T00:00:00.000Z",
+ }],
+ runtimes: {
+ codex: { available: true, version: "0.152.1" },
+ claude: { available: true, version: "2.1.223" },
+ pi: { available: true, version: "0.84.3" },
+ },
+ sessions: session ? [session] : [],
+ });
+ const response = (body: unknown, status = 200) => new Response(JSON.stringify(body), {
+ status,
+ headers: { "content-type": "application/json" },
+ });
+ Object.defineProperty(globalThis, "fetch", {
+ configurable: true,
+ value: async (input: RequestInfo | URL, init: RequestInit = {}) => {
+ const url = String(input);
+ const method = init.method ?? "GET";
+ const body = typeof init.body === "string" ? JSON.parse(init.body) : undefined;
+ calls.push({ url, method, body });
+ if (url.endsWith("/api/remote-workspace") && method === "GET") return response(snapshot());
+ if (url.endsWith("/pairing") && method === "POST") {
+ return response({ code: "ABCD-EFGH-JKLM", expiresAt: "2026-01-01T00:10:00.000Z" }, 201);
+ }
+ if (url.endsWith("/sessions") && method === "POST") {
+ session = {
+ id: SESSION_ID,
+ profile: "codex",
+ accessMode: "read-only",
+ deviceId: DEVICE_ID,
+ deviceName: "Computer 2",
+ rootId: ROOT_ID,
+ rootLabel: "Project",
+ capabilities: ["workspace.read"],
+ tools: ["list_directory", "read_file"],
+ threadId: "thread-1",
+ resumable: true,
+ status: "ready",
+ createdAt: "2026-01-01T00:00:00.000Z",
+ updatedAt: "2026-01-01T00:00:00.000Z",
+ events: [],
+ };
+ return response(session, 201);
+ }
+ if (url.endsWith(`/${SESSION_ID}/prompt`) && method === "POST") {
+ const longPrompt = (body as { prompt?: string } | undefined)?.prompt === "Keep working";
+ if (longPrompt) {
+ await new Promise(resolve => { releaseLongPrompt = resolve; });
+ }
+ const completed = {
+ ...session,
+ status: "ready",
+ events: [{ sequence: 1, at: "2026-01-01T00:00:01.000Z", type: "assistant", text: "Remote work completed" }],
+ };
+ // Simulate a response captured before DELETE completed. It can arrive late, but the
+ // server-side session remains stopped and the client must not resurrect its local copy.
+ if (!longPrompt) session = completed;
+ return response(completed);
+ }
+ if (url.endsWith(`/${SESSION_ID}`) && method === "DELETE") {
+ session = { ...session, status: "stopped" };
+ return response({ ok: true });
+ }
+ return response({ error: "unexpected request" }, 500);
+ },
+ });
+
+ const [{ act }, { createRoot }, { default: RemoteWorkspace }, { LanguageProvider }] = await Promise.all([
+ import("react"),
+ import("react-dom/client"),
+ import("../src/pages/RemoteWorkspace"),
+ import("../src/i18n/provider"),
+ ]);
+ const host = win.document.createElement("div") as unknown as HTMLElement;
+ win.document.body.appendChild(host as never);
+ await act(async () => {
+ root = createRoot(host);
+ root.render( );
+ await new Promise(resolve => setTimeout(resolve, 20));
+ });
+ expect(host.textContent).toContain("Computer 2");
+ expect(host.textContent).toContain("Codex");
+
+ const button = (label: string) => [...host.querySelectorAll("button")].find(element => element.textContent?.includes(label)) as HTMLButtonElement;
+ await act(async () => { button("Read only").click(); });
+ expect(win.document.body.textContent).toContain("Edit files only");
+ await act(async () => { button("Create pairing code").click(); await new Promise(resolve => setTimeout(resolve, 10)); });
+ expect(host.textContent).toContain("ABCD-EFGH-JKLM");
+ expect(host.querySelector("pre")?.textContent).toContain("https://actual-hub.example.test");
+ expect(host.querySelector("pre")?.textContent).not.toContain("http://localhost");
+ expect(host.textContent).toContain("Linux / macOS terminal");
+ expect(host.textContent).toContain("Windows PowerShell");
+
+ await act(async () => { button("Start remote session").click(); await new Promise(resolve => setTimeout(resolve, 20)); });
+ expect(host.textContent).toContain("Remote session is ready");
+ const textarea = host.querySelector("textarea") as HTMLTextAreaElement;
+ await act(async () => {
+ Object.getOwnPropertyDescriptor(win.HTMLTextAreaElement.prototype, "value")!.set!.call(textarea, "Inspect the project");
+ textarea.dispatchEvent(new win.Event("input", { bubbles: true }) as never);
+ });
+ await act(async () => { button("Send").click(); await new Promise(resolve => setTimeout(resolve, 20)); });
+ expect(host.textContent).toContain("Remote work completed");
+ expect(calls.some(call => call.method === "POST" && call.body && (call.body as { deviceId?: string }).deviceId === DEVICE_ID)).toBe(true);
+ expect(calls.some(call => (call.body as { accessMode?: string } | undefined)?.accessMode === "read-only")).toBe(true);
+
+ // A long prompt request must not disable Stop. The DELETE runs concurrently, and a late
+ // prompt response must not replace the locally stopped state with a stale ready snapshot.
+ await act(async () => {
+ Object.getOwnPropertyDescriptor(win.HTMLTextAreaElement.prototype, "value")!.set!.call(textarea, "Keep working");
+ textarea.dispatchEvent(new win.Event("input", { bubbles: true }) as never);
+ });
+ await act(async () => { button("Send").click(); await new Promise(resolve => setTimeout(resolve, 10)); });
+ expect(button("Stop").disabled).toBe(false);
+ await act(async () => { button("Stop").click(); await new Promise(resolve => setTimeout(resolve, 10)); });
+ expect(calls.some(call => call.method === "DELETE" && call.url.endsWith(`/${SESSION_ID}`))).toBe(true);
+ await act(async () => { releaseLongPrompt?.(); await new Promise(resolve => setTimeout(resolve, 10)); });
+ expect(host.textContent).toContain("Stopped");
+});
+
+async function mountRemotePage(apiBase: string) {
+ const [{ act }, { createRoot }, { default: RemoteWorkspace }, { LanguageProvider }] = await Promise.all([
+ import("react"), import("react-dom/client"), import("../src/pages/RemoteWorkspace"), import("../src/i18n/provider"),
+ ]);
+ const host = win.document.createElement("div") as unknown as HTMLElement;
+ win.document.body.appendChild(host as never);
+ await act(async () => {
+ root = createRoot(host);
+ root.render( );
+ });
+ await act(async () => { await Promise.resolve(); });
+ return { host, act, button: (label: string) => [...host.querySelectorAll("button")].find(element => element.textContent?.includes(label)) as HTMLButtonElement };
+}
+
+const jsonResponse = (body: unknown, status = 200) => new Response(JSON.stringify(body), { status, headers: { "content-type": "application/json" } });
+function readySnapshot(status = "ready") {
+ return {
+ available: true,
+ devices: [{ id: DEVICE_ID, name: "Executor", platform: "win32", online: true, capabilities: ["workspace.read", "workspace.write"], roots: [{ id: ROOT_ID, label: "Project" }] }],
+ runtimes: { codex: { available: true }, claude: { available: false }, pi: { available: false } },
+ sessions: [{ id: SESSION_ID, profile: "codex", accessMode: "workspace", deviceId: DEVICE_ID, deviceName: "Executor", rootId: ROOT_ID, rootLabel: "Project", capabilities: ["workspace.read", "workspace.write"], tools: ["read_file", "write_file"], resumable: true, status, events: [] }],
+ };
+}
+
+test("disabled status presents explicit activation instructions", async () => {
+ Reflect.set(globalThis, "fetch", async () => jsonResponse({ available: false, devices: [], runtimes: {}, sessions: [] }));
+ const { host } = await mountRemotePage("/disabled-fixture");
+ expect(host.textContent).toContain("OCX_REMOTE_WORKSPACE_ENABLED=1");
+ expect(host.textContent).not.toContain("Create pairing code");
+});
+
+test("202 acceptance stays busy over an older ready poll and does not resubmit", async () => {
+ let sends = 0;
+ const stale = readySnapshot();
+ const accepted = { ...stale.sessions[0]!, status: "running", events: [{ sequence: 2, at: "2026-01-01T00:00:01Z", type: "status", text: "Turn accepted" }] };
+ Reflect.set(globalThis, "fetch", async (input: RequestInfo | URL) => {
+ if (String(input).endsWith("/prompt")) { sends++; return jsonResponse(accepted, 202); }
+ return jsonResponse(stale);
+ });
+ const { host, act, button } = await mountRemotePage("/accepted-fixture");
+ const textarea = host.querySelector("textarea") as HTMLTextAreaElement;
+ const type = async (value: string) => act(async () => {
+ Object.getOwnPropertyDescriptor(win.HTMLTextAreaElement.prototype, "value")!.set!.call(textarea, value);
+ textarea.dispatchEvent(new win.Event("input", { bubbles: true }) as never);
+ });
+ await type("first turn");
+ await act(async () => { button("Send").click(); });
+ expect(textarea.value).toBe("");
+ await type("next turn");
+ expect(button("Send").disabled).toBe(true);
+ expect(button("Stop").disabled).toBe(false);
+ expect(sends).toBe(1);
+});
+
+test("a pruned explicit selection still compares the fallback acceptance cursor", async () => {
+ let pruned = false;
+ const snapshot = readySnapshot();
+ const fallback = { ...snapshot.sessions[0]!, id: "44444444-4444-4444-8444-444444444444", rootLabel: "Fallback session" };
+ const old = { ...snapshot.sessions[0]!, status: "stopped", rootLabel: "Older session" };
+ const accepted = { ...fallback, status: "running", events: [{ sequence: 3, at: "2026-01-01T00:00:01Z", type: "status", text: "Turn accepted" }] };
+ Reflect.set(globalThis, "fetch", async (input: RequestInfo | URL) => {
+ if (String(input).endsWith("/prompt")) return jsonResponse(accepted, 202);
+ return jsonResponse({ ...snapshot, sessions: pruned ? [fallback] : [old, fallback] });
+ });
+ const { host, act, button } = await mountRemotePage("/pruned-selection-fixture");
+ await act(async () => { (host.querySelector('button[aria-label="Sessions"]') as HTMLButtonElement).click(); });
+ const option = [...win.document.querySelectorAll('[role="option"]')].find(element => element.textContent?.includes("Older session"));
+ expect(option).toBeDefined();
+ await act(async () => { (option as unknown as HTMLButtonElement).click(); });
+ pruned = true;
+ await act(async () => { button("Refresh").click(); });
+ const textarea = host.querySelector("textarea") as HTMLTextAreaElement;
+ const type = async (value: string) => act(async () => {
+ Object.getOwnPropertyDescriptor(win.HTMLTextAreaElement.prototype, "value")!.set!.call(textarea, value);
+ textarea.dispatchEvent(new win.Event("input", { bubbles: true }) as never);
+ });
+ await type("fallback turn");
+ await act(async () => { button("Send").click(); });
+ await type("next turn");
+ expect(button("Send").disabled).toBe(true);
+});
+
+test("lost acceptance keeps the draft and reports uncertainty without retrying", async () => {
+ let sends = 0;
+ Reflect.set(globalThis, "fetch", async (input: RequestInfo | URL) => {
+ if (String(input).endsWith("/prompt")) { sends++; throw new TypeError("connection lost"); }
+ return jsonResponse(readySnapshot());
+ });
+ const { host, act, button } = await mountRemotePage("/unknown-acceptance-fixture");
+ const textarea = host.querySelector("textarea") as HTMLTextAreaElement;
+ await act(async () => {
+ Object.getOwnPropertyDescriptor(win.HTMLTextAreaElement.prototype, "value")!.set!.call(textarea, "unconfirmed turn");
+ textarea.dispatchEvent(new win.Event("input", { bubbles: true }) as never);
+ });
+ await act(async () => { button("Send").click(); });
+ expect(textarea.value).toBe("unconfirmed turn");
+ expect(host.textContent).toContain("Submission status is unknown");
+ expect(sends).toBe(1);
+});
+
+test("a failed prompt preserves the newer draft and pairing never disables Stop", async () => {
+ let finishPrompt!: () => void;
+ let finishPairing!: () => void;
+ const heldPrompt = new Promise(resolve => { finishPrompt = resolve; });
+ const heldPairing = new Promise(resolve => { finishPairing = resolve; });
+ Reflect.set(globalThis, "fetch", async (input: RequestInfo | URL, init: RequestInit = {}) => {
+ const url = String(input);
+ if (url.endsWith("/prompt")) { await heldPrompt; return jsonResponse({ error: "prompt failed" }, 500); }
+ if (url.endsWith("/pairing")) { await heldPairing; return jsonResponse({ code: "ABCD-EFGH-JKLM", expiresAt: "2099-01-01T00:00:00Z" }); }
+ if (init.method === "DELETE") return jsonResponse({ ok: true });
+ return jsonResponse(readySnapshot());
+ });
+ const { host, act, button } = await mountRemotePage("/draft-fixture");
+ expect(host.textContent).toContain("Edit files only");
+ const textarea = host.querySelector("textarea") as HTMLTextAreaElement;
+ const type = async (value: string) => act(async () => {
+ Object.getOwnPropertyDescriptor(win.HTMLTextAreaElement.prototype, "value")!.set!.call(textarea, value);
+ textarea.dispatchEvent(new win.Event("input", { bubbles: true }) as never);
+ });
+ await type("first request");
+ await act(async () => { button("Send").click(); });
+ await type("new draft");
+ await act(async () => { button("Create pairing code").click(); });
+ expect(button("Stop").disabled).toBe(false);
+ await act(async () => { finishPrompt(); await heldPrompt; });
+ expect(textarea.value).toBe("new draft");
+ await act(async () => { finishPairing(); await heldPairing; });
+});
+
+test("running sessions reject keyboard submission and stale snapshots show an error", async () => {
+ let reads = 0;
+ let mutations = 0;
+ Reflect.set(globalThis, "fetch", async (_input: RequestInfo | URL, init: RequestInit = {}) => {
+ if (init.method === "POST") { mutations += 1; return jsonResponse({}); }
+ if (++reads > 1) throw new Error("Hub unavailable");
+ return jsonResponse(readySnapshot("running"));
+ });
+ const { host, act, button } = await mountRemotePage("/stale-fixture");
+ const textarea = host.querySelector("textarea") as HTMLTextAreaElement;
+ await act(async () => {
+ Object.getOwnPropertyDescriptor(win.HTMLTextAreaElement.prototype, "value")!.set!.call(textarea, "do not submit");
+ textarea.dispatchEvent(new win.Event("input", { bubbles: true }) as never);
+ });
+ await act(async () => { textarea.dispatchEvent(new win.KeyboardEvent("keydown", { key: "Enter", ctrlKey: true, bubbles: true }) as never); });
+ expect(mutations).toBe(0);
+ expect(textarea.value).toBe("do not submit");
+ await act(async () => { button("Refresh").click(); });
+ await act(async () => { await Promise.resolve(); });
+ expect(host.textContent).toContain("Could not load Remote Workspace");
+ expect(button("Stop").disabled).toBe(false);
+});
+
+test("Stop sends DELETE while an unrelated pairing request is held", async () => {
+ let finishPairing!: () => void;
+ const heldPairing = new Promise(resolve => { finishPairing = resolve; });
+ let deleted = false;
+ Reflect.set(globalThis, "fetch", async (input: RequestInfo | URL, init: RequestInit = {}) => {
+ if (String(input).endsWith("/pairing")) { await heldPairing; return jsonResponse({ code: "ABCD-EFGH-JKLM", expiresAt: "2099-01-01T00:00:00Z" }); }
+ if (init.method === "DELETE") { deleted = true; return jsonResponse({ ok: true }); }
+ return jsonResponse(readySnapshot());
+ });
+ const { act, button } = await mountRemotePage("/stop-pairing-fixture");
+ await act(async () => { button("Create pairing code").click(); });
+ await act(async () => { button("Stop").click(); });
+ expect(deleted).toBe(true);
+ await act(async () => { finishPairing(); await heldPairing; });
+});
+
+test("stale ready sessions cannot submit through the keyboard", async () => {
+ let reads = 0;
+ let sent = false;
+ Reflect.set(globalThis, "fetch", async (_input: RequestInfo | URL, init: RequestInit = {}) => {
+ if (init.method === "POST") { sent = true; return jsonResponse({}); }
+ if (++reads > 1) throw new Error("offline");
+ return jsonResponse(readySnapshot());
+ });
+ const { host, act, button } = await mountRemotePage("/stale-ready-fixture");
+ const textarea = host.querySelector("textarea") as HTMLTextAreaElement;
+ await act(async () => {
+ Object.getOwnPropertyDescriptor(win.HTMLTextAreaElement.prototype, "value")!.set!.call(textarea, "preserve this");
+ textarea.dispatchEvent(new win.Event("input", { bubbles: true }) as never);
+ });
+ expect(button("Send").disabled).toBe(false);
+ await act(async () => { button("Refresh").click(); });
+ await act(async () => { await Promise.resolve(); });
+ expect(button("Send").disabled).toBe(true);
+ await act(async () => { textarea.dispatchEvent(new win.KeyboardEvent("keydown", { key: "Enter", ctrlKey: true, bubbles: true }) as never); });
+ expect(sent).toBe(false);
+ expect(textarea.value).toBe("preserve this");
+});
diff --git a/gui/tests/sidebar-rows.test.ts b/gui/tests/sidebar-rows.test.ts
index 308c08e21e..fbc682632a 100644
--- a/gui/tests/sidebar-rows.test.ts
+++ b/gui/tests/sidebar-rows.test.ts
@@ -27,11 +27,11 @@ test("every row maps one-to-one onto a page", () => {
const navBlock = src.slice(src.indexOf("const NAV: NavEntry[] = ["), src.indexOf("];", src.indexOf("const NAV: NavEntry[] = [")));
const ids = [...navBlock.matchAll(/\{ id: "([^"]+)"/g)].map(m => m[1]);
- // The exact nine, in order. A count alone would pass if a row were swapped for
+ // The exact rows, in order. A count alone would pass if a row were swapped for
// another, and Routing folding into Models is precisely that kind of change.
expect(ids).toEqual([
"dashboard", "codex-set", "providers", "models", "subagents",
- "logs", "usage", "storage", "integrations",
+ "logs", "usage", "storage", "remote", "integrations",
]);
// No two rows share a page id, which is what made the correction helper necessary.
expect(new Set(ids).size).toBe(ids.length);
diff --git a/gui/tests/usage-custom-range.test.tsx b/gui/tests/usage-custom-range.test.tsx
index ea4e98e51c..78a3e9b165 100644
--- a/gui/tests/usage-custom-range.test.tsx
+++ b/gui/tests/usage-custom-range.test.tsx
@@ -85,6 +85,25 @@ async function respond(index: number, marker: string, date?: string) {
await act(async () => { requests[index].resolve(Response.json(report(requests[index], marker, date))); });
}
+test("incomplete usage notice survives held cache and remains visible with no readable rows", async () => {
+ await mount();
+ const partial = { ...report(requests[0], "readable-model"), usageIncomplete: true, usageIncompleteReason: "oversized_rows" };
+ await act(async () => { requests[0].resolve(Response.json(partial)); });
+ expect(container.textContent).toContain("Some usage records could not be included");
+ expect(container.textContent).toContain("readable-model");
+ expect(sessionEntries().some(([, value]) => value?.includes('"usageIncomplete":true'))).toBe(true);
+ await act(async () => { root!.unmount(); });
+ root = undefined;
+ clearClientResourceStoresForTests();
+ await mount();
+ expect(container.textContent).toContain("Some usage records could not be included");
+ await act(async () => { requests[1].resolve(Response.json({ ...partial,
+ summary: { ...partial.summary, requests: 0, totalTokens: 0 }, days: [], models: [],
+ })); });
+ expect(container.textContent).toContain("Some usage records could not be included");
+ expect(container.textContent).not.toContain("readable-model");
+});
+
const toggle = () => container.querySelector(".usage-range-toggle")!;
const form = () => container.querySelector('form[aria-label="Custom date range"]')!;
const startInput = () => form().querySelectorAll('input[type="datetime-local"]')[0];
diff --git a/gui/tests/usage-incomplete-consumers.test.tsx b/gui/tests/usage-incomplete-consumers.test.tsx
new file mode 100644
index 0000000000..5fdb4b01ae
--- /dev/null
+++ b/gui/tests/usage-incomplete-consumers.test.tsx
@@ -0,0 +1,120 @@
+import { afterEach, beforeEach, expect, test } from "bun:test";
+import { Window } from "happy-dom";
+import { act, type ReactNode } from "react";
+import type { Root } from "react-dom/client";
+import { LanguageProvider } from "../src/i18n/provider";
+import { clearClientResourceStoresForTests } from "../src/client-resource";
+import { readSessionListCache } from "../src/session-list-cache";
+import { readUsageMetadata } from "../src/usage-summary-resource";
+import { DashboardOverviewHead } from "../src/pages/dashboard-overview-head";
+import ProviderWorkspaceShell from "../src/components/provider-workspace/ProviderWorkspaceShell";
+import AddProviderModal from "../src/components/AddProviderModal";
+import ApiKeys from "../src/pages/ApiKeys";
+
+const globals = ["document", "window", "navigator", "localStorage", "sessionStorage", "fetch", "IS_REACT_ACT_ENVIRONMENT"] as const;
+let previous: Map;
+let win: Window, host: HTMLElement, root: Root | null;
+let usageBody: Record, keysBody: Record;
+let hold = false;
+const partial = { usageIncomplete: true, usageIncompleteReason: "oversized_rows" };
+const warning = "Some usage records could not be included";
+
+beforeEach(() => {
+ clearClientResourceStoresForTests();
+ previous = new Map(globals.map(key => [key, Object.getOwnPropertyDescriptor(globalThis, key)]));
+ win = new Window({ url: "http://localhost/" });
+ win.localStorage.setItem("ocx-lang", "en");
+ const values = { document: win.document, window: win, navigator: win.navigator,
+ localStorage: win.localStorage, sessionStorage: win.sessionStorage, IS_REACT_ACT_ENVIRONMENT: true };
+ for (const [key, value] of Object.entries(values)) Object.defineProperty(globalThis, key, { configurable: true, value });
+ root = null; hold = false;
+ usageBody = { ...partial, providers: [], models: [] };
+ keysBody = { ...partial, keys: [], authMatrix: [{ endpoint: "/v1/models", bearer: "accepted", dedicated: "accepted", xApiKey: "accepted" }] };
+ Object.defineProperty(globalThis, "fetch", { configurable: true, value: async (input: RequestInfo | URL, init?: RequestInit) => {
+ if (hold) return new Promise((_resolve, reject) => {
+ if (init?.signal?.aborted) reject(new Error("aborted"));
+ else init?.signal?.addEventListener("abort", () => reject(new Error("aborted")), { once: true });
+ });
+ const path = String(input);
+ const body = path.includes("/api/usage?") ? usageBody
+ : path.endsWith("/api/keys") ? keysBody
+ : path.endsWith("/api/models") ? []
+ : path.endsWith("/v1/models") ? { data: [] }
+ : path.endsWith("/api/selected-models") ? { selected: {}, available: {}, liveModelCounts: {} }
+ : path.includes("/api/provider-quotas") ? { reports: [] }
+ : path.endsWith("/api/oauth/providers") ? { providers: [] }
+ : path.endsWith("/api/provider-presets") ? { providers: [{ id: "test", label: "Test", adapter: "openai-chat", baseUrl: "https://example.test", auth: "key" }] }
+ : {};
+ return Response.json(body);
+ } });
+ host = document.createElement("div"); document.body.append(host);
+});
+
+afterEach(async () => {
+ if (root) await act(async () => { root!.unmount(); });
+ clearClientResourceStoresForTests();
+ win.close();
+ for (const key of globals) {
+ const descriptor = previous.get(key);
+ if (descriptor) Object.defineProperty(globalThis, key, descriptor);
+ else Reflect.deleteProperty(globalThis, key);
+ }
+});
+
+async function mount(node: ReactNode) {
+ const { createRoot } = await import("react-dom/client");
+ await act(async () => { root ??= createRoot(host); root.render({node} ); });
+ const deadline = Date.now() + 5_000;
+ while (!host.textContent?.includes(warning) && Date.now() < deadline) {
+ await act(async () => { await new Promise(resolve => setImmediate(resolve)); });
+ }
+ expect(host.textContent, "expected usage notice must finish rendering").toContain(warning);
+}
+async function remountFromCache(node: ReactNode) {
+ await act(async () => { root!.unmount(); }); root = null;
+ clearClientResourceStoresForTests(); hold = true;
+ await mount(node);
+}
+
+test("metadata reader preserves positive diagnostics without inferring completeness or copying fields", () => {
+ for (const value of [null, {}, { usageIncomplete: false }, { usageIncomplete: "true" }]) expect(readUsageMetadata(value)).toEqual({});
+ expect(readUsageMetadata({ ...partial, models: [1], token: "private" })).toEqual(partial);
+ expect(readUsageMetadata({ usageIncomplete: true, usageIncompleteReason: "future_reason" })).toEqual({ usageIncomplete: true });
+});
+
+test("Dashboard warns even when no readable requests remain", async () => {
+ await mount( {}} switchMaMode={async () => {}} maError={null} />);
+ expect(host.textContent).toContain(warning);
+});
+
+test("provider usage projection retains incomplete metadata through a cache-only revisit", async () => {
+ usageBody = { ...partial, providers: [{ provider: "test", requests: 7, totalTokens: 123 }], models: [] };
+ const node = {}} onAddProvider={() => {}} />;
+ await mount(node);
+ expect(host.textContent).toContain(warning);
+ const cached = readSessionListCache>("ocx.providers.usage.v2:/provider");
+ expect(cached).toMatchObject({ ...partial, totals: { test: { requests: 7, totalTokens: 123 } } });
+ await remountFromCache(node);
+ expect(host.textContent).toContain(warning);
+});
+
+test("provider catalog explains that its usage ranking can be incomplete without any readable rows", async () => {
+ await mount( {}} onAdded={() => {}} />);
+ expect(host.textContent).toContain(warning);
+});
+
+test("API key fetch and session cache retain incomplete metadata even without attribution or keys", async () => {
+ const node = ;
+ await mount(node);
+ expect(host.textContent).toContain(warning);
+ const cached = readSessionListCache>("ocx.apikeys.list.v2:/keys");
+ expect(cached).toMatchObject({ ...partial, keys: [] });
+ expect(cached).not.toHaveProperty("attributionSince");
+ await remountFromCache(node);
+ expect(host.textContent).toContain(warning);
+});
diff --git a/native/remote-workspace-helper/Cargo.lock b/native/remote-workspace-helper/Cargo.lock
new file mode 100644
index 0000000000..8dba097e9d
--- /dev/null
+++ b/native/remote-workspace-helper/Cargo.lock
@@ -0,0 +1,130 @@
+# This file is automatically @generated by Cargo.
+# It is not intended for manual editing.
+version = 4
+
+[[package]]
+name = "base64"
+version = "0.22.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
+
+[[package]]
+name = "itoa"
+version = "1.0.18"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
+
+[[package]]
+name = "memchr"
+version = "2.8.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
+
+[[package]]
+name = "opencodex-remote-workspace-helper"
+version = "0.1.0"
+dependencies = [
+ "base64",
+ "serde",
+ "serde_json",
+ "windows-sys",
+]
+
+[[package]]
+name = "proc-macro2"
+version = "1.0.107"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9"
+dependencies = [
+ "unicode-ident",
+]
+
+[[package]]
+name = "quote"
+version = "1.0.47"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001"
+dependencies = [
+ "proc-macro2",
+]
+
+[[package]]
+name = "serde"
+version = "1.0.229"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba"
+dependencies = [
+ "serde_core",
+ "serde_derive",
+]
+
+[[package]]
+name = "serde_core"
+version = "1.0.229"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48"
+dependencies = [
+ "serde_derive",
+]
+
+[[package]]
+name = "serde_derive"
+version = "1.0.229"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "serde_json"
+version = "1.0.151"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14"
+dependencies = [
+ "itoa",
+ "memchr",
+ "serde",
+ "serde_core",
+ "zmij",
+]
+
+[[package]]
+name = "syn"
+version = "3.0.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e6275cddf4610d1775e6d1fe9469b2e77d0f39fd98fb7450901b821e0c53649f"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "unicode-ident",
+]
+
+[[package]]
+name = "unicode-ident"
+version = "1.0.24"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
+
+[[package]]
+name = "windows-link"
+version = "0.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
+
+[[package]]
+name = "windows-sys"
+version = "0.61.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc"
+dependencies = [
+ "windows-link",
+]
+
+[[package]]
+name = "zmij"
+version = "1.0.23"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
diff --git a/native/remote-workspace-helper/Cargo.toml b/native/remote-workspace-helper/Cargo.toml
new file mode 100644
index 0000000000..65bd1d0ba7
--- /dev/null
+++ b/native/remote-workspace-helper/Cargo.toml
@@ -0,0 +1,24 @@
+[package]
+name = "opencodex-remote-workspace-helper"
+version = "0.1.0"
+edition = "2024"
+license = "MIT"
+publish = false
+
+[dependencies]
+base64 = "0.22"
+serde = { version = "1", features = ["derive"] }
+serde_json = "1"
+
+[target.'cfg(windows)'.dependencies]
+windows-sys = { version = "0.61", features = [
+ "Win32_Foundation",
+ "Win32_Security",
+ "Win32_Security_Authorization",
+ "Win32_Security_Isolation",
+ "Win32_Storage_FileSystem",
+ "Win32_System_JobObjects",
+ "Win32_System_Memory",
+ "Win32_System_Pipes",
+ "Win32_System_Threading",
+] }
diff --git a/native/remote-workspace-helper/src/main.rs b/native/remote-workspace-helper/src/main.rs
new file mode 100644
index 0000000000..8312186b8d
--- /dev/null
+++ b/native/remote-workspace-helper/src/main.rs
@@ -0,0 +1,49 @@
+#![deny(unsafe_op_in_unsafe_fn)]
+
+mod protocol;
+mod sandbox;
+
+use std::io::{self, Read, Write};
+
+use protocol::{HelperRequest, HelperResponse, MAX_REQUEST_BYTES, PROTOCOL_VERSION};
+
+fn main() {
+ if std::env::args().nth(1).as_deref() == Some("__probe-child") {
+ std::process::exit(sandbox::run_probe_child());
+ }
+
+ let response = match read_request().and_then(handle_request) {
+ Ok(response) => response,
+ Err(error) => HelperResponse::error(error),
+ };
+ let mut stdout = io::stdout().lock();
+ if serde_json::to_writer(&mut stdout, &response).is_err() || stdout.write_all(b"\n").is_err() {
+ std::process::exit(2);
+ }
+}
+
+fn read_request() -> Result {
+ let mut body = Vec::new();
+ io::stdin()
+ .take((MAX_REQUEST_BYTES + 1) as u64)
+ .read_to_end(&mut body)
+ .map_err(|_| "could not read helper request".to_owned())?;
+ if body.len() > MAX_REQUEST_BYTES {
+ return Err("helper request exceeds its size limit".to_owned());
+ }
+ let request: HelperRequest =
+ serde_json::from_slice(&body).map_err(|_| "helper request is invalid".to_owned())?;
+ request.validate()?;
+ Ok(request)
+}
+
+fn handle_request(request: HelperRequest) -> Result {
+ if request.version != PROTOCOL_VERSION {
+ return Err("unsupported helper protocol version".to_owned());
+ }
+ match request.operation.as_str() {
+ "probe" => sandbox::probe().map(|()| HelperResponse::probe_success()),
+ "run" => sandbox::run(&request).map(HelperResponse::command_success),
+ _ => Err("unsupported helper operation".to_owned()),
+ }
+}
diff --git a/native/remote-workspace-helper/src/protocol.rs b/native/remote-workspace-helper/src/protocol.rs
new file mode 100644
index 0000000000..900f630e5c
--- /dev/null
+++ b/native/remote-workspace-helper/src/protocol.rs
@@ -0,0 +1,246 @@
+use base64::{Engine as _, engine::general_purpose::STANDARD};
+use serde::{Deserialize, Serialize};
+use std::path::Path;
+#[cfg(target_os = "windows")]
+use std::path::PathBuf;
+
+pub const PROTOCOL_VERSION: u8 = 1;
+pub const MAX_REQUEST_BYTES: usize = 64 * 1024;
+pub const MAX_OUTPUT_BYTES: usize = 256 * 1024;
+const MAX_PATH_BYTES: usize = 4096;
+const MAX_COMMAND_ARGUMENTS: usize = 64;
+const MAX_COMMAND_ARGUMENT_BYTES: usize = 4096;
+const MAX_COMMAND_BYTES: usize = 16 * 1024;
+const MAX_TOOLCHAIN_ROOTS: usize = 16;
+
+#[derive(Debug, Deserialize)]
+#[serde(deny_unknown_fields, rename_all = "camelCase")]
+pub struct HelperRequest {
+ pub version: u8,
+ pub operation: String,
+ #[serde(default)]
+ pub root: String,
+ #[serde(default)]
+ pub cwd: String,
+ #[serde(default)]
+ pub command: Vec,
+ #[serde(default)]
+ pub toolchain_roots: Vec,
+ #[serde(default)]
+ pub timeout_ms: u64,
+ #[serde(default)]
+ pub max_output_bytes: usize,
+ #[serde(default)]
+ pub network_access: bool,
+}
+
+impl HelperRequest {
+ pub fn validate(&self) -> Result<(), String> {
+ if self.operation == "probe" {
+ if !self.root.is_empty()
+ || !self.cwd.is_empty()
+ || !self.command.is_empty()
+ || !self.toolchain_roots.is_empty()
+ || self.timeout_ms != 0
+ || self.max_output_bytes != 0
+ || self.network_access
+ {
+ return Err("probe request must not carry command authority".to_owned());
+ }
+ return Ok(());
+ }
+ if self.operation != "run" {
+ return Ok(());
+ }
+ validate_path(&self.root, "workspace root")?;
+ validate_path(&self.cwd, "command cwd")?;
+ if !Path::new(&self.root).is_absolute() || !Path::new(&self.cwd).is_absolute() {
+ return Err("workspace root and cwd must be absolute".to_owned());
+ }
+ if self.command.is_empty() || self.command.len() > MAX_COMMAND_ARGUMENTS {
+ return Err("invalid command vector".to_owned());
+ }
+ let mut command_bytes = 0usize;
+ for value in &self.command {
+ if value.is_empty() || value.len() > MAX_COMMAND_ARGUMENT_BYTES || value.contains('\0')
+ {
+ return Err("invalid command vector".to_owned());
+ }
+ command_bytes = command_bytes
+ .checked_add(value.len())
+ .ok_or_else(|| "command vector is too large".to_owned())?;
+ }
+ if command_bytes > MAX_COMMAND_BYTES {
+ return Err("command vector is too large".to_owned());
+ }
+ if self.toolchain_roots.len() > MAX_TOOLCHAIN_ROOTS {
+ return Err("too many toolchain roots".to_owned());
+ }
+ for path in &self.toolchain_roots {
+ validate_path(path, "toolchain root")?;
+ if !Path::new(path).is_absolute() {
+ return Err("toolchain roots must be absolute".to_owned());
+ }
+ }
+ if !(1..=60_000).contains(&self.timeout_ms) {
+ return Err("command timeout is outside its limit".to_owned());
+ }
+ if !(1024..=MAX_OUTPUT_BYTES).contains(&self.max_output_bytes) {
+ return Err("command output limit is outside its limit".to_owned());
+ }
+ Ok(())
+ }
+
+ #[cfg(target_os = "windows")]
+ pub fn canonical_paths(&self) -> Result {
+ let root = canonical_directory(&self.root, "workspace root")?;
+ let cwd = canonical_directory(&self.cwd, "command cwd")?;
+ if !cwd.starts_with(&root) {
+ return Err("command cwd escaped its workspace root".to_owned());
+ }
+ let mut toolchain_roots = Vec::with_capacity(self.toolchain_roots.len());
+ for value in &self.toolchain_roots {
+ let canonical = canonical_directory(value, "toolchain root")?;
+ if !toolchain_roots.contains(&canonical) {
+ toolchain_roots.push(canonical);
+ }
+ }
+ Ok(CanonicalPaths {
+ root,
+ cwd,
+ toolchain_roots,
+ })
+ }
+}
+
+fn validate_path(value: &str, label: &str) -> Result<(), String> {
+ if value.is_empty() || value.len() > MAX_PATH_BYTES || value.contains('\0') {
+ return Err(format!("invalid {label}"));
+ }
+ Ok(())
+}
+
+#[cfg(target_os = "windows")]
+fn canonical_directory(value: &str, label: &str) -> Result {
+ let original = Path::new(value);
+ let metadata =
+ std::fs::symlink_metadata(original).map_err(|_| format!("{label} is unavailable"))?;
+ if metadata.file_type().is_symlink() || !metadata.is_dir() {
+ return Err(format!("{label} must remain a real directory"));
+ }
+ original
+ .canonicalize()
+ .map_err(|_| format!("{label} is unavailable"))
+}
+
+#[cfg(target_os = "windows")]
+#[derive(Debug)]
+pub struct CanonicalPaths {
+ pub root: PathBuf,
+ pub cwd: PathBuf,
+ pub toolchain_roots: Vec,
+}
+
+#[derive(Debug)]
+pub struct CommandOutcome {
+ pub exit_code: i32,
+ pub stdout: Vec,
+ pub stderr: Vec,
+}
+
+#[derive(Debug, Serialize)]
+#[serde(rename_all = "camelCase")]
+pub struct HelperResponse {
+ version: u8,
+ ok: bool,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ probe: Option,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ exit_code: Option,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ stdout_base64: Option,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ stderr_base64: Option,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ error: Option,
+}
+
+impl HelperResponse {
+ pub fn error(error: String) -> Self {
+ Self {
+ version: PROTOCOL_VERSION,
+ ok: false,
+ probe: None,
+ exit_code: None,
+ stdout_base64: None,
+ stderr_base64: None,
+ error: Some(limit_error(error)),
+ }
+ }
+
+ pub fn probe_success() -> Self {
+ Self {
+ version: PROTOCOL_VERSION,
+ ok: true,
+ probe: Some(true),
+ exit_code: None,
+ stdout_base64: None,
+ stderr_base64: None,
+ error: None,
+ }
+ }
+
+ pub fn command_success(outcome: CommandOutcome) -> Self {
+ Self {
+ version: PROTOCOL_VERSION,
+ ok: true,
+ probe: None,
+ exit_code: Some(outcome.exit_code),
+ stdout_base64: Some(STANDARD.encode(outcome.stdout)),
+ stderr_base64: Some(STANDARD.encode(outcome.stderr)),
+ error: None,
+ }
+ }
+}
+
+fn limit_error(mut value: String) -> String {
+ const MAX_ERROR_CHARS: usize = 512;
+ if value.chars().count() <= MAX_ERROR_CHARS {
+ return value;
+ }
+ value = value.chars().take(MAX_ERROR_CHARS).collect();
+ value.push('…');
+ value
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn rejects_authority_smuggled_into_probe() {
+ let request: HelperRequest =
+ serde_json::from_str(r#"{"version":1,"operation":"probe","command":["whoami"]}"#)
+ .expect("valid JSON fixture");
+ assert!(request.validate().is_err());
+ }
+
+ #[test]
+ fn rejects_unknown_wire_fields() {
+ assert!(
+ serde_json::from_str::(
+ r#"{"version":1,"operation":"probe","surprise":true}"#,
+ )
+ .is_err()
+ );
+ }
+
+ #[test]
+ fn bounds_command_shape_before_platform_code() {
+ let request: HelperRequest = serde_json::from_str(
+ r#"{"version":1,"operation":"run","root":"/tmp/a","cwd":"/tmp/a","command":["x"],"timeoutMs":0,"maxOutputBytes":262144}"#,
+ )
+ .expect("valid JSON fixture");
+ assert!(request.validate().is_err());
+ }
+}
diff --git a/native/remote-workspace-helper/src/sandbox/macos.rs b/native/remote-workspace-helper/src/sandbox/macos.rs
new file mode 100644
index 0000000000..2052f82707
--- /dev/null
+++ b/native/remote-workspace-helper/src/sandbox/macos.rs
@@ -0,0 +1,19 @@
+use crate::protocol::{CommandOutcome, HelperRequest};
+
+const MACOS_CONFINEMENT_UNAVAILABLE: &str =
+ "macOS Remote Workspace command confinement is unavailable; file tools remain enabled";
+
+/// macOS has no unprivileged Job Object or cgroup equivalent that can revoke every descendant's
+/// workspace access. A Seatbelt profile can constrain a process, but allowing subprocesses lets a
+/// descendant call `setsid()` and outlive cancellation. Importing broad system profiles merely to
+/// make a single-process probe start would also widen unrelated host-service authority. Until a
+/// native containment owner closes both boundaries, command execution must stay unavailable.
+pub fn probe() -> Result<(), String> {
+ Err(MACOS_CONFINEMENT_UNAVAILABLE.to_owned())
+}
+
+/// Keep the helper itself fail-closed even if a caller bypasses OCX capability negotiation and
+/// submits a `run` request directly.
+pub fn run(_request: &HelperRequest) -> Result {
+ Err(MACOS_CONFINEMENT_UNAVAILABLE.to_owned())
+}
diff --git a/native/remote-workspace-helper/src/sandbox/mod.rs b/native/remote-workspace-helper/src/sandbox/mod.rs
new file mode 100644
index 0000000000..4b9bf551d4
--- /dev/null
+++ b/native/remote-workspace-helper/src/sandbox/mod.rs
@@ -0,0 +1,77 @@
+#[cfg(target_os = "macos")]
+mod macos;
+#[cfg(target_os = "windows")]
+mod windows;
+
+#[cfg(not(any(target_os = "macos", target_os = "windows")))]
+use crate::protocol::{CommandOutcome, HelperRequest};
+use std::fs::{self, OpenOptions};
+use std::io::Read;
+use std::net::{SocketAddr, TcpStream};
+use std::time::Duration;
+
+#[cfg(target_os = "macos")]
+pub use macos::{probe, run};
+#[cfg(target_os = "windows")]
+pub use windows::{probe, run};
+
+#[cfg(not(any(target_os = "macos", target_os = "windows")))]
+pub fn probe() -> Result<(), String> {
+ Err("native helper is supported only on macOS and Windows".to_owned())
+}
+
+#[cfg(not(any(target_os = "macos", target_os = "windows")))]
+pub fn run(_request: &HelperRequest) -> Result {
+ Err("native helper is supported only on macOS and Windows".to_owned())
+}
+
+pub fn run_probe_child() -> i32 {
+ let mut args = std::env::args().skip(2);
+ let Some(workspace) = args.next() else {
+ return 20;
+ };
+ let Some(outside_file) = args.next() else {
+ return 21;
+ };
+ let Some(outside_write) = args.next() else {
+ return 22;
+ };
+ let Some(listener_address) = args.next() else {
+ return 23;
+ };
+ let Some(existing_workspace_file) = args.next() else {
+ return 24;
+ };
+ if args.next().is_some() {
+ return 24;
+ }
+
+ let marker = std::path::Path::new(&workspace).join("probe-marker");
+ if fs::write(&marker, b"sandboxed").is_err() {
+ return 25;
+ }
+ if !matches!(fs::read(&existing_workspace_file), Ok(value) if value == b"existing")
+ || fs::write(&existing_workspace_file, b"updated").is_err()
+ {
+ return 29;
+ }
+ let mut outside = Vec::new();
+ if OpenOptions::new()
+ .read(true)
+ .open(&outside_file)
+ .and_then(|mut file| file.read_to_end(&mut outside))
+ .is_ok()
+ {
+ return 26;
+ }
+ if fs::write(&outside_write, b"escaped").is_ok() {
+ return 27;
+ }
+ let Ok(listener_address) = listener_address.parse::() else {
+ return 23;
+ };
+ if TcpStream::connect_timeout(&listener_address, Duration::from_millis(500)).is_ok() {
+ return 28;
+ }
+ 0
+}
diff --git a/native/remote-workspace-helper/src/sandbox/windows.rs b/native/remote-workspace-helper/src/sandbox/windows.rs
new file mode 100644
index 0000000000..2ecefef055
--- /dev/null
+++ b/native/remote-workspace-helper/src/sandbox/windows.rs
@@ -0,0 +1,15 @@
+use crate::protocol::{CommandOutcome, HelperRequest};
+
+const WINDOWS_CONFINEMENT_UNAVAILABLE: &str =
+ "Windows Remote Workspace command confinement is unavailable; command execution is disabled";
+
+// A command-capable implementation must retain cleanup ownership through helper cancellation
+// and establish Job membership atomically. Until that owner is implemented and verified,
+// direct helper requests and capability probes refuse before allocating OS resources.
+pub fn probe() -> Result<(), String> {
+ Err(WINDOWS_CONFINEMENT_UNAVAILABLE.to_owned())
+}
+
+pub fn run(_request: &HelperRequest) -> Result {
+ Err(WINDOWS_CONFINEMENT_UNAVAILABLE.to_owned())
+}
diff --git a/native/remote-workspace-helper/tests/live_confinement.rs b/native/remote-workspace-helper/tests/live_confinement.rs
new file mode 100644
index 0000000000..e735029ac8
--- /dev/null
+++ b/native/remote-workspace-helper/tests/live_confinement.rs
@@ -0,0 +1,75 @@
+#![cfg(any(target_os = "macos", target_os = "windows"))]
+
+use serde_json::Value;
+use std::io::Write;
+use std::process::{Command, Stdio};
+
+fn run_helper(request: &Value) -> Value {
+ let binary = env!("CARGO_BIN_EXE_opencodex-remote-workspace-helper");
+ let mut child = Command::new(binary)
+ .stdin(Stdio::piped())
+ .stdout(Stdio::piped())
+ .stderr(Stdio::piped())
+ .spawn()
+ .expect("native helper starts");
+ child
+ .stdin
+ .take()
+ .expect("native helper stdin")
+ .write_all(&serde_json::to_vec(request).expect("helper request serializes"))
+ .expect("helper request is written");
+ let output = child.wait_with_output().expect("native helper exits");
+ assert!(
+ output.status.success(),
+ "helper stderr: {}",
+ String::from_utf8_lossy(&output.stderr)
+ );
+ serde_json::from_slice(&output.stdout).expect("helper response is JSON")
+}
+
+fn run_probe() -> Value {
+ run_helper(&serde_json::json!({ "version": 1, "operation": "probe" }))
+}
+
+#[cfg(target_os = "windows")]
+#[test]
+fn native_helper_keeps_windows_command_execution_fail_closed() {
+ let unavailable = serde_json::json!({
+ "version": 1,
+ "ok": false,
+ "error": "Windows Remote Workspace command confinement is unavailable; command execution is disabled"
+ });
+ assert_eq!(run_probe(), unavailable);
+ let root = std::env::current_dir().expect("test cwd");
+ assert_eq!(run_helper(&serde_json::json!({
+ "version": 1, "operation": "run", "root": root, "cwd": root,
+ "command": ["cmd.exe", "/c", "exit"], "timeoutMs": 1000, "maxOutputBytes": 4096
+ })), unavailable);
+}
+
+#[cfg(target_os = "macos")]
+#[test]
+fn native_helper_keeps_macos_command_execution_fail_closed() {
+ let unavailable = serde_json::json!({
+ "version": 1,
+ "ok": false,
+ "error": "macOS Remote Workspace command confinement is unavailable; file tools remain enabled"
+ });
+ assert_eq!(run_probe(), unavailable);
+
+ let root = std::env::current_dir().expect("test cwd");
+ assert_eq!(
+ run_helper(&serde_json::json!({
+ "version": 1,
+ "operation": "run",
+ "root": root,
+ "cwd": root,
+ "command": ["/usr/bin/true"],
+ "toolchainRoots": [],
+ "timeoutMs": 5_000,
+ "maxOutputBytes": 16 * 1024,
+ "networkAccess": false
+ })),
+ unavailable
+ );
+}
diff --git a/package.json b/package.json
index acf1f24f9c..80f23f7b90 100644
--- a/package.json
+++ b/package.json
@@ -28,6 +28,9 @@
"README.md",
"SPONSORS.md",
"AGENTS_INSTALL.md",
+ "native/remote-workspace-helper/Cargo.toml",
+ "native/remote-workspace-helper/Cargo.lock",
+ "native/remote-workspace-helper/src",
"LICENSE"
],
"engines": {
@@ -52,6 +55,8 @@
"structure:check": "bun scripts/structure-ssot.ts",
"generate:model-metadata": "bun scripts/generate-model-metadata.ts",
"build:gui": "cd gui && bun install --frozen-lockfile && bun run build && cd .. && bun run prepare:package",
+ "build:remote-workspace-helper": "cargo build --release --locked --manifest-path native/remote-workspace-helper/Cargo.toml",
+ "test:remote-workspace-helper": "cargo test --locked --manifest-path native/remote-workspace-helper/Cargo.toml",
"prepare:package": "bun scripts/prepare-package.ts",
"prepack": "bun run prepare:package",
"prepublishOnly": "bun run audit:high && bun run typecheck && bun run build:gui",
diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json
index 4f890cba99..5e9929d41e 100644
--- a/scripts/test-layout/layout.json
+++ b/scripts/test-layout/layout.json
@@ -167,6 +167,9 @@
}
},
"explicit": {
+ "hub-usage.test.ts": "server",
+ "client-hub-usage.test.ts": "clients",
+ "cli-usage-hub.test.ts": "cli",
"abort-idle-deadline.test.ts": "lib",
"abort-race.test.ts": "adapters",
"account-import.test.ts": "server",
@@ -488,6 +491,8 @@
"codex-prompt-text-probe.test.ts": "codex-integration",
"codex-quota-auto-refresh-main-admission.test.ts": "codex-integration",
"codex-quota-auto-refresh.test.ts": "codex-integration",
+ "codex-quota-capacity.test.ts": "codex-integration",
+ "codex-quota-history.test.ts": "codex-integration",
"codex-quota-parser-parity.test.ts": "codex-integration",
"codex-quota-prime.test.ts": "codex-integration",
"codex-quota-rejection.test.ts": "codex-integration",
@@ -1075,6 +1080,25 @@
"release-notes.test.ts": "ci-workflows",
"release-version-line.test.ts": "ci-workflows",
"remote-catalog.test.ts": "clients",
+ "remote-workspace-activation.test.ts": "clients",
+ "remote-workspace-management.test.ts": "clients",
+ "remote-workspace-server.test.ts": "clients",
+ "remote-workspace-secret-store.test.ts": "clients",
+ "remote-workspace-session-binding.test.ts": "clients",
+ "remote-workspace-agent-wire.test.ts": "clients",
+ "remote-workspace-app-server.integration.test.ts": "clients",
+ "remote-workspace-claude.integration.test.ts": "clients",
+ "remote-workspace-cli-runtimes.test.ts": "clients",
+ "remote-workspace-cli.test.ts": "clients",
+ "remote-workspace-codex-runtime.test.ts": "clients",
+ "remote-workspace-command-runner.test.ts": "clients",
+ "remote-workspace-device.test.ts": "clients",
+ "remote-workspace-hub.test.ts": "clients",
+ "remote-workspace-linux-confinement.test.ts": "clients",
+ "remote-workspace-platform.test.ts": "clients",
+ "remote-workspace-sessions.test.ts": "clients",
+ "remote-workspace-tool-bridge.test.ts": "clients",
+ "remote-workspace.test.ts": "clients",
"remote-control-prototype.test.ts": "clients",
"remote-workspace-protocol.test.ts": "clients",
"remote-workspace-rpc-framing.test.ts": "clients",
@@ -1383,7 +1407,11 @@
"zhipu-bigmodel-responses-quota.test.ts": "providers",
"zz-ci-api-usage-isolation.test.ts": "ci-workflows",
"zz-ci-storage-policy-isolation.test.ts": "ci-workflows",
- "zz-pr-coderabbit-readiness-revalidation.test.ts": "ci-workflows"
+ "zz-pr-coderabbit-readiness-revalidation.test.ts": "ci-workflows",
+ "usage-log-ws-stage.test.ts": "usage",
+ "main-device-reauth.test.ts": "codex-integration",
+ "main-device-reauth-api.test.ts": "codex-integration",
+ "main-device-reauth-ui.test.ts": "gui"
},
"migrated": [
"adapters",
diff --git a/skills/ocx/references/01_management_surface.md b/skills/ocx/references/01_management_surface.md
index f9fec70034..dcf62fa98e 100644
--- a/skills/ocx/references/01_management_surface.md
+++ b/skills/ocx/references/01_management_surface.md
@@ -28,6 +28,20 @@ These answer in the CLI head and never reach the proxy, so they work with nothin
Safe to run at any time; none of these change state.
+### `ocx remote-workspace status`
+
+Read local executor enrollment and available capabilities without printing credentials.
+
+Drives no management route.
+
+| Flag | Value | Meaning |
+|---|---|---|
+| `--json` | boolean | Emit the public local executor status. |
+
+JSON mode: `payload`.
+
+- Executor-local operation; Hub consent and session control stay in the dashboard.
+
### `ocx models price`
Read the saved manual price for an exact provider/model selector.
@@ -104,6 +118,23 @@ Recently detected quota resets and whether reset notifications are enabled.
JSON mode: `payload`.
+### `ocx account history`
+
+Cached quota observations for one stored Codex pool account.
+
+| Method | Route |
+|---|---|
+| GET | `/api/codex-auth/quota/history` |
+
+| Flag | Value | Meaning |
+|---|---|---|
+| `--json` | boolean | Emit the bounded observation history. |
+| `--limit` | number | Return the newest 1 to 200 observations. |
+
+JSON mode: `payload`.
+
+- Use account history openai . Reads cached observations only; no refresh or warmup. Native main is not included.
+
### `ocx account list`
Codex OAuth accounts with pool priority and pause state.
@@ -373,6 +404,35 @@ JSON mode: `payload`.
Each of these writes. Check the flags column before running one unattended.
+### `ocx remote-workspace pair`
+
+Enroll this executor with one Hub using a one-time code from stdin and locally approved roots.
+
+Drives no management route.
+
+| Flag | Value | Meaning |
+|---|---|---|
+| `--json` | boolean | Emit the public local executor status. |
+| `--pairing-code-stdin` | boolean | Read the one-time pairing code from stdin. |
+| `--root` | string | Approve an absolute workspace directory; repeatable. |
+| `--toolchain-root` | string | Approve a read-only toolchain directory; repeatable. |
+| `--executor-helper` | string | Select a reviewed native helper file. |
+| `--name` | string | Name this executor. |
+
+JSON mode: `payload`.
+
+- Executor-local operation; Hub consent and session control stay in the dashboard.
+
+### `ocx remote-workspace agent`
+
+Keep the paired executor connected to its Hub.
+
+Drives no management route.
+
+JSON mode: `none`.
+
+- Executor-local operation; Hub consent and session control stay in the dashboard.
+
### `ocx models set-price`
Save four manual USD-per-1M-token rates, or restore automatic pricing for one model.
@@ -455,6 +515,29 @@ JSON mode: `payload`.
- `store` verifies every keychain write by read-back before config.json is rewritten with keychain: references; an unavailable keychain refuses with 503 and leaves the file untouched.
- Headless services usually have no unlocked keychain session; prefer ${ENV_VAR} references there.
+### `ocx account main reauth`
+
+Reauthenticate the native main Codex login with a device code (#3898); headless hubs need no Codex App or keyring.
+
+| Method | Route |
+|---|---|
+| POST | `/api/codex-auth/main/reauth-device` |
+| GET | `/api/codex-auth/main/reauth-device` |
+| DELETE | `/api/codex-auth/main/reauth-device` |
+
+| Flag | Value | Meaning |
+|---|---|---|
+| `--device` | boolean | Run the device-code flow (the only reauth mode). |
+| `--no-wait` | boolean | Print the flow handle and code without waiting for completion. |
+| `--flow` | string | Flow id for status and cancel. |
+| `--json` | boolean | Emit the flow status as JSON. |
+
+JSON mode: `payload`.
+
+- Same-identity reauth only: the device login must complete for the ChatGPT account that already holds the native main slot, and the commit is fenced by the exclusive claim plus a path/hash/inode snapshot.
+- /api/codex-auth/login stays pool-only and keeps rejecting __main__; this namespace is the only device-reauth surface for the native main slot.
+- Payloads carry only flowId, status, the verification URL, the device code, and a closed set of failure codes -- never tokens, emails, or raw account ids.
+
### `ocx account refresh`
Refresh account quotas without model validation; pending Codex accounts require dashboard consent.
@@ -769,6 +852,6 @@ JSON mode: `payload`.
## Counts
-- declared capabilities: 41
-- of those, state-changing: 20
+- declared capabilities: 46
+- of those, state-changing: 23
- head-resolved invocations: 2
diff --git a/src/cli/account-api.ts b/src/cli/account-api.ts
index e0e573e251..7e7cda8e3b 100644
--- a/src/cli/account-api.ts
+++ b/src/cli/account-api.ts
@@ -1,3 +1,4 @@
+import { parseQuotaFailureCode, type QuotaFailureCode } from "../providers/quota-types";
/**
* Data-access layer for `ocx account` (issue #180) — live-proxy HTTP client and
* per-family account readers. Kept separate from account.ts (command handlers)
@@ -26,12 +27,16 @@ export interface AccountRow {
masked?: string;
active: boolean;
needsReauth?: boolean;
+ selectionExcludedReason?: "plan_excluded";
+ selectionExcludedPlan?: string;
/** Registered credential that is still excluded from routing until validation completes. */
validationPending?: boolean;
/** Codex pool selection order, higher used earlier. Absent where ordering does not apply. */
priority?: number;
quota?: CodexQuotaDto | null;
quotaRefresh?: CodexQuotaRefreshOutcome;
+ quotaUnavailable?: boolean;
+ quotaFailure?: QuotaFailureCode;
/**
* Whether the pool is holding this account out of rotation.
*
@@ -243,6 +248,8 @@ interface CodexAccountDto {
plan?: string;
isMain?: boolean;
needsReauth?: boolean;
+ selectionExcludedReason?: "plan_excluded";
+ selectionExcludedPlan?: string;
health?: { reason?: string };
priority?: number;
quota?: CodexQuotaDto | null;
@@ -309,6 +316,10 @@ export async function fetchCodexRows(
plan: a.plan,
active: a.id === activeId,
needsReauth: a.needsReauth,
+ ...(a.selectionExcludedReason === "plan_excluded" ? {
+ selectionExcludedReason: "plan_excluded" as const,
+ ...(typeof a.selectionExcludedPlan === "string" ? { selectionExcludedPlan: a.selectionExcludedPlan } : {}),
+ } : {}),
...(a.health?.reason === "validation_pending" ? { validationPending: true } : {}),
priority: typeof a.priority === "number" ? a.priority : 0,
paused: a.paused === true,
@@ -330,6 +341,7 @@ interface OAuthAccountDto {
plan?: string | null;
quota?: CodexQuotaDto | null;
quotaUnavailable?: boolean;
+ quotaFailure?: unknown;
}
async function fetchOAuthRows(
@@ -363,6 +375,8 @@ async function fetchOAuthRows(
plan: a.plan ?? null,
...(a.quota !== undefined ? { quota: a.quota } : {}),
...(a.quotaUnavailable !== undefined ? { quotaUnavailable: a.quotaUnavailable } : {}),
+ ...(a.quotaUnavailable === true && parseQuotaFailureCode(a.quotaFailure)
+ ? { quotaFailure: parseQuotaFailureCode(a.quotaFailure) } : {}),
}));
return { rows, activeId, status: 200 };
}
diff --git a/src/cli/account-extended.ts b/src/cli/account-extended.ts
index 18fca00fb7..1f22e41283 100644
--- a/src/cli/account-extended.ts
+++ b/src/cli/account-extended.ts
@@ -44,7 +44,7 @@ const EXTENDED_USAGE = `Usage:
ocx account pause [--json]
ocx account resume [--json]
ocx account pause-exhausted [--json]
- ocx account strategy [] [--json]
+ ocx account strategy [] [--json]
ocx account sticky [<1-100>] [--json]
ocx account remove --yes [--json]
ocx account clear-cooldown [--json]
diff --git a/src/cli/account-history.ts b/src/cli/account-history.ts
new file mode 100644
index 0000000000..9534cf30fd
--- /dev/null
+++ b/src/cli/account-history.ts
@@ -0,0 +1,60 @@
+import { parseCapacityReason } from "../codex/quota-capacity";
+import { isValidCodexAccountId } from "../codex/account-id";
+import { apiError, apiJson, proxyUnreachable, resolveBaseUrl, type AccountDeps } from "./account-api";
+
+function historyDate(value: unknown): string {
+ if (typeof value !== "number" || !Number.isFinite(value)) return "unknown";
+ const date = new Date(value);
+ return Number.isFinite(date.getTime()) ? date.toISOString() : "unknown";
+}
+
+/** Read cached pool observations without refreshing credentials or spending quota. */
+export async function cmdAccountHistory(args: string[], deps: AccountDeps): Promise {
+ const [provider, accountId, ...flags] = args;
+ let json = false;
+ let limit = 200;
+ let hasLimit = false;
+ let valid = provider === "openai" && isValidCodexAccountId(accountId);
+ for (let index = 0; index < flags.length; index++) {
+ if (flags[index] === "--json" && !json) json = true;
+ else if (flags[index] === "--limit" && !hasLimit && /^(?:[1-9]|[1-9][0-9]|1[0-9]{2}|200)$/.test(flags[index + 1] ?? "")) {
+ limit = Number(flags[++index]); hasLimit = true;
+ } else valid = false;
+ }
+ if (!valid) {
+ console.error("Usage: ocx account history openai [--limit <1-200>] [--json]");
+ return 1;
+ }
+ const baseUrl = await resolveBaseUrl(deps);
+ if (!baseUrl) return proxyUnreachable();
+ const result = await apiJson(deps, baseUrl, "GET", `/api/codex-auth/quota/history?accountId=${encodeURIComponent(accountId)}&limit=${limit}`);
+ if (result.status === 0) return proxyUnreachable(result.transportError);
+ if (result.status !== 200) return apiError(result.json, "Quota history unavailable", result.status);
+ if (json) { console.log(JSON.stringify(result.json, null, 2)); return 0; }
+ const observations = result.json.observations;
+ if (!Array.isArray(observations)) return apiError({}, "Invalid quota history response", 502);
+ console.log("OBSERVED\tSOURCE\tWINDOW\tUSED\tRESET");
+ if (!observations.length) console.log("No quota observations for this credential publication.");
+ for (const observation of observations) {
+ if (!observation || typeof observation !== "object" || !Array.isArray(observation.windows)
+ || !Number.isFinite(observation.observedAt)) return apiError({}, "Invalid quota history response", 502);
+ for (const window of observation.windows) {
+ console.log(`${historyDate(observation.observedAt)}\t${observation.source}\t${window.family}/${window.window}\t${window.usedPercent}%\t${historyDate(window.resetAtMs)}`);
+ }
+ }
+ const capacity = result.json.capacity;
+ if (capacity && typeof capacity === "object" && "status" in capacity && capacity.status === "estimated"
+ && "estimates" in capacity && Array.isArray(capacity.estimates)) {
+ console.log("Effective capacity estimate (low confidence; not a provider token limit):");
+ for (const estimate of capacity.estimates) {
+ if (estimate && Number.isFinite(estimate.estimatedTokens) && Number.isSafeInteger(estimate.sampleCount)) {
+ console.log(`${estimate.window}\t~${estimate.estimatedTokens} reported tokens / 100%\t${estimate.sampleCount} samples`);
+ }
+ }
+ }
+ if (capacity && typeof capacity === "object" && "status" in capacity && capacity.status === "insufficient-evidence") {
+ const reason = "reason" in capacity ? parseCapacityReason(capacity.reason) : undefined;
+ console.log(`Effective capacity: insufficient evidence${reason ? ` (${reason})` : ""}.`);
+ }
+ return 0;
+}
diff --git a/src/cli/account-main.ts b/src/cli/account-main.ts
index e6d0eafeb6..9169ab824a 100644
--- a/src/cli/account-main.ts
+++ b/src/cli/account-main.ts
@@ -17,6 +17,9 @@ const USAGE = `Usage:
ocx account main list [--json]
ocx account main register [--json]
ocx account main add
+ ocx account main reauth --device [--no-wait] [--json]
+ ocx account main reauth status --flow [--json]
+ ocx account main reauth cancel --flow [--json]
ocx account main switch --yes [--json]
ocx account main recover [--rollback --yes] [--json]
@@ -186,6 +189,83 @@ export async function cmdNativeMainAccount(args: string[], deps: AccountDeps): P
const baseUrl = await resolveBaseUrl(deps);
if (!baseUrl) return proxyUnreachable();
+ if (sub === "reauth") {
+ // #3898: same-identity device reauth of the native __main__ slot via the
+ // dedicated management namespace; /api/codex-auth/login stays pool-only.
+ if (confirmed || rollback) return reject(args);
+ const noWait = flag(args, "--no-wait");
+ const device = flag(args, "--device");
+ const flowIndex = args.indexOf("--flow");
+ const flowId = flowIndex >= 0 ? args.splice(flowIndex, 2)[1] : undefined;
+ const action = args.shift();
+ if (args.length > 0) return reject(args);
+
+ const printStatus = (status: Record): void => {
+ if (wantsJson) { console.log(JSON.stringify(status, null, 2)); return; }
+ console.log("status: " + String(status.status ?? "unknown"));
+ if (typeof status.verificationUrl === "string" && status.verificationUrl) {
+ console.log("url: " + status.verificationUrl);
+ }
+ if (typeof status.deviceCode === "string" && status.deviceCode) {
+ console.log("deviceCode: " + status.deviceCode);
+ }
+ if (typeof status.code === "string" && status.code) {
+ console.log("failure: " + status.code);
+ }
+ };
+
+ if (action === "status" || action === "cancel") {
+ if (!flowId || device || noWait) return reject(args);
+ const query = "/api/codex-auth/main/reauth-device?flowId=" + encodeURIComponent(flowId);
+ const result = action === "status"
+ ? await apiJson(deps, baseUrl, "GET", query)
+ : await apiJson(deps, baseUrl, "DELETE", query);
+ if (result.status === 0) return proxyUnreachable(result.transportError);
+ if (result.status !== 200) return apiError(result.json, "failed to " + action + " the native main reauth", result.status);
+ printStatus(result.json);
+ return 0;
+ }
+
+ if (action !== undefined || !device) return reject(args);
+ const started = await apiJson(deps, baseUrl, "POST", "/api/codex-auth/main/reauth-device");
+ if (started.status === 0) return proxyUnreachable(started.transportError);
+ if (started.status !== 200) return apiError(started.json, "failed to start the native main device reauth", started.status);
+ const startFlowId = typeof started.json.flowId === "string" ? started.json.flowId : "";
+ if (!startFlowId) {
+ console.error("The proxy returned an invalid reauth flow.");
+ return 1;
+ }
+ // The URL and human code arrive with the usercode response, a beat after start.
+ let pending = started.json;
+ const statusQuery = "/api/codex-auth/main/reauth-device?flowId=" + encodeURIComponent(startFlowId);
+ for (let attempt = 0; attempt < 50 && !pending.deviceCode; attempt += 1) {
+ await Bun.sleep(200);
+ const polled = await apiJson(deps, baseUrl, "GET", statusQuery);
+ if (polled.status === 200) pending = polled.json;
+ }
+ if (noWait) {
+ printStatus({ flowId: startFlowId, ...pending });
+ console.log("follow up: ocx account main reauth status --flow " + startFlowId);
+ return 0;
+ }
+ // Blocking wait bounded by the service flow expiry (15-minute grant + margin).
+ const deadline = Date.now() + 16 * 60_000;
+ for (;;) {
+ if (pending.status !== "pending" && pending.status !== "committing") break;
+ if (Date.now() >= deadline) {
+ console.error("The reauth flow did not finish within the device grant window; check status with --flow " + startFlowId + ".");
+ return 1;
+ }
+ await Bun.sleep(2_000);
+ const polled = await apiJson(deps, baseUrl, "GET", statusQuery);
+ if (polled.status === 0) return proxyUnreachable(polled.transportError);
+ if (polled.status !== 200) return apiError(polled.json, "failed to poll the native main reauth", polled.status);
+ pending = polled.json;
+ }
+ printStatus({ flowId: startFlowId, ...pending });
+ return pending.status === "succeeded" ? 0 : 1;
+ }
+
if (sub === "doctor" || sub === "list") {
if (args.length > 0 || confirmed || rollback) return reject(args);
const path = sub === "doctor" ? "/api/native-main-profiles/doctor" : "/api/native-main-profiles";
diff --git a/src/cli/account.ts b/src/cli/account.ts
index 4a8c6a0427..f834024f28 100644
--- a/src/cli/account.ts
+++ b/src/cli/account.ts
@@ -41,6 +41,7 @@ const REPLACEMENT_STYLE_OAUTH = new Set();
const ACCOUNT_USAGE = `Usage:
ocx account list [provider] [--json] [--all] [--quota [--refresh]]
+ ocx account history openai [--limit <1-200>] [--json]
ocx account current [--json]
ocx account use [--json]
ocx account refresh [--json]
@@ -50,7 +51,7 @@ const ACCOUNT_USAGE = `Usage:
ocx account pause [--json]
ocx account resume [--json]
ocx account pause-exhausted [--json]
- ocx account strategy [] [--json]
+ ocx account strategy [] [--json]
ocx account sticky [<1-100>] [--json]
ocx account remove --yes [--json]
ocx account clear-cooldown [--json]
@@ -61,7 +62,7 @@ const ACCOUNT_USAGE = `Usage:
ocx account cancel [--flow ] [--json]
ocx account reset-credits [--consume --yes] [--json]
ocx account grok-reset-coupons [] [--consume --yes] [--token-id ] [--json]
- ocx account main ...
+ ocx account main ...
List and switch provider accounts and API-key pools (masked output only).
'main' selects the Codex App login for the openai account pool.`;
@@ -101,6 +102,9 @@ function statusText(row: AccountRow): string {
if (row.active) parts.push(row.type === "codex" ? "selected" : "active");
if (row.needsReauth) parts.push("needs-reauth");
if (row.validationPending) parts.push("validation-pending");
+ if (row.selectionExcludedReason === "plan_excluded") {
+ parts.push(`not-auto-selected(plan=${row.selectionExcludedPlan ?? row.plan ?? "unknown"})`);
+ }
return parts.join(" ");
}
@@ -115,7 +119,7 @@ function priorityText(row: AccountRow): string {
* decides on before a long session. The full breakdown stays in `--json`.
*/
function quotaText(row: AccountRow): string {
- if ((row as { quotaUnavailable?: boolean }).quotaUnavailable) return "unavailable";
+ if (row.quotaUnavailable) return row.quotaFailure ? `unavailable (${row.quotaFailure})` : "unavailable";
const quota = row.quota;
if (!quota) return "-";
const parts: string[] = [];
@@ -335,6 +339,10 @@ export async function cmdAccount(args: string[], deps: AccountDeps = {}): Promis
const [sub, ...rest] = args;
try {
if (sub === "list") return await cmdList(rest, deps);
+ if (sub === "history") {
+ const { cmdAccountHistory } = await import("./account-history");
+ return await cmdAccountHistory(rest, deps);
+ }
if (sub === "current") return await cmdCurrent(rest, deps);
if (sub === "use") return await cmdUse(rest, deps);
if (sub === "refresh") return await cmdRefresh(rest, deps);
diff --git a/src/cli/capabilities.ts b/src/cli/capabilities.ts
index 63cf313676..cebafec88f 100644
--- a/src/cli/capabilities.ts
+++ b/src/cli/capabilities.ts
@@ -95,6 +95,85 @@ export const HEAD_CAPABILITIES: readonly HeadCapability[] = [
* A capability must not name a route the command does not actually fetch.
*/
export const CAPABILITIES: readonly Capability[] = [
+ {
+ "command": [
+ "remote-workspace",
+ "pair"
+ ],
+ "summary": "Enroll this executor with one Hub using a one-time code from stdin and locally approved roots.",
+ "routes": [],
+ "flags": [
+ {
+ "name": "--json",
+ "value": "boolean",
+ "summary": "Emit the public local executor status."
+ },
+ {
+ "name": "--pairing-code-stdin",
+ "value": "boolean",
+ "summary": "Read the one-time pairing code from stdin."
+ },
+ {
+ "name": "--root",
+ "value": "string",
+ "summary": "Approve an absolute workspace directory; repeatable."
+ },
+ {
+ "name": "--toolchain-root",
+ "value": "string",
+ "summary": "Approve a read-only toolchain directory; repeatable."
+ },
+ {
+ "name": "--executor-helper",
+ "value": "string",
+ "summary": "Select a reviewed native helper file."
+ },
+ {
+ "name": "--name",
+ "value": "string",
+ "summary": "Name this executor."
+ }
+ ],
+ "mutates": true,
+ "json": "payload",
+ "details": [
+ "Executor-local operation; Hub consent and session control stay in the dashboard."
+ ]
+ },
+ {
+ "command": [
+ "remote-workspace",
+ "agent"
+ ],
+ "summary": "Keep the paired executor connected to its Hub.",
+ "routes": [],
+ "flags": [],
+ "mutates": true,
+ "json": "none",
+ "details": [
+ "Executor-local operation; Hub consent and session control stay in the dashboard."
+ ]
+ },
+ {
+ "command": [
+ "remote-workspace",
+ "status"
+ ],
+ "summary": "Read local executor enrollment and available capabilities without printing credentials.",
+ "routes": [],
+ "flags": [
+ {
+ "name": "--json",
+ "value": "boolean",
+ "summary": "Emit the public local executor status."
+ }
+ ],
+ "mutates": false,
+ "json": "payload",
+ "details": [
+ "Executor-local operation; Hub consent and session control stay in the dashboard."
+ ]
+ },
{
command: ["models", "price"],
summary: "Read the saved manual price for an exact provider/model selector.",
@@ -235,6 +314,40 @@ export const CAPABILITIES: readonly Capability[] = [
"Headless services usually have no unlocked keychain session; prefer ${ENV_VAR} references there.",
],
},
+ {
+ command: ["account", "history"],
+ summary: "Cached quota observations for one stored Codex pool account.",
+ routes: [{ method: "GET", path: "/api/codex-auth/quota/history" }],
+ flags: [
+ { name: "--json", value: "boolean", summary: "Emit the bounded observation history." },
+ { name: "--limit", value: "number", summary: "Return the newest 1 to 200 observations." },
+ ],
+ mutates: false,
+ json: "payload",
+ details: ["Use account history openai . Reads cached observations only; no refresh or warmup. Native main is not included."],
+ },
+ {
+ command: ["account", "main", "reauth"],
+ summary: "Reauthenticate the native main Codex login with a device code (#3898); headless hubs need no Codex App or keyring.",
+ routes: [
+ { method: "POST", path: "/api/codex-auth/main/reauth-device" },
+ { method: "GET", path: "/api/codex-auth/main/reauth-device" },
+ { method: "DELETE", path: "/api/codex-auth/main/reauth-device" },
+ ],
+ flags: [
+ { name: "--device", value: "boolean", summary: "Run the device-code flow (the only reauth mode)." },
+ { name: "--no-wait", value: "boolean", summary: "Print the flow handle and code without waiting for completion." },
+ { name: "--flow", value: "string", summary: "Flow id for status and cancel." },
+ { name: "--json", value: "boolean", summary: "Emit the flow status as JSON." },
+ ],
+ mutates: true,
+ json: "payload",
+ details: [
+ "Same-identity reauth only: the device login must complete for the ChatGPT account that already holds the native main slot, and the commit is fenced by the exclusive claim plus a path/hash/inode snapshot.",
+ "/api/codex-auth/login stays pool-only and keeps rejecting __main__; this namespace is the only device-reauth surface for the native main slot.",
+ "Payloads carry only flowId, status, the verification URL, the device code, and a closed set of failure codes -- never tokens, emails, or raw account ids.",
+ ],
+ },
{
command: ["account", "list"],
summary: "Codex OAuth accounts with pool priority and pause state.",
diff --git a/src/cli/dispatch.ts b/src/cli/dispatch.ts
index 0f0ce8cc47..203d2d63d5 100644
--- a/src/cli/dispatch.ts
+++ b/src/cli/dispatch.ts
@@ -482,6 +482,10 @@ const commandRunners: Record = {
const { handleConnectCommand } = await import("./connect");
return await handleConnectCommand(deps.args.slice(1));
},
+ "remote-workspace": async deps => {
+ const { runRemoteWorkspaceCommand } = await import("./remote-workspace");
+ return await runRemoteWorkspaceCommand(deps.args.slice(1));
+ },
disconnect: async deps => {
const { handleDisconnectCommand } = await import("./connect");
return await handleDisconnectCommand(deps.args.slice(1));
diff --git a/src/cli/help.ts b/src/cli/help.ts
index 8608e5789d..15f6ed711a 100644
--- a/src/cli/help.ts
+++ b/src/cli/help.ts
@@ -41,6 +41,7 @@ Usage:
ocx tray Windows status tray (install|start|stop|status|uninstall)
ocx ensure Ensure the proxy is running and Codex config/cache are current
ocx connect Connect this machine to a remote OpenCodex hub (credential via stdin)
+ ocx remote-workspace Pair/run an OCX-only remote execution computer
ocx disconnect Restore local state and clear the hub connection
ocx sync [--restart-codex] Fetch models from providers and inject into Codex config
ocx sync-cache [--restart-codex]
diff --git a/src/cli/observe.ts b/src/cli/observe.ts
index 9edd87692e..dcbc5e9a59 100644
--- a/src/cli/observe.ts
+++ b/src/cli/observe.ts
@@ -14,6 +14,10 @@ import { formatUsageReport } from "./usage-report";
import { USAGE_RANGES, USAGE_SURFACES, type UsageSummary } from "../usage/summary";
import { parseUsageTimeWindow, type UsageTimeWindow } from "../usage/time-range";
import { redactSecretString } from "../lib/redact";
+import { readClientConnectionState, sameClientConnectionOwner } from "../client/state";
+import { readServiceApiTokenState } from "../lib/service-secrets";
+import { fetchHubUsage } from "../client/hub-client";
+import type { HubUsageReport } from "../remote/hub-usage";
const USAGE = `Usage:
ocx observe logs [--provider ] [--model ] [--status ]
@@ -175,7 +179,30 @@ async function usage(argv: string[], deps: RuntimeApiDeps): Promise {
throw new CliUsageError(`--surface must be one of ${USAGE_SURFACES.join(", ")}`, USAGE);
}
rejectArgs(args.map(redactSecretString), USAGE);
- const result = await runtimeRequest(`/api/usage${query({ range, surface, provider, model, since: window?.since, until: window?.until })}`, {}, deps);
+ const suffix = query({ range, surface, provider, model, since: window?.since, until: window?.until });
+ const connection = readClientConnectionState();
+ let result: UsageSummary | HubUsageReport;
+ if (connection.kind === "invalid" || connection.kind === "mismatched") {
+ throw new Error(`Client usage unavailable: ${connection.reason}`);
+ }
+ if (connection.kind === "connected") {
+ const token = readServiceApiTokenState();
+ if (token.kind !== "present" || token.fingerprint !== connection.value.tokenFingerprint) {
+ throw new Error("Client usage unavailable: the enrolled data key is missing or changed; repair the client connection");
+ }
+ result = await fetchHubUsage(connection.value.serverUrl, token.token, new URLSearchParams(suffix), {
+ fetchImpl: deps.fetchImpl, timeoutMs: 60_000,
+ });
+ const current = readClientConnectionState();
+ const currentToken = readServiceApiTokenState();
+ if (current.kind !== "connected" || !sameClientConnectionOwner(current.value, connection.value)
+ || current.value.tokenFingerprint !== token.fingerprint
+ || currentToken.kind !== "present" || currentToken.fingerprint !== token.fingerprint) {
+ throw new Error("Client connection changed while reading usage; retry for the current connection");
+ }
+ } else {
+ result = await runtimeRequest(`/api/usage${suffix}`, {}, deps);
+ }
// Older daemons ignore custom bounds and return successful preset reports.
if (window && (result?.customWindow !== true || result.since !== window.since || result.until !== window.until)) {
throw new Error("The server did not confirm the requested custom usage window. Upgrade and restart the proxy, then retry.");
diff --git a/src/cli/registry.ts b/src/cli/registry.ts
index 91eab8723b..1883bb27f2 100644
--- a/src/cli/registry.ts
+++ b/src/cli/registry.ts
@@ -107,6 +107,17 @@ export const CLI_COMMANDS: CliCommandEntry[] = [
"Credentials are accepted only through stdin; argv and environment credential forms are not supported.",
],
},
+ {
+ name: "remote-workspace",
+ usage: "ocx remote-workspace ",
+ summary: "Pair this OCX-only computer with a hub and run its remote workspace executor.",
+ details: [
+ "Pair: ocx remote-workspace pair --pairing-code-stdin --root [--toolchain-root ] [--executor-helper ] [--name ]",
+ "Agent: ocx remote-workspace agent",
+ "Status: ocx remote-workspace status [--json]",
+ "Codex, Claude Code, Pi, provider logins, and API keys remain on the hub; only workspace tools execute here.",
+ ],
+ },
{
name: "disconnect",
usage: "ocx disconnect [--keep-catalog] [--json]",
diff --git a/src/cli/remote-workspace.ts b/src/cli/remote-workspace.ts
new file mode 100644
index 0000000000..5eb8a59c44
--- /dev/null
+++ b/src/cli/remote-workspace.ts
@@ -0,0 +1,154 @@
+import type { RemoteWorkspaceDeviceState } from "../remote-control/workspace-device";
+import {
+ RemoteWorkspaceDeviceFileStore,
+ pairRemoteWorkspaceDevice,
+ remoteWorkspaceCapabilitiesForCommandRunner,
+ runRemoteWorkspaceAgent,
+ type PairRemoteWorkspaceDeviceOptions,
+ type RemoteWorkspaceAgentRunStatus,
+ type RemoteWorkspaceDeviceStateStore,
+} from "../remote-control/workspace-device";
+import { createPlatformRemoteWorkspaceCommandRunner } from "../remote-control/workspace-command-runner";
+import {
+ CliUsageError,
+ readSecretLine,
+ rejectArgs,
+ takeFlag,
+ takeJsonFlag,
+ takeOption,
+ type RuntimeApiDeps,
+} from "./runtime-api";
+
+export const REMOTE_WORKSPACE_USAGE = `Usage:
+ ocx remote-workspace pair --pairing-code-stdin --root [--root ...] [--toolchain-root ...] [--executor-helper ] [--name ] [--json]
+ ocx remote-workspace agent
+ ocx remote-workspace status [--json]`;
+
+export interface RemoteWorkspaceCliDeps extends RuntimeApiDeps {
+ store?: RemoteWorkspaceDeviceStateStore;
+ pair?: (options: PairRemoteWorkspaceDeviceOptions) => Promise;
+ runAgent?: typeof runRemoteWorkspaceAgent;
+ signal?: AbortSignal;
+ onStatus?: (status: RemoteWorkspaceAgentRunStatus) => void;
+}
+
+function takeRepeatedPathFlag(args: string[], flag: "--root" | "--toolchain-root"): string[] {
+ const roots: string[] = [];
+ for (;;) {
+ const index = args.indexOf(flag);
+ if (index < 0) break;
+ const value = args[index + 1];
+ if (!value || value.startsWith("--")) throw new CliUsageError(`${flag} requires an absolute path`, REMOTE_WORKSPACE_USAGE);
+ roots.push(value);
+ args.splice(index, 2);
+ }
+ return roots;
+}
+
+function publicStatus(state: RemoteWorkspaceDeviceState | null): Record {
+ if (!state) return { paired: false };
+ const capabilities = remoteWorkspaceCapabilitiesForCommandRunner(
+ createPlatformRemoteWorkspaceCommandRunner({
+ linux: {
+ toolchainRoots: state.toolchainRoots,
+ writableRoots: state.roots.map(root => root.path),
+ },
+ ...(state.nativeHelper ? { native: {
+ helper: state.nativeHelper,
+ toolchainRoots: state.toolchainRoots,
+ writableRoots: state.roots.map(root => root.path),
+ } } : {}),
+ }),
+ state.capabilities,
+ );
+ return {
+ paired: true,
+ hubUrl: state.hubUrl,
+ deviceId: state.deviceId,
+ deviceName: state.deviceName,
+ devicePlatform: state.devicePlatform,
+ capabilities,
+ roots: state.roots.map(root => ({ id: root.id, label: root.label, path: root.path })),
+ toolchainRoots: state.toolchainRoots,
+ };
+}
+
+export async function runRemoteWorkspaceCommand(rawArgs: string[], deps: RemoteWorkspaceCliDeps = {}): Promise {
+ const args = [...rawArgs];
+ const command = args.shift();
+ const store = deps.store ?? new RemoteWorkspaceDeviceFileStore();
+ if (command === "status") {
+ const wantsJson = takeJsonFlag(args);
+ rejectArgs(args, REMOTE_WORKSPACE_USAGE);
+ const status = publicStatus(store.load());
+ if (wantsJson) console.log(JSON.stringify(status, null, 2));
+ else if (!status.paired) console.log("Remote Workspace executor is not paired.");
+ else {
+ console.log(`Remote Workspace executor: ${status.deviceName}`);
+ console.log(`Hub: ${status.hubUrl}`);
+ console.log(`Capabilities: ${(status.capabilities as string[]).join(", ")}`);
+ console.log(`Workspace roots: ${(status.roots as unknown[]).length}`);
+ }
+ return 0;
+ }
+ if (command === "pair") {
+ const wantsJson = takeJsonFlag(args);
+ const readCode = takeFlag(args, "--pairing-code-stdin");
+ const name = takeOption(args, "--name");
+ const nativeHelperPath = takeOption(args, "--executor-helper");
+ const roots = takeRepeatedPathFlag(args, "--root");
+ const toolchainRoots = takeRepeatedPathFlag(args, "--toolchain-root");
+ const hubUrl = args.shift();
+ if (!hubUrl || !readCode || roots.length === 0) throw new CliUsageError(
+ "pair requires , --pairing-code-stdin, and at least one --root",
+ REMOTE_WORKSPACE_USAGE,
+ );
+ rejectArgs(args, REMOTE_WORKSPACE_USAGE, { redactValues: true });
+ const pairingCode = await readSecretLine(deps, "Remote Workspace pairing code");
+ const state = await (deps.pair ?? pairRemoteWorkspaceDevice)({
+ hubUrl,
+ pairingCode,
+ ...(name ? { name } : {}),
+ roots: roots.map(path => ({ path })),
+ toolchainRoots,
+ ...(nativeHelperPath ? { nativeHelperPath } : {}),
+ store,
+ });
+ const status = publicStatus(state);
+ if (wantsJson) console.log(JSON.stringify(status, null, 2));
+ else {
+ console.log(`Paired ${state.deviceName} with ${state.hubUrl}.`);
+ console.log("Run `ocx remote-workspace agent` to keep this executor online.");
+ }
+ return 0;
+ }
+ if (command === "agent") {
+ rejectArgs(args, REMOTE_WORKSPACE_USAGE);
+ const state = store.load();
+ if (!state) throw new CliUsageError("Remote Workspace executor is not paired. Run the pair command first.", REMOTE_WORKSPACE_USAGE);
+ const controller = deps.signal ? null : new AbortController();
+ const signal = deps.signal ?? controller!.signal;
+ const stop = () => controller?.abort();
+ if (controller) {
+ process.once("SIGINT", stop);
+ process.once("SIGTERM", stop);
+ }
+ try {
+ await (deps.runAgent ?? runRemoteWorkspaceAgent)({
+ state,
+ signal,
+ onStatus: deps.onStatus ?? (status => {
+ if (status.state === "online") console.log(`Remote Workspace executor online: ${state.deviceName}`);
+ if (status.state === "reconnecting" && status.message) console.error(`Remote Workspace reconnecting: ${status.message}`);
+ }),
+ });
+ } finally {
+ if (controller) {
+ process.removeListener("SIGINT", stop);
+ process.removeListener("SIGTERM", stop);
+ }
+ }
+ return 0;
+ }
+ throw new CliUsageError("choose pair, agent, or status", REMOTE_WORKSPACE_USAGE);
+}
diff --git a/src/cli/usage-report.ts b/src/cli/usage-report.ts
index 3311a781a1..e95bbf3354 100644
--- a/src/cli/usage-report.ts
+++ b/src/cli/usage-report.ts
@@ -21,6 +21,10 @@ interface CostRow {
}
interface UsageReportInput {
+ source?: "hub";
+ scope?: "client";
+ usageIncomplete?: true;
+ usageIncompleteReason?: "oversized_rows";
range?: string;
surface?: string;
since?: number | null;
@@ -109,11 +113,19 @@ function describeScope(data: UsageReportInput): string {
export function formatUsageReport(data: UsageReportInput): string[] {
const summary = data.summary ?? {};
const lines: string[] = [describeScope(data), ""];
+ if (data.source === "hub" && data.scope === "client") {
+ lines.push("Source: hub — this client's data key only. Account totals are not shared.", "");
+ }
+ if (data.usageIncomplete === true) {
+ lines.push("WARNING: Usage is incomplete; some records could not be included. Totals and rankings reflect readable records only.", "");
+ }
if (data.filter && !data.filter.matched) {
const what = [data.filter.provider && `provider "${data.filter.provider}"`, data.filter.model && `model "${data.filter.model}"`]
.filter(Boolean).join(" and ");
- lines.push(`No usage recorded for ${terminalText(what)} in this range.`);
+ lines.push(data.usageIncomplete === true
+ ? `No matching readable usage records for ${terminalText(what)} in this range; skipped records may contain matches.`
+ : `No usage recorded for ${terminalText(what)} in this range.`);
lines.push("Check the spelling against `ocx usage --json`, or widen --range.");
return lines.map(terminalText);
}
@@ -151,7 +163,7 @@ export function formatUsageReport(data: UsageReportInput): string[] {
// server cannot answer it honestly.
const accountFilterActive = Boolean(data.filter?.provider || data.filter?.model);
const accounts = (data.accounts ?? []).filter(row => row.requests > 0);
- if (accountFilterActive) {
+ if (accountFilterActive && data.scope !== "client") {
lines.push("");
lines.push("ACCOUNT: not reported under a provider or model filter; run without filters for per-account totals.");
} else if (accounts.length > 0) {
diff --git a/src/client/hub-client.ts b/src/client/hub-client.ts
index 707605f2bb..47604e3398 100644
--- a/src/client/hub-client.ts
+++ b/src/client/hub-client.ts
@@ -1,5 +1,6 @@
import { MAX_REMOTE_CATALOG_BYTES } from "../server/catalog-download";
import { MAX_HUB_STATE_BYTES, parseHubStateBody, type HubStateDTO } from "../remote/hub-state";
+import { MAX_HUB_USAGE_BYTES, parseHubUsage, type HubUsageReport } from "../remote/hub-usage";
import { readBoundedResponseBytes } from "../lib/bounded-body";
import { clearableDeadline } from "../lib/abort";
import type { Desktop3pModelEntry } from "../claude/desktop-3p";
@@ -472,6 +473,39 @@ export async function downloadClientCatalog(
return { kind: "fresh", body, ...(keyId ? { keyId } : {}) };
}
+/** Bounded own-key usage read; never falls back to a local management endpoint. */
+export async function fetchHubUsage(
+ serverUrl: string,
+ admissionToken: string,
+ query: URLSearchParams,
+ options: { timeoutMs?: number; fetchImpl?: typeof fetch } = {},
+): Promise {
+ const origin = normalizeHubOrigin(serverUrl);
+ if (!isPairingTransportPermitted(origin)) {
+ throw new HubClientError("insecure_http_refused", "Client usage requires HTTPS or loopback HTTP");
+ }
+ const response = await fetchBounded(options.fetchImpl ?? fetch, `${origin}/v1/usage?${query}`, {
+ method: "GET",
+ cache: "no-store",
+ headers: new Headers({ Accept: "application/json", "x-opencodex-api-key": admissionToken }),
+ }, options.timeoutMs);
+ if (!response.ok) {
+ try { await response.body?.cancel(); } catch { /* best effort */ }
+ const message = response.status === 404 ? "Hub does not support client usage; upgrade the hub"
+ : response.status === 401 || response.status === 403 ? "Hub rejected this client's usage credential"
+ : `Hub usage request failed (${response.status})`;
+ throw new HubClientError(`hub_usage_http_${response.status}`, message, response.status);
+ }
+ if (!jsonCompatibleContentType(response)) {
+ try { await response.body?.cancel(); } catch { /* best effort */ }
+ throw new HubClientError("hub_usage_invalid", "Hub usage response was not JSON");
+ }
+ const text = await boundedText(response, MAX_HUB_USAGE_BYTES, { inactivityTimeoutMs: safeTimeout(options.timeoutMs) });
+ const report = parseHubUsage(parseJson(text, "hub_usage_invalid"));
+ if (!report) throw new HubClientError("hub_usage_invalid", "Hub usage response was invalid");
+ return report;
+}
+
/**
* Read the hub's provider/login/roster state with the per-client DATA key (#4236).
*
diff --git a/src/codex/account-store.ts b/src/codex/account-store.ts
index 285db1708c..4b151707a1 100644
--- a/src/codex/account-store.ts
+++ b/src/codex/account-store.ts
@@ -13,6 +13,8 @@ import {
import { assertNotRealHomeUnderTest } from "../lib/test-home-guard";
import type { CodexAccountCredentialRecord, CodexAccountCredentials } from "../types";
import { advanceCodexCredentialMutationEpoch } from "./credential-mutation-epoch";
+import { isValidCodexAccountId } from "./account-id";
+import type { PoolQuotaWriter } from "./quota-types";
import { CODEX_REFRESH_FLIGHT_CEILING_MS } from "./quota-recovery-timing";
type LegacyCodexAccountStore = Record;
@@ -163,6 +165,7 @@ export function saveCodexAccountCredential(
generation: (current?.generation ?? 0) + 1,
refreshGrantFingerprint,
replacedAt: current ? Date.now() : undefined,
+ quotaHistoryIdentity: crypto.randomUUID(),
...preservedValidationMetadata(current),
...(options.validationPending ? {
codexValidationPending: true,
@@ -257,6 +260,75 @@ export function readCodexAccountRecord(id: string): CodexAccountCredentialRecord
return loadCodexAccountRecordStore()[id] ?? null;
}
+const QUOTA_HISTORY_IDENTITY_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/;
+
+function validQuotaHistoryIdentity(value: unknown): value is string {
+ return typeof value === "string" && QUOTA_HISTORY_IDENTITY_RE.test(value);
+}
+
+type DispatchedPoolCredential = Pick & { generation: number };
+
+function matchesDispatchedPoolCredential(record: CodexAccountCredentialRecord | undefined | null, dispatched: DispatchedPoolCredential): record is CodexAccountCredentialRecord & { credential: CodexAccountCredentials } {
+ return !!record?.credential && record.deletedAt == null
+ && dispatched.accessToken.length > 0 && dispatched.chatgptAccountId.length > 0
+ && Number.isSafeInteger(dispatched.generation) && dispatched.generation >= 0
+ && record.generation === dispatched.generation
+ && record.credential.accessToken === dispatched.accessToken
+ && record.credential.chatgptAccountId === dispatched.chatgptAccountId;
+}
+
+/** Optional evidence capture; a stale credential or unavailable store never gains a new writer. */
+export function capturePoolQuotaWriter(accountId: string, dispatched: DispatchedPoolCredential): PoolQuotaWriter | undefined {
+ if (!isValidCodexAccountId(accountId)) return undefined;
+ try {
+ const current = readCodexAccountRecord(accountId);
+ if (!matchesDispatchedPoolCredential(current, dispatched)) return undefined;
+ if (validQuotaHistoryIdentity(current.quotaHistoryIdentity)) {
+ return { accountId, credentialGeneration: dispatched.generation, historyIdentity: current.quotaHistoryIdentity };
+ }
+ return withCredentialMutationLockSync(() => {
+ const store = loadCodexAccountRecordStore();
+ const locked = store[accountId];
+ if (!matchesDispatchedPoolCredential(locked, dispatched)) return undefined;
+ if (!validQuotaHistoryIdentity(locked.quotaHistoryIdentity)) {
+ locked.quotaHistoryIdentity = crypto.randomUUID();
+ // Identity metadata is not a new credential; preserve generation and mutation epoch.
+ persist(store);
+ }
+ return { accountId, credentialGeneration: dispatched.generation, historyIdentity: locked.quotaHistoryIdentity };
+ });
+ } catch {
+ // History is optional evidence. Permission, lock and disk errors cannot fail inference.
+ return undefined;
+ }
+}
+
+/** Read-only retention identity; unlike capture this never initializes legacy metadata. */
+export function poolQuotaHistoryIdentity(accountId: string): string | undefined {
+ if (!isValidCodexAccountId(accountId)) return undefined;
+ try {
+ const record = readCodexAccountRecord(accountId);
+ return record?.credential && record.deletedAt == null && validQuotaHistoryIdentity(record.quotaHistoryIdentity)
+ ? record.quotaHistoryIdentity : undefined;
+ } catch {
+ return undefined;
+ }
+}
+
+/** Recheck append admission after upstream I/O; refresh may retire a writer without erasing history. */
+export function isPoolQuotaWriterLive(writer: PoolQuotaWriter): boolean {
+ if (!isValidCodexAccountId(writer.accountId)) return false;
+ try {
+ const record = readCodexAccountRecord(writer.accountId);
+ return !!record?.credential && record.deletedAt == null
+ && record.generation === writer.credentialGeneration
+ && validQuotaHistoryIdentity(writer.historyIdentity)
+ && record.quotaHistoryIdentity === writer.historyIdentity;
+ } catch {
+ return false;
+ }
+}
+
export function isCodexAccountGenerationLive(id: string, generation: number): boolean {
const record = readCodexAccountRecord(id);
return !!record?.credential && record.deletedAt == null && record.generation === generation;
@@ -281,6 +353,8 @@ export function saveCodexAccountCredentialIfGeneration(
generation: generation + 1,
refreshGrantFingerprint,
replacedAt: current.replacedAt,
+ quotaHistoryIdentity: current.credential.chatgptAccountId === cred.chatgptAccountId
+ ? current.quotaHistoryIdentity : crypto.randomUUID(),
...preservedValidationMetadata(current),
};
persistCredentialMutation(store);
@@ -340,6 +414,8 @@ export function commitRefreshedCodexCredentialWithAliases(
generation: generation + 1,
refreshGrantFingerprint,
replacedAt: current.replacedAt,
+ quotaHistoryIdentity: current.credential.chatgptAccountId === cred.chatgptAccountId
+ ? current.quotaHistoryIdentity : crypto.randomUUID(),
...preservedValidationMetadata(current),
};
@@ -354,6 +430,7 @@ export function commitRefreshedCodexCredentialWithAliases(
priorFingerprint !== undefined
&& priorCredential.refreshToken !== cred.refreshToken
&& !!priorCredential.chatgptAccountId
+ && priorCredential.chatgptAccountId === cred.chatgptAccountId
) {
for (const [aliasId, alias] of Object.entries(store)) {
if (aliasId === id || alias.deletedAt != null || !alias.credential) continue;
@@ -369,6 +446,7 @@ export function commitRefreshedCodexCredentialWithAliases(
generation: aliasGeneration,
refreshGrantFingerprint,
replacedAt: alias.replacedAt,
+ quotaHistoryIdentity: alias.quotaHistoryIdentity,
...preservedValidationMetadata(alias),
};
propagatedAliases.push({ id: aliasId, generation: aliasGeneration });
diff --git a/src/codex/auth-api.ts b/src/codex/auth-api.ts
index 09becf51ea..70c7273c5d 100644
--- a/src/codex/auth-api.ts
+++ b/src/codex/auth-api.ts
@@ -1,3 +1,10 @@
+import { CODEX_ACCOUNT_LOG_LABEL_RE } from "./account-label";
+import { poolQuotaHistoryIdentity } from "./account-store";
+import { estimateCodexQuotaCapacity, insufficientCodexCapacity, type CodexCapacityResult } from "./quota-capacity";
+import { readUsageSnapshotForManagement } from "../usage/log";
+import { capturePoolQuotaWriter } from "./account-store";
+import type { PoolQuotaWriter } from "./quota-types";
+import { getAccountQuotaHistory, isValidWhamHistoryObservation } from "./quota";
import {
ConfigMutationLockError,
loadConfig,
@@ -41,6 +48,7 @@ import {
} from "./account-priority";
import {
claimDueCodexQuotaRecoveryProbes,
+ codexQuotaScopeForModel,
claimManualResetCooldowns,
settleManualResetCooldown,
type ManualResetCooldownClaim,
@@ -49,6 +57,7 @@ import {
clearThreadAccountMapForAccount,
getEffectiveActiveCodexAccountId,
isEffectiveCodexAccountPinned,
+ isCodexAccountPlanExcluded,
reconcileCodexActiveAfterExclusion,
resetCodexRoutingForManualSelection,
settleCodexQuotaRecoveryProbe,
@@ -58,9 +67,9 @@ import {
MAX_ACCOUNT_PRIORITY,
MIN_ACCOUNT_PRIORITY,
normalizeAccountPoolStickyLimit,
- normalizeAccountPoolStrategy,
+ normalizeCodexAccountPoolStrategy,
parseAccountPoolStickyLimit,
- parseAccountPoolStrategy,
+ parseCodexAccountPoolStrategy,
parseAccountPriority,
} from "./pool-rotation";
import { checkAccountIdCollision, getMainChatgptAccountId, readCodexTokens, readCodexTokensResult } from "./auth-collision";
@@ -379,6 +388,7 @@ export type CodexAccountReauthReason =
| "forbidden";
function poolAccountDto(
+ config: OcxConfig,
account: CodexAccount,
quotaResult: PoolQuotaResult,
hasCredential: boolean,
@@ -413,6 +423,10 @@ function poolAccountDto(
quota: quota ? { ...quota } : null,
needsReauth: needsReauth || health.status === "reauth_required",
...(reauthReason !== undefined ? { reauthReason } : {}),
+ ...(isCodexAccountPlanExcluded(config, account.id) ? {
+ selectionExcludedReason: "plan_excluded" as const,
+ selectionExcludedPlan: codexPlanValue(config.codexAccounts?.find(row => row.id === account.id)?.plan),
+ } : {}),
hasCredential,
...(quotaResult.quotaProbeSkipped ? { quotaProbeSkipped: true as const } : {}),
...oauthAccountHealthFields("codex", account.id, health),
@@ -1192,6 +1206,9 @@ export interface CodexAuthAccountDto {
* needs the operator; `/api/oauth/accounts` already carries the same field name.
*/
reauthReason?: CodexAccountReauthReason;
+ /** Automatic selection policy only; explicit routes retain their usual auth checks. */
+ selectionExcludedReason?: "plan_excluded";
+ selectionExcludedPlan?: string;
hasCredential: boolean;
health: OAuthAccountHealth;
healthLabel: OAuthHealthLabel;
@@ -1358,6 +1375,7 @@ async function recoverPoolQuotaFrom401(ctx: {
const writerGeneration = captureConfigGeneration();
markQuotaProbeAttempted(ctx.quotaProbeEvidence, refreshed.generation);
+ const poolWriter = capturePoolQuotaWriter(accountId, refreshed);
const replay = await fetch("https://chatgpt.com/backend-api/wham/usage", {
headers: {
Authorization: `Bearer ${refreshed.accessToken}`,
@@ -1376,7 +1394,7 @@ async function recoverPoolQuotaFrom401(ctx: {
return { quota: existing ?? null, needsReauth: false, credentialGeneration: refreshed.generation };
}
const result = await commitPoolQuotaResponse(replay, {
- accountId, existing, configuredPlan, generation: refreshed.generation, writerGeneration,
+ accountId, existing, configuredPlan, generation: refreshed.generation, writerGeneration, poolWriter,
mayPublish: ctx.quotaProbeEvidence.mayPublish,
});
return result.freshCredentialGeneration === refreshed.generation ? {
@@ -1418,11 +1436,13 @@ async function commitPoolQuotaResponse(
configuredPlan: string | undefined;
generation: number;
writerGeneration: number;
+ poolWriter?: PoolQuotaWriter;
mayPublish?: () => boolean;
},
): Promise {
const { accountId, existing, configuredPlan, generation, writerGeneration } = ctx;
const data = (await resp.json()) as WhamUsageResponse;
+ const observedAt = Date.now();
if (ctx.mayPublish?.() === false) {
return { quota: getAccountQuota(accountId), needsReauth: false, credentialGeneration: generation };
}
@@ -1440,7 +1460,8 @@ async function commitPoolQuotaResponse(
if (!isCodexAccountGenerationLive(accountId, generation)) {
return { quota: null, needsReauth: false, credentialGeneration: generation };
}
- setAccountQuotaFromParsed(accountId, quota, writerGeneration);
+ setAccountQuotaFromParsed(accountId, quota, writerGeneration, undefined, quota,
+ ctx.poolWriter && isValidWhamHistoryObservation(data) ? { writer: ctx.poolWriter, observedAt, source: "wham", raw: quota } : undefined);
return {
quota: getAccountQuota(accountId),
needsReauth: false,
@@ -1464,6 +1485,7 @@ async function fetchFreshPoolAccountQuota(
let requestCredentialGeneration = readCodexAccountRecord(accountId)?.generation;
try {
const { accessToken, chatgptAccountId, generation } = await getValidToken(accountId);
+ const poolWriter = capturePoolQuotaWriter(accountId, { accessToken, chatgptAccountId, generation });
requestCredentialGeneration = generation;
onCredentialGeneration?.(generation);
markQuotaProbeAttempted(quotaProbeEvidence, generation);
@@ -1494,7 +1516,7 @@ async function fetchFreshPoolAccountQuota(
return withQuotaProbeEvidence(recovered, quotaProbeEvidence);
}
const committed = await commitPoolQuotaResponse(resp, {
- accountId, existing, configuredPlan, generation, writerGeneration,
+ accountId, existing, configuredPlan, generation, writerGeneration, poolWriter,
mayPublish: quotaProbeEvidence.mayPublish,
});
return withQuotaProbeEvidence(committed, quotaProbeEvidence);
@@ -2006,6 +2028,7 @@ export async function listCodexAuthAccountsSnapshot(
const currentCredential = getCodexAccountCredential(accountId);
if (!currentCredential) {
return [poolAccountDto(
+ runtimeConfig,
currentAccount,
{ quota: null, needsReauth: true },
false,
@@ -2026,6 +2049,7 @@ export async function listCodexAuthAccountsSnapshot(
? { ...currentAccount, plan: quotaResult.freshPlan }
: currentAccount;
return [poolAccountDto(
+ runtimeConfig,
dtoAccount,
effectiveQuotaResult,
true,
@@ -2457,7 +2481,7 @@ export async function handleCodexAuthAPI(
pinnedAccountId: pinnedCodexAccountId(runtimeConfig) ?? null,
autoSwitchThreshold: runtimeConfig.autoSwitchThreshold ?? 80,
upstreamFailoverThreshold: runtimeConfig.upstreamFailoverThreshold ?? 3,
- accountPoolStrategy: normalizeAccountPoolStrategy(runtimeConfig.accountPoolStrategy),
+ accountPoolStrategy: normalizeCodexAccountPoolStrategy(runtimeConfig.accountPoolStrategy),
accountPoolStickyLimit: normalizeAccountPoolStickyLimit(runtimeConfig.accountPoolStickyLimit),
});
}
@@ -2488,12 +2512,12 @@ export async function handleCodexAuthAPI(
return jsonResponse({ error: "strategy or stickyLimit required" }, 400);
}
const runtimeConfig = getRuntimeConfig(config);
- let nextStrategy: NonNullable> | undefined;
+ let nextStrategy: NonNullable> | undefined;
let nextSticky: NonNullable> | undefined;
if (body.strategy !== undefined) {
- const parsed = parseAccountPoolStrategy(body.strategy);
+ const parsed = parseCodexAccountPoolStrategy(body.strategy);
if (parsed === null) {
- return jsonResponse({ error: 'strategy must be one of: quota, round-robin, fill-first' }, 400);
+ return jsonResponse({ error: 'strategy must be one of: quota, round-robin, fill-first, reset-first' }, 400);
}
nextStrategy = parsed;
}
@@ -2509,7 +2533,7 @@ export async function handleCodexAuthAPI(
saveRuntimeConfig(config, runtimeConfig);
return jsonResponse({
ok: true,
- accountPoolStrategy: normalizeAccountPoolStrategy(runtimeConfig.accountPoolStrategy),
+ accountPoolStrategy: normalizeCodexAccountPoolStrategy(runtimeConfig.accountPoolStrategy),
accountPoolStickyLimit: normalizeAccountPoolStickyLimit(runtimeConfig.accountPoolStickyLimit),
});
}
@@ -2526,6 +2550,46 @@ export async function handleCodexAuthAPI(
return jsonResponse({ ok: true });
}
+ if (url.pathname === "/api/codex-auth/quota/history" && req.method === "GET") {
+ const accountId = url.searchParams.get("accountId");
+ const rawLimit = url.searchParams.get("limit");
+ if (url.searchParams.getAll("accountId").length !== 1 || !isValidCodexAccountId(accountId)
+ || url.searchParams.getAll("limit").length > 1
+ || [...url.searchParams.keys()].some(key => key !== "accountId" && key !== "limit")
+ || (rawLimit !== null && !/^(?:[1-9]|[1-9][0-9]|1[0-9]{2}|200)$/.test(rawLimit))) {
+ return jsonResponse({ error: "A stored pool accountId and optional limit from 1 to 200 are required" }, 400);
+ }
+ const runtimeConfig = getRuntimeConfig(config);
+ const account = configuredPoolAccount(runtimeConfig, accountId);
+ if (!account) return jsonResponse({ error: "Unknown pool account" }, 404);
+ const identity = poolQuotaHistoryIdentity(accountId);
+ const allHistory = getAccountQuotaHistory(accountId);
+ const limit = rawLimit === null ? 200 : Number(rawLimit);
+ const history = { ...allHistory, observations: allHistory.observations.slice(-limit), truncated: allHistory.observations.length > limit };
+ const label = account.logLabel;
+ const labelStillUnique = () => {
+ const current = getRuntimeConfig(config);
+ return configuredPoolAccount(current, accountId)?.logLabel === label
+ && current.codexAccounts?.filter(row => codexAccountLogLabel(row) === label).length === 1;
+ };
+ let capacity: CodexCapacityResult = insufficientCodexCapacity("identity_unavailable");
+ if (identity && identity === poolQuotaHistoryIdentity(accountId) && label && CODEX_ACCOUNT_LOG_LABEL_RE.test(label) && labelStillUnique()) {
+ try {
+ const usage = await readUsageSnapshotForManagement();
+ if (poolQuotaHistoryIdentity(accountId) !== identity || !labelStillUnique()) capacity = insufficientCodexCapacity("identity_changed");
+ else if (!usage.revision) capacity = insufficientCodexCapacity("ledger_unavailable");
+ else if (usage.truncatedPrefixBytes > 0 || usage.entriesTruncated || usage.entriesDropped > 0) capacity = insufficientCodexCapacity("ledger_truncated");
+ else capacity = estimateCodexQuotaCapacity(allHistory.observations, usage.entries, label,
+ model => codexQuotaScopeForModel(model) === "shared");
+ } catch { capacity = insufficientCodexCapacity("ledger_unavailable"); }
+ }
+ if (!configuredPoolAccount(getRuntimeConfig(config), accountId)) return jsonResponse({ error: "Unknown pool account" }, 404);
+ if (identity !== poolQuotaHistoryIdentity(accountId) || (identity && label && !labelStillUnique())) {
+ return jsonResponse({ accountId, ...getAccountQuotaHistory(accountId, limit), capacity: insufficientCodexCapacity("identity_changed") });
+ }
+ return jsonResponse({ accountId, ...history, capacity });
+ }
+
if (url.pathname === "/api/codex-auth/quota" && req.method === "GET") {
const quotas: Record = {};
for (const [id, q] of listAccountQuotas()) quotas[id] = q;
diff --git a/src/codex/auth-context.ts b/src/codex/auth-context.ts
index f362a2893a..5bf023565e 100644
--- a/src/codex/auth-context.ts
+++ b/src/codex/auth-context.ts
@@ -1,3 +1,4 @@
+import type { PoolQuotaWriter } from "./quota-types";
import { createHash, createHmac, randomBytes, timingSafeEqual } from "node:crypto";
import {
CodexCredentialGenerationConflictError,
@@ -6,6 +7,7 @@ import {
CodexCredentialRefreshStaleError,
getCodexAccountCredential,
getValidCodexToken,
+ capturePoolQuotaWriter,
isCodexAccountGenerationLive,
readCodexAccountRecord,
} from "./account-store";
@@ -120,6 +122,7 @@ export type CodexAuthContext =
accountId: string;
writerGeneration: number;
generation: number;
+ poolQuotaWriter?: PoolQuotaWriter;
accessToken: string;
chatgptAccountId: string;
/** Bypass Pool selection and suppress quota/transient failover for an exact selector. */
@@ -1030,6 +1033,7 @@ export async function resolveCodexAuthContext(
accountId,
writerGeneration,
generation: token.generation,
+ poolQuotaWriter: capturePoolQuotaWriter(accountId, token),
accessToken: token.accessToken,
chatgptAccountId: token.chatgptAccountId,
...(fixedAccountId !== undefined ? { fixedAccount: true } : {}),
diff --git a/src/codex/main-account.ts b/src/codex/main-account.ts
index a412046797..45efe351d7 100644
--- a/src/codex/main-account.ts
+++ b/src/codex/main-account.ts
@@ -21,6 +21,7 @@ import { clearAccountNeedsReauth } from "./account-runtime-state";
import { advanceCodexCredentialMutationEpoch } from "./credential-mutation-epoch";
import { withNativeMainExclusiveClaim } from "./native-main-claim";
import { resolveNativeProfileContext } from "./native-profile-store";
+import { isNativeMainTrafficBlocked } from "./native-profile-startup";
export { MAIN_CODEX_ACCOUNT_ID } from "./account-id";
@@ -218,6 +219,118 @@ export function setMainAuthJsonBeforeRenameHookForTests(hook: (() => void) | nul
beforeMainAuthJsonRenameForTests = hook;
}
+/** Complete token set a native device reauth commits into the main slot (#3898). */
+export interface NativeMainReauthTokens {
+ accessToken: string;
+ refreshToken: string;
+ idToken: string;
+ chatgptAccountId: string;
+}
+
+export class NativeMainReauthUnavailableError extends Error {
+ constructor(message = "Native main credential cannot be reauthenticated in this state") {
+ super(message);
+ this.name = "NativeMainReauthUnavailableError";
+ }
+}
+
+export class NativeMainReauthIdentityMismatchError extends Error {
+ constructor() {
+ super("Device login completed for a different ChatGPT account than the native main identity");
+ this.name = "NativeMainReauthIdentityMismatchError";
+ }
+}
+
+/**
+ * The reauth twin of persistRefreshedMainAuthJson (#3898). That function
+ * spreads expected.tokens and never writes id_token, which would keep the
+ * OLD identity token beside the new grant; this sibling sets all four
+ * credential fields together and overwrites any prior id_token. Everything
+ * else — allowed root metadata, the pre-rename snapshot guards, the
+ * mutation epoch — follows the refresh path exactly.
+ */
+function persistNativeMainReauthTokens(
+ expected: MainAuthJsonCredential,
+ tokens: NativeMainReauthTokens,
+): void {
+ assertNotRealCodexHomeUnderTest(resolveCodexHomeDir());
+ const nextTokens = {
+ ...expected.tokens,
+ access_token: tokens.accessToken,
+ refresh_token: tokens.refreshToken,
+ id_token: tokens.idToken,
+ account_id: tokens.chatgptAccountId,
+ };
+ atomicWriteFile(
+ expected.path,
+ JSON.stringify({ ...expected.root, tokens: nextTokens }, null, 2) + "\n",
+ undefined,
+ {
+ beforeRename: () => {
+ assertMainAuthJsonSnapshotUnchanged(expected);
+ const hook = beforeMainAuthJsonRenameForTests;
+ beforeMainAuthJsonRenameForTests = null;
+ hook?.();
+ },
+ validateBeforeRename: () => assertMainAuthJsonSnapshotUnchanged(expected),
+ },
+ );
+ advanceCodexCredentialMutationEpoch();
+}
+
+/**
+ * Prepare a same-identity reauth of the native __main__ slot (#3898).
+ *
+ * The existing credential snapshot is captured NOW and held only inside the
+ * closure — callers (the device-reauth service) never see the expected
+ * account id, so a flow cannot be steered toward a different identity. No
+ * claim is held while the human completes the device page. The returned
+ * commit, called once the device grant exists:
+ *
+ * 1. requires the SAME chatgpt account identity as the snapshot;
+ * 2. acquires the owner-independent exclusive claim (native-main-claim) —
+ * deliberately NOT assertNativeMainOwner, which a headless hub cannot
+ * satisfy;
+ * 3. re-verifies the snapshot (path + hash + dev/ino) inside the claim;
+ * 4. writes access/refresh/id token + account_id atomically and clears the
+ * main account's reauth quarantine for the new credential generation.
+ */
+export function beginNativeMainReauth(): {
+ commit: (tokens: NativeMainReauthTokens) => Promise<{ chatgptAccountId: string }>;
+} {
+ const expected = readMainAuthJsonCredential();
+ if (!expected || !expected.chatgptAccountId) {
+ throw new NativeMainReauthUnavailableError(
+ "No native main credential exists to reauthenticate; enrollment is the native profile workflow",
+ );
+ }
+ return {
+ async commit(tokens: NativeMainReauthTokens): Promise<{ chatgptAccountId: string }> {
+ if (!tokens.accessToken || !tokens.refreshToken || !tokens.idToken) {
+ throw new NativeMainReauthUnavailableError("Device grant did not produce a complete token set");
+ }
+ if (tokens.chatgptAccountId !== expected.chatgptAccountId) {
+ throw new NativeMainReauthIdentityMismatchError();
+ }
+ return withNativeMainExclusiveClaim(resolveNativeProfileContext(), async () => {
+ // Recovery/admission recheck (080): a recovery-blocked or not-ready
+ // home fails native_main_unavailable rather than rewriting auth.json
+ // underneath the gate. The claim waits bounded like the refresh path
+ // (30s) so a busy claim is not an instant refusal.
+ if (isNativeMainTrafficBlocked()) {
+ throw new NativeMainReauthUnavailableError(
+ "Native main traffic is blocked by startup or recovery state",
+ );
+ }
+ assertMainAuthJsonSnapshotUnchanged(expected);
+ persistNativeMainReauthTokens(expected, tokens);
+ clearAccountNeedsReauth(MAIN_CODEX_ACCOUNT_ID);
+ return { chatgptAccountId: tokens.chatgptAccountId };
+ }, { waitMs: 30_000 });
+ },
+ };
+}
+
async function resolveMainAccountToken(
dependencies: NativeMainRefreshDependencies = {},
rejectedAccessToken?: string,
diff --git a/src/codex/main-device-reauth-api.ts b/src/codex/main-device-reauth-api.ts
new file mode 100644
index 0000000000..538a4ead58
--- /dev/null
+++ b/src/codex/main-device-reauth-api.ts
@@ -0,0 +1,89 @@
+import type { OcxConfig } from "../types";
+import { jsonResponse } from "../server/auth-cors";
+import {
+ cancelMainDeviceReauth,
+ getMainDeviceReauthStatus,
+ MainDeviceReauthFlowBusyError,
+ startMainDeviceReauth,
+} from "./main-device-reauth";
+import { NativeMainReauthUnavailableError } from "./main-account";
+
+/**
+ * Dedicated native-main device reauth route (#3898).
+ *
+ * `/api/codex-auth/login` stays pool-only and keeps rejecting __main__;
+ * this namespace is the only device-reauth surface for the native main slot.
+ * DTOs carry flowId/status/verificationUrl/deviceCode and safe failure codes
+ * — never tokens, emails, or raw account ids. The route is registered in
+ * management-api ahead of the generic /api/codex-auth/* dispatch, so the
+ * existing management origin/auth/session controls wrap it unchanged.
+ */
+
+const ROUTE = "/api/codex-auth/main/reauth-device";
+
+function errorResponse(
+ req: Request,
+ config: OcxConfig,
+ message: string,
+ code: string,
+ status: number,
+): Response {
+ return jsonResponse({ error: message, code }, status, req, config);
+}
+
+function flowIdFromQuery(url: URL): string | null {
+ for (const key of url.searchParams.keys()) {
+ if (key !== "flowId") return null;
+ }
+ const flowId = url.searchParams.get("flowId");
+ return flowId && flowId.trim() ? flowId : null;
+}
+
+export async function handleMainDeviceReauthAPI(
+ req: Request,
+ url: URL,
+ config: OcxConfig,
+): Promise {
+ if (url.pathname !== ROUTE) return null;
+
+ if (req.method === "POST") {
+ // Strict body: no request keys exist for start; anything supplied is an error.
+ const text = await req.text();
+ if (text.trim()) {
+ return errorResponse(req, config, "The reauth-device start takes no request body", "invalid_request", 400);
+ }
+ try {
+ return jsonResponse(startMainDeviceReauth(), 200, req, config);
+ } catch (error) {
+ if (error instanceof MainDeviceReauthFlowBusyError) {
+ return errorResponse(req, config, error.message, error.code, 409);
+ }
+ if (error instanceof NativeMainReauthUnavailableError) {
+ return errorResponse(req, config, error.message, "native_main_unavailable", 503);
+ }
+ throw error;
+ }
+ }
+
+ if (req.method === "GET") {
+ const flowId = flowIdFromQuery(url);
+ if (!flowId) {
+ return errorResponse(req, config, "An exact flowId query is required", "invalid_request", 400);
+ }
+ const status = getMainDeviceReauthStatus(flowId);
+ if (!status) return errorResponse(req, config, "Unknown or expired reauth flow", "unknown_flow", 404);
+ return jsonResponse(status, 200, req, config);
+ }
+
+ if (req.method === "DELETE") {
+ const flowId = flowIdFromQuery(url);
+ if (!flowId) {
+ return errorResponse(req, config, "An exact flowId query is required", "invalid_request", 400);
+ }
+ const status = cancelMainDeviceReauth(flowId);
+ if (!status) return errorResponse(req, config, "Unknown or expired reauth flow", "unknown_flow", 404);
+ return jsonResponse(status, 200, req, config);
+ }
+
+ return errorResponse(req, config, "Method not allowed", "method_not_allowed", 405);
+}
diff --git a/src/codex/main-device-reauth.ts b/src/codex/main-device-reauth.ts
new file mode 100644
index 0000000000..1f6ede5215
--- /dev/null
+++ b/src/codex/main-device-reauth.ts
@@ -0,0 +1,217 @@
+import { randomUUID } from "node:crypto";
+import { loginChatGPTNativeDevice, type NativeDeviceLogin } from "../oauth/chatgpt-device";
+import type { OAuthController } from "../oauth/types";
+import {
+ beginNativeMainReauth,
+ MainAuthJsonChangedDuringRefreshError,
+ NativeMainReauthIdentityMismatchError,
+ NativeMainReauthUnavailableError,
+ type NativeMainReauthTokens,
+} from "./main-account";
+
+/**
+ * Process-owned native-main device reauth flow (#3898).
+ *
+ * Exactly one active flow per process, started from the management API or the
+ * CLI. The human-facing DTO carries only flowId, status, the verification
+ * URL and the device code: tokens, emails, and raw account ids never leave
+ * the device/grant layer, and the opaque device_auth_id never leaves
+ * chatgpt-device.ts at all. The grant runs on this flow's own
+ * AbortController — deliberately NOT through startLoginFlow("chatgpt"),
+ * which would overwrite the pool scratch slot and collide with pool logins.
+ */
+
+export type MainDeviceReauthStatus =
+ | { flowId: string; status: "pending"; verificationUrl: string; deviceCode: string }
+ | { flowId: string; status: "committing" }
+ | { flowId: string; status: "succeeded"; credentialUpdated: true }
+ | { flowId: string; status: "cancelled" }
+ | {
+ flowId: string;
+ status: "failed";
+ credentialUpdated?: true;
+ code:
+ | "identity_mismatch"
+ | "credential_changed"
+ | "native_main_unavailable"
+ | "device_authorization_failed"
+ | "publication_failed"
+ | "reconciliation_failed";
+ };
+
+export class MainDeviceReauthFlowBusyError extends Error {
+ readonly code = "flow_in_progress";
+ constructor() {
+ super("A native main device reauth is already in progress");
+ this.name = "MainDeviceReauthFlowBusyError";
+ }
+}
+
+interface ActiveFlow {
+ flowId: string;
+ controller: AbortController;
+ status: MainDeviceReauthStatus;
+ /** Set once auth.json has been replaced; cancellation can no longer win. */
+ published: boolean;
+ /** Snapshot-holding commit prepared at start; closure-private identity. */
+ prepared?: { commit: (tokens: NativeMainReauthTokens) => Promise<{ chatgptAccountId: string }> };
+}
+
+/** Bounded terminal retention so status/cancel stay answerable after completion. */
+const TERMINAL_RETENTION_MS = 300_000;
+
+let activeFlow: ActiveFlow | null = null;
+const terminalFlows = new Map();
+
+export interface MainDeviceReauthDeps {
+ login?: (ctrl: OAuthController) => Promise;
+ beginCommit?: () => { commit: (tokens: NativeMainReauthTokens) => Promise<{ chatgptAccountId: string }> };
+ flowId?: () => string;
+ now?: () => number;
+}
+
+function isTerminal(status: MainDeviceReauthStatus): boolean {
+ return status.status === "succeeded" || status.status === "cancelled" || status.status === "failed";
+}
+
+function sweepTerminal(now: number): void {
+ for (const [flowId, row] of terminalFlows) {
+ if (row.expiresAt <= now) terminalFlows.delete(flowId);
+ }
+}
+
+function finish(flow: ActiveFlow, status: MainDeviceReauthStatus, now: number): void {
+ // Publication beats a racing cancellation: once auth.json was replaced the
+ // honest terminal is succeeded, never cancelled (080). Every other terminal
+ // is first-write-wins so a superseded or cancelled completion cannot
+ // publish a later result.
+ if (isTerminal(flow.status)) {
+ if (!(flow.published && status.status === "succeeded")) return;
+ }
+ if (status.status === "succeeded") flow.published = true;
+ flow.status = status;
+ terminalFlows.set(flow.flowId, { status, expiresAt: now + TERMINAL_RETENTION_MS });
+}
+
+function mapFailure(flowId: string, error: unknown): MainDeviceReauthStatus {
+ if (error instanceof NativeMainReauthIdentityMismatchError) {
+ return { flowId, status: "failed", code: "identity_mismatch" };
+ }
+ if (error instanceof MainAuthJsonChangedDuringRefreshError) {
+ return { flowId, status: "failed", code: "credential_changed" };
+ }
+ if (error instanceof NativeMainReauthUnavailableError) {
+ return { flowId, status: "failed", code: "native_main_unavailable" };
+ }
+ const code = (error as { code?: unknown } | null)?.code;
+ if (code === "NATIVE_MAIN_CLAIM_UNAVAILABLE" || code === "NATIVE_MAIN_OWNER_UNAVAILABLE") {
+ return { flowId, status: "failed", code: "native_main_unavailable" };
+ }
+ const name = (error as { name?: unknown } | null)?.name;
+ if (name === "TimeoutError" || name === "AbortError") {
+ return { flowId, status: "failed", code: "device_authorization_failed" };
+ }
+ if (error instanceof Error && /device authorization/.test(error.message)) {
+ return { flowId, status: "failed", code: "device_authorization_failed" };
+ }
+ return { flowId, status: "failed", code: "publication_failed" };
+}
+
+/**
+ * Start the one active flow. Returns the pending status; the URL/code arrive
+ * with the usercode response and are visible through the status endpoint.
+ */
+export function startMainDeviceReauth(deps: MainDeviceReauthDeps = {}): MainDeviceReauthStatus {
+ const now = (deps.now ?? Date.now)();
+ sweepTerminal(now);
+ if (activeFlow && !isTerminal(activeFlow.status)) throw new MainDeviceReauthFlowBusyError();
+ const flowId = (deps.flowId ?? randomUUID)();
+ const flow: ActiveFlow = {
+ flowId,
+ controller: new AbortController(),
+ status: { flowId, status: "pending", verificationUrl: "", deviceCode: "" },
+ published: false,
+ };
+ // Claim the singleflight slot BEFORE preparing: two overlapping starts must
+ // not both snapshot and then have one throw flow_in_progress after the
+ // other already began polling.
+ activeFlow = flow;
+ try {
+ // Prepare NOW: the existing credential snapshot is captured at start (080),
+ // so a hub with no reauthenticatable main credential fails fast with
+ // native_main_unavailable instead of after the human completes the page.
+ flow.prepared = (deps.beginCommit ?? beginNativeMainReauth)();
+ } catch (error) {
+ activeFlow = null;
+ throw error;
+ }
+ const login = deps.login ?? loginChatGPTNativeDevice;
+ const clock = deps.now ?? Date.now;
+ void (async () => {
+ try {
+ const grant = await login({
+ signal: flow.controller.signal,
+ onAuth: info => {
+ // A superseded or cancelled flow may not publish its URL/code.
+ if (activeFlow !== flow || isTerminal(flow.status)) return;
+ flow.status = {
+ flowId,
+ status: "pending",
+ verificationUrl: info.url,
+ deviceCode: info.deviceCode ?? "",
+ };
+ },
+ });
+ if (flow.controller.signal.aborted) return;
+ if (!isTerminal(flow.status)) flow.status = { flowId, status: "committing" };
+ // Recheck immediately before the write: a cancel that landed while the
+ // grant was resolving must not reach auth.json. Once the commit DOES
+ // publish, the terminal is succeeded even if a cancel raced it (080).
+ if (activeFlow !== flow || flow.controller.signal.aborted || isTerminal(flow.status)) return;
+ await flow.prepared!.commit({
+ accessToken: grant.credential.access,
+ refreshToken: grant.credential.refresh,
+ idToken: grant.idToken,
+ chatgptAccountId: grant.credential.accountId!,
+ });
+ flow.published = true;
+ finish(flow, { flowId, status: "succeeded", credentialUpdated: true }, clock());
+ } catch (error) {
+ if (flow.controller.signal.aborted && !flow.published) return;
+ finish(flow, mapFailure(flowId, error), clock());
+ }
+ })();
+ return flow.status;
+}
+
+export function getMainDeviceReauthStatus(flowId: string, deps: MainDeviceReauthDeps = {}): MainDeviceReauthStatus | null {
+ const now = (deps.now ?? Date.now)();
+ sweepTerminal(now);
+ if (activeFlow?.flowId === flowId) return activeFlow.status;
+ return terminalFlows.get(flowId)?.status ?? null;
+}
+
+/**
+ * Cancel the flow. Cancellation after publication returns the published
+ * terminal (succeeded), never cancelled; a pending/committing flow aborts its
+ * grant and settles cancelled.
+ */
+export function cancelMainDeviceReauth(flowId: string, deps: MainDeviceReauthDeps = {}): MainDeviceReauthStatus | null {
+ const now = (deps.now ?? Date.now)();
+ sweepTerminal(now);
+ if (activeFlow?.flowId === flowId && !isTerminal(activeFlow.status)) {
+ activeFlow.controller.abort();
+ finish(activeFlow, { flowId, status: "cancelled" }, now);
+ return activeFlow.status;
+ }
+ return terminalFlows.get(flowId)?.status ?? null;
+}
+
+/** Test hook: drop all in-memory flow state. Production never calls this. */
+export function resetMainDeviceReauthForTests(): void {
+ // Abort first: a reset that only clears the maps leaves a pending grant
+ // polling against real timers for up to the 15-minute device TTL.
+ activeFlow?.controller.abort();
+ activeFlow = null;
+ terminalFlows.clear();
+}
diff --git a/src/codex/quota-auto-refresh.ts b/src/codex/quota-auto-refresh.ts
index ce11de97e4..cf21a46e16 100644
--- a/src/codex/quota-auto-refresh.ts
+++ b/src/codex/quota-auto-refresh.ts
@@ -8,7 +8,7 @@ import { isSelectableCodexPoolAccount } from "./account-id";
import { reconcileMainCodexAccountRuntimeState } from "./account-lifecycle";
import { isCodexAccountPaused } from "./account-pause";
import { isAccountNeedsReauth, markAccountNeedsReauth } from "./account-runtime-state";
-import { getValidCodexToken, isCodexAccountGenerationLive, readCodexAccountRecord } from "./account-store";
+import { capturePoolQuotaWriter, getValidCodexToken, isCodexAccountGenerationLive, readCodexAccountRecord } from "./account-store";
import { codexAccountLogLabel } from "./account-label";
import { getMainAccountToken, getValidMainAccountToken, MAIN_CODEX_ACCOUNT_ID } from "./main-account";
import { isMainAccountHardLocked } from "./main-account-hard-lock";
@@ -170,10 +170,11 @@ async function warmAccount(config: OcxConfig, accountId: string): Promise {
if (isCodexAccountGenerationLive(accountId, token.generation)) {
- applyAccountQuotaFromUpstreamHeaders(accountId, headers, writerGeneration);
+ applyAccountQuotaFromUpstreamHeaders(accountId, headers, writerGeneration, undefined, { poolWriter });
}
} });
} catch (error) {
diff --git a/src/codex/quota-capacity.ts b/src/codex/quota-capacity.ts
new file mode 100644
index 0000000000..d22d53b511
--- /dev/null
+++ b/src/codex/quota-capacity.ts
@@ -0,0 +1,98 @@
+import type { QuotaHistorySample, QuotaHistoryWindow } from "./quota-history";
+import type { PersistedUsageAttempt, PersistedUsageEntry } from "../usage/log";
+
+export const CAPACITY_ASSUMPTIONS = [
+ "Quota percentages can be rounded or delayed.",
+ "Only retained valid proxy usage rows are observed; external usage is unknown.",
+ "Account log labels are assumed stable within each observation interval.",
+ "This low-confidence effective-token estimate is not a provider token limit or lower bound.",
+] as const;
+export const CAPACITY_REASONS = ["insufficient_intervals", "ledger_unavailable", "ledger_truncated", "identity_unavailable", "identity_changed", "ambiguous_usage"] as const;
+export type CapacityReason = typeof CAPACITY_REASONS[number];
+export function parseCapacityReason(value: unknown): CapacityReason | undefined {
+ return CAPACITY_REASONS.find(reason => reason === value);
+}
+export interface CodexCapacityResult {
+ status: "estimated" | "insufficient-evidence";
+ estimates: Array<{ window: QuotaHistoryWindow["window"]; estimatedTokens: number; sampleCount: number; confidence: "low" }>;
+ reason?: CapacityReason;
+ assumptions: readonly string[];
+}
+export function insufficientCodexCapacity(reason: CapacityReason): CodexCapacityResult {
+ return { status: "insufficient-evidence", estimates: [], reason, assumptions: [...CAPACITY_ASSUMPTIONS] };
+}
+const nonnegative = (value: unknown): value is number => typeof value === "number" && Number.isFinite(value) && value >= 0;
+
+function reportedTokens(attempt: PersistedUsageAttempt): number | undefined {
+ if (attempt.sendCount !== 1 || attempt.usageStatus !== "reported" || attempt.locallyAnswered === true
+ || !attempt.usage || attempt.usage.estimated === true
+ || !nonnegative(attempt.usage.inputTokens) || !nonnegative(attempt.usage.outputTokens)) return undefined;
+ const total = attempt.usage.totalTokens ?? attempt.usage.inputTokens + attempt.usage.outputTokens;
+ return nonnegative(total) ? total : undefined;
+}
+
+/** Informational inference over raw same-window observations, never an account-selection input. */
+export function estimateCodexQuotaCapacity(
+ observations: ReadonlyArray>,
+ entries: readonly PersistedUsageEntry[],
+ label: string,
+ sharedQuotaModel: (model: string) => boolean,
+): CodexCapacityResult {
+ if (entries.length > 10_000) return insufficientCodexCapacity("ledger_truncated");
+ const requests = new Map();
+ for (const entry of entries) {
+ const previous = requests.get(entry.requestId);
+ if (previous && JSON.stringify(previous) !== JSON.stringify(entry)) return insufficientCodexCapacity("ambiguous_usage");
+ requests.set(entry.requestId, entry);
+ }
+ const sorted = [...observations].sort((a, b) => a.observedAt - b.observedAt);
+ const estimates: CodexCapacityResult["estimates"] = [];
+ for (const windowName of ["short", "weekly", "monthly"] as const) {
+ const points = sorted.flatMap(row => {
+ const window = row.windows.find(candidate => candidate.family === "account" && candidate.window === windowName);
+ return window ? [{ ...window, at: row.observedAt, source: row.source }] : [];
+ });
+ const samples: number[] = [];
+ for (let index = 1; index < points.length; index++) {
+ const left = points[index - 1], right = points[index];
+ const delta = right.usedPercent - left.usedPercent;
+ if (left.source !== right.source || !nonnegative(left.resetAtMs) || left.resetAtMs !== right.resetAtMs
+ || left.resetAtMs <= right.at || left.windowSeconds !== right.windowSeconds
+ || left.monthlyIsPrimaryWindow !== right.monthlyIsPrimaryWindow
+ || right.at <= left.at || delta < 1 || delta > 100 || !Number.isFinite(delta)) continue;
+ let tokens = 0;
+ let valid = true;
+ for (const entry of requests.values()) {
+ if (!nonnegative(entry.timestamp) || !nonnegative(entry.durationMs)) continue;
+ const end = entry.timestamp + entry.durationMs;
+ if (!Number.isFinite(end) || entry.timestamp <= left.at || end > right.at) continue;
+ // Untimed or absent physical-attempt evidence cannot be reconstructed from parent totals.
+ if (!entry.attempts?.length) continue;
+ const attempts = new Map();
+ for (const attempt of entry.attempts) {
+ const prior = attempts.get(attempt.ordinal);
+ if (prior && JSON.stringify(prior) !== JSON.stringify(attempt)) { valid = false; break; }
+ attempts.set(attempt.ordinal, attempt);
+ }
+ if (!valid) break;
+ for (const attempt of attempts.values()) {
+ if (attempt.accountLogLabel !== label || attempt.adapter !== "openai-responses" || !sharedQuotaModel(attempt.model)) continue;
+ const reported = reportedTokens(attempt);
+ if (reported === undefined) continue;
+ tokens += reported;
+ }
+ }
+ const inferred = tokens * 100 / delta;
+ if (valid && tokens > 0 && Number.isFinite(inferred) && inferred > 0) samples.push(inferred);
+ }
+ if (samples.length) {
+ samples.sort((a, b) => a - b);
+ const middle = Math.floor(samples.length / 2);
+ const median = samples.length % 2 ? samples[middle] : samples[middle - 1] / 2 + samples[middle] / 2;
+ const estimatedTokens = Math.round(median);
+ if (estimatedTokens > 0) estimates.push({ window: windowName, estimatedTokens, sampleCount: samples.length, confidence: "low" });
+ }
+ }
+ return estimates.length ? { status: "estimated", estimates, assumptions: [...CAPACITY_ASSUMPTIONS] }
+ : insufficientCodexCapacity("insufficient_intervals");
+}
diff --git a/src/codex/quota-history.ts b/src/codex/quota-history.ts
new file mode 100644
index 0000000000..a792f89597
--- /dev/null
+++ b/src/codex/quota-history.ts
@@ -0,0 +1,160 @@
+import { isValidCodexAccountId } from "./account-id";
+import type { PoolQuotaWriter } from "./quota-types";
+
+export const QUOTA_HISTORY_LIMITS = { perAccount: 200, accounts: 64, samples: 4096, bytes: 2 * 1024 * 1024, ageMs: 30 * 86400_000 } as const;
+export interface QuotaHistoryWindow {
+ family: "account" | "spark";
+ window: "short" | "weekly" | "monthly";
+ usedPercent: number;
+ resetAtMs?: number;
+ windowSeconds?: number;
+ monthlyIsPrimaryWindow?: boolean;
+}
+export interface QuotaHistorySample {
+ observedAt: number;
+ source: "wham" | "response-header";
+ credentialGeneration: number;
+ windows: QuotaHistoryWindow[];
+}
+type Envelope = { identity: string; samples: QuotaHistorySample[] };
+type Bucket = Envelope & { costs: number[]; overhead: number };
+const encoder = new TextEncoder();
+const byteSize = (value: unknown) => encoder.encode(JSON.stringify(value)).byteLength;
+const identityPattern = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/;
+const record = (value: unknown): value is Record => !!value && typeof value === "object" && !Array.isArray(value);
+const finite = (value: unknown): value is number => typeof value === "number" && Number.isFinite(value) && value >= 0;
+
+/** Reconstruct allowlisted data at the disk boundary; malformed windows cannot be partial evidence. */
+function parseSample(value: unknown, now: number): QuotaHistorySample | undefined {
+ if (!record(value) || !finite(value.observedAt) || value.observedAt > now
+ || value.observedAt < now - QUOTA_HISTORY_LIMITS.ageMs
+ || !Number.isSafeInteger(value.credentialGeneration) || (value.credentialGeneration as number) < 0
+ || (value.source !== "wham" && value.source !== "response-header")
+ || !Array.isArray(value.windows) || !value.windows.length || value.windows.length > 5) return undefined;
+ const windows: QuotaHistoryWindow[] = [];
+ const seen = new Set();
+ for (const item of value.windows) {
+ if (!record(item) || (item.family !== "account" && item.family !== "spark")
+ || (item.window !== "short" && item.window !== "weekly" && item.window !== "monthly")
+ || (item.family === "spark" && item.window === "monthly")
+ || !finite(item.usedPercent) || item.usedPercent > 100) return undefined;
+ const key = `${item.family}:${item.window}`;
+ if (seen.has(key)) return undefined;
+ seen.add(key);
+ if ((item.resetAtMs !== undefined && !finite(item.resetAtMs))
+ || (item.windowSeconds !== undefined && (!finite(item.windowSeconds) || item.windowSeconds === 0))
+ || (item.monthlyIsPrimaryWindow !== undefined && typeof item.monthlyIsPrimaryWindow !== "boolean")) return undefined;
+ windows.push({ family: item.family, window: item.window, usedPercent: item.usedPercent,
+ ...(item.resetAtMs !== undefined ? { resetAtMs: item.resetAtMs as number } : {}),
+ ...(item.windowSeconds !== undefined ? { windowSeconds: item.windowSeconds as number } : {}),
+ ...(item.monthlyIsPrimaryWindow === true && item.family === "account" && item.window === "monthly" ? { monthlyIsPrimaryWindow: true } : {}),
+ });
+ }
+ return { observedAt: value.observedAt, source: value.source, credentialGeneration: value.credentialGeneration as number, windows };
+}
+
+/** Bounded in-process observations. The quota cache owns persistence and credential admission. */
+export class CodexQuotaHistory {
+ private accounts = new Map();
+ private bytes = 32;
+ private count = 0;
+
+ append(writer: PoolQuotaWriter, sample: QuotaHistorySample, now = Date.now()): void {
+ if (!isValidCodexAccountId(writer.accountId) || !identityPattern.test(writer.historyIdentity)
+ || sample.credentialGeneration !== writer.credentialGeneration) return;
+ const parsed = parseSample(sample, now);
+ if (!parsed) return;
+ let bucket = this.accounts.get(writer.accountId);
+ if (bucket && bucket.identity !== writer.historyIdentity) { this.clear(writer.accountId); bucket = undefined; }
+ if (!bucket) {
+ const overhead = byteSize(writer.accountId) + byteSize({ identity: writer.historyIdentity, samples: [] }) + 8;
+ bucket = { identity: writer.historyIdentity, samples: [], costs: [], overhead };
+ this.accounts.set(writer.accountId, bucket);
+ this.bytes += overhead;
+ }
+ const index = bucket.samples.findIndex(row => row.observedAt > parsed.observedAt);
+ const position = index < 0 ? bucket.samples.length : index;
+ const cost = byteSize(parsed) + 1;
+ bucket.samples.splice(position, 0, parsed);
+ bucket.costs.splice(position, 0, cost);
+ this.bytes += cost;
+ this.count++;
+ while (bucket.samples.length > QUOTA_HISTORY_LIMITS.perAccount) this.dropFirst(writer.accountId);
+ this.prune(now);
+ }
+
+ read(accountId: string, identity: string | undefined, now = Date.now(), limit: number = QUOTA_HISTORY_LIMITS.perAccount): { samples: QuotaHistorySample[]; truncated: boolean } {
+ this.prune(now);
+ const bucket = this.accounts.get(accountId);
+ if (!identity || !bucket) return { samples: [], truncated: false };
+ if (bucket.identity !== identity) { this.clear(accountId); return { samples: [], truncated: false }; }
+ const capped = Math.max(1, Math.min(QUOTA_HISTORY_LIMITS.perAccount, Math.trunc(limit)));
+ return { samples: structuredClone(bucket.samples.slice(-capped)), truncated: bucket.samples.length > capped };
+ }
+
+ clear(accountId?: string): number {
+ if (accountId === undefined) {
+ const count = this.accounts.size;
+ this.accounts.clear(); this.count = 0; this.bytes = 32;
+ return count;
+ }
+ const bucket = this.accounts.get(accountId);
+ if (!bucket) return 0;
+ this.bytes -= bucket.overhead + bucket.costs.reduce((a, b) => a + b, 0);
+ this.count -= bucket.samples.length;
+ this.accounts.delete(accountId);
+ return 1;
+ }
+
+ reconcile(ids: ReadonlySet): number {
+ let removed = 0;
+ for (const id of this.accounts.keys()) if (!ids.has(id)) removed += this.clear(id);
+ return removed;
+ }
+
+ serialize(now = Date.now()): { version: 1; accounts: Record } {
+ this.prune(now);
+ return { version: 1, accounts: Object.fromEntries([...this.accounts].map(([id, bucket]) => [id,
+ { identity: bucket.identity, samples: structuredClone(bucket.samples) }])) };
+ }
+
+ hydrate(value: unknown, now = Date.now()): void {
+ this.clear();
+ if (!record(value) || value.version !== 1 || !record(value.accounts) || byteSize(value) > QUOTA_HISTORY_LIMITS.bytes) return;
+ const entries = Object.entries(value.accounts);
+ if (entries.length > QUOTA_HISTORY_LIMITS.accounts) return;
+ let count = 0;
+ for (const [id, envelope] of entries) {
+ if (!isValidCodexAccountId(id) || !record(envelope) || typeof envelope.identity !== "string"
+ || !identityPattern.test(envelope.identity) || !Array.isArray(envelope.samples)
+ || envelope.samples.length > QUOTA_HISTORY_LIMITS.perAccount) return;
+ count += envelope.samples.length;
+ if (count > QUOTA_HISTORY_LIMITS.samples) return;
+ }
+ for (const [accountId, raw] of entries) {
+ const envelope = raw as Envelope;
+ for (const sample of envelope.samples) {
+ const parsed = parseSample(sample, now);
+ if (parsed) this.append({ accountId, historyIdentity: envelope.identity, credentialGeneration: parsed.credentialGeneration }, parsed, now);
+ }
+ }
+ }
+
+ private dropFirst(id: string): void {
+ const bucket = this.accounts.get(id)!;
+ this.bytes -= bucket.costs.shift()!;
+ bucket.samples.shift(); this.count--;
+ if (!bucket.samples.length) { this.bytes -= bucket.overhead; this.accounts.delete(id); }
+ }
+
+ private prune(now: number): void {
+ for (const [id, bucket] of this.accounts) {
+ while (bucket.samples.length && bucket.samples[0].observedAt < now - QUOTA_HISTORY_LIMITS.ageMs) this.dropFirst(id);
+ }
+ while (this.accounts.size > QUOTA_HISTORY_LIMITS.accounts || this.count > QUOTA_HISTORY_LIMITS.samples || this.bytes > QUOTA_HISTORY_LIMITS.bytes) {
+ const first = [...this.accounts].sort(([a, x], [b, y]) => x.samples[0].observedAt - y.samples[0].observedAt || a.localeCompare(b))[0];
+ if (!first) break;
+ this.dropFirst(first[0]);
+ }
+ }
+}
diff --git a/src/codex/quota-types.ts b/src/codex/quota-types.ts
index 6c06de6ae9..cc73a7d5e3 100644
--- a/src/codex/quota-types.ts
+++ b/src/codex/quota-types.ts
@@ -49,3 +49,11 @@ export type WhamUsageResponse = {
rate_limit_reset_credits?: { available_count: number } | null;
additional_rate_limits?: WhamAdditionalRateLimit[] | null;
};
+
+
+/** Captured from the exact dispatched pool credential; never a management API field. */
+export interface PoolQuotaWriter {
+ accountId: string;
+ credentialGeneration: number;
+ historyIdentity: string;
+}
diff --git a/src/codex/quota.ts b/src/codex/quota.ts
index 1a6260636c..f7bff7dc0e 100644
--- a/src/codex/quota.ts
+++ b/src/codex/quota.ts
@@ -1,4 +1,4 @@
-import { existsSync, readFileSync, unlinkSync } from "node:fs";
+import { closeSync, constants as fsConstants, existsSync, fstatSync, openSync, readSync, unlinkSync } from "node:fs";
import { join } from "node:path";
import { atomicWriteFile, getConfigDir } from "../config";
import { captureConfigGeneration, type GenerationContext } from "../lib/state-store-sweeper";
@@ -6,10 +6,12 @@ import { isThirtyDayOnlyCodexPlan } from "./plan";
import { MAIN_CODEX_ACCOUNT_ID } from "./account-id";
import { getObservedMainQuotaIdentityKey, isMainQuotaWriterLive, type MainQuotaWriter } from "./main-account-cache";
-import type { StoredAccountQuota, WhamUsageResponse, WhamUsageWindow } from "./quota-types";
+import { CodexQuotaHistory, QUOTA_HISTORY_LIMITS, type QuotaHistoryWindow } from "./quota-history";
+import { isPoolQuotaWriterLive, poolQuotaHistoryIdentity } from "./account-store";
+import type { PoolQuotaWriter, StoredAccountQuota, WhamUsageResponse, WhamUsageWindow } from "./quota-types";
export type { StoredAccountQuota, WhamUsageResponse } from "./quota-types";
-/** Disk snapshot under OPENCODEX_HOME — quota and policy identity only, never credential tags. */
+/** Disk snapshot: quota, private non-secret publication UUIDs and policy identity; never token-derived fingerprints. */
const QUOTA_CACHE_FILENAME = "codex-quota-cache.json";
/** Keep last-known bars across restarts; WHAM still refreshes on TTL in live/prime paths. */
const QUOTA_DISK_MAX_AGE_MS = 6 * 60 * 60_000;
@@ -19,6 +21,7 @@ type QuotaDiskFile = {
version: 1;
quotas: Record;
mainPolicyQuota?: MainPolicyQuota;
+ history?: ReturnType;
};
type MainPolicyQuota = { identityKey: string; quota: StoredAccountQuota };
@@ -61,6 +64,7 @@ export function resetAtToMs(resetAt: number): number {
}
const accountQuota = new Map();
+const quotaHistory = new CodexQuotaHistory();
let lastReconciledGeneration = 0;
let liveAccountIds = new Set();
@@ -268,6 +272,7 @@ export function setAccountQuotaFromParsed(
writerGeneration = captureConfigGeneration(),
mainWriter?: MainQuotaWriter,
policyQuota: Omit | null = quota,
+ historyEvidence?: QuotaObservationEvidence,
): void {
if (!quota) return;
if (!mayCommitAccountQuota(accountId, writerGeneration)) return;
@@ -276,6 +281,11 @@ export function setAccountQuotaFromParsed(
hydrateAccountQuotasFromDisk();
const legacyExisting = accountQuota.get(accountId);
const updatedAt = Date.now();
+ if (historyEvidence && historyEvidence.writer.accountId === accountId && isPoolQuotaWriterLive(historyEvidence.writer)) {
+ quotaHistory.append(historyEvidence.writer, { observedAt: historyEvidence.observedAt, source: historyEvidence.source,
+ credentialGeneration: historyEvidence.writer.credentialGeneration, windows: historyWindows(historyEvidence.raw),
+ }, updatedAt);
+ }
// Legacy rotation keeps its existing carry behavior, but never inherits policy-only
// evidence that outlived its disk TTL. Policy has a separate, identity-checked base.
const next = mergeAccountQuota(quota, legacyExisting, updatedAt);
@@ -446,6 +456,7 @@ const SPARK_MODEL_MARKER = "codex-spark";
* must write the SAME label so a header refresh replaces the WHAM reading instead of doubling it.
*/
const SPARK_SHORT_WINDOW_LABEL = "GPT-5.3-Codex-Spark 5h";
+const SPARK_WEEKLY_WINDOW_LABEL = "GPT-5.3-Codex-Spark Weekly";
/** True when the routed model belongs to the Spark family, which carries its own rate limit. */
function isCodexSparkModel(modelId: string | undefined): boolean {
@@ -540,7 +551,7 @@ export function applyAccountQuotaFromUpstreamHeaders(
headers: Headers,
writerGeneration = captureConfigGeneration(),
mainWriter?: MainQuotaWriter,
- options?: { modelId?: string },
+ options?: { modelId?: string; poolWriter?: PoolQuotaWriter },
): void {
const quota = parseUpstreamQuotaHeaders(headers, options);
if (!quota) return;
@@ -564,7 +575,10 @@ export function applyAccountQuotaFromUpstreamHeaders(
legacyQuota = { ...quota, customWindows: merged };
}
}
- setAccountQuotaFromParsed(accountId, legacyQuota, writerGeneration, mainWriter, policyQuota);
+ const validHistory = !["x-codex-primary-used-percent", "x-codex-secondary-used-percent", "x-codex-tertiary-used-percent"]
+ .some(name => isInvalidPolicyUsagePercent(headers.get(name)));
+ setAccountQuotaFromParsed(accountId, legacyQuota, writerGeneration, mainWriter, policyQuota,
+ options?.poolWriter && validHistory ? { writer: options.poolWriter, observedAt: Date.now(), source: "response-header", raw: quota } : undefined);
}
export function updateAccountQuota(
@@ -658,9 +672,10 @@ function hydrateAccountQuotasFromDisk(): void {
try {
const path = join(getConfigDir(), QUOTA_CACHE_FILENAME);
if (!existsSync(path)) return;
- const raw = readFileSync(path, "utf8");
+ const raw = readQuotaCacheBounded(path);
const parsed = JSON.parse(raw) as QuotaDiskFile;
if (!parsed || parsed.version !== 1 || !parsed.quotas || typeof parsed.quotas !== "object") return;
+ quotaHistory.hydrate(parsed.history);
// Policy evidence deliberately outlives the legacy six-hour rotation-cache TTL.
mainPolicyQuota = readMainPolicyQuota(parsed.mainPolicyQuota);
const now = Date.now();
@@ -687,6 +702,7 @@ function schedulePersistAccountQuotas(): void {
version: 1,
quotas,
...(mainPolicyQuota ? { mainPolicyQuota } : {}),
+ history: quotaHistory.serialize(),
};
atomicWriteFile(join(getConfigDir(), QUOTA_CACHE_FILENAME), `${JSON.stringify(body)}\n`);
} catch {
@@ -735,6 +751,8 @@ function forgetCodexQuotaBaseline(accountId?: string): void {
}
export function clearAccountQuota(accountId?: string): void {
+ if (accountId) hydrateAccountQuotasFromDisk();
+ quotaHistory.clear(accountId);
if (accountId) {
hydrateAccountQuotasFromDisk();
accountQuota.delete(accountId);
@@ -762,7 +780,7 @@ export function clearAccountQuota(accountId?: string): void {
export function reconcileCodexQuotaAccounts(context: GenerationContext): number {
if (context.generation <= lastReconciledGeneration) return 0;
hydrateAccountQuotasFromDisk();
- let removed = 0;
+ let removed = quotaHistory.reconcile(context.codexAccountIds);
for (const accountId of accountQuota.keys()) {
if (context.codexAccountIds.has(accountId)) continue;
accountQuota.delete(accountId);
@@ -888,7 +906,7 @@ export function parseUsageQuota(data: WhamUsageResponse): Omit = [];
for (const [label, window] of [
[SPARK_SHORT_WINDOW_LABEL, sparkShort],
- ["GPT-5.3-Codex-Spark Weekly", sparkWeekly],
+ [SPARK_WEEKLY_WINDOW_LABEL, sparkWeekly],
] as const) {
const percent = normalizeUsagePercent(window?.used_percent);
if (percent === undefined) continue;
@@ -902,3 +920,70 @@ export function parseUsageQuota(data: WhamUsageResponse): Omit;
+}
+
+/** Reject raw invalid readings before the compatibility parser clamps them into valid-looking bars. */
+export function isValidWhamHistoryObservation(data: WhamUsageResponse): boolean {
+ const windows = [data.rate_limit?.primary_window, data.rate_limit?.secondary_window, data.rate_limit?.tertiary_window];
+ for (const limit of Array.isArray(data.additional_rate_limits) ? data.additional_rate_limits : []) {
+ if (limit && typeof limit === "object") windows.push(limit.rate_limit?.primary_window, limit.rate_limit?.secondary_window);
+ }
+ return !windows.some(window => isInvalidPolicyUsagePercent(window?.used_percent));
+}
+
+function historyWindows(quota: Omit): QuotaHistoryWindow[] {
+ const windows: QuotaHistoryWindow[] = [];
+ for (const window of ["short", "weekly", "monthly"] as const) {
+ const percent = quota[`${window}Percent`];
+ const reset = quota[`${window}ResetAt`];
+ if (typeof percent !== "number" || !Number.isFinite(percent) || percent < 0 || percent > 100) continue;
+ windows.push({ family: "account", window, usedPercent: percent,
+ ...(typeof reset === "number" && Number.isFinite(reset) && reset >= 0 ? { resetAtMs: resetAtToMs(reset) } : {}),
+ ...(window === "short" && quota.shortWindowSeconds ? { windowSeconds: quota.shortWindowSeconds } : {}),
+ ...(window === "monthly" && quota.monthlyIsPrimaryWindow ? { monthlyIsPrimaryWindow: true } : {}),
+ });
+ }
+ for (const [label, window] of [[SPARK_SHORT_WINDOW_LABEL, "short"], [SPARK_WEEKLY_WINDOW_LABEL, "weekly"]] as const) {
+ const raw = quota.customWindows?.find(row => row.label === label);
+ if (!raw || !Number.isFinite(raw.percent) || raw.percent < 0 || raw.percent > 100) continue;
+ windows.push({ family: "spark", window, usedPercent: raw.percent,
+ ...(typeof raw.resetAt === "number" && Number.isFinite(raw.resetAt) && raw.resetAt >= 0 ? { resetAtMs: resetAtToMs(raw.resetAt) } : {}),
+ });
+ }
+ return windows;
+}
+
+/** A cache read is bounded even if a file grows between stat and read. */
+function readQuotaCacheBounded(path: string): string {
+ const limit = 4 * 1024 * 1024;
+ const flags = fsConstants.O_RDONLY | (process.platform === "win32" ? 0 : fsConstants.O_NONBLOCK | fsConstants.O_NOFOLLOW);
+ const fd = openSync(path, flags);
+ try {
+ const stat = fstatSync(fd);
+ if (!stat.isFile() || stat.size > limit) throw new Error("quota cache exceeds bounds");
+ const chunks: Buffer[] = [];
+ let total = 0;
+ while (total <= limit) {
+ const chunk = Buffer.allocUnsafe(Math.min(64 * 1024, limit + 1 - total));
+ const size = readSync(fd, chunk, 0, chunk.length, null);
+ if (!size) return Buffer.concat(chunks, total).toString("utf8");
+ chunks.push(chunk.subarray(0, size)); total += size;
+ }
+ throw new Error("quota cache exceeds bounds");
+ } finally { closeSync(fd); }
+}
+
+/** Cached pool observations only. An unavailable identity never authorizes publication or deletion. */
+export function getAccountQuotaHistory(accountId: string, limit: number = QUOTA_HISTORY_LIMITS.perAccount) {
+ hydrateAccountQuotasFromDisk();
+ const result = quotaHistory.read(accountId, poolQuotaHistoryIdentity(accountId), Date.now(), limit);
+ return { observations: result.samples.map(({ credentialGeneration: _generation, ...sample }) => sample),
+ truncated: result.truncated, retention: { maxObservations: QUOTA_HISTORY_LIMITS.perAccount, maxAgeDays: 30 } };
+}
diff --git a/src/codex/routing.ts b/src/codex/routing.ts
index 12c3e5e951..0ec207f8a6 100644
--- a/src/codex/routing.ts
+++ b/src/codex/routing.ts
@@ -10,7 +10,7 @@ import { clearAccountNeedsReauth, isAccountNeedsReauth, markAccountNeedsReauth }
import {
POOL_KEY_CODEX,
normalizeAccountPoolStickyLimit,
- normalizeAccountPoolStrategy,
+ normalizeCodexAccountPoolStrategy,
notePoolRotationFailure,
notePoolRotationSuccess,
peekRoundRobinAccount,
@@ -1169,7 +1169,7 @@ function excludedCodexPoolPlanKeys(config: OcxConfig): ReadonlySet | und
* selection-only drain so routing never reads the fenced native credential for it, so a rule that
* covered main would disagree with itself between drain and ordinary routing.
*/
-function isCodexAccountPlanExcluded(
+export function isCodexAccountPlanExcluded(
config: OcxConfig,
accountId: string,
precomputed?: ReadonlySet,
@@ -1474,6 +1474,12 @@ function listEligibleCodexAccountIds(
return getEligiblePoolAccounts(config, undefined, now, quotaScope, selectionOptions);
}
+/** Shared reset timestamps are not evidence for independent model-quota groups. */
+function accountPoolStrategyForScope(config: OcxConfig, quotaScope?: CodexQuotaScope) {
+ const strategy = normalizeCodexAccountPoolStrategy(config.accountPoolStrategy);
+ return strategy === "reset-first" && isIndependentCodexQuotaScope(quotaScope) ? "quota" : strategy;
+}
+
function stickyLimitForConfig(config: OcxConfig): number {
return normalizeAccountPoolStickyLimit(config.accountPoolStickyLimit);
}
@@ -1505,6 +1511,32 @@ function hasCodexQuotaHeadroom(
return usage < threshold;
}
+/** Earliest future shared short/weekly reset; missing evidence and ties use usage order. */
+function pickResetFirstCodexAccount(
+ config: OcxConfig,
+ ids: readonly string[],
+ now: number,
+ selectionOptions?: CodexAccountUsabilityOptions,
+): string | null {
+ const available = ids.filter(id => hasCodexQuotaHeadroom(config, id, selectionOptions, now));
+ if (available.length === 0) return pickLowestUsageAmong(config, ids, selectionOptions, now);
+ let earliest = Number.POSITIVE_INFINITY;
+ let candidates: string[] = [];
+ for (const id of available) {
+ const quota = getAccountQuota(id);
+ const resets = [quota?.shortResetAt, quota?.weeklyResetAt]
+ .filter((reset): reset is number => typeof reset === "number" && Number.isFinite(reset))
+ .map(resetAtToMs)
+ .filter(reset => reset > now);
+ const next = Math.min(...resets);
+ if (next < earliest) {
+ earliest = next;
+ candidates = [id];
+ } else if (next === earliest) candidates.push(id);
+ }
+ return pickLowestUsageAmong(config, candidates, selectionOptions, now);
+}
+
/**
* Fill-first: keep selectable active under threshold; otherwise advance to the next
* eligible id in stable sorted order after the current active (wrapping).
@@ -1595,7 +1627,7 @@ function pickUnboundStrategyAccount(
commitSharedActive = commit,
commitAffinity = commit,
): string | null {
- const strategy = normalizeAccountPoolStrategy(config.accountPoolStrategy);
+ const strategy = accountPoolStrategyForScope(config, quotaScope);
if (strategy === "quota") return null;
const poolKey = codexPoolKeyForScope(quotaScope);
@@ -1619,8 +1651,10 @@ function pickUnboundStrategyAccount(
return picked;
}
- if (strategy === "fill-first") {
- picked = pickFillFirstCodexAccount(config, now, quotaScope, selectionOptions);
+ if (strategy === "fill-first" || strategy === "reset-first") {
+ picked = strategy === "reset-first"
+ ? pickResetFirstCodexAccount(config, listEligibleCodexAccountIds(config, now, quotaScope, selectionOptions), now, selectionOptions)
+ : pickFillFirstCodexAccount(config, now, quotaScope, selectionOptions);
if (!picked) return null;
if (commitSharedActive) {
if (!isIndependentCodexQuotaScope(quotaScope)
@@ -1753,7 +1787,7 @@ export function pickAlternateCodexAccount(
quotaScope?: CodexQuotaScope,
selectionOptions?: CodexAccountUsabilityOptions,
): string | null {
- const strategy = normalizeAccountPoolStrategy(config.accountPoolStrategy);
+ const strategy = accountPoolStrategyForScope(config, quotaScope);
// The exclusion is passed into eligibility rather than post-filtered off its
// result: when the excluded account is the only healthy member of the top
// tier, the tier walk must be free to descend instead of selecting that tier
@@ -1766,6 +1800,9 @@ export function pickAlternateCodexAccount(
const eligible = getEligiblePoolAccounts(config, excludeId, now, quotaScope, selectionOptions);
return pickNextFillFirstCodexAccount(config, excludeId, eligible, now, selectionOptions);
}
+ if (strategy === "reset-first") {
+ return pickResetFirstCodexAccount(config, getEligiblePoolAccounts(config, excludeId, now, quotaScope, selectionOptions), now, selectionOptions);
+ }
return pickLowestUsageCodexAccount(config, excludeId, now, quotaScope, selectionOptions);
}
@@ -1862,7 +1899,7 @@ function setActiveCodexAccount(config: OcxConfig, accountId: string): void {
/** Quota strategy persists; RR/fill-first keep a process-local cursor only. */
function promoteActiveCodexAccount(config: OcxConfig, accountId: string): void {
- if (normalizeAccountPoolStrategy(config.accountPoolStrategy) === "quota") {
+ if (normalizeCodexAccountPoolStrategy(config.accountPoolStrategy) === "quota") {
setActiveCodexAccount(config, accountId);
return;
}
@@ -2147,9 +2184,12 @@ function previewReusableAffinityAccount(
) {
return null;
}
+ if (accountPoolStrategyForScope(config, quotaScope) === "reset-first") {
+ return resetFirstAffinityReplacement(entry, config, now, quotaScope, selectionOptions) ?? entry.accountId;
+ }
// Quota strategy only: non-quota strategies keep affinity for ongoing threads
// (new-session-only rotation — docs / affinity policy A).
- if (normalizeAccountPoolStrategy(config.accountPoolStrategy) === "quota") {
+ if (accountPoolStrategyForScope(config, quotaScope) === "quota") {
const threshold = config.autoSwitchThreshold ?? 80;
if (threshold > 0) {
const usage = computeCodexUsageScore(
@@ -2202,6 +2242,23 @@ function mayRebindAffinityForQuota(
|| (!isUnknownUsage(usage) && usage >= 100);
}
+/** Reset ordering may move a binding only under the existing cache-affinity release policy. */
+function resetFirstAffinityReplacement(
+ entry: ThreadAffinityEntry,
+ config: OcxConfig,
+ now: number,
+ quotaScope?: CodexQuotaScope,
+ selectionOptions?: CodexAccountUsabilityOptions,
+): string | null {
+ const threshold = config.autoSwitchThreshold ?? 80;
+ if (threshold <= 0) return null;
+ const usage = computeCodexUsageScore(getAccountQuota(entry.accountId), getPoolAccountPlanForSelection(config, entry.accountId, selectionOptions), now);
+ if (!mayRebindAffinityForQuota(config, entry.accountId, usage, threshold, selectionOptions)) return null;
+ const candidates = getEligiblePoolAccounts(config, entry.accountId, now, quotaScope, selectionOptions, true)
+ .filter(id => hasCodexQuotaHeadroom(config, id, selectionOptions, now));
+ return pickResetFirstCodexAccount(config, candidates, now, selectionOptions);
+}
+
/**
* Re-evaluate an affined account under the quota strategy. Returns a strictly
* cooler replacement, or null when the current binding should remain.
@@ -2213,7 +2270,13 @@ function reevaluateAffinityQuota(
quotaScope?: CodexQuotaScope,
selectionOptions?: CodexAccountUsabilityOptions,
): string | null {
- if (normalizeAccountPoolStrategy(config.accountPoolStrategy) !== "quota") return null;
+ const strategy = accountPoolStrategyForScope(config, quotaScope);
+ if (strategy === "reset-first") {
+ const replacement = resetFirstAffinityReplacement(entry, config, now, quotaScope, selectionOptions);
+ if (replacement || now - entry.lastReevalAt >= CODEX_THREAD_AFFINITY_REEVAL_INTERVAL_MS) entry.lastReevalAt = now;
+ return replacement;
+ }
+ if (strategy !== "quota") return null;
const threshold = config.autoSwitchThreshold ?? 80;
const usage = threshold > 0
? computeCodexUsageScore(
@@ -2306,6 +2369,7 @@ export function previewCodexAccountForRequest(
else if (
hasConfiguredPoolAccount(config, active, selectionOptions)
&& !isCodexAccountPaused(config, active)
+ && !isCodexAccountPlanExcluded(config, active)
) return active;
else return null;
}
@@ -2440,7 +2504,7 @@ export function resolveCodexAccountForThreadDetailed(
const cooler = reevaluateAffinityQuota(entry, config, now, quotaScope, selectionOptions);
if (cooler) {
if (!isIndependentCodexQuotaScope(quotaScope)) {
- setActiveCodexAccount(config, cooler);
+ promoteActiveCodexAccount(config, cooler);
}
bindThreadAffinity(threadId, cooler, now, quotaScope); // rebinds + resets clocks
return { status: "selected", accountId: cooler };
@@ -2548,6 +2612,7 @@ export function resolveCodexAccountForThreadDetailed(
} else if (
hasConfiguredPoolAccount(config, active, selectionOptions)
&& !isCodexAccountPaused(config, active)
+ && !isCodexAccountPlanExcluded(config, active)
) {
return { status: "selected", accountId: active };
} else {
diff --git a/src/lib/windows-atomic-replace.ts b/src/lib/windows-atomic-replace.ts
index 0f3ba94552..a876c98bca 100644
--- a/src/lib/windows-atomic-replace.ts
+++ b/src/lib/windows-atomic-replace.ts
@@ -33,6 +33,7 @@ export type ReplacePublisher =
| "claude-agents"
| "lab-automation"
| "lab-ledger"
+ | "remote-workspace"
| "storage-cleanup"
| "tray";
diff --git a/src/oauth/chatgpt-device.ts b/src/oauth/chatgpt-device.ts
index 74fbeeabd9..8151a12354 100644
--- a/src/oauth/chatgpt-device.ts
+++ b/src/oauth/chatgpt-device.ts
@@ -23,6 +23,19 @@ export const DEVICE_VERIFICATION_URL = "https://auth.openai.com/codex/device";
/** The grant's own lifetime. Polling past this only produces a worse error message. */
const DEVICE_FLOW_TTL_MS = 15 * 60 * 1000;
+/**
+ * Per-fetch deadline for every device-flow HTTP call (#3898). Until this
+ * existed the only bounds were the 15-minute grant TTL and the caller's
+ * abort, so one stuck TCP connection could hold the login slot for the whole
+ * grant. A FRESH timeout per fetch attempt is required — a single timeout
+ * shared across the poll loop would kill the 15-minute grant.
+ */
+export const DEVICE_FETCH_TIMEOUT_MS = 30_000;
+
+function deviceFetchSignal(signal?: AbortSignal): AbortSignal {
+ const timeout = AbortSignal.timeout(DEVICE_FETCH_TIMEOUT_MS);
+ return signal ? AbortSignal.any([signal, timeout]) : timeout;
+}
const DEFAULT_POLL_INTERVAL_MS = 5_000;
const MIN_POLL_INTERVAL_MS = 1_000;
/**
@@ -86,7 +99,7 @@ async function requestUserCode(signal?: AbortSignal): Promise {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ client_id: CHATGPT_CLIENT_ID }),
- signal,
+ signal: deviceFetchSignal(signal),
});
if (!response.ok) throw deviceError("request", response.status);
const payload = (await response.json()) as Record;
@@ -123,7 +136,7 @@ async function pollForGrant(
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ device_auth_id: device.deviceAuthId, user_code: device.userCode }),
- signal,
+ signal: deviceFetchSignal(signal),
});
if (response.status === 403 || response.status === 404) {
// Cap the wait at the time actually left. Sleeping a full interval past
@@ -149,7 +162,7 @@ async function pollForGrant(
throw new Error("ChatGPT device authorization expired");
}
-async function exchangeGrant(grant: DeviceGrant, signal?: AbortSignal): Promise {
+async function exchangeGrantRaw(grant: DeviceGrant, signal?: AbortSignal): Promise> {
const response = await fetch(CHATGPT_TOKEN_URL, {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
@@ -160,10 +173,54 @@ async function exchangeGrant(grant: DeviceGrant, signal?: AbortSignal): Promise<
code_verifier: grant.codeVerifier,
redirect_uri: DEVICE_REDIRECT_URI,
}).toString(),
- signal,
+ signal: deviceFetchSignal(signal),
});
if (!response.ok) throw deviceError("token exchange", response.status);
- return credsFromToken((await response.json()) as Record);
+ return (await response.json()) as Record;
+}
+
+async function exchangeGrant(grant: DeviceGrant, signal?: AbortSignal): Promise {
+ return credsFromToken(await exchangeGrantRaw(grant, signal));
+}
+
+/**
+ * The native-main reauth result (#3898): the projected credential PLUS the
+ * id_token the pool projection deliberately drops. The id_token is the
+ * identity document the native auth.json requires
+ * (native-profile-store.ts), and it never leaves this process — it is
+ * written to the native main slot by the caller, never serialized into a
+ * DTO, log, or error.
+ */
+export interface NativeDeviceLogin {
+ credential: OAuthCredentials;
+ idToken: string;
+}
+
+async function exchangeGrantNative(grant: DeviceGrant, signal?: AbortSignal): Promise {
+ const payload = await exchangeGrantRaw(grant, signal);
+ const credential = credsFromToken(payload);
+ const idToken = nonEmptyString(payload.id_token);
+ if (!idToken) throw new Error("ChatGPT device token response missing id_token");
+ if (!credential.refresh) throw new Error("ChatGPT device token response missing refresh token");
+ if (!credential.accountId) throw new Error("ChatGPT device token response missing account identity");
+ return { credential, idToken };
+}
+
+/**
+ * Device flow for the native __main__ slot. Same grant as the pool flow, but
+ * nothing is persisted here and no OAuth store is touched: the caller
+ * (main-device-reauth service) owns the fenced commit into CODEX_HOME
+ * auth.json.
+ */
+export async function loginChatGPTNativeDevice(ctrl: OAuthController): Promise {
+ const device = await requestUserCode(ctrl.signal);
+ ctrl.onAuth?.({
+ url: DEVICE_VERIFICATION_URL,
+ instructions: `Enter code: ${device.userCode}`,
+ deviceCode: device.userCode,
+ });
+ const grant = await pollForGrant(device, ctrl.signal);
+ return exchangeGrantNative(grant, ctrl.signal);
}
/**
diff --git a/src/oauth/pool-kernel.ts b/src/oauth/pool-kernel.ts
index b36ac88e03..ffd535fe60 100644
--- a/src/oauth/pool-kernel.ts
+++ b/src/oauth/pool-kernel.ts
@@ -41,6 +41,15 @@ export function parseAccountPoolStrategy(raw: unknown): OcxAccountPoolRotationSt
return null;
}
+/** Codex alone supports ordering by the next shared quota reset. */
+export function parseCodexAccountPoolStrategy(raw: unknown): OcxAccountPoolRotationStrategy | "reset-first" | null {
+ return raw === "reset-first" ? raw : parseAccountPoolStrategy(raw);
+}
+
+export function normalizeCodexAccountPoolStrategy(raw: unknown): OcxAccountPoolRotationStrategy | "reset-first" {
+ return parseCodexAccountPoolStrategy(raw) ?? DEFAULT_STRATEGY;
+}
+
/** Strict parse for management APIs — returns null instead of defaulting. */
export function parseAccountPoolStickyLimit(raw: unknown): number | null {
if (typeof raw === "number" && Number.isInteger(raw) && raw >= MIN_STICKY_LIMIT && raw <= MAX_STICKY_LIMIT) {
diff --git a/src/oauth/pool-settings-capability.ts b/src/oauth/pool-settings-capability.ts
index 946a92d357..cf17309807 100644
--- a/src/oauth/pool-settings-capability.ts
+++ b/src/oauth/pool-settings-capability.ts
@@ -1,5 +1,5 @@
import { isGenericFailoverProvider } from "./generic-account-failover";
-import { parseAccountPoolStickyLimit, parseAccountPoolStrategy } from "./pool-kernel";
+import { parseAccountPoolStickyLimit, parseAccountPoolStrategy, parseCodexAccountPoolStrategy } from "./pool-kernel";
import type { OcxConfig, OcxProviderConfig } from "../types";
/**
@@ -147,7 +147,7 @@ export function unifiedPoolSettingsDto(
// honest answer is "not a field here" rather than a fabricated true.
enabled: null,
enabledEffective: true,
- strategy: parseGenericPoolStrategy(config.accountPoolStrategy) ?? "quota",
+ strategy: parseCodexAccountPoolStrategy(config.accountPoolStrategy) ?? "quota",
stickyLimit: parseGenericStickyLimit(config.accountPoolStickyLimit) ?? 1,
autoSwitchThreshold: parseGenericAutoSwitchThreshold(config.autoSwitchThreshold) ?? 80,
quotaWindow: null,
diff --git a/src/providers/quota-types.ts b/src/providers/quota-types.ts
index e0bdf9cb4f..3b7b2e236a 100644
--- a/src/providers/quota-types.ts
+++ b/src/providers/quota-types.ts
@@ -49,4 +49,16 @@ export interface AccountQuotaFields {
quotaMode?: AccountQuotaMode;
quota?: ProviderQuota | null;
quotaUnavailable?: boolean;
+ quotaFailure?: QuotaFailureCode;
+}
+
+
+/** Closed account-probe diagnoses; never upstream text, URLs, credentials or routing policy. */
+export const QUOTA_FAILURE_CODES = [
+ "account_unavailable", "access_denied", "rate_limited", "upstream_error", "redirect_blocked",
+ "destination_blocked", "dns_failed", "timeout", "transport_error", "response_unusable",
+] as const;
+export type QuotaFailureCode = typeof QUOTA_FAILURE_CODES[number];
+export function parseQuotaFailureCode(value: unknown): QuotaFailureCode | undefined {
+ return QUOTA_FAILURE_CODES.find(code => code === value);
}
diff --git a/src/providers/quota.ts b/src/providers/quota.ts
index da5e988c93..dfe887b5e2 100644
--- a/src/providers/quota.ts
+++ b/src/providers/quota.ts
@@ -15,7 +15,9 @@ import { getValidAccessToken, getValidAccessTokenForAccount } from "../oauth";
import { getAccountCredential, getAccountSet, getCredential } from "../oauth/store";
import { antigravityUserAgent } from "../adapters/client-fingerprint";
import { isCanonicalOllamaCloudUrl } from "../adapters/ollama-native-url";
-import { providerOutboundPost, providerRedirectError, type ProviderOutboundDependencies } from "../lib/provider-outbound";
+import { DestinationDnsResolutionError } from "../lib/destination-policy";
+import { PinnedHttpError } from "../lib/pinned-http";
+import { ProviderOutboundPolicyError, providerOutboundPost, providerRedirectError, type ProviderOutboundDependencies } from "../lib/provider-outbound";
import { apiKeyPoolEntryId } from "./api-keys";
import { fetchMuseKeyQuotaSnapshot } from "./muse-key-quota";
import { XAI_GROK_CLIENT_VERSION, XAI_GROK_COMPATIBILITY } from "./xai-transport";
@@ -52,6 +54,7 @@ import {
} from "./codex-capacity";
import type {
AccountQuotaMode,
+ QuotaFailureCode,
ProviderQuota,
ProviderQuotaCreditsUsd,
ProviderQuotaWindow,
@@ -1673,6 +1676,8 @@ type AccountQuotaCacheEntry = {
quota: ProviderQuota | null;
/** Last probe failed (429 / network / expired login); still may hold last-good quota. */
unavailable?: true;
+ quotaFailure?: QuotaFailureCode;
+ quotaFailureIsCurrent?: () => boolean;
/** Private new-reader identity; never persisted or serialized. */
identity?: string;
isCurrent?: () => boolean;
@@ -1784,6 +1789,8 @@ export interface ProviderAccountQuota {
quota: ProviderQuota | null;
/** Set when the probe could not reach upstream (expired login, 429, network). */
unavailable?: true;
+ quotaFailure?: QuotaFailureCode;
+ quotaFailureIsCurrent?: () => boolean;
isCurrent?: () => boolean;
}
@@ -2061,6 +2068,10 @@ function explicitQuotaIdentity(provider: string, accountId: string, configured?:
const credential = getAccountCredential(provider, accountId);
const target = explicitQuotaConfig(provider, configured);
if (!credential || !target) return undefined;
+ return quotaCredentialIdentity(provider, accountId, credential, target);
+}
+
+function quotaCredentialIdentity(provider: string, accountId: string, credential: NonNullable>, target: OcxProviderConfig): string {
return createHash("sha256").update(JSON.stringify([
provider, accountId, credential.access, credential.refresh, credential.expires,
credential.accountId, credential.projectId, credential.source,
@@ -2152,6 +2163,12 @@ async function fetchExplicitCurrentQuota(provider: string, config: OcxProviderCo
return read.result;
}
+function antigravityQuotaDiagnosticIdentity(accountId: string, credential = getAccountCredential("google-antigravity", accountId)): string | undefined {
+ return credential ? quotaCredentialIdentity("google-antigravity", accountId, credential, {
+ adapter: "google", baseUrl: ANTIGRAVITY_ACCOUNT_QUOTA_BASE, authMode: "oauth",
+ }) : undefined;
+}
+
async function fetchAccountQuota(
provider: string,
accountId: string,
@@ -2165,13 +2182,23 @@ async function fetchAccountQuota(
const writerGeneration = captureConfigGeneration();
const cached = accountQuotaCache.get(key);
if (!forceRefresh && cached && Date.now() - cached.ts < ACCOUNT_QUOTA_TTL_MS) {
+ if (provider === "google-antigravity" && cached.quotaFailure && cached.quotaFailureIsCurrent?.() !== true) return { ...cached, quotaFailure: undefined };
return provider === "anthropic" ? { ...cached, quota: normalizeAnthropicQuota(cached.quota, Date.now()) } : cached;
}
const joinable = accountQuotaInflight.get(key);
if (joinable) return joinable;
+ const epoch = explicitAccountEpoch;
const probe = (async (): Promise => {
+ let diagnosticIdentity: string | undefined;
+ let quotaFailure: QuotaFailureCode | undefined;
+ const quotaFailureIsCurrent = () => {
+ try { return epoch === explicitAccountEpoch && diagnosticIdentity !== undefined && diagnosticIdentity === antigravityQuotaDiagnosticIdentity(accountId); }
+ catch { return false; }
+ };
+ const diagnosticFields = () => quotaFailure && quotaFailureIsCurrent() ? { quotaFailure, quotaFailureIsCurrent } : {};
try {
+ if (provider === "google-antigravity") diagnosticIdentity = antigravityQuotaDiagnosticIdentity(accountId);
let quota: ProviderQuota | null;
let kiroSnapshot: KiroUsageSnapshot | null = null;
if (provider === "kiro") {
@@ -2189,9 +2216,12 @@ async function fetchAccountQuota(
// Per-account Gem/Cla windows (#1082). The project id is part of the stored
// credential; without it the probe cannot be made, and that is "unavailable",
// never 0%.
- const projectId = getAccountCredential(provider, accountId)?.projectId;
- if (!projectId) throw new Error("antigravity account has no project id");
- quota = await fetchAntigravityUsageQuota(token, projectId);
+ const credential = getAccountCredential(provider, accountId);
+ diagnosticIdentity = credential?.access === token ? antigravityQuotaDiagnosticIdentity(accountId, credential) : undefined;
+ if (!diagnosticIdentity || !credential?.projectId) throw new Error("antigravity account unavailable");
+ const result = await probeAntigravityUsageQuota(token, credential.projectId);
+ quota = result.kind === "available" ? result.quota : null;
+ if (result.kind === "unavailable") quotaFailure = result.failure;
} else if (provider === "anthropic") {
quota = await fetchAnthropicUsageQuota(token);
} else {
@@ -2207,6 +2237,7 @@ async function fetchAccountQuota(
quota: provider === "anthropic"
? normalizeAnthropicQuota(accountQuotaCache.get(key)?.quota, Date.now()) : cached?.quota ?? null,
unavailable: true,
+ ...diagnosticFields(),
};
if (mayCommitAccountQuotaKey(key, writerGeneration)) {
accountQuotaCache.set(key, entry);
@@ -2227,11 +2258,13 @@ async function fetchAccountQuota(
}
return entry;
} catch {
+ if (provider === "google-antigravity") quotaFailure = "account_unavailable";
const entry: AccountQuotaCacheEntry = {
ts: Date.now(),
quota: provider === "anthropic"
? normalizeAnthropicQuota(accountQuotaCache.get(key)?.quota, Date.now()) : cached?.quota ?? null,
unavailable: true,
+ ...diagnosticFields(),
};
if (mayCommitAccountQuotaKey(key, writerGeneration)) {
accountQuotaCache.set(key, entry);
@@ -2264,7 +2297,9 @@ export async function fetchProviderAccountQuotas(
accountId: account.id,
quota: provider === "anthropic" ? normalizeAnthropicQuota(entry.quota, Date.now()) : entry.quota,
...(entry.unavailable ? { unavailable: true as const } : {}),
+ ...(entry.unavailable && entry.quotaFailure && entry.quotaFailureIsCurrent?.() === true ? { quotaFailure: entry.quotaFailure } : {}),
};
+ if (entry.quotaFailureIsCurrent) Object.defineProperty(result, "quotaFailureIsCurrent", { value: entry.quotaFailureIsCurrent });
if (!explicitAccountReader(provider)) return result;
const identity = entry.identity;
Object.defineProperty(result, "isCurrent", { value: () => {
@@ -2895,104 +2930,78 @@ export function setAntigravityAccountQuotaTransportForTests(dependencies: Provid
* the destination keeps the `provider\0accountId` cache identity exact across config changes.
* A redirect or non-2xx yields null (unavailable), never a partial row.
*/
-export async function fetchAntigravityUsageQuota(accessToken: string, projectId: string): Promise {
- const summaryUrl = ANTIGRAVITY_QUOTA_SUMMARY_URL;
+type AntigravityQuotaProbeResult =
+ | { kind: "available"; quota: ProviderQuota; source: "google-antigravity:retrieveUserQuotaSummary" | "google-antigravity:fetchAvailableModels" }
+ | { kind: "unavailable"; failure: QuotaFailureCode; legacy: { kind: "null" } | { kind: "throw"; error: unknown } };
+
+function quotaTransportFailure(error: unknown): QuotaFailureCode {
+ if (error instanceof ProviderOutboundPolicyError) return "destination_blocked";
+ if (error instanceof DestinationDnsResolutionError) return "dns_failed";
+ if (error instanceof PinnedHttpError) return error.code === "output_byte_limit" ? "response_unusable" : "timeout";
+ if (error instanceof DOMException && error.name === "TimeoutError") return "timeout";
+ return "transport_error";
+}
+
+function quotaHttpFailure(status: number): QuotaFailureCode {
+ if (status >= 300 && status < 400) return "redirect_blocked";
+ if (status === 401 || status === 403) return "access_denied";
+ if (status === 429) return "rate_limited";
+ return "upstream_error";
+}
+
+function unavailableAntigravityQuota(failure: QuotaFailureCode): AntigravityQuotaProbeResult {
+ return { kind: "unavailable", failure, legacy: { kind: "null" } };
+}
+
+/** Final attempt determines the safe diagnosis; a successful fallback clears the first failure. */
+async function probeAntigravityUsageQuota(accessToken: string, projectId: string): Promise {
+ const fetchQuota = (url: string) => providerOutboundPost("google-antigravity", { baseUrl: ANTIGRAVITY_ACCOUNT_QUOTA_BASE }, url, {
+ headers: {
+ Accept: "application/json", "Content-Type": "application/json",
+ "User-Agent": antigravityUserAgent(), Authorization: `Bearer ${accessToken}`,
+ },
+ body: JSON.stringify({ project: projectId }), signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
+ }, antigravityOutboundDependencies);
try {
- const summaryResponse = await providerOutboundPost("google-antigravity", { baseUrl: ANTIGRAVITY_ACCOUNT_QUOTA_BASE }, summaryUrl, {
- headers: {
- Accept: "application/json",
- "Content-Type": "application/json",
- "User-Agent": antigravityUserAgent(),
- Authorization: `Bearer ${accessToken}`,
- },
- body: JSON.stringify({ project: projectId }),
- signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
- }, antigravityOutboundDependencies);
- if (await providerRedirectError(summaryResponse, summaryUrl)) return null;
- if (summaryResponse.status === 401 || summaryResponse.status === 403) return null;
- if (summaryResponse.ok) {
- const quota = parseAntigravityQuotaSummary(asRecord(await readQuotaJson(summaryResponse)));
- if (quota) return quota;
+ const response = await fetchQuota(ANTIGRAVITY_QUOTA_SUMMARY_URL);
+ if (await providerRedirectError(response, ANTIGRAVITY_QUOTA_SUMMARY_URL)) return unavailableAntigravityQuota("redirect_blocked");
+ if (response.status === 401 || response.status === 403) return unavailableAntigravityQuota("access_denied");
+ if (response.ok) {
+ const quota = parseAntigravityQuotaSummary(asRecord(await readQuotaJson(response)));
+ if (quota) return { kind: "available", quota, source: "google-antigravity:retrieveUserQuotaSummary" };
}
} catch {
- // Fallback to fetchAvailableModels on error
+ // Existing behavior: summary transport/parse failure may recover through the models probe.
}
+ try {
+ const response = await fetchQuota(ANTIGRAVITY_QUOTA_MODELS_URL);
+ if (await providerRedirectError(response, ANTIGRAVITY_QUOTA_MODELS_URL)) return unavailableAntigravityQuota("redirect_blocked");
+ if (!response.ok) return unavailableAntigravityQuota(quotaHttpFailure(response.status));
+ const customWindows = antigravityWindowsFromModels(asRecord(await readQuotaJson(response)));
+ if (!customWindows.length) return unavailableAntigravityQuota("response_unusable");
+ return { kind: "available", quota: { customWindows, updatedAt: Date.now() }, source: "google-antigravity:fetchAvailableModels" };
+ } catch (error) {
+ // The public compatibility wrapper still rejects this exact fallback error; it never enters a DTO.
+ return { kind: "unavailable", failure: quotaTransportFailure(error), legacy: { kind: "throw", error } };
+ }
+}
- const url = ANTIGRAVITY_QUOTA_MODELS_URL;
- const response = await providerOutboundPost("google-antigravity", { baseUrl: ANTIGRAVITY_ACCOUNT_QUOTA_BASE }, url, {
- headers: {
- Accept: "application/json",
- "Content-Type": "application/json",
- "User-Agent": antigravityUserAgent(),
- Authorization: `Bearer ${accessToken}`,
- },
- body: JSON.stringify({ project: projectId }),
- signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
- }, antigravityOutboundDependencies);
- if (await providerRedirectError(response, url)) return null;
- if (!response.ok) return null;
- const customWindows = antigravityWindowsFromModels(asRecord(await readQuotaJson(response)));
- if (customWindows.length === 0) return null;
- return { customWindows, updatedAt: Date.now() };
+export async function fetchAntigravityUsageQuota(accessToken: string, projectId: string): Promise {
+ const result = await probeAntigravityUsageQuota(accessToken, projectId);
+ if (result.kind === "available") return result.quota;
+ if (result.legacy.kind === "throw") throw result.legacy.error;
+ return null;
}
async function fetchAntigravityQuota(provider: string): Promise