@bitkyc08/opencodex 2.8.0 → 2.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (175) hide show
  1. package/README.md +24 -0
  2. package/bin/ocx.mjs +32 -4
  3. package/gui/dist/assets/index-CHwf3tTD.css +1 -0
  4. package/gui/dist/assets/index-u5eFOv2y.js +67 -0
  5. package/gui/dist/index.html +2 -2
  6. package/package.json +1 -1
  7. package/src/adapters/anthropic-image-normalize.ts +114 -20
  8. package/src/adapters/anthropic.ts +126 -10
  9. package/src/adapters/azure.ts +3 -3
  10. package/src/adapters/base.ts +7 -3
  11. package/src/adapters/cursor/discovery.ts +14 -4
  12. package/src/adapters/cursor/effort-map.ts +18 -6
  13. package/src/adapters/cursor/framing.ts +102 -27
  14. package/src/adapters/cursor/kv-store.ts +30 -3
  15. package/src/adapters/cursor/live-models.ts +22 -2
  16. package/src/adapters/cursor/live-transport.ts +245 -49
  17. package/src/adapters/cursor/mcp-manager.ts +105 -8
  18. package/src/adapters/cursor/native-exec-mcp.ts +5 -3
  19. package/src/adapters/cursor/native-exec-shell.ts +296 -14
  20. package/src/adapters/cursor/native-exec.ts +381 -33
  21. package/src/adapters/cursor/protobuf-events.ts +28 -1
  22. package/src/adapters/cursor/protobuf-request.ts +71 -39
  23. package/src/adapters/cursor/request-builder.ts +2 -2
  24. package/src/adapters/cursor/transport.ts +2 -0
  25. package/src/adapters/cursor.ts +13 -2
  26. package/src/adapters/google-antigravity-replay.ts +184 -17
  27. package/src/adapters/google.ts +58 -8
  28. package/src/adapters/kiro-thinking.ts +23 -9
  29. package/src/adapters/kiro-tools.ts +49 -18
  30. package/src/adapters/kiro.ts +377 -133
  31. package/src/adapters/mimo-free.ts +36 -4
  32. package/src/adapters/openai-chat.ts +143 -17
  33. package/src/adapters/openai-responses.ts +130 -14
  34. package/src/adapters/run-turn-queue.ts +7 -1
  35. package/src/bridge.ts +466 -69
  36. package/src/chat/outbound.ts +144 -38
  37. package/src/claude/inbound-debug.ts +53 -8
  38. package/src/claude/outbound.ts +224 -38
  39. package/src/cli/agent-driven.ts +34 -1
  40. package/src/cli/catalog-prewarm.ts +5 -2
  41. package/src/cli/claude-desktop.ts +2 -2
  42. package/src/cli/doctor.ts +12 -0
  43. package/src/cli/export-command.ts +187 -0
  44. package/src/cli/help.ts +11 -0
  45. package/src/cli/index.ts +13 -3
  46. package/src/cli/init.ts +129 -102
  47. package/src/cli/opencode.ts +36 -151
  48. package/src/cli/star-prompt.ts +13 -4
  49. package/src/cli/status-oauth.ts +12 -2
  50. package/src/clients/config-export.ts +377 -0
  51. package/src/codex/account-runtime-state.ts +19 -1
  52. package/src/codex/account-store.ts +162 -82
  53. package/src/codex/auth-api.ts +467 -159
  54. package/src/codex/auth-context.ts +15 -2
  55. package/src/codex/catalog/aggregation.ts +15 -0
  56. package/src/codex/catalog/effort.ts +16 -6
  57. package/src/codex/catalog/metadata.ts +6 -0
  58. package/src/codex/catalog/parsing.ts +3 -1
  59. package/src/codex/catalog/provider-fetch.ts +29 -0
  60. package/src/codex/catalog/sync.ts +64 -7
  61. package/src/codex/catalog.ts +2 -2
  62. package/src/codex/inject.ts +5 -5
  63. package/src/codex/main-account-cache.ts +8 -1
  64. package/src/codex/model-cache.ts +81 -2
  65. package/src/codex/pool-rotation.ts +39 -0
  66. package/src/codex/project-config-warnings.ts +12 -1
  67. package/src/codex/quota.ts +35 -3
  68. package/src/codex/routing.ts +46 -1
  69. package/src/codex/shim.ts +10 -4
  70. package/src/codex/subagent-model-fallback.ts +12 -0
  71. package/src/codex/websocket-registry.ts +27 -0
  72. package/src/combos/failover.ts +31 -1
  73. package/src/combos/request.ts +9 -0
  74. package/src/combos/resolve.ts +60 -4
  75. package/src/combos/types.ts +12 -0
  76. package/src/config.ts +510 -55
  77. package/src/github/star-state.ts +13 -1
  78. package/src/images/fulfill.ts +39 -1
  79. package/src/images/loop.ts +52 -12
  80. package/src/lib/admission.ts +83 -0
  81. package/src/lib/app-owned-memory-stores.ts +173 -0
  82. package/src/lib/app-owned-memory.ts +265 -0
  83. package/src/lib/bun-stream-caps.ts +31 -7
  84. package/src/lib/config-ownership.ts +33 -0
  85. package/src/lib/crash-guard.ts +65 -5
  86. package/src/lib/debug-log-buffer.ts +47 -6
  87. package/src/lib/destination-policy.ts +12 -1
  88. package/src/lib/errors.ts +3 -0
  89. package/src/lib/gcp-adc.ts +40 -2
  90. package/src/lib/injection-debug-log.ts +26 -2
  91. package/src/lib/provider-outbound.ts +3 -0
  92. package/src/lib/sidecar-tracker.ts +5 -2
  93. package/src/lib/sse-decoder.ts +257 -37
  94. package/src/lib/state-store-registrations.ts +109 -0
  95. package/src/lib/state-store-sweeper.ts +184 -0
  96. package/src/lib/translator-budget.ts +356 -0
  97. package/src/lib/windows-secret-acl.ts +33 -12
  98. package/src/lib/winsw.ts +14 -1
  99. package/src/oauth/anthropic-routing.ts +31 -7
  100. package/src/oauth/google-antigravity.ts +2 -1
  101. package/src/oauth/health.ts +30 -12
  102. package/src/oauth/index.ts +127 -23
  103. package/src/oauth/kiro-credentials.ts +72 -1
  104. package/src/oauth/kiro.ts +23 -4
  105. package/src/oauth/store.ts +165 -18
  106. package/src/oauth/token-guardian.ts +43 -4
  107. package/src/oauth/types.ts +2 -1
  108. package/src/providers/base-url-choices.ts +10 -0
  109. package/src/providers/derive.ts +12 -0
  110. package/src/providers/free-directory.ts +4 -1
  111. package/src/providers/key-failover.ts +12 -0
  112. package/src/providers/openai-sidecar.ts +4 -1
  113. package/src/providers/quota.ts +68 -7
  114. package/src/providers/registry.ts +279 -3
  115. package/src/responses/parser.ts +5 -1
  116. package/src/responses/spill-store.ts +394 -0
  117. package/src/responses/state.ts +520 -102
  118. package/src/router.ts +18 -1
  119. package/src/server/adapter-resolve.ts +20 -3
  120. package/src/server/auth-cors.ts +121 -28
  121. package/src/server/chat-completions.ts +57 -12
  122. package/src/server/claude-messages.ts +85 -13
  123. package/src/server/index.ts +242 -100
  124. package/src/server/lifecycle.ts +155 -25
  125. package/src/server/management/agent-settings-routes.ts +79 -36
  126. package/src/server/management/api-key-usage.ts +167 -0
  127. package/src/server/management/body.ts +35 -0
  128. package/src/server/management/combo-routes.ts +5 -1
  129. package/src/server/management/config-routes.ts +42 -12
  130. package/src/server/management/logs-usage-routes.ts +41 -21
  131. package/src/server/management/model-routes.ts +188 -54
  132. package/src/server/management/oauth-account-routes.ts +115 -26
  133. package/src/server/management/provider-routes.ts +56 -6
  134. package/src/server/management/shared.ts +16 -3
  135. package/src/server/management/sidebar-routes.ts +50 -1
  136. package/src/server/management/system-restart.ts +13 -6
  137. package/src/server/management/system-routes.ts +15 -3
  138. package/src/server/management/usage-summary-cache.ts +86 -0
  139. package/src/server/management-api.ts +39 -5
  140. package/src/server/management-auth.ts +65 -14
  141. package/src/server/port-reclaim.ts +58 -12
  142. package/src/server/ports.ts +2 -0
  143. package/src/server/proxy-liveness.ts +60 -14
  144. package/src/server/relay-eager.ts +20 -4
  145. package/src/server/relay.ts +548 -154
  146. package/src/server/request-decompress.ts +51 -4
  147. package/src/server/request-log.ts +134 -15
  148. package/src/server/responses/collaboration.ts +15 -4
  149. package/src/server/responses/compact.ts +3 -0
  150. package/src/server/responses/core.ts +241 -66
  151. package/src/server/responses-image-gen-repair.ts +19 -5
  152. package/src/server/responses-item-id-repair.ts +23 -5
  153. package/src/server/sse-payload-rewrite.ts +71 -12
  154. package/src/server/startup-health-cache.ts +14 -1
  155. package/src/server/system-env.ts +8 -1
  156. package/src/server/windows-tcp-drop.ts +15 -5
  157. package/src/server/ws-bridge.ts +25 -0
  158. package/src/service.ts +179 -13
  159. package/src/storage/policy-job.ts +93 -23
  160. package/src/storage/policy-worker.ts +6 -0
  161. package/src/storage/restore-job.ts +62 -16
  162. package/src/storage/restore-worker.ts +6 -0
  163. package/src/storage/storage-mutation-coordinator.ts +36 -6
  164. package/src/storage/worker-lifecycle.ts +181 -47
  165. package/src/tray/windows.ts +97 -25
  166. package/src/types.ts +39 -6
  167. package/src/update/index.ts +24 -5
  168. package/src/update/job.ts +598 -73
  169. package/src/usage/log.ts +115 -17
  170. package/src/usage/summary.ts +67 -2
  171. package/src/vision/index.ts +112 -22
  172. package/src/web-search/loop.ts +38 -6
  173. package/src/web-search/progress-stream.ts +14 -3
  174. package/gui/dist/assets/index-BDjpkcRN.js +0 -67
  175. package/gui/dist/assets/index-BHsKRFh9.css +0 -1
