@bitkyc08/opencodex 2.10.0 → 2.10.1

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 (274) hide show
  1. package/AGENTS_INSTALL.md +77 -0
  2. package/README.md +4 -10
  3. package/bin/ocx.mjs +71 -18
  4. package/gui/dist/assets/index-Cd6_PBKn.css +1 -0
  5. package/gui/dist/assets/index-ChZQsmBY.js +70 -0
  6. package/gui/dist/index.html +2 -2
  7. package/gui/dist/provider-icons/alibaba-color.svg +1 -1
  8. package/gui/dist/provider-icons/antigravity-color.svg +1 -1
  9. package/gui/dist/provider-icons/claude-color.svg +1 -1
  10. package/gui/dist/provider-icons/cline-color.svg +16 -0
  11. package/gui/dist/provider-icons/cloudflare-ai-gateway-color.svg +1 -1
  12. package/gui/dist/provider-icons/copilot-color.svg +1 -1
  13. package/gui/dist/provider-icons/cursor-color.svg +1 -1
  14. package/gui/dist/provider-icons/deepseek-color.svg +1 -1
  15. package/gui/dist/provider-icons/firepass-color.svg +1 -1
  16. package/gui/dist/provider-icons/fireworks-color.svg +1 -1
  17. package/gui/dist/provider-icons/gemini-color.svg +1 -1
  18. package/gui/dist/provider-icons/github-copilot-color.svg +1 -1
  19. package/gui/dist/provider-icons/gitlab-duo-color.svg +1 -1
  20. package/gui/dist/provider-icons/grok.svg +1 -1
  21. package/gui/dist/provider-icons/groq-color.svg +1 -1
  22. package/gui/dist/provider-icons/huggingface-color.svg +1 -1
  23. package/gui/dist/provider-icons/kimi-color.svg +1 -1
  24. package/gui/dist/provider-icons/kiro-color.svg +2 -2
  25. package/gui/dist/provider-icons/lm-studio-color.svg +1 -1
  26. package/gui/dist/provider-icons/mistral-color.svg +1 -1
  27. package/gui/dist/provider-icons/moonshot-color.svg +1 -1
  28. package/gui/dist/provider-icons/nvidia-color.svg +1 -1
  29. package/gui/dist/provider-icons/ollama-color.svg +1 -1
  30. package/gui/dist/provider-icons/openai.svg +1 -1
  31. package/gui/dist/provider-icons/opencode.svg +2 -1
  32. package/gui/dist/provider-icons/openrouter-color.svg +1 -1
  33. package/gui/dist/provider-icons/pi.svg +2 -2
  34. package/gui/dist/provider-icons/qianfan-color.svg +1 -1
  35. package/gui/dist/provider-icons/qwen-portal-color.svg +1 -1
  36. package/gui/dist/provider-icons/vercel-ai-gateway-color.svg +1 -1
  37. package/gui/dist/provider-icons/vllm-color.svg +1 -1
  38. package/gui/dist/provider-icons/xiaomi-color.svg +1 -1
  39. package/package.json +8 -4
  40. package/src/adapters/anthropic.ts +208 -14
  41. package/src/adapters/base.ts +16 -5
  42. package/src/adapters/cursor/effort-map.ts +3 -2
  43. package/src/adapters/cursor/framing.ts +39 -0
  44. package/src/adapters/cursor/live-transport.ts +105 -95
  45. package/src/adapters/cursor/native-exec.ts +32 -6
  46. package/src/adapters/cursor/protobuf-request.ts +20 -15
  47. package/src/adapters/cursor/request-builder.ts +21 -7
  48. package/src/adapters/cursor/types.ts +7 -0
  49. package/src/adapters/google-antigravity-replay.ts +237 -21
  50. package/src/adapters/google-truncation.ts +11 -0
  51. package/src/adapters/google.ts +50 -9
  52. package/src/adapters/identity.ts +39 -6
  53. package/src/adapters/kiro-errors.ts +11 -0
  54. package/src/adapters/kiro-events.ts +19 -1
  55. package/src/adapters/kiro-thinking.ts +10 -2
  56. package/src/adapters/kiro-tools.ts +10 -1
  57. package/src/adapters/kiro.ts +37 -11
  58. package/src/adapters/openai-chat.ts +284 -83
  59. package/src/adapters/openai-responses.ts +182 -24
  60. package/src/bridge.ts +177 -7
  61. package/src/chat/outbound.ts +78 -23
  62. package/src/claude/agents-inject.ts +27 -5
  63. package/src/claude/inbound.ts +11 -1
  64. package/src/claude/model-info.ts +13 -10
  65. package/src/claude/outbound.ts +17 -0
  66. package/src/cli/account-api.ts +24 -0
  67. package/src/cli/account-auth.ts +31 -6
  68. package/src/cli/account-main.ts +317 -0
  69. package/src/cli/account.ts +5 -0
  70. package/src/cli/claude.ts +2 -1
  71. package/src/cli/doctor.ts +93 -22
  72. package/src/cli/export-command.ts +26 -12
  73. package/src/cli/help.ts +8 -6
  74. package/src/cli/index.ts +56 -22
  75. package/src/cli/integrations.ts +84 -1
  76. package/src/cli/observe.ts +54 -1
  77. package/src/cli/opencode.ts +2 -1
  78. package/src/cli/provider-runtime.ts +18 -1
  79. package/src/cli/route-policy.ts +92 -0
  80. package/src/cli/runtime-api.ts +6 -3
  81. package/src/cli/star-prompt.ts +71 -15
  82. package/src/cli/status.ts +1 -1
  83. package/src/cli/v2.ts +36 -9
  84. package/src/clients/config-export.ts +687 -10
  85. package/src/codex/account-lifecycle.ts +30 -5
  86. package/src/codex/account-usability.ts +22 -2
  87. package/src/codex/admission.ts +255 -0
  88. package/src/codex/auth-api.ts +427 -140
  89. package/src/codex/auth-context.ts +155 -30
  90. package/src/codex/autostart-health.ts +8 -1
  91. package/src/codex/catalog/account-models.ts +62 -0
  92. package/src/codex/catalog/aggregation.ts +14 -1
  93. package/src/codex/catalog/bundled.ts +282 -32
  94. package/src/codex/catalog/filesystem-evidence.ts +302 -0
  95. package/src/codex/catalog/metadata.ts +51 -6
  96. package/src/codex/catalog/parsing.ts +6 -3
  97. package/src/codex/catalog/provider-fetch.ts +576 -41
  98. package/src/codex/catalog/sync.ts +505 -66
  99. package/src/codex/catalog-admission.ts +197 -0
  100. package/src/codex/catalog-write-serialization.ts +241 -0
  101. package/src/codex/catalog.ts +2 -1
  102. package/src/codex/codex-write-lock.ts +372 -0
  103. package/src/codex/convergence-types.ts +593 -0
  104. package/src/codex/convergence.ts +441 -0
  105. package/src/codex/desired-state.ts +177 -0
  106. package/src/codex/features.ts +52 -8
  107. package/src/codex/generation.ts +202 -0
  108. package/src/codex/history-job.ts +257 -0
  109. package/src/codex/history-lock.ts +241 -0
  110. package/src/codex/history-migration-guardian.ts +18 -5
  111. package/src/codex/history-provider.ts +9 -2
  112. package/src/codex/history-transition.ts +105 -0
  113. package/src/codex/history-worker.ts +176 -0
  114. package/src/codex/inject-coordination.ts +245 -0
  115. package/src/codex/inject.ts +605 -124
  116. package/src/codex/integration-record.ts +266 -0
  117. package/src/codex/internal/catalog-writer.ts +203 -0
  118. package/src/codex/internal/history-writer.ts +80 -0
  119. package/src/codex/journal.ts +10 -1
  120. package/src/codex/main-account-cache.ts +24 -0
  121. package/src/codex/management-convergence.ts +114 -0
  122. package/src/codex/native-main-admission.ts +47 -0
  123. package/src/codex/native-main-auth-temp.ts +187 -0
  124. package/src/codex/native-main-claim.ts +167 -0
  125. package/src/codex/native-main-lock-file.ts +158 -0
  126. package/src/codex/native-main-owner.ts +315 -0
  127. package/src/codex/native-profile-api.ts +247 -0
  128. package/src/codex/native-profile-manager.ts +1512 -0
  129. package/src/codex/native-profile-processes.ts +121 -0
  130. package/src/codex/native-profile-recovery.ts +99 -0
  131. package/src/codex/native-profile-stage-store.ts +387 -0
  132. package/src/codex/native-profile-startup.ts +340 -0
  133. package/src/codex/native-profile-store.ts +855 -0
  134. package/src/codex/native-profile-types.ts +120 -0
  135. package/src/codex/native-residue.ts +557 -0
  136. package/src/codex/project-config-warnings.ts +18 -4
  137. package/src/codex/prompt-journal.ts +311 -0
  138. package/src/codex/prompt-layers.ts +967 -0
  139. package/src/codex/prompt-lock.ts +143 -0
  140. package/src/codex/quota-rejection.ts +224 -0
  141. package/src/codex/quota.ts +86 -3
  142. package/src/codex/routing.ts +299 -62
  143. package/src/codex/runtime.ts +159 -38
  144. package/src/codex/shim.ts +39 -13
  145. package/src/codex/subagent-model-fallback.ts +73 -12
  146. package/src/codex/transition-state.ts +604 -0
  147. package/src/codex/upstream-host-health.ts +70 -0
  148. package/src/codex/user-identity.ts +266 -0
  149. package/src/codex/write-coordination.ts +114 -0
  150. package/src/config.ts +562 -26
  151. package/src/generated/jawcode-model-metadata.ts +2 -2
  152. package/src/grok/inject.ts +15 -4
  153. package/src/grok/inspect.ts +45 -0
  154. package/src/images/loop.ts +113 -20
  155. package/src/integrations/config-io.ts +151 -0
  156. package/src/integrations/journal.ts +315 -0
  157. package/src/integrations/merge.ts +135 -0
  158. package/src/integrations/native/ownership-preflight.ts +165 -0
  159. package/src/integrations/ownership.ts +111 -0
  160. package/src/integrations/registry.ts +101 -0
  161. package/src/integrations/serialize.ts +235 -0
  162. package/src/integrations/state.ts +290 -0
  163. package/src/integrations/store.ts +103 -0
  164. package/src/integrations/writer.ts +492 -0
  165. package/src/lib/bounded-body.ts +46 -8
  166. package/src/lib/bun-runtime.ts +110 -1
  167. package/src/lib/bun-stream-caps.ts +2 -1
  168. package/src/lib/redact.ts +407 -2
  169. package/src/lib/shadow-call.ts +24 -0
  170. package/src/lib/translator-budget.ts +10 -0
  171. package/src/lib/upstream-reachability.ts +91 -0
  172. package/src/lib/upstream-retry.ts +154 -2
  173. package/src/lib/windows-secret-acl.ts +212 -11
  174. package/src/lib/winsw.ts +9 -3
  175. package/src/oauth/index.ts +61 -3
  176. package/src/oauth/key-providers.ts +4 -0
  177. package/src/oauth/kiro.ts +50 -6
  178. package/src/oauth/store.ts +31 -6
  179. package/src/oauth/token-guardian.ts +9 -3
  180. package/src/providers/codex-capacity.ts +288 -0
  181. package/src/providers/derive.ts +33 -1
  182. package/src/providers/free-directory.ts +3 -1
  183. package/src/providers/key-failover.ts +71 -3
  184. package/src/providers/openai-sidecar.ts +64 -4
  185. package/src/providers/openai-virtual-models.ts +1 -0
  186. package/src/providers/quota.ts +334 -26
  187. package/src/providers/registry.ts +284 -16
  188. package/src/providers/xai-transport.ts +11 -4
  189. package/src/responses/compaction.ts +8 -1
  190. package/src/responses/hosted-tool-policy.ts +9 -0
  191. package/src/responses/parser.ts +44 -2
  192. package/src/responses/reasoning-envelope.ts +9 -1
  193. package/src/responses/reasoning-replay-cache.ts +105 -0
  194. package/src/responses/spill-store.ts +45 -8
  195. package/src/responses/state.ts +161 -17
  196. package/src/router.ts +302 -16
  197. package/src/routing/analytics.ts +377 -0
  198. package/src/routing/capability.ts +204 -0
  199. package/src/routing/cost.ts +77 -0
  200. package/src/routing/evaluator.ts +444 -0
  201. package/src/routing/health.ts +401 -0
  202. package/src/routing/history/cursor.ts +43 -0
  203. package/src/routing/history/indexer.ts +590 -0
  204. package/src/routing/history/schema.ts +72 -0
  205. package/src/routing/profile.ts +423 -0
  206. package/src/routing/quota.ts +124 -0
  207. package/src/routing/request-evidence.ts +45 -0
  208. package/src/routing/trace.ts +686 -0
  209. package/src/server/auth-cors.ts +46 -6
  210. package/src/server/chat-completions.ts +28 -13
  211. package/src/server/claude-messages.ts +23 -15
  212. package/src/server/gui-static.ts +39 -10
  213. package/src/server/images.ts +10 -1
  214. package/src/server/index.ts +238 -52
  215. package/src/server/lifecycle.ts +228 -9
  216. package/src/server/live.ts +14 -3
  217. package/src/server/management/agent-settings-routes.ts +64 -14
  218. package/src/server/management/combo-routes.ts +5 -5
  219. package/src/server/management/config-routes.ts +1 -1
  220. package/src/server/management/context.ts +42 -2
  221. package/src/server/management/integration-routes.ts +538 -0
  222. package/src/server/management/logs-usage-routes.ts +1 -1
  223. package/src/server/management/model-routes.ts +32 -113
  224. package/src/server/management/model-rows.ts +117 -0
  225. package/src/server/management/native-integration-routes.ts +587 -0
  226. package/src/server/management/oauth-account-routes.ts +1 -1
  227. package/src/server/management/provider-routes.ts +218 -117
  228. package/src/server/management/request-history-routes.ts +191 -0
  229. package/src/server/management/routing-analytics-routes.ts +74 -0
  230. package/src/server/management/routing-profile-routes.ts +177 -0
  231. package/src/server/management/shared.ts +2 -2
  232. package/src/server/management/sidebar-routes.ts +47 -31
  233. package/src/server/management/sync-response.ts +69 -0
  234. package/src/server/management/system-restart.ts +276 -24
  235. package/src/server/management/system-routes.ts +4 -0
  236. package/src/server/management-api.ts +84 -9
  237. package/src/server/management-auth.ts +43 -5
  238. package/src/server/relay-eager.ts +82 -42
  239. package/src/server/relay.ts +120 -6
  240. package/src/server/request-log.ts +26 -6
  241. package/src/server/responses/collaboration.ts +63 -8
  242. package/src/server/responses/compact.ts +272 -41
  243. package/src/server/responses/core.ts +730 -132
  244. package/src/server/responses/fetch-helpers.ts +15 -1
  245. package/src/server/responses-item-id-repair.ts +32 -3
  246. package/src/server/responses-json-events.ts +52 -0
  247. package/src/server/responses-snapshot-repair.ts +621 -0
  248. package/src/server/search.ts +51 -6
  249. package/src/server/sse-payload-rewrite.ts +89 -12
  250. package/src/server/startup-health-cache.ts +7 -1
  251. package/src/server/ws-bridge.ts +11 -17
  252. package/src/service-manager-probe.ts +297 -0
  253. package/src/service.ts +222 -32
  254. package/src/tray/windows-tray.ps1 +9 -0
  255. package/src/tray/windows.ts +15 -7
  256. package/src/types.ts +194 -14
  257. package/src/update/index.ts +13 -13
  258. package/src/update/job.ts +24 -21
  259. package/src/update/notify.ts +7 -3
  260. package/src/usage/cost.ts +0 -0
  261. package/src/usage/expected-prices.ts +129 -10
  262. package/src/usage/log.ts +50 -15
  263. package/src/usage/summary.ts +4 -4
  264. package/src/vision/index.ts +6 -1
  265. package/src/web-search/loop.ts +161 -34
  266. package/gui/dist/assets/index-OY43ubAq.css +0 -1
  267. package/gui/dist/assets/index-YwNnKZcL.js +0 -67
  268. package/gui/dist/provider-icons/antigravity.svg +0 -1
  269. package/gui/dist/provider-icons/claude.svg +0 -1
  270. package/gui/dist/provider-icons/copilot.svg +0 -1
  271. package/gui/dist/provider-icons/cursor.svg +0 -2
  272. package/gui/dist/provider-icons/gemini.svg +0 -1
  273. package/gui/dist/provider-icons/grok-color.svg +0 -1
  274. package/gui/dist/provider-icons/kiro.svg +0 -14
