@bitkyc08/opencodex 2.67.0 → 2.68.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 (257) hide show
  1. package/bin/ocx.mjs +3 -0
  2. package/gui/dist/assets/App-DCBismRi.css +1 -0
  3. package/gui/dist/assets/App-mpsibsFj.js +51 -0
  4. package/gui/dist/assets/Tray-Bn8WhKBH.js +1 -0
  5. package/gui/dist/assets/{index-DtNmX7hW.css → index-BDUBS8PW.css} +1 -1
  6. package/gui/dist/assets/index-kUE8tuAK.js +86 -0
  7. package/gui/dist/assets/quota-summary-DL-bNuNL.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
@@ -0,0 +1,419 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { loadConfig } from "../config";
3
+ import { readRuntimePort } from "../config/process-state";
4
+ import { readClientConnectionState } from "../client/state";
5
+ import { appendDebugLogLine } from "../lib/debug-log-buffer";
6
+ import { redactUserPath } from "../lib/redact";
7
+ import { isRecyclingForExit, isShutdownDraining } from "../server/lifecycle";
8
+ import { probeEndpointLiveness, type EndpointLiveness } from "../server/proxy-liveness";
9
+ import type { OcxConfig } from "../types";
10
+ import { shouldSyncCodexOnStart } from "./desired-state";
11
+ import { injectCodexConfig, type CodexInjectResult, type InjectCodexOptions } from "./inject";
12
+ import { standaloneCodexRoutingTarget } from "./inject/routing-target";
13
+ import { journaledInjectedOpenaiBaseUrl, journaledInjectedRealtimeWsBaseUrl, journalOwner } from "./journal";
14
+ import { effectiveLoopbackListenerPort, shouldInjectApiAuthHeader } from "./loopback-target";
15
+ import { CODEX_CONFIG_PATH } from "./paths";
16
+ import {
17
+ detectCodexRoutingDrift,
18
+ type CodexRoutingDrift,
19
+ type CodexRoutingDriftTarget,
20
+ type JournaledCodexRouting,
21
+ } from "./routing-drift";
22
+ import { siblingOfLivePort } from "./sibling-start";
23
+
24
+ /**
25
+ * The live owner re-points Codex routing that a dead instance left on another loopback port.
26
+ *
27
+ * Startup sync is the last time an owner looks at `config.toml`. When something else re-points the
28
+ * opencodex-owned routing at its own port and then dies, every new Codex thread fails with
29
+ * "Connection refused" while this proxy keeps serving its own port. This loop closes that gap.
30
+ *
31
+ * Cost: one read of `config.toml` every 10 s from an unref'd timer, and nothing else while the
32
+ * bytes are unchanged. Probes, gates and the journal run only while owned routing names a port this
33
+ * process does not serve. Nothing here touches the request path.
34
+ *
35
+ * It writes only when every foreign target has been dead on at least two probes spanning 20 s, a
36
+ * final probe still says dead, and every gate is open. The write is the ordinary injector with no
37
+ * catalog path (so no catalog gather and the current `model_catalog_json` stays), a short lock
38
+ * timeout, and a synchronous guard that re-reads `config.toml` under the lock and aborts when the
39
+ * routing changed since the probe. A refusal that raced another write of `config.toml` is the same
40
+ * abort, not a refusal. A live foreign opencodex is never fought; an unknown answer never advances
41
+ * the streak. The caps pause the loop for at most an hour and never stop it. Detection, gates and
42
+ * probes read the journal read-only, so watching never deletes an unreadable journal (a heal write
43
+ * goes through the injector's ordinary journal handling). Started only from `handleStart`'s owner
44
+ * path and stopped first in its exit cleanup.
45
+ */
46
+
47
+ export const ROUTING_HEAL_TICK_MS = 10_000;
48
+ export const ROUTING_HEAL_MIN_DEAD_PROBES = 2;
49
+ export const ROUTING_HEAL_MIN_DEAD_SPAN_MS = 20_000;
50
+ export const ROUTING_HEAL_LOCK_TIMEOUT_MS = 1_000;
51
+ /** While the bytes stay equal, a live foreign owner, an unknown answer or a closed gate is looked at again only this often. */
52
+ export const ROUTING_HEAL_RECHECK_MS = 30_000;
53
+ export const ROUTING_HEAL_REFUSED_BACKOFF_MS = 10 * 60_000;
54
+ export const ROUTING_HEAL_WINDOW_MS = 60 * 60_000;
55
+ /** Rate cap: write attempts (busy ones included) per rolling window; more pause until the oldest leaves it. */
56
+ export const ROUTING_HEAL_MAX_ATTEMPTS = 6;
57
+ /**
58
+ * Flap cap: this many successful heals in one window means another writer keeps re-pointing it, so a
59
+ * further heal waits until the oldest leaves the window, then needs a fresh dead streak.
60
+ */
61
+ export const ROUTING_HEAL_MAX_HEALS = 3;
62
+
63
+ export type CodexRoutingHealGate =
64
+ | "sibling"
65
+ | "exiting"
66
+ | "runtime-record"
67
+ | "codex-off"
68
+ | "admission-token"
69
+ | "target-not-served"
70
+ | "client";
71
+
72
+ export interface CodexRoutingHealGates {
73
+ siblingOfLivePort(): number | null;
74
+ /** True while the process is recycling for exit or draining for shutdown. */
75
+ exiting(): boolean;
76
+ /** The port in this home's runtime record when that record names this process. */
77
+ runtimePortOfThisProcess(): number | null;
78
+ loadConfig(): OcxConfig;
79
+ /** Any client-connection state other than disconnected (connected, link, invalid). */
80
+ clientConnected(): boolean;
81
+ /** The journal names a connected client as its owner (read-only). */
82
+ clientJournalOwner(): boolean;
83
+ }
84
+
85
+ export type CodexRoutingHealGateResult =
86
+ | { readonly open: true; readonly config: OcxConfig; readonly targetPort: number }
87
+ | { readonly open: false; readonly gate: CodexRoutingHealGate };
88
+
89
+ /** Cheapest first; `loadConfig` and the journal only when the in-memory gates are open. */
90
+ export function evaluateCodexRoutingHealGates(
91
+ port: number,
92
+ ownPorts: ReadonlySet<number>,
93
+ gates: CodexRoutingHealGates,
94
+ ): CodexRoutingHealGateResult {
95
+ if (gates.siblingOfLivePort() !== null) return { open: false, gate: "sibling" };
96
+ if (gates.exiting()) return { open: false, gate: "exiting" };
97
+ if (gates.runtimePortOfThisProcess() !== port) return { open: false, gate: "runtime-record" };
98
+ const config = gates.loadConfig();
99
+ // Codex ON and not hub-gated; the injector re-checks this under its lock.
100
+ if (!shouldSyncCodexOnStart(config)) return { open: false, gate: "codex-off" };
101
+ if (shouldInjectApiAuthHeader(config)) return { open: false, gate: "admission-token" };
102
+ const targetPort = Number(new URL(standaloneCodexRoutingTarget(port, config).baseUrl).port);
103
+ if (!ownPorts.has(targetPort)) return { open: false, gate: "target-not-served" };
104
+ if (gates.clientConnected() || gates.clientJournalOwner()) return { open: false, gate: "client" };
105
+ return { open: true, config, targetPort };
106
+ }
107
+
108
+ export interface CodexRoutingHealRecord {
109
+ readonly at: string;
110
+ readonly fromUrls: readonly string[];
111
+ readonly toPort: number;
112
+ readonly probes: number;
113
+ readonly deadForMs: number;
114
+ }
115
+
116
+ export interface CodexRoutingHealerHandle {
117
+ stop(): void;
118
+ lastHeal(): CodexRoutingHealRecord | null;
119
+ }
120
+
121
+ export interface CodexRoutingHealerDeps {
122
+ /** Schedule `fn` after `ms`; defaults to an unref'd setTimeout. */
123
+ scheduleFn?: (fn: () => void, ms: number) => { cancel(): void };
124
+ /** Monotonic milliseconds. */
125
+ now?: () => number;
126
+ /** `config.toml` text, or null when absent or unreadable. */
127
+ readConfig?: () => string | null;
128
+ readJournaled?: () => JournaledCodexRouting;
129
+ probe?: (target: { port: number; hostname: string }) => Promise<EndpointLiveness>;
130
+ inject?: (port: number, config: OcxConfig, options: InjectCodexOptions) => Promise<CodexInjectResult>;
131
+ gates?: Partial<CodexRoutingHealGates>;
132
+ log?: Pick<Console, "warn">;
133
+ debugLine?: (line: string) => void;
134
+ }
135
+
136
+ /** Thrown by the under-lock guard: the routing changed after it was proven dead. */
137
+ class RoutingHealAborted extends Error {
138
+ constructor() {
139
+ super("Codex routing changed before the heal could be written.");
140
+ this.name = "RoutingHealAborted";
141
+ }
142
+ }
143
+
144
+ function defaultSchedule(fn: () => void, ms: number): { cancel(): void } {
145
+ const timer = setTimeout(fn, ms);
146
+ if (typeof timer.unref === "function") timer.unref();
147
+ return { cancel: () => clearTimeout(timer) };
148
+ }
149
+
150
+ function readCodexConfigText(): string | null {
151
+ try {
152
+ return readFileSync(CODEX_CONFIG_PATH, "utf8");
153
+ } catch {
154
+ return null;
155
+ }
156
+ }
157
+
158
+ const defaultGates: CodexRoutingHealGates = {
159
+ siblingOfLivePort,
160
+ exiting: () => isRecyclingForExit() || isShutdownDraining(),
161
+ runtimePortOfThisProcess: () => readRuntimePort(process.pid)?.port ?? null,
162
+ loadConfig,
163
+ clientConnected: () => readClientConnectionState().kind !== "disconnected",
164
+ clientJournalOwner: () => journalOwner({ readOnly: true })?.kind === "client",
165
+ };
166
+
167
+ /** The first line of an injector or error message, with home paths and secret-shaped values masked. */
168
+ function logDetail(message: string): string {
169
+ return redactUserPath(message.split("\n")[0] ?? "");
170
+ }
171
+
172
+ function endpointLabel(target: CodexRoutingDriftTarget): string {
173
+ return `${target.hostname.includes(":") ? `[${target.hostname}]` : target.hostname}:${target.port}`;
174
+ }
175
+
176
+ function endpointKey(target: Pick<CodexRoutingDriftTarget, "hostname" | "port">): string {
177
+ return JSON.stringify([target.hostname, target.port]);
178
+ }
179
+
180
+ export function startCodexRoutingHealer(options: {
181
+ port: number;
182
+ config: OcxConfig;
183
+ deps?: CodexRoutingHealerDeps;
184
+ }): CodexRoutingHealerHandle {
185
+ const { port, config } = options;
186
+ const deps = options.deps ?? {};
187
+ let stopped = false;
188
+ let pending: { cancel(): void } | undefined;
189
+ let last: CodexRoutingHealRecord | null = null;
190
+ const handle: CodexRoutingHealerHandle = {
191
+ stop() {
192
+ stopped = true;
193
+ pending?.cancel();
194
+ pending = undefined;
195
+ },
196
+ lastHeal: () => last,
197
+ };
198
+ // Admission-token (non-loopback) routing is never healed, and the bind scope is fixed for the
199
+ // process lifetime, so such an owner starts no timer at all.
200
+ if (shouldInjectApiAuthHeader(config)) return handle;
201
+
202
+ const listenerPort = effectiveLoopbackListenerPort(config, port);
203
+ const ownPorts: ReadonlySet<number> = new Set(listenerPort === null ? [port] : [port, listenerPort]);
204
+ const scheduleFn = deps.scheduleFn ?? defaultSchedule;
205
+ const clock = deps.now ?? (() => performance.now());
206
+ const readConfig = deps.readConfig ?? readCodexConfigText;
207
+ const readJournaled = deps.readJournaled ?? ((): JournaledCodexRouting => ({
208
+ openaiBaseUrl: journaledInjectedOpenaiBaseUrl({ readOnly: true }),
209
+ realtimeWsBaseUrl: journaledInjectedRealtimeWsBaseUrl({ readOnly: true }),
210
+ }));
211
+ const probe = deps.probe ?? (target => probeEndpointLiveness(target));
212
+ const inject = deps.inject ?? injectCodexConfig;
213
+ const gates: CodexRoutingHealGates = { ...defaultGates, ...deps.gates };
214
+ const log = deps.log ?? console;
215
+ const debugLine = deps.debugLine ?? appendDebugLogLine;
216
+
217
+ // The verdict for the last bytes read. Unchanged bytes with a non-foreign verdict cost nothing.
218
+ let cache: { content: string; kind: CodexRoutingDrift["kind"] } | null = null;
219
+ let streak: { key: string; firstDeadAt: number; probes: number } | null = null;
220
+ let recheckAt = 0;
221
+ /** After a real refusal; cleared as soon as routing is not foreign any more. */
222
+ let backoffUntil = 0;
223
+ /** While a cap is reached: until the oldest counted attempt or heal leaves the window. */
224
+ let pausedUntil = 0;
225
+ const announcedLive = new Set<string>();
226
+ const attempts: number[] = [];
227
+ const heals: number[] = [];
228
+ let rateCapAnnounced = false;
229
+ let flapCapAnnounced = false;
230
+
231
+ const warn = (line: string) => {
232
+ try { log.warn(line); } catch { /* logging never breaks the loop */ }
233
+ try { debugLine(line); } catch { /* the debug buffer is best-effort */ }
234
+ };
235
+
236
+ const detect = (content: string): CodexRoutingDrift =>
237
+ detectCodexRoutingDrift(content, { ownPorts, journaled: readJournaled });
238
+
239
+ /** "live" wins over "unknown", which wins over "dead": all must be dead to proceed. */
240
+ const probeAll = async (targets: readonly CodexRoutingDriftTarget[]): Promise<{ verdict: EndpointLiveness; liveEndpoints: CodexRoutingDriftTarget[] }> => {
241
+ const byEndpoint = new Map<string, CodexRoutingDriftTarget>();
242
+ for (const target of targets) byEndpoint.set(endpointKey(target), target);
243
+ const liveEndpoints: CodexRoutingDriftTarget[] = [];
244
+ let unknown = false;
245
+ for (const target of byEndpoint.values()) {
246
+ let result: EndpointLiveness;
247
+ try { result = await probe({ port: target.port, hostname: target.hostname }); }
248
+ catch { result = "unknown"; }
249
+ if (result === "live") liveEndpoints.push(target);
250
+ else if (result === "unknown") unknown = true;
251
+ }
252
+ return { verdict: liveEndpoints.length > 0 ? "live" : unknown ? "unknown" : "dead", liveEndpoints };
253
+ };
254
+
255
+ const standDownForLive = (liveEndpoints: readonly CodexRoutingDriftTarget[]) => {
256
+ streak = null;
257
+ recheckAt = clock() + ROUTING_HEAL_RECHECK_MS;
258
+ for (const target of liveEndpoints) {
259
+ const key = endpointKey(target);
260
+ if (announcedLive.has(key)) continue;
261
+ announcedLive.add(key);
262
+ warn(`Codex routing points at another running opencodex on ${endpointLabel(target)}; leaving it.`);
263
+ }
264
+ };
265
+
266
+ /** A cap pauses probing and writing until `until`; the streak starts over once it has passed. */
267
+ const pauseUntil = (until: number) => {
268
+ streak = null;
269
+ pausedUntil = until;
270
+ };
271
+
272
+ const heal = async (
273
+ content: string,
274
+ targets: readonly CodexRoutingDriftTarget[],
275
+ proven: { firstDeadAt: number; probes: number },
276
+ ) => {
277
+ const now = clock();
278
+ while (attempts.length > 0 && now - attempts[0]! >= ROUTING_HEAL_WINDOW_MS) attempts.shift();
279
+ while (heals.length > 0 && now - heals[0]! >= ROUTING_HEAL_WINDOW_MS) heals.shift();
280
+ if (heals.length >= ROUTING_HEAL_MAX_HEALS) {
281
+ pauseUntil(heals[0]! + ROUTING_HEAL_WINDOW_MS);
282
+ if (!flapCapAnnounced) warn(`Codex routing was re-pointed at a dead port ${heals.length} times within an hour; the self-heal pauses until that hour has passed so it does not fight another writer. Run 'ocx sync' to repair routing now.`);
283
+ flapCapAnnounced = true;
284
+ return;
285
+ }
286
+ flapCapAnnounced = false;
287
+ if (attempts.length >= ROUTING_HEAL_MAX_ATTEMPTS) {
288
+ pauseUntil(attempts[0]! + ROUTING_HEAL_WINDOW_MS);
289
+ if (!rateCapAnnounced) warn("Codex routing self-heal paused after repeated attempts; it resumes within the hour. Run 'ocx sync' to repair now.");
290
+ rateCapAnnounced = true;
291
+ return;
292
+ }
293
+ rateCapAnnounced = false;
294
+ // One final look right before the write, then the gates again: the probe may have taken seconds.
295
+ const final = await probeAll(targets);
296
+ if (stopped) return; // the exit cleanup ran while the probe was out
297
+ if (final.verdict === "live") { standDownForLive(final.liveEndpoints); return; }
298
+ if (final.verdict !== "dead") { recheckAt = clock() + ROUTING_HEAL_RECHECK_MS; return; }
299
+ const gate = evaluateCodexRoutingHealGates(port, ownPorts, gates);
300
+ if (!gate.open) {
301
+ streak = null;
302
+ if (gate.gate === "sibling") stopped = true;
303
+ return;
304
+ }
305
+ attempts.push(clock());
306
+ const deadEndpoints = new Set(targets.map(endpointKey));
307
+ const guard = () => {
308
+ const current = readConfig();
309
+ const drift = current === null ? null : detect(current);
310
+ if (current !== content || drift?.kind !== "foreign"
311
+ || drift.targets.some(target => !deadEndpoints.has(endpointKey(target)))) {
312
+ throw new RoutingHealAborted();
313
+ }
314
+ };
315
+ /*
316
+ * Whether a failed write raced another writer. A coordinated home re-reads its admission under
317
+ * the lock BEFORE this guard runs, so a config.toml rewritten between the plan and the lock
318
+ * comes back as a plain refusal. Moved bytes are the same abort as the guard's; only a refusal
319
+ * over the very bytes that were proven dead backs off.
320
+ */
321
+ const raced = (): boolean => {
322
+ const current = readConfig();
323
+ if (current !== content) return true;
324
+ const drift = detect(current);
325
+ return drift.kind !== "foreign" || drift.targets.some(target => !deadEndpoints.has(endpointKey(target)));
326
+ };
327
+ let result: CodexInjectResult;
328
+ try {
329
+ result = await inject(port, gate.config, { lockTimeoutMs: ROUTING_HEAL_LOCK_TIMEOUT_MS, beforeClientWrite: guard });
330
+ } catch (error) {
331
+ streak = null;
332
+ cache = null;
333
+ if (error instanceof RoutingHealAborted || raced()) return;
334
+ backoffUntil = clock() + ROUTING_HEAL_REFUSED_BACKOFF_MS;
335
+ warn(`Codex routing self-heal failed (${logDetail(error instanceof Error ? error.message : String(error))}); retrying in 10 minutes. Run 'ocx sync' to repair now.`);
336
+ return;
337
+ }
338
+ if (result.success && result.status !== "skipped" && result.configApplied !== false) {
339
+ heals.push(clock());
340
+ streak = null;
341
+ cache = null;
342
+ const fromUrls = [...new Set(targets.map(target => target.url))];
343
+ last = {
344
+ at: new Date().toISOString(),
345
+ fromUrls,
346
+ toPort: gate.targetPort,
347
+ probes: proven.probes + 1,
348
+ deadForMs: Math.round(clock() - proven.firstDeadAt),
349
+ };
350
+ const from = [...new Set(targets.map(endpointLabel))].join(", ");
351
+ warn(`Codex routing pointed at ${from}, where no opencodex answers; re-pointed it at this proxy on port ${gate.targetPort}. Codex threads opened meanwhile keep the old address until you reopen them.`);
352
+ return;
353
+ }
354
+ // Busy lock: keep the proven streak so the next tick re-probes and tries again.
355
+ if (result.retryable) return;
356
+ streak = null;
357
+ cache = null;
358
+ // Skipped under the lock (Codex OFF, hub, sibling), an external provider appeared, or a race.
359
+ if (result.success || raced()) return;
360
+ backoffUntil = clock() + ROUTING_HEAL_REFUSED_BACKOFF_MS;
361
+ warn(`Codex routing self-heal was refused (${logDetail(result.message)}); retrying in 10 minutes. Run 'ocx sync' to repair now.`);
362
+ };
363
+
364
+ const evaluate = async () => {
365
+ if (gates.siblingOfLivePort() !== null) { stopped = true; return; }
366
+ const content = readConfig();
367
+ if (content === null) { cache = null; streak = null; return; }
368
+ const unchanged = cache !== null && cache.content === content;
369
+ if (unchanged && cache!.kind !== "foreign") return;
370
+ const drift = detect(content);
371
+ cache = { content, kind: drift.kind };
372
+ if (drift.kind !== "foreign") {
373
+ streak = null;
374
+ recheckAt = 0;
375
+ backoffUntil = 0;
376
+ announcedLive.clear();
377
+ return;
378
+ }
379
+ const now = clock();
380
+ if (now < backoffUntil || now < pausedUntil) return;
381
+ if (unchanged && now < recheckAt) return;
382
+ const gate = evaluateCodexRoutingHealGates(port, ownPorts, gates);
383
+ if (!gate.open) {
384
+ streak = null;
385
+ recheckAt = now + ROUTING_HEAL_RECHECK_MS;
386
+ if (gate.gate === "sibling") stopped = true;
387
+ return;
388
+ }
389
+ const probed = await probeAll(drift.targets);
390
+ if (stopped) return;
391
+ if (probed.verdict === "live") { standDownForLive(probed.liveEndpoints); return; }
392
+ // Unknown never advances the streak and never resets it; it is asked again at the recheck pace.
393
+ if (probed.verdict !== "dead") { recheckAt = clock() + ROUTING_HEAL_RECHECK_MS; return; }
394
+ recheckAt = 0;
395
+ const probedAt = clock();
396
+ const key = JSON.stringify([...new Set(drift.targets.map(endpointKey))].sort());
397
+ if (!streak || streak.key !== key) {
398
+ streak = { key, firstDeadAt: probedAt, probes: 1 };
399
+ return;
400
+ }
401
+ streak.probes += 1;
402
+ if (streak.probes < ROUTING_HEAL_MIN_DEAD_PROBES || probedAt - streak.firstDeadAt < ROUTING_HEAL_MIN_DEAD_SPAN_MS) return;
403
+ await heal(content, drift.targets, streak);
404
+ };
405
+
406
+ const tick = async () => {
407
+ pending = undefined;
408
+ if (stopped) return;
409
+ try { await evaluate(); } catch {
410
+ // A background check never throws into the timer; an unreadable config.json is looked at
411
+ // again at the slower recheck pace rather than on every tick.
412
+ recheckAt = clock() + ROUTING_HEAL_RECHECK_MS;
413
+ }
414
+ if (!stopped) pending = scheduleFn(() => { void tick(); }, ROUTING_HEAL_TICK_MS);
415
+ };
416
+
417
+ pending = scheduleFn(() => { void tick(); }, ROUTING_HEAL_TICK_MS);
418
+ return handle;
419
+ }
@@ -1,3 +1,4 @@
1
+ import { clearIdleWindowSteering, pickIdleWindowAccount } from "./routing/idle-window";
1
2
  import { getEffectiveCodexAutoSwitchThreshold } from "./account-auto-switch";
