@bitkyc08/opencodex 2.59.0 → 2.61.0-preview.20260922

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 (374) hide show
  1. package/AGENTS_INSTALL.md +64 -0
  2. package/README.md +28 -1
  3. package/bin/ocx.mjs +382 -209
  4. package/gui/dist/assets/App-E64Rzjap.js +50 -0
  5. package/gui/dist/assets/Tray-_nfzD8k4.js +1 -0
  6. package/gui/dist/assets/index-DpdfZWMK.js +86 -0
  7. package/gui/dist/assets/index-_bpvxJu0.css +1 -0
  8. package/gui/dist/assets/usage-companion-chart-DtoK7T6h.js +1 -0
  9. package/gui/dist/favicon.png +0 -0
  10. package/gui/dist/index.html +2 -2
  11. package/gui/dist/provider-icons/stepfun-color.svg +1 -0
  12. package/package.json +8 -1
  13. package/src/adapters/anthropic-image-codec.ts +16 -2
  14. package/src/adapters/anthropic-image-normalize.ts +49 -2
  15. package/src/adapters/anthropic.ts +20 -1
  16. package/src/adapters/base.ts +23 -0
  17. package/src/adapters/coding-agent/protocol.ts +36 -6
  18. package/src/adapters/coding-agent/turn.ts +32 -4
  19. package/src/adapters/command-code.ts +52 -4
  20. package/src/adapters/cursor/catalog.ts +51 -7
  21. package/src/adapters/cursor/checkpoint-store.ts +3 -0
  22. package/src/adapters/cursor/discovery.ts +11 -8
  23. package/src/adapters/cursor/live-transport.ts +26 -9
  24. package/src/adapters/cursor/protobuf-request.ts +6 -3
  25. package/src/adapters/cursor/request-builder.ts +20 -4
  26. package/src/adapters/cursor/transport.ts +19 -0
  27. package/src/adapters/cursor.ts +14 -3
  28. package/src/adapters/declaration-carrier.ts +45 -0
  29. package/src/adapters/devin/cloud-direct/chat.ts +3 -1
  30. package/src/adapters/devin/cloud-direct/stated-reset-retry.ts +42 -5
  31. package/src/adapters/devin.ts +125 -36
  32. package/src/adapters/google-antigravity-replay.ts +1 -1
  33. package/src/adapters/google-antigravity-wire.ts +34 -7
  34. package/src/adapters/google-errors.ts +7 -1
  35. package/src/adapters/google-http.ts +49 -10
  36. package/src/adapters/google-tool-schema.ts +595 -31
  37. package/src/adapters/google-wire-compiler.ts +93 -10
  38. package/src/adapters/google-wire-shape.ts +461 -0
  39. package/src/adapters/google.ts +66 -11
  40. package/src/adapters/image.ts +4 -1
  41. package/src/adapters/input-media-guard.ts +21 -9
  42. package/src/adapters/kiro/usage.ts +3 -2
  43. package/src/adapters/kiro-tool-fallback.ts +1 -1
  44. package/src/adapters/ollama-native.ts +6 -0
  45. package/src/adapters/openai-chat/developer-role.ts +61 -0
  46. package/src/adapters/openai-chat/messages.ts +46 -27
  47. package/src/adapters/openai-chat/parallel-tool-calls.ts +32 -0
  48. package/src/adapters/openai-chat/passthrough.ts +33 -9
  49. package/src/adapters/openai-chat/reasoning-wire.ts +89 -0
  50. package/src/adapters/openai-chat-images.ts +3 -1
  51. package/src/adapters/openai-chat.ts +23 -58
  52. package/src/adapters/openai-responses/image-gen.ts +8 -6
  53. package/src/adapters/openai-responses/passthrough.ts +17 -3
  54. package/src/adapters/openai-responses/reasoning.ts +7 -0
  55. package/src/adapters/opencode-go-additional-tools.ts +12 -2
  56. package/src/adapters/registry.ts +3 -2
  57. package/src/adapters/run-turn-queue.ts +178 -29
  58. package/src/adapters/xai-web-search.ts +16 -1
  59. package/src/bridge/errors.ts +8 -2
  60. package/src/bridge/response-json.ts +9 -1
  61. package/src/bridge/sse.ts +13 -151
  62. package/src/chat/inbound.ts +141 -5
  63. package/src/claude/desktop-3p.ts +7 -1
  64. package/src/claude/desktop-first-party.ts +183 -0
  65. package/src/claude/desktop-gateway-state.ts +41 -0
  66. package/src/claude/inbound-content-options.ts +6 -0
  67. package/src/claude/inbound.ts +32 -6
  68. package/src/claude/intercept/connect-proxy.ts +179 -0
  69. package/src/claude/intercept/listener.ts +122 -0
  70. package/src/claude/intercept/local-ca.ts +298 -0
  71. package/src/claude/intercept/runtime.ts +98 -0
  72. package/src/claude/intercept/settings.ts +189 -0
  73. package/src/cli/access.ts +87 -0
  74. package/src/cli/account-auth.ts +19 -0
  75. package/src/cli/account-extended.ts +4 -4
  76. package/src/cli/capabilities.ts +31 -0
  77. package/src/cli/claude-desktop.ts +206 -16
  78. package/src/cli/codex-shim-autorestore.ts +3 -0
  79. package/src/cli/companion.ts +56 -0
  80. package/src/cli/dispatch.ts +46 -7
  81. package/src/cli/doctor.ts +28 -9
  82. package/src/cli/ensure-desired-integrations.ts +43 -5
  83. package/src/cli/help.ts +7 -9
  84. package/src/cli/hub.ts +3 -2
  85. package/src/cli/index.ts +203 -59
  86. package/src/cli/init.ts +8 -0
  87. package/src/cli/integrations.ts +7 -1
  88. package/src/cli/opencode.ts +2 -2
  89. package/src/cli/provider.ts +13 -1
  90. package/src/cli/registry.ts +41 -2
  91. package/src/cli/resolve.ts +230 -0
  92. package/src/cli/root.ts +24 -1
  93. package/src/cli/start-ownership-publication.ts +56 -0
  94. package/src/cli/status-probes.ts +2 -18
  95. package/src/cli/status.ts +62 -0
  96. package/src/cli/stop-report.ts +143 -0
  97. package/src/cli/uninstall-plan.ts +9 -0
  98. package/src/client/machine-api.ts +2 -2
  99. package/src/client/machine-listener.ts +4 -7
  100. package/src/client/runtime.ts +26 -2
  101. package/src/clients/aside-profiles.ts +4 -0
  102. package/src/clients/config-export/zcode-store.ts +157 -0
  103. package/src/clients/config-export.ts +36 -0
  104. package/src/codex/account-store.ts +65 -0
  105. package/src/codex/app-server-processes.ts +72 -40
  106. package/src/codex/auth-api/account-list.ts +19 -11
  107. package/src/codex/auth-api/login-flow.ts +6 -1
  108. package/src/codex/auth-api/pool-quota-probe.ts +30 -7
  109. package/src/codex/autostart-health.ts +28 -0
  110. package/src/codex/catalog/build-entries.ts +2 -2
  111. package/src/codex/catalog/effort.ts +3 -3
  112. package/src/codex/catalog/gather-capture.ts +21 -2
  113. package/src/codex/catalog/model-hints.ts +29 -28
  114. package/src/codex/catalog/parsing.ts +7 -0
  115. package/src/codex/catalog/provider-models.ts +43 -17
  116. package/src/codex/catalog/retained-sync.ts +24 -28
  117. package/src/codex/catalog/routed-gather.ts +19 -0
  118. package/src/codex/context-compat.ts +5 -2
  119. package/src/codex/convergence.ts +2 -2
  120. package/src/codex/desired-state.ts +4 -1
  121. package/src/codex/history-job.ts +6 -6
  122. package/src/codex/history-provider.ts +31 -166
  123. package/src/codex/history-rollout-read.ts +174 -0
  124. package/src/codex/inject/config-toml.ts +41 -6
  125. package/src/codex/inject/paginated-openai-compat.ts +90 -0
  126. package/src/codex/inject.ts +18 -15
  127. package/src/codex/injected-marker.ts +18 -0
  128. package/src/codex/internal/catalog-writer.ts +33 -1
  129. package/src/codex/main-account.ts +6 -0
  130. package/src/codex/model-cache.ts +99 -6
  131. package/src/codex/model-entitlement-admission.ts +59 -0
  132. package/src/codex/model-entitlements.ts +116 -54
  133. package/src/codex/native-main-admission.ts +83 -0
  134. package/src/codex/observed-model-denials.ts +101 -8
  135. package/src/codex/prompt-text-probe.ts +9 -6
  136. package/src/codex/routing/health-store.ts +39 -0
  137. package/src/codex/routing/selection.ts +37 -1
  138. package/src/codex/routing.ts +12 -42
  139. package/src/codex/shim-templates.ts +29 -3
  140. package/src/codex/shim.ts +1 -1
  141. package/src/codex/subagent-model-fallback.ts +22 -4
  142. package/src/combos/failover.ts +3 -0
  143. package/src/companion/settings.ts +132 -0
  144. package/src/config/admitted-identity.ts +222 -0
  145. package/src/config/atomic-write.ts +117 -5
  146. package/src/config/diagnostics.ts +22 -1
  147. package/src/config/feature-flags.ts +5 -0
  148. package/src/config/load-degrade.ts +52 -7
  149. package/src/config/process-state.ts +1 -1
  150. package/src/config/proxy-env.ts +8 -2
  151. package/src/config/schema/compaction-triggers.ts +11 -0
  152. package/src/config/schema/config-schema.ts +31 -1
  153. package/src/config/schema/leaf-validators.ts +59 -0
  154. package/src/config.ts +3 -3
  155. package/src/generated/compatibility-version.json +604 -280
  156. package/src/grok/reset-coupons.ts +38 -19
  157. package/src/images/loop.ts +6 -1
  158. package/src/integrations/aside-profile-context.ts +37 -3
  159. package/src/integrations/aside-profile-journal.ts +68 -3
  160. package/src/integrations/aside-profiles.ts +128 -3
  161. package/src/integrations/config-io.ts +44 -10
  162. package/src/integrations/merge.ts +120 -13
  163. package/src/integrations/mutation-plan.ts +921 -0
  164. package/src/integrations/registry.ts +38 -0
  165. package/src/integrations/state.ts +78 -45
  166. package/src/integrations/target.ts +208 -0
  167. package/src/integrations/writer.ts +134 -110
  168. package/src/lab/conformance/fixture-provider.ts +5 -0
  169. package/src/lab/live/transport.ts +4 -0
  170. package/src/lab/live/types.ts +5 -0
  171. package/src/lab/subject/behavior-fingerprint.ts +1 -1
  172. package/src/lib/admin-secrets.ts +9 -1
  173. package/src/lib/browser-launch-notice.ts +59 -0
  174. package/src/lib/bun-runtime.ts +6 -2
  175. package/src/lib/debug-log-buffer.ts +6 -1
  176. package/src/lib/debug.ts +63 -0
  177. package/src/lib/errors.ts +79 -0
  178. package/src/lib/http-response-semantics.ts +57 -0
  179. package/src/lib/lab-live-pinned-sender.ts +26 -12
  180. package/src/lib/open-url.ts +51 -7
  181. package/src/lib/package-tree-integrity.ts +2 -1
  182. package/src/lib/package-version.ts +8 -0
  183. package/src/lib/pinned-http.ts +142 -2
  184. package/src/lib/plain-data.ts +103 -0
  185. package/src/lib/process-control.ts +13 -5
  186. package/src/lib/provider-egress.ts +310 -0
  187. package/src/lib/provider-outbound.ts +109 -16
  188. package/src/lib/proxy-env.ts +82 -7
  189. package/src/lib/request-execution-budget.ts +72 -0
  190. package/src/lib/request-failure-attribution.ts +183 -0
  191. package/src/lib/request-failure-model.ts +236 -0
  192. package/src/lib/request-resend-gate.ts +138 -0
  193. package/src/lib/socks5-fetch.ts +136 -26
  194. package/src/lib/spend-ledger-owner.ts +364 -0
  195. package/src/lib/spend-reservation-ledger.ts +218 -27
  196. package/src/lib/standalone.ts +16 -0
  197. package/src/lib/upstream-retry.ts +167 -16
  198. package/src/lib/windows-system-proxy.ts +16 -11
  199. package/src/lib/winsw.ts +2 -2
  200. package/src/oauth/callback-server.ts +4 -3
  201. package/src/oauth/generic-account-failover.ts +1 -0
  202. package/src/oauth/health.ts +12 -1
  203. package/src/oauth/index.ts +27 -108
  204. package/src/oauth/login-cli.ts +80 -29
  205. package/src/oauth/login-flow-state.ts +127 -0
  206. package/src/providers/api-key-resolve.ts +133 -0
  207. package/src/providers/api-key-selection.ts +5 -1
  208. package/src/providers/derive.ts +34 -17
  209. package/src/providers/devin-cli-authmode-migration.ts +14 -10
  210. package/src/providers/key-failover.ts +97 -19
  211. package/src/providers/key-store.ts +34 -110
  212. package/src/providers/model-rename-fields.ts +147 -0
  213. package/src/providers/model-rename-migration.ts +179 -38
  214. package/src/providers/model-rename-startup.ts +7 -5
  215. package/src/providers/openai-virtual-models.ts +42 -2
  216. package/src/providers/quota/antigravity.ts +22 -2
  217. package/src/providers/quota/vendor-probes-key.ts +38 -23
  218. package/src/providers/reasoning-metadata.ts +43 -18
  219. package/src/providers/registry/entries-core.ts +47 -20
  220. package/src/providers/registry/entries-extended.ts +63 -4
  221. package/src/providers/registry/model-ids.ts +168 -0
  222. package/src/providers/registry/model-seeds.ts +56 -10
  223. package/src/providers/registry/types.ts +2 -0
  224. package/src/providers/resolved-model-policy-merge.ts +167 -0
  225. package/src/providers/resolved-model-policy.ts +406 -0
  226. package/src/providers/stale-vision-classification-migration.ts +137 -0
  227. package/src/providers/xai-transport.ts +12 -1
  228. package/src/reasoning-effort.ts +8 -0
  229. package/src/responses/apply-patch-envelope.ts +0 -12
  230. package/src/responses/freeform-wrapper-scan.ts +279 -0
  231. package/src/responses/function-call-compat.ts +38 -1
  232. package/src/responses/inline-document.ts +65 -0
  233. package/src/responses/input-media.ts +42 -8
  234. package/src/responses/legacy-dotted-tool-name-repair.ts +134 -0
  235. package/src/responses/muse-tool-name-alias.ts +19 -0
  236. package/src/responses/parser-content.ts +8 -2
  237. package/src/responses/parser-tools.ts +3 -0
  238. package/src/responses/parser.ts +3 -1
  239. package/src/responses/progressive-freeform-input.ts +130 -0
  240. package/src/responses/reasoning-envelope.ts +30 -0
  241. package/src/responses/schema.ts +3 -0
  242. package/src/responses/state.ts +5 -12
  243. package/src/responses/tool-name-aliases.ts +15 -1
  244. package/src/router.ts +108 -117
  245. package/src/routing/compatibility/behavior.ts +9 -0
  246. package/src/routing/compatibility/subject.ts +16 -1
  247. package/src/server/adapter-resolve.ts +9 -0
  248. package/src/server/admission-model-scope.ts +219 -0
  249. package/src/server/audio-live.ts +9 -3
  250. package/src/server/audio-upstream.ts +18 -0
  251. package/src/server/auth-cors.ts +29 -0
  252. package/src/server/chat-completions.ts +60 -4
  253. package/src/server/chat-native.ts +19 -4
  254. package/src/server/claude-messages.ts +61 -20
  255. package/src/server/effort-row.ts +11 -3
  256. package/src/server/grok-responses-control-frame.ts +160 -1
  257. package/src/server/grok-responses-snapshot-repair.ts +113 -11
  258. package/src/server/gui-freshness.ts +103 -0
  259. package/src/server/gui-static.ts +7 -9
  260. package/src/server/images.ts +59 -6
  261. package/src/server/index/claude-intercept-lifecycle.ts +49 -0
  262. package/src/server/index/serve-options.ts +142 -39
  263. package/src/server/index/spend-ledger-lifecycle.ts +92 -0
  264. package/src/server/index/startup-warnings.ts +24 -0
  265. package/src/server/index/websocket-handler.ts +6 -1
  266. package/src/server/index.ts +34 -38
  267. package/src/server/lifecycle.ts +4 -4
  268. package/src/server/live-call-bindings.ts +6 -0
  269. package/src/server/live.ts +88 -3
  270. package/src/server/management/agent-settings-routes.ts +121 -36
  271. package/src/server/management/aside-profile-routes.ts +266 -7
  272. package/src/server/management/companion-routes.ts +77 -0
  273. package/src/server/management/config-routes.ts +18 -2
  274. package/src/server/management/context.ts +3 -0
  275. package/src/server/management/integration-routes.ts +287 -5
  276. package/src/server/management/logs-usage-routes.ts +19 -0
  277. package/src/server/management/metrics-routes.ts +20 -0
  278. package/src/server/management/model-rows.ts +224 -12
  279. package/src/server/management/native-integration-routes.ts +103 -6
  280. package/src/server/management/oauth-account-routes.ts +45 -7
  281. package/src/server/management/route-registry.ts +20 -0
  282. package/src/server/management/shared.ts +28 -4
  283. package/src/server/management/system-restart.ts +7 -2
  284. package/src/server/management/system-routes.ts +2 -0
  285. package/src/server/management/usage-aggregate-cache.ts +4 -0
  286. package/src/server/management/usage-timeline-routes.ts +44 -0
  287. package/src/server/management-api.ts +10 -9
  288. package/src/server/management-auth.ts +15 -1
  289. package/src/server/proxy-liveness.ts +75 -0
  290. package/src/server/readiness.ts +29 -10
  291. package/src/server/relay-eager.ts +24 -2
  292. package/src/server/relay.ts +138 -12
  293. package/src/server/request-log-failure-attribution.ts +99 -0
  294. package/src/server/request-log.ts +169 -2
  295. package/src/server/request-metrics.ts +298 -0
  296. package/src/server/responses/adapter-continuation.ts +3 -3
  297. package/src/server/responses/adapter-dispatch.ts +11 -6
  298. package/src/server/responses/codex-ws-wire.ts +34 -8
  299. package/src/server/responses/combo-stream-preflight.ts +168 -6
  300. package/src/server/responses/compact.ts +43 -10
  301. package/src/server/responses/compaction-routing.ts +111 -0
  302. package/src/server/responses/core-codex-account.ts +8 -3
  303. package/src/server/responses/core-combo.ts +7 -7
  304. package/src/server/responses/core-normalize.ts +6 -12
  305. package/src/server/responses/core-opaque-recovery.ts +91 -0
  306. package/src/server/responses/core-options.ts +4 -0
  307. package/src/server/responses/encrypted-payload.ts +20 -2
  308. package/src/server/responses/fetch-helpers.ts +124 -8
  309. package/src/server/responses/input-admission.ts +10 -0
  310. package/src/server/responses/passthrough-delivery.ts +45 -15
  311. package/src/server/responses/passthrough-dispatch.ts +215 -44
  312. package/src/server/responses/passthrough-error.ts +27 -8
  313. package/src/server/responses/policy-fallback.ts +5 -13
  314. package/src/server/responses/request-prepare.ts +109 -18
  315. package/src/server/responses/request-send-budget.ts +17 -1
  316. package/src/server/responses/request-sidecar-auth.ts +1 -1
  317. package/src/server/responses/request-transport.ts +26 -6
  318. package/src/server/responses/reset-replay.ts +108 -0
  319. package/src/server/responses/run-turn-execution.ts +25 -3
  320. package/src/server/responses/sidecar-execution.ts +17 -2
  321. package/src/server/responses/ws-upstream.ts +14 -27
  322. package/src/server/responses-custom-tool-repair.ts +27 -54
  323. package/src/server/responses-request-tool-scope.ts +214 -0
  324. package/src/server/responses-undeclared-tool-guard.ts +35 -2
  325. package/src/server/search.ts +25 -1
  326. package/src/server/sse-payload-rewrite.ts +1 -1
  327. package/src/server/usage-ledger-retention.ts +73 -0
  328. package/src/service/cli.ts +48 -2
  329. package/src/service/health.ts +3 -2
  330. package/src/service/install-state-contract.d.mts +27 -0
  331. package/src/service/install-state-contract.mjs +34 -0
  332. package/src/service/launchd.ts +1 -1
  333. package/src/service/orchestration.ts +2 -4
  334. package/src/service/ownership-compatibility.ts +164 -0
  335. package/src/service/ownership-mutation-lease.d.mts +32 -0
  336. package/src/service/ownership-mutation-lease.mjs +211 -0
  337. package/src/service/repair.ts +45 -1
  338. package/src/service/state-lock.ts +269 -0
  339. package/src/service/state-record.d.mts +36 -0
  340. package/src/service/state-record.mjs +138 -0
  341. package/src/service/state.ts +582 -68
  342. package/src/service/windows-taskxml.ts +11 -10
  343. package/src/service.ts +7 -3
  344. package/src/tray/windows-tray.ps1 +156 -4
  345. package/src/types/config.ts +48 -3
  346. package/src/types/provider.ts +91 -0
  347. package/src/types/request.ts +30 -2
  348. package/src/types/tools.ts +33 -0
  349. package/src/types.ts +4 -0
  350. package/src/update/index.ts +207 -63
  351. package/src/update/job.ts +9 -5
  352. package/src/update/ownership-transaction.ts +47 -0
  353. package/src/update/restart-ownership.ts +54 -0
  354. package/src/update/runtime-ownership.d.mts +40 -0
  355. package/src/update/runtime-ownership.mjs +122 -0
  356. package/src/usage/attempt-delivery.ts +198 -0
  357. package/src/usage/cache-diagnostic.ts +305 -0
  358. package/src/usage/failure-fingerprint.ts +118 -0
  359. package/src/usage/failure-projection-cache.ts +174 -0
  360. package/src/usage/failure-projection.ts +174 -0
  361. package/src/usage/ledger-retention.ts +165 -0
  362. package/src/usage/log.ts +126 -79
  363. package/src/usage/request-outcome.ts +150 -0
  364. package/src/usage/retention-contract.ts +28 -0
  365. package/src/usage/summary.ts +2 -2
  366. package/src/usage/telemetry-contract.ts +237 -0
  367. package/src/usage/timeline.ts +236 -0
  368. package/src/vision/eligibility.ts +88 -9
  369. package/src/vision/plan.ts +34 -10
  370. package/src/web-search/alpha-search.ts +21 -1
  371. package/src/web-search/executor.ts +41 -2
  372. package/src/web-search/loop.ts +6 -1
  373. package/gui/dist/assets/index-C5IebErG.js +0 -136
  374. package/gui/dist/assets/index-OESInAjC.css +0 -1
