@bitkyc08/opencodex 2.58.0 → 2.60.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 (303) hide show
  1. package/README.md +28 -10
  2. package/gui/dist/assets/index-BTuCbqQd.css +1 -0
  3. package/gui/dist/assets/index-DoBVdPHP.js +134 -0
  4. package/gui/dist/index.html +2 -2
  5. package/gui/dist/provider-icons/crusoe.svg +1 -0
  6. package/gui/dist/provider-icons/opper.svg +3 -0
  7. package/package.json +4 -1
  8. package/src/adapters/anthropic-image-codec.ts +16 -2
  9. package/src/adapters/anthropic-image-normalize.ts +49 -2
  10. package/src/adapters/anthropic.ts +4 -1
  11. package/src/adapters/base.ts +34 -1
  12. package/src/adapters/coding-agent/turn.ts +22 -2
  13. package/src/adapters/command-code.ts +50 -3
  14. package/src/adapters/cursor/catalog.ts +11 -0
  15. package/src/adapters/cursor/checkpoint-store.ts +3 -0
  16. package/src/adapters/cursor/discovery.ts +11 -8
  17. package/src/adapters/cursor/effort-map.ts +16 -2
  18. package/src/adapters/cursor/envelope-echo.ts +55 -2
  19. package/src/adapters/cursor/live-transport.ts +26 -9
  20. package/src/adapters/cursor/message-mapper.ts +3 -2
  21. package/src/adapters/cursor/protobuf-request.ts +8 -5
  22. package/src/adapters/cursor/request-builder.ts +21 -4
  23. package/src/adapters/cursor/thread-continuity.ts +105 -31
  24. package/src/adapters/cursor/tool-guidance.ts +5 -4
  25. package/src/adapters/cursor/transport.ts +19 -0
  26. package/src/adapters/cursor.ts +45 -2
  27. package/src/adapters/devin/cloud-direct/chat.ts +14 -3
  28. package/src/adapters/devin/cloud-direct/index.ts +7 -0
  29. package/src/adapters/devin/cloud-direct/stated-reset-retry.ts +140 -0
  30. package/src/adapters/devin.ts +125 -26
  31. package/src/adapters/google-antigravity-replay.ts +1 -1
  32. package/src/adapters/google-antigravity-wire.ts +55 -4
  33. package/src/adapters/google-http.ts +57 -11
  34. package/src/adapters/google-tool-schema.ts +595 -31
  35. package/src/adapters/google-wire-compiler.ts +93 -10
  36. package/src/adapters/google-wire-shape.ts +461 -0
  37. package/src/adapters/google.ts +60 -10
  38. package/src/adapters/openai-chat/response-events.ts +61 -0
  39. package/src/adapters/openai-chat-images.ts +3 -1
  40. package/src/adapters/openai-chat.ts +10 -11
  41. package/src/adapters/openai-responses/image-gen.ts +8 -6
  42. package/src/adapters/openai-responses/passthrough.ts +25 -4
  43. package/src/adapters/openai-responses/reasoning.ts +7 -0
  44. package/src/adapters/openai-responses/tool-output-recovery.ts +75 -0
  45. package/src/adapters/openai-responses/tool-schema.ts +19 -7
  46. package/src/adapters/opencode-go-additional-tools.ts +12 -2
  47. package/src/adapters/responses-tool-schema.ts +76 -46
  48. package/src/adapters/run-turn-queue.ts +17 -4
  49. package/src/bridge/response-json.ts +1 -1
  50. package/src/bridge/sse.ts +28 -35
  51. package/src/claude/context-windows.ts +22 -0
  52. package/src/claude/outbound.ts +35 -4
  53. package/src/cli/account-api.ts +4 -3
  54. package/src/cli/account-extended.ts +26 -6
  55. package/src/cli/account-orca-import.ts +63 -0
  56. package/src/cli/account.ts +32 -4
  57. package/src/cli/capabilities.ts +40 -0
  58. package/src/cli/claude.ts +29 -1
  59. package/src/cli/codex-cli-update.ts +97 -2
  60. package/src/cli/dispatch.ts +57 -3
  61. package/src/cli/doctor.ts +218 -4
  62. package/src/cli/help.ts +4 -1
  63. package/src/cli/hub.ts +3 -2
  64. package/src/cli/index.ts +95 -22
  65. package/src/cli/models-runtime.ts +33 -4
  66. package/src/cli/opencode.ts +2 -2
  67. package/src/cli/provider.ts +13 -1
  68. package/src/cli/registry.ts +11 -1
  69. package/src/cli/runtime-api.ts +44 -0
  70. package/src/cli/start-args.ts +94 -0
  71. package/src/cli/system-command.ts +2 -0
  72. package/src/client/machine-api.ts +6 -5
  73. package/src/client/machine-listener.ts +16 -3
  74. package/src/client/runtime.ts +26 -2
  75. package/src/clients/config-export/constants.ts +2 -3
  76. package/src/clients/config-export.ts +5 -5
  77. package/src/codex/account-store.ts +146 -5
  78. package/src/codex/auth-api/account-list.ts +19 -11
  79. package/src/codex/auth-api/pool-quota-probe.ts +44 -10
  80. package/src/codex/auth-api/routes.ts +17 -2
  81. package/src/codex/auth-context.ts +16 -12
  82. package/src/codex/catalog/build-entries.ts +25 -4
  83. package/src/codex/catalog/derive-entry.ts +8 -1
  84. package/src/codex/catalog/effort.ts +10 -6
  85. package/src/codex/catalog/gather-capture.ts +22 -2
  86. package/src/codex/catalog/model-hints.ts +66 -33
  87. package/src/codex/catalog/parsing.ts +90 -5
  88. package/src/codex/catalog/provider-models.ts +19 -2
  89. package/src/codex/catalog/reserve-warn.ts +96 -0
  90. package/src/codex/catalog/retained-sync.ts +41 -26
  91. package/src/codex/catalog/routed-gather.ts +61 -3
  92. package/src/codex/cli-installation-identity.ts +210 -0
  93. package/src/codex/cli-installation-targets.ts +158 -0
  94. package/src/codex/context-compat.ts +5 -2
  95. package/src/codex/convergence.ts +5 -0
  96. package/src/codex/desired-state.ts +4 -1
  97. package/src/codex/history-job.ts +6 -6
  98. package/src/codex/history-provider.ts +24 -167
  99. package/src/codex/history-rollout-read.ts +174 -0
  100. package/src/codex/history-state-open.ts +105 -0
  101. package/src/codex/inject/config-toml.ts +44 -2
  102. package/src/codex/inject.ts +3 -2
  103. package/src/codex/internal/catalog-writer.ts +33 -1
  104. package/src/codex/lineage.ts +83 -32
  105. package/src/codex/loopback-target.ts +31 -0
  106. package/src/codex/main-account-hard-lock.ts +2 -1
  107. package/src/codex/main-account.ts +10 -3
  108. package/src/codex/main-device-reauth.ts +17 -9
  109. package/src/codex/model-cache.ts +47 -0
  110. package/src/codex/model-entitlements.ts +80 -2
  111. package/src/codex/observed-model-denials.ts +230 -0
  112. package/src/codex/orca-auth-source.ts +94 -0
  113. package/src/codex/orca-import.ts +219 -0
  114. package/src/codex/prompt-text-probe.ts +289 -16
  115. package/src/codex/quota-401-recovery.ts +12 -0
  116. package/src/codex/quota-types.ts +65 -0
  117. package/src/codex/quota.ts +24 -19
  118. package/src/codex/routing/cooldown-math.ts +8 -47
  119. package/src/codex/routing/pin-drain.ts +57 -0
  120. package/src/codex/routing.ts +20 -16
  121. package/src/codex/shim.ts +1 -1
  122. package/src/codex/subagent-model-fallback.ts +114 -2
  123. package/src/codex/windows-installation-files.ts +224 -0
  124. package/src/combos/failover.ts +125 -5
  125. package/src/config/admitted-identity.ts +222 -0
  126. package/src/config/diagnostics.ts +43 -1
  127. package/src/config/feature-flags.ts +5 -0
  128. package/src/config/load-degrade.ts +33 -0
  129. package/src/config/pending-teardown.ts +8 -0
  130. package/src/config/process-state.ts +36 -3
  131. package/src/config/provider-relative-send-path.ts +16 -0
  132. package/src/config/proxy-env.ts +31 -7
  133. package/src/config/schema/compaction-triggers.ts +11 -0
  134. package/src/config/schema/config-schema.ts +25 -0
  135. package/src/config/schema/leaf-validators.ts +76 -17
  136. package/src/config.ts +2 -2
  137. package/src/generated/compatibility-version.json +410 -258
  138. package/src/generated/model-metadata.ts +1 -1
  139. package/src/grok/reset-coupons.ts +38 -19
  140. package/src/images/loop.ts +6 -1
  141. package/src/integrations/aside-profile-context.ts +37 -3
  142. package/src/integrations/aside-profile-journal.ts +68 -3
  143. package/src/integrations/aside-profiles.ts +128 -3
  144. package/src/integrations/mutation-plan.ts +815 -0
  145. package/src/integrations/writer.ts +85 -99
  146. package/src/lab/live/transport.ts +4 -0
  147. package/src/lab/live/types.ts +5 -0
  148. package/src/lab/subject/behavior-fingerprint.ts +1 -1
  149. package/src/lib/admin-secrets.ts +9 -1
  150. package/src/lib/bounded-body.ts +4 -2
  151. package/src/lib/debug-log-buffer.ts +6 -1
  152. package/src/lib/debug.ts +23 -0
  153. package/src/lib/destination-policy.ts +48 -6
  154. package/src/lib/errors.ts +82 -15
  155. package/src/lib/http-response-semantics.ts +57 -0
  156. package/src/lib/lab-live-pinned-sender.ts +26 -12
  157. package/src/lib/local-destinations.ts +32 -5
  158. package/src/lib/pinned-http.ts +142 -2
  159. package/src/lib/plain-data.ts +103 -0
  160. package/src/lib/process-control.ts +13 -5
  161. package/src/lib/provider-outbound.ts +53 -5
  162. package/src/lib/proxy-env.ts +70 -3
  163. package/src/lib/request-execution-budget.ts +11 -3
  164. package/src/lib/response-body-inactivity.ts +193 -0
  165. package/src/lib/retry-delay.ts +69 -0
  166. package/src/lib/socks5-fetch.ts +741 -0
  167. package/src/lib/spend-ledger-owner.ts +364 -0
  168. package/src/lib/spend-reservation-ledger.ts +332 -35
  169. package/src/lib/windows-system-proxy.ts +16 -11
  170. package/src/lib/workflow-budget.ts +145 -8
  171. package/src/oauth/account-quota-rank.ts +72 -15
  172. package/src/oauth/callback-server.ts +4 -3
  173. package/src/oauth/generic-account-failover.ts +41 -27
  174. package/src/oauth/health.ts +12 -1
  175. package/src/oauth/index.ts +3 -107
  176. package/src/oauth/login-flow-state.ts +127 -0
  177. package/src/oauth/orcarouter.ts +15 -2
  178. package/src/oauth/store.ts +8 -0
  179. package/src/providers/codex-capacity.ts +9 -0
  180. package/src/providers/derive.ts +34 -17
  181. package/src/providers/devin-cli-authmode-migration.ts +14 -10
  182. package/src/providers/devin-provider-merge-migration.ts +33 -12
  183. package/src/providers/free-directory.ts +20 -2
  184. package/src/providers/key-failover.ts +327 -25
  185. package/src/providers/model-rename-migration.ts +56 -1
  186. package/src/providers/model-rename-startup.ts +7 -5
  187. package/src/providers/openai-sidecar.ts +4 -0
  188. package/src/providers/openai-virtual-models.ts +42 -2
  189. package/src/providers/opencode-go-transport.ts +14 -5
  190. package/src/providers/quota/antigravity.ts +22 -2
  191. package/src/providers/quota/report-cache.ts +3 -0
  192. package/src/providers/quota/vendor-probes-key.ts +1 -1
  193. package/src/providers/registry/entries-core.ts +39 -17
  194. package/src/providers/registry/entries-extended.ts +131 -1
  195. package/src/providers/registry/model-ids.ts +168 -0
  196. package/src/providers/registry/model-seeds.ts +87 -21
  197. package/src/providers/registry/types.ts +2 -0
  198. package/src/providers/resolved-model-policy-merge.ts +167 -0
  199. package/src/providers/resolved-model-policy.ts +406 -0
  200. package/src/providers/stale-vision-classification-migration.ts +137 -0
  201. package/src/responses/apply-patch-envelope.ts +32 -11
  202. package/src/responses/bridge-search-replay-cache.ts +152 -0
  203. package/src/responses/code-mode-helper-compat.ts +26 -16
  204. package/src/responses/custom-tool-compat.ts +1 -1
  205. package/src/responses/freeform-wrapper-scan.ts +279 -0
  206. package/src/responses/hosted-tool-policy.ts +85 -2
  207. package/src/responses/legacy-dotted-tool-name-repair.ts +134 -0
  208. package/src/responses/progressive-freeform-input.ts +130 -0
  209. package/src/responses/reasoning-envelope.ts +30 -0
  210. package/src/responses/schema.ts +9 -2
  211. package/src/responses/state.ts +5 -12
  212. package/src/responses/tool-name-aliases.ts +15 -1
  213. package/src/router.ts +91 -115
  214. package/src/routing/compatibility/behavior.ts +9 -0
  215. package/src/routing/compatibility/subject.ts +16 -1
  216. package/src/server/adapter-resolve.ts +9 -0
  217. package/src/server/auth-cors.ts +29 -0
  218. package/src/server/chat-completions.ts +13 -5
  219. package/src/server/chat-native-sse.ts +26 -9
  220. package/src/server/chat-native.ts +10 -4
  221. package/src/server/claude-messages.ts +30 -5
  222. package/src/server/effort-row.ts +11 -3
  223. package/src/server/grok-responses-control-frame.ts +160 -1
  224. package/src/server/gui-static.ts +36 -2
  225. package/src/server/inbound-body-admission.ts +187 -0
  226. package/src/server/index/serve-options.ts +86 -29
  227. package/src/server/index/spend-ledger-lifecycle.ts +66 -0
  228. package/src/server/index/websocket-handler.ts +6 -1
  229. package/src/server/index.ts +24 -25
  230. package/src/server/management/api-access.ts +3 -4
  231. package/src/server/management/aside-profile-routes.ts +266 -7
  232. package/src/server/management/config-routes.ts +48 -7
  233. package/src/server/management/context.ts +3 -0
  234. package/src/server/management/integration-routes.ts +287 -5
  235. package/src/server/management/metrics-routes.ts +20 -0
  236. package/src/server/management/model-rows.ts +224 -12
  237. package/src/server/management/provider-capability-config.ts +35 -7
  238. package/src/server/management/provider-routes.ts +70 -18
  239. package/src/server/management/route-registry.ts +14 -0
  240. package/src/server/management/shared.ts +10 -3
  241. package/src/server/management/system-restart.ts +7 -2
  242. package/src/server/management/system-routes.ts +2 -0
  243. package/src/server/management/usage-aggregate-cache.ts +4 -0
  244. package/src/server/management-api.ts +2 -0
  245. package/src/server/management-auth.ts +15 -1
  246. package/src/server/proxy-liveness.ts +97 -2
  247. package/src/server/readiness.ts +29 -10
  248. package/src/server/relay-eager.ts +24 -2
  249. package/src/server/relay.ts +136 -34
  250. package/src/server/request-log.ts +80 -3
  251. package/src/server/request-metrics.ts +236 -0
  252. package/src/server/responses/adapter-continuation.ts +74 -30
  253. package/src/server/responses/adapter-delivery.ts +39 -8
  254. package/src/server/responses/adapter-dispatch.ts +63 -30
  255. package/src/server/responses/compact.ts +89 -18
  256. package/src/server/responses/compaction-routing.ts +111 -0
  257. package/src/server/responses/core-codex-account.ts +90 -24
  258. package/src/server/responses/core-combo.ts +7 -7
  259. package/src/server/responses/core-normalize.ts +16 -15
  260. package/src/server/responses/core-opaque-recovery.ts +1 -0
  261. package/src/server/responses/core-options.ts +4 -0
  262. package/src/server/responses/encrypted-payload.ts +20 -2
  263. package/src/server/responses/fetch-helpers.ts +68 -2
  264. package/src/server/responses/passthrough-delivery.ts +41 -15
  265. package/src/server/responses/passthrough-dispatch.ts +145 -53
  266. package/src/server/responses/passthrough-execution.ts +11 -1
  267. package/src/server/responses/policy-fallback.ts +5 -13
  268. package/src/server/responses/request-prepare.ts +96 -17
  269. package/src/server/responses/request-send-budget.ts +89 -8
  270. package/src/server/responses/request-sidecar-auth.ts +17 -9
  271. package/src/server/responses/request-spend.ts +38 -9
  272. package/src/server/responses/request-transport.ts +15 -12
  273. package/src/server/responses/run-turn-execution.ts +45 -8
  274. package/src/server/responses/sidecar-execution.ts +19 -2
  275. package/src/server/responses/ws-upstream.ts +16 -28
  276. package/src/server/responses-custom-tool-repair.ts +29 -56
  277. package/src/server/responses-undeclared-tool-guard.ts +31 -1
  278. package/src/server/sse-frame-buffer.ts +12 -10
  279. package/src/server/sse-payload-rewrite.ts +37 -10
  280. package/src/server/system-env-shell.ts +5 -1
  281. package/src/server/system-env.ts +7 -1
  282. package/src/server/workflow-refusal.ts +56 -2
  283. package/src/service/cli.ts +16 -6
  284. package/src/service/guards.ts +10 -0
  285. package/src/service/health.ts +43 -0
  286. package/src/service/state.ts +7 -2
  287. package/src/tray/windows-tray.ps1 +155 -3
  288. package/src/types/accounts.ts +4 -0
  289. package/src/types/config.ts +111 -6
  290. package/src/types/provider.ts +37 -0
  291. package/src/types/request.ts +9 -1
  292. package/src/types/tools.ts +14 -0
  293. package/src/types/wire.ts +9 -1
  294. package/src/types.ts +1 -0
  295. package/src/usage/expected-prices.ts +28 -0
  296. package/src/usage/log.ts +87 -4
  297. package/src/vision/eligibility.ts +88 -9
  298. package/src/vision/plan.ts +34 -10
  299. package/src/web-search/executor.ts +41 -2
  300. package/src/web-search/loop.ts +6 -1
  301. package/src/web-search/passthrough-bridge.ts +39 -5
  302. package/gui/dist/assets/index-BbrHOIY0.js +0 -128
  303. package/gui/dist/assets/index-C5-RdDmD.css +0 -1
