@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
@@ -13,32 +13,38 @@ export type CodexRuntimeSource =
13
13
  | "path"
14
14
  | "fallback";
15
15
 
16
+ export type DeepReadonly<T> =
17
+ T extends (...args: never[]) => unknown ? T
18
+ : T extends readonly (infer U)[] ? readonly DeepReadonly<U>[]
19
+ : T extends object ? { readonly [K in keyof T]: DeepReadonly<T[K]> }
20
+ : T;
21
+
16
22
  export interface ResolvedCodexRuntime {
17
- command: string;
18
- version: string | null;
19
- source: CodexRuntimeSource;
23
+ readonly command: string;
24
+ readonly version: string | null;
25
+ readonly source: CodexRuntimeSource;
20
26
  }
21
27
 
22
28
  export interface RuntimeProbeFailure {
23
- command: string;
24
- source: CodexRuntimeSource;
25
- reason: string;
29
+ readonly command: string;
30
+ readonly source: CodexRuntimeSource;
31
+ readonly reason: string;
26
32
  }
27
33
 
28
34
  export interface EffortClampDiagnostic {
29
- runtimePath: string;
30
- runtimeVersion: string | null;
31
- removedEfforts: string[];
32
- affectedModels: string[];
35
+ readonly runtimePath: string;
36
+ readonly runtimeVersion: string | null;
37
+ readonly removedEfforts: readonly string[];
38
+ readonly affectedModels: readonly string[];
33
39
  }
34
40
 
35
41
  export interface ResolveCodexRuntimeResult {
36
- runtime: ResolvedCodexRuntime;
37
- failures: RuntimeProbeFailure[];
38
- replacedConfigured?: { from: ResolvedCodexRuntime; reason: string };
39
- newerAvailable?: ResolvedCodexRuntime;
42
+ readonly runtime: ResolvedCodexRuntime;
43
+ readonly failures: readonly RuntimeProbeFailure[];
44
+ readonly replacedConfigured?: Readonly<{ from: ResolvedCodexRuntime; reason: string }>;
45
+ readonly newerAvailable?: ResolvedCodexRuntime;
40
46
  /** Set when the selected runtime could not be written to codex-runtime.json. */
41
- persistError?: string;
47
+ readonly persistError?: string;
42
48
  }
43
49
 
