@bitkyc08/opencodex 2.10.2 → 2.11.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 (121) hide show
  1. package/README.md +31 -0
  2. package/bin/ocx.mjs +10 -0
  3. package/gui/dist/assets/index-Bk-PN-70.css +1 -0
  4. package/gui/dist/assets/index-BynIEIV-.js +70 -0
  5. package/gui/dist/index.html +2 -2
  6. package/package.json +4 -2
  7. package/src/adapters/cursor/effort-map.ts +11 -0
  8. package/src/adapters/cursor/live-transport.ts +11 -0
  9. package/src/adapters/cursor/native-exec-fs.ts +9 -6
  10. package/src/adapters/cursor/native-exec.ts +4 -2
  11. package/src/adapters/cursor/protobuf-events.ts +176 -4
  12. package/src/adapters/cursor/request-builder.ts +15 -4
  13. package/src/adapters/cursor/tool-definitions.ts +118 -2
  14. package/src/adapters/google.ts +15 -5
  15. package/src/adapters/openai-chat.ts +24 -2
  16. package/src/adapters/openai-responses.ts +2 -1
  17. package/src/bridge.ts +9 -5
  18. package/src/chat/outbound.ts +4 -3
  19. package/src/claude/desktop-3p.ts +222 -2
  20. package/src/claude/outbound.ts +15 -6
  21. package/src/cli/account-api.ts +4 -0
  22. package/src/cli/account-extended.ts +112 -0
  23. package/src/cli/account.ts +23 -6
  24. package/src/cli/claude-desktop.ts +26 -3
  25. package/src/cli/config-command.ts +9 -0
  26. package/src/cli/help.ts +18 -2
  27. package/src/cli/index.ts +277 -55
  28. package/src/cli/models.ts +5 -1
  29. package/src/cli/provider.ts +8 -2
  30. package/src/cli/ready.ts +301 -0
  31. package/src/cli/system-restart-client.ts +146 -0
  32. package/src/cli/tray-proxy.ts +153 -6
  33. package/src/clients/config-export.ts +12 -19
  34. package/src/codex/account-lifecycle.ts +3 -0
  35. package/src/codex/account-namespaces.ts +49 -3
  36. package/src/codex/account-priority.ts +83 -0
  37. package/src/codex/auth-api.ts +83 -0
  38. package/src/codex/auth-context.ts +5 -2
  39. package/src/codex/catalog/provider-fetch.ts +11 -0
  40. package/src/codex/catalog/sync.ts +23 -1
  41. package/src/codex/codex-write-lock.ts +16 -4
  42. package/src/codex/desired-state.ts +37 -4
  43. package/src/codex/history-job.ts +15 -5
  44. package/src/codex/history-provider.ts +31 -14
  45. package/src/codex/history-worker.ts +28 -4
  46. package/src/codex/inject-coordination.ts +13 -1
  47. package/src/codex/inject.ts +360 -66
  48. package/src/codex/internal/history-writer.ts +1 -1
  49. package/src/codex/native-main-lock-file.ts +5 -1
  50. package/src/codex/native-main-owner.ts +17 -3
  51. package/src/codex/native-profile-manager.ts +19 -0
  52. package/src/codex/native-profile-startup.ts +8 -0
  53. package/src/codex/native-residue.ts +140 -27
  54. package/src/codex/pool-rotation.ts +74 -4
  55. package/src/codex/refresh.ts +7 -0
  56. package/src/codex/routing.ts +177 -36
  57. package/src/codex/subagent-model-fallback.ts +34 -4
  58. package/src/codex/sync.ts +61 -0
  59. package/src/codex/upstream-host-health.ts +329 -31
  60. package/src/combos/request.ts +2 -0
  61. package/src/config.ts +221 -2
  62. package/src/images/loop.ts +1 -1
  63. package/src/integrations/native/ownership-preflight.ts +39 -2
  64. package/src/lib/bun-stream-caps.ts +3 -3
  65. package/src/lib/sse-decoder.ts +41 -0
  66. package/src/lib/system-restart-contract.ts +73 -0
  67. package/src/lib/windows-secret-acl.ts +141 -39
  68. package/src/lib/windows-user-principal.ts +283 -0
  69. package/src/lib/winsw.ts +18 -2
  70. package/src/oauth/key-providers.ts +12 -0
  71. package/src/providers/derive.ts +54 -2
  72. package/src/providers/free-directory.ts +6 -5
  73. package/src/providers/model-discovery.ts +9 -3
  74. package/src/providers/quota.ts +592 -0
  75. package/src/providers/registry.ts +316 -13
  76. package/src/responses/parser.ts +26 -10
  77. package/src/responses/reasoning-replay-cache.ts +1 -0
  78. package/src/routing/profile-namespace.ts +15 -0
  79. package/src/routing/profile.ts +2 -1
  80. package/src/server/auth-cors.ts +44 -13
  81. package/src/server/chat-completions.ts +0 -4
  82. package/src/server/claude-messages.ts +73 -15
  83. package/src/server/github-copilot-responses-repair.ts +338 -0
  84. package/src/server/index.ts +328 -111
  85. package/src/server/lifecycle.ts +36 -0
  86. package/src/server/management/agent-settings-routes.ts +147 -56
  87. package/src/server/management/config-routes.ts +7 -2
  88. package/src/server/management/context.ts +4 -0
  89. package/src/server/management/native-integration-routes.ts +199 -20
  90. package/src/server/management/provider-routes.ts +41 -0
  91. package/src/server/management/routing-profile-routes.ts +234 -5
  92. package/src/server/management/system-restart.ts +12 -10
  93. package/src/server/management/system-routes.ts +20 -0
  94. package/src/server/management-auth.ts +51 -3
  95. package/src/server/ports.ts +41 -1
  96. package/src/server/proxy-liveness.ts +129 -4
  97. package/src/server/readiness.ts +99 -0
  98. package/src/server/relay.ts +113 -97
  99. package/src/server/request-log.ts +10 -4
  100. package/src/server/responses/compact.ts +107 -12
  101. package/src/server/responses/core.ts +220 -39
  102. package/src/server/responses-item-id-repair.ts +22 -3
  103. package/src/server/responses-model-rewrite.ts +29 -0
  104. package/src/server/sse-frame-buffer.ts +292 -0
  105. package/src/server/sse-payload-rewrite.ts +25 -14
  106. package/src/server/ws-bridge.ts +27 -22
  107. package/src/service-manager-probe.ts +520 -10
  108. package/src/service.ts +134 -2
  109. package/src/storage/worker-lifecycle.ts +14 -14
  110. package/src/tray/windows-tray.ps1 +74 -9
  111. package/src/types.ts +68 -2
  112. package/src/update/index.ts +12 -0
  113. package/src/update/job.ts +392 -18
  114. package/src/update/npm-cache-preflight.d.mts +47 -0
  115. package/src/update/npm-cache-preflight.mjs +201 -0
  116. package/src/usage/log.ts +1 -1
  117. package/src/vision/index.ts +77 -2
  118. package/src/web-search/loop.ts +1 -1
  119. package/src/web-search/parse.ts +4 -1
  120. package/gui/dist/assets/index-BKVqyYqT.js +0 -70
  121. package/gui/dist/assets/index-Ca_3269W.css +0 -1
