@bitkyc08/opencodex 2.55.0 → 2.56.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 (167) hide show
  1. package/gui/dist/assets/{index-VuoiWj9J.js → index-D4zuyIxQ.js} +1 -1
  2. package/gui/dist/index.html +1 -1
  3. package/package.json +2 -1
  4. package/src/adapters/base.ts +21 -0
  5. package/src/adapters/cursor/transport-retry.ts +46 -1
  6. package/src/adapters/cursor.ts +4 -0
  7. package/src/adapters/kiro/adapter.ts +42 -1
  8. package/src/adapters/kiro-retry.ts +23 -4
  9. package/src/adapters/openai-chat/errors.ts +116 -0
  10. package/src/adapters/openai-chat/messages.ts +346 -0
  11. package/src/adapters/openai-chat/passthrough.ts +146 -0
  12. package/src/adapters/openai-chat/response-events.ts +117 -0
  13. package/src/adapters/openai-chat/tool-call-validation.ts +200 -0
  14. package/src/adapters/openai-chat/tool-schema.ts +477 -0
  15. package/src/adapters/openai-chat/wire.ts +50 -0
  16. package/src/adapters/openai-chat.ts +33 -1445
  17. package/src/adapters/openai-responses/canonical-forward.ts +202 -0
  18. package/src/adapters/openai-responses/image-gen.ts +406 -0
  19. package/src/adapters/openai-responses/internal.ts +3 -0
  20. package/src/adapters/openai-responses/passthrough.ts +611 -0
  21. package/src/adapters/openai-responses/prompt-cache.ts +83 -0
  22. package/src/adapters/openai-responses/reasoning.ts +220 -0
  23. package/src/adapters/openai-responses/request-strips.ts +185 -0
  24. package/src/adapters/openai-responses/tool-output-recovery.ts +509 -0
  25. package/src/adapters/openai-responses/tool-schema.ts +293 -0
  26. package/src/adapters/openai-responses/web-search.ts +156 -0
  27. package/src/adapters/openai-responses.ts +4 -2625
  28. package/src/bridge/errors.ts +34 -0
  29. package/src/bridge/internal.ts +174 -0
  30. package/src/bridge/response-json.ts +624 -0
  31. package/src/bridge/sse.ts +1444 -0
  32. package/src/bridge.ts +5 -2204
  33. package/src/chat/inbound.ts +12 -1
  34. package/src/codex/account-lifecycle.ts +3 -0
  35. package/src/codex/account-store.ts +71 -9
  36. package/src/codex/auth-api/account-list.ts +507 -0
  37. package/src/codex/auth-api/http.ts +32 -0
  38. package/src/codex/auth-api/login-flow.ts +554 -0
  39. package/src/codex/auth-api/login-state.ts +64 -0
  40. package/src/codex/auth-api/main-account-probe.ts +331 -0
  41. package/src/codex/auth-api/pool-mode-gate.ts +274 -0
  42. package/src/codex/auth-api/pool-quota-probe.ts +512 -0
  43. package/src/codex/auth-api/reset-credit-service.ts +422 -0
  44. package/src/codex/auth-api/routes.ts +425 -0
  45. package/src/codex/auth-api/runtime-config.ts +48 -0
  46. package/src/codex/auth-api.ts +27 -3118
  47. package/src/codex/auth-context.ts +95 -28
  48. package/src/codex/catalog/auto-review.ts +507 -0
  49. package/src/codex/catalog/build-entries.ts +981 -0
  50. package/src/codex/catalog/combo-member.ts +375 -0
  51. package/src/codex/catalog/derive-entry.ts +229 -0
  52. package/src/codex/catalog/effort.ts +0 -1
  53. package/src/codex/catalog/gated-native-warn.ts +63 -0
  54. package/src/codex/catalog/gather-capture.ts +533 -0
  55. package/src/codex/catalog/model-hints.ts +691 -0
  56. package/src/codex/catalog/model-visibility.ts +304 -0
  57. package/src/codex/catalog/provider-fetch.ts +52 -2942
  58. package/src/codex/catalog/provider-models.ts +685 -0
  59. package/src/codex/catalog/restore.ts +132 -0
  60. package/src/codex/catalog/retained-sync.ts +706 -0
  61. package/src/codex/catalog/routed-gather.ts +858 -0
  62. package/src/codex/catalog/subagent-roster.ts +176 -0
  63. package/src/codex/catalog/sync.ts +52 -2698
  64. package/src/codex/inject/config-toml.ts +563 -0
  65. package/src/codex/inject/remove.ts +192 -0
  66. package/src/codex/inject/restore.ts +540 -0
  67. package/src/codex/inject/routing-classify.ts +109 -0
  68. package/src/codex/inject/routing-target.ts +125 -0
  69. package/src/codex/inject.ts +81 -1436
  70. package/src/codex/lineage.ts +458 -0
  71. package/src/codex/pool-refresh-backoff.ts +152 -0
  72. package/src/codex/routing/active-account.ts +194 -0
  73. package/src/codex/routing/cooldown-math.ts +275 -0
  74. package/src/codex/routing/health-store.ts +402 -0
  75. package/src/codex/routing/probe-lease.ts +358 -0
  76. package/src/codex/routing/selection.ts +703 -0
  77. package/src/codex/routing/thread-affinity.ts +538 -0
  78. package/src/codex/routing.ts +353 -2234
  79. package/src/codex/shim-fingerprint.ts +223 -0
  80. package/src/codex/shim-inspect.ts +175 -0
  81. package/src/codex/shim-probe.ts +367 -0
  82. package/src/codex/shim-restore-lock.ts +169 -0
  83. package/src/codex/shim-state-file.ts +151 -0
  84. package/src/codex/shim-templates.ts +265 -0
  85. package/src/codex/shim.ts +48 -1268
  86. package/src/config/diagnostics.ts +705 -0
  87. package/src/config/feature-flags.ts +55 -0
  88. package/src/config/live-reconcile.ts +403 -0
  89. package/src/config/load-degrade.ts +880 -0
  90. package/src/config/mutation-lock.ts +244 -0
  91. package/src/config/openai-tier-backup.ts +268 -0
  92. package/src/config/persist-unlocked.ts +92 -0
  93. package/src/config/proxy-env.ts +188 -0
  94. package/src/config/salvage.ts +244 -0
  95. package/src/config/schema/config-schema.ts +640 -0
  96. package/src/config/schema/leaf-validators.ts +855 -0
  97. package/src/config/warn-memo.ts +28 -0
  98. package/src/config.ts +234 -4481
  99. package/src/generated/compatibility-version.json +539 -39
  100. package/src/lib/request-execution-budget.ts +69 -20
  101. package/src/lib/spend-reservation-ledger.ts +940 -0
  102. package/src/lib/upstream-retry.ts +55 -11
  103. package/src/lib/workflow-budget.ts +553 -30
  104. package/src/providers/quota/account-cache.ts +441 -0
  105. package/src/providers/quota/antigravity.ts +295 -0
  106. package/src/providers/quota/report-cache.ts +320 -0
  107. package/src/providers/quota/vendor-probes-key.ts +1243 -0
  108. package/src/providers/quota/vendor-probes-oauth.ts +590 -0
  109. package/src/providers/quota.ts +324 -3079
  110. package/src/providers/registry/entries-core.ts +1221 -0
  111. package/src/providers/registry/entries-extended.ts +1204 -0
  112. package/src/providers/registry/model-seeds.ts +908 -0
  113. package/src/providers/registry/types.ts +352 -0
  114. package/src/providers/registry.ts +24 -3536
  115. package/src/responses/continuation-ownership.ts +29 -0
  116. package/src/responses/state/replay-fingerprint.ts +80 -0
  117. package/src/responses/state/snapshot-codec.ts +104 -0
  118. package/src/responses/state/spill-failure.ts +118 -0
  119. package/src/responses/state/spill-queue.ts +665 -0
  120. package/src/responses/state/temp-recovery.ts +257 -0
  121. package/src/responses/state.ts +82 -1143
  122. package/src/routing/identity-domains.ts +449 -0
  123. package/src/routing/probe-lease.ts +511 -0
  124. package/src/server/index/bounded-request.ts +88 -0
  125. package/src/server/index/live-sideband.ts +565 -0
  126. package/src/server/index/serve-options.ts +1766 -0
  127. package/src/server/index/startup-warnings.ts +213 -0
  128. package/src/server/index/websocket-handler.ts +335 -0
  129. package/src/server/index.ts +40 -2547
  130. package/src/server/management/route-registry.ts +26 -23
  131. package/src/server/management/shared.ts +8 -5
  132. package/src/server/management/workflow-budget-routes.ts +133 -0
  133. package/src/server/management-api.ts +12 -0
  134. package/src/server/request-log-conversation.ts +9 -7
  135. package/src/server/request-log.ts +245 -1
  136. package/src/server/responses/account-change-state.ts +233 -0
  137. package/src/server/responses/adapter-continuation.ts +514 -0
  138. package/src/server/responses/adapter-delivery.ts +214 -0
  139. package/src/server/responses/adapter-dispatch.ts +971 -0
  140. package/src/server/responses/compact.ts +59 -4
  141. package/src/server/responses/completion-policy.ts +33 -0
  142. package/src/server/responses/core-auth.ts +527 -0
  143. package/src/server/responses/core-codex-account.ts +859 -0
  144. package/src/server/responses/core-combo-failure.ts +210 -0
  145. package/src/server/responses/core-combo.ts +707 -0
  146. package/src/server/responses/core-errors.ts +152 -0
  147. package/src/server/responses/core-lifetime.ts +95 -0
  148. package/src/server/responses/core-normalize.ts +350 -0
  149. package/src/server/responses/core-opaque-recovery.ts +380 -0
  150. package/src/server/responses/core-options.ts +159 -0
  151. package/src/server/responses/core-replay.ts +225 -0
  152. package/src/server/responses/core.ts +192 -8893
  153. package/src/server/responses/passthrough-delivery.ts +856 -0
  154. package/src/server/responses/passthrough-dispatch.ts +1476 -0
  155. package/src/server/responses/passthrough-execution.ts +54 -0
  156. package/src/server/responses/request-prepare.ts +970 -0
  157. package/src/server/responses/request-send-budget.ts +164 -0
  158. package/src/server/responses/request-sidecar-auth.ts +149 -0
  159. package/src/server/responses/request-transport.ts +744 -0
  160. package/src/server/responses/response-effects.ts +157 -0
  161. package/src/server/responses/run-turn-execution.ts +448 -0
  162. package/src/server/responses/sidecar-execution.ts +469 -0
  163. package/src/server/responses-image-gen-repair.ts +1 -1
  164. package/src/server/workflow-refusal.ts +84 -0
  165. package/src/types/config.ts +30 -0
  166. package/src/usage/log.ts +146 -0
  167. package/src/usage/summary.ts +171 -21
