@bitkyc08/opencodex 2.67.0 → 2.68.0-preview.20260927

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 (257) hide show
  1. package/bin/ocx.mjs +3 -0
  2. package/gui/dist/assets/App-B5-cAEd2.js +51 -0
  3. package/gui/dist/assets/App-DCBismRi.css +1 -0
  4. package/gui/dist/assets/Tray-DFnMiyD0.js +1 -0
  5. package/gui/dist/assets/{index-DtNmX7hW.css → index-BDUBS8PW.css} +1 -1
  6. package/gui/dist/assets/index-BcXblvem.js +86 -0
  7. package/gui/dist/assets/quota-summary-i84eOU3o.js +1 -0
  8. package/gui/dist/index.html +21 -2
  9. package/package.json +1 -1
  10. package/src/adapters/anthropic-image-guard.ts +13 -1
  11. package/src/adapters/anthropic-image-normalize.ts +3 -2
  12. package/src/adapters/anthropic-output-schema.ts +36 -0
  13. package/src/adapters/anthropic.ts +10 -2
  14. package/src/adapters/base.ts +7 -0
  15. package/src/adapters/codebuddy/live-models.ts +180 -0
  16. package/src/adapters/coding-agent/protocol.ts +91 -12
  17. package/src/adapters/coding-agent/turn.ts +32 -27
  18. package/src/adapters/devin/cloud-direct/index.ts +1 -0
  19. package/src/adapters/devin/cloud-direct/stated-reset-retry.ts +18 -4
  20. package/src/adapters/devin.ts +9 -6
  21. package/src/adapters/google-http.ts +4 -4
  22. package/src/adapters/google.ts +93 -3
  23. package/src/adapters/kiro/adapter.ts +6 -2
  24. package/src/adapters/kiro/stream.ts +12 -2
  25. package/src/adapters/kiro/usage.ts +3 -1
  26. package/src/adapters/kiro-errors.ts +11 -1
  27. package/src/adapters/kiro-events.ts +27 -1
  28. package/src/adapters/kiro-refusal.ts +30 -0
  29. package/src/adapters/kiro-retry.ts +71 -31
  30. package/src/adapters/openai-chat/deepseek-artifact-schema.ts +45 -0
  31. package/src/adapters/openai-chat/messages.ts +31 -4
  32. package/src/adapters/openai-chat/serialized-tool-call-content.ts +52 -7
  33. package/src/adapters/openai-chat/tool-call-id-remint.ts +65 -0
  34. package/src/adapters/openai-chat/tool-schema.ts +6 -2
  35. package/src/adapters/openai-chat.ts +2 -2
  36. package/src/adapters/openai-responses/muse-tool-choice.ts +31 -0
  37. package/src/adapters/openai-responses/passthrough.ts +9 -3
  38. package/src/adapters/opencode-go-additional-tools.ts +19 -10
  39. package/src/adapters/physical-send.ts +10 -3
  40. package/src/adapters/registry.ts +2 -1
  41. package/src/adapters/responses-tool-schema.ts +31 -3
  42. package/src/adapters/run-turn-queue.ts +63 -23
  43. package/src/adapters/unique-tool-call-ids.ts +63 -0
  44. package/src/adapters/xai-web-search.ts +32 -2
  45. package/src/bridge/sse.ts +14 -2
  46. package/src/chat/inbound.ts +20 -6
  47. package/src/claude/agents-inject.ts +4 -0
  48. package/src/claude/desktop-3p.ts +53 -13
  49. package/src/claude/desktop-profile.ts +41 -4
  50. package/src/claude/inbound-model-options.ts +14 -2
  51. package/src/claude/inbound.ts +1 -1
  52. package/src/claude/intercept/connect-proxy.ts +17 -1
  53. package/src/claude/intercept/local-ca.ts +7 -1
  54. package/src/claude/message-threads.ts +28 -0
  55. package/src/cli/account-api.ts +13 -0
  56. package/src/cli/account-auth.ts +49 -2
  57. package/src/cli/account-extended.ts +4 -2
  58. package/src/cli/account.ts +4 -2
  59. package/src/cli/capabilities.ts +17 -0
  60. package/src/cli/claude.ts +3 -1
  61. package/src/cli/dispatch.ts +62 -5
  62. package/src/cli/index.ts +84 -79
  63. package/src/cli/minimax.ts +4 -2
  64. package/src/cli/opencode.ts +6 -1
  65. package/src/cli/restart-handoff.ts +110 -0
  66. package/src/cli/status.ts +51 -0
  67. package/src/client/connect.ts +34 -15
  68. package/src/client/link-ingress.ts +102 -0
  69. package/src/client/link-join.ts +37 -16
  70. package/src/client/link-relay.ts +228 -45
  71. package/src/client/link-state.ts +54 -0
  72. package/src/client/link-status.ts +39 -0
  73. package/src/client/link-teardown.ts +2 -2
  74. package/src/client/link-tunnel.ts +615 -86
  75. package/src/client/machine-api.ts +10 -4
  76. package/src/client/machine-listener.ts +57 -10
  77. package/src/client/runtime.ts +210 -35
  78. package/src/clients/config-export/constants.ts +1 -12
  79. package/src/clients/config-export/contracts.ts +2 -0
  80. package/src/clients/config-export/model-metadata.ts +9 -3
  81. package/src/clients/config-export/omp.ts +1 -1
  82. package/src/clients/config-export/zcode-store.ts +2 -2
  83. package/src/clients/config-export.ts +36 -3
  84. package/src/codex/app-server-processes.ts +32 -0
  85. package/src/codex/app-server-restart-service.ts +20 -3
  86. package/src/codex/catalog/access-programs.ts +50 -0
  87. package/src/codex/catalog/build-entries.ts +13 -0
  88. package/src/codex/catalog/derive-entry.ts +1 -0
  89. package/src/codex/catalog/model-hints.ts +11 -0
  90. package/src/codex/catalog/parsing.ts +6 -2
  91. package/src/codex/catalog/provider-models.ts +81 -0
  92. package/src/codex/catalog/retained-sync.ts +3 -0
  93. package/src/codex/codex-write-lock.ts +2 -2
  94. package/src/codex/convergence.ts +2 -0
  95. package/src/codex/desired-state.ts +14 -4
  96. package/src/codex/home.ts +21 -3
  97. package/src/codex/inject/restore.ts +17 -0
  98. package/src/codex/inject/routing-classify.ts +3 -2
  99. package/src/codex/inject-coordination.ts +4 -1
  100. package/src/codex/inject.ts +8 -1
  101. package/src/codex/journal.ts +6 -2
  102. package/src/codex/management-convergence.ts +9 -0
  103. package/src/codex/model-entitlements.ts +28 -5
  104. package/src/codex/routing/idle-window.ts +58 -0
  105. package/src/codex/routing-drift.ts +134 -0
  106. package/src/codex/routing-healer.ts +419 -0
  107. package/src/codex/routing.ts +10 -0
  108. package/src/codex/runtime.ts +188 -57
  109. package/src/codex/sibling-handoff.ts +71 -0
  110. package/src/codex/sibling-start.ts +139 -0
  111. package/src/codex/sync.ts +5 -3
  112. package/src/combos/failover.ts +32 -5
  113. package/src/combos/request.ts +11 -3
  114. package/src/combos/reset-window.ts +10 -5
  115. package/src/combos/resolve.ts +36 -6
  116. package/src/config/diagnostics.ts +2 -1
  117. package/src/config/load-degrade.ts +2 -0
  118. package/src/config/paths.ts +7 -1
  119. package/src/config/process-state.ts +10 -1
  120. package/src/config/schema/compaction-recovery.ts +15 -0
  121. package/src/config/schema/config-schema.ts +2 -0
  122. package/src/config/schema/leaf-validators.ts +9 -3
  123. package/src/generated/compatibility-version.json +360 -204
  124. package/src/images/loop.ts +47 -32
  125. package/src/integrations/catalog-refresh.ts +4 -0
  126. package/src/integrations/omp-yaml-source.ts +1 -0
  127. package/src/lib/claude-request-projection.ts +101 -0
  128. package/src/lib/local-account-switch-capability.ts +48 -0
  129. package/src/lib/local-upstream.ts +146 -0
  130. package/src/lib/package-tree-integrity.ts +25 -3
  131. package/src/lib/package-tree-retarget.ts +130 -0
  132. package/src/lib/process-control.ts +4 -1
  133. package/src/lib/system-restart-contract.ts +106 -0
  134. package/src/lib/upstream-retry.ts +57 -1
  135. package/src/link/ports.ts +9 -0
  136. package/src/link/ssh-argv.ts +16 -0
  137. package/src/link/ssh-runner.ts +70 -2
  138. package/src/link/tunnel-state.ts +58 -9
  139. package/src/oauth/account-quota-rank.ts +29 -18
  140. package/src/oauth/generic-account-failover.ts +179 -22
  141. package/src/oauth/index.ts +17 -4
  142. package/src/oauth/kiro-account-load.ts +107 -0
  143. package/src/oauth/kiro-device-login.ts +312 -0
  144. package/src/oauth/kiro-terminal-failover.ts +28 -0
  145. package/src/oauth/login-flow-state.ts +7 -1
  146. package/src/oauth/pool-settings-capability.ts +23 -14
  147. package/src/oauth/store.ts +56 -3
  148. package/src/oauth/types.ts +4 -0
  149. package/src/plugins/loader.ts +350 -0
  150. package/src/plugins/upstream-hooks.ts +119 -0
  151. package/src/protocols/encoders/adapter-events.ts +9 -2
  152. package/src/providers/account-quota-disk.ts +42 -4
  153. package/src/providers/codebuddy-models.ts +6 -2
  154. package/src/providers/command-code-efforts.ts +33 -10
  155. package/src/providers/kiro-account-state-disk.ts +44 -0
  156. package/src/providers/kiro-model-catalog.ts +162 -0
  157. package/src/providers/kiro-models.ts +5 -4
  158. package/src/providers/kiro-quota-metrics.ts +35 -0
  159. package/src/providers/kiro-usage.ts +118 -14
  160. package/src/providers/quota/account-cache.ts +43 -7
  161. package/src/providers/quota/antigravity.ts +8 -3
  162. package/src/providers/quota/kiro-account-probe.ts +12 -0
  163. package/src/providers/quota/vendor-probes-key.ts +31 -26
  164. package/src/providers/quota/vendor-probes-oauth.ts +16 -7
  165. package/src/providers/quota-types.ts +14 -0
  166. package/src/providers/quota.ts +21 -21
  167. package/src/providers/registry/entries-extended.ts +5 -3
  168. package/src/providers/request-pacing.ts +410 -12
  169. package/src/remote-control/workspace-codex-runtime.ts +4 -3
  170. package/src/responses/citation-markers.ts +132 -65
  171. package/src/responses/hosted-tool-policy.ts +14 -3
  172. package/src/responses/parser-content.ts +3 -2
  173. package/src/responses/parser.ts +4 -1
  174. package/src/responses/schema.ts +4 -0
  175. package/src/responses/visualization-directives.ts +182 -0
  176. package/src/server/chat-native-sse.ts +24 -12
  177. package/src/server/chat-native.ts +137 -68
  178. package/src/server/claude-messages.ts +147 -12
  179. package/src/server/index/link-listener.ts +7 -0
  180. package/src/server/index/optional-listeners.ts +4 -1
  181. package/src/server/index/package-tree-guard.ts +35 -16
  182. package/src/server/index/serve-options.ts +4 -2
  183. package/src/server/index/startup-warnings.ts +6 -0
  184. package/src/server/index.ts +7 -7
  185. package/src/server/inference/client-encoder-delivery.ts +3 -0
  186. package/src/server/inference/context.ts +30 -2
  187. package/src/server/local-account-switch-auth.ts +79 -0
  188. package/src/server/management/agent-settings-routes.ts +11 -3
  189. package/src/server/management/config-routes.ts +28 -11
  190. package/src/server/management/context.ts +2 -0
  191. package/src/server/management/link-routes.ts +123 -39
  192. package/src/server/management/logs-usage-routes.ts +9 -42
  193. package/src/server/management/model-rows.ts +1 -0
  194. package/src/server/management/oauth-account-routes.ts +67 -12
  195. package/src/server/management/route-registry.ts +5 -5
  196. package/src/server/management/sibling-guard.ts +60 -0
  197. package/src/server/management/storage-log-guard-routes.ts +72 -6
  198. package/src/server/management/system-restart.ts +92 -135
  199. package/src/server/management/system-routes.ts +1 -1
  200. package/src/server/management-api.ts +31 -3
  201. package/src/server/management-auth.ts +3 -0
  202. package/src/server/port-reclaim.ts +111 -1
  203. package/src/server/proxy-liveness.ts +16 -0
  204. package/src/server/relay.ts +16 -15
  205. package/src/server/request-log-filter.ts +28 -27
  206. package/src/server/request-log.ts +7 -3
  207. package/src/server/request-metrics.ts +28 -0
  208. package/src/server/responses/adapter-continuation.ts +151 -21
  209. package/src/server/responses/adapter-delivery.ts +21 -7
  210. package/src/server/responses/adapter-dispatch.ts +239 -51
  211. package/src/server/responses/codex-ws-pool.ts +5 -4
  212. package/src/server/responses/codex-ws-request.ts +4 -1
  213. package/src/server/responses/compact.ts +8 -4
  214. package/src/server/responses/compaction-recovery-policy.ts +116 -0
  215. package/src/server/responses/compaction-recovery.ts +330 -0
  216. package/src/server/responses/core-combo-failure.ts +1 -0
  217. package/src/server/responses/core-combo.ts +5 -1
  218. package/src/server/responses/core-lifetime.ts +22 -0
  219. package/src/server/responses/core-options.ts +16 -1
  220. package/src/server/responses/core.ts +14 -4
  221. package/src/server/responses/empty-completion-guard.ts +2 -0
  222. package/src/server/responses/fetch-helpers.ts +82 -31
  223. package/src/server/responses/passthrough-delivery.ts +3 -2
  224. package/src/server/responses/passthrough-dispatch.ts +12 -2
  225. package/src/server/responses/passthrough-execution.ts +2 -2
  226. package/src/server/responses/request-send-budget.ts +21 -5
  227. package/src/server/responses/request-transport.ts +116 -18
  228. package/src/server/responses/reset-replay.ts +32 -0
  229. package/src/server/responses/run-turn-execution.ts +136 -19
  230. package/src/server/responses/sidecar-execution.ts +41 -8
  231. package/src/server/responses/terminal-guard.ts +2 -0
  232. package/src/server/responses/ws-upstream.ts +44 -4
  233. package/src/server/restart-replacement.ts +382 -0
  234. package/src/server/startup-health-cache.ts +23 -4
  235. package/src/server/stop-teardown.ts +35 -1
  236. package/src/server/system-env.ts +3 -0
  237. package/src/service/windows-taskxml.ts +11 -7
  238. package/src/service/windows-wrapper-exit.ts +8 -0
  239. package/src/stall-timeout.ts +35 -10
  240. package/src/storage/policy-job.ts +4 -0
  241. package/src/storage/scanner.ts +177 -29
  242. package/src/storage/storage-mutation-coordinator.ts +17 -0
  243. package/src/tray/windows-tray.ps1 +50 -23
  244. package/src/types/config.ts +4 -0
  245. package/src/types/provider.ts +5 -1
  246. package/src/types/request.ts +13 -2
  247. package/src/update/index.ts +3 -1
  248. package/src/update/job.ts +6 -2
  249. package/src/update/mise-launcher-target.ts +100 -0
  250. package/src/update/worker-launch.ts +46 -0
  251. package/src/usage/log.ts +2 -0
  252. package/src/web-search/loop.ts +5 -2
  253. package/gui/dist/assets/App-BJsT8Icc.css +0 -1
  254. package/gui/dist/assets/App-D3pNGiN4.js +0 -51
  255. package/gui/dist/assets/Tray-yb05wCjb.js +0 -1
  256. package/gui/dist/assets/index-CouvvtMV.js +0 -86
  257. package/gui/dist/assets/tray-data-CYGdjCJ7.js +0 -1