@@ -3,6 +3,7 @@ import { saveConfigPreservingClaudeCode } from "../config";
3
3
  import { isCodexAccountGenerationLive, readCodexAccountRecord } from "./account-store";
4
4
  import { codexAccountLogLabel } from "./account-label";
5
5
  import { isCodexAccountPaused } from "./account-pause";
6
+ import { clearCodexAccountPin, codexAccountPriorityLookup, pinnedCodexAccountId } from "./account-priority";
6
7
  import { isCodexAccountUsable, type CodexAccountUsabilityOptions } from "./account-usability";
7
8
  import { isAccountNeedsReauth, markAccountNeedsReauth } from "./account-runtime-state";
8
9
  import {
@@ -14,6 +15,7 @@ import {
14
15
  peekRoundRobinAccount,
15
16
  pickRoundRobinAccount,
16
17
  seedPoolRotationAccount,
18
+ selectPriorityTier,
17
19
  } from "./pool-rotation";
18
20
  import { CODEX_UNKNOWN_USAGE_SCORE, getAccountQuota } from "./quota";
19
21
  import { MAIN_CODEX_ACCOUNT_ID, getMainAccountPlan } from "./main-account";
@@ -904,7 +906,7 @@ function getEligiblePoolAccounts(
904
906
  now = Date.now(),
905
907
  quotaScope?: CodexQuotaScope,
906
908
  selectionOptions?: CodexAccountUsabilityOptions,
907
- ): string[] {
909
+ ): readonly string[] {
908
910
  const ids = (config.codexAccounts ?? [])
909
911
  .filter(account => isSelectableCodexPoolAccount(account)
910
912
  && account.id !== excludeId
@@ -926,7 +928,15 @@ function getEligiblePoolAccounts(
926
928
  ) {
927
929
  ids.unshift(MAIN_CODEX_ACCOUNT_ID);
928
930
  }
929
- return ids;
931
+ // Single choke point for selection order: every strategy, failover, and preview
932
+ // reaches the pool through here, so tiering applies once rather than per picker.
933
+ // Eligibility above is unchanged — this only narrows an already-eligible list.
934
+ return selectPriorityTier(
935
+ ids,
936
+ codexAccountPriorityLookup(config),
937
+ id => hasCodexQuotaHeadroom(config, id),
938
+ pinnedCodexAccountId(config),
939
+ );
930
940
  }
931
941
 
932
942
  function listEligibleCodexAccountIds(
@@ -934,7 +944,7 @@ function listEligibleCodexAccountIds(
934
944
  now: number,
935
945
  quotaScope?: CodexQuotaScope,
936
946
  selectionOptions?: CodexAccountUsabilityOptions,
937
- ): string[] {
947
+ ): readonly string[] {
938
948
  return getEligiblePoolAccounts(config, undefined, now, quotaScope, selectionOptions);
939
949
  }
940
950
 
@@ -942,11 +952,20 @@ function stickyLimitForConfig(config: OcxConfig): number {
942
952
  return normalizeAccountPoolStickyLimit(config.accountPoolStickyLimit);
943
953
  }
944
954
 
945
- function isActiveUnderFillFirstThreshold(config: OcxConfig, accountId: string): boolean {
955
+ /**
956
+ * Whether an account still has quota to give under the auto-switch threshold.
957
+ *
958
+ * Fill-first and the priority tier filter share this predicate, and share both of
959
+ * its escape hatches. A disabled threshold means only health, pause, and reauth
960
+ * may drain an account; unknown usage is a guess, so it must neither force
961
+ * fill-first off the active account nor drain a tier that was simply never
962
+ * primed. A genuinely exhausted account 429s into cooldown and leaves
963
+ * eligibility on its own.
964
+ */
965
+ function hasCodexQuotaHeadroom(config: OcxConfig, accountId: string): boolean {
946
966
  const threshold = config.autoSwitchThreshold ?? 80;
947
967
  if (threshold <= 0) return true;
948
968
  const usage = computeCodexUsageScore(getAccountQuota(accountId), getPoolAccountPlan(config, accountId));
949
- // Unknown usage must not force fill-first to abandon the active account.
950
969
  if (isUnknownUsage(usage)) return true;
951
970
  return usage < threshold;
952
971
  }
@@ -965,7 +984,7 @@ function pickFillFirstCodexAccount(
965
984
  if (eligible.length === 0) return null;
966
985
 
967
986
  const active = getEffectiveActiveCodexAccountId(config);
968
- if (active && eligible.includes(active) && isActiveUnderFillFirstThreshold(config, active)) {
987
+ if (active && eligible.includes(active) && hasCodexQuotaHeadroom(config, active)) {
969
988
  return active;
970
989
  }
971
990
 
@@ -976,7 +995,7 @@ function pickFillFirstCodexAccount(
976
995
  function pickNextFillFirstCodexAccount(
977
996
  config: OcxConfig,
978
997
  afterId: string | null,
979
- eligible = listEligibleCodexAccountIds(config, Date.now()),
998
+ eligible: readonly string[] = listEligibleCodexAccountIds(config, Date.now()),
980
999
  _now = Date.now(),
981
1000
  selectionOptions?: CodexAccountUsabilityOptions,
982
1001
  ): string | null {
@@ -985,7 +1004,7 @@ function pickNextFillFirstCodexAccount(
985
1004
  if (!afterId) {
986
1005
  // Prefer an under-threshold account when starting with no active cursor.
987
1006
  for (const id of ordered) {
988
- if (isActiveUnderFillFirstThreshold(config, id)) return id;
1007
+ if (hasCodexQuotaHeadroom(config, id)) return id;
989
1008
  }
990
1009
  return ordered[0] ?? null;
991
1010
  }
@@ -1000,7 +1019,7 @@ function pickNextFillFirstCodexAccount(
1000
1019
  const startIdx = stableAll.indexOf(afterId);
1001
1020
  if (startIdx < 0) {
1002
1021
  for (const id of ordered) {
1003
- if (isActiveUnderFillFirstThreshold(config, id)) return id;
1022
+ if (hasCodexQuotaHeadroom(config, id)) return id;
1004
1023
  }
1005
1024
  return ordered[0] ?? null;
1006
1025
  }
@@ -1011,7 +1030,7 @@ function pickNextFillFirstCodexAccount(
1011
1030
  const candidate = stableAll[(startIdx + step) % stableAll.length]!;
1012
1031
  if (!eligible.includes(candidate)) continue;
1013
1032
  if (!fallback) fallback = candidate;
1014
- if (isActiveUnderFillFirstThreshold(config, candidate)) return candidate;
1033
+ if (hasCodexQuotaHeadroom(config, candidate)) return candidate;
1015
1034
  }
1016
1035
  return fallback ?? ordered[0] ?? null;
1017
1036
  }
@@ -1097,16 +1116,11 @@ function pickLowerUsageAccount(
1097
1116
  return best;
1098
1117
  }
1099
1118
 
1100
- export function pickLowestUsageCodexAccount(
1101
- config: OcxConfig,
1102
- excludeId?: string,
1103
- now = Date.now(),
1104
- quotaScope?: CodexQuotaScope,
1105
- selectionOptions?: CodexAccountUsabilityOptions,
1106
- ): string | null {
1119
+ /** Coolest account in an already-selected candidate list; first index wins ties. */
1120
+ function pickLowestUsageAmong(config: OcxConfig, ids: readonly string[]): string | null {
1107
1121
  let best: string | null = null;
1108
1122
  let bestUsage = Number.POSITIVE_INFINITY;
1109
- for (const id of getEligiblePoolAccounts(config, excludeId, now, quotaScope, selectionOptions)) {
1123
+ for (const id of ids) {
1110
1124
  const usage = computeCodexUsageScore(getAccountQuota(id), getPoolAccountPlan(config, id));
1111
1125
  if (usage < bestUsage) {
1112
1126
  best = id;
@@ -1116,6 +1130,16 @@ export function pickLowestUsageCodexAccount(
1116
1130
  return best;
1117
1131
  }
1118
1132
 
1133
+ export function pickLowestUsageCodexAccount(
1134
+ config: OcxConfig,
1135
+ excludeId?: string,
1136
+ now = Date.now(),
1137
+ quotaScope?: CodexQuotaScope,
1138
+ selectionOptions?: CodexAccountUsabilityOptions,
1139
+ ): string | null {
1140
+ return pickLowestUsageAmong(config, getEligiblePoolAccounts(config, excludeId, now, quotaScope, selectionOptions));
1141
+ }
1142
+
1119
1143
  /**
1120
1144
  * Strategy-aware alternate after a cooled/excluded account (same-request 429 retry
1121
1145
  * and active promotion). Quota keeps lowest-usage; fill-first advances stable order;
@@ -1129,12 +1153,16 @@ export function pickAlternateCodexAccount(
1129
1153
  selectionOptions?: CodexAccountUsabilityOptions,
1130
1154
  ): string | null {
1131
1155
  const strategy = normalizeAccountPoolStrategy(config.accountPoolStrategy);
1156
+ // The exclusion is passed into eligibility rather than post-filtered off its
1157
+ // result: when the excluded account is the only healthy member of the top
1158
+ // tier, the tier walk must be free to descend instead of selecting that tier
1159
+ // and then handing back an empty list.
1132
1160
  if (strategy === "round-robin") {
1133
- const eligible = listEligibleCodexAccountIds(config, now, quotaScope, selectionOptions).filter(id => id !== excludeId);
1161
+ const eligible = getEligiblePoolAccounts(config, excludeId, now, quotaScope, selectionOptions);
1134
1162
  return pickRoundRobinAccount(codexPoolKeyForScope(quotaScope), eligible, stickyLimitForConfig(config));
1135
1163
  }
1136
1164
  if (strategy === "fill-first") {
1137
- const eligible = listEligibleCodexAccountIds(config, now, quotaScope, selectionOptions).filter(id => id !== excludeId);
1165
+ const eligible = getEligiblePoolAccounts(config, excludeId, now, quotaScope, selectionOptions);
1138
1166
  return pickNextFillFirstCodexAccount(config, excludeId, eligible, now, selectionOptions);
1139
1167
  }
1140
1168
  return pickLowestUsageCodexAccount(config, excludeId, now, quotaScope, selectionOptions);
@@ -1145,6 +1173,17 @@ export function getEffectiveActiveCodexAccountId(config: OcxConfig): string | un
1145
1173
  return runtimeActiveCodexAccountId ?? config.activeCodexAccountId;
1146
1174
  }
1147
1175
 
1176
+ /**
1177
+ * Whether the account routing is currently on is there because an operator asked
1178
+ * for it, rather than because a strategy landed on it. Surfaces read this instead
1179
+ * of comparing the stored pin themselves, which would report a pin that a later
1180
+ * automatic pick has already moved past.
1181
+ */
1182
+ export function isEffectiveCodexAccountPinned(config: OcxConfig): boolean {
1183
+ const pinned = pinnedCodexAccountId(config);
1184
+ return pinned !== undefined && pinned === getEffectiveActiveCodexAccountId(config);
1185
+ }
1186
+
1148
1187
  /**
1149
1188
  * Automatic strategy / failover cursor only — never mutates `config.activeCodexAccountId`
1150
1189
  * so an unrelated `saveConfig` cannot persist transient rotation as operator selection.
@@ -1153,10 +1192,22 @@ function rememberActiveCodexAccount(_config: OcxConfig, accountId: string): void
1153
1192
  runtimeActiveCodexAccountId = accountId;
1154
1193
  }
1155
1194
 
1195
+ /**
1196
+ * End the manual pin when routing moves to a different account. Returns whether
1197
+ * the pin changed so the caller can fold it into a write it was already making.
1198
+ */
1199
+ function releaseCodexAccountPinFor(config: OcxConfig, accountId: string): boolean {
1200
+ const pinned = pinnedCodexAccountId(config);
1201
+ if (pinned === undefined || pinned === accountId) return false;
1202
+ clearCodexAccountPin(config);
1203
+ return true;
1204
+ }
1205
+
1156
1206
  /** Persist operator (or quota-strategy) active selection to config + disk. */
1157
1207
  function setActiveCodexAccount(config: OcxConfig, accountId: string): void {
1158
1208
  runtimeActiveCodexAccountId = undefined;
1159
- if (config.activeCodexAccountId === accountId) return;
1209
+ const releasedPin = releaseCodexAccountPinFor(config, accountId);
1210
+ if (config.activeCodexAccountId === accountId && !releasedPin) return;
1160
1211
  config.activeCodexAccountId = accountId;
1161
1212
  saveConfigPreservingClaudeCode(config);
1162
1213
  }
@@ -1167,6 +1218,10 @@ function promoteActiveCodexAccount(config: OcxConfig, accountId: string): void {
1167
1218
  setActiveCodexAccount(config, accountId);
1168
1219
  return;
1169
1220
  }
1221
+ // Runtime-only, like the cursor itself: a caller that persists (pause, delete)
1222
+ // saves this release with its own write; a transient failover does not, so the
1223
+ // pin survives a restart that also clears the failure history behind it.
1224
+ releaseCodexAccountPinFor(config, accountId);
1170
1225
  rememberActiveCodexAccount(config, accountId);
1171
1226
  }
1172
1227
 
@@ -1185,6 +1240,10 @@ export function reconcileCodexActiveAfterExclusion(
1185
1240
  if (config.activeCodexAccountId === excludedAccountId) {
1186
1241
  config.activeCodexAccountId = undefined;
1187
1242
  }
1243
+ // Excluding an account revokes any manual pin on it even when it was not the
1244
+ // effective active — otherwise a paused account keeps acting as a tier ceiling,
1245
+ // suppressing every higher-ordered account while being unusable itself.
1246
+ clearCodexAccountPin(config, excludedAccountId);
1188
1247
  if (!wasEffective) return getEffectiveActiveCodexAccountId(config) ?? null;
1189
1248
 
1190
1249
  runtimeActiveCodexAccountId = undefined;
@@ -1197,6 +1256,54 @@ function isUnknownUsage(usage: number): boolean {
1197
1256
  return usage >= CODEX_UNKNOWN_USAGE_SCORE;
1198
1257
  }
1199
1258
 
1259
+ /**
1260
+ * Move an unbound request back up when a higher tier regains headroom — the
1261
+ * weekly-reset case. Returns null when nothing should change.
1262
+ *
1263
+ * Downward moves are deliberately left to {@link applyQuotaAutoSwitch}: this only
1264
+ * fires when the tier filter has already excluded `active`, and only toward a
1265
+ * tier that strictly outranks it. Threads bound by affinity never reach here.
1266
+ */
1267
+ function pickPriorityPreemption(
1268
+ config: OcxConfig,
1269
+ active: string,
1270
+ now: number,
1271
+ quotaScope?: CodexQuotaScope,
1272
+ selectionOptions?: CodexAccountUsabilityOptions,
1273
+ ): string | null {
1274
+ const eligible = getEligiblePoolAccounts(config, undefined, now, quotaScope, selectionOptions);
1275
+ if (eligible.length === 0 || eligible.includes(active)) return null;
1276
+ const pinned = pinnedCodexAccountId(config);
1277
+ // A live pin already lowered the tier ceiling; never preempt past an explicit
1278
+ // operator choice. Same liveness test the tier filter applies, so preview and
1279
+ // resolve agree even before the pin is garbage-collected.
1280
+ if (pinned !== undefined && eligible.includes(pinned) && hasCodexQuotaHeadroom(config, pinned)) return null;
1281
+ const priorityOf = codexAccountPriorityLookup(config);
1282
+ if (priorityOf(eligible[0]!) <= priorityOf(active)) return null;
1283
+ // Members without headroom are in the tier only because a sibling has some;
1284
+ // picking one would hand the request straight back to a drained account.
1285
+ return pickLowestUsageAmong(config, eligible.filter(id => hasCodexQuotaHeadroom(config, id)));
1286
+ }
1287
+
1288
+ /**
1289
+ * Release a pin whose account is durably drained. "Use this account now" ends
1290
+ * when the account crosses the auto-switch threshold or stops being selectable
1291
+ * at all — never on a transient cooldown or soft-avoid, which it recovers from
1292
+ * on its own. Clearing the pin also removes the condition, so this writes at
1293
+ * most once per pin.
1294
+ */
1295
+ function releaseDrainedCodexAccountPin(config: OcxConfig): void {
1296
+ const pinned = pinnedCodexAccountId(config);
1297
+ if (pinned === undefined) return;
1298
+ const drained = !isCodexAccountUsable(config, pinned)
1299
+ || isAccountNeedsReauth(pinned)
1300
+ || isCodexAccountPaused(config, pinned)
1301
+ || !hasCodexQuotaHeadroom(config, pinned);
1302
+ if (!drained) return;
1303
+ clearCodexAccountPin(config);
1304
+ saveConfigPreservingClaudeCode(config);
1305
+ }
1306
+
1200
1307
  function applyQuotaAutoSwitch(
1201
1308
  config: OcxConfig,
1202
1309
  active: string,
@@ -1233,12 +1340,19 @@ function applyFailureFailover(
1233
1340
  config: OcxConfig,
1234
1341
  active: string,
1235
1342
  now: number,
1343
+ quotaScope?: CodexQuotaScope,
1236
1344
  selectionOptions?: CodexAccountUsabilityOptions,
1237
1345
  ): string {
1238
1346
  if (!shouldFailover(config, active, now)) return active;
1239
- const best = pickAlternateCodexAccount(config, active, now, undefined, selectionOptions);
1347
+ const best = pickAlternateCodexAccount(config, active, now, quotaScope, selectionOptions);
1240
1348
  if (best) {
1241
- promoteActiveCodexAccount(config, best);
1349
+ // The scope still routes away from the failing account — that is this request's
1350
+ // own decision — but an independent one must not persist a new shared active
1351
+ // account. recordCodexUpstreamOutcome only suppresses the promotion it makes at
1352
+ // the moment of the failure; the streak outlives the soft avoid, so a later
1353
+ // scoped resolve reaches here with the streak still tripped and would otherwise
1354
+ // move the shared cursor after all.
1355
+ if (!isIndependentCodexQuotaScope(quotaScope)) promoteActiveCodexAccount(config, best);
1242
1356
  return best;
1243
1357
  }
1244
1358
  return active;
@@ -1328,6 +1442,7 @@ export function previewCodexAccountForRequest(
1328
1442
  ) return active;
1329
1443
  else return null;
1330
1444
  }
1445
+ active = pickPriorityPreemption(config, active, now, quotaScope, selectionOptions) ?? active;
1331
1446
 
1332
1447
  const threshold = config.autoSwitchThreshold ?? 80;
1333
1448
  if (threshold > 0) {
@@ -1357,6 +1472,12 @@ export function resolveCodexAccountForThreadDetailed(
1357
1472
  quotaScope?: CodexQuotaScope,
1358
1473
  selectionOptions?: CodexAccountUsabilityOptions,
1359
1474
  ): CodexThreadResolution {
1475
+ // Retiring a spent manual pin is independent of affinity: an existing thread
1476
+ // keeps its account below, but the operator's tier ceiling must not silently
1477
+ // revive after quota resets. Independent model scopes must never persist a
1478
+ // change to shared routing state.
1479
+ if (!isIndependentCodexQuotaScope(quotaScope)) releaseDrainedCodexAccountPin(config);
1480
+
1360
1481
  const entry = threadId ? getThreadAffinity(threadId, quotaScope) : undefined;
1361
1482
  if (threadId && entry) {
1362
1483
  if (isThreadAffinityExpired(entry, now)) {
@@ -1430,8 +1551,19 @@ export function resolveCodexAccountForThreadDetailed(
1430
1551
  return { status: "none" };
1431
1552
  }
1432
1553
  }
1554
+ // Before applyQuotaAutoSwitch: its sync disk write would otherwise persist a
1555
+ // move inside the drained tier that preemption immediately overrides.
1556
+ const preempted = pickPriorityPreemption(config, active, now, quotaScope, selectionOptions);
1557
+ if (preempted) {
1558
+ // Runtime-only, like every other automatic pick: config.activeCodexAccountId
1559
+ // stays the operator's selection and getEffectiveActiveCodexAccountId is what
1560
+ // surfaces this to the API and dashboard. An independent quota group must not
1561
+ // move the shared cursor at all — its ordering decision is its own.
1562
+ if (!isIndependentCodexQuotaScope(quotaScope)) rememberActiveCodexAccount(config, preempted);
1563
+ active = preempted;
1564
+ }
1433
1565
  active = applyQuotaAutoSwitch(config, active, now, quotaScope, selectionOptions);
1434
- active = applyFailureFailover(config, active, now, selectionOptions);
1566
+ active = applyFailureFailover(config, active, now, quotaScope, selectionOptions);
1435
1567
  if (!isCodexAccountUsable(config, active, selectionOptions)) {
1436
1568
  return hasConfiguredPoolAccount(config, active, selectionOptions)
1437
1569
  ? { status: "selected", accountId: active }
@@ -1634,16 +1766,21 @@ export function recordCodexUpstreamOutcome(
1634
1766
  });
1635
1767
  if (!meta.fixedAccount) {
1636
1768
  clearThreadAccountMapForAccount(accountId);
1637
- notePoolRotationFailure(POOL_KEY_CODEX, accountId);
1638
- const effectiveActive = getEffectiveActiveCodexAccountId(config);
1639
- if (effectiveActive === accountId) {
1640
- // Same-request 429 retry already picked via excludeAccountId — reuse it so
1641
- // round-robin does not advance the ring a second time.
1642
- const reused = meta.promoteAccountId && meta.promoteAccountId !== accountId
1643
- ? meta.promoteAccountId
1644
- : null;
1645
- const fallback = reused ?? pickAlternateCodexAccount(config, accountId, now);
1646
- if (fallback) promoteActiveCodexAccount(config, fallback);
1769
+ // An independent native quota request may discover an account-wide throttle,
1770
+ // but it still must not advance the shared RR ring or active cursor. The next
1771
+ // shared request observes the cooldown and chooses its own fallback.
1772
+ if (!isIndependentCodexQuotaScope(quotaScope)) {
1773
+ notePoolRotationFailure(POOL_KEY_CODEX, accountId);
1774
+ const effectiveActive = getEffectiveActiveCodexAccountId(config);
1775
+ if (effectiveActive === accountId) {
1776
+ // Same-request 429 retry already picked via excludeAccountId — reuse it so
1777
+ // round-robin does not advance the ring a second time.
1778
+ const reused = meta.promoteAccountId && meta.promoteAccountId !== accountId
1779
+ ? meta.promoteAccountId
1780
+ : null;
1781
+ const fallback = reused ?? pickAlternateCodexAccount(config, accountId, now, quotaScope);
1782
+ if (fallback) promoteActiveCodexAccount(config, fallback);
1783
+ }
1647
1784
  }
1648
1785
  }
1649
1786
  return;
@@ -1697,8 +1834,12 @@ export function recordCodexUpstreamOutcome(
1697
1834
  if (!meta.fixedAccount && shouldFailover(config, accountId, now)) {
1698
1835
  clearThreadAccountMapForAccount(accountId);
1699
1836
  }
1700
- if (!meta.fixedAccount && getEffectiveActiveCodexAccountId(config) === accountId) {
1701
- applyFailureFailover(config, accountId, now);
1837
+ if (
1838
+ !meta.fixedAccount
1839
+ && !isIndependentCodexQuotaScope(quotaScope)
1840
+ && getEffectiveActiveCodexAccountId(config) === accountId
1841
+ ) {
1842
+ applyFailureFailover(config, accountId, now, quotaScope);
1702
1843
  }
1703
1844
  }
1704
1845
 
@@ -18,6 +18,7 @@ import {
18
18
  codexQuotaScopeForModel,
19
19
  computeCodexUsageScore,
20
20
  getCodexQuotaHealthSnapshot,
21
+ getEffectiveActiveCodexAccountId,
21
22
  getPoolAccountPlan,
22
23
  isCodexAccountInCooldown,
23
24
  } from "./routing";
@@ -29,12 +30,23 @@ import { isCodexAccountPaused } from "./account-pause";
29
30
  import { slugEquals } from "../providers/slug-codec";
30
31
  import { isThreadSpawnRequest } from "../server/effort-policy";
31
32
  import { PROVIDER_REGISTRY } from "../providers/registry";
32
- import { isCanonicalOpenAiForwardProvider } from "../providers/openai-tiers";
33
+ import {
34
+ CODEX_FORWARD_BASE_URL,
35
+ OPENAI_CODEX_PROVIDER_ID,
36
+ isCanonicalOpenAiForwardProvider,
37
+ } from "../providers/openai-tiers";
33
38
  import { routeModel, type RouteResult } from "../router";
34
39
  import { sweepExpiredOnWrite } from "../lib/state-store-sweeper";
35
40
  import { codexAccountNamespaceForModel } from "./account-namespace-match";
41
+ import {
42
+ getUpstreamHostHealth,
43
+ normalizeUpstreamHostCircuitThreshold,
44
+ upstreamHostHealthKey,
45
+ } from "./upstream-host-health";
36
46
  export const DEFAULT_SUBAGENT_MODEL_FALLBACK_POLL_MS = 60_000;
37
47
 
48
+ const CODEX_FORWARD_ORIGIN = new URL(CODEX_FORWARD_BASE_URL).origin.toLowerCase();
49
+
38
50
  type SubagentQuotaPrimeFn = (config: OcxConfig, reason: string) => Promise<void>;
39
51
  let subagentQuotaPrimeForTests: SubagentQuotaPrimeFn | null = null;
40
52
  let quotaPrimeInFlight: Promise<void> | null = null;
@@ -131,8 +143,14 @@ function quotaThreshold(config: OcxConfig): number {
131
143
  return threshold > 0 ? threshold : Number.POSITIVE_INFINITY;
132
144
  }
133
145
 
146
+ /**
147
+ * The account routing would actually use, not just the persisted operator
148
+ * selection: round-robin, fill-first, failover, and priority preemption all
149
+ * move the cursor in memory only, so reading the raw field would check quota
150
+ * against an account this request is not going to touch.
151
+ */
134
152
  function activeCodexAccountId(config: OcxConfig): string | null {
135
- return config.activeCodexAccountId ?? null;
153
+ return getEffectiveActiveCodexAccountId(config) ?? null;
136
154
  }
137
155
 
138
156
  /**
@@ -393,11 +411,19 @@ export function resolveAgentModelFallbackForPrimary(
393
411
  return merged;
394
412
  }
395
413
 
414
+ function subagentQuotaPrimeBlockedByHostCircuit(config: OcxConfig): boolean {
415
+ if (normalizeUpstreamHostCircuitThreshold(config.upstreamHostCircuitThreshold) === 0) return false;
416
+ const key = upstreamHostHealthKey(OPENAI_CODEX_PROVIDER_ID, CODEX_FORWARD_ORIGIN);
417
+ return getUpstreamHostHealth(key)?.cooldownUntil !== undefined;
418
+ }
419
+
396
420
  /**
397
421
  * Best-effort quota refresh before subagent model selection.
398
422
  * Concurrent callers share one in-flight promise. The success TTL is updated only
399
423
  * after a successful refresh so failures remain retryable. Errors are swallowed so
400
- * spawn routing can continue.
424
+ * spawn routing can continue. When the canonical ChatGPT origin is circuit-blocked,
425
+ * cached quota is used instead of sending credential-bearing usage probes to the
426
+ * same origin before the request's final host admission check.
401
427
  */
402
428
  export function maybePrimeSubagentQuota(
403
429
  config: OcxConfig,
@@ -405,11 +431,15 @@ export function maybePrimeSubagentQuota(
405
431
  options: { nativeMainReadsForbidden?: boolean } = {},
406
432
  ): Promise<void> {
407
433
  if (options.nativeMainReadsForbidden) return Promise.resolve();
434
+ if (subagentQuotaPrimeBlockedByHostCircuit(config)) return Promise.resolve();
408
435
  if (quotaPrimeInFlight) return quotaPrimeInFlight;
409
436
  if (!shouldPrimeSubagentQuota(config, now)) return Promise.resolve();
410
437
 
411
438
  quotaPrimeInFlight = (async () => {
412
439
  try {
440
+ // Re-check after claiming single-flight ownership so a circuit opened by
441
+ // a concurrent request cannot race us into a fresh usage-probe pass.
442
+ if (subagentQuotaPrimeBlockedByHostCircuit(config)) return;
413
443
  if (subagentQuotaPrimeForTests) {
414
444
  await subagentQuotaPrimeForTests(config, "subagent-spawn");
415
445
  } else {
@@ -527,4 +557,4 @@ export function listCodexAgentRoles(codexHome = CODEX_HOME): string[] {
527
557
  export function shouldPrimeSubagentQuota(config: OcxConfig, now = Date.now()): boolean {
528
558
  const last = quotaPrimedAt.get("global") ?? 0;
529
559
  return now - last >= pollIntervalMs(config);
530
- }
560
+ }
package/src/codex/sync.ts CHANGED
@@ -5,9 +5,16 @@ import { applyProxyEnv, loadConfig } from "../config";
5
5
  import type { OcxConfig } from "../types";
6
6
  import { collectOrcaCodexHomeDiagnostic } from "./home";
7
7
  import { summarizeComboCatalogOmissions, type ComboCatalogOmission } from "./catalog/aggregation";
8
+ import { shouldSyncCodexOnStart } from "./desired-state";
9
+ import { admitCodexWrite, type CodexAdmission } from "./admission";
8
10
 
9
11
  export interface CodexSyncResult {
12
+ /** `skipped` is policy truth, never evidence that Codex was written. */
13
+ status: "applied" | "skipped" | "refused";
10
14
  ok: boolean;
15
+ skippedReason?: "desired_disabled";
16
+ /** Present when unattended convergence refused another service's native home. */
17
+ authority?: "service-home";
11
18
  added: number;
12
19
  catalogPath: string | null;
13
20
  catalogExists: boolean;
@@ -21,9 +28,13 @@ export interface CodexSyncResult {
21
28
  projectConfigGrouped?: { path: string; issues: string[]; bypass: string }[];
22
29
  }
23
30
 
31
+ type CodexSyncAdmission = Extract<CodexAdmission, { kind: "refused" }> | { readonly kind: "admitted" };
32
+
24
33
  interface CodexSyncDeps {
25
34
  refreshCodexModelCatalog: typeof refreshCodexModelCatalog;
26
35
  injectCodexConfig: typeof injectCodexConfig;
36
+ /** The sync entry only needs this admission's service-home verdict. */
37
+ admitCodexWrite?: () => CodexSyncAdmission;
27
38
  currentExternalCodexModelProvider?: typeof currentExternalCodexModelProvider;
28
39
  collectCodexHomeDiagnostic?: typeof collectOrcaCodexHomeDiagnostic;
29
40
  }
@@ -52,6 +63,40 @@ export async function syncModelsToCodex(
52
63
  log: Pick<Console, "log" | "error"> | null = console,
53
64
  deps: CodexSyncDeps = defaultDeps,
54
65
  ): Promise<CodexSyncResult> {
66
+ // `config` can be the server's startup object. The decision, however, is a
67
+ // durable user switch and must be read again at this production boundary: a
68
+ // PUT OFF while provider discovery is in flight cannot be allowed to commit
69
+ // through an older captured object.
70
+ if (!shouldSyncCodexOnStart(loadConfig())) {
71
+ return {
72
+ status: "skipped",
73
+ skippedReason: "desired_disabled",
74
+ ok: true,
75
+ added: 0,
76
+ catalogPath: null,
77
+ catalogExists: false,
78
+ catalogWritten: false,
79
+ cacheSynced: false,
80
+ message: "Codex integration is OFF; no Codex config, catalog, cache, or history was changed.",
81
+ };
82
+ }
83
+ // Catalog gathering precedes injection and can itself write the native
84
+ // catalog/cache. It therefore needs the same unattended service-home veto as
85
+ // the injector, before it gets a chance to create any artifact.
86
+ const admission = (deps.admitCodexWrite ?? admitCodexWrite)();
87
+ if (admission.kind === "refused" && admission.authority === "service-home") {
88
+ return {
89
+ status: "refused",
90
+ authority: "service-home",
91
+ ok: false,
92
+ added: 0,
93
+ catalogPath: null,
94
+ catalogExists: false,
95
+ catalogWritten: false,
96
+ cacheSynced: false,
97
+ message: admission.message,
98
+ };
99
+ }
55
100
  const p = port ?? config.port ?? 10100;
56
101
  const externalProvider = (deps.currentExternalCodexModelProvider ?? currentExternalCodexModelProvider)();
57
102
  if (externalProvider) {
@@ -59,6 +104,7 @@ export async function syncModelsToCodex(
59
104
  log?.log(result.message);
60
105
  reportCodexHomeTarget(log, deps.collectCodexHomeDiagnostic ?? collectOrcaCodexHomeDiagnostic);
61
106
  return {
107
+ status: "applied",
62
108
  ok: result.success,
63
109
  added: 0,
64
110
  catalogPath: null,
@@ -108,10 +154,25 @@ export async function syncModelsToCodex(
108
154
  }
109
155
 
110
156
  const result = await deps.injectCodexConfig(p, config, { catalogPath: catalogPathForInjection });
157
+ if (result.status === "skipped") {
158
+ return {
159
+ status: "skipped",
160
+ // The apply direction's only under-lock policy skip is desired OFF.
161
+ skippedReason: "desired_disabled",
162
+ ok: true,
163
+ added: 0,
164
+ catalogPath: null,
165
+ catalogExists: false,
166
+ catalogWritten: false,
167
+ cacheSynced: false,
168
+ message: result.message,
169
+ };
170
+ }
111
171
  log?.log(result.message);
112
172
  reportCodexHomeTarget(log, deps.collectCodexHomeDiagnostic ?? collectOrcaCodexHomeDiagnostic);
113
173
  const projectConfigWarnings = printProjectCodexConfigWarnings(log, { cwd: process.cwd() });
114
174
  return {
175
+ status: "applied",
115
176
  ok: result.success,
116
177
  added,
117
178
  catalogPath,