@bitkyc08/opencodex 2.75.0 → 2.76.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 (217) hide show
  1. package/README.md +6 -1
  2. package/bin/ocx.mjs +12 -1
  3. package/gui/dist/assets/App-CddorHdA.js +51 -0
  4. package/gui/dist/assets/App-DMudtISW.css +1 -0
  5. package/gui/dist/assets/{Tray-CGAD2AGk.js → Tray-DjNwwrWS.js} +1 -1
  6. package/gui/dist/assets/index-Btze1I3T.css +1 -0
  7. package/gui/dist/assets/index-CvnY7E6i.js +86 -0
  8. package/gui/dist/assets/quota-summary-B_AWpHfP.js +1 -0
  9. package/gui/dist/index.html +2 -2
  10. package/gui/dist/provider-icons/opengateway.svg +12 -0
  11. package/package.json +1 -1
  12. package/src/adapters/anthropic/account-metadata.ts +17 -0
  13. package/src/adapters/anthropic/client-identity.ts +69 -0
  14. package/src/adapters/anthropic/passthrough.ts +23 -4
  15. package/src/adapters/anthropic.ts +18 -1
  16. package/src/adapters/command-code-project-context.ts +7 -11
  17. package/src/adapters/devin/cloud-direct/tool-schema.ts +12 -48
  18. package/src/adapters/kiro-retry.ts +23 -8
  19. package/src/adapters/openai-chat/messages.ts +1 -1
  20. package/src/adapters/registry.ts +8 -1
  21. package/src/adapters/zed.ts +377 -0
  22. package/src/bridge/sse.ts +4 -2
  23. package/src/chatgpt/app-server-shim/app-server-rewrite.ts +45 -0
  24. package/src/chatgpt/app-server-shim/bundle-trust.ts +77 -0
  25. package/src/chatgpt/app-server-shim/filter.ts +157 -0
  26. package/src/chatgpt/app-server-shim/gate-rewrite.ts +99 -0
  27. package/src/chatgpt/app-server-shim/launcher.ts +91 -0
  28. package/src/claude/desktop-3p-startup.ts +62 -0
  29. package/src/claude/desktop-3p.ts +5 -0
  30. package/src/claude/first-party-settings.ts +14 -0
  31. package/src/claude/intercept/cli-catalog.ts +150 -0
  32. package/src/claude/intercept/cli-picker.ts +82 -0
  33. package/src/claude/intercept/listener.ts +21 -6
  34. package/src/claude/intercept/picker-bootstrap.ts +23 -5
  35. package/src/claude/intercept/picker-models.ts +57 -13
  36. package/src/claude/intercept/runtime.ts +30 -2
  37. package/src/claude/intercept/settings.ts +3 -0
  38. package/src/cli/agent.ts +172 -0
  39. package/src/cli/alias.ts +2 -1
  40. package/src/cli/capabilities-command.ts +3 -2
  41. package/src/cli/capabilities.ts +87 -0
  42. package/src/cli/chatgpt-command.ts +140 -0
  43. package/src/cli/combo.ts +144 -19
  44. package/src/cli/config-command.ts +3 -1
  45. package/src/cli/dispatch.ts +10 -1
  46. package/src/cli/help.ts +1 -0
  47. package/src/cli/index.ts +5 -24
  48. package/src/cli/init.ts +16 -2
  49. package/src/cli/integrations.ts +13 -0
  50. package/src/cli/internal-command.ts +12 -5
  51. package/src/cli/observe.ts +6 -1
  52. package/src/cli/registry.ts +6 -0
  53. package/src/cli/route-policy.ts +3 -1
  54. package/src/cli/runtime-api.ts +5 -2
  55. package/src/cli/status-oauth.ts +5 -0
  56. package/src/cli/status.ts +8 -1
  57. package/src/cli/stop-approval.ts +55 -16
  58. package/src/cli/stop-report.ts +5 -0
  59. package/src/clients/lazycodex.ts +48 -0
  60. package/src/clients/omo-role-models.ts +111 -0
  61. package/src/codex/account-credit-use.ts +99 -0
  62. package/src/codex/account-lifecycle.ts +2 -0
  63. package/src/codex/agent-role-models.ts +171 -0
  64. package/src/codex/auth-api/account-list.ts +10 -3
  65. package/src/codex/auth-api/routes.ts +34 -0
  66. package/src/codex/auth-context.ts +52 -11
  67. package/src/codex/catalog/discovered-natives.ts +11 -1
  68. package/src/codex/catalog/gather-capture.ts +6 -1
  69. package/src/codex/catalog/model-hints.ts +9 -1
  70. package/src/codex/catalog/provider-models.ts +60 -0
  71. package/src/codex/catalog/routed-gather.ts +30 -5
  72. package/src/codex/convergence-types.ts +2 -1
  73. package/src/codex/main-account-external-usage.ts +62 -0
  74. package/src/codex/main-account-hard-lock.ts +24 -13
  75. package/src/codex/native-profile-processes.ts +53 -16
  76. package/src/codex/prompt-layers/toml-edit.ts +1 -1
  77. package/src/codex/quota-types.ts +3 -7
  78. package/src/codex/quota.ts +19 -4
  79. package/src/codex/role-auto-assign.ts +159 -0
  80. package/src/codex/role-sizing-limits.ts +2 -0
  81. package/src/codex/role-sizing.ts +216 -0
  82. package/src/codex/routing/selection.ts +36 -0
  83. package/src/codex/routing/thread-affinity.ts +2 -0
  84. package/src/codex/routing.ts +4 -4
  85. package/src/codex/subagent-model-fallback.ts +82 -26
  86. package/src/codex/subagent-selectable-models.ts +32 -0
  87. package/src/combos/index.ts +18 -0
  88. package/src/combos/jev-decision-contract.ts +20 -0
  89. package/src/combos/jev-dispatch.ts +18 -0
  90. package/src/combos/jev-model-backend.ts +127 -0
  91. package/src/combos/jev.ts +249 -33
  92. package/src/combos/types.ts +125 -2
  93. package/src/config/diagnostics.ts +18 -0
  94. package/src/config/macos-system-proxy.ts +72 -15
  95. package/src/config/owner-registry.ts +13 -4
  96. package/src/config/process-state.ts +17 -8
  97. package/src/config/proxy-env.ts +35 -5
  98. package/src/config/schema/config-schema.ts +30 -1
  99. package/src/config/schema/leaf-validators.ts +4 -0
  100. package/src/config/serving-runtimes.ts +95 -1
  101. package/src/generated/compatibility-version.json +336 -156
  102. package/src/lib/errors.ts +7 -7
  103. package/src/lib/jsonc.ts +13 -0
  104. package/src/lib/local-chat-completion.ts +144 -0
  105. package/src/lib/provider-outbound.ts +87 -1
  106. package/src/lib/provider-runtime-fetch.ts +23 -0
  107. package/src/lib/provider-tls-profile.ts +221 -0
  108. package/src/lib/request-execution-budget.ts +16 -6
  109. package/src/lib/spend-reservation-ledger.ts +54 -20
  110. package/src/lib/sse-decoder.ts +20 -17
  111. package/src/lib/synced-state-location.ts +96 -0
  112. package/src/lib/winsw.ts +14 -5
  113. package/src/oauth/anthropic-account-refusal.ts +53 -4
  114. package/src/oauth/anthropic-model-quota.ts +83 -0
  115. package/src/oauth/anthropic-rate-limit-policy.ts +37 -0
  116. package/src/oauth/anthropic-routing.ts +112 -58
  117. package/src/oauth/health.ts +39 -3
  118. package/src/oauth/index.ts +14 -0
  119. package/src/oauth/kiro-account-load.ts +10 -0
  120. package/src/oauth/zed.ts +134 -0
  121. package/src/providers/model-discovery.ts +19 -8
  122. package/src/providers/model-rename-fields.ts +1 -0
  123. package/src/providers/provider-id-rewrite.ts +9 -2
  124. package/src/providers/quota/account-cache.ts +29 -7
  125. package/src/providers/quota/anthropic-cooldown-recovery.ts +18 -0
  126. package/src/providers/quota/anthropic-family-headers.ts +25 -0
  127. package/src/providers/quota/vendor-probes-oauth.ts +5 -1
  128. package/src/providers/quota-types.ts +4 -0
  129. package/src/providers/registry/entries-core.ts +18 -0
  130. package/src/providers/registry/entries-extended.ts +55 -0
  131. package/src/providers/registry/types.ts +3 -0
  132. package/src/providers/xai-fast-model.ts +12 -4
  133. package/src/providers/zed.ts +598 -0
  134. package/src/responses/state/snapshot-select.ts +22 -9
  135. package/src/responses/state.ts +2 -3
  136. package/src/router.ts +9 -0
  137. package/src/server/anthropic-rate-limit-headers.ts +7 -0
  138. package/src/server/auth-cors.ts +7 -0
  139. package/src/server/chat-completions.ts +9 -4
  140. package/src/server/claude-messages.ts +59 -21
  141. package/src/server/index/claude-intercept-lifecycle.ts +71 -25
  142. package/src/server/index/optional-listeners.ts +4 -0
  143. package/src/server/index/spend-ledger-lifecycle.ts +4 -0
  144. package/src/server/management/agent-settings-routes.ts +74 -37
  145. package/src/server/management/claude-desktop-picker-routes.ts +7 -0
  146. package/src/server/management/claude-intercept-routes.ts +35 -0
  147. package/src/server/management/codex-agent-role-routes.ts +122 -0
  148. package/src/server/management/codex-role-auto-assign.ts +208 -0
  149. package/src/server/management/combo-routes.ts +62 -5
  150. package/src/server/management/config-routes.ts +35 -3
  151. package/src/server/management/context.ts +4 -1
  152. package/src/server/management/decision-discovery.ts +74 -0
  153. package/src/server/management/decision-model-validation.ts +80 -0
  154. package/src/server/management/decision-routes.ts +160 -0
  155. package/src/server/management/native-integration-routes.ts +16 -0
  156. package/src/server/management/provider-routes.ts +30 -34
  157. package/src/server/management/route-registry.ts +12 -0
  158. package/src/server/management/sibling-guard.ts +1 -0
  159. package/src/server/management/zed-provider-probe.ts +38 -0
  160. package/src/server/management-api.ts +12 -1
  161. package/src/server/messages-native-oauth.ts +7 -2
  162. package/src/server/messages-native.ts +7 -3
  163. package/src/server/relay.ts +3 -1
  164. package/src/server/request-log.ts +2 -0
  165. package/src/server/responses/adapter-continuation.ts +3 -3
  166. package/src/server/responses/adapter-dispatch.ts +18 -3
  167. package/src/server/responses/compact.ts +14 -1
  168. package/src/server/responses/core-auth.ts +2 -0
  169. package/src/server/responses/core-combo.ts +67 -11
  170. package/src/server/responses/core-normalize.ts +21 -1
  171. package/src/server/responses/core-options.ts +11 -1
  172. package/src/server/responses/core.ts +5 -3
  173. package/src/server/responses/fetch-helpers.ts +6 -3
  174. package/src/server/responses/jev-model-invoke.ts +190 -0
  175. package/src/server/responses/passthrough-delivery.ts +16 -0
  176. package/src/server/responses/policy-fallback.ts +55 -3
  177. package/src/server/responses/policy-request-scope.ts +80 -0
  178. package/src/server/responses/request-prepare.ts +70 -18
  179. package/src/server/responses/request-transport.ts +54 -23
  180. package/src/server/responses/run-turn-execution.ts +1 -0
  181. package/src/server/responses/sidecar-execution.ts +9 -3
  182. package/src/server/responses-hosted-image-display.ts +225 -0
  183. package/src/service/managing-cli.ts +34 -4
  184. package/src/service/systemd-env.ts +75 -0
  185. package/src/service/windows-guarded-manager.ts +60 -19
  186. package/src/service/windows-scheduler.ts +22 -3
  187. package/src/service/windows-taskxml.ts +32 -10
  188. package/src/service-manager-probe.ts +72 -19
  189. package/src/types/config.ts +31 -1
  190. package/src/types/provider.ts +2 -0
  191. package/src/types/request.ts +2 -0
  192. package/src/update/index.ts +39 -6
  193. package/src/update/job.ts +63 -63
  194. package/src/update/npm-cache-preflight.d.mts +33 -1
  195. package/src/update/npm-cache-preflight.mjs +131 -18
  196. package/src/update/ownership-transaction.ts +19 -1
  197. package/src/update/restart-ownership.ts +80 -6
  198. package/src/update/transactional-install.d.mts +2 -0
  199. package/src/update/transactional-install.mjs +6 -1
  200. package/src/usage/jev-stats.ts +28 -1
  201. package/src/usage/log.ts +1 -1
  202. package/src/vision/anthropic-describe.ts +5 -3
  203. package/src/vision/index.ts +1 -0
  204. package/src/vision/plan.ts +2 -1
  205. package/src/vision/routed-describe.ts +29 -121
  206. package/src/web-search/alpha-search.ts +3 -3
  207. package/src/web-search/anthropic-executor.ts +6 -4
  208. package/src/web-search/index.ts +1 -1
  209. package/src/web-search/loop.ts +3 -3
  210. package/src/web-search/passthrough-bridge.ts +2 -1
  211. package/src/web-search/run-turn-loop.ts +1 -1
  212. package/src/web-search/sidecar-providers.ts +2 -1
  213. package/gui/dist/assets/App-ia4U0ufY.css +0 -1
  214. package/gui/dist/assets/App-xm0zGNOe.js +0 -51
  215. package/gui/dist/assets/index-DBT3Q31Y.css +0 -1
  216. package/gui/dist/assets/index-D_RGAT6Q.js +0 -86
  217. package/gui/dist/assets/quota-summary-CUFq48t-.js +0 -1