@@ -10,6 +10,7 @@
10
10
  import type { CatalogModel } from "./catalog";
11
11
  import type { GenerationContext } from "../lib/state-store-sweeper";
12
12
  import { enforceAppOwnedMemoryBudget, type RetainedStoreSnapshot } from "../lib/app-owned-memory";
13
+ import { clearLiveCursorRosterState } from "../adapters/cursor/catalog";
13
14
 
14
15
  /** Default freshness window. Matches Codex's own 5-min models cache so the two stay in step. */
15
16
  export const DEFAULT_MODEL_CACHE_TTL_MS = 5 * 60 * 1000;
@@ -71,6 +72,8 @@ function deleteCachedProvider(provider: string): number {
71
72
  cache.delete(provider);
72
73
  cacheBytes = Math.max(0, cacheBytes - entry.sizeBytes);
73
74
  if (oldestCachedProvider === provider) recomputeOldestCachedProvider();
75
+ // A removal changes this provider's content as surely as a publication does.
76
+ bumpProviderCacheRevision(provider);
74
77
  return entry.sizeBytes;
75
78
  }
76
79
 
@@ -78,7 +81,17 @@ function deleteCachedProvider(provider: string): number {
78
81
  * the full fetch timeout on every catalog poll (issue #54: UI stalls behind corporate proxies). */
79
82
  export const MODELS_FETCH_FAILURE_COOLDOWN_MS = 30_000;
80
83
 
81
- const failureAt = new Map<string, number>();
84
+ interface DiscoveryFailure {
85
+ at: number;
86
+ /**
87
+ * Credential the failure was observed under, for entitlement-specific rosters. Absent means
88
+ * the failure is credential-agnostic (a plain `/models` endpoint) and suppresses every
89
+ * caller, which is the original #54 behaviour.
90
+ */
91
+ authorityIdentity?: string;
92
+ }
93
+
94
+ const failureAt = new Map<string, DiscoveryFailure>();
82
95
  const discoveryStatus = new Map<string, ProviderModelDiscoveryStatus>();
83
96
  /**
84
97
  * How many models the last successful discovery actually returned, before any configured-alias
@@ -89,8 +102,12 @@ const discoveryStatus = new Map<string, ProviderModelDiscoveryStatus>();
89
102
  const liveModelCounts = new Map<string, number>();
90
103
  let lastReconciledGeneration = 0;
91
104
 
92
- export function markModelsFetchFailure(provider: string, now = Date.now()): void {
93
- failureAt.set(provider, now);
105
+ export function markModelsFetchFailure(
106
+ provider: string,
107
+ now = Date.now(),
108
+ authorityIdentity?: string,
109
+ ): void {
110
+ failureAt.set(provider, { at: now, ...(authorityIdentity ? { authorityIdentity } : {}) });
94
111
  }
95
112
 
96
113
  /** `liveModelCount` is required so a caller that forgets to pass it fails typecheck instead of
@@ -145,9 +162,25 @@ export function getProviderLiveModelCount(provider: string): number | undefined
145
162
  return liveModelCounts.get(provider);
146
163
  }
147
164
 
148
- export function isModelsFetchCoolingDown(provider: string, cooldownMs = MODELS_FETCH_FAILURE_COOLDOWN_MS, now = Date.now()): boolean {
149
- const at = failureAt.get(provider);
150
- return at !== undefined && now - at < cooldownMs;
165
+ /**
166
+ * Whether a failed discovery still suppresses the next one.
167
+ *
168
+ * `authorityIdentity` scopes the suppression to the credential that actually observed the
169
+ * failure. A roster that upstream filters per account is evidence about that account, and
170
+ * one account's 401 or 404 must not decide that a different account has no catalog. A
171
+ * failure recorded without an identity stays credential-agnostic and suppresses everyone,
172
+ * so the plain-endpoint providers keep the timeout protection #54 added.
173
+ */
174
+ export function isModelsFetchCoolingDown(
175
+ provider: string,
176
+ cooldownMs = MODELS_FETCH_FAILURE_COOLDOWN_MS,
177
+ now = Date.now(),
178
+ authorityIdentity?: string,
179
+ ): boolean {
180
+ const failure = failureAt.get(provider);
181
+ if (failure === undefined || now - failure.at >= cooldownMs) return false;
182
+ if (failure.authorityIdentity === undefined || authorityIdentity === undefined) return true;
183
+ return failure.authorityIdentity === authorityIdentity;
151
184
  }
152
185
 
153
186
  /** Fresh cached models for a provider, or null when absent/stale (caller should re-fetch). */
@@ -166,6 +199,20 @@ export function getStaleCached(provider: string, authorityIdentity?: string): Ca
166
199
  return entry.models;
167
200
  }
168
201
 
202
+ /** Selector decoding may use unscoped rows, but scoped rows need current authority. */
203
+ export function getRoutingCached(
204
+ provider: string, resolveAuthority: () => string | undefined,
205
+ ): CatalogModel[] | null {
206
+ const entry = cache.get(provider);
207
+ if (!entry) return null;
208
+ if (entry.authorityIdentity !== undefined) {
209
+ try {
210
+ if (resolveAuthority() !== entry.authorityIdentity) return null;
211
+ } catch { return null; }
212
+ }
213
+ return entry.models;
214
+ }
215
+
169
216
  /** Capture the cache generation before an asynchronous provider discovery starts. */
170
217
  export function captureModelCacheGeneration(provider: string): string {
171
218
  if (!providerCacheGenerations.has(provider)) providerCacheGenerations.set(provider, 0);
@@ -177,6 +224,34 @@ export function isModelCacheGenerationCurrent(provider: string, generation: stri
177
224
  return generation === captureModelCacheGeneration(provider);
178
225
  }
179
226
 
227
+ /**
228
+ * How many times this provider's cached content has actually changed.
229
+ *
230
+ * Deliberately separate from the generation. A generation revokes an in-flight discovery's right
231
+ * to publish, so it advances on an authority clear and must not be repurposed: moving it on a
232
+ * successful publication would cancel writes that are still legitimate. This counts accepted
233
+ * publications and removals instead, which is what a reader holding a derived roster needs to
234
+ * know, and it advances on exactly the event a generation does not: a discovery that succeeded
235
+ * and changed the rows.
236
+ *
237
+ * Reading is passive. An unseen provider reads as `0` rather than seeding an entry, so observing
238
+ * one cannot alter what a later capture or publication sees.
239
+ */
240
+ const providerCacheRevisions = new Map<string, number>();
241
+ let globalContentRevision = 0;
242
+
243
+ function bumpProviderCacheRevision(provider: string): void {
244
+ providerCacheRevisions.set(provider, (providerCacheRevisions.get(provider) ?? 0) + 1);
245
+ }
246
+
247
+ export function observeModelCacheRevision(provider: string): string {
248
+ // The global term covers a clear that empties the map wholesale, which per-provider counters
249
+ // cannot express: without it, wiping every entry and republishing identical-looking rows would
250
+ // read as unchanged. It also survives pruning, so a retired provider cannot come back with a
251
+ // counter that matches a roster built before it left.
252
+ return `${globalContentRevision}:${providerCacheRevisions.get(provider) ?? 0}`;
253
+ }
254
+
180
255
  /**
181
256
  * Store a live result unless the cache was cleared while that asynchronous discovery was running.
182
257
  * The optional generation keeps existing direct cache writers unchanged while discovery callers can
@@ -200,6 +275,8 @@ export function setCached(
200
275
  oldestCachedAt = now;
201
276
  }
202
277
  enforceAppOwnedMemoryBudget();
278
+ // Published and accepted, so anything derived from this provider's rows is now out of date.
279
+ bumpProviderCacheRevision(provider);
203
280
  return true;
204
281
  }
205
282
 
@@ -208,6 +285,7 @@ export function clearModelCache(
208
285
  provider?: string,
209
286
  reason: ModelCacheClearReason = "authority",
210
287
  ): void {
288
+ clearLiveCursorRosterState(provider);
211
289
  const revokesInFlightDiscovery = reason === "authority";
212
290
  if (provider) {
213
291
  if (revokesInFlightDiscovery) {
@@ -220,6 +298,11 @@ export function clearModelCache(
220
298
  } else {
221
299
  if (revokesInFlightDiscovery) globalCacheGeneration += 1;
222
300
  cache.clear();
301
+ // A wholesale clear changes every provider's content at once, and clearing the map means no
302
+ // per-provider counter can record it. Advancing the global term retires every derived roster
303
+ // and lets the per-provider entries be dropped without an ABA on the way back.
304
+ globalContentRevision += 1;
305
+ providerCacheRevisions.clear();
223
306
  cacheBytes = 0;
224
307
  oldestCachedProvider = undefined;
225
308
  oldestCachedAt = null;
@@ -237,6 +320,7 @@ export function reconcileModelCacheProviders(
237
320
  const removedProviders = new Set<string>();
238
321
  const trackedProviders = new Set([
239
322
  ...providerCacheGenerations.keys(),
323
+ ...providerCacheRevisions.keys(),
240
324
  ...failureAt.keys(),
241
325
  ...discoveryStatus.keys(),
242
326
  ...liveModelCounts.keys(),
@@ -247,6 +331,10 @@ export function reconcileModelCacheProviders(
247
331
  if (validProviders.has(provider)) continue;
248
332
  if (!revokedRemovedProviderAuthority) {
249
333
  globalCacheGeneration += 1;
334
+ // Advanced BEFORE any revision entry is dropped, so a provider that comes back cannot
335
+ // present the counter a roster was built against. Without this the delete below is an ABA:
336
+ // the entry returns at zero and an old stamp matches again.
337
+ globalContentRevision += 1;
250
338
  revokedRemovedProviderAuthority = true;
251
339
  }
252
340
  providerCacheGenerations.set(provider, (providerCacheGenerations.get(provider) ?? 0) + 1);
@@ -255,6 +343,11 @@ export function reconcileModelCacheProviders(
255
343
  failureAt.delete(provider);
256
344
  discoveryStatus.delete(provider);
257
345
  liveModelCounts.delete(provider);
346
+ // AFTER the cache deletion, which bumps this provider's revision and would otherwise recreate
347
+ // the entry we just removed. Dropped rather than left behind: a provider the configuration no
348
+ // longer has must not keep an entry alive for the life of the process merely because nothing
349
+ // cleared the whole cache. The global epoch advanced above, so the removal is not an ABA.
350
+ providerCacheRevisions.delete(provider);
258
351
  removedProviders.add(provider);
259
352
  }
260
353
  lastReconciledGeneration = generation;
@@ -0,0 +1,59 @@
1
+ import type { OcxConfig } from "../types";
2
+ import { MAIN_CODEX_ACCOUNT_ID } from "./main-account";
3
+ import {
4
+ resolveCodexModelEntitlements,
5
+ type CodexModelEntitlementResolveOptions,
6
+ type CodexModelEntitlementSnapshot,
7
+ } from "./model-entitlements";
8
+ import {
9
+ type NativeMainCredentialAdmissionDeps,
10
+ withNativeMainCredentialAdmission,
11
+ } from "./native-main-admission";
12
+
13
+ interface ModelEntitlementAdmissionDeps extends NativeMainCredentialAdmissionDeps {
14
+ readonly resolve?: typeof resolveCodexModelEntitlements;
15
+ }
16
+
17
+ function excludeNativeMain(
18
+ options: CodexModelEntitlementResolveOptions,
19
+ ): CodexModelEntitlementResolveOptions {
20
+ return {
21
+ ...options,
22
+ excludeAccountIds: new Set([
23
+ ...(options.excludeAccountIds ?? []),
24
+ MAIN_CODEX_ACCOUNT_ID,
25
+ ]),
26
+ };
27
+ }
28
+
29
+ /**
30
+ * Resolve background/data-plane entitlements inside the native-main fences.
31
+ *
32
+ * Pool discovery remains available when startup recovery or a profile drain
33
+ * owns the physical credential. When main is admitted, the process-local lease
34
+ * covers only the credential snapshot: getValidMainAccountToken holds the
35
+ * cross-process exclusive claim just for the auth.json refresh write, and the
36
+ * lease is released before the upstream entitlement request so a profile drain
37
+ * never waits on a network fetch.
38
+ */
39
+ export async function resolveAdmittedCodexModelEntitlements(
40
+ config: Pick<OcxConfig, "codexAccounts">,
41
+ options: CodexModelEntitlementResolveOptions = {},
42
+ deps: ModelEntitlementAdmissionDeps = {},
43
+ ): Promise<CodexModelEntitlementSnapshot> {
44
+ const resolve = deps.resolve ?? resolveCodexModelEntitlements;
45
+ // A caller-supplied roster or an already-excluded main never reads auth.json,
46
+ // so there is no native credential to fence.
47
+ if (options.credentials || options.excludeAccountIds?.has(MAIN_CODEX_ACCOUNT_ID)) {
48
+ return resolve(config, options);
49
+ }
50
+ return withNativeMainCredentialAdmission(
51
+ (excludedAccountIds, releaseMainLease) => resolve(
52
+ config,
53
+ excludedAccountIds.size === 0
54
+ ? { ...options, releaseNativeMainCredentialLease: releaseMainLease }
55
+ : excludeNativeMain(options),
56
+ ),
57
+ { acquireNativeMain: deps.acquireNativeMain },
58
+ );
59
+ }
@@ -2,13 +2,18 @@ import { createHash } from "node:crypto";
2
2
  import { readBoundedResponseBody } from "../lib/bounded-body";
3
3
  import type { CodexAccountCredentialRecord, OcxConfig } from "../types";
4
4
  import { isSelectableCodexPoolAccount } from "./account-id";
5
- import { getValidCodexToken, loadCodexAccountRecordSnapshot } from "./account-store";
5
+ import {
6
+ beginCodexAccountGenerationLiveCheck,
7
+ getValidCodexToken,
8
+ loadCodexAccountRecordSnapshot,
9
+ } from "./account-store";
6
10
  import {
7
11
  getMainAccountToken,
8
12
  getValidMainAccountToken,
9
13
  MAIN_CODEX_ACCOUNT_ID,
10
14
  type NativeMainRefreshDependencies,
11
15
  } from "./main-account";
16
+ import { withNativeMainCredentialAdmission } from "./native-main-admission";
12
17
  import {
13
18
  ACCOUNT_GATED_NATIVE_OPENAI_MODELS,
14
19
  NATIVE_GPT6_ASTRA_MODEL,
@@ -22,6 +27,7 @@ import {
22
27
  forgetObservedCodexModelDenialsForAccount,
23
28
  observedDeniedCodexAccountIdsForModel,
24
29
  recordObservedCodexModelDenial,
30
+ setObservedDenialGenerationCheck,
25
31
  resetObservedCodexModelDenialsForTests,
26
32
  } from "./observed-model-denials";
27
33
 
@@ -434,6 +440,12 @@ export interface CodexModelEntitlementResolveOptions {
434
440
  readonly excludeAccountIds?: ReadonlySet<string>;
435
441
  /** Ensure-only fence; ordinary request resolvers retain their established flight identity. */
436
442
  readonly credentialMutationEpoch?: number;
443
+ /**
444
+ * Internal plumbing from `withNativeMainCredentialAdmission`: releases the
445
+ * native-main lifecycle lease once the credential phase settles, before any
446
+ * upstream roster fetch, so a profile drain never waits on network work.
447
+ */
448
+ readonly releaseNativeMainCredentialLease?: () => void;
437
449
  }
438
450
 
439
451
  export interface CodexEntitlementFreshnessOptions extends Pick<
@@ -447,6 +459,8 @@ export interface CodexEntitlementFreshnessOptions extends Pick<
447
459
  | "signal"
448
460
  > {
449
461
  readonly waitMs?: number;
462
+ /** Test seam for the native-main admission fence around the refresh workset. */
463
+ readonly nativeMainCredentialAdmission?: typeof withNativeMainCredentialAdmission;
450
464
  }
451
465
 
452
466
  const accountModelsCache = new Map<string, CachedAccountModels>();
@@ -576,21 +590,31 @@ function currentCredentialIdentity(accountId: string): string | undefined {
576
590
 
577
591
  async function accountCredentialSnapshot(
578
592
  accountId: string,
579
- options: Pick<CodexModelEntitlementResolveOptions, "nativeMainRefreshDependencies" | "signal"> = {},
593
+ options: Pick<
594
+ CodexModelEntitlementResolveOptions,
595
+ "nativeMainRefreshDependencies" | "releaseNativeMainCredentialLease" | "signal"
596
+ > = {},
580
597
  ): Promise<CodexModelEntitlementCredentialSnapshot | null> {
581
598
  if (accountId === MAIN_CODEX_ACCOUNT_ID) {
582
- const token = await getValidMainAccountToken({
583
- signal: options.signal,
584
- ...(options.nativeMainRefreshDependencies ?? {}),
585
- });
586
- return token
587
- ? {
588
- accountId,
589
- accessToken: token.accessToken,
590
- chatgptAccountId: token.chatgptAccountId,
591
- credentialIdentity: `main:${token.chatgptAccountId}`,
592
- }
593
- : null;
599
+ try {
600
+ const token = await getValidMainAccountToken({
601
+ signal: options.signal,
602
+ ...(options.nativeMainRefreshDependencies ?? {}),
603
+ });
604
+ return token
605
+ ? {
606
+ accountId,
607
+ accessToken: token.accessToken,
608
+ chatgptAccountId: token.chatgptAccountId,
609
+ credentialIdentity: `main:${token.chatgptAccountId}`,
610
+ }
611
+ : null;
612
+ } finally {
613
+ // The lifecycle lease fences only this credential read; releasing here —
614
+ // on success and on a credential-ownership failure alike — keeps a
615
+ // profile drain from waiting on the roster fetches that follow.
616
+ options.releaseNativeMainCredentialLease?.();
617
+ }
594
618
  }
595
619
  try {
596
620
  const token = await getValidCodexToken(accountId);
@@ -906,38 +930,58 @@ async function refreshCodexEntitlementWorkset(
906
930
  mutationEpoch: number,
907
931
  options: CodexEntitlementFreshnessOptions,
908
932
  ): Promise<void> {
909
- const credentialSnapshot = options.credentialSnapshot ?? accountCredentialSnapshot;
910
- const observations = await Promise.all(workset.map(async accountId => {
911
- const credential = await credentialSnapshot(accountId, options);
912
- return {
913
- accountId,
914
- credential,
915
- absenceObservedAt: options.now ?? Date.now(),
916
- };
917
- }));
918
- const credentials = observations.flatMap(observation => observation.credential
919
- ? [observation.credential]
920
- : []);
921
- if (credentials.length > 0) {
922
- await resolveCodexModelEntitlements(config, {
923
- ...options,
924
- clientVersion,
925
- credentialMutationEpoch: mutationEpoch,
926
- credentials,
927
- });
928
- }
933
+ const run = async (
934
+ excludedAccountIds: ReadonlySet<string>,
935
+ releaseMainLease?: () => void,
936
+ ): Promise<void> => {
937
+ // An excluded main is filtered before the snapshot phase, not just before the
938
+ // roster fetch: it never produces an absence observation, so a denied
939
+ // admission cannot memoize a credential read that never happened.
940
+ const admittedWorkset = excludedAccountIds.size === 0
941
+ ? workset
942
+ : workset.filter(accountId => !excludedAccountIds.has(accountId));
943
+ const credentialSnapshot = options.credentialSnapshot ?? accountCredentialSnapshot;
944
+ const observations = await Promise.all(admittedWorkset.map(async accountId => {
945
+ const credential = await credentialSnapshot(accountId, {
946
+ ...options,
947
+ releaseNativeMainCredentialLease: releaseMainLease,
948
+ });
949
+ return {
950
+ accountId,
951
+ credential,
952
+ absenceObservedAt: options.now ?? Date.now(),
953
+ };
954
+ }));
955
+ // The lease fences only the credential phase; release before roster fetches
956
+ // so a profile drain never waits on upstream network work.
957
+ releaseMainLease?.();
958
+ const credentials = observations.flatMap(observation => observation.credential
959
+ ? [observation.credential]
960
+ : []);
961
+ if (credentials.length > 0) {
962
+ await resolveCodexModelEntitlements(config, {
963
+ ...options,
964
+ clientVersion,
965
+ credentialMutationEpoch: mutationEpoch,
966
+ credentials,
967
+ });
968
+ }
929
969
 
930
- for (const observation of observations) {
931
- if (observation.credential) continue;
932
- const capturedIdentity = identityVector.get(observation.accountId) ?? null;
933
- if (codexCredentialMutationEpoch() !== mutationEpoch) continue;
934
- if ((currentCredentialIdentity(observation.accountId) ?? null) !== capturedIdentity) continue;
935
- boundedNegativeCredentialMemoSet(observation.accountId, {
936
- credentialIdentity: capturedIdentity,
937
- mutationEpoch,
938
- expiresAt: observation.absenceObservedAt + MODEL_ROSTER_NEGATIVE_CREDENTIAL_TTL_MS,
939
- });
940
- }
970
+ for (const observation of observations) {
971
+ if (observation.credential) continue;
972
+ const capturedIdentity = identityVector.get(observation.accountId) ?? null;
973
+ if (codexCredentialMutationEpoch() !== mutationEpoch) continue;
974
+ if ((currentCredentialIdentity(observation.accountId) ?? null) !== capturedIdentity) continue;
975
+ boundedNegativeCredentialMemoSet(observation.accountId, {
976
+ credentialIdentity: capturedIdentity,
977
+ mutationEpoch,
978
+ expiresAt: observation.absenceObservedAt + MODEL_ROSTER_NEGATIVE_CREDENTIAL_TTL_MS,
979
+ });
980
+ }
981
+ };
982
+ if (!workset.includes(MAIN_CODEX_ACCOUNT_ID)) return run(new Set<string>());
983
+ const admission = options.nativeMainCredentialAdmission ?? withNativeMainCredentialAdmission;
984
+ return admission(run);
941
985
  }
942
986
 
943
987
  function waitForEntitlementEnsureFlight(
@@ -1105,6 +1149,10 @@ export async function resolveCodexModelEntitlements(
1105
1149
  ? [...options.credentials].filter(credential => !options.excludeAccountIds?.has(credential.accountId))
1106
1150
  : (await Promise.all(allowedAccountIds.map(accountId => credentialSnapshot(accountId, options))))
1107
1151
  .filter((value): value is CodexModelEntitlementCredentialSnapshot => value !== null);
1152
+ // The credential phase is the only part the native-main lease fences. The
1153
+ // real snapshot releases it as soon as the main token settles; this boundary
1154
+ // release keeps the guarantee when a seam snapshot never invokes it.
1155
+ options.releaseNativeMainCredentialLease?.();
1108
1156
  const results = await Promise.all(credentials.map(async credential => ({
1109
1157
  credential,
1110
1158
  result: await modelsForCredential(
@@ -1297,13 +1345,14 @@ export function cachedDeniedCodexAccountIdsForModel(
1297
1345
  // absent an ongoing catalog sync the loop above contributes nothing at all. An upstream
1298
1346
  // refusal does not expire on that schedule and is not a snapshot of a pending answer: it is
1299
1347
  // the account's own Codex surface naming this model and declining it (#4906).
1300
- for (const accountId of observedDeniedCodexAccountIdsForModel(modelId, now) ?? []) {
1301
- // Under the caller's read fence, like the roster loop above. Nothing here reads account
1302
- // storage, but an excluded account must stay UNKNOWN rather than denied so a profile switch
1303
- // or a request-owned credential produces the same selection it does today.
1304
- if (options.excludeAccountIds?.has(accountId)) continue;
1305
- denied.add(accountId);
1306
- }
1348
+ // The caller's read fence is passed IN rather than applied to the result, so an excluded
1349
+ // account is skipped before the credential-generation validation reads account storage
1350
+ // (#4952). An excluded account must stay UNKNOWN rather than denied, so a profile switch or
1351
+ // a request-owned credential produces the same selection it does today.
1352
+ const observedDenied = observedDeniedCodexAccountIdsForModel(modelId, now, {
1353
+ ...(options.excludeAccountIds ? { excludeAccountIds: options.excludeAccountIds } : {}),
1354
+ });
1355
+ for (const accountId of observedDenied ?? []) denied.add(accountId);
1307
1356
  // One account holds one entry per client version, and upstream filters the roster by that
1308
1357
  // version. So the same account can legitimately carry a granted entry under a current client
1309
1358
  // and a denied one under an older client that predates the model. Positive evidence is
@@ -1330,23 +1379,36 @@ export function cachedDeniedCodexAccountIdsForModel(
1330
1379
  * admissible here: 400 covers every malformed request too, and remembering one of those as an
1331
1380
  * entitlement fact would steer routing away from a perfectly capable account.
1332
1381
  */
1382
+ // Denial evidence is credential-scoped (#4952). The store stays a leaf module, so the
1383
+ // liveness predicate is injected here, where the account store is already a dependency.
1384
+ setObservedDenialGenerationCheck(beginCodexAccountGenerationLiveCheck);
1385
+
1333
1386
  export function recordCodexModelDenialEvidence(
1334
1387
  accountId: string | null | undefined,
1335
1388
  modelId: string | undefined,
1389
+ generation: number | null | undefined,
1336
1390
  now = Date.now(),
1337
1391
  ): void {
1338
1392
  if (!accountId || !modelId) return;
1339
1393
  if (!ENTITLEMENT_PREFERRED_NATIVE_OPENAI_MODELS.has(modelId)) return;
1340
- recordObservedCodexModelDenial(accountId, modelId, now);
1394
+ // A caller that cannot name a credential generation records ACCOUNT-scoped evidence rather
1395
+ // than none. The one production context in that position is `main-pool`, whose credential
1396
+ // lives in `auth.json` and has no pool generation; discarding its refusals would revert
1397
+ // #4906 for the stored main login (#4952). Request-owned `main` never reaches here — its
1398
+ // `accountId` is null and the guard above returns.
1399
+ recordObservedCodexModelDenial(accountId, modelId, typeof generation === "number" ? generation : undefined, now);
1341
1400
  }
1342
1401
 
1343
1402
  /** Drop the refusal evidence for a pair the account has just served successfully. */
1344
1403
  export function clearCodexModelDenialEvidence(
1345
1404
  accountId: string | null | undefined,
1346
1405
  modelId: string | undefined,
1406
+ generation: number | null | undefined,
1347
1407
  ): void {
1348
1408
  if (!accountId || !modelId) return;
1349
- clearObservedCodexModelDenial(accountId, modelId);
1409
+ // Mirror of the write: an account-scoped success clears account-scoped evidence. It cannot
1410
+ // clear a credential-scoped entry that names a newer generation, and vice versa (#4952).
1411
+ clearObservedCodexModelDenial(accountId, modelId, typeof generation === "number" ? generation : undefined);
1350
1412
  }
1351
1413
 
1352
1414
  /** Synchronous projection for management/catalog readers after a discovery pass. */
@@ -3,7 +3,13 @@ import {
3
3
  tryAcquireNativeMainProfileClaim as tryAcquireLifecycleNativeMainProfileClaim,
4
4
  tryClaimNativeMainProfileForTurn as tryClaimLifecycleNativeMainProfileForTurn,
5
5
  } from "../server/lifecycle";
6
+ import {
7
+ MAIN_CODEX_ACCOUNT_ID,
8
+ MainAccountTokenRefreshError,
9
+ MainAuthJsonChangedDuringRefreshError,
10
+ } from "./main-account";
6
11
  import { isNativeMainTrafficBlocked } from "./native-profile-startup";
12
+ import { NativeProfileError } from "./native-profile-types";
7
13
 
8
14
  export interface NativeMainTurnClaimDeps {
9
15
  /** Test seams for the synchronous precheck/claim/postcheck transition. */
@@ -45,3 +51,80 @@ export function tryAcquireNativeMainProfileClaim(): AdmissionLease | null {
45
51
  claim.release();
46
52
  return null;
47
53
  }
54
+
55
+ export interface NativeMainCredentialAdmissionDeps {
56
+ /** Test seam for the synchronous admission precheck. */
57
+ readonly acquireNativeMain?: () => AdmissionLease | null;
58
+ }
59
+
60
+ const NO_EXCLUDED_ACCOUNT_IDS: ReadonlySet<string> = new Set();
61
+ const NATIVE_MAIN_EXCLUDED_ACCOUNT_IDS: ReadonlySet<string> = new Set([MAIN_CODEX_ACCOUNT_ID]);
62
+ const RELEASE_NOTHING = () => {};
63
+
64
+ /**
65
+ * Run credential-backed work inside the native-main lifecycle fence.
66
+ *
67
+ * The lease keeps startup recovery and profile drains from owning the physical
68
+ * credential while the operation reads it. Cross-process ownership of the file
69
+ * itself is already coordinated inside the refresh path's exclusive claim, so
70
+ * this fence deliberately does not take the shared claim: holding it across an
71
+ * operation that may refresh would ask for exclusive ownership against our own
72
+ * shared lock, and holding it across the upstream work that follows would
73
+ * stall an unrelated credential commit behind a network fetch.
74
+ *
75
+ * The fence covers only the credential read. The operation must invoke
76
+ * `releaseMainLease` as soon as the native-main credential settles — on both
77
+ * the success and the credential-error path — and before any upstream model
78
+ * listing, so a profile drain never waits on a network fetch while this turn
79
+ * is still counted. The wrapper releases on settle regardless, so an
80
+ * operation without a fenced credential phase may ignore the callback.
81
+ *
82
+ * When the gate refuses, or the credential cannot be read because another
83
+ * lifecycle owns it, the operation reruns with main excluded so independent
84
+ * Pool work is never suppressed by main's unavailability.
85
+ */
86
+ export async function withNativeMainCredentialAdmission<T>(
87
+ operation: (
88
+ excludeAccountIds: ReadonlySet<string>,
89
+ releaseMainLease?: () => void,
90
+ ) => Promise<T>,
91
+ deps: NativeMainCredentialAdmissionDeps = {},
92
+ ): Promise<T> {
93
+ const lease = (deps.acquireNativeMain ?? tryAcquireNativeMainProfileClaim)();
94
+ if (!lease) return operation(NATIVE_MAIN_EXCLUDED_ACCOUNT_IDS, RELEASE_NOTHING);
95
+ let released = false;
96
+ const releaseMainLease = () => {
97
+ if (released) return;
98
+ released = true;
99
+ lease.release();
100
+ };
101
+ try {
102
+ return await operation(NO_EXCLUDED_ACCOUNT_IDS, releaseMainLease);
103
+ } catch (error) {
104
+ // The Pool-only retry never reads the native-main credential; release first
105
+ // so a profile drain is not kept waiting behind Pool network work.
106
+ releaseMainLease();
107
+ if (!isNativeMainCredentialUnavailableError(error)) throw error;
108
+ return await operation(NATIVE_MAIN_EXCLUDED_ACCOUNT_IDS, RELEASE_NOTHING);
109
+ } finally {
110
+ releaseMainLease();
111
+ }
112
+ }
113
+
114
+ /**
115
+ * The credential-ownership failures that make main unavailable for one
116
+ * operation: a foreign exclusive holder or an unsupported claim filesystem
117
+ * (NATIVE_MAIN_CLAIM_BUSY / NATIVE_MAIN_CLAIM_UNAVAILABLE), a writer that moved
118
+ * auth.json mid-refresh, or a grant that no longer refreshes. Like a Pool
119
+ * credential failure, none of them may suppress independent Pool discovery.
120
+ * Any other NativeProfileError — MAIN_REQUESTS_ACTIVE, VAULT_INVALID,
121
+ * INTERNAL_ERROR — is not a credential-ownership failure and propagates.
122
+ */
123
+ function isNativeMainCredentialUnavailableError(error: unknown): boolean {
124
+ if (error instanceof NativeProfileError) {
125
+ return error.code === "NATIVE_MAIN_CLAIM_BUSY"
126
+ || error.code === "NATIVE_MAIN_CLAIM_UNAVAILABLE";
127
+ }
128
+ return error instanceof MainAuthJsonChangedDuringRefreshError
129
+ || error instanceof MainAccountTokenRefreshError;
130
+ }