@bitkyc08/opencodex 2.54.0-preview.20260914 → 2.55.0-preview.20260914

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 (86) hide show
  1. package/gui/dist/assets/{index-B4VYfZcY.js → index-DH2PUHqr.js} +10 -10
  2. package/gui/dist/index.html +1 -1
  3. package/package.json +1 -1
  4. package/src/adapters/anthropic-image-codec.ts +57 -0
  5. package/src/adapters/anthropic-image-normalize.ts +28 -1
  6. package/src/adapters/anthropic.ts +68 -6
  7. package/src/adapters/base.ts +8 -0
  8. package/src/adapters/coding-agent/protocol.ts +41 -16
  9. package/src/adapters/cursor/cursor-errors.ts +1 -1
  10. package/src/adapters/cursor/live-transport.ts +5 -1
  11. package/src/adapters/cursor/native-exec-fs.ts +10 -10
  12. package/src/adapters/cursor/native-exec-network.ts +2 -2
  13. package/src/adapters/cursor/native-exec-shell.ts +13 -12
  14. package/src/adapters/cursor/native-exec.ts +51 -10
  15. package/src/adapters/cursor/policy-error.ts +75 -0
  16. package/src/adapters/cursor/protobuf-request.ts +105 -1
  17. package/src/adapters/devin/cloud-direct/catalog.ts +34 -2
  18. package/src/adapters/devin/live-models.ts +33 -2
  19. package/src/adapters/google-wire-compiler.ts +8 -0
  20. package/src/adapters/google.ts +46 -0
  21. package/src/adapters/input-media-guard.ts +45 -0
  22. package/src/adapters/kiro/adapter.ts +8 -0
  23. package/src/adapters/kiro/payload.ts +28 -6
  24. package/src/adapters/kiro-events.ts +25 -6
  25. package/src/adapters/kiro-images.ts +30 -0
  26. package/src/adapters/kiro-retry.ts +8 -0
  27. package/src/adapters/openai-chat.ts +33 -4
  28. package/src/adapters/openai-responses.ts +26 -0
  29. package/src/adapters/registry.ts +4 -0
  30. package/src/bridge.ts +163 -116
  31. package/src/chat/image-parts.ts +151 -0
  32. package/src/chat/inbound.ts +70 -33
  33. package/src/cli/connect.ts +30 -9
  34. package/src/cli/dispatch.ts +7 -3
  35. package/src/cli/index.ts +3 -0
  36. package/src/cli/runtime-api.ts +25 -0
  37. package/src/cli/status.ts +21 -19
  38. package/src/cli/system-restart-client.ts +25 -0
  39. package/src/clients/config-export.ts +14 -4
  40. package/src/codex/app-server-processes.ts +25 -0
  41. package/src/codex/auth-context.ts +8 -0
  42. package/src/codex/autostart-health.ts +36 -2
  43. package/src/codex/catalog/provider-fetch.ts +41 -0
  44. package/src/codex/catalog-auto-refresh.ts +182 -0
  45. package/src/codex/catalog-refresh-status.ts +93 -0
  46. package/src/codex/history-provider.ts +55 -0
  47. package/src/codex/model-entitlements.ts +78 -0
  48. package/src/codex/native-profile-processes.ts +114 -15
  49. package/src/codex/prompt-text-probe.ts +274 -41
  50. package/src/codex/routing-adoption.ts +189 -0
  51. package/src/codex/routing.ts +520 -48
  52. package/src/codex/runtime.ts +249 -7
  53. package/src/combos/failover.ts +45 -0
  54. package/src/config.ts +124 -4
  55. package/src/generated/compatibility-version.json +110 -74
  56. package/src/generated/model-metadata.ts +1 -0
  57. package/src/lib/request-execution-budget.ts +202 -0
  58. package/src/lib/upstream-retry.ts +95 -8
  59. package/src/lib/workflow-budget.ts +172 -0
  60. package/src/oauth/devin.ts +57 -12
  61. package/src/providers/quota.ts +37 -6
  62. package/src/providers/registry.ts +53 -6
  63. package/src/responses/input-media.ts +65 -0
  64. package/src/responses/parser-content.ts +42 -0
  65. package/src/responses/schema.ts +12 -2
  66. package/src/server/audio-live.ts +1 -2
  67. package/src/server/audio-transcriptions.ts +1 -2
  68. package/src/server/auth-cors.ts +1 -1
  69. package/src/server/background-lifecycle.ts +18 -0
  70. package/src/server/chat-completions.ts +23 -8
  71. package/src/server/chat-native.ts +17 -17
  72. package/src/server/index.ts +24 -0
  73. package/src/server/management/request-history-routes.ts +5 -0
  74. package/src/server/request-log.ts +8 -2
  75. package/src/server/responses/compact.ts +51 -3
  76. package/src/server/responses/core.ts +238 -28
  77. package/src/server/search.ts +7 -9
  78. package/src/types/config.ts +51 -6
  79. package/src/usage/log.ts +37 -0
  80. package/src/vision/eligibility.ts +37 -4
  81. package/src/vision/index.ts +1 -0
  82. package/src/vision/plan.ts +45 -10
  83. package/src/web-search/alpha-search.ts +324 -0
  84. package/src/web-search/index.ts +13 -22
  85. package/src/web-search/passthrough-bridge.ts +195 -22
  86. package/src/web-search/sidecar-providers.ts +22 -0
