@bitkyc08/opencodex 2.43.0 → 2.44.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (138) hide show
  1. package/README.md +25 -17
  2. package/gui/dist/assets/index-B7_K1Hsj.js +115 -0
  3. package/gui/dist/assets/index-ltx3L-WS.css +1 -0
  4. package/gui/dist/index.html +2 -2
  5. package/package.json +1 -1
  6. package/src/adapters/command-code.ts +23 -2
  7. package/src/adapters/cursor/native-exec.ts +63 -11
  8. package/src/adapters/cursor/tool-definitions.ts +1 -1
  9. package/src/adapters/cursor/tool-schemas.ts +59 -0
  10. package/src/adapters/kiro.ts +54 -8
  11. package/src/adapters/openai-chat.ts +23 -6
  12. package/src/adapters/openai-responses.ts +3 -1
  13. package/src/adapters/opencode-go.ts +35 -0
  14. package/src/claude/agents-inject.ts +12 -5
  15. package/src/claude/desktop-3p-library.ts +89 -0
  16. package/src/claude/desktop-3p.ts +166 -95
  17. package/src/claude/desktop-discovery-inputs.ts +44 -0
  18. package/src/claude/desktop-profile.ts +1 -1
  19. package/src/claude/desktop-remote-store-artifact.ts +185 -0
  20. package/src/claude/desktop-remote-store-io.ts +91 -0
  21. package/src/claude/desktop-remote-store-state.ts +127 -0
  22. package/src/claude/desktop-remote-store.ts +269 -0
  23. package/src/claude/inbound-model-options.ts +10 -2
  24. package/src/claude/inbound-records.ts +7 -0
  25. package/src/claude/inbound.ts +1 -1
  26. package/src/claude/model-info.ts +16 -3
  27. package/src/cli/account-api.ts +7 -1
  28. package/src/cli/aside-profiles.ts +17 -0
  29. package/src/cli/capabilities.ts +39 -0
  30. package/src/cli/claude-agent-startup-sync.ts +3 -1
  31. package/src/cli/claude-desktop.ts +127 -18
  32. package/src/cli/connect.ts +27 -14
  33. package/src/cli/dispatch.ts +30 -16
  34. package/src/cli/export-command.ts +3 -1
  35. package/src/cli/index.ts +28 -16
  36. package/src/cli/integrations.ts +46 -12
  37. package/src/cli/opencode.ts +11 -4
  38. package/src/cli/runtime-api.ts +7 -2
  39. package/src/cli/uninstall-client-state.ts +78 -0
  40. package/src/client/connect.ts +476 -223
  41. package/src/client/hub-client.ts +81 -0
  42. package/src/client/hub-relay.ts +38 -6
  43. package/src/client/lifecycle-lock.ts +129 -0
  44. package/src/client/state.ts +71 -42
  45. package/src/clients/aside-profiles.ts +224 -0
  46. package/src/codex/account-lifecycle.ts +5 -9
  47. package/src/codex/auth-api.ts +50 -4
  48. package/src/codex/catalog/metadata.ts +0 -2
  49. package/src/codex/catalog/provider-fetch.ts +7 -5
  50. package/src/codex/catalog/sync.ts +88 -24
  51. package/src/codex/convergence.ts +2 -0
  52. package/src/codex/inject.ts +45 -14
  53. package/src/codex/journal.ts +6 -6
  54. package/src/codex/quota-refresh-outcome.ts +27 -0
  55. package/src/codex/shim.ts +7 -1
  56. package/src/combos/resolve.ts +10 -7
  57. package/src/config.ts +18 -5
  58. package/src/generated/compatibility-version.json +209 -105
  59. package/src/images/loop.ts +5 -2
  60. package/src/integrations/aside-profile-context.ts +270 -0
  61. package/src/integrations/aside-profile-journal.ts +229 -0
  62. package/src/integrations/aside-profiles.ts +176 -0
  63. package/src/integrations/catalog-refresh.ts +40 -0
  64. package/src/integrations/config-io.ts +16 -1
  65. package/src/integrations/owned-refresh.ts +14 -2
  66. package/src/integrations/state.ts +3 -1
  67. package/src/integrations/writer.ts +3 -1
  68. package/src/lab/fabric/producer-isolate.ts +84 -46
  69. package/src/lib/account-selection-events.ts +32 -0
  70. package/src/lib/errors.ts +9 -1
  71. package/src/lib/provider-outbound.ts +2 -45
  72. package/src/lib/proxy-env.ts +67 -0
  73. package/src/oauth/account-quota-rank.ts +9 -1
  74. package/src/oauth/anthropic-routing.ts +91 -23
  75. package/src/oauth/generic-account-failover.ts +15 -53
  76. package/src/oauth/index.ts +67 -68
  77. package/src/oauth/pool-settings-capability.ts +2 -1
  78. package/src/oauth/store.ts +91 -7
  79. package/src/oauth/types.ts +8 -0
  80. package/src/providers/api-key-selection.ts +110 -0
  81. package/src/providers/api-keys.ts +38 -31
  82. package/src/providers/context-cap.ts +27 -3
  83. package/src/providers/key-failover.ts +27 -17
  84. package/src/providers/provider-id-rewrite.ts +10 -8
  85. package/src/providers/registry.ts +1 -0
  86. package/src/responses/custom-tool-compat.ts +10 -0
  87. package/src/responses/function-call-compat.ts +173 -0
  88. package/src/responses/namespace-tool-compat.ts +27 -4
  89. package/src/responses/parser.ts +8 -0
  90. package/src/responses/task-input.ts +36 -0
  91. package/src/responses/tool-name-aliases.ts +79 -0
  92. package/src/router.ts +2 -0
  93. package/src/routing/capability.ts +10 -3
  94. package/src/routing/compatibility/assemble.ts +16 -1
  95. package/src/routing/evaluator.ts +5 -1
  96. package/src/server/auth-cors.ts +3 -0
  97. package/src/server/chat-native.ts +44 -9
  98. package/src/server/claude-messages.ts +71 -36
  99. package/src/server/grok-responses-snapshot-repair.ts +333 -0
  100. package/src/server/index.ts +26 -10
  101. package/src/server/management/account-selection-stream.ts +70 -0
  102. package/src/server/management/aside-profile-routes.ts +231 -0
  103. package/src/server/management/config-routes.ts +36 -41
  104. package/src/server/management/context.ts +3 -0
  105. package/src/server/management/integration-routes.ts +61 -3
  106. package/src/server/management/logs-usage-routes.ts +1 -0
  107. package/src/server/management/model-routes.ts +20 -9
  108. package/src/server/management/model-rows.ts +10 -2
  109. package/src/server/management/oauth-account-routes.ts +7 -0
  110. package/src/server/management/provider-routes.ts +10 -8
  111. package/src/server/management/route-registry.ts +20 -2
  112. package/src/server/management-auth.ts +16 -0
  113. package/src/server/relay-eager.ts +17 -5
  114. package/src/server/relay.ts +43 -4
  115. package/src/server/request-log.ts +29 -1
  116. package/src/server/responses/agent-task-recovery-cache.ts +11 -0
  117. package/src/server/responses/agent-task-recovery.ts +24 -4
  118. package/src/server/responses/codex-ws-pool.ts +4 -4
  119. package/src/server/responses/codex-ws-session.ts +2 -2
  120. package/src/server/responses/collaboration.ts +2 -1
  121. package/src/server/responses/combo-stream-preflight.ts +26 -7
  122. package/src/server/responses/core.ts +668 -151
  123. package/src/server/responses/fetch-helpers.ts +6 -1
  124. package/src/server/responses/ws-upstream.ts +8 -3
  125. package/src/server/responses-custom-tool-repair.ts +29 -9
  126. package/src/server/responses-function-tool-repair.ts +183 -0
  127. package/src/server/responses-snapshot-codec.ts +14 -0
  128. package/src/server/responses-snapshot-repair.ts +1 -13
  129. package/src/server/responses-undeclared-tool-guard.ts +1 -68
  130. package/src/server/sse-payload-rewrite.ts +3 -1
  131. package/src/service.ts +6 -2
  132. package/src/types/config.ts +23 -16
  133. package/src/types/provider.ts +14 -3
  134. package/src/types/request.ts +2 -0
  135. package/src/usage/log.ts +9 -0
  136. package/src/web-search/loop.ts +5 -2
  137. package/gui/dist/assets/index-DS1NE4Jn.css +0 -1
  138. package/gui/dist/assets/index-Djowl68T.js +0 -112