@@ -407,17 +407,21 @@ interface CachedAccountModels {
407
407
  readonly clientVersion: string;
408
408
  readonly expiresAt: number;
409
409
  readonly models: ReadonlySet<string>;
410
+ readonly accessProgramsByModel?: ReadonlyMap<string, CodexAvailableAccessPrograms>;
410
411
  readonly confirmed: boolean;
411
412
  readonly provenance?: CodexModelEntitlementProvenance;
412
413
  }
413
414
 
414
415
  export interface CodexModelEntitlementSnapshot {
415
416
  readonly modelsByAccount: ReadonlyMap<string, ReadonlySet<string>>;
417
+ readonly accessProgramsByAccount?: ReadonlyMap<string, ReadonlyMap<string, CodexAvailableAccessPrograms>>;
416
418
  readonly clientVersionByAccount: ReadonlyMap<string, string>;
417
419
  readonly confirmedAccountIds: ReadonlySet<string>;
418
420
  readonly credentialIdentities: ReadonlyMap<string, string>;
419
421
  }
420
422
 
423
+ export type CodexAvailableAccessPrograms = Readonly<Record<string, readonly string[]>> | null;
424
+
421
425
  export type CodexModelEntitlementState = "granted" | "denied" | "unknown";
422
426
 
423
427
  export interface CodexModelEntitlementResolveOptions {
@@ -633,17 +637,29 @@ async function accountCredentialSnapshot(
633
637
  }
634
638
  }