@@ -1,4 +1,69 @@
1
1
  /** Quota wire/storage shapes. This leaf must not import credential or config owners. */
2
+ export const MAIN_ACCOUNT_HARD_LOCK_PERCENT = 99;
3
+
4
+ /**
5
+ * How recently a 100% burst reading must have been observed to exclude an account when it
6
+ * carries no reset timestamp (#3425). This is deliberately far tighter than the disk-hydration
7
+ * horizon so a persisted reading cannot strand a recovered account. Routing and UI share this
8
+ * value because both must answer whether the same short-window observation is still current.
9
+ */
10
+ export const TERMINAL_SHORT_WINDOW_FRESHNESS_MS = 5 * 60_000;
11
+
12
+ /**
13
+ * A window reading at or above this is a measured refusal, not a position on a scale.
14
+ *
15
+ * Separate from `CODEX_UNKNOWN_USAGE_SCORE` because they mean opposite things: unknown is
16
+ * "we have not observed this account", 100 is "we observed it and it is full". It lives on
17
+ * this leaf, next to the freshness window, because the dashboard has to answer the same
18
+ * question and cannot import the routing or disk-cache owners to do it.
19
+ */
20
+ export const CODEX_EXHAUSTED_USAGE_PERCENT = 100;
21
+
22
+ /**
23
+ * Above this a value is already milliseconds; at or below it, it is Unix seconds.
24
+ *
25
+ * Both reach storage, so the split has to live somewhere every reader can see. It lives on this
26
+ * leaf rather than beside the merge that uses it because the dashboard reads the same stored
27
+ * value and cannot import the disk-cache owner.
28
+ */
29
+ const RESET_AT_SECONDS_MAX = 10_000_000_000;
30
+
31
+ /** Normalize a stored reset instant to milliseconds. */
32
+ export function resetAtToMs(resetAt: number): number {
33
+ return resetAt < RESET_AT_SECONDS_MAX ? resetAt * 1000 : resetAt;
34
+ }
35
+
36
+ /**
37
+ * A short-only reading that proves the account is blocked NOW.
38
+ *
39
+ * Routing and the dashboard's account-switch warning must answer this identically for the same
40
+ * snapshot, or the warning tells an operator an account is usable while the router refuses it.
41
+ * They did not: the dashboard compared a stored reset against `Date.now()` without normalizing
42
+ * units, so a seconds-form future reset looked expired there and live here, and it treated a
43
+ * fresh observation as sufficient even when an ELAPSED reset was present, where routing treats
44
+ * the reset as authoritative once it exists (#5045).
45
+ *
46
+ * Freshness is not optional in the reset-less branch. `getAccountQuota` performs no expiry
47
+ * check, partial updates carry a still-open short tuple forward, and disk hydration accepts a
48
+ * persisted reading for hours, so scoring exhausted from `shortPercent` alone would keep
49
+ * excluding an account whose burst window has since reset.
50
+ */
51
+ export function isTerminalShortWindow(
52
+ quota: Pick<StoredAccountQuota, "shortPercent" | "shortResetAt" | "shortObservedAt">,
53
+ now: number,
54
+ ): boolean {
55
+ const finite = (value: unknown): value is number => typeof value === "number" && Number.isFinite(value);
56
+ if (!finite(quota.shortPercent) || quota.shortPercent < CODEX_EXHAUSTED_USAGE_PERCENT) return false;
57
+ const resetAt = quota.shortResetAt;
58
+ if (!finite(resetAt) || resetAt <= 0) {
59
+ const observedAt = quota.shortObservedAt;
60
+ if (!finite(observedAt)) return false;
61
+ const age = now - observedAt;
62
+ return age >= 0 && age <= TERMINAL_SHORT_WINDOW_FRESHNESS_MS;
63
+ }
64
+ return resetAtToMs(resetAt) > now;
65
+ }
66
+
2
67
  export type StoredAccountQuota = {
3
68
  weeklyPercent?: number;
4
69
  monthlyPercent?: number;
@@ -8,8 +8,11 @@ import { getObservedMainQuotaIdentityKey, isMainQuotaWriterLive, type MainQuotaW
8
8
 
9
9
  import { CodexQuotaHistory, QUOTA_HISTORY_LIMITS, type QuotaHistoryWindow } from "./quota-history";
10
10
  import { isPoolQuotaWriterLive, poolQuotaHistoryIdentity } from "./account-store";
11
+ import { CODEX_EXHAUSTED_USAGE_PERCENT, MAIN_ACCOUNT_HARD_LOCK_PERCENT, resetAtToMs } from "./quota-types";
11
12
  import type { PoolQuotaWriter, StoredAccountQuota, WhamUsageResponse, WhamUsageWindow } from "./quota-types";
13
+
12
14
  export type { StoredAccountQuota, WhamUsageResponse } from "./quota-types";
15
+ export { CODEX_EXHAUSTED_USAGE_PERCENT } from "./quota-types";
13
16
 
14
17
  /** Disk snapshot: quota, private non-secret publication UUIDs and policy identity; never token-derived fingerprints. */
15
18
  const QUOTA_CACHE_FILENAME = "codex-quota-cache.json";
@@ -54,14 +57,13 @@ const WEEKLY_WINDOW_MIN_MINUTES = WEEKLY_WINDOW_MIN_SECONDS / 60;
54
57
  * Both units reach storage — `normalizeResetAt` does not scale, and the GUI disambiguates by
55
58
  * magnitude at read time — so a comparison written against one assumption is off by 1000x
56
59
  * against the other. In the seconds-read-as-milliseconds direction every reading looks like it
57
- * elapsed in 1970, which is a check that passes its own test and does nothing. Exported so
58
- * `isTerminalShortWindow` in routing.ts shares this one split instead of repeating the literal.
60
+ * elapsed in 1970, which is a check that passes its own test and does nothing.
61
+ *
62
+ * The split now lives on `./quota-types`, the leaf the dashboard can also import, because the
63
+ * dashboard was the reader that did not have it (#5045). Re-exported here so the existing
64
+ * callers of this module keep their import path.
59
65
  */
60
- const RESET_AT_SECONDS_MAX = 10_000_000_000;
61
-
62
- export function resetAtToMs(resetAt: number): number {
63
- return resetAt < RESET_AT_SECONDS_MAX ? resetAt * 1000 : resetAt;
64
- }
66
+ export { resetAtToMs };
65
67
 
66
68
  const accountQuota = new Map<string, StoredAccountQuota>();
67
69
  const quotaHistory = new CodexQuotaHistory();
@@ -75,14 +77,6 @@ function mayCommitAccountQuota(accountId: string, writerGeneration: number): boo
75
77
  // Valid upstream percentages are normalized to 0..100. Keep "unknown" outside that domain so an
76
78
  // actually exhausted account is still eligible for threshold rotation.
77
79
  export const CODEX_UNKNOWN_USAGE_SCORE = 101;
78
- /**
79
- * A window reading at or above this is a measured refusal, not a position on a scale.
80
- *
81
- * Separate from `CODEX_UNKNOWN_USAGE_SCORE` because they mean opposite things: unknown is
82
- * "we have not observed this account", 100 is "we observed it and it is full".
83
- */
84
- export const CODEX_EXHAUSTED_USAGE_PERCENT = 100;
85
-
86
80
  export function isCodexQuotaExhausted(
87
81
  quota: Pick<StoredAccountQuota, "weeklyPercent" | "monthlyPercent" | "shortPercent"> | null,
88
82
  plan?: unknown,
@@ -146,7 +140,7 @@ export function isCompleteCodexQuotaRecoverySnapshot(
146
140
  return quota.monthlyIsPrimaryWindow === true && finitePercent(quota.monthlyPercent);
147
141
  }
148
142
 
149
- function finitePercent(value: number | undefined): boolean {
143
+ function finitePercent(value: number | undefined): value is number {
150
144
  return typeof value === "number" && Number.isFinite(value);
151
145
  }
152
146
 
@@ -245,14 +239,20 @@ function shortResetHasElapsed(resetAt: number | undefined, now: number): boolean
245
239
  return resetAtToMs(resetAt) <= now;
246
240
  }
247
241
 
248
- /** Display/rotation carry expires; a reset clock cannot retract hard-lock evidence. */
242
+ /** Display/rotation carry expires; a reset clock cannot retract blocking hard-lock evidence. */
249
243
  function assignCarriedShort(
250
244
  next: StoredAccountQuota,
251
245
  existing: StoredAccountQuota | undefined,
252
246
  now: number,
253
247
  policyEvidence = false,
254
248
  ): void {
255
- if (!existing || (!policyEvidence && shortResetHasElapsed(existing.shortResetAt, now))) return;
249
+ if (!existing) return;
250
+ const existingShortPercent = existing.shortPercent;
251
+ const preserveBlockingEvidence = policyEvidence
252
+ && finitePercent(existingShortPercent)
253
+ && existingShortPercent >= MAIN_ACCOUNT_HARD_LOCK_PERCENT
254
+ && existingShortPercent <= 100;
255
+ if (!preserveBlockingEvidence && shortResetHasElapsed(existing.shortResetAt, now)) return;
256
256
  if (existing.shortPercent !== undefined) next.shortPercent = existing.shortPercent;
257
257
  if (existing.shortObservedAt !== undefined) next.shortObservedAt = existing.shortObservedAt;
258
258
  if (existing.shortResetAt !== undefined) next.shortResetAt = existing.shortResetAt;
@@ -363,7 +363,12 @@ function mergeAccountQuota(
363
363
  if (existing.monthlyIsPrimaryWindow === true) next.monthlyIsPrimaryWindow = true;
364
364
  }
365
365
 
366
- const preserveKnownShort = policyEvidence && quota.shortPercent === undefined && finitePercent(existing?.shortPercent);
366
+ const existingShortPercent = existing?.shortPercent;
367
+ const preserveKnownShort = policyEvidence
368
+ && quota.shortPercent === undefined
369
+ && finitePercent(existingShortPercent)
370
+ && existingShortPercent >= MAIN_ACCOUNT_HARD_LOCK_PERCENT
371
+ && existingShortPercent <= 100;
367
372
  if (snapshotHasShort(quota) && !preserveKnownShort) {
368
373
  if (quota.shortPercent !== undefined) {
369
374
  next.shortPercent = quota.shortPercent;
@@ -1,12 +1,14 @@
1
1
  import {
2
2
  CODEX_EXHAUSTED_USAGE_PERCENT,
3
3
  CODEX_UNKNOWN_USAGE_SCORE,
4
- resetAtToMs,
5
4
  } from "../quota";
6
5
  import { isThirtyDayOnlyCodexPlan } from "../plan";
6
+ import { isTerminalShortWindow } from "../quota-types";
7
7
  import type { CodexQuotaScope } from "./health-store";
8
8
  import type { TransientProbeGrant } from "./thread-affinity";
9
9
 
10
+ export { TERMINAL_SHORT_WINDOW_FRESHNESS_MS } from "../quota-types";
11
+
10
12
  export const CODEX_DEFAULT_QUOTA_COOLDOWN_MS = 60_000;
11
13
  export const CODEX_MAX_QUOTA_COOLDOWN_MS = 24 * 60 * 60_000;
12
14
  /**
@@ -25,14 +27,6 @@ export const CODEX_MAX_QUOTA_AVOID_MS = 6 * 60 * 60_000;
25
27
  /** Minimum gap between probe leases for one cooled-down account. */
26
28
  export const CODEX_QUOTA_PROBE_INTERVAL_MS = 5 * 60_000;
27
29
  export const CODEX_FAILURE_WINDOW_MS = 5 * 60_000;
28
- /**
29
- * How recently a 100% burst reading must have been OBSERVED to exclude an account when it
30
- * carries no reset timestamp (#3425). Deliberately far tighter than the 6h disk-hydration
31
- * horizon in `quota.ts`: shorter than any plausible five-hour burst window, so a persisted
32
- * reading can never strand a recovered account, and long enough that a snapshot taken at
33
- * admission is still fresh when selection reads it.
34
- */
35
- export const TERMINAL_SHORT_WINDOW_FRESHNESS_MS = 5 * 60_000;
36
30
  /** How long a transient failure keeps the account out of pool selection. */
37
31
  export const CODEX_TRANSIENT_SOFT_AVOID_MS = 30_000;
38
32
  export const CODEX_TRANSIENT_SOFT_AVOID_ESCALATION_MS = [
@@ -138,44 +132,11 @@ export function computeCodexUsageScore(quota: {
138
132
  return Math.max(...values);
139
133
  }
140
134
 
141
- /**
142
- * A short-only reading that proves the account is blocked NOW.
143
- *
144
- * Freshness is not optional. `getAccountQuota` performs no expiry check, partial updates
145
- * carry a still-open short tuple forward, and disk hydration accepts a persisted reading for
146
- * hours — so scoring 100 from `shortPercent` alone would keep excluding an account whose
147
- * five-hour window has since reset. Merge no longer carries an elapsed shortResetAt, but an
148
- * explicit incoming elapsed tuple is still stored, and a missing reset cannot be aged there.
149
- * That is #3029 pointed the other way: the issue is that
150
- * an exhausted account stays selected, and "a recovered account stays excluded" trades one
151
- * unusable pool for another.
152
- *
153
- * A reading with no `shortResetAt` cannot be aged, so it stays unknown. The conservative
154
- * direction here is the one that keeps an account selectable: a wrongly-selected account
155
- * fails one request, while a wrongly-excluded one is invisible until someone reads the pool
156
- * by hand.
157
- *
158
- * A missing reset can instead be aged by shortObservedAt (#3425). General updatedAt is not
159
- * sufficient: credit-only updates preserve the old short tuple but advance that timestamp.
160
- * Old disk snapshots without short-window provenance remain unknown.
161
- */
162
- function isTerminalShortWindow(
163
- quota: { shortPercent?: number; shortResetAt?: number; shortObservedAt?: number },
164
- now: number,
165
- ): boolean {
166
- if (typeof quota.shortPercent !== "number" || !Number.isFinite(quota.shortPercent)) return false;
167
- if (quota.shortPercent < CODEX_EXHAUSTED_USAGE_PERCENT) return false;
168
- const resetAt = quota.shortResetAt;
169
- if (typeof resetAt !== "number" || !Number.isFinite(resetAt) || resetAt <= 0) {
170
- const observedAt = quota.shortObservedAt;
171
- if (typeof observedAt !== "number" || !Number.isFinite(observedAt)) return false;
172
- const age = now - observedAt;
173
- return age >= 0 && age <= TERMINAL_SHORT_WINDOW_FRESHNESS_MS;
174
- }
175
- // Seconds and milliseconds both reach storage, so the split lives in one place next to the
176
- // merge that also ages a stored reset instant (`resetAtToMs`, src/codex/quota.ts).
177
- return resetAtToMs(resetAt) > now;
178
- }
135
+ // `isTerminalShortWindow` moved to ../quota-types, the leaf the dashboard can import. Routing
136
+ // and the account-switch warning have to answer this identically for the same snapshot, and
137
+ // they did not: see the note on the shared function (#5045). Its #3029 and #3425 reasoning —
138
+ // why freshness is not optional, and why a reading with no reset and no observation stays
139
+ // unknown rather than exhausted — moved with it.
179
140
 
180
141
  export function classifyCodexUpstreamOutcome(
181
142
  outcome: CodexUpstreamOutcome,
@@ -0,0 +1,57 @@
1
+ import type { OcxConfig } from "../../types";
2
+ import { isCodexAccountPaused } from "../account-pause";
3
+ import { isAccountNeedsReauth } from "../account-runtime-state";
4
+ import type { CodexAccountUsabilityOptions } from "../account-usability";
5
+ import { isCodexAccountUsable } from "../account-usability";
6
+ import { MAIN_CODEX_ACCOUNT_ID } from "../main-account";
7
+ import { hasCodexQuotaHeadroom } from "./selection";
8
+
9
+ /** Why routing would drop a manual pin, or undefined when the pin survives. */
10
+ export type CodexPinDrainReason = "needs_reauth" | "paused" | "unusable" | "quota_threshold";
11
+
12
+ /**
13
+ * Would routing release a pin on this account the next time it resolves?
14
+ *
15
+ * This lives beside selection rather than inside `routing.ts` for the same reason
16
+ * {@link ./cache-affinity} does: it is a policy question asked by two callers that must answer
17
+ * identically. One is `releaseDrainedCodexAccountPin`, which acts on it. The other is
18
+ * `PUT /api/codex-auth/active`, which reports it.
19
+ *
20
+ * The two-step contradiction in #4521 is what happens when only the first exists. That route
21
+ * validates existence, pause and pending validation, answers 200, and says nothing about quota;
22
+ * the very next resolve runs this rule and drops the pin. The operator sees a setting accepted
23
+ * and then ignored. Reporting it from the same predicate, rather than from a second copy on the
24
+ * accepting surface, is what keeps the answer and the action from drifting -- a client-side
25
+ * re-derivation of the usage score has to track {@link ./cooldown-math} exactly, including the
26
+ * Free/Go plan windows and short-window freshness.
27
+ *
28
+ * Ordering is load-bearing. Cached reauth and configured pause are classified FIRST, because
29
+ * they hold for the main account even while its fenced native profile is unreadable; a
30
+ * selection-only caller then makes every later classification answer "no drain", so reading
31
+ * reauth after that guard would make a pin on a signed-out main look durable.
32
+ *
33
+ * This answers only whether the pin survives. It is not an admission check: the caller that
34
+ * acts on it releases a preference, and every involuntary release -- quota refusal, failover
35
+ * streak, cooldown, lost generation, affinity expiry -- is decided elsewhere and earlier.
36
+ */
37
+ export function codexAccountPinDrainReason(
38
+ config: OcxConfig,
39
+ accountId: string,
40
+ selectionOptions?: Pick<
41
+ CodexAccountUsabilityOptions,
42
+ "nativeMainSelectionOnly" | "isMainAccountTokenLive"
43
+ >,
44
+ now: number = Date.now(),
45
+ ): CodexPinDrainReason | undefined {
46
+ if (isAccountNeedsReauth(accountId)) return "needs_reauth";
47
+ if (isCodexAccountPaused(config, accountId)) return "paused";
48
+ // Temporary drain deliberately forbids every native-main read. A pin on main cannot be
49
+ // classified by credential liveness or quota until the fenced profile is readable. Cached
50
+ // reauth and configured pause state were handled above.
51
+ if (accountId === MAIN_CODEX_ACCOUNT_ID && selectionOptions?.nativeMainSelectionOnly === true) {
52
+ return undefined;
53
+ }
54
+ if (!isCodexAccountUsable(config, accountId, selectionOptions)) return "unusable";
55
+ if (!hasCodexQuotaHeadroom(config, accountId, selectionOptions, now)) return "quota_threshold";
56
+ return undefined;
57
+ }
@@ -1,10 +1,12 @@
1
1
  import { saveConfigPreservingClaudeCode } from "../config";
2
- import { isCodexAccountGenerationLive } from "./account-store";
2
+ import { isCodexAccountGenerationLive, registerCodexRefreshGenerationHandoff } from "./account-store";
3
+ import { handOffThreadAffinityGeneration } from "./routing/thread-affinity";
3
4
  import { codexAccountLogLabel } from "./account-label";
4
5
  import { isCodexAccountPaused } from "./account-pause";
5
6
  import { clearCodexAccountPin, pinnedCodexAccountId } from "./account-priority";
6
7
  import { isCodexAccountUsable, type CodexAccountUsabilityOptions } from "./account-usability";
7
- import { isAccountNeedsReauth, markAccountNeedsReauth } from "./account-runtime-state";
8
+ import { markAccountNeedsReauth } from "./account-runtime-state";
9
+ import { codexAccountPinDrainReason } from "./routing/pin-drain";
8
10
  import { POOL_KEY_CODEX, notePoolRotationFailure } from "./pool-rotation";
9
11
  import { getAccountQuota, isRetiredCodexSparkModel } from "./quota";
10
12
  import { MAIN_CODEX_ACCOUNT_ID } from "./main-account";
@@ -202,6 +204,13 @@ export {
202
204
  getEffectiveActiveCodexAccountId,
203
205
  isEffectiveCodexAccountPinned,
204
206
  } from "./routing/active-account";
207
+ export { codexAccountPinDrainReason } from "./routing/pin-drain";
208
+ export type { CodexPinDrainReason } from "./routing/pin-drain";
209
+
210
+ // A shared refresh can outlive the request that opened it. Register the affinity
211
+ // handoff with the flight so a detached G -> G+1 commit cannot strand bindings at G.
212
+ registerCodexRefreshGenerationHandoff(handOffThreadAffinityGeneration);
213
+
205
214
  function hasConfiguredPoolAccount(
206
215
  config: OcxConfig,
207
216
  accountId: string,
@@ -392,7 +401,14 @@ function pickLineageServingAccount(
392
401
  selectionOptions?: CodexAccountUsabilityOptions,
393
402
  modelId?: string,
394
403
  ): { accountId: string; reason: CodexAffinityReason } | null {
395
- if (lineage.parentConversationKey !== undefined) {
404
+ // Cohort keying (#4780) makes a tree share one key, so for a same-session family the parent's
405
+ // key IS this request's and the lookup below would re-ask a question the caller already
406
+ // answered by finding no binding entry. What remains is the case cohort keying cannot unify:
407
+ // a session-less chain whose parent this scope has not recorded, where the keys differ.
408
+ if (
409
+ lineage.parentConversationKey !== undefined
410
+ && lineage.parentConversationKey !== lineage.conversationKey
411
+ ) {
396
412
  const parent = lineageServingAccountId(
397
413
  lineage.parentConversationKey, config, now, quotaScope, selectionOptions, modelId,
398
414
  );
@@ -455,19 +471,7 @@ function releaseDrainedCodexAccountPin(
455
471
  ): void {
456
472
  const pinned = pinnedCodexAccountId(config);
457
473
  if (pinned === undefined) return;
458
- const knownUnavailable = isAccountNeedsReauth(pinned) || isCodexAccountPaused(config, pinned);
459
- if (knownUnavailable) {
460
- clearCodexAccountPin(config);
461
- saveConfigPreservingClaudeCode(config);
462
- return;
463
- }
464
- // Temporary drain deliberately forbids every native-main read. A pin on main
465
- // cannot be classified by credential liveness or quota until the fenced profile
466
- // is readable. Cached reauth and configured pause state were handled above.
467
- if (pinned === MAIN_CODEX_ACCOUNT_ID && selectionOptions?.nativeMainSelectionOnly === true) return;
468
- const drained = !isCodexAccountUsable(config, pinned, selectionOptions)
469
- || !hasCodexQuotaHeadroom(config, pinned, selectionOptions, now);
470
- if (!drained) return;
474
+ if (codexAccountPinDrainReason(config, pinned, selectionOptions, now) === undefined) return;
471
475
  clearCodexAccountPin(config);
472
476
  saveConfigPreservingClaudeCode(config);
473
477
  }
package/src/codex/shim.ts CHANGED
@@ -241,7 +241,7 @@ function backupPathFor(path: string): string {
241
241
  */
242
242
  function destroyedShimMessage(file: ShimFileState): string {
243
243
  const wrapper = existsSync(file.wrapperPath)
244
- ? isShim(file.wrapperPath) ? "present but unusable" : "present but not an opencodex shim"
244
+ ? stableShimPathProbe(file.wrapperPath)?.prefix.includes(SHIM_MARKER) ? "present but unusable" : "present but not an opencodex shim"
245
245
  : "missing";
246
246
  const backup = existsSync(file.backupPath) ? "present" : "missing";
247
247
  const base = `Codex autostart shim not restored: wrapper ${wrapper} at ${file.wrapperPath}; original backup ${backup} at ${file.backupPath}.`;
@@ -905,8 +905,120 @@ export function hasCodexAgentModelFallbackField(role: string, codexHome = CODEX_
905
905
  }
906
906
 
907
907
  /** Roles whose TOML still carries `model_fallback`, including empty arrays. */
908
- export function scanCodexAgentRolesWithTomlModelFallback(codexHome = CODEX_HOME): string[] {
909
- return listCodexAgentRoles(codexHome).filter(role => hasCodexAgentModelFallbackField(role, codexHome));
908
+ export function scanCodexAgentRolesWithTomlModelFallback(
909
+ codexHome = CODEX_HOME,
910
+ onListError?: (cause: unknown) => void,
911
+ ): string[] {
912
+ try {
913
+ return listCodexAgentRoles(codexHome).filter(role => hasCodexAgentModelFallbackField(role, codexHome));
914
+ } catch (cause) {
915
+ onListError?.(cause);
916
+ return [];
917
+ }
918
+ }
919
+
920
+ const TOML_MODEL_KEY = /^\s*(?:model|"model"|'model')\s*=/;
921
+
922
+ /**
923
+ * TOML-aware read of the root `model` pin, or null when there is none.
924
+ *
925
+ * Distinct from {@link readCodexAgentModel}, which matches one exact unindented double-quoted
926
+ * line. That is fine for resolving a fallback chain opencodex itself wrote, but it is the wrong
927
+ * question for a diagnostic: the file being judged was written by somebody else, so `model = 'x'`,
928
+ * an indented key, or a trailing comment are all valid TOML that Codex honours and that a
929
+ * stricter matcher would report as unpinned.
930
+ *
931
+ * It shares the scanner used for `model_fallback` for the reason that matters here: an imported
932
+ * role file keeps its instructions in a multiline string, and that string contains the very words
933
+ * this scan looks for. A line matcher would read a key out of prose.
934
+ *
935
+ * Table context is not tracked, matching the `model_fallback` parse. A `model` key under a later
936
+ * table header would be read as the root pin; Codex role files are flat in practice, and for a
937
+ * warning the conservative direction is to stay quiet.
938
+ */
939
+ function parseTomlModelPin(content: string): string | null {
940
+ const lines = content.split(/\r?\n/);
941
+ const state: TomlScanState = { inMultilineString: null, arrayDepth: 0 };
942
+ for (let i = 0; i < lines.length; i += 1) {
943
+ const line = lines[i]!;
944
+ if (state.inMultilineString) {
945
+ const end = findTomlMultilineStringEnd(line, 0, state.inMultilineString[0]!);
946
+ if (end === -1) continue;
947
+ state.inMultilineString = null;
948
+ scanTomlLine(line.slice(end + 3), state);
949
+ continue;
950
+ }
951
+ if (state.arrayDepth === 0) {
952
+ const key = line.match(TOML_MODEL_KEY);
953
+ if (key) {
954
+ const rest = `${line.slice(key[0].length)}\n${lines.slice(i + 1).join("\n")}`;
955
+ let at = 0;
956
+ while (at < rest.length && (rest[at] === " " || rest[at] === "\t")) at += 1;
957
+ if (rest[at] !== '"' && rest[at] !== "'") return null;
958
+ const value = parseTomlStringAt(rest, at)?.value.trim() ?? "";
959
+ return value === "" ? null : value;
960
+ }
961
+ }
962
+ scanTomlLine(line, state);
963
+ }
964
+ return null;
965
+ }
966
+
967
+ /** Filename prefix opencodex gives the Claude agents it generates. */
968
+ const OPENCODEX_DERIVED_ROLE_PREFIX = "ocx-";
969
+
970
+ /**
971
+ * Body markers that survive the Codex desktop external-agent import.
972
+ *
973
+ * The import carries the generated Claude agent's instructions across, so both the provenance
974
+ * marker and the routing directive end up inside the role TOML. `ocx-route:` is matched without
975
+ * its `<!--` comment prefix on purpose: the same import text-replaces "Claude Code" with "Codex"
976
+ * inside that body, so anything around the directive should be assumed rewritten.
977
+ */
978
+ const OPENCODEX_DERIVED_ROLE_MARKERS = ["generated-by: opencodex", "ocx-route:"] as const;
979
+
980
+ /**
981
+ * Roles that look opencodex-derived but pin no model, so Codex runs them on the parent model.
982
+ *
983
+ * opencodex does not write Codex role TOMLs. These arrive when the Codex desktop external-agent
984
+ * import converts `~/.claude/agents/ocx-*.md` into `$CODEX_HOME/agents/ocx-*.toml`, dropping the
985
+ * `model:` frontmatter because a `claude-ocx-native--` id is not a Codex model and keeping only the
986
+ * instructions. The surviving `ocx-route` directive cannot make up the difference: it is honoured
987
+ * only on the Claude `/v1/messages` path and is inert on `/v1/responses`. So the role file names
988
+ * one model while every spawn runs on another, which is invisible until someone diffs
989
+ * `session_meta.agent_role` against `turn_context.model` (#4790).
990
+ *
991
+ * Detection is a heuristic for a warning, deliberately not an ownership claim. Nothing here
992
+ * authorizes writing to, repairing, or removing these files, and the marker-based ownership rules
993
+ * that govern the files opencodex does write are unchanged.
994
+ */
995
+ export function scanOpencodexDerivedCodexAgentRolesWithoutModelPin(
996
+ codexHome = CODEX_HOME,
997
+ onListError?: (cause: unknown) => void,
998
+ ): string[] {
999
+ const findings: string[] = [];
1000
+ let roles: string[];
1001
+ try {
1002
+ roles = listCodexAgentRoles(codexHome);
1003
+ } catch (cause) {
1004
+ onListError?.(cause);
1005
+ return [];
1006
+ }
1007
+ for (const role of roles) {
1008
+ let content: string;
1009
+ try {
1010
+ content = readFileSync(join(codexHome, "agents", `${role}.toml`), "utf8");
1011
+ } catch {
1012
+ // An unreadable file is not evidence of a missing pin.
1013
+ continue;
1014
+ }
1015
+ const derived = role.startsWith(OPENCODEX_DERIVED_ROLE_PREFIX)
1016
+ || OPENCODEX_DERIVED_ROLE_MARKERS.some(marker => content.includes(marker));
1017
+ if (!derived) continue;
1018
+ if (parseTomlModelPin(content) !== null) continue;
1019
+ findings.push(role);
1020
+ }
1021
+ return findings.sort();
910
1022
  }
911
1023
 
912
1024
  export function listCodexAgentRoles(codexHome = CODEX_HOME): string[] {