@@ -81,14 +81,31 @@ export interface ListCodexClientProcessesOptions {
81
81
  */
82
82
  export function isCodexClientProcess(command: string, args: string): boolean {
83
83
  const comm = basename(command).toLowerCase();
84
- const [rawArgv0 = "", rawEntrypoint = ""] = args.trim().split(/\s+/, 2);
85
- const argv0 = basename(rawArgv0).toLowerCase();
86
- const entrypoint = basename(rawEntrypoint).toLowerCase();
87
- const isDirectCodex = DIRECT_CODEX_BASENAMES.has(comm)
88
- || DIRECT_CODEX_BASENAMES.has(argv0);
89
- const isInterpreterWrappedCodex = CODEX_INTERPRETER_BASENAMES.has(argv0)
90
- && CODEX_ENTRYPOINT_BASENAMES.has(entrypoint);
91
- return isDirectCodex || isInterpreterWrappedCodex;
84
+ if (DIRECT_CODEX_BASENAMES.has(comm)) return true;
85
+ // A full executable path is stronger evidence than ps's unquoted args.
86
+ // Splitting ".../Codex Framework.framework/.../browser_crashpad_handler"
87
+ // at its first space would otherwise manufacture an argv0 named Codex.
88
+ if (command.includes("/") && !CODEX_INTERPRETER_BASENAMES.has(comm)) return false;
89
+ const trimmedArgs = args.trim();
90
+ const hasExecutablePrefix = command && trimmedArgs.startsWith(command)
91
+ && /^(?:\s|$)/.test(trimmedArgs.slice(command.length));
92
+ const remainingArgs = hasExecutablePrefix ? trimmedArgs.slice(command.length).trim() : trimmedArgs;
93
+ const rawTokens = remainingArgs.split(/\s+/, 2);
94
+ // Even with only a comm basename, this initial token pair is the split
95
+ // Codex.app framework path, not a CLI executable plus an argument. Only
96
+ // inspect the executable or immediate script, never a later argument.
97
+ if (rawTokens[0]?.endsWith("/Codex.app/Contents/Frameworks/Codex")
98
+ && rawTokens[1]?.startsWith("Framework.framework/")) return false;
99
+ const quotedTokens = tokenizeCommandLine(remainingArgs);
100
+ // ps/procfs also leave literal apostrophes unescaped. Preserve a matching
101
+ // immediate raw entrypoint before interpreting shell-like quotes.
102
+ return [rawTokens, quotedTokens].some(tokens => {
103
+ const [rawArgv0 = "", rawEntrypoint = ""] = hasExecutablePrefix ? [command, ...tokens] : tokens;
104
+ const argv0 = basename(rawArgv0).toLowerCase();
105
+ const entrypoint = basename(rawEntrypoint).toLowerCase();
106
+ return DIRECT_CODEX_BASENAMES.has(argv0)
107
+ || (CODEX_INTERPRETER_BASENAMES.has(argv0) && CODEX_ENTRYPOINT_BASENAMES.has(entrypoint));
108
+ });
92
109
  }
93
110
 
94
111
  function parseUnixPsLine(line: string): { pid: number; command: string; args: string } | null {
@@ -147,9 +164,7 @@ export function listCodexClientProcesses({
147
164
  }
148
165
 
149
166
  function snapshotIsCodexClient(snapshot: ProcessSnapshot): boolean {
150
- if (isCodexClientProcess(snapshot.executable ?? "", snapshot.commandLine)) return true;
151
- const argv0 = tokenizeCommandLine(snapshot.commandLine)[0] ?? "";
152
- return argv0 !== "" && isCodexClientProcess(argv0, snapshot.commandLine);
167
+ return isCodexClientProcess(snapshot.executable ?? "", snapshot.commandLine);
153
168
  }
154
169
 
155
170
  /** Async, shell-free child execution with runtime-enforced timeout and output bounds. */
@@ -192,15 +207,37 @@ async function windowsProcessCount(run: NativeProcessExecutor): Promise<number>
192
207
  return count;
193
208
  }
194
209
 
195
- async function unixProcessCount(run: NativeProcessExecutor, pid: number): Promise<number> {
196
- const output = await run("ps", ["-eo", "pid=,comm=,args="], {
210
+ async function unixProcessCount(run: NativeProcessExecutor, pid: number, platform: NodeJS.Platform): Promise<number> {
211
+ const options: NativeProcessExecOptions = {
197
212
  encoding: "utf8",
198
213
  timeout: 5_000,
199
214
  maxBuffer: PROCESS_LIST_MAX_BUFFER,
200
215
  shell: false,
201
216
  killSignal: "SIGKILL",
202
- });
203
- return unixCodexClientsFromPs(output, pid).length;
217
+ };
218
+ if (platform !== "darwin") {
219
+ const output = await run("ps", ["-eo", "pid=,comm=,args="], options);
220
+ return unixCodexClientsFromPs(output, pid).length;
221
+ }
222
+ // macOS comm is a full path and may contain spaces. Read the two fields
223
+ // separately, as the routing-adoption snapshot collector does, then join by PID.
224
+ const commands = new Map<number, string>();
225
+ const commOutput = await run("ps", ["-eo", "pid=,comm="], options);
226
+ for (const line of commOutput.split("\n")) {
227
+ const match = /^\s*(\d+)\s+(.+)$/.exec(line);
228
+ if (match) commands.set(Number(match[1]), match[2]!.trim());
229
+ }
230
+ const argsOutput = await run("ps", ["-eo", "pid=,args="], options);
231
+ let count = 0;
232
+ for (const line of argsOutput.split("\n")) {
233
+ const match = /^\s*(\d+)\s+(.+)$/.exec(line);
234
+ if (!match) continue;
235
+ const candidatePid = Number(match[1]);
236
+ const command = commands.get(candidatePid);
237
+ if (Number.isSafeInteger(candidatePid) && candidatePid !== pid && command
238
+ && isCodexClientProcess(command, match[2]!)) count++;
239
+ }
240
+ return count;
204
241
  }
205
242
 
206
243
  /** Best-effort, read-only process probe. It never terminates a user process. */
@@ -212,7 +249,7 @@ export async function probeNativeCodexProcesses({
212
249
  try {
213
250
  const count = await (platform === "win32"
214
251
  ? windowsProcessCount(run)
215
- : unixProcessCount(run, pid));
252
+ : unixProcessCount(run, pid, platform));
216
253
  return count > 0 ? { status: "busy", count } : { status: "clear", count: 0 };
217
254
  } catch {
218
255
  return { status: "unknown", count: 0 };
@@ -3,7 +3,7 @@ import { encodeBasicString } from "./encoding";
3
3
  import { TABLE_HEADER, ANY_DEV_INSTRUCTIONS, DEV_INSTRUCTIONS_KEY } from "./toml-read";
4
4
 
5
5
  /** Line editing, not re-serialization: the user's comments and layout survive. */
6
- function dominantEol(content: string): "\r\n" | "\n" {
6
+ export function dominantEol(content: string): "\r\n" | "\n" {
7
7
  const crlf = (content.match(/\r\n/g) ?? []).length;
8
8
  if (crlf === 0) return "\n";
9
9
  const bareLf = (content.match(/\n/g) ?? []).length - crlf;
@@ -1,12 +1,8 @@
1
1
  /** Quota wire/storage shapes. This leaf must not import credential or config owners. */
2
- /**
3
- * Observed usage at which new identity-matched main-account requests are refused (#5694).
4
- *
5
- * The policy is on by default, so this constant is what every installation without an explicit
6
- * `codexMainAccountHardLock: false` admits against. 98 leaves one point of headroom under the
7
- * exhausted reading Codex Desktop already treats as its own disabled send button.
8
- */
2
+ /** Long-window default; explicit main-account hard-lock thresholds may override it. */
9
3
  export const MAIN_ACCOUNT_HARD_LOCK_PERCENT = 98;
4
+ export const MAIN_ACCOUNT_HARD_LOCK_SHORT_PERCENT = 90;
5
+ export const MAIN_ACCOUNT_HARD_LOCK_MIN_PERCENT = 80;
10
6
 
11
7
  /**
12
8
  * How recently a 100% burst reading must have been observed to exclude an account when it
@@ -1,3 +1,4 @@
1
+ import { forgetMainAccountUsage, observeMainAccountUsage, type FreshWindow } from "./main-account-external-usage";
1
2
  import { closeSync, constants as fsConstants, existsSync, fstatSync, openSync, readSync, unlinkSync } from "node:fs";
2
3
  import { join } from "node:path";
3
4
  import { atomicWriteFile, getConfigDir } from "../config";
@@ -10,7 +11,7 @@ import { getObservedMainQuotaIdentityKey, isMainQuotaWriterLive, type MainQuotaW
10
11
 
11
12
  import { CodexQuotaHistory, QUOTA_HISTORY_LIMITS, type QuotaHistoryWindow } from "./quota-history";
12
13
  import { isPoolQuotaWriterLive, poolQuotaHistoryIdentity } from "./account-store";
13
- import { CODEX_EXHAUSTED_USAGE_PERCENT, MAIN_ACCOUNT_HARD_LOCK_PERCENT, resetAtToMs } from "./quota-types";
14
+ import { CODEX_EXHAUSTED_USAGE_PERCENT, MAIN_ACCOUNT_HARD_LOCK_MIN_PERCENT, resetAtToMs } from "./quota-types";
14
15
  import type { PoolQuotaWriter, StoredAccountQuota, WhamUsageResponse, WhamUsageWindow } from "./quota-types";
15
16
 
16
17
  export type { StoredAccountQuota, WhamUsageResponse } from "./quota-types";
@@ -260,7 +261,7 @@ function assignCarriedShort(
260
261
  const existingShortPercent = existing.shortPercent;
261
262
  const preserveBlockingEvidence = policyEvidence
262
263
  && finitePercent(existingShortPercent)
263
- && existingShortPercent >= MAIN_ACCOUNT_HARD_LOCK_PERCENT
264
+ && existingShortPercent >= MAIN_ACCOUNT_HARD_LOCK_MIN_PERCENT
264
265
  && existingShortPercent <= 100;
265
266
  if (!preserveBlockingEvidence && shortResetHasElapsed(existing.shortResetAt, now)) return;
266
267
  if (existing.shortPercent !== undefined) next.shortPercent = existing.shortPercent;
@@ -315,6 +316,19 @@ export function setAccountQuotaFromParsed(
315
316
  const next = mergeAccountQuota(quota, legacyExisting, updatedAt);
316
317
  accountQuota.set(accountId, next);
317
318
  if (isMain) {
319
+ if (mainWriter && policyQuota && snapshotHasUsage(policyQuota)) {
320
+ const windows: FreshWindow[] = [];
321
+ const add = (kind: FreshWindow["kind"], percent: number | undefined, reset: number | undefined) => {
322
+ if (finitePercent(percent) && percent >= 0 && percent <= 100
323
+ && typeof reset === "number" && Number.isFinite(reset) && reset > 0) {
324
+ windows.push({ kind, percent, resetAtMs: resetAtToMs(reset) });
325
+ }
326
+ };
327
+ add("short", policyQuota.shortPercent, policyQuota.shortResetAt);
328
+ if (policyQuota.monthlyIsPrimaryWindow === true) add("long", policyQuota.monthlyPercent, policyQuota.monthlyResetAt);
329
+ else add("long", policyQuota.weeklyPercent, policyQuota.weeklyResetAt);
330
+ observeMainAccountUsage(mainWriter.identityKey, windows, updatedAt);
331
+ }
318
332
  const policyExisting = mainWriter && mainPolicyQuota?.identityKey === mainWriter.identityKey
319
333
  ? mainPolicyQuota.quota
320
334
  : undefined;
@@ -371,7 +385,7 @@ function mergeAccountQuota(
371
385
  && quota.weeklyPercent === undefined
372
386
  && quota.monthlyIsPrimaryWindow !== true
373
387
  && finitePercent(existingWeeklyPercent)
374
- && existingWeeklyPercent >= MAIN_ACCOUNT_HARD_LOCK_PERCENT
388
+ && existingWeeklyPercent >= MAIN_ACCOUNT_HARD_LOCK_MIN_PERCENT
375
389
  && existingWeeklyPercent <= 100;
376
390
  if (snapshotHasWeekly(quota) && !preserveKnownWeekly) {
377
391
  if (quota.weeklyPercent !== undefined) next.weeklyPercent = quota.weeklyPercent;
@@ -404,7 +418,7 @@ function mergeAccountQuota(
404
418
  const preserveKnownShort = policyEvidence
405
419
  && quota.shortPercent === undefined
406
420
  && finitePercent(existingShortPercent)
407
- && existingShortPercent >= MAIN_ACCOUNT_HARD_LOCK_PERCENT
421
+ && existingShortPercent >= MAIN_ACCOUNT_HARD_LOCK_MIN_PERCENT
408
422
  && existingShortPercent <= 100;
409
423
  if (snapshotHasShort(quota) && !preserveKnownShort) {
410
424
  if (quota.shortPercent !== undefined) {
@@ -778,6 +792,7 @@ function forgetCodexQuotaBaseline(accountId?: string): void {
778
792
  }
779
793
 
780
794
  export function clearAccountQuota(accountId?: string): void {
795
+ if (!accountId || accountId === MAIN_CODEX_ACCOUNT_ID) forgetMainAccountUsage();
781
796
  if (accountId) hydrateAccountQuotasFromDisk();
782
797
  quotaHistory.clear(accountId);
783
798
  if (accountId) {
@@ -0,0 +1,159 @@
1
+ /**
2
+ * Turns a role's sized tier and effort into a concrete model and reasoning level. Deterministic:
3
+ * the sizing model never names a model, and nothing here asks one.
4
+ *
5
+ * Tier of a candidate model, first rule that applies:
6
+ * 1. codexRoleTiers in the opencodex config lists it under fast, standard or frontier.
7
+ * 2. It has a known price. Priced candidates are ranked by input plus output USD per 1M tokens.
8
+ * Three or more split evenly across the three tiers by rank, cheapest in fast. Fewer than three
9
+ * cannot fill every tier, so the ranking is anchored at the top: the dearest is frontier,
10
+ * because it is the strongest model on offer, and each cheaper one sits one tier lower. One
11
+ * priced model is frontier; two are standard and frontier. The tier left empty is fast, so a
12
+ * fast role steps up to the cheapest model and a standard role keeps it instead of jumping to
13
+ * the dearest, which keeps every role on the cheapest sufficient model on offer.
14
+ * 3. Otherwise it is unclassified and never proposed.
15
+ *
16
+ * A role gets the cheapest sufficient model: the lowest tier at or above what it was sized for,
17
+ * then the lowest price, then the picker's own order.
18
+ *
19
+ * Effort binds to positions on the chosen model's ladder: glance is the floor, measured is the
20
+ * model's default or the middle rung, exhaustive is the ceiling, and thorough is the rung above
21
+ * measured when that rung is not the ceiling. When no such interior rung exists, thorough
22
+ * collapses to the nearer of measured and the ceiling; the two are equally near, and the one
23
+ * above wins because thorough asks for more than the everyday setting. Nothing resolves outside
24
+ * the ladder.
25
+ */
26
+ import { SIZING_TIERS, type RoleSizingOutcome, type SizingEffort, type SizingTier } from "./role-sizing";
27
+ import { CODEX_REASONING_LEVELS } from "../reasoning-effort";
28
+
29
+ export type RoleTierMapping = Partial<Record<SizingTier, readonly string[]>>;
30
+
31
+ export interface RoleModelCandidate {
32
+ readonly model: string;
33
+ /** Input plus output USD per 1M tokens, or null when no price is known. */
34
+ readonly unitPrice: number | null;
35
+ readonly efforts: readonly string[];
36
+ readonly defaultEffort?: string;
37
+ }
38
+
39
+ export interface ClassifiedCandidate extends RoleModelCandidate {
40
+ readonly tier: SizingTier | null;
41
+ readonly tierSource: "mapping" | "price" | null;
42
+ }
43
+
44
+ const LADDER_ORDER = ["none", "minimal", ...CODEX_REASONING_LEVELS.map(level => level.effort)];
45
+
46
+ export function effortLadder(efforts: readonly string[]): string[] {
47
+ return [...new Set(efforts)]
48
+ .filter(effort => LADDER_ORDER.includes(effort))
49
+ .sort((a, b) => LADDER_ORDER.indexOf(a) - LADDER_ORDER.indexOf(b));
50
+ }
51
+
52
+ export function mapEffortToLevel(intent: SizingEffort, efforts: readonly string[], defaultEffort?: string): string | null {
53
+ const ladder = effortLadder(efforts);
54
+ if (ladder.length === 0) return null;
55
+ const top = ladder.length - 1;
56
+ const measured = defaultEffort !== undefined && ladder.includes(defaultEffort)
57
+ ? ladder.indexOf(defaultEffort)
58
+ : Math.floor(top / 2);
59
+ switch (intent) {
60
+ case "glance": return ladder[0]!;
61
+ case "measured": return ladder[measured]!;
62
+ case "thorough": return ladder[Math.min(measured + 1, top)]!;
63
+ case "exhaustive": return ladder[top]!;
64
+ }
65
+ }
66
+
67
+ export function classifyRoleModelCandidates(
68
+ candidates: readonly RoleModelCandidate[],
69
+ mapping: RoleTierMapping = {},
70
+ ): ClassifiedCandidate[] {
71
+ const mapped = new Map<string, SizingTier>();
72
+ for (const tier of SIZING_TIERS) {
73
+ for (const model of mapping[tier] ?? []) if (!mapped.has(model)) mapped.set(model, tier);
74
+ }
75
+ const priced = candidates
76
+ .map((candidate, order) => ({ candidate, order }))
77
+ .filter(({ candidate }) => !mapped.has(candidate.model) && candidate.unitPrice !== null)
78
+ .sort((a, b) => a.candidate.unitPrice! - b.candidate.unitPrice! || a.order - b.order);
79
+ const byPrice = new Map<string, SizingTier>();
80
+ priced.forEach(({ candidate }, rank) => {
81
+ const top = SIZING_TIERS.length - 1;
82
+ const index = priced.length < SIZING_TIERS.length
83
+ ? top - (priced.length - 1 - rank)
84
+ : Math.round((rank * top) / (priced.length - 1));
85
+ byPrice.set(candidate.model, SIZING_TIERS[index]!);
86
+ });
87
+ return candidates.map(candidate => {
88
+ const fromMapping = mapped.get(candidate.model);
89
+ if (fromMapping) return { ...candidate, tier: fromMapping, tierSource: "mapping" as const };
90
+ const fromPrice = byPrice.get(candidate.model);
91
+ return fromPrice
92
+ ? { ...candidate, tier: fromPrice, tierSource: "price" as const }
93
+ : { ...candidate, tier: null, tierSource: null };
94
+ });
95
+ }
96
+
97
+ export function cheapestSufficientCandidate(
98
+ classified: readonly ClassifiedCandidate[],
99
+ tier: SizingTier,
100
+ ): ClassifiedCandidate | null {
101
+ const need = SIZING_TIERS.indexOf(tier);
102
+ const ranked = classified
103
+ .map((candidate, order) => ({ candidate, order, rank: candidate.tier ? SIZING_TIERS.indexOf(candidate.tier) : -1 }))
104
+ .filter(entry => entry.rank >= need)
105
+ .sort((a, b) => a.rank - b.rank
106
+ || (a.candidate.unitPrice ?? Infinity) - (b.candidate.unitPrice ?? Infinity)
107
+ || a.order - b.order);
108
+ return ranked[0]?.candidate ?? null;
109
+ }
110
+
111
+ export interface RoleProposalInput {
112
+ readonly role: string;
113
+ readonly model: string | null;
114
+ readonly effort: string | null;
115
+ }
116
+
117
+ export type RoleProposal = RoleProposalInput & (
118
+ | {
119
+ readonly status: "proposed" | "unassigned";
120
+ readonly tier: SizingTier;
121
+ readonly effortIntent: SizingEffort;
122
+ readonly rationale: string;
123
+ readonly moveUpIf: string;
124
+ readonly moveDownIf: string;
125
+ readonly proposedModel: string | null;
126
+ /** Only when the role already carries an effort, unless the caller asks for one always. */
127
+ readonly proposedEffort: string | null;
128
+ readonly reason: string | null;
129
+ }
130
+ | { readonly status: "unsized"; readonly reason: string }
131
+ );
132
+
133
+ export function buildRoleProposals(
134
+ roles: readonly RoleProposalInput[],
135
+ sizing: ReadonlyMap<string, RoleSizingOutcome>,
136
+ classified: readonly ClassifiedCandidate[],
137
+ options: { readonly alwaysProposeEffort?: boolean } = {},
138
+ ): RoleProposal[] {
139
+ return roles.map(role => {
140
+ const outcome = sizing.get(role.role) ?? { unsized: "the role was not sized" };
141
+ if ("unsized" in outcome) return { ...role, status: "unsized", reason: outcome.unsized };
142
+ const { tier, effort, rationale, moveUpIf, moveDownIf } = outcome.sizing;
143
+ const pick = cheapestSufficientCandidate(classified, tier);
144
+ const sized = { ...role, tier, effortIntent: effort, rationale, moveUpIf, moveDownIf };
145
+ if (!pick) {
146
+ return {
147
+ ...sized,
148
+ status: "unassigned",
149
+ proposedModel: null,
150
+ proposedEffort: null,
151
+ reason: `no available model is classified ${tier} or above`,
152
+ };
153
+ }
154
+ const proposedEffort = role.effort === null && !options.alwaysProposeEffort
155
+ ? null
156
+ : mapEffortToLevel(effort, pick.efforts, pick.defaultEffort);
157
+ return { ...sized, status: "proposed", proposedModel: pick.model, proposedEffort, reason: null };
158
+ });
159
+ }
@@ -0,0 +1,2 @@
1
+ /** Input limits the sizing call enforces. A leaf with no imports so the GUI shares the same values. */
2
+ export const ROLE_INSTRUCTIONS_EXCERPT_CHARS = 1500;
@@ -0,0 +1,216 @@
1
+ /**
2
+ * The sizing rubric for Codex agent roles: which capability tier a role needs and how hard it
3
+ * should reason, on a neutral scale that never names a model.
4
+ *
5
+ * This is the one home of the rubric. The dashboard's auto-assign sends it as the system prompt
6
+ * and parses the answer here; any later surface that sizes a role imports it rather than
7
+ * restating it. Choosing the concrete model and the concrete effort level is not the sizing
8
+ * model's job: that mapping is deterministic code in "role-auto-assign.ts".
9
+ *
10
+ * The tier and effort definitions, the risk factors, and the rules are ported from the
11
+ * modelchk skill by LilMGenius,
12
+ * https://github.com/LilMGenius/paperthin/blob/main/skills/depth/modelchk/SKILL.md,
13
+ * adapted from sizing one task to sizing a standing role and given a strict JSON answer shape.
14
+ * That text is used under the MIT License:
15
+ *
16
+ * Copyright (c) 2026 LilMGenius
17
+ *
18
+ * Permission is hereby granted, free of charge, to any person obtaining a copy of this software
19
+ * and associated documentation files (the "Software"), to deal in the Software without
20
+ * restriction, including without limitation the rights to use, copy, modify, merge, publish,
21
+ * distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the
22
+ * Software is furnished to do so, subject to the following conditions:
23
+ *
24
+ * The above copyright notice and this permission notice shall be included in all copies or
25
+ * substantial portions of the Software.
26
+ *
27
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING
28
+ * BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
29
+ * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM,
30
+ * DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
31
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
32
+ */
33
+
34
+ import { ROLE_INSTRUCTIONS_EXCERPT_CHARS } from "./role-sizing-limits";
35
+
36
+ export { ROLE_INSTRUCTIONS_EXCERPT_CHARS };
37
+
38
+ /** Capability tiers, cheapest first. */
39
+ export const SIZING_TIERS = ["fast", "standard", "frontier"] as const;
40
+ export type SizingTier = typeof SIZING_TIERS[number];
41
+
42
+ /** Reasoning effort intents, least deliberation first. Positions on a ladder, never level names. */
43
+ export const SIZING_EFFORTS = ["glance", "measured", "thorough", "exhaustive"] as const;
44
+ export type SizingEffort = typeof SIZING_EFFORTS[number];
45
+
46
+ const MAX_FIELD_CHARS = 400;
47
+
48
+ export const ROLE_SIZING_SYSTEM_PROMPT = [
49
+ "You size Codex agent roles before anyone spends a run on them. Each role is a standing agent that",
50
+ "a parent hands many tasks of the kind its instructions describe. For each role, size two dials",
51
+ "from one assessment of risk and complexity: capability tier and reasoning effort.",
52
+ "",
53
+ "Capability tier is the cheapest sufficient class:",
54
+ "- fast: local, mechanical, reversible work with cheap, complete verification.",
55
+ "- standard: ordinary repo-grounded reasoning, multi-step drafting, normal coding, and conventional documentation or skill work.",
56
+ "- frontier: architecture, high ambiguity, safety/security/privacy/data-loss risk, release-critical review, cross-domain scope, or work where one wrong assumption wastes a large run.",
57
+ "",
58
+ "Reasoning effort is how hard the model should deliberate. From least to most deliberation:",
59
+ "- glance: minimal deliberation; take the direct path. Resolves to the model's floor.",
60
+ "- measured: ordinary deliberation. Resolves to the model's default, or the middle of its ladder if no default is named.",
61
+ "- thorough: work the alternatives and check assumptions. Resolves above the everyday setting, short of the top.",
62
+ "- exhaustive: maximal deliberation; exhaust the search and re-check the work. Resolves to the model's ceiling.",
63
+ "",
64
+ "The axes are independent. A bounded but fiddly role can be fast + thorough; a quick expert call can",
65
+ "be frontier + glance. In most work they move together, parting when a cheap task needs hard thinking",
66
+ "or a strong model needs only a quick call. Effort buys deliberation, never capability, and more",
67
+ "effort is not more correct.",
68
+ "",
69
+ "Assess risk and complexity once for both dials:",
70
+ "- file, module, or ownership boundary crossing;",
71
+ "- reversibility and blast radius;",
72
+ "- safety, security, privacy, publishing, or data-loss risk;",
73
+ "- novelty, ambiguity, and long-context synthesis load;",
74
+ "- need for external research, adversarial review, or careful release sequencing;",
75
+ "- cost of a wrong answer.",
76
+ "",
77
+ "Choose the cheapest capability tier whose ceiling covers the role's judgment and risk. Default effort",
78
+ "to track tier (fast->glance, standard->measured, frontier->thorough), reserving exhaustive for the",
79
+ "hardest, highest-stakes work. Raise effort when an otherwise cheap role needs ambiguity resolved, long",
80
+ "multi-step reasoning, or adversarial self-check; lower it for bounded work under a strong model.",
81
+ "",
82
+ "Rules:",
83
+ "- Default to the cheapest sufficient tier and the effort the work needs, not the strongest of either.",
84
+ "- Never raise effort to buy capability; raise the tier.",
85
+ "- Risk beats size: a narrow high-risk role can need frontier; a broad mechanical role can stay fast + thorough.",
86
+ "- Use only the neutral words above. Do not name concrete model products, vendors, versions, or vendor effort levels anywhere in the answer.",
87
+ "",
88
+ "Answer with one JSON object and nothing else, no prose and no code fence:",
89
+ '{"roles":{"<role name>":{"tier":"fast|standard|frontier","effort":"glance|measured|thorough|exhaustive","rationale":"<one sentence covering both dials>","move_up_if":"<signals that would justify a stronger tier or higher effort>","move_down_if":"<signals that would justify a cheaper tier or lower effort>"}}}',
90
+ "Include every role you were given, keyed by its exact name, with exactly those five fields.",
91
+ ].join("\n");
92
+
93
+ /**
94
+ * The rubric for sizing one described piece of delegated work instead of a standing role. The role
95
+ * rubric is kept whole so both surfaces share one scale; the addendum only changes what is sized.
96
+ */
97
+ export const DELEGATED_WORK_SIZING_SYSTEM_PROMPT = [
98
+ ROLE_SIZING_SYSTEM_PROMPT,
99
+ "",
100
+ "This request sizes one-shot delegated work rather than a standing role. The single entry's instructions",
101
+ "describe the work a parent hands off; size that work itself, as described, rather than every task a",
102
+ "role with that description might later receive. Answer in the same shape, keyed by the entry's exact name.",
103
+ ].join("\n");
104
+
105
+ export interface RoleSizing {
106
+ readonly tier: SizingTier;
107
+ readonly effort: SizingEffort;
108
+ readonly rationale: string;
109
+ readonly moveUpIf: string;
110
+ readonly moveDownIf: string;
111
+ }
112
+
113
+ /** A role's sizing, or the reason it could not be sized. No reason is ever turned into a guess. */
114
+ export type RoleSizingOutcome =
115
+ | { readonly sizing: RoleSizing }
116
+ | { readonly unsized: string };
117
+
118
+ export interface RoleSizingInput {
119
+ readonly role: string;
120
+ readonly instructions: string;
121
+ }
122
+
123
+ function textField(value: unknown): string | null {
124
+ return typeof value === "string" && value.trim() !== "" ? value.trim() : null;
125
+ }
126
+
127
+ /**
128
+ * What the sizing model reads about one role: its description, then its developer instructions,
129
+ * cut to ROLE_INSTRUCTIONS_EXCERPT_CHARS. Null when the file is not TOML or carries neither,
130
+ * because a role sized from its name alone would be a guess.
131
+ */
132
+ export function roleInstructionsExcerpt(toml: string): string | null {
133
+ let document: unknown;
134
+ try {
135
+ document = Bun.TOML.parse(toml.replace(/^\ufeff/, ""));
136
+ } catch {
137
+ return null;
138
+ }
139
+ if (typeof document !== "object" || document === null) return null;
140
+ const fields = document as Record<string, unknown>;
141
+ const parts = [textField(fields.description), textField(fields.developer_instructions)]
142
+ .filter((part): part is string => part !== null);
143
+ if (parts.length === 0) return null;
144
+ return parts.join("\n\n").slice(0, ROLE_INSTRUCTIONS_EXCERPT_CHARS);
145
+ }
146
+
147
+ export function buildRoleSizingUserMessage(inputs: readonly RoleSizingInput[]): string {
148
+ return "Size these Codex agent roles.\n\n" + JSON.stringify({
149
+ roles: inputs.map(input => ({ name: input.role, instructions: input.instructions })),
150
+ }, null, 2);
151
+ }
152
+
153
+ function oneLine(value: unknown): string | null {
154
+ if (typeof value !== "string") return null;
155
+ const collapsed = value.replace(/\s+/g, " ").trim();
156
+ return collapsed === "" || collapsed.length > MAX_FIELD_CHARS ? null : collapsed;
157
+ }
158
+
159
+ function isMember<T extends string>(set: readonly T[], value: unknown): value is T {
160
+ return typeof value === "string" && (set as readonly string[]).includes(value);
161
+ }
162
+
163
+ function validateEntry(entry: unknown): RoleSizingOutcome {
164
+ if (typeof entry !== "object" || entry === null || Array.isArray(entry)) {
165
+ return { unsized: "the sizing answer for this role is not an object" };
166
+ }
167
+ const fields = entry as Record<string, unknown>;
168
+ if (!isMember(SIZING_TIERS, fields.tier)) return { unsized: "the sizing answer has no valid tier" };
169
+ if (!isMember(SIZING_EFFORTS, fields.effort)) return { unsized: "the sizing answer has no valid effort" };
170
+ const rationale = oneLine(fields.rationale);
171
+ const moveUpIf = oneLine(fields.move_up_if);
172
+ const moveDownIf = oneLine(fields.move_down_if);
173
+ if (rationale === null || moveUpIf === null || moveDownIf === null) {
174
+ return { unsized: "the sizing answer is missing a rationale or a move trigger" };
175
+ }
176
+ return { sizing: { tier: fields.tier, effort: fields.effort, rationale, moveUpIf, moveDownIf } };
177
+ }
178
+
179
+ const CODE_FENCE = /^\x60{3}(?:json)?[ \t]*\r?\n([\s\S]*?)\r?\n[ \t]*\x60{3}$/i;
180
+
181
+ /**
182
+ * Validate the sizing model's answer for each requested role.
183
+ *
184
+ * The answer must be one JSON object of the shape the rubric asks for. Two leniencies, both for
185
+ * content the mapping never reads: a single surrounding code fence, which models add out of habit,
186
+ * and keys beyond the five fields in a role's entry, which are ignored. Every one of the five must
187
+ * still be present and valid. Anything else wrong marks the affected role, or every role when the
188
+ * whole answer is unusable, as unsized with the reason.
189
+ */
190
+ export function parseRoleSizingResponse(text: string, roles: readonly string[]): Map<string, RoleSizingOutcome> {
191
+ const out = new Map<string, RoleSizingOutcome>();
192
+ const everyRole = (reason: string) => {
193
+ for (const role of roles) out.set(role, { unsized: reason });
194
+ return out;
195
+ };
196
+ const trimmed = text.trim();
197
+ const fenced = CODE_FENCE.exec(trimmed);
198
+ let parsed: unknown;
199
+ try {
200
+ parsed = JSON.parse(fenced ? fenced[1]! : trimmed);
201
+ } catch {
202
+ return everyRole("the sizing model did not answer with JSON");
203
+ }
204
+ const answered = typeof parsed === "object" && parsed !== null
205
+ ? (parsed as Record<string, unknown>).roles
206
+ : undefined;
207
+ if (typeof answered !== "object" || answered === null || Array.isArray(answered)) {
208
+ return everyRole("the sizing answer has no roles object");
209
+ }
210
+ for (const role of roles) {
211
+ out.set(role, Object.hasOwn(answered, role)
212
+ ? validateEntry((answered as Record<string, unknown>)[role])
213
+ : { unsized: "the sizing model did not size this role" });
214
+ }
215
+ return out;
216
+ }
@@ -1,4 +1,5 @@
1
1
  import { getEffectiveCodexAutoSwitchThreshold } from "../account-auto-switch";
2
+ import { isCodexAccountHeldForCredits } from "../account-credit-use";
2
3
  import { isCodexAccountPaused } from "../account-pause";
3
4
  import { codexAccountPriorityLookup, pinnedCodexAccountId } from "../account-priority";
4
5
  import { isSelectableCodexPoolAccount } from "../account-id";
@@ -82,6 +83,37 @@ export function isCodexAccountPlanExcluded(
82
83
  return plan !== undefined && excluded.has(plan);
83
84
  }
84
85
 
86
+ /**
87
+ * Whether the operator turned credits off for this account and one of its usage windows is full
88
+ * now (#6334). Checked wherever plan exclusion is checked, for the reason given there: an account
89
+ * that is already active or bound is served without passing through the eligible list.
90
+ */
91
+ export function isCodexAccountHeldAtUsageLimit(
92
+ config: OcxConfig,
93
+ accountId: string,
94
+ now: number,
95
+ selectionOptions?: CodexAccountUsabilityOptions,
96
+ ): boolean {
97
+ return isCodexAccountHeldForCredits(
98
+ config,
99
+ accountId,
100
+ getAccountQuota(accountId),
101
+ getPoolAccountPlanForSelection(config, accountId, selectionOptions),
102
+ now,
103
+ );
104
+ }
105
+
106
+ /** The automatic-rotation policies that also bind the legacy keep-the-active-account fallback. */
107
+ export function isCodexAccountRotationExcluded(
108
+ config: OcxConfig,
109
+ accountId: string,
110
+ now: number,
111
+ selectionOptions?: CodexAccountUsabilityOptions,
112
+ ): boolean {
113
+ return isCodexAccountPlanExcluded(config, accountId)
114
+ || isCodexAccountHeldAtUsageLimit(config, accountId, now, selectionOptions);
115
+ }
116
+
85
117
  export function isCodexAccountSelectable(
86
118
  config: OcxConfig,
87
119
  accountId: string,
@@ -91,6 +123,7 @@ export function isCodexAccountSelectable(
91
123
  ): boolean {
92
124
  return !isCodexAccountPaused(config, accountId)
93
125
  && !isCodexAccountPlanExcluded(config, accountId)
126
+ && !isCodexAccountHeldAtUsageLimit(config, accountId, now, selectionOptions)
94
127
  && getCodexQuotaHealthSnapshot(accountId, quotaScope, now) === null
95
128
  && !isCodexQuotaAvoided(accountId, quotaScope, now)
96
129
  && !isCodexAccountSoftAvoided(accountId, now)
@@ -117,6 +150,7 @@ export function codexAccountBlockReason(
117
150
  ): CodexAffinityReason | undefined {
118
151
  if (isCodexAccountPaused(config, accountId)) return "paused";
119
152
  if (isCodexAccountPlanExcluded(config, accountId)) return "plan_excluded";
153
+ if (isCodexAccountHeldAtUsageLimit(config, accountId, now, selectionOptions)) return "credits_off";
120
154
  if (getCodexQuotaHealthSnapshot(accountId, quotaScope, now) !== null) return "cooldown";
121
155
  if (isCodexQuotaAvoided(accountId, quotaScope, now)) return "quota_avoided";
122
156
  if (isCodexAccountSoftAvoided(accountId, now)) return "transient";
@@ -172,6 +206,7 @@ export function getEligiblePoolAccounts(
172
206
  && account.id !== excludeId
173
207
  && !isCodexAccountPaused(config, account.id)
174
208
  && !isCodexAccountPlanExcluded(config, account.id, excludedPlans)
209
+ && !isCodexAccountHeldAtUsageLimit(config, account.id, now, selectionOptions)
175
210
  && !isAccountNeedsReauth(account.id)
176
211
  && (!skipFailoverReadyCandidates || !shouldFailover(config, account.id, now)))
177
212
  .filter(account => getCodexQuotaHealthSnapshot(account.id, quotaScope, now) === null)
@@ -195,6 +230,7 @@ export function getEligiblePoolAccounts(
195
230
  // in between the main account returns as a first-class candidate.
196
231
  && !isCodexQuotaAvoided(MAIN_CODEX_ACCOUNT_ID, quotaScope, now)
197
232
  && !isCodexPoolRefreshCooling(MAIN_CODEX_ACCOUNT_ID, now)
233
+ && !isCodexAccountHeldAtUsageLimit(config, MAIN_CODEX_ACCOUNT_ID, now, selectionOptions)
198
234
  && (!skipFailoverReadyCandidates || !shouldFailover(config, MAIN_CODEX_ACCOUNT_ID, now))
199
235
  && isCodexAccountUsable(config, MAIN_CODEX_ACCOUNT_ID, selectionOptions)
200
236
  ) {
@@ -102,6 +102,8 @@ export type CodexAffinityReason =
102
102
  | "unusable"
103
103
  | "paused"
104
104
  | "plan_excluded"
105
+ /** Credits are off for the account and one of its usage windows is full (#6334). */
106
+ | "credits_off"
105
107
  | "cooldown"
106
108
  | "quota_avoided"
107
109
  | "generation"