@@ -0,0 +1,224 @@
1
+ import { lstatSync, readFileSync, readlinkSync, realpathSync, statSync, type Stats } from "node:fs";
2
+ import { homedir } from "node:os";
3
+ import { basename, dirname, isAbsolute, join, resolve } from "node:path";
4
+ import type { IntegrationIO } from "../integrations/config-io";
5
+ import { asideHomeDir, ClientPathError } from "./config-export";
6
+
7
+ export interface AsideProfile {
8
+ id: number;
9
+ name?: string;
10
+ current: boolean;
11
+ root: string;
12
+ configPath: string;
13
+ detectDir: string;
14
+ }
15
+
16
+ const MAX_PROFILES = 128;
17
+ const MAX_MANIFEST_BYTES = 4 * 1024 * 1024;
18
+ const MAX_LEAF_LINKS = 40;
19
+
20
+ function refuse(message: string): never {
21
+ // Never include manifest contents or underlying filesystem error messages.
22
+ throw new ClientPathError(`Aside profile: ${message}`);
23
+ }
24
+
25
+ function isId(value: unknown): value is number {
26
+ return typeof value === "number" && Number.isSafeInteger(value) && value >= 0 && !Object.is(value, -0);
27
+ }
28
+
29
+ function object(value: unknown): value is Record<string, unknown> {
30
+ return value !== null && typeof value === "object" && !Array.isArray(value);
31
+ }
32
+
33
+ function inspect(path: string, follow = false): Stats | null {
34
+ try {
35
+ return follow ? statSync(path) : lstatSync(path);
36
+ } catch (error) {
37
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") return null;
38
+ return refuse("a filesystem boundary could not be inspected.");
39
+ }
40
+ }
41
+
42
+ function canonical(path: string): string {
43
+ try { return realpathSync.native(path); } catch {
44
+ return refuse("a filesystem boundary could not be resolved.");
45
+ }
46
+ }
47
+
48
+ /** Resolve a peer's leaf link even when its final model file does not exist yet. */
49
+ function leafDestination(path: string): string | null {
50
+ const visited = new Set<string>();
51
+ while (inspect(path)?.isSymbolicLink()) {
52
+ if (visited.has(path) || visited.size >= MAX_LEAF_LINKS) refuse("an account catalog has a cyclic or excessive link chain.");
53
+ visited.add(path);
54
+ try { path = resolve(dirname(path), readlinkSync(path)); } catch {
55
+ return refuse("an account catalog link could not be inspected.");
56
+ }
57
+ }
58
+ if (!inspect(dirname(path), true)?.isDirectory()) return null;
59
+ return join(canonical(dirname(path)), basename(path));
60
+ }
61
+
62
+ function readProfiles(root: string): AsideProfile[] {
63
+ const rootStat = inspect(root);
64
+ if (!rootStat || rootStat.isSymbolicLink() || !rootStat.isDirectory()) {
65
+ refuse("the configured root is missing or is not a safe directory.");
66
+ }
67
+ const manifest = join(root, "accounts.json");
68
+ const manifestStat = inspect(manifest);
69
+ if (!manifestStat || manifestStat.isSymbolicLink() || !manifestStat.isFile()
70
+ || manifestStat.size > MAX_MANIFEST_BYTES) {
71
+ refuse("the account manifest is missing, unreadable or unsafe. Launch Aside to create it.");
72
+ }
73
+ let parsed: unknown;
74
+ try { parsed = JSON.parse(readFileSync(manifest, "utf8")); } catch {
75
+ return refuse("the account manifest is not readable JSON.");
76
+ }
77
+ if (!object(parsed) || !isId(parsed.currentAccountId)) {
78
+ refuse("the account manifest has no valid current account ID.");
79
+ }
80
+ const currentId = parsed.currentAccountId;
81
+ const accounts: unknown = Object.hasOwn(parsed, "accounts") ? parsed.accounts : [{ id: currentId }];
82
+ if (!Array.isArray(accounts) || accounts.length === 0 || accounts.length > MAX_PROFILES) {
83
+ refuse("the account manifest must contain between 1 and 128 accounts.");
84
+ }
85
+ const ids = new Set<number>();
86
+ const profiles = accounts.map((account: unknown): AsideProfile => {
87
+ if (!object(account) || !isId(account.id) || ids.has(account.id)) {
88
+ return refuse("the account manifest contains an invalid or duplicate account ID.");
89
+ }
90
+ const current = account.id === currentId;
91
+ if (Object.hasOwn(account, "current") && account.current !== current) {
92
+ refuse("the account manifest has inconsistent current account metadata.");
93
+ }
94
+ ids.add(account.id);
95
+ const detectDir = join(root, "u", String(account.id));
96
+ return {
97
+ id: account.id,
98
+ ...(typeof account.name === "string" ? { name: account.name } : {}),
99
+ current, root, detectDir, configPath: join(detectDir, "models.json"),
100
+ };
101
+ });
102
+ if (!ids.has(currentId)) refuse("the current account is not registered in the account manifest.");
103
+ return profiles;
104
+ }
105
+
106
+ /** Enumerate account catalogs; browser bindings and session data are never projected. */
107
+ export function listAsideProfiles(env: NodeJS.ProcessEnv = process.env, home: string = homedir()): AsideProfile[] {
108
+ const root = asideHomeDir(env, home);
109
+ if (!isAbsolute(root)) refuse("the configured root must be absolute.");
110
+ return readProfiles(root);
111
+ }
112
+
113
+ type DirectoryIdentity = { path: string; dev: number; ino: number };
114
+ type Boundary = Array<DirectoryIdentity | null>;
115
+
116
+ function sameIdentity(a: Pick<Stats, "dev" | "ino">, b: Pick<Stats, "dev" | "ino">): boolean {
117
+ return a.dev === b.dev && a.ino === b.ino;
118
+ }
119
+
120
+ function validatePaths(profile: AsideProfile): void {
121
+ if (!isId(profile.id) || !isAbsolute(profile.root) || resolve(profile.root) !== profile.root
122
+ || profile.detectDir !== join(profile.root, "u", String(profile.id))
123
+ || profile.configPath !== join(profile.detectDir, "models.json")) {
124
+ refuse("the selected account paths are invalid.");
125
+ }
126
+ }
127
+
128
+ function registeredProfiles(profile: AsideProfile, profiles?: AsideProfile[]): AsideProfile[] {
129
+ validatePaths(profile);
130
+ const registered = profiles ?? readProfiles(profile.root);
131
+ if (registered.length === 0 || registered.length > MAX_PROFILES) refuse("the account list is invalid.");
132
+ const ids = new Set<number>();
133
+ for (const peer of registered) {
134
+ validatePaths(peer);
135
+ if (peer.root !== profile.root || ids.has(peer.id)) refuse("the account list has conflicting paths.");
136
+ ids.add(peer.id);
137
+ }
138
+ if (!ids.has(profile.id)) refuse("the selected account is not registered.");
139
+ return registered;
140
+ }
141
+
142
+ function boundary(profile: AsideProfile, profiles: AsideProfile[], mutation: boolean): Boundary {
143
+ const directories = [profile.root, join(profile.root, "u"), profile.detectDir];
144
+ const identities: Boundary = [];
145
+ let parent: string | undefined;
146
+ let absent = false;
147
+ for (const [index, directory] of directories.entries()) {
148
+ const stats = absent ? null : inspect(directory);
149
+ if (!stats) {
150
+ if (index === 0 || mutation) refuse("the account directory is not installed; it will not be created.");
151
+ absent = true;
152
+ identities.push(null);
153
+ continue;
154
+ }
155
+ if (stats.isSymbolicLink() || !stats.isDirectory()) refuse("an account directory is a link or is unsafe.");
156
+ const path = canonical(directory);
157
+ // Aliases ABOVE the chosen root (notably macOS /var) are valid.
158
+ const child = index === 1 ? "u" : String(profile.id);
159
+ if (parent && path !== join(parent, child)) refuse("an account directory resolves outside its boundary.");
160
+ identities.push({ path, dev: stats.dev, ino: stats.ino });
161
+ parent = path;
162
+ }
163
+ if (absent) return identities;
164
+ const leaf = inspect(profile.configPath);
165
+ if (leaf && (leaf.isSymbolicLink() || !leaf.isFile() || leaf.nlink > 1)) {
166
+ refuse("the model catalog is a link, shared file or non-regular file.");
167
+ }
168
+ if (leaf && canonical(profile.configPath) !== join(parent!, "models.json")) {
169
+ refuse("the model catalog resolves outside its account directory.");
170
+ }
171
+ const account = identities[2]!;
172
+ for (const peer of profiles) {
173
+ if (peer.id === profile.id) continue;
174
+ // Follow peers only for identity comparison, never for content or writes.
175
+ // This also detects a sibling symlink pointing BACK at this safe target.
176
+ const peerDirectory = inspect(peer.detectDir, true);
177
+ if (peerDirectory && sameIdentity(account, peerDirectory)) refuse("account directories share a target.");
178
+ if (!peerDirectory?.isDirectory()) continue;
179
+ if (inspect(peer.configPath)?.isSymbolicLink()
180
+ && leafDestination(peer.configPath) === join(parent!, "models.json")) {
181
+ refuse("account catalogs share a target.");
182
+ }
183
+ const peerLeaf = inspect(peer.configPath, true);
184
+ if (leaf && peerLeaf && sameIdentity(leaf, peerLeaf)) refuse("account catalogs share a target.");
185
+ }
186
+ return identities;
187
+ }
188
+
189
+ /** Missing account directories are readable as not installed, but never writable. */
190
+ export function assertAsideProfileBoundary(profile: AsideProfile, profiles?: AsideProfile[], mutation = false): void {
191
+ boundary(profile, registeredProfiles(profile, profiles), mutation);
192
+ }
193
+
194
+ /**
195
+ * Pin directories for one status/write operation, retaining the caller's IO and store.
196
+ * Rechecks complement atomic writes; they do not defeat a hostile same-user process
197
+ * racing every filesystem syscall. Leaf inodes may change during our atomic writes.
198
+ */
199
+ export function guardAsideProfileIO(profile: AsideProfile, io: IntegrationIO, profiles?: AsideProfile[]): IntegrationIO {
200
+ const selected = { ...profile };
201
+ const registered = registeredProfiles(selected, profiles).map(peer => ({ ...peer }));
202
+ const captured = boundary(selected, registered, false);
203
+ function check(path: string, directory: boolean, mutation: boolean): void {
204
+ if (path !== (directory ? selected.detectDir : selected.configPath)) {
205
+ refuse("IO attempted to access a different account path.");
206
+ }
207
+ const current = boundary(selected, registered, mutation);
208
+ if (current.some((item, index) => {
209
+ const prior = captured[index];
210
+ return item === null || prior == null ? item !== prior : item.path !== prior.path || !sameIdentity(item, prior);
211
+ })) refuse("the account directory changed after the operation began.");
212
+ }
213
+ return {
214
+ readText: path => { check(path, false, false); return io.readText(path); },
215
+ statKind: path => { check(path, path === selected.detectDir, false); return io.statKind(path); },
216
+ writeText: (path, text) => { check(path, false, true); io.writeText(path, text); },
217
+ removeFile: path => { check(path, false, true); io.removeFile(path); },
218
+ mkdirp: path => { check(path, true, true); io.mkdirp(path); },
219
+ now: () => io.now(),
220
+ appendJournal: entry => io.appendJournal(entry),
221
+ putRecord: record => io.putRecord(record),
222
+ dropRecord: clientId => io.dropRecord(clientId),
223
+ };
224
+ }
@@ -1,6 +1,5 @@
1
1
  import { existsSync, readFileSync } from "node:fs";