@@ -0,0 +1,143 @@
1
+ /**
2
+ * prompt-lock.ts — advisory cross-process lock for prompt-layer mutations.
3
+ *
4
+ * An in-process mutex only serialises browser tabs behind one service. A CLI
5
+ * invocation, a second service, or a stale process can all reach the same
6
+ * files, so the lock lives on disk.
7
+ *
8
+ * STALE TAKEOVER IS A RENAME, NOT AN UNLINK. Naive breaking is racy: A judges
9
+ * the lock stale, B removes it and acquires its own, then A unlinks *B's live
10
+ * lock* and both proceed. Unlinking a path you did not verify is the bug. Here
11
+ * the contender renames the observed stale lock to a token-quarantined name —
12
+ * an atomic operation exactly one contender can win — and only the winner
13
+ * creates the real lock.
14
+ *
15
+ * RELEASE ONLY DELETES A LOCK WHOSE TOKEN IS STILL OURS. A mismatch means we
16
+ * were superseded, and deleting it would hand the critical section to two
17
+ * writers at once.
18
+ *
19
+ * This does NOT cover Codex, which knows nothing about our lock. That residual
20
+ * is handled by the per-target byte checks in the write path, and the rename
21
+ * window itself is documented as irreducible from user space.
22
+ */
23
+ import { existsSync, readFileSync, renameSync, unlinkSync, writeFileSync } from "node:fs";
24
+ import { randomBytes } from "node:crypto";
25
+
26
+ const FILE_MODE = 0o600;
27
+
28
+ /** A lock younger than this is respected even if its pid looks gone. */
29
+ export const STALE_AFTER_MS = 10_000;
30
+
31
+ export interface LockRecord {
32
+ token: string;
33
+ pid: number;
34
+ acquiredAt: number;
35
+ }
36
+
37
+ export interface LockHandle {
38
+ path: string;
39
+ token: string;
40
+ }
41
+
42
+ export type AcquireResult =
43
+ | { ok: true; handle: LockHandle }
44
+ | { ok: false; error: "locked" };
45
+
46
+ export interface LockDeps {
47
+ /** Injectable so tests can simulate a live or dead owner. */
48
+ isProcessAlive: (pid: number) => boolean;
49
+ now: () => number;
50
+ }
51
+
52
+ const defaultDeps: LockDeps = {
53
+ isProcessAlive: pid => {
54
+ try {
55
+ process.kill(pid, 0);
56
+ return true;
57
+ } catch (error) {
58
+ // EPERM means it exists but belongs to another user.
59
+ return (error as NodeJS.ErrnoException).code === "EPERM";
60
+ }
61
+ },
62
+ now: () => Date.now(),
63
+ };
64
+
65
+ function readRecord(path: string): LockRecord | null {
66
+ try {
67
+ const parsed = JSON.parse(readFileSync(path, "utf8")) as LockRecord;
68
+ if (typeof parsed?.token !== "string" || typeof parsed?.pid !== "number") return null;
69
+ return parsed;
70
+ } catch {
71
+ return null;
72
+ }
73
+ }
74
+
75
+ /** True when the holder is gone AND the lock is older than the grace window. */
76
+ function isStale(record: LockRecord | null, deps: LockDeps): boolean {
77
+ if (record === null) return true; // unparseable: treat as debris
78
+ if (deps.isProcessAlive(record.pid)) return false;
79
+ return deps.now() - record.acquiredAt > STALE_AFTER_MS;
80
+ }
81
+
82
+ /**
83
+ * One attempt. The caller decides whether to retry — a loser must go back to
84
+ * re-reading the lock rather than assuming its quarantine still applies.
85
+ */
86
+ export function tryAcquire(path: string, deps: LockDeps = defaultDeps): AcquireResult {
87
+ const token = randomBytes(8).toString("hex");
88
+ const record: LockRecord = { token, pid: process.pid, acquiredAt: deps.now() };
89
+ const body = JSON.stringify(record);
90
+
91
+ try {
92
+ writeFileSync(path, body, { encoding: "utf8", mode: FILE_MODE, flag: "wx" });
93
+ return { ok: true, handle: { path, token } };
94
+ } catch (error) {
95
+ if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error;
96
+ }
97
+
98
+ if (!isStale(readRecord(path), deps)) return { ok: false, error: "locked" };
99
+
100
+ // Quarantine by rename: atomic, and exactly one contender wins it.
101
+ const quarantine = `${path}.stale-${token}`;
102
+ try {
103
+ renameSync(path, quarantine);
104
+ } catch {
105
+ // Someone else won the rename, or the owner released between our checks.
106
+ // Either way we do NOT touch the path — retry from the top.
107
+ return { ok: false, error: "locked" };
108
+ }
109
+
110
+ try {
111
+ writeFileSync(path, body, { encoding: "utf8", mode: FILE_MODE, flag: "wx" });
112
+ } catch (error) {
113
+ // A successor acquired the real lock between our rename and this create.
114
+ // Its lock is live and is not ours to remove.
115
+ try { unlinkSync(quarantine); } catch { /* debris */ }
116
+ if ((error as NodeJS.ErrnoException).code === "EEXIST") return { ok: false, error: "locked" };
117
+ throw error;
118
+ }
119
+
120
+ try { unlinkSync(quarantine); } catch { /* debris */ }
121
+ return { ok: true, handle: { path, token } };
122
+ }
123
+
124
+ /**
125
+ * Release. Deletes nothing unless the on-disk token is still ours; returns false
126
+ * when we were superseded, which the caller surfaces as `write_superseded`.
127
+ */
128
+ export function release(handle: LockHandle): boolean {
129
+ const record = readRecord(handle.path);
130
+ if (record === null || record.token !== handle.token) return false;
131
+ try {
132
+ unlinkSync(handle.path);
133
+ return true;
134
+ } catch {
135
+ return false;
136
+ }
137
+ }
138
+
139
+ /** True when the on-disk lock is still the one this handle acquired. */
140
+ export function stillHeld(handle: LockHandle): boolean {
141
+ if (!existsSync(handle.path)) return false;
142
+ return readRecord(handle.path)?.token === handle.token;
143
+ }
@@ -0,0 +1,224 @@
1
+ import { readBoundedResponseBody } from "../lib/bounded-body";
2
+
3
+ const RESET_ELIGIBLE_CODE_VALUES = [
4
+ "usage_limit_exceeded",
5
+ "insufficient_quota",
6
+ ] as const;
7
+
8
+ export type CodexResetEligibleExhaustionCode =
9
+ (typeof RESET_ELIGIBLE_CODE_VALUES)[number];
10
+
11
+ export type CodexPreStreamRejectionKind =
12
+ | "reset-eligible-exhaustion"
13
+ | "generic-rate-limit"
14
+ | "unverified-billing-or-quota"
15
+ | "transient-server-error"
16
+ | "authentication-error"
17
+ | "permission-error"
18
+ | "other";
19
+
20
+ export interface CodexPreStreamRejection {
21
+ kind: CodexPreStreamRejectionKind;
22
+ status: number;
23
+ alternateRetryEligible: boolean;
24
+ resetCreditEligible: boolean;
25
+ semanticCode?: CodexResetEligibleExhaustionCode;
26
+ }
27
+
28
+ const RESET_ELIGIBLE_CODES: ReadonlySet<string> = new Set(RESET_ELIGIBLE_CODE_VALUES);
29
+
30
+ const TRANSIENT_SERVER_STATUSES = new Set([500, 502, 503, 504, 520, 521, 522]);
31
+ const JSON_NUMBER_PATTERN = /-?(?:0|[1-9]\d*)(?:\.\d+)?(?:[eE][+-]?\d+)?/y;
32
+
33
+ function rejection(
34
+ status: number,
35
+ kind: CodexPreStreamRejectionKind,
36
+ options: {
37
+ alternateRetryEligible?: boolean;
38
+ semanticCode?: CodexResetEligibleExhaustionCode;
39
+ } = {},
40
+ ): CodexPreStreamRejection {
41
+ return {
42
+ kind,
43
+ status,
44
+ alternateRetryEligible: options.alternateRetryEligible === true,
45
+ resetCreditEligible: options.semanticCode !== undefined,
46
+ ...(options.semanticCode ? { semanticCode: options.semanticCode } : {}),
47
+ };
48
+ }
49
+
50
+ function hasOwnField(container: Record<string, unknown>, field: string): boolean {
51
+ return Object.prototype.hasOwnProperty.call(container, field);
52
+ }
53
+
54
+ type JsonScanResult = {
55
+ next: number;
56
+ duplicate: boolean;
57
+ };
58
+
59
+ function skipJsonWhitespace(text: string, index: number): number {
60
+ while (index < text.length && /[\t\n\r ]/.test(text[index] ?? "")) index += 1;
61
+ return index;
62
+ }
63
+
64
+ function scanJsonStringEnd(text: string, index: number): number {
65
+ if (text[index] !== '"') throw new SyntaxError("expected JSON string");
66
+ for (let cursor = index + 1; cursor < text.length; cursor += 1) {
67
+ const char = text[cursor];
68
+ if (char === '"') return cursor + 1;
69
+ if (char === "\\") cursor += 1;
70
+ }
71
+ throw new SyntaxError("unterminated JSON string");
72
+ }
73
+
74
+ function scanJsonValue(text: string, index: number): JsonScanResult {
75
+ const start = skipJsonWhitespace(text, index);
76
+ if (text[start] === "{") return scanJsonObject(text, start);
77
+ if (text[start] === "[") return scanJsonArray(text, start);
78
+ if (text[start] === '"') return { next: scanJsonStringEnd(text, start), duplicate: false };
79
+
80
+ for (const literal of ["true", "false", "null"]) {
81
+ if (text.startsWith(literal, start)) {
82
+ return { next: start + literal.length, duplicate: false };
83
+ }
84
+ }
85
+ JSON_NUMBER_PATTERN.lastIndex = start;
86
+ const number = JSON_NUMBER_PATTERN.exec(text);
87
+ if (!number) throw new SyntaxError("expected JSON value");
88
+ return { next: start + number[0].length, duplicate: false };
89
+ }
90
+
91
+ function scanJsonObject(text: string, index: number): JsonScanResult {
92
+ const keys = new Set<string>();
93
+ let duplicate = false;
94
+ let cursor = skipJsonWhitespace(text, index + 1);
95
+ if (text[cursor] === "}") return { next: cursor + 1, duplicate: false };
96
+
97
+ while (cursor < text.length) {
98
+ const keyEnd = scanJsonStringEnd(text, cursor);
99
+ const key = JSON.parse(text.slice(cursor, keyEnd)) as unknown;
100
+ if (typeof key !== "string") throw new SyntaxError("invalid JSON object key");
101
+ if (keys.has(key)) duplicate = true;
102
+ keys.add(key);
103
+
104
+ cursor = skipJsonWhitespace(text, keyEnd);
105
+ if (text[cursor] !== ":") throw new SyntaxError("expected JSON object colon");
106
+ const value = scanJsonValue(text, cursor + 1);
107
+ duplicate ||= value.duplicate;
108
+ cursor = skipJsonWhitespace(text, value.next);
109
+ if (text[cursor] === "}") return { next: cursor + 1, duplicate };
110
+ if (text[cursor] !== ",") throw new SyntaxError("expected JSON object separator");
111
+ cursor = skipJsonWhitespace(text, cursor + 1);
112
+ }
113
+ throw new SyntaxError("unterminated JSON object");
114
+ }
115
+
116
+ function scanJsonArray(text: string, index: number): JsonScanResult {
117
+ let duplicate = false;
118
+ let cursor = skipJsonWhitespace(text, index + 1);
119
+ if (text[cursor] === "]") return { next: cursor + 1, duplicate: false };
120
+
121
+ while (cursor < text.length) {
122
+ const value = scanJsonValue(text, cursor);
123
+ duplicate ||= value.duplicate;
124
+ cursor = skipJsonWhitespace(text, value.next);
125
+ if (text[cursor] === "]") return { next: cursor + 1, duplicate };
126
+ if (text[cursor] !== ",") throw new SyntaxError("expected JSON array separator");
127
+ cursor = skipJsonWhitespace(text, cursor + 1);
128
+ }
129
+ throw new SyntaxError("unterminated JSON array");
130
+ }
131
+
132
+ function isUnsafeJsonDocument(text: string): boolean {
133
+ try {
134
+ const result = scanJsonValue(text, 0);
135
+ return result.duplicate || skipJsonWhitespace(text, result.next) !== text.length;
136
+ } catch {
137
+ // Scanner disagreement is untrusted input, just like JSON.parse failure.
138
+ return true;
139
+ }
140
+ }
141
+
142
+ function exactResetEligibleCode(
143
+ container: Record<string, unknown>,
144
+ ): CodexResetEligibleExhaustionCode | undefined {
145
+ const hasCode = hasOwnField(container, "code");
146
+ const hasType = hasOwnField(container, "type");
147
+ if (!hasCode && !hasType) return undefined;
148
+
149
+ const code = hasCode ? container.code : undefined;
150
+ const type = hasType ? container.type : undefined;
151
+ if ((hasCode && typeof code !== "string") || (hasType && typeof type !== "string")) {
152
+ return undefined;
153
+ }
154
+ if (hasCode && hasType && code !== type) return undefined;
155
+
156
+ const value = hasCode ? code : type;
157
+ if (typeof value !== "string") return undefined;
158
+ return RESET_ELIGIBLE_CODES.has(value as CodexResetEligibleExhaustionCode)
159
+ ? value as CodexResetEligibleExhaustionCode
160
+ : undefined;
161
+ }
162
+
163
+ function structuredResetEligibleCode(payload: unknown): CodexResetEligibleExhaustionCode | undefined {
164
+ if (!payload || typeof payload !== "object" || Array.isArray(payload)) return undefined;
165
+ const root = payload as Record<string, unknown>;
166
+ const hasRootDiscriminator = hasOwnField(root, "code") || hasOwnField(root, "type");
167
+
168
+ if (!hasOwnField(root, "error")) return exactResetEligibleCode(root);
169
+ if (hasRootDiscriminator) return undefined;
170
+
171
+ const nested = root.error;
172
+ if (!nested || typeof nested !== "object" || Array.isArray(nested)) return undefined;
173
+ return exactResetEligibleCode(nested as Record<string, unknown>);
174
+ }
175
+
176
+ async function resetEligibleCodeFromResponse(
177
+ response: Response,
178
+ signal?: AbortSignal,
179
+ ): Promise<CodexResetEligibleExhaustionCode | undefined> {
180
+ try {
181
+ const body = await readBoundedResponseBody(response.clone(), { signal, fatalUtf8: true });
182
+ if (!body.displaySafe || body.truncated || !body.text.trim()) return undefined;
183
+ const payload = JSON.parse(body.text) as unknown;
184
+ // JSON.parse silently keeps the last duplicate key, making contradictory
185
+ // payloads order-dependent. Reject any duplicate at any object depth.
186
+ if (isUnsafeJsonDocument(body.text)) return undefined;
187
+ return structuredResetEligibleCode(payload);
188
+ } catch {
189
+ // Classification must fail closed. A malformed, oversized, consumed, or
190
+ // cancelled body cannot authorize an irreversible reset-credit operation.
191
+ return undefined;
192
+ }
193
+ }
194
+
195
+ /**
196
+ * Classify an upstream Codex rejection before any response event is exposed.
197
+ *
198
+ * Only an exact structured exhaustion code on HTTP 429/402 is reset-eligible.
199
+ * Status alone and message text are intentionally insufficient. The broad
200
+ * alternate-account retry remains eligible for 429/402 to preserve #584.
201
+ */
202
+ export async function classifyCodexPreStreamRejection(
203
+ response: Response,
204
+ options: { signal?: AbortSignal } = {},
205
+ ): Promise<CodexPreStreamRejection> {
206
+ const status = response.status;
207
+ if (status === 401) return rejection(status, "authentication-error");
208
+ if (status === 403) return rejection(status, "permission-error");
209
+ if (TRANSIENT_SERVER_STATUSES.has(status)) return rejection(status, "transient-server-error");
210
+ if (status !== 429 && status !== 402) return rejection(status, "other");
211
+
212
+ const semanticCode = await resetEligibleCodeFromResponse(response, options.signal);
213
+ if (semanticCode) {
214
+ return rejection(status, "reset-eligible-exhaustion", {
215
+ alternateRetryEligible: true,
216
+ semanticCode,
217
+ });
218
+ }
219
+ return rejection(
220
+ status,
221
+ status === 429 ? "generic-rate-limit" : "unverified-billing-or-quota",
222
+ { alternateRetryEligible: true },
223
+ );
224
+ }
@@ -9,6 +9,14 @@ export type StoredAccountQuota = {
9
9
  weeklyResetAt?: number;
10
10
  monthlyResetAt?: number;
11
11
  resetCredits?: number;
12
+ /**
13
+ * True when `monthlyPercent` came from an explicitly-monthly PRIMARY window —
14
+ * i.e. it is the account's governing quota reading, not a supplementary
15
+ * tertiary window. Tertiary-only monthly data lands in the same field but says
16
+ * nothing about the weekly quota that actually gates a non-Go/Free account,
17
+ * so recovery must be able to tell the two apart (#967 audit).
18
+ */
19
+ monthlyIsPrimaryWindow?: boolean;
12
20
  updatedAt: number;
13
21
  };