@@ -0,0 +1,55 @@
1
+ import type { OcxConfig } from "../types";
2
+
3
+ export function websocketsEnabled(config: Pick<OcxConfig, "websockets">): boolean {
4
+ return config.websockets === true;
5
+ }
6
+
7
+ /**
8
+ * Opt-in Ultra Fast, read with the house `=== true` idiom so an absent key and a
9
+ * malformed one both mean off.
10
+ */
11
+ export function ultraFastTierEnabled(config: Pick<OcxConfig, "ultraFastTier">): boolean {
12
+ return config.ultraFastTier === true;
13
+ }
14
+
15
+ /**
16
+ * Default cadence for the opt-in catalog auto-refresh (issue #3630): one converge pass
17
+ * per hour. Each pass spends a live /models call against every enabled provider, and
18
+ * provider catalogs are themselves cached upstream for minutes, so an hour is fresh
19
+ * enough for newly released models to appear without an `ocx sync`.
20
+ */
21
+ export const CATALOG_AUTO_REFRESH_DEFAULT_INTERVAL_MS: number = 60 * 60_000;
22
+
23
+ /**
24
+ * Floor under the configured cadence, for the same reason src/quota/reset-poller.ts has
25
+ * MIN_INTERVAL_MS: below this the refresh buys no freshness — upstream caches have not
26
+ * moved — and only multiplies the chance of a rate limit across every enabled provider.
27
+ */
28
+ export const CATALOG_AUTO_REFRESH_MIN_INTERVAL_MS: number = 15 * 60_000;
29
+
30
+ /**
31
+ * Opt-in master switch, read with the house `=== true` idiom so an absent key and a
32
+ * malformed one both mean off. Pure on purpose: the scheduler calls this from a
33
+ * dynamically imported context, so it takes an explicit config slice and reads nothing
34
+ * global.
35
+ */
36
+ export function isCatalogAutoRefreshEnabled(
37
+ config: Pick<OcxConfig, "catalogAutoRefresh">,
38
+ ): boolean {
39
+ return config.catalogAutoRefresh?.enabled === true;
40
+ }
41
+
42
+ /**
43
+ * Resolved tick interval in milliseconds. An explicit `intervalMinutes: 0` returns 0 —
44
+ * the section stays configured but the timer stays dormant — and any other value is
45
+ * clamped up to CATALOG_AUTO_REFRESH_MIN_INTERVAL_MS so a hand edit cannot outrun the
46
+ * upstream catalog caches. Absent means the hourly default.
47
+ */
48
+ export function resolveCatalogAutoRefreshIntervalMs(
49
+ config: Pick<OcxConfig, "catalogAutoRefresh">,
50
+ ): number {
51
+ const minutes = config.catalogAutoRefresh?.intervalMinutes;
52
+ if (minutes === undefined) return CATALOG_AUTO_REFRESH_DEFAULT_INTERVAL_MS;
53
+ if (minutes === 0) return 0;
54
+ return Math.max(CATALOG_AUTO_REFRESH_MIN_INTERVAL_MS, Math.floor(minutes * 60_000));
55
+ }
@@ -0,0 +1,403 @@
1
+ import type { OcxConfig, OcxProviderConfig } from "../types";
2
+ import { configReasoningPinsConfigError } from "./provider-validation";
3
+ import { adoptCustomModelCatalogMigration, projectCustomModelCatalogMigration } from "../codex/custom-model-catalog-migration";
4
+ import { refreshPreservedProviderOwner, refreshUserCostOverlays } from "../usage/user-cost-overlays";
5
+ import {
6
+ clearPendingConfigTopLevelDeletions,
7
+ configHasRebaseProvenance,
8
+ configRebaseDeletionKeys,
9
+ CONFIG_REBASE_PROVENANCE_KEY,
10
+ projectConfigRebaseProvenance,
11
+ } from "./rebase-provenance";
12
+ import { withConfigMutationLockSync, bumpGenerationForCooperatingConfigWrite } from "./mutation-lock";
13
+ import { persistConfigUnlocked, readRawConfigJson } from "./persist-unlocked";
14
+ import { configDiagnosticsFromRaw, readConfigDiagnostics } from "./diagnostics";
15
+ import { normalizePersistedClaudeCode } from "./load-degrade";
16
+
17
+ // ---------------------------------------------------------------------------
18
+ // Hand-edit protection for the `claudeCode` subtree (devlog 260726_claude_auth_auto/040 H1).
19
+ //
20
+ // `saveConfig` serializes the WHOLE config object, so ANY service-time save — a model
21
+ // visibility toggle, a 429 key rotation on the request path — rewrites `claudeCode`
22
+ // from whatever the long-lived server config happens to hold. A user who hand-edits
23
+ // `config.json` while the proxy runs then watches their edit vanish for no visible
24
+ // reason (issue #488). Enumerating `claudeCode` mutators cannot fix that; the guard has
25
+ // to live in ONE save wrapper that every live-config writer goes through.
26
+ // ---------------------------------------------------------------------------
27
+
28
+ /**
29
+ * Baseline keyed on the CONFIG INSTANCE, never a module global: a second `loadConfig()`
30
+ * elsewhere must not refresh the baseline the long-lived server config is judged
31
+ * against, or a later stale save would masquerade as "our own change".
32
+ */
33
+ const claudeCodeBaseline = new WeakMap<OcxConfig, unknown>();
34
+ /**
35
+ * Full live-config baseline used to rebase unrelated cooperating writes. The
36
+ * Claude subtree and the bound listener fields remain on their dedicated
37
+ * reconciliation paths below.
38
+ */
39
+ const liveConfigBaseline = new WeakMap<OcxConfig, OcxConfig>();
40
+ /**
41
+ * The live config retains the address of the socket Bun actually opened, while
42
+ * this map retains the operator's desired address for the next process start.
43
+ * Keeping them separate prevents an unrelated live save from restoring a stale
44
+ * externally exposed bind after OAuth adopted a newer loopback disk config.
45
+ */
46
+ type PersistedServerBinding = Pick<OcxConfig, "port" | "hostname">;
47
+
48
+ const persistedLiveServerBinding = new WeakMap<OcxConfig, PersistedServerBinding>();
49
+
50
+ /**
51
+ * Arm the baseline for a long-lived config. MANDATORY at `startServer`, not lazy on
52
+ * first save — arming lazily would lose exactly the hand edit made before that first
53
+ * save, which is the case the guard exists for.
54
+ */
55
+ export function armClaudeCodeBaseline(config: OcxConfig): void {
56
+ liveConfigBaseline.set(config, structuredClone(config));
57
+ claudeCodeBaseline.set(config, structuredClone(config.claudeCode));
58
+ }
59
+
60
+ /**
61
+ * Adopt one schema-validated provider that was read from the authoritative disk
62
+ * config into a long-lived server config without rebasing any unrelated field.
63
+ * Updating the matching baseline row keeps a later guarded save from treating the
64
+ * adopted provider as an unsaved live edit that should defeat a newer disk change.
65
+ */
66
+ export function adoptPersistedProviderIntoLiveConfig(
67
+ config: OcxConfig,
68
+ name: string,
69
+ provider: OcxProviderConfig,
70
+ persistedConfig?: OcxConfig,
71
+ ): void {
72
+ config.providers[name] = structuredClone(provider);
73
+ const baseline = liveConfigBaseline.get(config);
74
+ if (baseline) baseline.providers[name] = structuredClone(provider);
75
+ if (persistedConfig) refreshPreservedProviderOwner(config, persistedConfig);
76
+ }
77
+
78
+ /** Test seam only: is this instance armed? */
79
+ export function claudeCodeBaselineArmed(config: OcxConfig): boolean {
80
+ return claudeCodeBaseline.has(config);
81
+ }
82
+
83
+ /**
84
+ * Structural compare of parsed subtrees. NOT `JSON.stringify`: key order must not
85
+ * decide whether a user's hand edit survives.
86
+ */
87
+ function deepEqual(a: unknown, b: unknown): boolean {
88
+ if (a === b) return true;
89
+ if (a === null || b === null || typeof a !== "object" || typeof b !== "object") return false;
90
+ if (Array.isArray(a) !== Array.isArray(b)) return false;
91
+ if (Array.isArray(a) && Array.isArray(b)) {
92
+ return a.length === b.length && a.every((item, index) => deepEqual(item, b[index]));
93
+ }
94
+ const left = a as Record<string, unknown>;
95
+ const right = b as Record<string, unknown>;
96
+ // `undefined` values and absent keys are the same thing after a JSON round-trip.
97
+ const keys = new Set([...Object.keys(left), ...Object.keys(right)]);
98
+ for (const key of keys) {
99
+ if (left[key] === undefined && right[key] === undefined) continue;
100
+ if (!deepEqual(left[key], right[key])) return false;
101
+ }
102
+ return true;
103
+ }
104
+
105
+ const MISSING_CONFIG_VALUE = Symbol("missing-config-value");
106
+ type ConfigMergeValue = unknown | typeof MISSING_CONFIG_VALUE;
107
+
108
+ function isPlainConfigRecord(value: ConfigMergeValue): value is Record<string, unknown> {
109
+ if (!value || typeof value !== "object" || Array.isArray(value)) return false;
110
+ const prototype = Object.getPrototypeOf(value);
111
+ return prototype === Object.prototype || prototype === null;
112
+ }
113
+
114
+ function ownConfigValue(record: Record<string, unknown>, key: string): ConfigMergeValue {
115
+ return Object.hasOwn(record, key) ? record[key] : MISSING_CONFIG_VALUE;
116
+ }
117
+
118
+ function cloneConfigValue(value: ConfigMergeValue): ConfigMergeValue {
119
+ return value === MISSING_CONFIG_VALUE ? value : structuredClone(value);
120
+ }
121
+
122
+ type IndexedCustomModels = {
123
+ order: string[];
124
+ byId: Map<string, Record<string, unknown>>;
125
+ };
126
+
127
+ function indexCustomModels(value: ConfigMergeValue): IndexedCustomModels | null {
128
+ if (!Array.isArray(value)) return null;
129
+ const order: string[] = [];
130
+ const byId = new Map<string, Record<string, unknown>>();
131
+ for (const item of value) {
132
+ if (!isPlainConfigRecord(item) || typeof item.id !== "string" || item.id.length === 0 || byId.has(item.id)) {
133
+ return null;
134
+ }
135
+ order.push(item.id);
136
+ byId.set(item.id, item);
137
+ }
138
+ return { order, byId };
139
+ }
140
+
141
+ /**
142
+ * Merge custom-model rows by their stable id instead of treating the array as
143
+ * one opaque value. A row changed only on disk is adopted, a row changed only
144
+ * in the live config is retained, and disjoint edits to the same row recurse
145
+ * through the normal three-way object merge. A newer persisted row deletion
146
+ * wins over a stale live edit to that row.
147
+ */
148
+ function reconcileCustomModels(
149
+ baseline: ConfigMergeValue,
150
+ live: ConfigMergeValue,
151
+ persisted: ConfigMergeValue,
152
+ ): ConfigMergeValue | null {
153
+ const baselineRows = indexCustomModels(baseline);
154
+ const liveRows = indexCustomModels(live);
155
+ const persistedRows = indexCustomModels(persisted);
156
+ if (!baselineRows || !liveRows || !persistedRows) return null;
157
+
158
+ const order = [...liveRows.order, ...persistedRows.order.filter(id => !liveRows.byId.has(id))];
159
+ const merged: Array<Record<string, unknown>> = [];
160
+ for (const id of order) {
161
+ const baselineRow = baselineRows.byId.get(id) ?? MISSING_CONFIG_VALUE;
162
+ const persistedRow = persistedRows.byId.get(id) ?? MISSING_CONFIG_VALUE;
163
+ const row = baselineRow !== MISSING_CONFIG_VALUE && persistedRow === MISSING_CONFIG_VALUE
164
+ ? MISSING_CONFIG_VALUE
165
+ : reconcileConfigValue(
166
+ baselineRow,
167
+ liveRows.byId.get(id) ?? MISSING_CONFIG_VALUE,
168
+ persistedRow,
169
+ );
170
+ if (row !== MISSING_CONFIG_VALUE) merged.push(row as Record<string, unknown>);
171
+ }
172
+ return merged;
173
+ }
174
+
175
+ function reconcileConfigRecord(
176
+ live: Record<string, unknown>,
177
+ baseline: Record<string, unknown>,
178
+ persisted: Record<string, unknown>,
179
+ skippedKeys?: ReadonlySet<string>,
180
+ persistedDeletionsWin = false,
181
+ ): void {
182
+ const keys = new Set([...Object.keys(baseline), ...Object.keys(live), ...Object.keys(persisted)]);
183
+ for (const key of keys) {
184
+ if (skippedKeys?.has(key)) continue;
185
+ const baselineValue = ownConfigValue(baseline, key);
186
+ const liveValue = ownConfigValue(live, key);
187
+ const persistedValue = ownConfigValue(persisted, key);
188
+ const merged = persistedDeletionsWin
189
+ && baselineValue !== MISSING_CONFIG_VALUE
190
+ && persistedValue === MISSING_CONFIG_VALUE
191
+ ? MISSING_CONFIG_VALUE
192
+ : key === "customModels"
193
+ ? reconcileCustomModels(baselineValue, liveValue, persistedValue)
194
+ ?? reconcileConfigValue(baselineValue, liveValue, persistedValue)
195
+ : reconcileConfigValue(baselineValue, liveValue, persistedValue, key === "providers");
196
+ if (merged === MISSING_CONFIG_VALUE) delete live[key];
197
+ else live[key] = merged;
198
+ }
199
+ }
200
+
201
+ function reconcileConfigValue(
202
+ baseline: ConfigMergeValue,
203
+ live: ConfigMergeValue,
204
+ persisted: ConfigMergeValue,
205
+ persistedChildDeletionsWin = false,
206
+ ): ConfigMergeValue {
207
+ const liveChanged = !deepEqual(live, baseline);
208
+ const persistedChanged = !deepEqual(persisted, baseline);
209
+
210
+ if (!liveChanged) {
211
+ if (live !== MISSING_CONFIG_VALUE && Array.isArray(live) && Array.isArray(persisted)) {
212
+ live.splice(0, live.length, ...structuredClone(persisted));
213
+ return live;
214
+ }
215
+ if (isPlainConfigRecord(live) && isPlainConfigRecord(persisted)) {
216
+ reconcileConfigRecord(
217
+ live,
218
+ isPlainConfigRecord(baseline) ? baseline : {},
219
+ persisted,
220
+ );
221
+ return live;
222
+ }
223
+ return cloneConfigValue(persisted);
224
+ }
225
+
226
+ if (!persistedChanged) return live;
227
+
228
+ if (isPlainConfigRecord(live)
229
+ && isPlainConfigRecord(persisted)
230
+ && (baseline === MISSING_CONFIG_VALUE || isPlainConfigRecord(baseline))) {
231
+ reconcileConfigRecord(
232
+ live,
233
+ isPlainConfigRecord(baseline) ? baseline : {},
234
+ persisted,
235
+ undefined,
236
+ persistedChildDeletionsWin,
237
+ );
238
+ }
239
+ // Same-leaf conflicts prefer the pending live management mutation.
240
+ return live;
241
+ }
242
+
243
+ /**
244
+ * Reconcile an async OAuth disk commit into the shared live config without erasing
245
+ * management mutations that have not saved yet. The baseline is a normalized disk
246
+ * snapshot from immediately before login; disjoint object edits merge recursively,
247
+ * while same-leaf conflicts prefer live state.
248
+ */
249
+ export function reconcileLiveConfigFromDisk(config: OcxConfig, persistedBaseline: OcxConfig): void {
250
+ const diagnostics = readConfigDiagnostics();
251
+ if (diagnostics.source === "fallback") {
252
+ throw new Error(`OAuth config reconciliation failed: ${diagnostics.error ?? "invalid config file"}`);
253
+ }
254
+ const persisted = diagnostics.config;
255
+ const claudeGuardArmed = claudeCodeBaseline.has(config);
256
+ const pendingLiveClaudeMutation = claudeGuardArmed
257
+ && !deepEqual(config.claudeCode, claudeCodeBaseline.get(config));
258
+
259
+ persistedLiveServerBinding.set(config, {
260
+ port: persisted.port,
261
+ ...(persisted.hostname !== undefined ? { hostname: persisted.hostname } : {}),
262
+ });
263
+
264
+ reconcileConfigRecord(
265
+ config as unknown as Record<string, unknown>,
266
+ persistedBaseline as unknown as Record<string, unknown>,
267
+ persisted as unknown as Record<string, unknown>,
268
+ new Set(["hostname", "port", ...(claudeGuardArmed ? ["claudeCode"] : [])]),
269
+ );
270
+
271
+ if (claudeGuardArmed && !pendingLiveClaudeMutation) {
272
+ if (persisted.claudeCode === undefined) delete config.claudeCode;
273
+ else config.claudeCode = structuredClone(persisted.claudeCode);
274
+ claudeCodeBaseline.set(config, structuredClone(config.claudeCode));
275
+ }
276
+ // The reconciliation may have adopted a providers.<name>.modelCosts edit made
277
+ // by a cooperating process while the OAuth login was pending; keep the overlay
278
+ // registry (and the usage-cache overlay version) in sync with the live config.
279
+ refreshUserCostOverlays(config);
280
+ }
281
+
282
+ /**
283
+ * Read only schema-valid binding fields from the literal file. Missing fields mean
284
+ * their schema defaults; malformed fields keep the last known persisted value.
285
+ */
286
+ function readPersistedServerBinding(
287
+ raw: Record<string, unknown>,
288
+ baseline: PersistedServerBinding,
289
+ ): PersistedServerBinding {
290
+ const port = raw.port === undefined
291
+ ? 10100
292
+ : (typeof raw.port === "number"
293
+ && Number.isInteger(raw.port)
294
+ && raw.port >= 0
295
+ && raw.port <= 65535
296
+ ? raw.port
297
+ : baseline.port);
298
+ const hostname = raw.hostname === undefined
299
+ ? undefined
300
+ : (typeof raw.hostname === "string" ? raw.hostname : baseline.hostname);
301
+ return { port, ...(hostname !== undefined ? { hostname } : {}) };
302
+ }
303
+
304
+ /**
305
+ * The save entry point for every writer holding a LIVE server config.
306
+ *
307
+ * Conflict policy, chosen deliberately:
308
+ * - disk changed, we did not → their hand edit wins;
309
+ * - disk changed AND we changed → disjoint fields are merged, while a same-leaf
310
+ * conflict keeps the live value;
311
+ * - a provider or custom-model row deleted on disk stays deleted even if stale
312
+ * live state edited that same row;
313
+ * - file missing/unreadable → save what we have, no throw.
314
+ *
315
+ * Custom-model rows are merged by their stable `id`, preserving independent
316
+ * edits and deletions across stale whole-config saves.
317
+ */
318
+ export function saveConfigPreservingClaudeCode(config: OcxConfig): void {
319
+ const pinError = configReasoningPinsConfigError(config);
320
+ if (pinError) throw new Error(pinError);
321
+ withConfigMutationLockSync(() => {
322
+ const bindingBaseline = persistedLiveServerBinding.get(config);
323
+ // One authoritative pre-write read feeds both the live-config reconciliation and
324
+ // custom-model deletion migration. A second read could observe different bytes.
325
+ const onDisk = readRawConfigJson();
326
+ const baseline = liveConfigBaseline.get(config);
327
+ if (baseline && onDisk !== undefined) {
328
+ const persistedDiagnostics = configDiagnosticsFromRaw(JSON.stringify(onDisk));
329
+ if (persistedDiagnostics.source === "file") {
330
+ const deletedKeys = configRebaseDeletionKeys(config);
331
+ const provenanceExists = configHasRebaseProvenance(config);
332
+ // Only keys this live config is actually known to have diverged on may be
333
+ // rebased. The baseline is captured once when the server arms it, so any key
334
+ // that appeared on disk afterwards — through saveConfig(), a hand edit, or
335
+ // another process — is absent from the baseline as well as from the live
336
+ // config. Reconciling those keys reads "live never changed this" and adopts
337
+ // the disk value, which resurrects a field the live writer had deliberately
338
+ // deleted (#1462 regression: PUT /api/grok/selection with an empty list).
339
+ // Restrict the merge to keys the baseline knew about, plus keys the live
340
+ // config still carries; a key that exists only on disk is left to the
341
+ // ordinary whole-config write below.
342
+ const rebaseableKeys = new Set([
343
+ ...Object.keys(baseline as unknown as Record<string, unknown>),
344
+ ...Object.keys(config as unknown as Record<string, unknown>),
345
+ ...(provenanceExists
346
+ ? Object.keys(persistedDiagnostics.config as unknown as Record<string, unknown>)
347
+ : []),
348
+ ]);
349
+ const skipped = new Set(["hostname", "port", "claudeCode", CONFIG_REBASE_PROVENANCE_KEY]);
350
+ for (const key of Object.keys(persistedDiagnostics.config as unknown as Record<string, unknown>)) {
351
+ if (!rebaseableKeys.has(key)) skipped.add(key);
352
+ }
353
+ reconcileConfigRecord(
354
+ config as unknown as Record<string, unknown>,
355
+ baseline as unknown as Record<string, unknown>,
356
+ persistedDiagnostics.config as unknown as Record<string, unknown>,
357
+ skipped,
358
+ );
359
+ for (const key of deletedKeys) delete (config as unknown as Record<string, unknown>)[key];
360
+ }
361
+ }
362
+ if (claudeCodeBaseline.has(config)) {
363
+ if (onDisk !== undefined) {
364
+ const baseline = claudeCodeBaseline.get(config);
365
+ const persistedClaudeCode = normalizePersistedClaudeCode(onDisk.claudeCode);
366
+ const diskChanged = !deepEqual(persistedClaudeCode, baseline);
367
+ const weChanged = !deepEqual(config.claudeCode, baseline);
368
+ if (diskChanged && !weChanged) {
369
+ config.claudeCode = persistedClaudeCode;
370
+ }
371
+ }
372
+ }
373
+ const provenanceProjection = projectConfigRebaseProvenance(config);
374
+ const projectedConfig = projectCustomModelCatalogMigration(
375
+ onDisk,
376
+ config,
377
+ );
378
+ if (provenanceProjection.configRebaseProvenance === undefined) delete projectedConfig.configRebaseProvenance;
379
+ else projectedConfig.configRebaseProvenance = provenanceProjection.configRebaseProvenance;
380
+ const persistedBinding = bindingBaseline && onDisk
381
+ ? readPersistedServerBinding(onDisk, bindingBaseline)
382
+ : bindingBaseline;
383
+ if (persistedBinding) {
384
+ const persistedConfig: OcxConfig = { ...projectedConfig, port: persistedBinding.port };
385
+ if (persistedBinding.hostname === undefined) delete persistedConfig.hostname;
386
+ else persistedConfig.hostname = persistedBinding.hostname;
387
+ if (persistConfigUnlocked(persistedConfig)) bumpGenerationForCooperatingConfigWrite();
388
+ persistedLiveServerBinding.set(config, persistedBinding);
389
+ } else {
390
+ if (persistConfigUnlocked(projectedConfig)) bumpGenerationForCooperatingConfigWrite();
391
+ }
392
+ adoptCustomModelCatalogMigration(config, projectedConfig);
393
+ if (claudeCodeBaseline.has(config)) {
394
+ claudeCodeBaseline.set(config, structuredClone(config.claudeCode));
395
+ }
396
+ if (liveConfigBaseline.has(config)) {
397
+ if (projectedConfig.configRebaseProvenance === undefined) delete config.configRebaseProvenance;
398
+ else config.configRebaseProvenance = structuredClone(projectedConfig.configRebaseProvenance);
399
+ liveConfigBaseline.set(config, structuredClone(projectedConfig));
400
+ }
401
+ clearPendingConfigTopLevelDeletions(config);
402
+ });
403
+ }