@@ -1,6 +1,7 @@
1
1
  import { existsSync, readFileSync, unlinkSync } from "node:fs";
2
2
  import { join } from "node:path";
3
3
  import { atomicWriteFile, getConfigDir } from "../config";
4
+ import { captureConfigGeneration, type GenerationContext } from "../lib/state-store-sweeper";
4
5
 
5
6
  export type StoredAccountQuota = {
6
7
  weeklyPercent?: number;
@@ -49,8 +50,16 @@ const MONTHLY_WINDOW_MIN_SECONDS = 28 * 24 * 60 * 60;
49
50
  const MONTHLY_WINDOW_MIN_MINUTES = MONTHLY_WINDOW_MIN_SECONDS / 60;
50
51
 
51
52
  const accountQuota = new Map<string, StoredAccountQuota>();
53
+ let lastReconciledGeneration = 0;
54
+ let liveAccountIds = new Set<string>();
52
55
 
53
- export const CODEX_UNKNOWN_USAGE_SCORE = 100;
56
+ function mayCommitAccountQuota(accountId: string, writerGeneration: number): boolean {
57
+ return writerGeneration >= lastReconciledGeneration || liveAccountIds.has(accountId);
58
+ }
59
+
60
+ // Valid upstream percentages are normalized to 0..100. Keep "unknown" outside that domain so an
61
+ // actually exhausted account is still eligible for threshold rotation.
62
+ export const CODEX_UNKNOWN_USAGE_SCORE = 101;
54
63
  export const CODEX_EXHAUSTED_USAGE_PERCENT = 100;
55
64
 
56
65
  export function isCodexQuotaExhausted(
@@ -125,8 +134,10 @@ function snapshotHasUsage(quota: Omit<StoredAccountQuota, "updatedAt">): boolean
125
134
  export function setAccountQuotaFromParsed(
126
135
  accountId: string,
127
136
  quota: Omit<StoredAccountQuota, "updatedAt"> | null,
137
+ writerGeneration = captureConfigGeneration(),
128
138
  ): void {
129
139
  if (!quota) return;
140
+ if (!mayCommitAccountQuota(accountId, writerGeneration)) return;
130
141
  const existing = accountQuota.get(accountId);
131
142
  const next: StoredAccountQuota = { updatedAt: Date.now() };
132
143
  const creditsOnly = quota.resetCredits !== undefined && !snapshotHasUsage(quota);
@@ -214,10 +225,14 @@ export function parseUpstreamQuotaHeaders(headers: Headers): Omit<StoredAccountQ
214
225
  return hasKnownQuotaValue(quota) ? quota : null;
215
226
  }
216
227
 
217
- export function applyAccountQuotaFromUpstreamHeaders(accountId: string, headers: Headers): void {
228
+ export function applyAccountQuotaFromUpstreamHeaders(
229
+ accountId: string,
230
+ headers: Headers,
231
+ writerGeneration = captureConfigGeneration(),
232
+ ): void {
218
233
  const quota = parseUpstreamQuotaHeaders(headers);
219
234
  if (!quota) return;
220
- setAccountQuotaFromParsed(accountId, quota);
235
+ setAccountQuotaFromParsed(accountId, quota, writerGeneration);
221
236
  }
222
237
 
223
238
  export function updateAccountQuota(
@@ -227,7 +242,9 @@ export function updateAccountQuota(
227
242
  monthly?: unknown,
228
243
  monthlyResetAt?: unknown,
229
244
  resetCredits?: number,
245
+ writerGeneration = captureConfigGeneration(),
230
246
  ): void {
247
+ if (!mayCommitAccountQuota(accountId, writerGeneration)) return;
231
248
  const existing = accountQuota.get(accountId);
232
249
  const nextWeekly = normalizeUsagePercent(weekly);
233
250
  const nextMonthly = normalizeUsagePercent(monthly);
@@ -325,6 +342,21 @@ export function clearAccountQuota(accountId?: string): void {
325
342
  }
326
343
  }
327
344
 
345
+ export function reconcileCodexQuotaAccounts(context: GenerationContext): number {
346
+ if (context.generation <= lastReconciledGeneration) return 0;
347
+ hydrateAccountQuotasFromDisk();
348
+ let removed = 0;
349
+ for (const accountId of accountQuota.keys()) {
350
+ if (context.codexAccountIds.has(accountId)) continue;
351
+ accountQuota.delete(accountId);
352
+ removed += 1;
353
+ }
354
+ liveAccountIds = new Set(context.codexAccountIds);
355
+ lastReconciledGeneration = context.generation;
356
+ if (removed > 0) schedulePersistAccountQuotas();
357
+ return removed;
358
+ }
359
+
328
360
  export function parseUsageQuota(data: WhamUsageResponse): Omit<StoredAccountQuota, "updatedAt"> | null {
329
361
  const resetCredits = typeof data.rate_limit_reset_credits?.available_count === "number"
330
362
  ? data.rate_limit_reset_credits.available_count
@@ -19,6 +19,9 @@ import { CODEX_UNKNOWN_USAGE_SCORE, getAccountQuota } from "./quota";
19
19
  import { MAIN_CODEX_ACCOUNT_ID, getMainAccountPlan } from "./main-account";
20
20
  import { isSelectableCodexPoolAccount } from "./account-id";
21
21
  import type { OcxConfig } from "../types";
22
+ import { captureConfigGeneration, type GenerationContext } from "../lib/state-store-sweeper";
23
+ import { isCanonicalOpenAiForwardProvider } from "../providers/openai-tiers";
24
+ import { retainedUtf8Bytes } from "../lib/admission";
22
25
 
23
26
  type ThreadAffinityEntry = {
24
27
  accountId: string;
@@ -104,6 +107,7 @@ const CODEX_TRANSIENT_SOFT_AVOID_ESCALATION_MS = [
104
107
  ] as const;
105
108
  export const CODEX_THREAD_AFFINITY_IDLE_TTL_MS = 24 * 60 * 60_000;
106
109
  export const CODEX_THREAD_AFFINITY_MAX_ENTRIES = 2048;
110
+ const MAX_AFFINITY_COMPONENT_BYTES = 512;
107
111
  // Min interval between quota threshold re-evaluations for a single bound thread.
108
112
  // Well under the 5h/weekly quota windows, but enough to stop per-request flapping.
109
113
  export const CODEX_THREAD_AFFINITY_REEVAL_INTERVAL_MS = 60_000;
@@ -115,6 +119,8 @@ const upstreamHealth = new Map<string, CodexUpstreamHealth>();
115
119
  * from account-wide Retry-After/default throttles and transient health.
116
120
  */
117
121
  const quotaScopedHealth = new Map<string, Map<CodexQuotaScope, CodexUpstreamHealth>>();
122
+ let lastReconciledGeneration = 0;
123
+ let liveHealthAccountIds = new Set<string>();
118
124
 
119
125
  export type CodexUpstreamOutcome = number | "connect_error" | "timeout";
120
126
  export type CodexUpstreamOutcomeClass = "success" | "credential" | "quota" | "transient" | "caller" | "unknown";
@@ -185,6 +191,8 @@ export type CodexUpstreamOutcomeMeta = {
185
191
  * again (which would advance a round-robin ring twice).
186
192
  */
187
193
  promoteAccountId?: string;
194
+ /** Generation captured when this routed account was selected. */
195
+ writerGeneration?: number;
188
196
  };
189
197
 
190
198
  function hasConfiguredPoolAccount(config: OcxConfig, accountId: string): boolean {
@@ -193,6 +201,15 @@ function hasConfiguredPoolAccount(config: OcxConfig, accountId: string): boolean
193
201
  .some(account => isSelectableCodexPoolAccount(account) && account.id === accountId);
194
202
  }
195
203
 
204
+ export function listLiveCodexAccountIds(config: OcxConfig): ReadonlySet<string> {
205
+ const ids = new Set((config.codexAccounts ?? []).map(account => account.id));
206
+ const openai = config.providers.openai;
207
+ if (openai && openai.disabled !== true && isCanonicalOpenAiForwardProvider(openai)) {
208
+ ids.add(MAIN_CODEX_ACCOUNT_ID);
209
+ }
210
+ return ids;
211
+ }
212
+
196
213
  export function clearThreadAccountMap(): void {
197
214
  threadAccountMap.clear();
198
215
  }
@@ -217,6 +234,24 @@ export function clearCodexUpstreamHealthForAccount(accountId: string): void {
217
234
  quotaScopedHealth.delete(accountId);
218
235
  }
219
236
 
237
+ export function reconcileCodexRoutingHealth(context: GenerationContext): number {
238
+ if (context.generation <= lastReconciledGeneration) return 0;
239
+ let removed = 0;
240
+ for (const accountId of upstreamHealth.keys()) {
241
+ if (context.codexAccountIds.has(accountId)) continue;
242
+ upstreamHealth.delete(accountId);
243
+ removed += 1;
244
+ }
245
+ for (const accountId of quotaScopedHealth.keys()) {
246
+ if (context.codexAccountIds.has(accountId)) continue;
247
+ quotaScopedHealth.delete(accountId);
248
+ removed += 1;
249
+ }
250
+ liveHealthAccountIds = new Set(context.codexAccountIds);
251
+ lastReconciledGeneration = context.generation;
252
+ return removed;
253
+ }
254
+
220
255
  export function getCodexUpstreamHealth(
221
256
  accountId: string,
222
257
  ): CodexUpstreamHealth | null {
@@ -600,11 +635,17 @@ function threadAffinityScope(quotaScope?: CodexQuotaScope): ThreadAffinityScope
600
635
  return quotaScope ?? LEGACY_THREAD_AFFINITY_SCOPE;
601
636
  }
602
637
 
638
+ function admissibleAffinityComponent(value: string): boolean {
639
+ return retainedUtf8Bytes(value) <= MAX_AFFINITY_COMPONENT_BYTES;
640
+ }
641
+
603
642
  function getThreadAffinity(threadId: string, quotaScope?: CodexQuotaScope): ThreadAffinityEntry | undefined {
643
+ if (!admissibleAffinityComponent(threadId)) return undefined;
604
644
  return threadAccountMap.get(threadId)?.get(threadAffinityScope(quotaScope));
605
645
  }
606
646
 
607
647
  function deleteThreadAffinity(threadId: string, quotaScope?: CodexQuotaScope): void {
648
+ if (!admissibleAffinityComponent(threadId)) return;
608
649
  const affinities = threadAccountMap.get(threadId);
609
650
  if (!affinities) return;
610
651
  affinities.delete(threadAffinityScope(quotaScope));
@@ -613,6 +654,7 @@ function deleteThreadAffinity(threadId: string, quotaScope?: CodexQuotaScope): v
613
654
 
614
655
  /** Remove only the matching failed account's affinities for one thread. */
615
656
  function deleteThreadAffinitiesForAccount(threadId: string, accountId: string): void {
657
+ if (!admissibleAffinityComponent(threadId) || !admissibleAffinityComponent(accountId)) return;
616
658
  const affinities = threadAccountMap.get(threadId);
617
659
  if (!affinities) return;
618
660
  for (const [scope, entry] of affinities) {
@@ -671,6 +713,7 @@ function bindThreadAffinity(
671
713
  now: number,
672
714
  quotaScope?: CodexQuotaScope,
673
715
  ): void {
716
+ if (!admissibleAffinityComponent(threadId) || !admissibleAffinityComponent(accountId)) return;
674
717
  const record = accountId === MAIN_CODEX_ACCOUNT_ID ? undefined : readCodexAccountRecord(accountId);
675
718
  if (accountId !== MAIN_CODEX_ACCOUNT_ID && (!record?.credential || record.deletedAt != null)) return;
676
719
  pruneExpiredThreadAffinities(now);
@@ -1204,6 +1247,8 @@ export function recordCodexUpstreamOutcome(
1204
1247
  meta: CodexUpstreamOutcomeMeta = {},
1205
1248
  ): void {
1206
1249
  if (!accountId) return;
1250
+ const writerGeneration = meta.writerGeneration ?? captureConfigGeneration();
1251
+ if (writerGeneration < lastReconciledGeneration && !liveHealthAccountIds.has(accountId)) return;
1207
1252
  const now = meta.now ?? Date.now();
1208
1253
  const outcomeClass = classifyCodexUpstreamOutcome(outcome);
1209
1254
  const quotaScope = codexQuotaScopeForModel(meta.modelId);
@@ -1275,7 +1320,7 @@ export function recordCodexUpstreamOutcome(
1275
1320
  lastFailureAt: now,
1276
1321
  });
1277
1322
  quotaScopedHealth.delete(accountId);
1278
- markAccountNeedsReauth(accountId);
1323
+ markAccountNeedsReauth(accountId, writerGeneration);
1279
1324
  clearThreadAccountMapForAccount(accountId);
1280
1325
  return;
1281
1326
  }
package/src/codex/shim.ts CHANGED
@@ -25,12 +25,14 @@ import { serviceApiTokenFilePath } from "../lib/service-secrets";
25
25
  import { recordOwnedConfigPath } from "../lib/config-ownership";
26
26
  import { windowsEnvIndirectBatchValue } from "../lib/win-paths";
27
27
  import { isWslRuntime, wslAutomountRoot } from "./home";
28
+ import { truncateRetainedUtf8 } from "../lib/admission";
28
29
 
29
30
  const SHIM_MARKER = "opencodex codex autostart shim";
30
31
  const CODEX_SHIM_PROBE_BYTES = 16 * 1024;
31
32
  export const CODEX_SHIM_REPLACEMENT_STABLE_MS = 100;
32
33
  export const CODEX_SHIM_STATE_MAX_BYTES = 1024 * 1024;
33
34
  const CODEX_SHIM_RESTORE_LOCK_STALE_MS = 30_000;
35
+ const MAX_DIAGNOSTIC_VALUE_BYTES = 8 * 1024;
34
36
  let lastShimDiscoveryError: string | null = null;
35
37
  /** Last human-readable reason discovery returned null (exposed for doctor/tests). */
36
38
  export function lastCodexDiscoveryError(): string | null {
@@ -309,10 +311,12 @@ export function findCodexOnPath(deps: CodexPathScanDeps = {}): string | null {
309
311
  }
310
312
 
311
313
  if (skippedInterop) {
312
- lastShimDiscoveryError =
314
+ lastShimDiscoveryError = truncateRetainedUtf8(
313
315
  `Found a Windows codex at ${skippedInterop} via WSL PATH interop, but no Linux-side codex. ` +
314
316
  "Refusing to shim a Windows launcher from WSL (a WSL shim breaks Windows invocations). " +
315
- "Install codex inside WSL (npm i -g @openai/codex), or run 'ocx ensure' from Windows to shim the Windows side.";
317
+ "Install codex inside WSL (npm i -g @openai/codex), or run 'ocx ensure' from Windows to shim the Windows side.",
318
+ MAX_DIAGNOSTIC_VALUE_BYTES,
319
+ );
316
320
  }
317
321
  return null;
318
322
  }
@@ -324,9 +328,11 @@ function findWindowsCodexTargets(): ShimFileState[] | null {
324
328
  if (existsSync(exe) && !isShim(exe)) {
325
329
  try {
326
330
  if (!lstatSync(exe).isDirectory()) {
327
- lastShimDiscoveryError =
331
+ lastShimDiscoveryError = truncateRetainedUtf8(
328
332
  `Found codex.exe at ${exe}. Refusing to rename a real .exe because exact codex.exe invocations would break; ` +
329
- "install a codex.cmd/codex.ps1 launcher or use `ocx service install` for autostart.";
333
+ "install a codex.cmd/codex.ps1 launcher or use `ocx service install` for autostart.",
334
+ MAX_DIAGNOSTIC_VALUE_BYTES,
335
+ );
330
336
  return null;
331
337
  }
332
338
  } catch { /* keep scanning */ }
@@ -26,6 +26,7 @@ import { isThreadSpawnRequest } from "../server/effort-policy";
26
26
  import { PROVIDER_REGISTRY } from "../providers/registry";
27
27
  import { isCanonicalOpenAiForwardProvider } from "../providers/openai-tiers";
28
28
  import { routeModel, type RouteResult } from "../router";
29
+ import { sweepExpiredOnWrite } from "../lib/state-store-sweeper";
29
30
  export const DEFAULT_SUBAGENT_MODEL_FALLBACK_POLL_MS = 60_000;
30
31
 
31
32
  type SubagentQuotaPrimeFn = (config: OcxConfig, reason: string) => Promise<void>;
@@ -245,6 +246,17 @@ export function noteSubagentModelFailure(
245
246
  reason: "quota_exhausted",
246
247
  },
247
248
  );
249
+ sweepExpiredOnWrite(now);
250
+ }
251
+
252
+ export function sweepExpiredSubagentModelHealth(now = Date.now()): number {
253
+ let removed = 0;
254
+ for (const [key, health] of modelHealth) {
255
+ if (health.unavailableUntil > now) continue;
256
+ modelHealth.delete(key);
257
+ removed += 1;
258
+ }
259
+ return removed;
248
260
  }
249
261
 
250
262
  export function resetSubagentModelFallbackStateForTests(): void {
@@ -1,5 +1,32 @@
1
1
  import type { ServerWebSocket } from "bun";
2
2
  import type { WsData } from "../server/ws-bridge";
3
+ import { createAdmissionGate, type AdmissionMetrics, type AdmissionReservation } from "../lib/admission";
4
+
5
+ export const MAX_TRACKED_CODEX_WEBSOCKETS = 128;
6
+ const websocketGate = createAdmissionGate("codex_websockets", MAX_TRACKED_CODEX_WEBSOCKETS);
7
+
8
+ export function tryReserveCodexWebSocket(): AdmissionReservation<ServerWebSocket<WsData>> | null {
9
+ const gateLease = websocketGate.tryAcquire();
10
+ if (!gateLease) return null;
11
+ let socket: ServerWebSocket<WsData> | undefined;
12
+ let active = true;
13
+ return {
14
+ bind(value) {
15
+ if (!active) return;
16
+ socket = value;
17
+ },
18
+ release() {
19
+ if (!active) return;
20
+ active = false;
21
+ socket = undefined;
22
+ gateLease.release();
23
+ },
24
+ };
25
+ }
26
+
27
+ export function codexWebSocketAdmissionMetrics(): AdmissionMetrics {
28
+ return websocketGate.metrics();
29
+ }
3
30
 
4
31
  const socketsByAccount = new Map<string, Set<ServerWebSocket<WsData>>>();
5
32
 
@@ -1,6 +1,11 @@
1
1
  import { classifyError, isCyberPolicyCode } from "../lib/errors";
2
2
  import type { OcxComboTarget } from "../types";
3
3
  import { targetKey } from "./types";
4
+ import {
5
+ captureConfigGeneration,
6
+ sweepExpiredOnWrite,
7
+ type GenerationContext,
8
+ } from "../lib/state-store-sweeper";
4
9
 
5
10
  interface TargetCooldown {
6
11
  cooldownUntil: number;
@@ -11,6 +16,8 @@ const MAX_COOLDOWN_MS = 10 * 60_000;
11
16
 
12
17
  /** Map<`${comboId}\0${provider/model}`, TargetCooldown> */
13
18
  const targetCooldowns = new Map<string, TargetCooldown>();
19
+ let lastReconciledGeneration = 0;
20
+ let liveComboTargets = new Set<string>();
14
21
 
15
22
  function cooldownMapKey(
16
23
  comboId: string,
@@ -55,20 +62,43 @@ export function isComboTargetInCooldown(
55
62
  export function coolComboTarget(
56
63
  comboId: string,
57
64
  target: Pick<OcxComboTarget, "provider" | "model">,
58
- options?: { retryAfter?: string | null; now?: number; cooldownMs?: number },
65
+ options?: { retryAfter?: string | null; now?: number; cooldownMs?: number; writerGeneration?: number },
59
66
  ): void {
60
67
  const now = options?.now ?? Date.now();
68
+ const writerGeneration = options?.writerGeneration ?? captureConfigGeneration();
69
+ const ownerKey = `${comboId}::${targetKey(target)}`;
70
+ if (writerGeneration < lastReconciledGeneration && !liveComboTargets.has(ownerKey)) return;
61
71
  const cooldownMs = options?.cooldownMs
62
72
  ?? parseRetryAfterMs(options?.retryAfter, now)
63
73
  ?? DEFAULT_COOLDOWN_MS;
64
74
  targetCooldowns.set(cooldownMapKey(comboId, target), {
65
75
  cooldownUntil: now + Math.min(Math.max(cooldownMs, 1), MAX_COOLDOWN_MS),
66
76
  });
77
+ sweepExpiredOnWrite(now);
78
+ }
79
+
80
+ export function reconcileComboTargetCooldowns(context: GenerationContext): number {
81
+ if (context.generation <= lastReconciledGeneration) return 0;
82
+ liveComboTargets = new Set(context.comboTargets);
83
+ lastReconciledGeneration = context.generation;
84
+ return 0;
85
+ }
86
+
87
+ export function sweepExpiredComboTargetCooldowns(now = Date.now()): number {
88
+ let removed = 0;
89
+ for (const [key, cooldown] of targetCooldowns) {
90
+ if (cooldown.cooldownUntil > now) continue;
91
+ targetCooldowns.delete(key);
92
+ removed += 1;
93
+ }
94
+ return removed;
67
95
  }
68
96
 
69
97
  export function clearComboTargetCooldowns(comboId?: string): void {
70
98
  if (comboId === undefined) {
71
99
  targetCooldowns.clear();
100
+ liveComboTargets.clear();
101
+ lastReconciledGeneration = 0;
72
102
  return;
73
103
  }
74
104
  const prefix = `${comboId}\0`;
@@ -2,6 +2,15 @@ import type { OcxComboDefaultEffort, OcxComboTarget, OcxConfig } from "../types"
2
2
  import { resolveComboId } from "./types";
3
3
 
4
4
  const warnedUnsupportedDefaults = new Set<string>();
5
+ let lastWarningReconciledGeneration = 0;
6
+
7
+ export function reconcileComboWarningMemos(generation: number): number {
8
+ if (generation <= lastWarningReconciledGeneration) return 0;
9
+ const removed = warnedUnsupportedDefaults.size;
10
+ warnedUnsupportedDefaults.clear();
11
+ lastWarningReconciledGeneration = generation;
12
+ return removed;
13
+ }
5
14
 
6
15
  export function resetComboEffortWarningStateForTests(): void {
7
16
  warnedUnsupportedDefaults.clear();
@@ -2,12 +2,17 @@ import type { OcxComboTarget, OcxConfig } from "../types";
2
2
  import { coolComboTarget, isComboTargetInCooldown } from "./failover";
3
3
  import { getCombo, resolveComboId, targetKey } from "./types";
4
4
  import type { NormalizedComboConfig } from "./types";
5
+ import {
6
+ captureConfigGeneration,
7
+ type GenerationContext,
8
+ } from "../lib/state-store-sweeper";
5
9
 
6
10
  export interface ComboPick {
7
11
  comboId: string;
8
12
  target: Required<OcxComboTarget>;
9
13
  targetIndex: number;
10
14
  attempted: string[];
15
+ writerGeneration: number;
11
16
  }
12
17
 
13
18
  interface SelectionState {
@@ -17,6 +22,17 @@ interface SelectionState {
17
22
  }
18
23
 
19
24
  const selectionState = new Map<string, SelectionState>();
25
+ let lastReconciledGeneration = 0;
26
+ let liveComboTargets = new Set<string>();
27
+
28
+ function comboTargetOwnerKey(comboId: string, key: string): string {
29
+ return `${comboId}::${key}`;
30
+ }
31
+
32
+ function mayCommitComboState(comboId: string, key: string, writerGeneration: number): boolean {
33
+ return writerGeneration >= lastReconciledGeneration
34
+ || liveComboTargets.has(comboTargetOwnerKey(comboId, key));
35
+ }
20
36
 
21
37
  export class UnknownComboError extends Error {
22
38
  constructor(readonly comboId: string) {
@@ -74,6 +90,7 @@ export function pickComboTarget(
74
90
  eligible?: (target: Required<OcxComboTarget>) => boolean;
75
91
  } = {},
76
92
  ): ComboPick | null {
93
+ const writerGeneration = captureConfigGeneration();
77
94
  const combo = getCombo(config, comboId);
78
95
  if (!combo) throw new UnknownComboError(comboId);
79
96
  const excluded = new Set(options.exclude ?? []);
@@ -114,6 +131,7 @@ export function pickComboTarget(
114
131
  target,
115
132
  targetIndex,
116
133
  attempted: [...excluded, targetKey(target)],
134
+ writerGeneration,
117
135
  };
118
136
  }
119
137
 
@@ -121,10 +139,13 @@ export function noteComboSuccess(
121
139
  comboId: string,
122
140
  combo: NormalizedComboConfig,
123
141
  target: Required<OcxComboTarget>,
142
+ writerGeneration = captureConfigGeneration(),
124
143
  ): void {
125
144
  if (combo.strategy !== "round-robin") return;
145
+ const key = targetKey(target);
146
+ if (!mayCommitComboState(comboId, key, writerGeneration)) return;
126
147
  const state = selectionState.get(comboId);
127
- if (!state || state.activeKey !== targetKey(target)) return;
148
+ if (!state || state.activeKey !== key) return;
128
149
  state.successes += 1;
129
150
  if (state.successes >= combo.stickyLimit) {
130
151
  delete state.activeKey;
@@ -132,7 +153,12 @@ export function noteComboSuccess(
132
153
  }
133
154
  }
134
155
 
135
- export function noteComboFailure(comboId: string, target: OcxComboTarget): void {
156
+ export function noteComboFailure(
157
+ comboId: string,
158
+ target: OcxComboTarget,
159
+ writerGeneration = captureConfigGeneration(),
160
+ ): void {
161
+ if (!mayCommitComboState(comboId, targetKey(target), writerGeneration)) return;
136
162
  const state = selectionState.get(comboId);
137
163
  if (state?.activeKey === targetKey(target)) {
138
164
  delete state.activeKey;
@@ -149,8 +175,11 @@ export function advanceComboAfterFailure(
149
175
  eligible?: (target: Required<OcxComboTarget>) => boolean;
150
176
  } = {},
151
177
  ): ComboPick | null {
152
- noteComboFailure(pick.comboId, pick.target);
153
- coolComboTarget(pick.comboId, pick.target, options);
178
+ noteComboFailure(pick.comboId, pick.target, pick.writerGeneration);
179
+ coolComboTarget(pick.comboId, pick.target, {
180
+ ...options,
181
+ writerGeneration: pick.writerGeneration,
182
+ });
154
183
  return pickComboTarget(config, pick.comboId, {
155
184
  exclude: pick.attempted,
156
185
  eligible: target => !isComboTargetInCooldown(pick.comboId, target, options.now)
@@ -158,9 +187,36 @@ export function advanceComboAfterFailure(
158
187
  });
159
188
  }
160
189
 
190
+ export function reconcileComboRotationState(context: GenerationContext): number {
191
+ if (context.generation <= lastReconciledGeneration) return 0;
192
+ let removed = 0;
193
+ for (const [comboId, state] of selectionState) {
194
+ if (!context.comboIds.has(comboId)) {
195
+ selectionState.delete(comboId);
196
+ removed += 1;
197
+ continue;
198
+ }
199
+ if (state.activeKey && !context.comboTargets.has(comboTargetOwnerKey(comboId, state.activeKey))) {
200
+ delete state.activeKey;
201
+ state.successes = 0;
202
+ removed += 1;
203
+ }
204
+ for (const key of state.currentWeights.keys()) {
205
+ if (context.comboTargets.has(comboTargetOwnerKey(comboId, key))) continue;
206
+ state.currentWeights.delete(key);
207
+ removed += 1;
208
+ }
209
+ }
210
+ liveComboTargets = new Set(context.comboTargets);
211
+ lastReconciledGeneration = context.generation;
212
+ return removed;
213
+ }
214
+
161
215
  export function clearComboSelectionState(comboId?: string): void {
162
216
  if (comboId === undefined) {
163
217
  selectionState.clear();
218
+ liveComboTargets.clear();
219
+ lastReconciledGeneration = 0;
164
220
  return;
165
221
  }
166
222
  selectionState.delete(comboId);
@@ -304,6 +304,18 @@ export function listComboIds(config: { combos?: Record<string, OcxComboConfig> }
304
304
  return Object.keys(config.combos ?? {}).sort((a, b) => a.localeCompare(b));
305
305
  }
306
306
 
307
+ export function listLiveComboTargetKeys(
308
+ config: { combos?: Record<string, OcxComboConfig> },
309
+ ): ReadonlySet<string> {
310
+ const keys = new Set<string>();
311
+ for (const id of listComboIds(config)) {
312
+ const combo = getCombo(config, id);
313
+ if (!combo) continue;
314
+ for (const target of combo.targets) keys.add(`${id}::${targetKey(target)}`);
315
+ }
316
+ return keys;
317
+ }
318
+
307
319
  export function getCombo(
308
320
  config: { combos?: Record<string, OcxComboConfig> },
309
321
  id: string,