635
639
 
636
- function parseAccountModels(text: string): ReadonlySet<string> | null {
640
+ /** Keep valid roster slugs while dropping malformed program metadata; preserve explicit null separately from absence. */
641
+ function parseAccountModels(text: string): { models: ReadonlySet<string>; accessProgramsByModel: ReadonlyMap<string, CodexAvailableAccessPrograms> } | null {
637
642
  try {
638
643
  const payload = JSON.parse(text) as { models?: unknown };
639
644
  if (!Array.isArray(payload.models)) return null;
645
+ const accessProgramsByModel = new Map<string, CodexAvailableAccessPrograms>();
640
646
  const models = payload.models.flatMap(entry => {
641
647
  if (!entry || typeof entry !== "object" || Array.isArray(entry)) return [];
642
- const row = entry as { slug?: unknown; supported_in_api?: unknown; visibility?: unknown };
648
+ const row = entry as { slug?: unknown; supported_in_api?: unknown; visibility?: unknown; available_access_programs?: unknown };
643
649
  if (typeof row.slug !== "string" || row.supported_in_api !== true || row.visibility === "hide") return [];
650
+ const programs = row.available_access_programs;
651
+ if (programs === null) accessProgramsByModel.set(row.slug, null);
652
+ else if (programs && typeof programs === "object" && !Array.isArray(programs)) {
653
+ const record = programs as Record<string, unknown>;
654
+ if (Array.isArray(record.cyber) && record.cyber.every(item => typeof item === "string")) {
655
+ const validPrograms = Object.fromEntries(Object.entries(record).filter(([, value]) =>
656
+ Array.isArray(value) && value.every(item => typeof item === "string"))) as Record<string, string[]>;
657
+ accessProgramsByModel.set(row.slug, validPrograms);
658
+ }
659
+ }
644
660
  return [row.slug];
645
661
  });
646
- return new Set(models);
662
+ return { models: new Set(models), accessProgramsByModel };
647
663
  } catch {
648
664
  return null;
649
665
  }
@@ -669,6 +685,7 @@ function isTimeoutError(error: unknown): boolean {
669
685
  return error instanceof Error && error.name === "TimeoutError";
670
686
  }
671
687
 
688
+ /** Fetch one bounded, version-scoped roster; failures and empty results remain unconfirmed for a short retry. */
672
689
  async function fetchAccountModels(
673
690
  credential: CodexModelEntitlementCredentialSnapshot,
674
691
  fetcher: typeof fetch,
@@ -702,8 +719,8 @@ async function fetchAccountModels(
702
719
  if (!body.displaySafe || body.truncated) {
703
720
  return unconfirmedAccountModels(credential, clientVersion, now, { kind: "unparseable" });
704
721
  }
705
- const models = parseAccountModels(body.text);
706
- if (models === null) {
722
+ const parsed = parseAccountModels(body.text);
723
+ if (parsed === null) {
707
724
  return unconfirmedAccountModels(credential, clientVersion, now, { kind: "unparseable" });
708
725
  }
709
726
  // A roster is a confirmation only when it lists something usable. `models` is a Set, and an
@@ -713,6 +730,7 @@ async function fetchAccountModels(
713
730
  // account asked under too old a client version answers with no gated rows, and treating
714
731
  // that as authoritative is exactly how 2.36.0 denied sol/terra/luna to accounts that own
715
732
  // them (#3022). No usable rows means unconfirmed, on the 15s failure TTL, asked again.
733
+ const { models, accessProgramsByModel } = parsed;
716
734
  const usable = models.size > 0;
717
735
  if (!usable) {
718
736
  return unconfirmedAccountModels(credential, clientVersion, now, { kind: "parsed-empty" });
@@ -735,6 +753,7 @@ async function fetchAccountModels(
735
753
  ? MODEL_ROSTER_TTL_MS
736
754
  : MODEL_ROSTER_FAILURE_TTL_MS),
737
755
  models,
756
+ accessProgramsByModel,
738
757
  confirmed: true,
739
758
  };
740
759
  } catch (error) {
@@ -1173,6 +1192,10 @@ export async function resolveCodexModelEntitlements(
1173
1192
  })));
1174
1193
  return {
1175
1194
  modelsByAccount: new Map(results.map(({ credential, result }) => [credential.accountId, result.models])),
1195
+ accessProgramsByAccount: new Map(results.flatMap(({ credential, result }) => (
1196
+ result.confirmed && result.accessProgramsByModel
1197
+ ? [[credential.accountId, result.accessProgramsByModel] as const] : []
1198
+ ))),
1176
1199
  clientVersionByAccount: new Map(results.map(({ credential, result }) => (
1177
1200
  [credential.accountId, result.clientVersion]
1178
1201
  ))),
@@ -0,0 +1,58 @@
1
+ import type { OcxConfig } from "../../types";
2
+ import type { CodexAccountUsabilityOptions } from "../account-usability";
3
+ import { pinnedCodexAccountId } from "../account-priority";
4
+ import { getAccountQuota, resetAtToMs } from "../quota";
5
+ import { manualPreferenceBlocks } from "./active-account";
6
+ import { codexPoolKeyForScope, isIndependentCodexQuotaScope, type CodexQuotaScope } from "./health-store";
7
+ import { getEligiblePoolAccounts, hasCodexQuotaHeadroom } from "./selection";
8
+ import { bindThreadAffinity } from "./thread-affinity";
9
+
10
+ const WINDOW_MS = 5 * 60 * 60_000;
11
+ const TOLERANCE_MS = 60_000;
12
+ const FRESHNESS_MS = 5 * 60_000;
13
+ // Reservation is synchronous, so concurrent resolves cannot start the same window twice.
14
+ // Keep the deadline fixed: idle observations may slide their reset forward on every poll.
15
+ const steeredUntil = new Map<string, number>();
16
+
17
+ export function clearIdleWindowSteering(): void {
18
+ steeredUntil.clear();
19
+ }
20
+
21
+ /** Only called for first placement, after conversation/family affinity has had precedence. */
22
+ export function pickIdleWindowAccount(
23
+ config: OcxConfig,
24
+ threadId: string | null,
25
+ now: number,
26
+ commit: boolean,
27
+ quotaScope?: CodexQuotaScope,
28
+ selectionOptions?: CodexAccountUsabilityOptions,
29
+ ): string | null {
30
+ if (config.codexPool?.startIdleWindows !== true || isIndependentCodexQuotaScope(quotaScope)
31
+ || pinnedCodexAccountId(config) !== undefined) return null;
32
+ const eligible = getEligiblePoolAccounts(config, undefined, now, quotaScope, selectionOptions, true);
33
+ for (const id of eligible) {
34
+ if (manualPreferenceBlocks(codexPoolKeyForScope(quotaScope), id)
35
+ || selectionOptions?.deniedModelAccountIds?.has(id)
36
+ || !hasCodexQuotaHeadroom(config, id, selectionOptions, now)) continue;
37
+ const quota = getAccountQuota(id);
38
+ const observed = quota?.shortObservedAt;
39
+ const reset = quota?.shortResetAt;
40
+ if (quota?.shortPercent !== 0 || quota.shortWindowSeconds !== WINDOW_MS / 1000
41
+ || observed === undefined || !Number.isFinite(observed)
42
+ || reset === undefined || !Number.isFinite(reset)
43
+ || now < observed || now - observed > FRESHNESS_MS) continue;
44
+ const resetMs = resetAtToMs(reset);
45
+ const previous = steeredUntil.get(id);
46
+ if (Math.abs(resetMs - observed - WINDOW_MS) > TOLERANCE_MS || resetMs <= now
47
+ || (previous !== undefined && (now <= previous || observed <= previous))) continue;
48
+ if (commit) {
49
+ for (const [accountId, until] of steeredUntil) {
50
+ if (until < now - FRESHNESS_MS) steeredUntil.delete(accountId);
51
+ }
52
+ steeredUntil.set(id, Math.max(resetMs, now + WINDOW_MS) + TOLERANCE_MS);
53
+ if (threadId) bindThreadAffinity(threadId, id, now, quotaScope);
54
+ }
55
+ return id;
56
+ }
57
+ return null;
58
+ }
@@ -0,0 +1,134 @@
1
+ /**
2
+ * Does opencodex-owned Codex routing name a loopback port this process does not serve?
3
+ *
4
+ * `classifyCodexRouting` answers "opencodex-local" whatever the port, so a live proxy cannot tell
5
+ * from it that `config.toml` still points at a port a dead instance left behind. That is the state a
6
+ * killed second instance produced: the owner kept serving its port while every new Codex thread
7
+ * failed with "Connection refused" against the other one, and nothing looked again after startup.
8
+ *
9
+ * Pure: the caller passes the file bytes, the ports it serves, and (lazily) the journaled values.
10
+ * Only OWNED routing counts. Native config, a user's own root URL, a custom provider and an external
11
+ * `model_provider` are all `not-owned`, so nothing built on this can undo `ocx restore`, an eject, a
12
+ * Codex-OFF toggle or a stop teardown. Only loopback endpoints with an explicit port can be foreign:
13
+ * LAN and admission-token routing is never reported.
14
+ */
15
+ import { externalCodexModelProvider } from "./inject/config-toml";
16
+ import { classifyRoutingEndpoint } from "./inject/routing-classify";
17
+ import {
18
+ OCX_SECTION_MARKER,
19
+ REALTIME_WS_BASE_URL_KEY,
20
+ isRootOpenaiBaseUrlLine,
21
+ isRootRealtimeWsBaseUrlLine,
22
+ providerTableString,
23
+ rootTomlString,
24
+ } from "./injected-marker";
25
+
26
+ export type CodexRoutingDriftKey =
27
+ | "openai_base_url"
28
+ | typeof REALTIME_WS_BASE_URL_KEY
29
+ | "model_providers.opencodex.base_url";
30
+
31
+ export interface CodexRoutingDriftTarget {
32
+ readonly key: CodexRoutingDriftKey;
33
+ readonly url: string;
34
+ /** Host as written, brackets removed (`127.0.0.1`, `localhost`, `::1`). */
35
+ readonly hostname: string;
36
+ readonly port: number;
37
+ }
38
+
39
+ export type CodexRoutingDrift =
40
+ | { readonly kind: "none" }
41
+ | { readonly kind: "not-owned" }
42
+ | { readonly kind: "foreign"; readonly targets: readonly CodexRoutingDriftTarget[] };
43
+
44
+ /** The root URLs the last injection recorded writing (`journal.ts`), read without side effects. */
45
+ export interface JournaledCodexRouting {
46
+ readonly openaiBaseUrl: string | null;
47
+ readonly realtimeWsBaseUrl: string | null;
48
+ }
49
+
50
+ export interface CodexRoutingDriftInput {
51
+ /** Ports this process serves: the bound port and the loopback listener port. */
52
+ readonly ownPorts: Iterable<number>;
53
+ /**
54
+ * Value evidence for a root line whose marker comment a Codex app rewrite dropped (#1798). A
55
+ * function is called at most once, and only when an unmarked root URL names a foreign port.
56
+ */
57
+ readonly journaled?: JournaledCodexRouting | (() => JournaledCodexRouting);
58
+ }
59
+
60
+ /** The root line's value, and whether the ownership marker sits directly above it. */
61
+ function rootLine(content: string, key: string, isLine: (line: string) => boolean): { value: string | null; marked: boolean } {
62
+ const lines = content.split("\n");
63
+ const firstTable = lines.findIndex(line => /^\s*\[/.test(line));
64
+ const rootEnd = firstTable === -1 ? lines.length : firstTable;
65
+ for (let index = 0; index < rootEnd; index += 1) {
66
+ if (!isLine(lines[index]!)) continue;
67
+ return {
68
+ value: rootTomlString(lines[index]!, key),
69
+ marked: index > 0 && lines[index - 1]!.includes(OCX_SECTION_MARKER),
70
+ };
71
+ }
72
+ return { value: null, marked: false };
73
+ }
74
+
75
+ /** A loopback endpoint with an explicit port, or null for anything the detector must ignore. */
76
+ function loopbackEndpoint(url: string): { hostname: string; port: number } | null {
77
+ if (classifyRoutingEndpoint(url) !== "local") return null;
78
+ try {
79
+ const parsed = new URL(url);
80
+ if (!parsed.port) return null;
81
+ const port = Number(parsed.port);
82
+ if (!Number.isInteger(port) || port <= 0 || port > 65535) return null;
83
+ return { hostname: parsed.hostname.replace(/^\[|\]$/g, ""), port };
84
+ } catch {
85
+ return null;
86
+ }
87
+ }
88
+
89
+ export function detectCodexRoutingDrift(content: string, input: CodexRoutingDriftInput): CodexRoutingDrift {
90
+ const own = new Set(input.ownPorts);
91
+ const normalized = content.replace(/\r\n/g, "\n");
92
+ let journaled: JournaledCodexRouting | undefined;
93
+ const readJournaled = (): JournaledCodexRouting => {
94
+ if (journaled) return journaled;
95
+ const source = input.journaled;
96
+ journaled = typeof source === "function" ? source() : (source ?? { openaiBaseUrl: null, realtimeWsBaseUrl: null });
97
+ return journaled;
98
+ };
99
+
100
+ // `marked` is the cheap ownership proof; `owned` adds the journal's value evidence, which is read
101
+ // only for an unmarked line that names a foreign port.
102
+ const candidates: Array<{ key: CodexRoutingDriftKey; url: string; marked: boolean; owned: () => boolean }> = [];
103
+ const base = rootLine(normalized, "openai_base_url", isRootOpenaiBaseUrlLine);
104
+ if (base.value) {
105
+ const url = base.value;
106
+ candidates.push({ key: "openai_base_url", url, marked: base.marked, owned: () => base.marked || readJournaled().openaiBaseUrl === url });
107
+ }
108
+ const realtime = rootLine(normalized, REALTIME_WS_BASE_URL_KEY, isRootRealtimeWsBaseUrlLine);
109
+ if (realtime.value) {
110
+ const url = realtime.value;
111
+ candidates.push({ key: REALTIME_WS_BASE_URL_KEY, url, marked: realtime.marked, owned: () => realtime.marked || readJournaled().realtimeWsBaseUrl === url });
112
+ }
113
+ if (rootTomlString(normalized, "model_provider") === "opencodex") {
114
+ const url = providerTableString(normalized, "opencodex", "base_url");
115
+ if (url) candidates.push({ key: "model_providers.opencodex.base_url", url, marked: true, owned: () => true });
116
+ }
117
+ if (candidates.length === 0) return { kind: "not-owned" };
118
+ // An external provider owns routing even when an old opencodex line is still in the file.
119
+ if (externalCodexModelProvider(normalized) !== null) return { kind: "not-owned" };
120
+
121
+ const targets: CodexRoutingDriftTarget[] = [];
122
+ let ours = false;
123
+ for (const candidate of candidates) {
124
+ const endpoint = loopbackEndpoint(candidate.url);
125
+ if (endpoint !== null && !own.has(endpoint.port)) {
126
+ if (candidate.owned()) targets.push({ key: candidate.key, url: candidate.url, ...endpoint });
127
+ continue;
128
+ }
129
+ // A URL naming a port we serve is never drift, whoever wrote it; neither is non-loopback routing.
130
+ ours ||= candidate.marked || endpoint !== null;
131
+ }
132
+ if (targets.length > 0) return { kind: "foreign", targets };
133
+ return ours ? { kind: "none" } : { kind: "not-owned" };
134
+ }