@bitkyc08/opencodex 2.10.0 → 2.10.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (274) hide show
  1. package/AGENTS_INSTALL.md +77 -0
  2. package/README.md +4 -10
  3. package/bin/ocx.mjs +71 -18
  4. package/gui/dist/assets/index-Cd6_PBKn.css +1 -0
  5. package/gui/dist/assets/index-ChZQsmBY.js +70 -0
  6. package/gui/dist/index.html +2 -2
  7. package/gui/dist/provider-icons/alibaba-color.svg +1 -1
  8. package/gui/dist/provider-icons/antigravity-color.svg +1 -1
  9. package/gui/dist/provider-icons/claude-color.svg +1 -1
  10. package/gui/dist/provider-icons/cline-color.svg +16 -0
  11. package/gui/dist/provider-icons/cloudflare-ai-gateway-color.svg +1 -1
  12. package/gui/dist/provider-icons/copilot-color.svg +1 -1
  13. package/gui/dist/provider-icons/cursor-color.svg +1 -1
  14. package/gui/dist/provider-icons/deepseek-color.svg +1 -1
  15. package/gui/dist/provider-icons/firepass-color.svg +1 -1
  16. package/gui/dist/provider-icons/fireworks-color.svg +1 -1
  17. package/gui/dist/provider-icons/gemini-color.svg +1 -1
  18. package/gui/dist/provider-icons/github-copilot-color.svg +1 -1
  19. package/gui/dist/provider-icons/gitlab-duo-color.svg +1 -1
  20. package/gui/dist/provider-icons/grok.svg +1 -1
  21. package/gui/dist/provider-icons/groq-color.svg +1 -1
  22. package/gui/dist/provider-icons/huggingface-color.svg +1 -1
  23. package/gui/dist/provider-icons/kimi-color.svg +1 -1
  24. package/gui/dist/provider-icons/kiro-color.svg +2 -2
  25. package/gui/dist/provider-icons/lm-studio-color.svg +1 -1
  26. package/gui/dist/provider-icons/mistral-color.svg +1 -1
  27. package/gui/dist/provider-icons/moonshot-color.svg +1 -1
  28. package/gui/dist/provider-icons/nvidia-color.svg +1 -1
  29. package/gui/dist/provider-icons/ollama-color.svg +1 -1
  30. package/gui/dist/provider-icons/openai.svg +1 -1
  31. package/gui/dist/provider-icons/opencode.svg +2 -1
  32. package/gui/dist/provider-icons/openrouter-color.svg +1 -1
  33. package/gui/dist/provider-icons/pi.svg +2 -2
  34. package/gui/dist/provider-icons/qianfan-color.svg +1 -1
  35. package/gui/dist/provider-icons/qwen-portal-color.svg +1 -1
  36. package/gui/dist/provider-icons/vercel-ai-gateway-color.svg +1 -1
  37. package/gui/dist/provider-icons/vllm-color.svg +1 -1
  38. package/gui/dist/provider-icons/xiaomi-color.svg +1 -1
  39. package/package.json +8 -4
  40. package/src/adapters/anthropic.ts +208 -14
  41. package/src/adapters/base.ts +16 -5
  42. package/src/adapters/cursor/effort-map.ts +3 -2
  43. package/src/adapters/cursor/framing.ts +39 -0
  44. package/src/adapters/cursor/live-transport.ts +105 -95
  45. package/src/adapters/cursor/native-exec.ts +32 -6
  46. package/src/adapters/cursor/protobuf-request.ts +20 -15
  47. package/src/adapters/cursor/request-builder.ts +21 -7
  48. package/src/adapters/cursor/types.ts +7 -0
  49. package/src/adapters/google-antigravity-replay.ts +237 -21
  50. package/src/adapters/google-truncation.ts +11 -0
  51. package/src/adapters/google.ts +50 -9
  52. package/src/adapters/identity.ts +39 -6
  53. package/src/adapters/kiro-errors.ts +11 -0
  54. package/src/adapters/kiro-events.ts +19 -1
  55. package/src/adapters/kiro-thinking.ts +10 -2
  56. package/src/adapters/kiro-tools.ts +10 -1
  57. package/src/adapters/kiro.ts +37 -11
  58. package/src/adapters/openai-chat.ts +284 -83
  59. package/src/adapters/openai-responses.ts +182 -24
  60. package/src/bridge.ts +177 -7
  61. package/src/chat/outbound.ts +78 -23
  62. package/src/claude/agents-inject.ts +27 -5
  63. package/src/claude/inbound.ts +11 -1
  64. package/src/claude/model-info.ts +13 -10
  65. package/src/claude/outbound.ts +17 -0
  66. package/src/cli/account-api.ts +24 -0
  67. package/src/cli/account-auth.ts +31 -6
  68. package/src/cli/account-main.ts +317 -0
  69. package/src/cli/account.ts +5 -0
  70. package/src/cli/claude.ts +2 -1
  71. package/src/cli/doctor.ts +93 -22
  72. package/src/cli/export-command.ts +26 -12
  73. package/src/cli/help.ts +8 -6
  74. package/src/cli/index.ts +56 -22
  75. package/src/cli/integrations.ts +84 -1
  76. package/src/cli/observe.ts +54 -1
  77. package/src/cli/opencode.ts +2 -1
  78. package/src/cli/provider-runtime.ts +18 -1
  79. package/src/cli/route-policy.ts +92 -0
  80. package/src/cli/runtime-api.ts +6 -3
  81. package/src/cli/star-prompt.ts +71 -15
  82. package/src/cli/status.ts +1 -1
  83. package/src/cli/v2.ts +36 -9
  84. package/src/clients/config-export.ts +687 -10
  85. package/src/codex/account-lifecycle.ts +30 -5
  86. package/src/codex/account-usability.ts +22 -2
  87. package/src/codex/admission.ts +255 -0
  88. package/src/codex/auth-api.ts +427 -140
  89. package/src/codex/auth-context.ts +155 -30
  90. package/src/codex/autostart-health.ts +8 -1
  91. package/src/codex/catalog/account-models.ts +62 -0
  92. package/src/codex/catalog/aggregation.ts +14 -1
  93. package/src/codex/catalog/bundled.ts +282 -32
  94. package/src/codex/catalog/filesystem-evidence.ts +302 -0
  95. package/src/codex/catalog/metadata.ts +51 -6
  96. package/src/codex/catalog/parsing.ts +6 -3
  97. package/src/codex/catalog/provider-fetch.ts +576 -41
  98. package/src/codex/catalog/sync.ts +505 -66
  99. package/src/codex/catalog-admission.ts +197 -0
  100. package/src/codex/catalog-write-serialization.ts +241 -0
  101. package/src/codex/catalog.ts +2 -1
  102. package/src/codex/codex-write-lock.ts +372 -0
  103. package/src/codex/convergence-types.ts +593 -0
  104. package/src/codex/convergence.ts +441 -0
  105. package/src/codex/desired-state.ts +177 -0
  106. package/src/codex/features.ts +52 -8
  107. package/src/codex/generation.ts +202 -0
  108. package/src/codex/history-job.ts +257 -0
  109. package/src/codex/history-lock.ts +241 -0
  110. package/src/codex/history-migration-guardian.ts +18 -5
  111. package/src/codex/history-provider.ts +9 -2
  112. package/src/codex/history-transition.ts +105 -0
  113. package/src/codex/history-worker.ts +176 -0
  114. package/src/codex/inject-coordination.ts +245 -0
  115. package/src/codex/inject.ts +605 -124
  116. package/src/codex/integration-record.ts +266 -0
  117. package/src/codex/internal/catalog-writer.ts +203 -0
  118. package/src/codex/internal/history-writer.ts +80 -0
  119. package/src/codex/journal.ts +10 -1
  120. package/src/codex/main-account-cache.ts +24 -0
  121. package/src/codex/management-convergence.ts +114 -0
  122. package/src/codex/native-main-admission.ts +47 -0
  123. package/src/codex/native-main-auth-temp.ts +187 -0
  124. package/src/codex/native-main-claim.ts +167 -0
  125. package/src/codex/native-main-lock-file.ts +158 -0
  126. package/src/codex/native-main-owner.ts +315 -0
  127. package/src/codex/native-profile-api.ts +247 -0
  128. package/src/codex/native-profile-manager.ts +1512 -0
  129. package/src/codex/native-profile-processes.ts +121 -0
  130. package/src/codex/native-profile-recovery.ts +99 -0
  131. package/src/codex/native-profile-stage-store.ts +387 -0
  132. package/src/codex/native-profile-startup.ts +340 -0
  133. package/src/codex/native-profile-store.ts +855 -0
  134. package/src/codex/native-profile-types.ts +120 -0
  135. package/src/codex/native-residue.ts +557 -0
  136. package/src/codex/project-config-warnings.ts +18 -4
  137. package/src/codex/prompt-journal.ts +311 -0
  138. package/src/codex/prompt-layers.ts +967 -0
  139. package/src/codex/prompt-lock.ts +143 -0
  140. package/src/codex/quota-rejection.ts +224 -0
  141. package/src/codex/quota.ts +86 -3
  142. package/src/codex/routing.ts +299 -62
  143. package/src/codex/runtime.ts +159 -38
  144. package/src/codex/shim.ts +39 -13
  145. package/src/codex/subagent-model-fallback.ts +73 -12
  146. package/src/codex/transition-state.ts +604 -0
  147. package/src/codex/upstream-host-health.ts +70 -0
  148. package/src/codex/user-identity.ts +266 -0
  149. package/src/codex/write-coordination.ts +114 -0
  150. package/src/config.ts +562 -26
  151. package/src/generated/jawcode-model-metadata.ts +2 -2
  152. package/src/grok/inject.ts +15 -4
  153. package/src/grok/inspect.ts +45 -0
  154. package/src/images/loop.ts +113 -20
  155. package/src/integrations/config-io.ts +151 -0
  156. package/src/integrations/journal.ts +315 -0
  157. package/src/integrations/merge.ts +135 -0
  158. package/src/integrations/native/ownership-preflight.ts +165 -0
  159. package/src/integrations/ownership.ts +111 -0
  160. package/src/integrations/registry.ts +101 -0
  161. package/src/integrations/serialize.ts +235 -0
  162. package/src/integrations/state.ts +290 -0
  163. package/src/integrations/store.ts +103 -0
  164. package/src/integrations/writer.ts +492 -0
  165. package/src/lib/bounded-body.ts +46 -8
  166. package/src/lib/bun-runtime.ts +110 -1
  167. package/src/lib/bun-stream-caps.ts +2 -1
  168. package/src/lib/redact.ts +407 -2
  169. package/src/lib/shadow-call.ts +24 -0
  170. package/src/lib/translator-budget.ts +10 -0
  171. package/src/lib/upstream-reachability.ts +91 -0
  172. package/src/lib/upstream-retry.ts +154 -2
  173. package/src/lib/windows-secret-acl.ts +212 -11
  174. package/src/lib/winsw.ts +9 -3
  175. package/src/oauth/index.ts +61 -3
  176. package/src/oauth/key-providers.ts +4 -0
  177. package/src/oauth/kiro.ts +50 -6
  178. package/src/oauth/store.ts +31 -6
  179. package/src/oauth/token-guardian.ts +9 -3
  180. package/src/providers/codex-capacity.ts +288 -0
  181. package/src/providers/derive.ts +33 -1
  182. package/src/providers/free-directory.ts +3 -1
  183. package/src/providers/key-failover.ts +71 -3
  184. package/src/providers/openai-sidecar.ts +64 -4
  185. package/src/providers/openai-virtual-models.ts +1 -0
  186. package/src/providers/quota.ts +334 -26
  187. package/src/providers/registry.ts +284 -16
  188. package/src/providers/xai-transport.ts +11 -4
  189. package/src/responses/compaction.ts +8 -1
  190. package/src/responses/hosted-tool-policy.ts +9 -0
  191. package/src/responses/parser.ts +44 -2
  192. package/src/responses/reasoning-envelope.ts +9 -1
  193. package/src/responses/reasoning-replay-cache.ts +105 -0
  194. package/src/responses/spill-store.ts +45 -8
  195. package/src/responses/state.ts +161 -17
  196. package/src/router.ts +302 -16
  197. package/src/routing/analytics.ts +377 -0
  198. package/src/routing/capability.ts +204 -0
  199. package/src/routing/cost.ts +77 -0
  200. package/src/routing/evaluator.ts +444 -0
  201. package/src/routing/health.ts +401 -0
  202. package/src/routing/history/cursor.ts +43 -0
  203. package/src/routing/history/indexer.ts +590 -0
  204. package/src/routing/history/schema.ts +72 -0
  205. package/src/routing/profile.ts +423 -0
  206. package/src/routing/quota.ts +124 -0
  207. package/src/routing/request-evidence.ts +45 -0
  208. package/src/routing/trace.ts +686 -0
  209. package/src/server/auth-cors.ts +46 -6
  210. package/src/server/chat-completions.ts +28 -13
  211. package/src/server/claude-messages.ts +23 -15
  212. package/src/server/gui-static.ts +39 -10
  213. package/src/server/images.ts +10 -1
  214. package/src/server/index.ts +238 -52
  215. package/src/server/lifecycle.ts +228 -9
  216. package/src/server/live.ts +14 -3
  217. package/src/server/management/agent-settings-routes.ts +64 -14
  218. package/src/server/management/combo-routes.ts +5 -5
  219. package/src/server/management/config-routes.ts +1 -1
  220. package/src/server/management/context.ts +42 -2
  221. package/src/server/management/integration-routes.ts +538 -0
  222. package/src/server/management/logs-usage-routes.ts +1 -1
  223. package/src/server/management/model-routes.ts +32 -113
  224. package/src/server/management/model-rows.ts +117 -0
  225. package/src/server/management/native-integration-routes.ts +587 -0
  226. package/src/server/management/oauth-account-routes.ts +1 -1
  227. package/src/server/management/provider-routes.ts +218 -117
  228. package/src/server/management/request-history-routes.ts +191 -0
  229. package/src/server/management/routing-analytics-routes.ts +74 -0
  230. package/src/server/management/routing-profile-routes.ts +177 -0
  231. package/src/server/management/shared.ts +2 -2
  232. package/src/server/management/sidebar-routes.ts +47 -31
  233. package/src/server/management/sync-response.ts +69 -0
  234. package/src/server/management/system-restart.ts +276 -24
  235. package/src/server/management/system-routes.ts +4 -0
  236. package/src/server/management-api.ts +84 -9
  237. package/src/server/management-auth.ts +43 -5
  238. package/src/server/relay-eager.ts +82 -42
  239. package/src/server/relay.ts +120 -6
  240. package/src/server/request-log.ts +26 -6
  241. package/src/server/responses/collaboration.ts +63 -8
  242. package/src/server/responses/compact.ts +272 -41
  243. package/src/server/responses/core.ts +730 -132
  244. package/src/server/responses/fetch-helpers.ts +15 -1
  245. package/src/server/responses-item-id-repair.ts +32 -3
  246. package/src/server/responses-json-events.ts +52 -0
  247. package/src/server/responses-snapshot-repair.ts +621 -0
  248. package/src/server/search.ts +51 -6
  249. package/src/server/sse-payload-rewrite.ts +89 -12
  250. package/src/server/startup-health-cache.ts +7 -1
  251. package/src/server/ws-bridge.ts +11 -17
  252. package/src/service-manager-probe.ts +297 -0
  253. package/src/service.ts +222 -32
  254. package/src/tray/windows-tray.ps1 +9 -0
  255. package/src/tray/windows.ts +15 -7
  256. package/src/types.ts +194 -14
  257. package/src/update/index.ts +13 -13
  258. package/src/update/job.ts +24 -21
  259. package/src/update/notify.ts +7 -3
  260. package/src/usage/cost.ts +0 -0
  261. package/src/usage/expected-prices.ts +129 -10
  262. package/src/usage/log.ts +50 -15
  263. package/src/usage/summary.ts +4 -4
  264. package/src/vision/index.ts +6 -1
  265. package/src/web-search/loop.ts +161 -34
  266. package/gui/dist/assets/index-OY43ubAq.css +0 -1
  267. package/gui/dist/assets/index-YwNnKZcL.js +0 -67
  268. package/gui/dist/provider-icons/antigravity.svg +0 -1
  269. package/gui/dist/provider-icons/claude.svg +0 -1
  270. package/gui/dist/provider-icons/copilot.svg +0 -1
  271. package/gui/dist/provider-icons/cursor.svg +0 -2
  272. package/gui/dist/provider-icons/gemini.svg +0 -1
  273. package/gui/dist/provider-icons/grok-color.svg +0 -1
  274. package/gui/dist/provider-icons/kiro.svg +0 -14