@@ -38,6 +38,7 @@ import {
38
38
  tryAcquireCodexQuotaScopeProbeLease,
39
39
  pickAlternateCodexAccount,
40
40
  resolveCodexAccountForThreadDetailed,
41
+ type CodexAffinityDecision,
41
42
  } from "./routing";
42
43
  import {
43
44
  entitledCodexAccountIdsForModel,
@@ -137,6 +138,8 @@ export type CodexAuthContext =
137
138
  probeLeaseId?: string;
138
139
  /** Native model quota group selected for this request, when known. */
139
140
  quotaScope?: CodexQuotaScope;
141
+ /** What happened to this thread's binding on this request (#4546). */
142
+ affinityDecision?: CodexAffinityDecision;
140
143
  /** Scope that owns `probeLeaseId`, when it is a scoped recovery probe. */
141
144
  probeQuotaScope?: CodexQuotaScope;
142
145
  }
@@ -798,6 +801,9 @@ export async function resolveCodexAuthContext(
798
801
  const affinityKey = fixedAccountId === undefined && !requestScopedMainCredential
799
802
  ? codexPoolAffinityKey(headers)
800
803
  : undefined;
804
+ // Why this request is on this account, carried to the request log so a move reads as an event
805
+ // instead of something inferred from account labels across lines (#4546).
806
+ let affinityDecision: CodexAffinityDecision | undefined;
801
807
  // Retained startup recovery makes the physical main identity ineligible. Routing
802
808
  // can still preserve service by selecting a healthy configured pool account. A
803
809
  // request-owned bearer likewise cannot inspect or reconcile file-main state.
@@ -870,6 +876,7 @@ export async function resolveCodexAuthContext(
870
876
  );
871
877
  if (resolution.status === "expired") throw new CodexThreadAffinityExpiredError(resolution.accountId);
872
878
  const selected = resolution.status === "selected" ? resolution.accountId : null;
879
+ affinityDecision = "affinity" in resolution ? resolution.affinity : undefined;
873
880
  if (!selected) {
874
881
  // A retry that excluded a failed Pool account may still use the validated caller-owned
875
882
  // main credential. Treating every exclusion as if main itself had failed strands a healthy
@@ -1066,6 +1073,7 @@ export async function resolveCodexAuthContext(
1066
1073
  ...(quotaScope ? { quotaScope } : {}),
1067
1074
  ...(probeLeaseId ? { probeLeaseId } : {}),
1068
1075
  ...(probeQuotaScope ? { probeQuotaScope } : {}),
1076
+ ...(affinityDecision ? { affinityDecision } : {}),
1069
1077
  };
1070
1078
  } catch (cause) {
1071
1079
  if (probeLeaseId && probeQuotaScope) releaseCodexQuotaScopeProbeLease(accountId, probeQuotaScope, probeLeaseId);
@@ -2,6 +2,7 @@ import { codexAutoStartEnabled } from "../config";
2
2
  import { diagnoseService, type ServiceDiagnostic } from "../service";
3
3
  import type { OcxConfig } from "../types";
4
4
  import { getCodexRoutingKind, type CodexRoutingKind } from "./inject";
5
+ import { collectRoutingAdoption, type RoutingAdoptionEvidence } from "./routing-adoption";
5
6
  import { diagnoseCodexShim, type CodexShimDiagnostic } from "./shim";
6
7
 
7
8
  export type StartupProtection = "service" | "shim" | "none";
@@ -22,6 +23,7 @@ export interface StartupHealthInputs {
22
23
  shimHealthy: boolean;
23
24
  platform: NodeJS.Platform;
24
25
  diagnosticStale?: boolean;
26
+ routingAdoption?: RoutingAdoptionEvidence;
25
27
  }
26
28
 
27
29
  export interface StartupHealth {
@@ -51,6 +53,7 @@ export interface StartupHealth {
51
53
  installShim: string;
52
54
  restoreNative: string;
53
55
  };
56
+ routingAdoption?: RoutingAdoptionEvidence;
54
57
  }
55
58
 
56
59
  const COMMANDS = {
@@ -115,6 +118,7 @@ export interface StartupHealthDiagnostics {
115
118
  routingKind?: CodexRoutingKind;
116
119
  service?: ServiceDiagnostic;
117
120
  shim?: CodexShimDiagnostic;
121
+ routingAdoption?: RoutingAdoptionEvidence;
118
122
  }
119
123
 
120
124
  /** Collect current machine state without mutating config, services, or shims. */
@@ -124,8 +128,11 @@ export function collectStartupHealth(
124
128
  ): StartupHealth {
125
129
  const shim = diagnostics.shim ?? diagnoseCodexShim();
126
130
  const service = diagnostics.service ?? diagnoseService();
131
+ const routingKind = diagnostics.routingKind ?? getCodexRoutingKind();
132
+ const routingAdoption = diagnostics.routingAdoption
133
+ ?? (routingKind === "opencodex-local" ? collectRoutingAdoption({ routingKind }) : undefined);
127
134
  return deriveStartupHealth({
128
- routingKind: diagnostics.routingKind ?? getCodexRoutingKind(),
135
+ routingKind,
129
136
  autostartEnabled: codexAutoStartEnabled(config),
130
137
  serviceInstalled: service.installed,
131
138
  serviceViable: service.viable,
@@ -137,10 +144,17 @@ export function collectStartupHealth(
137
144
  shimInstalled: shim.installed,
138
145
  shimHealthy: shim.healthy,
139
146
  platform: process.platform,
147
+ ...(routingAdoption ? { routingAdoption } : {}),
140
148
  });
141
149
  }
142
150
 
143
151
  export function startupHealthSummary(health: StartupHealth): string {
152
+ const summary = classifyStartupHealthSummary(health);
153
+ const action = pendingClientRestartAction(health);
154
+ return action ? `${summary}; ${action}` : summary;
155
+ }
156
+
157
+ function classifyStartupHealthSummary(health: StartupHealth): string {
144
158
  if (health.status === "native") return health.routingKind === "custom-remote"
145
159
  ? "custom remote Codex routing (no local restart dependency)"
146
160
  : "native Codex routing (no opencodex restart dependency)";
@@ -155,6 +169,24 @@ export function startupHealthSummary(health: StartupHealth): string {
155
169
  return `AT RISK after restart (no viable background service; run '${command}')`;
156
170
  }
157
171
 
172
+ function pendingClientRestartAction(health: StartupHealth): string | null {
173
+ const adoption = health.routingAdoption;
174
+ if (adoption?.adoption !== "pending-client-restart") return null;
175
+ const pids = adoption.staleClients.map(client => client.pid);
176
+ if (pids.length === 0) return null;
177
+ const pidList = pids.join(", ");
178
+ return pids.length === 1
179
+ ? `restart Codex client pid ${pidList} so it adopts the injected proxy route`
180
+ : `restart Codex clients pid ${pidList} so they adopt the injected proxy route`;
181
+ }
182
+
183
+ function pendingClientRestartDetail(adoption: RoutingAdoptionEvidence | undefined): string | null {
184
+ if (adoption?.adoption !== "pending-client-restart") return null;
185
+ const pids = adoption.staleClients.map(client => client.pid);
186
+ if (pids.length === 0) return null;
187
+ return `clients=pending-restart(pid ${pids.join(", ")})`;
188
+ }
189
+
158
190
  /**
159
191
  * The routing/service/shim token `ocx doctor` prints under restart safety.
160
192
  * Extracted so `ocx status` can show the same string rather than growing a
@@ -168,5 +200,7 @@ export function formatStartupRoutingDetail(health: StartupHealth): string {
168
200
  const shim = health.shimHealthy
169
201
  ? "healthy"
170
202
  : health.shimInstalled ? "stale" : "absent";
171
- return `routing=${health.routingKind}, service=${service}, shim=${shim}`;
203
+ const base = `routing=${health.routingKind}, service=${service}, shim=${shim}`;
204
+ const token = pendingClientRestartDetail(health.routingAdoption);
205
+ return token ? `${base}, ${token}` : base;
172
206
  }
@@ -418,6 +418,40 @@ function captureModelsRequest(
418
418
  });
419
419
  }
420
420
 
421
+ /**
422
+ * Fill the registry seed's per-model numeric capability maps beneath the provider's own
423
+ * values, mutating `prov` in place. The merge is per key — an operator's entry always
424
+ * wins; a model the persisted map never mentions picks up its seed value — matching
425
+ * `mergeRecordFill` in src/router.ts exactly.
426
+ *
427
+ * Routing already performs this fill at resolve time (routedProviderConfig in
428
+ * src/router.ts) and the catalog did not, and that divergence is #4570:
429
+ * zhipu-bigmodel-coding/glm-5.3-flash reached the live catalog with correct modalities
430
+ * but no context window, because an install persisted before Flash joined the seed map
431
+ * held a truthy partial `modelContextWindows` that shadowed the whole seed.
432
+ *
433
+ * This lives here and not in enrichProviderFromRegistry because enrichment output is
434
+ * persisted on a management POST, and #1409 (pinned by
435
+ * tests/server/management-provider-validation.test.ts) requires that a save never write
436
+ * registry seed keys into the operator's config. The gather clone is detached and
437
+ * frozen, never saved, so the catalog can see the seed without the config gaining it.
438
+ */
439
+ export function applyRegistryCapabilitySeedFill(name: string, prov: OcxProviderConfig): void {
440
+ // router.ts resolves the canonical OpenAI API provider's token maps with
441
+ // mergePositiveNumberCaps (user values cap the seed rather than replace it), so a
442
+ // plain fill here would give that one provider catalog semantics routing never has.
443
+ if (name === OPENAI_API_PROVIDER_ID) return;
444
+ if (!providerMatchesRegistryTransport(name, prov)) return;
445
+ const entry = getProviderRegistryEntry(name);
446
+ if (!entry) return;
447
+ if (entry.modelContextWindows || prov.modelContextWindows) {
448
+ prov.modelContextWindows = { ...(entry.modelContextWindows ?? {}), ...(prov.modelContextWindows ?? {}) };
449
+ }
450
+ if (entry.modelMaxOutputTokens || prov.modelMaxOutputTokens) {
451
+ prov.modelMaxOutputTokens = { ...(entry.modelMaxOutputTokens ?? {}), ...(prov.modelMaxOutputTokens ?? {}) };
452
+ }
453
+ }
454
+
421
455
  function captureProviderGather(
422
456
  name: string,
423
457
  configured: OcxProviderConfig,
@@ -427,6 +461,7 @@ function captureProviderGather(
427
461
  ): CapturedProviderGather {
428
462
  const enriched = detachedClone(withCanonicalOpenAiForwardAuthDefault(name, configured));
429
463
  enrichProviderFromRegistry(name, enriched);
464
+ applyRegistryCapabilitySeedFill(name, enriched);
430
465
  const registryTransportMatch = providerMatchesRegistryTransport(name, enriched);
431
466
  const provider = recursivelyFreeze(enriched);
432
467
  const fastPolicyAuthority = captureFastPolicyAuthority(
@@ -1745,6 +1780,12 @@ async function fetchProviderModelsWithAuth(
1745
1780
  // away, and every client that keys an effort control off this field —
1746
1781
  // the Pi-shaped exports — renders no control at all.
1747
1782
  ...(liveResult.efforts[id]?.length ? { reasoningEfforts: liveResult.efforts[id] } : {}),
1783
+ // The account catalog's per-base supportsImages vote collapses to one
1784
+ // modalities value. It spreads before the hints so exact
1785
+ // modelCapabilities declarations, the legacy modelInputModalities
1786
+ // record and the vision-sidecar rewrite keep winning — the live
1787
+ // value survives only when none of them applies.
1788
+ ...(liveResult.inputModalities[id]?.length ? { inputModalities: liveResult.inputModalities[id] } : {}),
1748
1789
  ...catalogHintsFromProviderConfig(name, prov, id, contextCap, metadataModelIdCaseFold, captured.effectiveAlias),
1749
1790
  } as CatalogModel;
1750
1791
  });
@@ -0,0 +1,182 @@
1
+ /**
2
+ * Opt-in periodic catalog refresh so newly released models appear without a
3
+ * manual `ocx sync` (issue #3630).
4
+ *
5
+ * This is load-bearing, not a convenience. The served model set is otherwise
6
+ * only rewritten by an explicit sync, a management mutation, or startup
7
+ * convergence, so an overnight provider release stays invisible until someone
8
+ * happens to run one of those. The overnight case is the whole reason the
9
+ * scheduler exists.
10
+ *
11
+ * Shape follows src/quota/reset-poller.ts: a module-singleton unref'd interval
12
+ * whose config gate lives in the callee, so toggling `enabled` or changing the
13
+ * cadence takes effect on the next tick without a restart (the rationale
14
+ * spelled out at src/oauth/token-guardian.ts:276). Importing this module at
15
+ * startup must cost nothing — src/server/background-lifecycle.ts loads it
16
+ * statically — so the config barrel, the admission snapshot, and the
17
+ * convergence funnel are all dynamic import()s inside the tick.
18
+ */
19
+
20
+ /**
21
+ * Keep these numeric literals aligned with CATALOG_AUTO_REFRESH_* in src/config.ts.
22
+ * They cannot be imported from there: this module is a static edge from
23
+ * background-lifecycle, and the config barrel is a heavy import reserved for the tick.
24
+ */
25
+ const DEFAULT_INTERVAL_MS = 60 * 60_000;
26
+ const MIN_INTERVAL_MS = 15 * 60_000;
27
+ /**
28
+ * Commit-lock wait only. Gather already has per-provider timeouts, and automatic
29
+ * callers fail fast and defer (ConvergeRequest.mode) rather than holding the
30
+ * write lock across a slow tick.
31
+ */
32
+ const TICK_DEADLINE_MS = 1_000;
33
+
34
+ let timer: ReturnType<typeof setInterval> | null = null;
35
+ let detachShutdownHook: (() => void) | null = null;
36
+ /** The bounded cadence the live timer was created with, so a tick can notice config drift. */
37
+ let liveIntervalMs: number | null = null;
38
+ /**
39
+ * Bumped by every start and stop. A tick captures it on entry and re-checks before publishing,
40
+ * so a converge still in flight when the timer stops cannot publish into the next generation.
41
+ */
42
+ let generation = 0;
43
+ /** setInterval does not skip a firing while the previous callback is still awaiting. */
44
+ let inFlight = false;
45
+
46
+ /** Number of ticks that have run. Test-only observability; carries no catalog data. */
47
+ let tickCount = 0;
48
+
49
+ function boundedInterval(value: number): number {
50
+ return Math.max(MIN_INTERVAL_MS, Math.floor(value));
51
+ }
52
+
53
+ /** Re-arm the timer when the operator changed the cadence since it was created. */
54
+ function restartIfCadenceChanged(configured: number): void {
55
+ if (timer === null || boundedInterval(configured) === liveIntervalMs) return;
56
+ stopCatalogAutoRefresh();
57
+ startCatalogAutoRefresh(configured);
58
+ }
59
+
60
+ async function tick(): Promise<void> {
61
+ // An interval firing while the previous converge is still awaiting would stack
62
+ // provider fetches precisely when a slow /models call is already in flight.
63
+ if (inFlight) return;
64
+ inFlight = true;
65
+ const entryGeneration = generation;
66
+ try {
67
+ const {
68
+ loadConfig,
69
+ isCatalogAutoRefreshEnabled,
70
+ resolveCatalogAutoRefreshIntervalMs,
71
+ } = await import("../config");
72
+ const config = loadConfig();
73
+ if (!isCatalogAutoRefreshEnabled(config)) return;
74
+ const configured = resolveCatalogAutoRefreshIntervalMs(config);
75
+ // 0 is dormant: the section stays configured but this tick must not converge,
76
+ // and the unref'd timer is left running so flipping the minutes back on is
77
+ // picked up without a process restart.
78
+ if (configured === 0) return;
79
+ // A stop or restart landed while the config resolved: this tick no longer owns the timer,
80
+ // so it must neither count as a refresh nor adopt a cadence for a generation that is gone.
81
+ if (entryGeneration !== generation) return;
82
+ // Adopt a changed cadence without a restart, which is why the config gate lives in the
83
+ // callee at all. Only while this tick still owns the timer.
84
+ restartIfCadenceChanged(configured);
85
+ tickCount += 1;
86
+ const [{ createManagementConvergeCodex }, { createCatalogConvergeRequest }] = await Promise.all([
87
+ import("./management-convergence"),
88
+ import("./catalog-admission"),
89
+ ]);
90
+ // A stop or restart landed while the funnel was loading: the result belongs to a
91
+ // generation that no longer owns the timer, so it must not publish.
92
+ if (entryGeneration !== generation) return;
93
+ const converge = createManagementConvergeCodex(config);
94
+ const outcome = await converge(createCatalogConvergeRequest({ deadlineMs: TICK_DEADLINE_MS }));
95
+ if (entryGeneration !== generation) return;
96
+ // createManagementConvergeCodex always projects catalog-only. Any other kind is a
97
+ // funnel contract break, not something this scheduler should re-classify.
98
+ if (outcome.kind !== "catalog-only") return;
99
+ const { recordCatalogAutoRefreshOutcome } = await import("./catalog-refresh-status");
100
+ recordCatalogAutoRefreshOutcome(outcome.catalogRefresh, outcome.changed);
101
+ if (outcome.changed) {
102
+ // Privacy scan: no provider names, model ids, paths, or account identifiers.
103
+ console.info("[catalog-auto-refresh] served model set changed");
104
+ }
105
+ } catch {
106
+ // A failed refresh is not an error worth surfacing: the next tick tries again.
107
+ } finally {
108
+ inFlight = false;
109
+ }
110
+ }
111
+
112
+ /** Idempotent. A second call while running is a no-op, matching startQuotaResetPoller. */
113
+ export function startCatalogAutoRefresh(intervalMs = DEFAULT_INTERVAL_MS): void {
114
+ if (timer) return;
115
+ const bounded = boundedInterval(intervalMs);
116
+ generation += 1;
117
+ liveIntervalMs = bounded;
118
+ timer = setInterval(() => void tick(), bounded);
119
+ // Never keep the process alive for a catalog refresh.
120
+ timer.unref?.();
121
+ void import("../lib/optional-shutdown-hooks")
122
+ .then(hooks => {
123
+ detachShutdownHook = hooks.registerOptionalShutdownHook(
124
+ "catalog-auto-refresh",
125
+ stopCatalogAutoRefresh,
126
+ );
127
+ })
128
+ .catch(() => {
129
+ // Without the hook the unref'd timer still cannot delay exit.
130
+ });
131
+ }
132
+
133
+ export function stopCatalogAutoRefresh(): void {
134
+ if (timer) {
135
+ clearInterval(timer);
136
+ timer = null;
137
+ }
138
+ liveIntervalMs = null;
139
+ generation += 1;
140
+ detachShutdownHook?.();
141
+ detachShutdownHook = null;
142
+ }
143
+
144
+ export function isCatalogAutoRefreshRunning(): boolean {
145
+ return timer !== null;
146
+ }
147
+
148
+ /**
149
+ * Adopt the operator's configured cadence at startup.
150
+ *
151
+ * The caller starts the scheduler synchronously with the default interval, because
152
+ * resolving the config here would mean a static edge to ../config from a module
153
+ * background-lifecycle imports at load time. Resolving it through import() keeps
154
+ * that edge dynamic, at the cost of the timer running at the default for the few
155
+ * microtasks before this settles.
156
+ */
157
+ export async function syncCatalogAutoRefreshCadence(): Promise<void> {
158
+ const { loadConfig, resolveCatalogAutoRefreshIntervalMs } = await import("../config");
159
+ const configured = resolveCatalogAutoRefreshIntervalMs(loadConfig());
160
+ // 0 is dormant: tick() already returns before converging, and the timer stays unref'd.
161
+ if (configured === 0) return;
162
+ restartIfCadenceChanged(configured);
163
+ }
164
+
165
+ /** Test-only: run one tick synchronously rather than waiting out the interval. */
166
+ export async function runCatalogAutoRefreshTickForTests(): Promise<void> {
167
+ await tick();
168
+ }
169
+
170
+ export function catalogAutoRefreshTickCountForTests(): number {
171
+ return tickCount;
172
+ }
173
+
174
+ /** Test-only: the bounded cadence the live timer is running at, or null when stopped. */
175
+ export function catalogAutoRefreshIntervalForTests(): number | null {
176
+ return liveIntervalMs;
177
+ }
178
+
179
+ export function resetCatalogAutoRefreshForTests(): void {
180
+ stopCatalogAutoRefresh();
181
+ tickCount = 0;
182
+ }
@@ -103,3 +103,96 @@ function normalizeCatalogFailureCause(value: unknown): CatalogFailureCause | und
103
103
  export function catalogRefreshIsPending(disposition: CatalogDisposition): boolean {
104
104
  return disposition.status !== "committed";
105
105
  }
106
+
107
+ export interface CatalogAutoRefreshOutcome {
108
+ readonly at: number;
109
+ readonly disposition: CatalogDisposition;
110
+ readonly changed: boolean;
111
+ /**
112
+ * A refresh that has failed repeatedly is the signal an operator needs, and the
113
+ * boolean disposition alone cannot express it: skipped and failed look the same
114
+ * as a one-off busy skip until this count climbs.
115
+ */
116
+ readonly consecutiveFailures: number;
117
+ }
118
+
119
+ let lastAutoRefreshOutcome: CatalogAutoRefreshOutcome | null = null;
120
+
121
+ /** Rebuild and freeze so a management reader cannot mutate scheduler state. */
122
+ function freezeCatalogDisposition(disposition: CatalogDisposition): CatalogDisposition {
123
+ if (disposition.status === "committed") {
124
+ return Object.freeze({
125
+ status: "committed" as const,
126
+ changed: disposition.changed,
127
+ degraded: disposition.degraded,
128
+ notices: Object.freeze([...disposition.notices]),
129
+ });
130
+ }
131
+ if (disposition.status === "skipped") {
132
+ return Object.freeze({
133
+ status: "skipped" as const,
134
+ reason: disposition.reason,
135
+ retryable: disposition.retryable,
136
+ });
137
+ }
138
+ const cause = disposition.cause
139
+ ? Object.freeze({
140
+ kind: disposition.cause.kind,
141
+ ...(disposition.cause.code ? { code: disposition.cause.code } : {}),
142
+ })
143
+ : undefined;
144
+ return Object.freeze({
145
+ status: "failed" as const,
146
+ reason: disposition.reason,
147
+ phase: disposition.phase,
148
+ retryable: disposition.retryable,
149
+ partialWrite: disposition.partialWrite,
150
+ ...(cause ? { cause } : {}),
151
+ });
152
+ }
153
+
154
+ function freezeCatalogAutoRefreshOutcome(
155
+ outcome: CatalogAutoRefreshOutcome,
156
+ ): CatalogAutoRefreshOutcome {
157
+ return Object.freeze({
158
+ at: outcome.at,
159
+ disposition: freezeCatalogDisposition(outcome.disposition),
160
+ changed: outcome.changed,
161
+ consecutiveFailures: outcome.consecutiveFailures,
162
+ });
163
+ }
164
+
165
+ /**
166
+ * Record one auto-refresh tick. The disposition is rebuilt through
167
+ * normalizeCatalogDisposition before anything is stored: an unnormalizable
168
+ * value is exactly the case this privacy boundary exists for, so it is dropped
169
+ * rather than copied through into a management response.
170
+ */
171
+ export function recordCatalogAutoRefreshOutcome(
172
+ disposition: CatalogDisposition,
173
+ changed: boolean,
174
+ ): CatalogAutoRefreshOutcome | null {
175
+ const normalized = normalizeCatalogDisposition(disposition);
176
+ if (normalized === null) return null;
177
+ const consecutiveFailures = catalogRefreshIsPending(normalized)
178
+ ? (lastAutoRefreshOutcome?.consecutiveFailures ?? 0) + 1
179
+ : 0;
180
+ const outcome = freezeCatalogAutoRefreshOutcome({
181
+ at: Date.now(),
182
+ disposition: normalized,
183
+ changed: changed === true,
184
+ consecutiveFailures,
185
+ });
186
+ lastAutoRefreshOutcome = outcome;
187
+ return freezeCatalogAutoRefreshOutcome(outcome);
188
+ }
189
+
190
+ export function lastCatalogAutoRefreshOutcome(): CatalogAutoRefreshOutcome | null {
191
+ return lastAutoRefreshOutcome === null
192
+ ? null
193
+ : freezeCatalogAutoRefreshOutcome(lastAutoRefreshOutcome);
194
+ }
195
+
196
+ export function resetCatalogAutoRefreshStatusForTests(): void {
197
+ lastAutoRefreshOutcome = null;
198
+ }
@@ -171,6 +171,41 @@ function readFirstRolloutLine(fd: number): string | null {
171
171
  return nlIndex === -1 ? null : collected.subarray(0, nlIndex).toString("utf8");
172
172
  }
173
173
 
174
+ /**
175
+ * Bounded tail of complete JSONL lines, newest-last.
176
+ *
177
+ * Used to refuse a rollout that *became* paginated after a legacy first line
178
+ * (#4311). Line 1 can still look writable after a newer Codex migrates the
179
+ * thread in place, and the native projector then dies on the first
180
+ * out-of-sequence ordinal a legacy append introduces. Every record written
181
+ * after such a migration carries an ordinal, so the newest records are where
182
+ * the evidence is.
183
+ *
184
+ * One read of a fixed window from EOF, split once. An earlier draft grew the
185
+ * window chunk by chunk and re-decoded the accumulated buffer on every
186
+ * iteration, which is quadratic: a rollout whose only `session_meta` sits at
187
+ * the top would have decoded and split up to the whole window ~256 times. The
188
+ * window is a cap, not a target — it is not walked and it is not the file.
189
+ *
190
+ * Returns `null` only when the file cannot be measured, which the caller
191
+ * treats as an unreadable record rather than a writable rollout.
192
+ */
193
+ const ROLLOUT_TAIL_WINDOW_BYTES = 1 << 20;
194
+
195
+ function readRolloutTailCompleteLines(fd: number): string[] | null {
196
+ const size = Number(fstatSync(fd).size);
197
+ if (!Number.isFinite(size) || size < 0) return null;
198
+ if (size === 0) return [];
199
+ const start = Math.max(0, size - ROLLOUT_TAIL_WINDOW_BYTES);
200
+ const window = Buffer.alloc(size - start);
201
+ const read = readSync(fd, window, 0, window.length, start);
202
+ if (read === 0) return [];
203
+ const lines = window.subarray(0, read).toString("utf8").split("\n");
204
+ // Unless the window reached BOF, the first element starts mid-record (and
205
+ // possibly mid-codepoint), so it is not a complete line.
206
+ return (start === 0 ? lines : lines.slice(1)).filter(line => line.length > 0);
207
+ }
208
+
174
209
  function planFirstLineProvider(firstLine: string, expectedId: string, provider: string): FirstLineProviderPlan {
175
210
  const meta = parseSessionMetaLine(firstLine);
176
211
  if (!meta || meta.record.payload.id !== expectedId) return { state: "unsafe" };
@@ -315,6 +350,26 @@ function assertLegacyHistoryWritable(path: string, heldFd?: number): void {
315
350
  const first = readFirstRolloutLine(fd);
316
351
  if (!first) throw new CodexHistoryIntegrityError("history_rollout_record_invalid");
317
352
  assertLegacyHistoryRecord(first);
353
+ // Line 1 is not enough: a newer Codex can migrate a live rollout in place,
354
+ // leaving the original session_meta and writing ordinals / history_mode only
355
+ // onto later records (#4311). The native projector then stops at the first
356
+ // cloned ordinal-0 append. Inspect a bounded window of the newest records
357
+ // and refuse before any mutation of the rollout, the row, or the manifest.
358
+ const tail = readRolloutTailCompleteLines(fd);
359
+ if (tail === null) throw new CodexHistoryIntegrityError("history_rollout_record_invalid");
360
+ if (tail.length === 0) return;
361
+ const last = tail[tail.length - 1];
362
+ if (!last) throw new CodexHistoryIntegrityError("history_rollout_record_invalid");
363
+ if (last !== first) assertLegacyHistoryRecord(last);
364
+ // Cheap filter: only re-parse tail lines that look paginated. Needed because
365
+ // a compensating append can make the last line look legacy again while an
366
+ // earlier-in-tail native conversion still carries ordinals (#4311).
367
+ for (const line of tail) {
368
+ if (line === first || line === last) continue;
369
+ if (line.includes("\"ordinal\"") || line.includes("\"history_mode\"")) {
370
+ assertLegacyHistoryRecord(line);
371
+ }
372
+ }
318
373
  } finally {
319
374
  if (heldFd === undefined) closeSync(fd);
320
375
  }
@@ -317,6 +317,37 @@ const MODEL_ROSTER_VERSIONS_PER_ACCOUNT_MAX = 4;
317
317
  * roster.
318
318
  */
319
319
  const MODEL_ROSTER_FLIGHTS_PER_ACCOUNT_MAX = 4;
320
+
321
+ /**
322
+ * Distinct caller-selected roster versions admitted per account in one roster window.
323
+ *
324
+ * The cache budget and the flight budget both bound STATE, not WORK. A caller that cycles
325
+ * `client_version` and waits for each answer misses the cache by design and misses the flight
326
+ * key by design, so it can renew an authenticated upstream request under EVERY stored account
327
+ * token as often as it likes, and the gated-model checks it displaces fail closed while it does.
328
+ *
329
+ * DISTINCT VERSIONS are counted, never attempts. One legitimate client retrying a single version
330
+ * through an upstream outage comes back every 15s on the failure TTL; charging each attempt would
331
+ * spend the whole allowance on that one version and then refuse it for the rest of the 5-minute
332
+ * window, turning a recovered upstream into several more minutes without gated models.
333
+ */
334
+ const MODEL_ROSTER_VERSION_MISSES_PER_ACCOUNT_MAX = 4;
335
+
336
+ interface AccountVersionMissBudget {
337
+ credentialIdentity: string;
338
+ /** Version -> when this version stops occupying the allowance. */
339
+ versions: Map<string, number>;
340
+ }
341
+
342
+ /**
343
+ * One row per ACCOUNT, not per credential identity.
344
+ *
345
+ * A Pool access-token refresh increments the generation, so an identity-keyed map would gain a
346
+ * permanent row per generation for the lifetime of the process: a protection against renewable
347
+ * work would have introduced an unbounded cache. A generation change replaces the row instead,
348
+ * which is also the right budget semantics — new credential, new allowance.
349
+ */
350
+ const accountModelsMisses = new Map<string, AccountVersionMissBudget>();
320
351
  const DIRECT_CALLER_ACCOUNT_PREFIX = "__direct_codex__:";
321
352
 
322
353
  export interface CodexModelEntitlementCredentialSnapshot {
@@ -670,6 +701,7 @@ async function modelsForCredential(
670
701
  fetcher: typeof fetch,
671
702
  now: number,
672
703
  clientVersion: string,
704
+ trustedClientVersion: string,
673
705
  credentialMutationEpoch?: number,
674
706
  ): Promise<CachedAccountModels> {
675
707
  const cached = accountModelsCache.get(cacheKeyFor(credential.accountId, clientVersion));
@@ -698,6 +730,21 @@ async function modelsForCredential(
698
730
  confirmed: false,
699
731
  };
700
732
  }
733
+ // Cache hits, joined flights and capacity refusals start no upstream request. Charge only
734
+ // after capacity admission; the locally selected runtime version remains exempt.
735
+ if (
736
+ !credential.accountId.startsWith(DIRECT_CALLER_ACCOUNT_PREFIX)
737
+ && clientVersion !== trustedClientVersion
738
+ && !admitVersionMiss(credential, clientVersion, now)
739
+ ) {
740
+ return {
741
+ credentialIdentity: credential.credentialIdentity,
742
+ clientVersion,
743
+ expiresAt: now,
744
+ models: new Set(),
745
+ confirmed: false,
746
+ };
747
+ }
701
748
  const flight = fetchAccountModels(credential, fetcher, now, clientVersion)
702
749
  .then(result => {
703
750
  if (
@@ -716,6 +763,30 @@ async function modelsForCredential(
716
763
  return flight;
717
764
  }
718
765
 
766
+ /** Whether this caller-selected version may open a new upstream request for the account. */
767
+ function admitVersionMiss(
768
+ credential: CodexModelEntitlementCredentialSnapshot,
769
+ clientVersion: string,
770
+ now: number,
771
+ ): boolean {
772
+ const stored = accountModelsMisses.get(credential.accountId);
773
+ const budget = stored && stored.credentialIdentity === credential.credentialIdentity
774
+ ? stored
775
+ : { credentialIdentity: credential.credentialIdentity, versions: new Map<string, number>() };
776
+ for (const [version, expiresAt] of budget.versions) {
777
+ if (expiresAt <= now) budget.versions.delete(version);
778
+ }
779
+ const alreadyCharged = budget.versions.has(clientVersion);
780
+ const admitted = alreadyCharged
781
+ || budget.versions.size < MODEL_ROSTER_VERSION_MISSES_PER_ACCOUNT_MAX;
782
+ // A repeat keeps its ORIGINAL expiry. Refreshing it here would let a caller hold one version
783
+ // open indefinitely, and it is the retry case this distinction exists to protect.
784
+ if (admitted && !alreadyCharged) budget.versions.set(clientVersion, now + MODEL_ROSTER_TTL_MS);
785
+ if (budget.versions.size === 0) accountModelsMisses.delete(credential.accountId);
786
+ else accountModelsMisses.set(credential.accountId, budget);
787
+ return admitted;
788
+ }
789
+
719
790
  function candidateAccountIds(config: Pick<OcxConfig, "codexAccounts">): string[] {
720
791
  return [
721
792
  MAIN_CODEX_ACCOUNT_ID,
@@ -992,6 +1063,10 @@ export async function resolveCodexModelEntitlements(
992
1063
  fetcher,
993
1064
  now,
994
1065
  clientVersion,
1066
+ resolveCodexEntitlementClientVersion(
1067
+ null,
1068
+ options.loadPersistedRuntime ?? loadPersistedCodexRuntime,
1069
+ ),
995
1070
  options.credentialMutationEpoch,
996
1071
  ),
997
1072
  })));
@@ -1050,6 +1125,7 @@ export async function isDirectCallerEntitledToCodexModel(
1050
1125
  options.fetcher ?? fetch,
1051
1126
  options.now ?? Date.now(),
1052
1127
  clientVersion,
1128
+ clientVersion,
1053
1129
  );
1054
1130
  return codexModelEntitlementStateForRoster(
1055
1131
  result.models,
@@ -1128,11 +1204,13 @@ export function invalidateCodexModelEntitlementsForAccount(accountId: string | n
1128
1204
  for (const key of [...accountModelsCache.keys()]) {
1129
1205
  if (accountIdOfCacheKey(key) === accountId) accountModelsCache.delete(key);
1130
1206
  }
1207
+ accountModelsMisses.delete(accountId);
1131
1208
  }
1132
1209
 
1133
1210
  export function resetCodexModelEntitlementCacheForTests(): void {
1134
1211
  accountModelsCache.clear();
1135
1212
  accountModelsFlights.clear();
1213
+ accountModelsMisses.clear();
1136
1214
  negativeCredentialMemo.clear();
1137
1215
  entitlementEnsureFlights.clear();
1138
1216
  runtimeVersionMemo = null;