2
2
  import {
3
- atomicWriteFile,
4
3
  deleteConfigTopLevelKey,
5
4
  getConfigPath,
6
5
  saveConfigPreservingClaudeCode,
@@ -107,13 +106,10 @@ function restoreRuntimeConfig(target: OcxConfig, snapshot: OcxConfig): void {
107
106
  Object.assign(target, snapshot);
108
107
  }
109
108
 
110
- function restorePersistedConfig(configPath: string, previousBytes: string): void {
111
- try {
112
- if (readFileSync(configPath, "utf8") === previousBytes) return;
113
- } catch (error) {
114
- if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
109
+ function assertPersistedConfigUnchanged(configPath: string, previousBytes: Buffer): void {
110
+ if (!readFileSync(configPath).equals(previousBytes)) {
111
+ throw new CodexAccountDeleteRollbackError();
115
112
  }
116
- atomicWriteFile(configPath, previousBytes);
117
113
  }
118
114
 
119
115
  /**
@@ -133,7 +129,7 @@ export function deleteCodexAccount(runtimeConfig: OcxConfig, accountId: string):
133
129
  const previousConfig = structuredClone(runtimeConfig);
134
130
  const configPath = getConfigPath();
135
131
  const hasPersistedConfig = existsSync(configPath);
136
- const previousPersistedConfig = hasPersistedConfig ? readFileSync(configPath, "utf8") : undefined;
132
+ const previousPersistedConfig = hasPersistedConfig ? readFileSync(configPath) : undefined;
137
133
  const hadStoredAccount = (runtimeConfig.codexAccounts ?? [])
138
134
  .some(account => !account.isMain && account.id === accountId);
139
135
  const hadVisiblePickerBinding = hadStoredAccount
@@ -162,7 +158,7 @@ export function deleteCodexAccount(runtimeConfig: OcxConfig, accountId: string):
162
158
  } catch (error) {
163
159
  restoreRuntimeConfig(runtimeConfig, previousConfig);
164
160
  try {
165
- restorePersistedConfig(configPath, previousPersistedConfig);
161
+ assertPersistedConfigUnchanged(configPath, previousPersistedConfig);
166
162
  } catch {
167
163
  throw new CodexAccountDeleteRollbackError();
168
164
  }
@@ -105,6 +105,7 @@ import {
105
105
  type MainAccountInfo,
106
106
  } from "./main-account-cache";
107
107
  export { clearMainAccountInfoCache } from "./main-account-cache";
108
+ import type { CodexQuotaRefreshOutcome } from "./quota-refresh-outcome";
108
109
  import { getMainAccountHardLockStatus, type MainAccountHardLockStatus } from "./main-account-hard-lock";
109
110
  import { observeMainReserveRevocation } from "./reserve-availability";
110
111
  import { maskEmail } from "../lib/privacy";
@@ -777,6 +778,10 @@ async function readMainAuthErrorCode(resp: Response): Promise<unknown> {
777
778
 
778
779
  interface MainAccountInfoFetchResult {
779
780
  info: MainAccountInfo;
781
+ /** Ephemeral result of this attempt, omitted when no WHAM request was made. */
782
+ quotaRefresh?: CodexQuotaRefreshOutcome;
783
+ /** Internal dispatch fence for diagnostics only; never copied into a public DTO or cache. */
784
+ quotaRefreshGeneration?: number;
780
785
  /** Whether this attempt safely inspected the physical native-main credential. */
781
786
  credentialChecked: boolean;
782
787
  /** Meaningful only when credentialChecked is true. */
@@ -792,12 +797,16 @@ interface MainAccountInfoFetchResult {
792
797
  export interface MainAccountInfoSnapshot {
793
798
  info: MainAccountInfo;
794
799
  mainIdentityGeneration: number;
800
+ quotaRefresh?: CodexQuotaRefreshOutcome;
795
801
  }
796
802
 
797
803
  export async function fetchMainAccountInfoSnapshot(forceRefresh = false): Promise<MainAccountInfoSnapshot> {
798
804
  const result = await fetchMainAccountInfoAttempt(forceRefresh, 1);
799
805
  return {
800
806
  info: result.info,
807
+ ...(result.quotaRefresh && result.quotaRefreshGeneration !== undefined
808
+ && isMainAccountIdentityGenerationLive(result.quotaRefreshGeneration)
809
+ ? { quotaRefresh: result.quotaRefresh } : {}),
801
810
  mainIdentityGeneration: result.identityGeneration ?? captureMainAccountIdentityGeneration(),
802
811
  };
803
812
  }
@@ -900,34 +909,55 @@ async function fetchMainAccountInfoWhileOwned(
900
909
  ? observeMainQuotaCredential(tokens.access_token, tokens.account_id)
901
910
  : undefined;
902
911
  const mainQuotaCredentialGeneration = getMainQuotaCredentialGeneration();
912
+ // Keep diagnostics separate from authentication and freshness policy. Never serialize errors.
913
+ const quotaSignal = AbortSignal.timeout(WHAM_REQUEST_TIMEOUT_MS);
914
+ let quotaPhase: "request" | "body" | "decode" | "publish" = "request";
915
+ let quotaRefreshGeneration = captureMainAccountIdentityGeneration();
903
916
  try {
904
917
  const resp = await fetch("https://chatgpt.com/backend-api/wham/usage", {
905
918
  headers: { Authorization: `Bearer ${tokens.access_token}`, "ChatGPT-Account-Id": tokens.account_id },
906
- signal: AbortSignal.timeout(WHAM_REQUEST_TIMEOUT_MS),
919
+ signal: quotaSignal,
907
920
  });
921
+ quotaPhase = "publish";
908
922
  if (!resp.ok) {
909
923
  const terminalAuthFailure = await isTerminalMainAuthResponse(resp, isMainAccountTokenVerifiablyLive());
910
924
  const retried = await retryMainAccountInfoIfIdentityChanged(requestAccountId, retriesRemaining, nativeMainLease, explicitRefresh);
911
925
  if (retried) return retried;
912
926
  if (terminalAuthFailure) {
927
+ // Account for this attempt's own synchronous invalidation, never prior external drift.
928
+ const diagnosticStillLive = isMainAccountIdentityGenerationLive(quotaRefreshGeneration);
913
929
  clearMainAccountInfoCache();
930
+ if (diagnosticStillLive) quotaRefreshGeneration = captureMainAccountIdentityGeneration();
914
931
  markAccountNeedsReauth(MAIN_CODEX_ACCOUNT_ID, writerGeneration);
915
932
  }
916
- return { info: EMPTY_MAIN_ACCOUNT_INFO, credentialChecked: true, hasCredential: true };
933
+ return {
934
+ info: EMPTY_MAIN_ACCOUNT_INFO, credentialChecked: true, hasCredential: true,
935
+ quotaRefresh: { status: "http_error", httpStatus: resp.status },
936
+ quotaRefreshGeneration,
937
+ };
917
938
  }
939
+ quotaPhase = "body";
918
940
  const data = (await resp.json()) as WhamUsageResponse;
941
+ quotaPhase = "publish";
919
942
  const retried = await retryMainAccountInfoIfIdentityChanged(requestAccountId, retriesRemaining, nativeMainLease, explicitRefresh);
920
943
  if (retried) return retried;
944
+ quotaPhase = "decode";
945
+ if (data === null || typeof data !== "object" || Array.isArray(data)) {
946
+ throw new Error("Invalid WHAM usage object");
947
+ }
948
+ quotaPhase = "publish";
921
949
  // A delayed response from a replaced bearer cannot revoke a newer Reserve grant,
922
950
  // even in the same workspace or after an A→B→A credential transition.
923
951
  if (mainQuotaCredentialGeneration === getMainQuotaCredentialGeneration()
924
952
  && matchesMainQuotaCredential(tokens.access_token, tokens.account_id)) {
925
953
  observeMainReserveRevocation(data, mainQuotaWriter);
926
954
  }
955
+ quotaPhase = "decode";
927
956
  const plan = nonEmptyPlan(data.plan_type) ?? nonEmptyPlan(cached?.plan) ?? nonEmptyPlan(getMainAccountPlan());
928
957
  const usage = { ...data, ...(plan ? { plan_type: plan } : {}) };
929
958
  const quota = parseUsageQuota(usage);
930
959
  const policyQuota = parseMainPolicyUsageQuota(usage);
960
+ quotaPhase = "publish";
931
961
  const freshResetCredits = quota?.resetCredits;
932
962
  // Tag the count with the identity it was read from, so a later response that omits the
933
963
  // summary can restore the badge without ever crossing an account boundary.
@@ -957,14 +987,26 @@ async function fetchMainAccountInfoWhileOwned(
957
987
  }
958
988
  return {
959
989
  info: result,
990
+ quotaRefresh: { status: quota ? "ok" : "not_reported" },
991
+ quotaRefreshGeneration,
960
992
  credentialChecked: true,
961
993
  hasCredential: true,
962
994
  ...(quota ? { freshQuota: quota } : {}),
963
995
  ...(freshResetCredits !== undefined ? { freshResetCredits } : {}),
964
996
  };
965
- } catch {
997
+ } catch (error) {
966
998
  const retried = await retryMainAccountInfoIfIdentityChanged(requestAccountId, retriesRemaining, nativeMainLease, explicitRefresh);
967
- return retried ?? { info: EMPTY_MAIN_ACCOUNT_INFO, credentialChecked: true, hasCredential: true };
999
+ if (retried) return retried;
1000
+ let status: CodexQuotaRefreshOutcome["status"] = "internal_error";
1001
+ if ((quotaPhase === "request" || quotaPhase === "body") && quotaSignal.aborted) status = "timeout";
1002
+ else if (quotaPhase === "request") status = "network_error";
1003
+ else if (quotaPhase === "body") status = error instanceof SyntaxError ? "invalid_response" : "network_error";
1004
+ else if (quotaPhase === "decode") status = "invalid_response";
1005
+ return {
1006
+ info: EMPTY_MAIN_ACCOUNT_INFO, credentialChecked: true, hasCredential: true,
1007
+ quotaRefresh: { status },
1008
+ quotaRefreshGeneration,
1009
+ };
968
1010
  }
969
1011
  }
970
1012
 
@@ -1061,6 +1103,7 @@ export interface CodexAuthAccountDto {
1061
1103
  healthSummary: string;
1062
1104
  healthAction?: string;
1063
1105
  quotaProbeSkipped?: true;
1106
+ quotaRefresh?: CodexQuotaRefreshOutcome;
1064
1107
  mainAccountHardLock?: MainAccountHardLockStatus;
1065
1108
  }
1066
1109
 
@@ -1769,6 +1812,9 @@ export async function listCodexAuthAccountsSnapshot(
1769
1812
  id: MAIN_CODEX_ACCOUNT_ID,
1770
1813
  email: maskEmail(mainInfo.email) ?? "Codex App login",
1771
1814
  plan: mainInfo.plan,
1815
+ ...(mainSnapshotLive && mainResult.quotaRefresh && mainResult.quotaRefreshGeneration !== undefined
1816
+ && isMainAccountIdentityGenerationLive(mainResult.quotaRefreshGeneration)
1817
+ ? { quotaRefresh: mainResult.quotaRefresh } : {}),
1772
1818
  logLabel: "main",
1773
1819
  isMain: true,
1774
1820
  paused: isCodexAccountPaused(runtimeConfig, MAIN_CODEX_ACCOUNT_ID),
@@ -299,8 +299,6 @@ function narrowToLimits(raw: number | undefined, slug: string, input: NativeCont
299
299
  return overlay !== undefined && cap !== undefined ? Math.min(window, cap) : window;
300
300
  }
301
301
  const narrowed = overlay === undefined ? raw : Math.min(raw, overlay);
302
- // 922k is the GPT-5.6 1M opt-in, not a request to shrink gpt-5.4's 1M window.
303
- if (cap === NATIVE_GPT56_MAX_INPUT_TOKENS) return narrowed;
304
302
  return applyProviderContextCap(narrowed, cap) ?? narrowed;
305
303
  }
306
304
 
@@ -1506,11 +1506,14 @@ async function fetchProviderModelsWithAuth(
1506
1506
  && prov.googleMode === "vertex"
1507
1507
  && (prov.models?.length ?? 0) === 0
1508
1508
  && Boolean(prov.defaultModel);
1509
- // Ordered dedupe union: Vertex seed, then `models`, then `retainModels`. `configured` is the
1509
+ const seedStaticDefault = prov.liveModels === false
1510
+ && (prov.models?.length ?? 0) === 0
1511
+ && Boolean(prov.defaultModel);
1512
+ // Ordered dedupe union: implicit default seed, then `models`, then `retainModels`. `configured` is the
1510
1513
  // single seed for the static path, the degraded fallback, drop diagnostics, and provider hints,
1511
1514
  // so a retain-only id must enter here or it never exists to be retained (#1690).
1512
1515
  const configuredIds = [...new Set([
1513
- ...(seedVertexDefault && prov.defaultModel ? [prov.defaultModel] : []),
1516
+ ...((seedVertexDefault || seedStaticDefault) && prov.defaultModel ? [prov.defaultModel] : []),
1514
1517
  ...(prov.models ?? []),
1515
1518
  ...(prov.retainModels ?? []),
1516
1519
  ])];
@@ -1563,9 +1566,8 @@ async function fetchProviderModelsWithAuth(
1563
1566
  : resolveAuth.resolve(name, prov));
1564
1567
  const apiKey = auth.apiKey;
1565
1568
  // A configured default is a real callable selector and must remain discoverable when a
1566
- // compatible provider's live /models request fails (issue #308). Keep this separate from the
1567
- // explicit static list: `liveModels: false` + empty `models[]` intentionally publishes zero
1568
- // rows, while a failed live discovery may degrade to the default selector.
1569
+ // compatible provider's live /models request fails (issue #308). Static providers already seed
1570
+ // their default selector above when no explicit model list exists.
1569
1571
  const failedDiscoveryConfigured = configured.length > 0 || !prov.defaultModel || prov.adapter !== "anthropic"
1570
1572
  ? configured
1571
1573
  : [{