14
22
 
@@ -67,8 +75,7 @@ export function isCodexQuotaExhausted(
67
75
  plan?: string | null,
68
76
  ): boolean {
69
77
  if (!quota) return false;
70
- const normalizedPlan = plan?.trim().toLowerCase();
71
- const values = normalizedPlan === "go" || normalizedPlan === "free"
78
+ const values = codexQuotaWindowForPlan(plan) === "monthly"
72
79
  ? [quota.monthlyPercent]
73
80
  : [quota.weeklyPercent, quota.monthlyPercent];
74
81
  return values.some(value => typeof value === "number"
@@ -76,6 +83,58 @@ export function isCodexQuotaExhausted(
76
83
  && value >= CODEX_EXHAUSTED_USAGE_PERCENT);
77
84
  }
78
85
 
86
+ /**
87
+ * Which usage window a plan reports in. This is the SINGLE rule shared by quota
88
+ * parsing, exhaustion, and recovery — they must not diverge.
89
+ *
90
+ * An allowlist of "known" plans was tried here and was wrong: the upstream model
91
+ * snapshot alone carries 21 distinct plan strings (`edu_plus`, `finserv`, `k12`,
92
+ * `quorum`, `self_serve_business_usage_based`, ...), and `CodexAccount.plan` is an
93
+ * unrestricted string, so any list is a list of the plans someone remembered.
94
+ * Twelve real plans would have been refused recovery and stayed cooled forever —
95
+ * the very defect this unit exists to fix, reintroduced as a typo-shaped hole.
96
+ *
97
+ * The honest rule is the parser's own: Go and Free report a 30-day window,
98
+ * everything else (including an absent plan) reports weekly. Recovery reads the
99
+ * window the parser actually wrote rather than second-guessing it.
100
+ */
101
+ export function codexQuotaWindowForPlan(plan?: string | null): "monthly" | "weekly" {
102
+ const normalized = plan?.trim().toLowerCase();
103
+ return normalized === "go" || normalized === "free" ? "monthly" : "weekly";
104
+ }
105
+
106
+ export function isCompleteCodexQuotaRecoverySnapshot(
107
+ quota: Pick<StoredAccountQuota, "weeklyPercent" | "monthlyPercent" | "monthlyIsPrimaryWindow"> | null,
108
+ plan?: string | null,
109
+ ): boolean {
110
+ if (!quota || isCodexQuotaExhausted(quota, plan)) return false;
111
+ // Recovery still fails closed on MISSING EVIDENCE — a credits-only or windowless payload
112
+ // carries no usage reading at all and must never clear a cooldown. What it does not do is
113
+ // fail closed on an unfamiliar plan NAME, which only ever meant "cooled forever".
114
+ //
115
+ // The parser classifies windows by DURATION, not by plan name: a Team response whose
116
+ // primary window is explicitly monthly parses to monthlyPercent only (no secondary
117
+ // window exists), so requiring weeklyPercent because the plan is not go/free would
118
+ // strand exactly those accounts until their predicted expiry. Accept whichever window(s)
119
+ // the parser actually wrote; Go/Free never carry a weekly value, so monthly-only is
120
+ // required there.
121
+ //
122
+ // Audit correction: "the parser wrote monthlyPercent" is NOT by itself evidence for a
123
+ // weekly-quota plan. A tertiary-only response also writes monthlyPercent, and it says
124
+ // nothing about the weekly quota that actually gates a Team/Plus account — accepting it
125
+ // would clear the cooldown on a reading of a different window. Only an explicitly-monthly
126
+ // PRIMARY window is the governing reading, which is what `monthlyIsPrimaryWindow` records.
127
+ if (codexQuotaWindowForPlan(plan) === "monthly") {
128
+ return finitePercent(quota.monthlyPercent);
129
+ }
130
+ if (finitePercent(quota.weeklyPercent)) return true;
131
+ return quota.monthlyIsPrimaryWindow === true && finitePercent(quota.monthlyPercent);
132
+ }
133
+
134
+ function finitePercent(value: number | undefined): boolean {
135
+ return typeof value === "number" && Number.isFinite(value);
136
+ }
137
+
79
138
  export function normalizeUsagePercent(value: unknown): number | undefined {
80
139
  const numeric = typeof value === "number"
81
140
  ? value
@@ -147,6 +206,7 @@ export function setAccountQuotaFromParsed(
147
206
  if (existing?.weeklyResetAt !== undefined) next.weeklyResetAt = existing.weeklyResetAt;
148
207
  if (existing?.monthlyPercent !== undefined) next.monthlyPercent = existing.monthlyPercent;
149
208
  if (existing?.monthlyResetAt !== undefined) next.monthlyResetAt = existing.monthlyResetAt;
209
+ if (existing?.monthlyIsPrimaryWindow === true) next.monthlyIsPrimaryWindow = true;
150
210
  next.resetCredits = quota.resetCredits;
151
211
  accountQuota.set(accountId, next);
152
212
  schedulePersistAccountQuotas();
@@ -166,9 +226,15 @@ export function setAccountQuotaFromParsed(
166
226
  if (snapshotHasMonthly(quota)) {
167
227
  if (quota.monthlyPercent !== undefined) next.monthlyPercent = quota.monthlyPercent;
168
228
  if (quota.monthlyResetAt !== undefined) next.monthlyResetAt = quota.monthlyResetAt;
229
+ // Carry the provenance with the value it describes. Recovery reads `freshQuota` directly,
230
+ // so this is not on its path today — but a cached snapshot that kept `monthlyPercent`
231
+ // while silently dropping `monthlyIsPrimaryWindow` would look like tertiary-only data to
232
+ // any future reader, and that failure would be invisible.
233
+ if (quota.monthlyIsPrimaryWindow === true) next.monthlyIsPrimaryWindow = true;
169
234
  } else if (snapshotHasWeekly(quota) && existing?.monthlyPercent !== undefined) {
170
235
  next.monthlyPercent = existing.monthlyPercent;
171
236
  if (existing.monthlyResetAt !== undefined) next.monthlyResetAt = existing.monthlyResetAt;
237
+ if (existing.monthlyIsPrimaryWindow === true) next.monthlyIsPrimaryWindow = true;
172
238
  }
173
239
 
174
240
  if (quota.resetCredits !== undefined) next.resetCredits = quota.resetCredits;
@@ -201,6 +267,10 @@ export function parseUpstreamQuotaHeaders(headers: Headers): Omit<StoredAccountQ
201
267
  if (primaryPercent !== undefined) {
202
268
  quota.monthlyPercent = primaryPercent;
203
269
  if (primaryResetAt !== undefined) quota.monthlyResetAt = primaryResetAt;
270
+ // Same provenance rule as parseUsageQuota(): this monthly value is the governing
271
+ // primary window, not a supplementary tertiary one. Not on the recovery path today,
272
+ // but the two parsers must agree on what a bare monthlyPercent means.
273
+ quota.monthlyIsPrimaryWindow = true;
204
274
  }
205
275
  if (secondaryPercent !== undefined) {
206
276
  quota.weeklyPercent = secondaryPercent;
@@ -253,6 +323,11 @@ export function updateAccountQuota(
253
323
  const quota: StoredAccountQuota = {
254
324
  ...(existing?.weeklyPercent !== undefined ? { weeklyPercent: existing.weeklyPercent } : {}),
255
325
  ...(existing?.monthlyPercent !== undefined ? { monthlyPercent: existing.monthlyPercent } : {}),
326
+ // Carry provenance with the value it describes. Dropping it here would downgrade a proven
327
+ // explicit-primary reading to "unproven" on the next unrelated weekly update.
328
+ ...(existing?.monthlyPercent !== undefined && existing.monthlyIsPrimaryWindow === true
329
+ ? { monthlyIsPrimaryWindow: true }
330
+ : {}),
256
331
  ...(existing?.weeklyResetAt !== undefined ? { weeklyResetAt: existing.weeklyResetAt } : {}),
257
332
  ...(existing?.monthlyResetAt !== undefined ? { monthlyResetAt: existing.monthlyResetAt } : {}),
258
333
  ...(existing?.resetCredits !== undefined ? { resetCredits: existing.resetCredits } : {}),
@@ -268,6 +343,10 @@ export function updateAccountQuota(
268
343
  if (nextMonthly !== undefined) {
269
344
  quota.monthlyPercent = nextMonthly;
270
345
  if (nextMonthlyResetAt !== undefined) quota.monthlyResetAt = nextMonthlyResetAt;
346
+ // A caller-supplied monthly value arrives without window provenance, so it REPLACES the
347
+ // proven reading and must not inherit its flag — otherwise an unproven number would be
348
+ // treated as governing evidence.
349
+ delete quota.monthlyIsPrimaryWindow;
271
350
  }
272
351
  if (resetCredits !== undefined) quota.resetCredits = resetCredits;
273
352
 
@@ -367,7 +446,7 @@ export function parseUsageQuota(data: WhamUsageResponse): Omit<StoredAccountQuot
367
446
  }
368
447
 
369
448
  const quota: Omit<StoredAccountQuota, "updatedAt"> = {};
370
- const thirtyDayOnly = data.plan_type?.trim().toLowerCase() === "go" || data.plan_type?.trim().toLowerCase() === "free";
449
+ const thirtyDayOnly = codexQuotaWindowForPlan(data.plan_type) === "monthly";
371
450
  const primaryWindow = data.rate_limit.primary_window;
372
451
  const secondaryWindow = data.rate_limit.secondary_window;
373
452
  const tertiaryWindow = data.rate_limit.tertiary_window;
@@ -404,6 +483,10 @@ export function parseUsageQuota(data: WhamUsageResponse): Omit<StoredAccountQuot
404
483
  if (!thirtyDayOnly && monthlyPercent !== undefined) {
405
484
  quota.monthlyPercent = monthlyPercent;
406
485
  if (monthlyResetAt !== undefined) quota.monthlyResetAt = monthlyResetAt;
486
+ // Record WHERE this reading came from. Only an explicitly-monthly primary window is the
487
+ // account's governing quota; a tertiary window lands in the same field but describes a
488
+ // different period, so recovery must not treat the two as interchangeable.
489
+ if (primaryIsMonthly && primaryPercent !== undefined) quota.monthlyIsPrimaryWindow = true;
407
490
  }
408
491
  if (resetCredits !== undefined) quota.resetCredits = resetCredits;
409
492