44
50
  export type RuntimeExecFile = (
@@ -70,17 +76,43 @@ export interface ResolveCodexRuntimeDeps {
70
76
  discoverAlternatives?: boolean;
71
77
  }
72
78
 
73
- interface PersistedRuntimeState {
74
- version: 1;
75
- command: string;
76
- source: CodexRuntimeSource;
77
- selectedVersion: string | null;
78
- updatedAt: string;
79
+ export interface PersistedCodexRuntimeState {
80
+ readonly version: 1;
81
+ readonly command: string;
82
+ readonly source: CodexRuntimeSource;
83
+ readonly selectedVersion?: string | null;
84
+ readonly updatedAt: string;
79
85
  }
80
86
 
81
87
  const PERSIST_FILE = "codex-runtime.json";
82
88
  const CLAMP_PERSIST_FILE = "codex-runtime-clamp.json";
83
89
 
90
+ function cloneAndDeepFreeze<T>(value: T): DeepReadonly<T> {
91
+ const clone = (current: unknown): unknown => {
92
+ if (Array.isArray(current)) return current.map(clone);
93
+ if (current && typeof current === "object") {
94
+ const out: Record<string, unknown> = {};
95
+ for (const [key, child] of Object.entries(current)) out[key] = clone(child);
96
+ return out;
97
+ }
98
+ return current;
99
+ };
100
+ const freeze = (current: unknown): unknown => {
101
+ if (!current || typeof current !== "object" || Object.isFrozen(current)) return current;
102
+ for (const child of Object.values(current)) freeze(child);
103
+ return Object.freeze(current);
104
+ };
105
+ return freeze(clone(value)) as DeepReadonly<T>;
106
+ }
107
+
108
+ function isCodexRuntimeSource(value: unknown): value is CodexRuntimeSource {
109
+ return value === "environment"
110
+ || value === "configured"
111
+ || value === "shim"
112
+ || value === "path"
113
+ || value === "fallback";
114
+ }
115
+
84
116
  export function codexRuntimeStatePath(configDir: string = getConfigDir()): string {
85
117
  return join(configDir, PERSIST_FILE);
86
118
  }
@@ -94,9 +126,16 @@ interface PersistedClampState extends EffortClampDiagnostic {
94
126
  updatedAt: string;
95
127
  }
96
128
 
129
+ interface LoadedEffortClampDiagnostic {
130
+ runtimePath: string;
131
+ runtimeVersion: string | null;
132
+ removedEfforts: string[];
133
+ affectedModels: string[];
134
+ }
135
+
97
136
  export function loadLastEffortClamp(
98
137
  deps: ResolveCodexRuntimeDeps = {},
99
- ): EffortClampDiagnostic | null {
138
+ ): LoadedEffortClampDiagnostic | null {
100
139
  const configDir = deps.configDir ?? getConfigDir();
101
140
  const read = deps.readFileSync ?? ((path, encoding) => readFileSync(path, encoding));
102
141
  try {
@@ -196,15 +235,31 @@ export function compareCodexVersions(a: string | null, b: string | null): number
196
235
  return 0;
197
236
  }
198
237
 
238
+ /** Parse already-observed runtime-selection bytes without consulting the filesystem. */
239
+ export function parsePersistedCodexRuntime(
240
+ bytes: string | Uint8Array,
241
+ ): DeepReadonly<PersistedCodexRuntimeState> | null {
242
+ try {
243
+ const text = typeof bytes === "string" ? bytes : Buffer.from(bytes).toString("utf8");
244
+ const raw = JSON.parse(text) as Partial<PersistedCodexRuntimeState>;
245
+ if (raw.version !== 1 || typeof raw.command !== "string" || !raw.command.trim()) return null;
246
+ if (!isCodexRuntimeSource(raw.source) || typeof raw.updatedAt !== "string") return null;
247
+ if (raw.selectedVersion !== undefined
248
+ && raw.selectedVersion !== null
249
+ && typeof raw.selectedVersion !== "string") return null;
250
+ return cloneAndDeepFreeze(raw as PersistedCodexRuntimeState);
251
+ } catch {
252
+ return null;
253
+ }
254
+ }
255
+
199
256
  export function loadPersistedCodexRuntime(
200
257
  deps: ResolveCodexRuntimeDeps = {},
201
- ): PersistedRuntimeState | null {
258
+ ): DeepReadonly<PersistedCodexRuntimeState> | null {
202
259
  const configDir = deps.configDir ?? getConfigDir();
203
260
  const read = deps.readFileSync ?? ((path, encoding) => readFileSync(path, encoding));
204
261
  try {
205
- const raw = JSON.parse(read(codexRuntimeStatePath(configDir), "utf8")) as PersistedRuntimeState;
206
- if (raw?.version !== 1 || typeof raw.command !== "string" || !raw.command.trim()) return null;
207
- return raw;
262
+ return parsePersistedCodexRuntime(read(codexRuntimeStatePath(configDir), "utf8"));
208
263
  } catch {
209
264
  return null;
210
265
  }
@@ -216,16 +271,16 @@ export function persistCodexRuntime(
216
271
  ): void {
217
272
  const configDir = deps.configDir ?? getConfigDir();
218
273
  mkdirSync(configDir, { recursive: true, mode: 0o700 });
219
- const payload: PersistedRuntimeState = {
274
+ const payload: PersistedCodexRuntimeState = {
220
275
  version: 1,
221
276
  command: runtime.command,
222
277
  source: runtime.source,
223
278
  selectedVersion: runtime.version,
224
279
  updatedAt: new Date((deps.now ?? Date.now)()).toISOString(),
225
280
  };
281
+ // Invalidate process authority before the persisted replacement is visible.
282
+ clearCodexRuntimeResolveCache();
226
283
  atomicWriteFile(codexRuntimeStatePath(configDir), `${JSON.stringify(payload, null, 2)}\n`);
227
- // Same-process consumers must re-resolve; catalog cache is keyed by runtime identity.
228
- resolveCache = null;
229
284
  }
230
285
 
231
286
  function probeVersion(
@@ -360,15 +415,68 @@ export function effortClampAppliesToRuntime(
360
415
  }
361
416
 
362
417
  const RESOLVE_CACHE_MS = 15_000;
363
- let resolveCache: { key: string; at: number; value: ResolveCodexRuntimeResult } | null = null;
418
+ interface ResolveCacheMemo {
419
+ readonly key: string;
420
+ readonly at: number;
421
+ readonly epoch: number;
422
+ readonly valueIdentity: string;
423
+ readonly value: DeepReadonly<ResolveCodexRuntimeResult>;
424
+ }
425
+
426
+ export type CodexRuntimeProcessCachePeek =
427
+ | Readonly<{
428
+ kind: "available";
429
+ epoch: number;
430
+ valueIdentity: string;
431
+ value: DeepReadonly<ResolveCodexRuntimeResult>;
432
+ }>
433
+ | Readonly<{ kind: "unavailable"; epoch: number }>;
434
+
435
+ let resolveCacheEpoch = 0;
436
+ let resolveCache: ResolveCacheMemo | null = null;
437
+
438
+ function publishResolveCache(key: string, at: number, value: ResolveCodexRuntimeResult): void {
439
+ const epoch = ++resolveCacheEpoch;
440
+ resolveCache = {
441
+ key,
442
+ at,
443
+ epoch,
444
+ valueIdentity: `runtime:${epoch}`,
445
+ value: cloneAndDeepFreeze(value),
446
+ };
447
+ }
448
+
449
+ function clearResolveCache(): void {
450
+ resolveCacheEpoch += 1;
451
+ resolveCache = null;
452
+ }
453
+
454
+ /** Clear process-local runtime authority without resolving a replacement. */
455
+ export function clearCodexRuntimeResolveCache(): void {
456
+ clearResolveCache();
457
+ }
458
+
459
+ /** Observe only an unexpired successful process memo; never resolves or probes. */
460
+ export function peekCodexRuntimeProcessCache(): CodexRuntimeProcessCachePeek {
461
+ const memo = resolveCache;
462
+ if (!memo || Date.now() - memo.at >= RESOLVE_CACHE_MS) {
463
+ return Object.freeze({ kind: "unavailable" as const, epoch: resolveCacheEpoch });
464
+ }
465
+ return Object.freeze({
466
+ kind: "available" as const,
467
+ epoch: memo.epoch,
468
+ valueIdentity: memo.valueIdentity,
469
+ value: cloneAndDeepFreeze(memo.value),
470
+ });
471
+ }
364
472
 
365
473
  function persistedRuntimeCacheStamp(deps: ResolveCodexRuntimeDeps): string {
366
474
  // Include on-disk selection so doctor --fix in another process busts this memo.
367
475
  const configDir = deps.configDir ?? getConfigDir();
368
476
  const read = deps.readFileSync ?? ((path, encoding) => readFileSync(path, encoding));
369
477
  try {
370
- const raw = JSON.parse(read(codexRuntimeStatePath(configDir), "utf8")) as PersistedRuntimeState;
371
- if (raw?.version !== 1 || typeof raw.command !== "string") return "";
478
+ const raw = parsePersistedCodexRuntime(read(codexRuntimeStatePath(configDir), "utf8"));
479
+ if (!raw) return "";
372
480
  return `${raw.command}|${raw.selectedVersion ?? ""}|${raw.updatedAt ?? ""}`;
373
481
  } catch {
374
482
  return "";
@@ -397,17 +505,30 @@ function resolveCacheKey(deps: ResolveCodexRuntimeDeps): string | null {
397
505
  export function resolveCodexRuntime(deps: ResolveCodexRuntimeDeps = {}): ResolveCodexRuntimeResult {
398
506
  const cacheKey = resolveCacheKey(deps);
399
507
  if (cacheKey && resolveCache && resolveCache.key === cacheKey && Date.now() - resolveCache.at < RESOLVE_CACHE_MS) {
400
- return resolveCache.value;
508
+ return cloneAndDeepFreeze(resolveCache.value);
401
509
  }
402
510
 
403
511
  const result = resolveCodexRuntimeUncached(deps);
404
- if (cacheKey) resolveCache = { key: cacheKey, at: Date.now(), value: result };
405
- return result;
512
+ if (cacheKey) {
513
+ publishResolveCache(cacheKey, Date.now(), result);
514
+ return cloneAndDeepFreeze(resolveCache!.value);
515
+ }
516
+ return cloneAndDeepFreeze(result);
406
517
  }
407
518
 
408
519
  /** Test-only: drop the short-lived process resolve cache. */
409
520
  export function resetCodexRuntimeResolveCacheForTests(): void {
410
- resolveCache = null;
521
+ clearCodexRuntimeResolveCache();
522
+ }
523
+
524
+ /** Test-only owner mutation seam; input is cloned and frozen before publication. */
525
+ export function setCodexRuntimeResolveCacheForTests(
526
+ value: ResolveCodexRuntimeResult,
527
+ deps: ResolveCodexRuntimeDeps = {},
528
+ ): void {
529
+ const key = resolveCacheKey(deps);
530
+ if (!key) throw new TypeError("Injected runtime dependencies cannot populate the process memo.");
531
+ publishResolveCache(key, Date.now(), value);
411
532
  }
412
533
 
413
534
  function resolveCodexRuntimeUncached(deps: ResolveCodexRuntimeDeps = {}): ResolveCodexRuntimeResult {
@@ -468,7 +589,7 @@ function resolveCodexRuntimeUncached(deps: ResolveCodexRuntimeDeps = {}): Resolv
468
589
  replacedConfigured = {
469
590
  from: {
470
591
  command: persisted.command,
471
- version: persisted.selectedVersion,
592
+ version: persisted.selectedVersion ?? null,
472
593
  source: "configured",
473
594
  },
474
595
  reason: failures.find(item => sameRuntimeCommand(item.command, persisted.command))?.reason
@@ -518,7 +639,7 @@ export function resolveAndPersistCodexRuntime(
518
639
  const message = error instanceof Error ? error.message : String(error);
519
640
  const persistError = redactUserPath(redactSecretString(message)).slice(0, 200);
520
641
  console.warn(`[opencodex] Failed to persist Codex runtime selection: ${persistError}`);
521
- return { ...result, persistError };
642
+ return cloneAndDeepFreeze({ ...result, persistError });
522
643
  }
523
644
  }
524
645
  return result;
package/src/codex/shim.ts CHANGED
@@ -19,7 +19,8 @@ import {
19
19
  writeFileSync,
20
20
  } from "node:fs";
21
21
  import { getConfigDir } from "../config";
22
- import { durableBunPath } from "../lib/bun-runtime";
22
+ import { BUN_RUNTIME_PATH_ENV, BUN_RUNTIME_SOURCE_ENV, durableBunRuntime } from "../lib/bun-runtime";
23
+ import type { BunRuntimeSource } from "../lib/bun-runtime";
23
24
  import { isProcessAlive } from "../lib/process-control";
24
25
  import { serviceApiTokenFilePath } from "../lib/service-secrets";
25
26
  import { recordOwnedConfigPath } from "../lib/config-ownership";
@@ -120,11 +121,13 @@ export type CodexShimAutoRestoreResult =
120
121
  | { status: "ineligible" | "deferred"; message?: string }
121
122
  | { status: "restored"; message: string };
122
123
 
123
- function cliEntry(): { bun: string; cli: string } {
124
+ function cliEntry(): { bun: string; bunRuntimeSource: BunRuntimeSource; cli: string } {
124
125
  // Bundled Bun path (survives `ocx update`); all three shim builders
125
126
  // (Unix / Windows cmd / Windows PowerShell) receive it via this entry.
126
127
  // This module lives in src/codex/, the CLI entry in src/cli/index.ts.
127
- return { bun: durableBunPath(), cli: join(import.meta.dir, "..", "cli", "index.ts") };
128
+ // Path and provenance resolve together so the marker always describes this binary.
129
+ const runtime = durableBunRuntime();
130
+ return { bun: runtime.path, bunRuntimeSource: runtime.source, cli: join(import.meta.dir, "..", "cli", "index.ts") };
128
131
  }
129
132
 
130
133
  function commandNames(name: string): string[] {
@@ -366,7 +369,16 @@ function shQuote(value: string): string {
366
369
  return `'${value.replace(/'/g, "'\\''")}'`;
367
370
  }
368
371
 
369
- export function buildUnixCodexShim(realCodexPath: string, bunPath: string, cliPath: string, tokenFile = serviceApiTokenFilePath()): string {
372
+ // Provenance is required rather than defaulted: a default would let a caller pass an
373
+ // override binary and silently label it something else, which is precisely the
374
+ // path/marker disagreement this feature exists to prevent.
375
+ //
376
+ // The marker is scoped to the `ensure` invocation in every flavor below and is never
377
+ // exported into the shim's own environment. A shim wraps the real `codex`, so an
378
+ // exported marker would be inherited by Codex and everything it spawns — a shell that
379
+ // then ran a *different* Bun directly would carry a provenance describing a binary it
380
+ // is not executing.
381
+ export function buildUnixCodexShim(realCodexPath: string, bunPath: string, cliPath: string, bunRuntimeSource: BunRuntimeSource, tokenFile = serviceApiTokenFilePath()): string {
370
382
  const internalCommands = CODEX_INTERNAL_COMMANDS.join("|");
371
383
  const valueOptions = CODEX_GLOBAL_OPTIONS_WITH_VALUE.join("|");
372
384
  return `#!/usr/bin/env sh
@@ -406,7 +418,7 @@ case "$ocx_subcommand" in
406
418
  ;;
407
419
  *)
408
420
  if [ -z "$OCX_SHIM_BYPASS" ]; then
409
- ${shQuote(bunPath)} ${shQuote(cliPath)} ensure >/dev/null 2>&1 || true
421
+ ${BUN_RUNTIME_SOURCE_ENV}=${shQuote(bunRuntimeSource)} ${BUN_RUNTIME_PATH_ENV}=${shQuote(bunPath)} ${shQuote(bunPath)} ${shQuote(cliPath)} ensure >/dev/null 2>&1 || true
410
422
  fi
411
423
  ;;
412
424
  esac
@@ -431,7 +443,7 @@ function windowsBatchSet(name: string, value: string): string {
431
443
  return `set "${name}=${windowsEnvIndirectBatchValue(value, windowsBatchValue)}"`;
432
444
  }
433
445
 
434
- export function buildWindowsCodexShim(realCodexPath: string, bunPath: string, cliPath: string): string {
446
+ export function buildWindowsCodexShim(realCodexPath: string, bunPath: string, cliPath: string, bunRuntimeSource: BunRuntimeSource): string {
435
447
  const internalCommandChecks = CODEX_INTERNAL_COMMANDS.map(command => `if /I "%~1"=="${command}" goto run_codex`).join("\r\n");
436
448
  const valueOptionChecks = CODEX_GLOBAL_OPTIONS_WITH_VALUE.map(option => `if /I "%~1"=="${option}" goto skip_option_value`).join("\r\n");
437
449
  return `@echo off\r
@@ -462,7 +474,11 @@ if "%~1"=="" goto ensure_ocx\r
462
474
  shift\r
463
475
  goto scan_codex_args\r
464
476
  :ensure_ocx\r
477
+ setlocal\r
478
+ ${windowsBatchSet(BUN_RUNTIME_SOURCE_ENV, bunRuntimeSource)}\r
479
+ ${windowsBatchSet(BUN_RUNTIME_PATH_ENV, bunPath)}\r
465
480
  "%OCX_BUN%" "%OCX_CLI%" ensure >nul 2>nul\r
481
+ endlocal\r
466
482
  :run_codex\r
467
483
  "%OCX_REAL_CODEX%" %*\r
468
484
  `;
@@ -472,7 +488,7 @@ function psString(value: string): string {
472
488
  return `'${value.replace(/'/g, "''")}'`;
473
489
  }
474
490
 
475
- export function buildWindowsPowerShellCodexShim(realCodexPath: string, bunPath: string, cliPath: string): string {
491
+ export function buildWindowsPowerShellCodexShim(realCodexPath: string, bunPath: string, cliPath: string, bunRuntimeSource: BunRuntimeSource): string {
476
492
  const internalCommands = CODEX_INTERNAL_COMMANDS.map(command => psString(command)).join(", ");
477
493
  const valueOptions = CODEX_GLOBAL_OPTIONS_WITH_VALUE.map(option => psString(option)).join(", ");
478
494
  const tokenFile = serviceApiTokenFilePath();
@@ -497,7 +513,17 @@ foreach ($argValue in $args) {
497
513
  }
498
514
  $skipEnsure = $env:OCX_SHIM_BYPASS -or $internalCommands -contains $subcommand -or @("--help", "-h", "--version", "-V") -contains $subcommand
499
515
  if (-not $skipEnsure) {
500
- & ${psString(bunPath)} ${psString(cliPath)} ensure *> $null
516
+ $priorRuntimeSource = $env:${BUN_RUNTIME_SOURCE_ENV}
517
+ $priorRuntimePath = $env:${BUN_RUNTIME_PATH_ENV}
518
+ $env:${BUN_RUNTIME_SOURCE_ENV} = ${psString(bunRuntimeSource)}
519
+ $env:${BUN_RUNTIME_PATH_ENV} = ${psString(bunPath)}
520
+ try { & ${psString(bunPath)} ${psString(cliPath)} ensure *> $null }
521
+ finally {
522
+ if ($null -eq $priorRuntimeSource) { Remove-Item Env:\\${BUN_RUNTIME_SOURCE_ENV} -ErrorAction SilentlyContinue }
523
+ else { $env:${BUN_RUNTIME_SOURCE_ENV} = $priorRuntimeSource }
524
+ if ($null -eq $priorRuntimePath) { Remove-Item Env:\\${BUN_RUNTIME_PATH_ENV} -ErrorAction SilentlyContinue }
525
+ else { $env:${BUN_RUNTIME_PATH_ENV} = $priorRuntimePath }
526
+ }
501
527
  }
502
528
  & ${psString(realCodexPath)} @args
503
529
  exit $LASTEXITCODE
@@ -601,25 +627,25 @@ function gitBashPath(path: string): string {
601
627
  }
602
628
 
603
629
  function writeShim(wrapperPath: string, realCodexPath: string): void {
604
- const { bun, cli } = cliEntry();
630
+ const { bun, bunRuntimeSource, cli } = cliEntry();
605
631
  if (process.platform === "win32") {
606
632
  const lower = wrapperPath.toLowerCase();
607
633
  if (lower.endsWith(".ps1")) {
608
634
  // UTF-8 BOM: Windows PowerShell 5.1 decodes BOM-less .ps1 files in the ANSI
609
635
  // codepage, which mangles non-ASCII paths embedded in the shim.
610
- writeFileSync(wrapperPath, `\uFEFF${buildWindowsPowerShellCodexShim(realCodexPath, bun, cli)}`, "utf8");
636
+ writeFileSync(wrapperPath, `\uFEFF${buildWindowsPowerShellCodexShim(realCodexPath, bun, cli, bunRuntimeSource)}`, "utf8");
611
637
  } else if (lower.endsWith(".cmd") || lower.endsWith(".bat")) {
612
- writeFileSync(wrapperPath, buildWindowsCodexShim(realCodexPath, bun, cli), "utf8");
638
+ writeFileSync(wrapperPath, buildWindowsCodexShim(realCodexPath, bun, cli, bunRuntimeSource), "utf8");
613
639
  } else {
614
640
  // Extensionless Git-Bash sh launcher: sh shim with forward-slash paths.
615
641
  writeFileSync(
616
642
  wrapperPath,
617
- buildUnixCodexShim(gitBashPath(realCodexPath), gitBashPath(bun), gitBashPath(cli), gitBashPath(serviceApiTokenFilePath())),
643
+ buildUnixCodexShim(gitBashPath(realCodexPath), gitBashPath(bun), gitBashPath(cli), bunRuntimeSource, gitBashPath(serviceApiTokenFilePath())),
618
644
  "utf8",
619
645
  );
620
646
  }
621
647
  } else {
622
- writeFileSync(wrapperPath, buildUnixCodexShim(realCodexPath, bun, cli), "utf8");
648
+ writeFileSync(wrapperPath, buildUnixCodexShim(realCodexPath, bun, cli, bunRuntimeSource), "utf8");
623
649
  chmodSync(wrapperPath, 0o755);
624
650
  }
625
651
  }
@@ -15,11 +15,16 @@ import { CODEX_HOME, getCodexHome } from "./paths";
15
15
  import { CODEX_UNKNOWN_USAGE_SCORE, getAccountQuota } from "./quota";
16
16
  import {
17
17
  canAcquireCodexQuotaProbeLease,
18
+ codexQuotaScopeForModel,
18
19
  computeCodexUsageScore,
20
+ getCodexQuotaHealthSnapshot,
19
21
  getPoolAccountPlan,
20
22
  isCodexAccountInCooldown,
21
23
  } from "./routing";
22
- import { isCodexAccountUsable } from "./account-usability";
24
+ import {
25
+ isCodexAccountUsable,
26
+ type CodexAccountUsabilityOptions,
27
+ } from "./account-usability";
23
28
  import { isCodexAccountPaused } from "./account-pause";
24
29
  import { slugEquals } from "../providers/slug-codec";
25
30
  import { isThreadSpawnRequest } from "../server/effort-policy";
@@ -27,6 +32,7 @@ import { PROVIDER_REGISTRY } from "../providers/registry";
27
32
  import { isCanonicalOpenAiForwardProvider } from "../providers/openai-tiers";
28
33
  import { routeModel, type RouteResult } from "../router";
29
34
  import { sweepExpiredOnWrite } from "../lib/state-store-sweeper";
35
+ import { codexAccountNamespaceForModel } from "./account-namespace-match";
30
36
  export const DEFAULT_SUBAGENT_MODEL_FALLBACK_POLL_MS = 60_000;
31
37
 
32
38
  type SubagentQuotaPrimeFn = (config: OcxConfig, reason: string) => Promise<void>;
@@ -68,6 +74,13 @@ function isDisabledFallbackModel(model: string, config: OcxConfig): boolean {
68
74
  const slash = model.indexOf("/");
69
75
  const provider = model.slice(0, slash);
70
76
  const modelId = model.slice(slash + 1);
77
+ if (codexAccountNamespaceForModel(config.codexAccountNamespaces, model)) {
78
+ return disabled.some(stored =>
79
+ stored === model
80
+ || stored === modelId
81
+ || slugEquals(stored, "openai", modelId)
82
+ );
83
+ }
71
84
  return disabled.some(stored => stored === model || slugEquals(stored, provider, modelId));
72
85
  }
73
86
 
@@ -79,13 +92,22 @@ function pollIntervalMs(config: OcxConfig): number {
79
92
  return configured;
80
93
  }
81
94
 
95
+ function fallbackChainKey(model: string, namespaces: unknown): string {
96
+ const selector = codexAccountNamespaceForModel(namespaces, model);
97
+ if (!selector) return JSON.stringify(["model", model.toLowerCase()]);
98
+ const slash = model.indexOf("/");
99
+ // Selector keys are exact-case account boundaries. Keep that segment distinct while
100
+ // retaining legacy case-insensitive de-duplication for the native model suffix.
101
+ return JSON.stringify(["account", selector, model.slice(slash + 1).toLowerCase()]);
102
+ }
103
+
82
104
  function normalizedChain(primary: string, config: OcxConfig, extra: readonly string[] = []): string[] {
83
105
  const chain: string[] = [];
84
106
  const seen = new Set<string>();
85
107
  const push = (model: string | undefined) => {
86
108
  if (!model || model.trim() === "") return;
87
109
  const trimmed = model.trim();
88
- const key = trimmed.toLowerCase();
110
+ const key = fallbackChainKey(trimmed, config.codexAccountNamespaces);
89
111
  if (seen.has(key)) return;
90
112
  seen.add(key);
91
113
  chain.push(trimmed);
@@ -127,9 +149,18 @@ function resolvePoolFallbackAccountId(
127
149
  return activeCodexAccountId(config);
128
150
  }
129
151
 
152
+ function resolveRouteFallbackAccountId(
153
+ route: RouteResult | null,
154
+ config: OcxConfig,
155
+ accountId?: string | null,
156
+ ): string | null {
157
+ return route?.codexAccountId ?? resolvePoolFallbackAccountId(config, accountId);
158
+ }
159
+
130
160
  function isRoutableFallbackModel(model: string, config: OcxConfig): boolean {
131
161
  const slash = model.indexOf("/");
132
162
  if (slash > 0) {
163
+ if (codexAccountNamespaceForModel(config.codexAccountNamespaces, model)) return true;
133
164
  const providerName = model.slice(0, slash);
134
165
  if (!hasOwnProvider(config.providers, providerName)) {
135
166
  // Allow well-known "vendor/model" ids (e.g. anthropic/claude-*) to flow as
@@ -150,7 +181,7 @@ export function isNativeModelQuotaExhausted(
150
181
  ): boolean {
151
182
  const route = tryRouteFallbackModel(config, model);
152
183
  if (!route || !isPoolCodexRoute(route)) return false;
153
- const resolvedAccountId = resolvePoolFallbackAccountId(config, accountId);
184
+ const resolvedAccountId = resolveRouteFallbackAccountId(route, config, accountId);
154
185
  if (!resolvedAccountId) return false;
155
186
  const quota = getAccountQuota(resolvedAccountId);
156
187
  const usage = computeCodexUsageScore(quota, getPoolAccountPlan(config, resolvedAccountId));
@@ -167,7 +198,11 @@ export function isModelHealthBlocked(
167
198
  const route = tryRouteFallbackModel(config, model);
168
199
  const poolScoped = !!route && isPoolCodexRoute(route);
169
200
  const health = modelHealth.get(
170
- healthKey(model, resolvePoolFallbackAccountId(config, accountId), poolScoped),
201
+ healthKey(
202
+ model,
203
+ resolveRouteFallbackAccountId(route, config, accountId),
204
+ poolScoped,
205
+ ),
171
206
  );
172
207
  return !!health && health.unavailableUntil > now;
173
208
  }
@@ -177,6 +212,7 @@ export function isSubagentModelUnavailable(
177
212
  config: OcxConfig,
178
213
  accountId?: string | null,
179
214
  now = Date.now(),
215
+ accountUsabilityOptions?: CodexAccountUsabilityOptions,
180
216
  ): boolean {
181
217
  if (isDisabledFallbackModel(model, config)) return true;
182
218
  if (!isRoutableFallbackModel(model, config)) return true;
@@ -187,11 +223,17 @@ export function isSubagentModelUnavailable(
187
223
 
188
224
  // Pool candidates need a usable account. Derive requirement from the resolved
189
225
  // route (canonical openai defaults to pool even when codexAccountMode is omitted).
190
- const resolvedAccountId = resolvePoolFallbackAccountId(config, accountId);
226
+ const resolvedAccountId = resolveRouteFallbackAccountId(route, config, accountId);
191
227
  if (!resolvedAccountId) return true;
192
228
  if (isCodexAccountPaused(config, resolvedAccountId)) return true;
193
- if (!isCodexAccountUsable(config, resolvedAccountId)) return true;
194
- if (
229
+ if (!isCodexAccountUsable(config, resolvedAccountId, accountUsabilityOptions)) return true;
230
+ if (route.codexAccountId !== undefined) {
231
+ // An account-qualified route is pinned and cannot consume Pool's recovery-probe
232
+ // escape hatch. Honor both account-wide and model-scoped cooldowns so fallback
233
+ // advances instead of selecting a candidate that exact auth will reject.
234
+ const quotaScope = codexQuotaScopeForModel(route.modelId);
235
+ if (getCodexQuotaHealthSnapshot(resolvedAccountId, quotaScope, now) !== null) return true;
236
+ } else if (
195
237
  isCodexAccountInCooldown(resolvedAccountId, now)
196
238
  && !canAcquireCodexQuotaProbeLease(resolvedAccountId, now)
197
239
  ) {
@@ -207,6 +249,7 @@ export function selectAvailableSubagentModel(
207
249
  accountId?: string | null,
208
250
  now = Date.now(),
209
251
  nativeFallbackOnly = false,
252
+ accountUsabilityOptions?: CodexAccountUsabilityOptions,
210
253
  ): { model: string; rewritten: boolean; skipped: string[] } {
211
254
  const chain = normalizedChain(primary, config, extraFallback);
212
255
  const skipped: string[] = [];
@@ -218,7 +261,7 @@ export function selectAvailableSubagentModel(
218
261
  continue;
219
262
  }
220
263
  }
221
- if (isSubagentModelUnavailable(candidate, config, accountId, now)) {
264
+ if (isSubagentModelUnavailable(candidate, config, accountId, now, accountUsabilityOptions)) {
222
265
  skipped.push(candidate);
223
266
  continue;
224
267
  }
@@ -240,7 +283,11 @@ export function noteSubagentModelFailure(
240
283
  const route = tryRouteFallbackModel(config, model);
241
284
  const poolScoped = !!route && isPoolCodexRoute(route);
242
285
  modelHealth.set(
243
- healthKey(model, resolvePoolFallbackAccountId(config, accountId), poolScoped),
286
+ healthKey(
287
+ model,
288
+ resolveRouteFallbackAccountId(route, config, accountId),
289
+ poolScoped,
290
+ ),
244
291
  {
245
292
  unavailableUntil: now + interval,
246
293
  reason: "quota_exhausted",
@@ -324,13 +371,16 @@ export function readCodexAgentModel(role: string, codexHome = CODEX_HOME): strin
324
371
  export function resolveAgentModelFallbackForPrimary(
325
372
  primary: string,
326
373
  codexHome = CODEX_HOME,
374
+ namespaces?: unknown,
327
375
  ): string[] {
328
376
  const merged: string[] = [];
329
377
  const seen = new Set<string>();
330
378
  const push = (model: string | null | undefined) => {
331
379
  if (!model || model.trim() === "") return;
332
380
  const trimmed = model.trim();
333
- const key = trimmed.toLowerCase();
381
+ // Match global-chain de-dupe: keep account-selector prefixes case-sensitive when
382
+ // those selectors are configured, while ordinary provider/model ids stay case-insensitive.
383
+ const key = fallbackChainKey(trimmed, namespaces);
334
384
  if (seen.has(key)) return;
335
385
  seen.add(key);
336
386
  merged.push(trimmed);
@@ -349,7 +399,12 @@ export function resolveAgentModelFallbackForPrimary(
349
399
  * after a successful refresh so failures remain retryable. Errors are swallowed so
350
400
  * spawn routing can continue.
351
401
  */
352
- export function maybePrimeSubagentQuota(config: OcxConfig, now = Date.now()): Promise<void> {
402
+ export function maybePrimeSubagentQuota(
403
+ config: OcxConfig,
404
+ now = Date.now(),
405
+ options: { nativeMainReadsForbidden?: boolean } = {},
406
+ ): Promise<void> {
407
+ if (options.nativeMainReadsForbidden) return Promise.resolve();
353
408
  if (quotaPrimeInFlight) return quotaPrimeInFlight;
354
409
  if (!shouldPrimeSubagentQuota(config, now)) return Promise.resolve();
355
410
 
@@ -391,9 +446,14 @@ export function applySubagentModelFallback(
391
446
  accountId?: string | null,
392
447
  now = Date.now(),
393
448
  nativeFallbackOnly = false,
449
+ accountUsabilityOptions?: CodexAccountUsabilityOptions,
394
450
  ): { from?: string; to?: string; skipped?: string[] } | null {
395
451
  if (!isThreadSpawnRequest(headers)) return null;
396
- const roleFallback = resolveAgentModelFallbackForPrimary(parsed.modelId, getCodexHome());
452
+ const roleFallback = resolveAgentModelFallbackForPrimary(
453
+ parsed.modelId,
454
+ getCodexHome(),
455
+ config.codexAccountNamespaces,
456
+ );
397
457
  const globalFallback = config.subagentModelFallback ?? [];
398
458
  if (globalFallback.length === 0 && roleFallback.length === 0) return null;
399
459
  const selection = selectAvailableSubagentModel(
@@ -403,6 +463,7 @@ export function applySubagentModelFallback(
403
463
  accountId,
404
464
  now,
405
465
  nativeFallbackOnly,
466
+ accountUsabilityOptions,
406
467
  );
407
468
  if (!selection.rewritten) return selection.skipped.length > 0
408
469
  ? { from: parsed.modelId, to: parsed.modelId, skipped: selection.skipped }