@@ -6,11 +6,13 @@ import {
6
6
  getValidCodexToken,
7
7
  isCodexAccountGenerationLive,
8
8
  } from "./account-store";
9
+ import { isAccountNeedsReauth, markAccountNeedsReauth } from "./account-runtime-state";
10
+ import { isCodexAccountPaused } from "./account-pause";
9
11
  import { ConfigMutationLockError } from "../config";
10
- import { markAccountNeedsReauth } from "./account-runtime-state";
11
12
  import { isCodexAccountUsable } from "./account-usability";
12
13
  import { reconcileMainCodexAccountRuntimeState } from "./account-lifecycle";
13
14
  import { MAIN_CODEX_ACCOUNT_ID, getMainAccountToken } from "./main-account";
15
+ import { isNativeMainTrafficBlocked } from "./native-profile-startup";
14
16
  import {
15
17
  codexQuotaScopeForModel,
16
18
  getCodexQuotaHealthSnapshot,
@@ -38,6 +40,8 @@ export type CodexAuthContext =
38
40
  generation: number;
39
41
  accessToken: string;
40
42
  chatgptAccountId: string;
43
+ /** Bypass Pool selection and suppress quota/transient failover for an exact selector. */
44
+ fixedAccount?: boolean;
41
45
  /**
42
46
  * Set when this request was admitted through an active quota cooldown as
43
47
  * the account's single probe. Must be echoed into the upstream outcome so
@@ -57,6 +61,8 @@ export type CodexAuthContext =
57
61
  writerGeneration: number;
58
62
  accessToken: string;
59
63
  chatgptAccountId: string;
64
+ /** Bypass Pool selection and suppress quota/transient failover for an exact selector. */
65
+ fixedAccount?: boolean;
60
66
  /** See `pool.probeLeaseId`. */
61
67
  probeLeaseId?: string;
62
68
  quotaScope?: CodexQuotaScope;
@@ -100,12 +106,26 @@ export class CodexAuthContextError extends Error {
100
106
  }
101
107
 
102
108
  export class CodexPoolAuthenticationError extends Error {
103
- constructor() {
104
- super("OpenAI account pool has no usable account credential");
109
+ constructor(message = "OpenAI account pool has no usable account credential") {
110
+ super(message);
105
111
  this.name = "CodexPoolAuthenticationError";
106
112
  }
107
113
  }
108
114
 
115
+ export class CodexMainProfileDrainingError extends Error {
116
+ constructor() {
117
+ super("Native Codex main profile is switching; retry this request");
118
+ this.name = "CodexMainProfileDrainingError";
119
+ }
120
+ }
121
+
122
+ export function codexMainProfileDrainingResponse(): Response {
123
+ const response = formatErrorResponse(503, "server_busy", "Native Codex main profile is switching; retry this request");
124
+ const headers = new Headers(response.headers);
125
+ headers.set("Retry-After", "1");
126
+ return new Response(response.body, { status: response.status, headers });
127
+ }
128
+
109
129
  export class CodexDirectAuthenticationError extends Error {
110
130
  constructor() {
111
131
  super("Codex Direct requires a caller Authorization bearer token");
@@ -154,22 +174,31 @@ export function cooldownAccountLabel(accountId: string): string {
154
174
  * as HTTP. The bare "cooling down" string left users with no route but commenting out the
155
175
  * injected `openai_base_url` in config.toml.
156
176
  */
157
- export function cooldownErrorMessage(err: CodexAccountCooldownError): string {
177
+ export function cooldownErrorMessage(err: CodexAccountCooldownError, accountSelector?: string): string {
158
178
  const until = new Date(err.cooldownUntil).toISOString();
159
179
  const scope = err.quotaScope === "spark"
160
180
  ? "Spark quota"
161
181
  : err.quotaScope === "shared"
162
182
  ? "shared native quota"
163
183
  : null;
164
- return `Selected Codex account (${cooldownAccountLabel(err.accountId)})${scope ? ` ${scope} is` : " is"} cooling down until ${until}`
165
- + ` (source: ${err.cooldownSource ?? "default"}).`
166
- + ` Run 'ocx account list openai' to find the id, then`
167
- + ` 'ocx account clear-cooldown openai <id>' to lift it, or switch accounts with 'ocx account use openai <id>'.`;
184
+ const selected = accountSelector
185
+ ? `Selected Codex account selector (${accountSelector})`
186
+ : `Selected Codex account (${cooldownAccountLabel(err.accountId)})`;
187
+ const recovery = accountSelector
188
+ ? " This request is pinned to that selector and will not switch accounts; choose another account-qualified model or retry later."
189
+ : " Run 'ocx account list openai' to find the id, then"
190
+ + " 'ocx account clear-cooldown openai <id>' to lift it, or switch accounts with 'ocx account use openai <id>'.";
191
+ return `${selected}${scope ? ` ${scope} is` : " is"} cooling down until ${until}`
192
+ + ` (source: ${err.cooldownSource ?? "default"}).${recovery}`;
168
193
  }
169
194
 
170
195
  /** HTTP form of {@link cooldownErrorMessage}, carrying Retry-After for well-behaved clients. */
171
- export function cooldownErrorResponse(err: CodexAccountCooldownError, now = Date.now()): Response {
172
- const res = formatErrorResponse(429, "rate_limit_error", cooldownErrorMessage(err));
196
+ export function cooldownErrorResponse(
197
+ err: CodexAccountCooldownError,
198
+ now = Date.now(),
199
+ accountSelector?: string,
200
+ ): Response {
201
+ const res = formatErrorResponse(429, "rate_limit_error", cooldownErrorMessage(err, accountSelector));
173
202
  const headers = new Headers(res.headers);
174
203
  headers.set("Retry-After", String(Math.max(1, Math.ceil((err.cooldownUntil - now) / 1000))));
175
204
  return new Response(res.body, { status: res.status, headers });
@@ -195,8 +224,22 @@ export function shouldMarkAccountNeedsReauthForCodexAuthFailure(cause: unknown):
195
224
 
196
225
  export interface ResolveCodexAuthContextOptions {
197
226
  excludeAccountId?: string;
227
+ /** Resolve exactly this account without consulting or mutating Pool selection. */
228
+ accountId?: string;
198
229
  /** Final native model selected for this request, used to select its quota group. */
199
230
  modelId?: string;
231
+ /** Short reservation converted to turn ownership before native `__main__` token materialization. */
232
+ beginCodexAccountSelection?: () => CodexAccountSelectionAdmission | undefined;
233
+ /** Test-only native credential read seams. */
234
+ isMainAccountTokenLive?: () => boolean;
235
+ getMainAccountToken?: typeof getMainAccountToken;
236
+ primeCodexPoolQuotas?: (config: OcxConfig, reason: string) => Promise<void>;
237
+ }
238
+
239
+ export interface CodexAccountSelectionAdmission {
240
+ readonly mainProfileDraining: boolean;
241
+ claimMainProfile(): boolean;
242
+ release(): void;
200
243
  }
201
244
 
202
245
  export async function resolveCodexAuthContext(
@@ -206,33 +249,106 @@ export async function resolveCodexAuthContext(
206
249
  options: ResolveCodexAuthContextOptions = {},
207
250
  ): Promise<CodexAuthContext> {
208
251
  const writerGeneration = captureConfigGeneration();
209
- if (mode === "direct") {
252
+ const fixedAccountId = options.accountId;
253
+ if (fixedAccountId !== undefined && options.excludeAccountId !== undefined) {
254
+ throw new Error("Codex auth context cannot select and exclude an account simultaneously");
255
+ }
256
+ // An explicit namespace binding is stronger than the provider's default mode. It must use the
257
+ // selected stored credential even while the canonical OpenAI provider is globally Direct.
258
+ if (mode === "direct" && fixedAccountId === undefined) {
210
259
  if (!hasCallerCodexBearer(headers)) throw new CodexDirectAuthenticationError();
211
260
  return { kind: "main", accountId: null };
212
261
  }
213
- reconcileMainCodexAccountRuntimeState();
214
- const threadId = headers.get("x-codex-parent-thread-id");
262
+ // Retained startup recovery makes the physical main identity ineligible. Routing
263
+ // can still preserve service by selecting a healthy configured pool account.
264
+ const nativeMainTrafficBlocked = isNativeMainTrafficBlocked();
265
+ const selectionAdmission = options.beginCodexAccountSelection?.();
266
+ const nativeMainReadsForbidden = nativeMainTrafficBlocked || selectionAdmission?.mainProfileDraining === true;
267
+ const selectionOptions = {
268
+ // Temporary switch drain keeps the candidate until the atomic claim rejects
269
+ // it. Retained recovery makes main wholly ineligible so pool routing continues.
270
+ nativeMainSelectionOnly: !nativeMainTrafficBlocked
271
+ && selectionAdmission?.mainProfileDraining === true,
272
+ isMainAccountTokenLive: options.isMainAccountTokenLive,
273
+ };
274
+ let accountId: string;
215
275
  const quotaScope = codexQuotaScopeForModel(options.modelId);
216
- const resolution = options.excludeAccountId
217
- ? (() => {
218
- const accountId = pickAlternateCodexAccount(config, options.excludeAccountId!, Date.now(), quotaScope);
219
- return accountId
220
- ? { status: "selected" as const, accountId }
221
- : { status: "none" as const };
222
- })()
223
- : resolveCodexAccountForThreadDetailed(threadId, config, Date.now(), quotaScope);
224
- if (resolution.status === "expired") throw new CodexThreadAffinityExpiredError(resolution.accountId);
225
- let accountId = resolution.status === "selected" ? resolution.accountId : null;
226
- if (!accountId) throw new CodexPoolAuthenticationError();
276
+ try {
277
+ // A pre-drain selector reserves the native identity while reconciliation and
278
+ // routing inspect it. Selectors arriving after the fence skip reconciliation
279
+ // and may still route to non-main pool accounts without touching switch state.
280
+ if (!nativeMainReadsForbidden) reconcileMainCodexAccountRuntimeState();
281
+ const threadId = headers.get("x-codex-parent-thread-id");
282
+ const resolution = fixedAccountId !== undefined
283
+ ? { status: "selected" as const, accountId: fixedAccountId }
284
+ : options.excludeAccountId
285
+ ? (() => {
286
+ const selected = pickAlternateCodexAccount(
287
+ config,
288
+ options.excludeAccountId!,
289
+ Date.now(),
290
+ quotaScope,
291
+ selectionOptions,
292
+ );
293
+ return selected
294
+ ? { status: "selected" as const, accountId: selected }
295
+ : { status: "none" as const };
296
+ })()
297
+ : resolveCodexAccountForThreadDetailed(threadId, config, Date.now(), quotaScope, selectionOptions);
298
+ if (resolution.status === "expired") throw new CodexThreadAffinityExpiredError(resolution.accountId);
299
+ const selected = resolution.status === "selected" ? resolution.accountId : null;
300
+ if (!selected) {
301
+ if (fixedAccountId !== undefined) {
302
+ throw new CodexPoolAuthenticationError("Selected Codex account is unavailable");
303
+ }
304
+ // Recovery deliberately makes physical main ineligible. If no healthy
305
+ // pool route is configured and main is the intended route, report the
306
+ // temporary fence rather than misclassifying that credential as invalid.
307
+ // A configured pool retry/exclusion that finds no alternate preserves its
308
+ // ordinary pool-auth failure instead of being mislabeled as a main fence.
309
+ if (nativeMainTrafficBlocked && !options.excludeAccountId) {
310
+ throw new CodexMainProfileDrainingError();
311
+ }
312
+ throw new CodexPoolAuthenticationError();
313
+ }
314
+ accountId = selected;
315
+ if (accountId === MAIN_CODEX_ACCOUNT_ID && nativeMainTrafficBlocked) {
316
+ throw new CodexMainProfileDrainingError();
317
+ }
318
+ if (
319
+ accountId === MAIN_CODEX_ACCOUNT_ID
320
+ && selectionAdmission
321
+ && !selectionAdmission.claimMainProfile()
322
+ ) {
323
+ throw new CodexMainProfileDrainingError();
324
+ }
325
+ if (fixedAccountId !== undefined) {
326
+ if (isCodexAccountPaused(config, accountId)) {
327
+ throw new CodexPoolAuthenticationError("Selected Codex account is unavailable");
328
+ }
329
+ if (isAccountNeedsReauth(accountId)) {
330
+ throw new CodexPoolAuthenticationError("Selected Codex account needs reauthentication");
331
+ }
332
+ if (!isCodexAccountUsable(config, accountId, selectionOptions)) {
333
+ throw new CodexPoolAuthenticationError("Selected Codex account is unavailable");
334
+ }
335
+ }
336
+ } finally {
337
+ selectionAdmission?.release();
338
+ }
227
339
  // Lazy prime: if the selected account has no quota yet, the pool is likely
228
340
  // unprimed (dashboard never opened, or startup prime was blocked). Kick a
229
341
  // best-effort prime so the NEXT routing decision has real scores. This never
230
342
  // blocks the current request, and the helper's single-flight guard collapses
231
343
  // repeated triggers into one pass.
232
- if (!getAccountQuota(accountId)) {
233
- import("./auth-api")
234
- .then(({ primeCodexPoolQuotas }) => primeCodexPoolQuotas(config, "pre-route"))
235
- .catch(() => {});
344
+ if (fixedAccountId === undefined && !nativeMainReadsForbidden && !getAccountQuota(accountId)) {
345
+ if (options.primeCodexPoolQuotas) {
346
+ void options.primeCodexPoolQuotas(config, "pre-route").catch(() => {});
347
+ } else {
348
+ import("./auth-api")
349
+ .then(({ primeCodexPoolQuotas }) => primeCodexPoolQuotas(config, "pre-route"))
350
+ .catch(() => {});
351
+ }
236
352
  }
237
353
  // Snapshot (not just the deadline) so a refused request can report WHY it is cooled:
238
354
  // a literal Retry-After reads very differently to a user than a reset-derived guess.
@@ -244,6 +360,11 @@ export async function resolveCodexAuthContext(
244
360
  let probeLeaseId: string | undefined;
245
361
  let probeQuotaScope: CodexQuotaScope | undefined;
246
362
  if (cooldownUntil) {
363
+ // Exact bindings are not Pool recovery traffic. Fail closed instead of consuming the Pool's
364
+ // one probe lease or selecting another account.
365
+ if (fixedAccountId !== undefined) {
366
+ throw new CodexAccountCooldownError(accountId, cooldownUntil, cooldown?.cooldownSource, cooldown?.quotaScope);
367
+ }
247
368
  probeQuotaScope = cooldown?.quotaScope;
248
369
  probeLeaseId = probeQuotaScope
249
370
  ? tryAcquireCodexQuotaScopeProbeLease(accountId, probeQuotaScope) ?? undefined
@@ -255,12 +376,14 @@ export async function resolveCodexAuthContext(
255
376
 
256
377
  if (accountId === MAIN_CODEX_ACCOUNT_ID) {
257
378
  // Main account in rotation: inject the read-only auth.json token and fail closed if it vanished.
258
- const token = getMainAccountToken();
379
+ const token = (options.getMainAccountToken ?? getMainAccountToken)();
259
380
  if (!token) {
260
381
  // Nothing will reach upstream, so give the probe back instead of burning it.
261
382
  if (probeLeaseId && probeQuotaScope) releaseCodexQuotaScopeProbeLease(accountId, probeQuotaScope, probeLeaseId);
262
383
  else if (probeLeaseId) releaseCodexQuotaProbeLease(accountId, probeLeaseId);
263
- throw new CodexPoolAuthenticationError();
384
+ throw new CodexPoolAuthenticationError(
385
+ fixedAccountId !== undefined ? "Selected Codex account is unavailable" : undefined,
386
+ );
264
387
  }
265
388
  return {
266
389
  kind: "main-pool",
@@ -268,6 +391,7 @@ export async function resolveCodexAuthContext(
268
391
  writerGeneration,
269
392
  accessToken: token.accessToken,
270
393
  chatgptAccountId: token.chatgptAccountId,
394
+ ...(fixedAccountId !== undefined ? { fixedAccount: true } : {}),
271
395
  ...(quotaScope ? { quotaScope } : {}),
272
396
  ...(probeLeaseId ? { probeLeaseId } : {}),
273
397
  ...(probeQuotaScope ? { probeQuotaScope } : {}),
@@ -283,6 +407,7 @@ export async function resolveCodexAuthContext(
283
407
  generation: token.generation,
284
408
  accessToken: token.accessToken,
285
409
  chatgptAccountId: token.chatgptAccountId,
410
+ ...(fixedAccountId !== undefined ? { fixedAccount: true } : {}),
286
411
  ...(quotaScope ? { quotaScope } : {}),
287
412
  ...(probeLeaseId ? { probeLeaseId } : {}),
288
413
  ...(probeQuotaScope ? { probeQuotaScope } : {}),
@@ -47,6 +47,7 @@ export interface StartupHealth {
47
47
  recommendedCommand: string | null;
48
48
  commands: {
49
49
  installService: string;
50
+ repairService: string;
50
51
  installShim: string;
51
52
  restoreNative: string;
52
53
  };
@@ -54,6 +55,7 @@ export interface StartupHealth {
54
55
 
55
56
  const COMMANDS = {
56
57
  installService: "ocx service install",
58
+ repairService: "ocx service repair",
57
59
  installShim: "ocx codex-shim install",
58
60
  restoreNative: "ocx restore",
59
61
  } as const;
@@ -88,7 +90,12 @@ export function deriveStartupHealth(inputs: StartupHealthInputs): StartupHealth
88
90
  : inputs.routingKind === "custom-local" || inputs.routingKind === "unknown"
89
91
  ? COMMANDS.restoreNative
90
92
  : inputs.serviceSupported
91
- ? COMMANDS.installService
93
+ // An already-registered service is refreshed in place: `repair` rewrites its assets
94
+ // and restarts it without re-registering, so it needs no elevation on Windows and
95
+ // cannot switch a WinSW install to Task Scheduler the way `install` would. Only a
96
+ // genuinely absent (or conflicting, which needs uninstall-then-install) service
97
+ // gets the registering command.
98
+ ? (inputs.serviceInstalled && !inputs.serviceConflict ? COMMANDS.repairService : COMMANDS.installService)
92
99
  : COMMANDS.restoreNative;
93
100
  return {
94
101
  ...inputs,
@@ -0,0 +1,62 @@
1
+ import type { OcxConfig } from "../../types";
2
+ import { isMainCodexAccountTarget } from "../account-namespaces";
3
+ import type { RawEntry } from "./parsing";
4
+
5
+ /** Stable nonsemantic marker used to distinguish generated rows from provider-owned rows. */
6
+ export const CODEX_ACCOUNT_BOUND_CATALOG_KIND = "account-selector-v1";
7
+
8
+ /**
9
+ * Public selectors whose configured account row still exists.
10
+ *
11
+ * Stale mappings stay in config so exact routing keeps failing closed, but they must not advertise
12
+ * a deleted account. Credential health, pause, and reauthentication state intentionally do not
13
+ * churn catalog identity. Selector keys have already passed config's nonempty, single-segment
14
+ * namespace validation. Only those public keys leave this boundary; private account ids do not.
15
+ */
16
+ export function visibleCodexAccountSelectors(
17
+ config: Pick<OcxConfig, "codexAccounts" | "codexAccountNamespaces">,
18
+ ): string[] {
19
+ const storedPoolAccounts = new Set(
20
+ (config.codexAccounts ?? [])
21
+ .filter(account => !account.isMain)
22
+ .map(account => account.id),
23
+ );
24
+ return Object.entries(config.codexAccountNamespaces ?? {})
25
+ .filter(([, accountId]) =>
26
+ isMainCodexAccountTarget(accountId) || storedPoolAccounts.has(accountId)
27
+ )
28
+ .map(([selector]) => selector);
29
+ }
30
+
31
+ export function accountBoundNativeDisplayName(selector: string, native: RawEntry): string {
32
+ const rawModel = typeof native.display_name === "string"
33
+ ? native.display_name
34
+ : String(native.slug ?? "");
35
+ const model = rawModel
36
+ .replace(/^gpt-/i, "")
37
+ .split("-")
38
+ .filter(Boolean)
39
+ .map(part => /^[a-z]/.test(part) ? part.charAt(0).toUpperCase() + part.slice(1) : part)
40
+ .join(" ");
41
+ return `${selector} / ${model}`;
42
+ }
43
+
44
+ /** Identify current generated rows without changing Codex's semantic model fields. */
45
+ export function trustedAccountBoundNativeCatalogSlug(entry: RawEntry): string | undefined {
46
+ if (entry.opencodex_catalog_kind !== CODEX_ACCOUNT_BOUND_CATALOG_KIND
47
+ || typeof entry.slug !== "string") return undefined;
48
+ const slash = entry.slug.indexOf("/");
49
+ if (slash <= 0 || slash !== entry.slug.lastIndexOf("/") || slash === entry.slug.length - 1) {
50
+ return undefined;
51
+ }
52
+ return entry.slug.slice(slash + 1);
53
+ }
54
+
55
+ export function accountBoundNativeModelSlugs(
56
+ config: Pick<OcxConfig, "codexAccounts" | "codexAccountNamespaces">,
57
+ nativeSlugs: Iterable<string>,
58
+ ): string[] {
59
+ const natives = [...nativeSlugs];
60
+ return visibleCodexAccountSelectors(config)
61
+ .flatMap(selector => natives.map(slug => `${selector}/${slug}`));
62
+ }
@@ -281,6 +281,8 @@ export function resetOpenAiApiCatalogWarningStateForTests(): void {
281
281
  export const slugAliasCollisionWarnings = new Set<string>();
282
282
 
283
283
  export const comboMasqueradeCollisionWarnings = new Set<string>();
284
+
285
+ export const accountSelectorShadowCollisionWarnings = new Set<string>();
284
286
  let lastWarningReconciledGeneration = 0;
285
287
 
286
288
  export function reconcileCatalogWarningMemos(generation: number): number {
@@ -288,11 +290,13 @@ export function reconcileCatalogWarningMemos(generation: number): number {
288
290
  const removed = openAiApiCollisionWarnings.size
289
291
  + comboCatalogWarningSignatures.size
290
292
  + slugAliasCollisionWarnings.size
291
- + comboMasqueradeCollisionWarnings.size;
293
+ + comboMasqueradeCollisionWarnings.size
294
+ + accountSelectorShadowCollisionWarnings.size;
292
295
  openAiApiCollisionWarnings.clear();
293
296
  comboCatalogWarningSignatures.clear();
294
297
  slugAliasCollisionWarnings.clear();
295
298
  comboMasqueradeCollisionWarnings.clear();
299
+ accountSelectorShadowCollisionWarnings.clear();
296
300
  lastWarningReconciledGeneration = generation;
297
301
  return removed;
298
302
  }
@@ -305,6 +309,15 @@ export function warnComboMasqueradeCollisionOnce(slug: string): void {
305
309
  );
306
310
  }
307
311
 
312
+ /** Warn once when a live provider row loses its reserved slug to an account selector. */
313
+ export function warnAccountSelectorShadowedProviderOnce(slug: string): void {
314
+ if (accountSelectorShadowCollisionWarnings.has(slug)) return;
315
+ accountSelectorShadowCollisionWarnings.add(slug);
316
+ console.warn(
317
+ `[opencodex] account selector collision on "${safeCatalogWarningLabel(slug)}": the account-bound native model wins and the shadowed provider model is omitted from the catalog. Rename the provider or account selector.`,
318
+ );
319
+ }
320
+
308
321
  export function resolveSlugAliasCollisions(goModels: CatalogModel[]): Set<CatalogModel> {
309
322
  const skipped = new Set<CatalogModel>();
310
323
  const winnerByAlias = new Map<string, CatalogModel>();