2
3
  import { codexQuotaHasFreshUsage } from "./quota-observation-freshness";
3
4
  import { saveConfigPreservingClaudeCode } from "../config";
@@ -234,6 +235,7 @@ export function clearCodexUpstreamHealth(): void {
234
235
  // reset points. Leaving them behind lets a selection from one context suppress the
235
236
  // automatic cursor in the next one.
236
237
  clearAllManualPreferences();
238
+ clearIdleWindowSteering();
237
239
  clearUpstreamHealthState();
238
240
  forgetRuntimeActiveCodexAccount();
239
241
  // The reconcile watermark is part of this state, not something that outlives it. Keeping
@@ -786,6 +788,10 @@ export function previewCodexAccountForRequest(
786
788
  if (lineagePreview) return lineagePreview.accountId;
787
789
  }
788
790
 
791
+ const idlePick = !entry && !peekPendingReleaseReason(threadId)
792
+ && !(threadId && getModelDetourAffinity(threadId, modelId, quotaScope))
793
+ ? pickIdleWindowAccount(config, threadId, now, false, quotaScope, selectionOptions) : null;
794
+ if (idlePick) return idlePick;
789
795
  const strategyPick = pickUnboundStrategyAccount(
790
796
  config,
791
797
  threadId,
@@ -881,6 +887,7 @@ export function resolveCodexAccountForThreadDetailed(
881
887
  // cold one: every branch that follows -- detour reuse, transient hold, quota re-eval --
882
888
  // should treat it as the continuing conversation it is. No-op on a fresh process.
883
889
  if (threadId) adoptLegacyLineageAffinity(threadId, lineage, now, quotaScope, modelId);
890
+ const hadModelAffinity = !!(threadId && getModelDetourAffinity(threadId, modelId, quotaScope));
884
891
 
885
892
  if (threadId && modelScopedSelection) {
886
893
  const detourEntry = getModelDetourAffinity(threadId, modelId, quotaScope);
@@ -1088,6 +1095,9 @@ export function resolveCodexAccountForThreadDetailed(
1088
1095
  }
1089
1096
  }
1090
1097
 
1098
+ const idlePick = !entry && !releaseReason && !hadModelAffinity
1099
+ ? pickIdleWindowAccount(config, threadId, now, true, quotaScope, selectionOptions) : null;
1100
+ if (idlePick) return { status: "selected", accountId: idlePick, affinity: affinityAfterRelease(threadId, releaseReason) };
1091
1101
  // A request-scoped roster may still contain unhealthy candidates. Non-quota strategies return
1092
1102
  // before the quota/failover helpers below, so prefer only shared-healthy roster members here;
1093
1103
  // otherwise RR/fill-first can immediately re-pick a known failing account even when another