@bitkyc08/opencodex 2.74.0 → 2.75.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 (48) hide show
  1. package/gui/dist/assets/{App-KLz1TAXi.css → App-ia4U0ufY.css} +1 -1
  2. package/gui/dist/assets/{App-B00Yq11u.js → App-xm0zGNOe.js} +3 -3
  3. package/gui/dist/assets/{Tray-D3Ij1Cvf.js → Tray-CGAD2AGk.js} +1 -1
  4. package/gui/dist/assets/{index-BYQQfBGK.js → index-D_RGAT6Q.js} +2 -2
  5. package/gui/dist/assets/{quota-summary-yPtcEhGI.js → quota-summary-CUFq48t-.js} +1 -1
  6. package/gui/dist/index.html +1 -1
  7. package/package.json +1 -1
  8. package/src/adapters/devin/cloud-direct/chat.ts +38 -6
  9. package/src/adapters/devin/live-models.ts +28 -8
  10. package/src/adapters/devin.ts +1 -1
  11. package/src/claude/devin-output-order.ts +98 -0
  12. package/src/codex/auth-api/pool-quota-probe.ts +2 -1
  13. package/src/codex/catalog/retained-sync.ts +9 -0
  14. package/src/codex/inject/config-toml.ts +179 -248
  15. package/src/codex/inject/provider-table.ts +5 -2
  16. package/src/codex/inject/remove.ts +23 -8
  17. package/src/codex/injected-marker.ts +28 -23
  18. package/src/codex/quota-auto-refresh.ts +1 -1
  19. package/src/codex/quota.ts +12 -4
  20. package/src/codex/toml-source-lines.ts +93 -1
  21. package/src/config/live-reconcile.ts +18 -0
  22. package/src/config/rebase-provenance.ts +12 -0
  23. package/src/config.ts +4 -3
  24. package/src/generated/compatibility-version.json +50 -38
  25. package/src/images/loop.ts +2 -0
  26. package/src/integrations/cursor-local-installer.ts +29 -2
  27. package/src/lib/redact.ts +54 -8
  28. package/src/oauth/anthropic-account-refusal.ts +76 -0
  29. package/src/oauth/anthropic-routing.ts +38 -10
  30. package/src/providers/new-model-policy-runtime.ts +83 -0
  31. package/src/providers/new-model-policy.ts +25 -0
  32. package/src/providers/registry/entries-core.ts +3 -0
  33. package/src/providers/registry/model-seeds.ts +31 -2
  34. package/src/providers/xai-transport.ts +2 -2
  35. package/src/server/claude-messages.ts +33 -10
  36. package/src/server/management/agent-settings-routes.ts +3 -1
  37. package/src/server/management/model-rows.ts +34 -4
  38. package/src/server/management/shared.ts +18 -2
  39. package/src/server/responses/adapter-continuation.ts +13 -21
  40. package/src/server/responses/adapter-delivery.ts +2 -2
  41. package/src/server/responses/adapter-dispatch.ts +10 -21
  42. package/src/server/responses/compact.ts +3 -2
  43. package/src/server/responses/core-codex-account.ts +4 -2
  44. package/src/server/responses/passthrough-delivery.ts +2 -1
  45. package/src/server/responses/request-transport.ts +2 -0
  46. package/src/server/responses/run-turn-execution.ts +25 -5
  47. package/src/server/responses/sidecar-execution.ts +9 -14
  48. package/src/web-search/loop.ts +7 -2
@@ -1,5 +1,5 @@
1
1
  import { isDeepStrictEqual } from "node:util";
2
- import { OCX_SECTION_MARKER } from "../injected-marker";
2
+ import { OCX_ROUTING_MARKER_LINE, OCX_SECTION_MARKER } from "../injected-marker";
3
3
  import { splitSourceLines, type SourceLine } from "../toml-source-lines";
4
4
 
5
5
  function record(value: unknown): Record<string, unknown> | null {
@@ -61,7 +61,10 @@ function providerLines(content: string): { bom: string; lines: SourceLine[]; own
61
61
  section = headerKind(text);
62
62
  if (section === "provider") {
63
63
  const previous = lines[index - 1];
64
- if (previous?.structural && previous.text.trim() === OCX_SECTION_MARKER) owned.add(index - 1);
64
+ const marker = previous?.text.trim();
65
+ if (previous?.structural && (marker === OCX_SECTION_MARKER || marker === OCX_ROUTING_MARKER_LINE)) {
66
+ owned.add(index - 1);
67
+ }
65
68
  }
66
69
  } else if (line.structural && unsupportedProviderAssignment(text, section)) {
67
70
  // These forms have no independently removable table span. Do not append a
@@ -1,4 +1,5 @@
1
1
  import { existsSync, readFileSync, unlinkSync } from "node:fs";
2
+ import { normalizeStructuralWhitespace, rootAssignmentKey, rootSourceLines, sourceAssignment, sourceText } from "../toml-source-lines";
2
3
  import { atomicWriteFile } from "../../config";
3
4
  import {
4
5
  REALTIME_WS_BASE_URL_KEY,
@@ -13,7 +14,7 @@ import {
13
14
  journaledInjectedRootWebSearch,
14
15
  journaledReplacedRootWebSearch,
15
16
  } from "../journal";
16
- import { CODEX_CONFIG_PATH, CODEX_PROFILE_PATH, readRootTomlString } from "../paths";
17
+ import { CODEX_CONFIG_PATH, CODEX_PROFILE_PATH } from "../paths";
17
18
  import { transformManagedSubagentDefaults } from "../subagent-defaults";
18
19
  import {
19
20
  applyEol,
@@ -59,6 +60,15 @@ interface StripOpencodexConfigResult {
59
60
  managedDefaultsError: string | null;
60
61
  }
61
62
 
63
+ function hasRootOcxProvider(content: string): boolean {
64
+ const { lines, rootEnd } = rootSourceLines(content);
65
+ return lines.slice(0, rootEnd).some((line, index) => {
66
+ if (!line.structural || rootAssignmentKey(line.text) !== "model_provider") return false;
67
+ const value = sourceAssignment(lines, index, rootEnd)?.value;
68
+ return typeof value === "string" && value.trim() === "opencodex";
69
+ });
70
+ }
71
+
62
72
  /**
63
73
  * Detailed form used by the on-disk restore path. A damaged ownership marker is
64
74
  * ambiguous: keep the associated value, but return the transform error so the
@@ -73,7 +83,7 @@ function stripOpencodexConfigResult(
73
83
  ): StripOpencodexConfigResult {
74
84
  let out = content;
75
85
  const hadRootOcxProvider =
76
- readRootTomlString(out, "model_provider") === "opencodex";
86
+ hasRootOcxProvider(out);
77
87
  // #1798: marker adjacency is FORMATTING evidence, and a Codex app rewrite keeps values
78
88
  // while dropping comments. Fall back to VALUE evidence -- the exact URL we recorded
79
89
  // writing -- so an app-rewritten config is still recognized as ours.
@@ -94,10 +104,15 @@ function stripOpencodexConfigResult(
94
104
  out = removeProfileSection(out);
95
105
  // Regex (not exact-string) removal so compact `model_provider="opencodex"` is stripped too —
96
106
  // must match the detection regex above, or a detected line could survive un-removed.
97
- out = out
98
- .split("\n")
99
- .filter((l) => !/^\s*model_provider\s*=\s*"opencodex"\s*$/.test(l))
100
- .join("\n");
107
+ const routingSource = rootSourceLines(out);
108
+ const providerLines = new Set<number>();
109
+ routingSource.lines.forEach((line, index) => {
110
+ if (!line.structural || rootAssignmentKey(line.text) !== "model_provider") return;
111
+ const assignment = sourceAssignment(routingSource.lines, index);
112
+ if (!assignment || typeof assignment.value !== "string" || assignment.value.trim() !== "opencodex") return;
113
+ for (let at = index; at < assignment.end; at++) providerLines.add(at);
114
+ });
115
+ out = routingSource.bom + sourceText(routingSource.lines.filter((_, index) => !providerLines.has(index)));
101
116
  // Routed root model ids (`model = "provider/slug"`) only make sense while the proxy serves
102
117
  // them — strip on both the legacy re-tag form and the Design B injected-base-url form.
103
118
  if (hadRootOcxProvider || hadInjectedBaseUrl) out = stripRootRoutedModel(out);
@@ -105,7 +120,7 @@ function stripOpencodexConfigResult(
105
120
  if (managedDefaults.ok) out = managedDefaults.content;
106
121
  out = stripOpencodexCatalogPath(out);
107
122
  return {
108
- content: out.replace(/\n{3,}/g, "\n\n").trimEnd() + "\n",
123
+ content: normalizeStructuralWhitespace(out),
109
124
  managedDefaultsError: !managedDefaults.ok ? managedDefaults.error : null,
110
125
  };
111
126
  }
@@ -118,7 +133,7 @@ export function stripOpencodexConfig(content: string): string {
118
133
  function hasOpencodexRouting(content: string): boolean {
119
134
  return (
120
135
  hasOcxProviderTable(content) ||
121
- /^\s*model_provider\s*=\s*"opencodex"/m.test(content) ||
136
+ hasRootOcxProvider(content) ||
122
137
  hasInjectedOpenaiBaseUrl(content)
123
138
  );
124
139
  }
@@ -8,6 +8,7 @@
8
8
  * two public predicates, so external callers see no change.
9
9
  */
10
10
  import { parseTomlString } from "./paths";
11
+ import { rootAssignmentKey, rootSourceLines, sourceText } from "./toml-source-lines";
11
12
 
12
13
  export const OCX_SECTION_MARKER = "# Auto-injected by opencodex";
13
14
 
@@ -20,17 +21,22 @@ export const OCX_SECTION_MARKER = "# Auto-injected by opencodex";
20
21
  * and it said nothing but "Auto-injected by opencodex" — so the recovery they found was to
21
22
  * hand-delete lines and the catalog file, which is strictly worse than `ocx restore`.
22
23
  *
23
- * Every ownership predicate below matches on {@link OCX_SECTION_MARKER} as a SUBSTRING, never by
24
- * equality, so this longer line is recognized by them and by any opencodex old enough to predate
25
- * it. That is the whole reason the hint is appended to the marker instead of occupying a line of
26
- * its own: a separate comment line would survive removal as orphaned text.
24
+ * Root routing predicates and lossless provider table capture/removal accept either exact marker
25
+ * line directly above structural routing syntax; they do not claim user comments or opaque value
26
+ * lines that merely contain the marker. Keeping the hint on the same
27
+ * line lets those transforms remove it together with the routing they own.
27
28
  *
28
29
  * Scope is routing only. Prompt layers keep the bare marker: `ocx restore` is not their undo.
29
30
  */
30
31
  export const OCX_ROUTING_MARKER_LINE = `${OCX_SECTION_MARKER} (undo: ocx restore)`;
31
32
 
33
+ export function isOcxRoutingMarkerLine(line: string): boolean {
34
+ const text = line.trim();
35
+ return text === OCX_SECTION_MARKER || text === OCX_ROUTING_MARKER_LINE;
36
+ }
37
+
32
38
  export function isRootOpenaiBaseUrlLine(line: string): boolean {
33
- return /^\s*openai_base_url\s*=/.test(line);
39
+ return rootAssignmentKey(line) === "openai_base_url";
34
40
  }
35
41
 
36
42
  /**
@@ -44,7 +50,7 @@ export function isRootOpenaiBaseUrlLine(line: string): boolean {
44
50
  export const REALTIME_WS_BASE_URL_KEY = "experimental_realtime_ws_base_url";
45
51
 
46
52
  export function isRootRealtimeWsBaseUrlLine(line: string): boolean {
47
- return /^\s*experimental_realtime_ws_base_url\s*=/.test(line);
53
+ return rootAssignmentKey(line) === REALTIME_WS_BASE_URL_KEY;
48
54
  }
49
55
 
50
56
  export function tomlStringPattern(key: string): RegExp {
@@ -58,13 +64,13 @@ export function tomlStringPattern(key: string): RegExp {
58
64
  }
59
65
 
60
66
  export function rootTomlString(content: string, key: string): string | null {
61
- const lines = content.split("\n");
62
- const firstTable = lines.findIndex(line => /^\s*\[/.test(line));
63
- const rootLines = lines.slice(0, firstTable === -1 ? lines.length : firstTable);
64
- const pattern = tomlStringPattern(key);
65
- for (const line of rootLines) {
66
- const match = pattern.exec(line);
67
- if (match?.[1]) return parseTomlString(match[1]).trim();
67
+ const { lines, rootEnd } = rootSourceLines(content);
68
+ for (const line of lines.slice(0, rootEnd)) {
69
+ if (!line.structural || rootAssignmentKey(line.text) !== key) continue;
70
+ try {
71
+ const value = (Bun.TOML.parse(line.text) as Record<string, unknown>)[key];
72
+ if (typeof value === "string") return value.trim();
73
+ } catch { /* Incomplete multiline assignments are not single-line ownership evidence. */ }
68
74
  }
69
75
  return null;
70
76
  }
@@ -103,12 +109,11 @@ export function stripJournaledOpenaiBaseUrl(
103
109
  injectedRealtimeWsUrl: string | null = null,
104
110
  ): string {
105
111
  if (!injectedUrl && !injectedRealtimeWsUrl) return content;
106
- const lines = content.split(String.fromCharCode(10));
107
- const firstTable = lines.findIndex(l => /^\s*\[/.test(l));
108
- const rootEnd = firstTable === -1 ? lines.length : firstTable;
112
+ const { bom, lines, rootEnd } = rootSourceLines(content);
109
113
  const drop = new Set<number>();
110
114
  for (let i = 0; i < rootEnd; i++) {
111
- const line = lines[i]!;
115
+ if (!lines[i]!.structural) continue;
116
+ const line = lines[i]!.text;
112
117
  // Each key is matched against ITS OWN recorded value. The realtime override is
113
118
  // journaled separately so a user-owned override that happens to equal the proxy
114
119
  // URL is never mistaken for ours.
@@ -122,18 +127,18 @@ export function stripJournaledOpenaiBaseUrl(
122
127
  drop.add(i);
123
128
  // Take an ownership marker directly above it too, so repeated cycles cannot
124
129
  // accumulate orphaned comments.
125
- if (i > 0 && lines[i - 1]!.includes(OCX_SECTION_MARKER)) drop.add(i - 1);
130
+ if (i > 0 && lines[i - 1]!.structural && isOcxRoutingMarkerLine(lines[i - 1]!.text)) drop.add(i - 1);
126
131
  }
127
132
  if (drop.size === 0) return content;
128
- return lines.filter((_, i) => !drop.has(i)).join(String.fromCharCode(10));
133
+ return bom + sourceText(lines.filter((_, i) => !drop.has(i)));
129
134
  }
130
135
 
131
136
  export function hasInjectedOpenaiBaseUrl(content: string): boolean {
132
- const lines = content.split("\n");
133
- const firstTable = lines.findIndex(l => /^\s*\[/.test(l));
134
- const rootEnd = firstTable === -1 ? lines.length : firstTable;
137
+ const { lines, rootEnd } = rootSourceLines(content);
135
138
  for (let i = 1; i < rootEnd; i++) {
136
- if (isRootOpenaiBaseUrlLine(lines[i]!) && lines[i - 1]!.includes(OCX_SECTION_MARKER)) return true;
139
+ if (lines[i]!.structural && lines[i - 1]!.structural
140
+ && isRootOpenaiBaseUrlLine(lines[i]!.text) && rootTomlString(lines[i]!.text, "openai_base_url") !== null
141
+ && isOcxRoutingMarkerLine(lines[i - 1]!.text)) return true;
137
142
  }
138
143
  return false;
139
144
  }
@@ -226,7 +226,7 @@ async function warmAccount(config: OcxConfig, accountId: string): Promise<void |
226
226
  try {
227
227
  await warmCodexAccount({ ...token, onCompleted: headers => {
228
228
  if (isCodexAccountGenerationLive(accountId, token.generation)) {
229
- applyAccountQuotaFromUpstreamHeaders(accountId, headers, writerGeneration, undefined, { poolWriter });
229
+ applyAccountQuotaFromUpstreamHeaders(accountId, headers, writerGeneration, undefined, { poolWriter, poolResponse: true });
230
230
  }
231
231
  } });
232
232
  } catch (error) {
@@ -288,6 +288,10 @@ export function setAccountQuotaFromParsed(
288
288
  mainWriter?: MainQuotaWriter,
289
289
  policyQuota: MainPolicyQuotaObservation | null = accountId === MAIN_CODEX_ACCOUNT_ID ? quota : null,
290
290
  historyEvidence?: QuotaObservationEvidence,
291
+ // Pool responses carry their credential provenance separately from the optional history
292
+ // writer: when capture failed before dispatch, an absent writer must fail closed instead
293
+ // of reading as a writer-free legacy/login observation.
294
+ poolRequest = false,
291
295
  ): void {
292
296
  quota = withoutRetiredCodexQuota(quota);
293
297
  policyQuota = withoutRetiredCodexQuota(policyQuota);
@@ -298,7 +302,10 @@ export function setAccountQuotaFromParsed(
298
302
  hydrateAccountQuotasFromDisk();
299
303
  const legacyExisting = accountQuota.get(accountId);
300
304
  const updatedAt = Date.now();
301
- if (historyEvidence && historyEvidence.writer.accountId === accountId && isPoolQuotaWriterLive(historyEvidence.writer)) {
305
+ const livePoolEvidence = historyEvidence
306
+ ? historyEvidence.writer.accountId === accountId && isPoolQuotaWriterLive(historyEvidence.writer)
307
+ : !poolRequest;
308
+ if (historyEvidence && livePoolEvidence) {
302
309
  quotaHistory.append(historyEvidence.writer, { observedAt: historyEvidence.observedAt, source: historyEvidence.source,
303
310
  credentialGeneration: historyEvidence.writer.credentialGeneration, windows: historyWindows(historyEvidence.raw),
304
311
  }, updatedAt);
@@ -323,7 +330,7 @@ export function setAccountQuotaFromParsed(
323
330
  schedulePersistAccountQuotas();
324
331
  // Credits carry the previous usage tuple; they must not refresh its observation clock.
325
332
  if (!(quota.resetCredits !== undefined && !snapshotHasUsage(quota))) {
326
- if (!isMain && policyQuota) observeCodexLowQuota(accountId, policyQuota);
333
+ if (!isMain && policyQuota && livePoolEvidence) observeCodexLowQuota(accountId, policyQuota);
327
334
  notifyCodexQuotaSnapshot(accountId, next);
328
335
  }
329
336
  }
@@ -581,7 +588,7 @@ export function applyAccountQuotaFromUpstreamHeaders(
581
588
  headers: Headers,
582
589
  writerGeneration = captureConfigGeneration(),
583
590
  mainWriter?: MainQuotaWriter,
584
- options?: { modelId?: string; poolWriter?: PoolQuotaWriter },
591
+ options?: { modelId?: string; poolWriter?: PoolQuotaWriter; poolResponse?: boolean },
585
592
  ): void {
586
593
  const quota = parseUpstreamQuotaHeaders(headers, options);
587
594
  if (!quota) return;
@@ -591,7 +598,8 @@ export function applyAccountQuotaFromUpstreamHeaders(
591
598
  const validHistory = !["x-codex-primary-used-percent", "x-codex-secondary-used-percent", "x-codex-tertiary-used-percent"]
592
599
  .some(name => isInvalidPolicyUsagePercent(headers.get(name)));
593
600
  setAccountQuotaFromParsed(accountId, quota, writerGeneration, mainWriter, policyQuota,
594
- options?.poolWriter && validHistory ? { writer: options.poolWriter, observedAt: Date.now(), source: "response-header", raw: quota } : undefined);
601
+ options?.poolWriter && validHistory ? { writer: options.poolWriter, observedAt: Date.now(), source: "response-header", raw: quota } : undefined,
602
+ options?.poolResponse === true);
595
603
  }
596
604
 
597
605
  export function updateAccountQuota(
@@ -2,7 +2,7 @@
2
2
  export interface SourceLine {
3
3
  text: string;
4
4
  eol: "\r\n" | "\n" | "";
5
- /** False when this physical line began inside a TOML multiline string. */
5
+ /** False when this physical line began inside a multiline string or collection. */
6
6
  structural: boolean;
7
7
  }
8
8
 
@@ -28,6 +28,98 @@ export function splitSourceLines(content: string): SourceLine[] {
28
28
  return lines;
29
29
  }
30
30
 
31
+ /** Root boundaries exclude headers and assignments that are merely multiline/array data. */
32
+ export function rootSourceLines(content: string): { bom: string; lines: SourceLine[]; rootEnd: number } {
33
+ const bom = content.startsWith("\uFEFF") ? "\uFEFF" : "";
34
+ const lines = splitSourceLines(content.slice(bom.length));
35
+ const firstTable = lines.findIndex(line => line.structural && /^\s*\[/.test(line.text));
36
+ return { bom, lines, rootEnd: firstTable === -1 ? lines.length : firstTable };
37
+ }
38
+
39
+ export function sourceText(lines: readonly SourceLine[]): string {
40
+ return lines.map(line => line.text + line.eol).join("");
41
+ }
42
+
43
+ const assignmentKeyToken = /^[ \t]*([A-Za-z0-9_-]+|"(?:[^"\\]|\\.)*"|'[^']*')[ \t]*=/;
44
+
45
+ /** Decode only a single key token, without parsing unrelated values or dotted paths. */
46
+ export function rootAssignmentKey(text: string): string | null {
47
+ const match = assignmentKeyToken.exec(text);
48
+ if (!match) return null;
49
+ try {
50
+ return Object.keys(Bun.TOML.parse(`${match[1]} = true\n`))[0] ?? null;
51
+ } catch {
52
+ return null;
53
+ }
54
+ }
55
+
56
+ interface AssignmentSpan { key: string; start: number; end: number; text: string }
57
+
58
+ function lexicalAssignmentSpan(lines: readonly SourceLine[], start: number, limit: number): AssignmentSpan | null {
59
+ const line = lines[start];
60
+ if (!line?.structural) return null;
61
+ const key = rootAssignmentKey(line.text);
62
+ if (key === null) return null;
63
+ let end = start + 1;
64
+ while (end < limit && !lines[end]!.structural) end += 1;
65
+ // Store the original assignment bytes, excluding only its final document separator.
66
+ const text = sourceText(lines.slice(start, end)).replace(/\r?\n$/, "");
67
+ return { key, start, end, text };
68
+ }
69
+
70
+ /** A complete assignment whose value can be decoded without losing precision. */
71
+ export function sourceAssignment(lines: readonly SourceLine[], start: number, limit = lines.length):
72
+ (AssignmentSpan & { value: unknown }) | null {
73
+ const span = lexicalAssignmentSpan(lines, start, limit);
74
+ if (!span) return null;
75
+ try {
76
+ const parsed = Bun.TOML.parse(span.text) as Record<string, unknown>;
77
+ return Object.keys(parsed).length === 1 && Object.hasOwn(parsed, span.key)
78
+ ? { ...span, value: parsed[span.key] } : null;
79
+ } catch { return null; }
80
+ }
81
+
82
+ /** Validate complete syntax for removal/capture without requiring a JavaScript value. */
83
+ export function sourceAssignmentSpan(lines: readonly SourceLine[], start: number, limit = lines.length): AssignmentSpan | null {
84
+ const decoded = sourceAssignment(lines, start, limit);
85
+ if (decoded) return decoded;
86
+ const span = lexicalAssignmentSpan(lines, start, limit);
87
+ // Bun rejects valid TOML integers outside the JS safe-integer range. Only a complete
88
+ // scalar integer gets this exception: collections, strings and other parse failures do not.
89
+ if (!span || span.end !== start + 1) return null;
90
+ const keyToken = assignmentKeyToken.exec(span.text)!;
91
+ const scalar = /^([ \t]*)([+-]?(?:0|[1-9](?:_?[0-9])*)|0x[0-9A-Fa-f](?:_?[0-9A-Fa-f])*|0o[0-7](?:_?[0-7])*|0b[01](?:_?[01])*)([ \t]*(?:#[^\r\n]*)?)$/.exec(span.text.slice(keyToken[0].length));
92
+ if (!scalar) return null;
93
+ const integer = BigInt(scalar[2]!.replaceAll("_", ""));
94
+ if (integer < -(1n << 63n) || integer >= (1n << 63n)) return null;
95
+ // Keep the parser responsible for the key, separator and comment grammar. Replacing
96
+ // this already validated integer for validation never edits or decodes the source value.
97
+ try {
98
+ const parsed = Bun.TOML.parse(`${keyToken[0]}${scalar[1]}0${scalar[3]}`);
99
+ return Object.keys(parsed).length === 1 && Object.hasOwn(parsed, span.key) ? span : null;
100
+ } catch { return null; }
101
+ }
102
+
103
+ /** Cosmetic document whitespace only; opaque value lines retain every byte. */
104
+ export function normalizeStructuralWhitespace(content: string): string {
105
+ const { bom, lines } = rootSourceLines(content);
106
+ const out: SourceLine[] = [];
107
+ let blankRun = 0;
108
+ for (const line of lines) {
109
+ if (line.structural && line.text === "") {
110
+ blankRun += 1;
111
+ if (blankRun > 1) continue;
112
+ } else {
113
+ blankRun = 0;
114
+ }
115
+ out.push(line);
116
+ }
117
+ while (out.at(-1)?.structural && out.at(-1)!.text.trim() === "") out.pop();
118
+ const text = bom + sourceText(out);
119
+ const eol = lines.find(line => line.eol)?.eol || "\n";
120
+ return text.endsWith("\n") ? text : text + eol;
121
+ }
122
+
31
123
  type MultilineStringKind = "basic" | "literal" | null;
32
124
 
33
125
  /**
@@ -41,6 +41,24 @@ const claudeCodeBaseline = new WeakMap<OcxConfig, unknown>();
41
41
  * reconciliation paths below.
42
42
  */
43
43
  const liveConfigBaseline = new WeakMap<OcxConfig, OcxConfig>();
44
+
45
+ /**
46
+ * Adopt a committed discovery decision and its merge baseline as one synchronous step.
47
+ * Visibility writers assign and save synchronously; any future writer yielding between
48
+ * those steps must preserve pending edits through a three-way merge here.
49
+ */
50
+ export function adoptPersistedModelDiscovery(
51
+ config: OcxConfig,
52
+ persisted: Pick<OcxConfig, "modelDiscovery" | "disabledModels">,
53
+ ): void {
54
+ config.modelDiscovery = structuredClone(persisted.modelDiscovery);
55
+ config.disabledModels = structuredClone(persisted.disabledModels);
56
+ const baseline = liveConfigBaseline.get(config);
57
+ if (baseline) {
58
+ baseline.modelDiscovery = structuredClone(persisted.modelDiscovery);
59
+ baseline.disabledModels = structuredClone(persisted.disabledModels);
60
+ }
61
+ }
44
62
  /**
45
63
  * The live config retains the address of the socket Bun actually opened, while
46
64
  * this map retains the operator's desired address for the next process start.
@@ -1,5 +1,17 @@
1
1
  import type { OcxConfig } from "../types";
2
2
 
3
+ // Object provenance survives disk drift without adding serializable config fields.
4
+ const fileBackedConfigs = new WeakMap<OcxConfig, string>();
5
+
6
+ export function recordFileBackedConfig(config: OcxConfig, path: string): OcxConfig {
7
+ fileBackedConfigs.set(config, path);
8
+ return config;
9
+ }
10
+
11
+ export function fileBackedConfigPath(config: OcxConfig): string | undefined {
12
+ return fileBackedConfigs.get(config);
13
+ }
14
+
3
15
  const pendingTopLevelDeletions = new WeakMap<OcxConfig, Set<string>>();
4
16
  const pendingObjectChildDeletions = new WeakMap<OcxConfig, Map<string, Set<string>>>();
5
17
  export const CONFIG_REBASE_PROVENANCE_KEY = "configRebaseProvenance";
package/src/config.ts CHANGED
@@ -11,6 +11,7 @@ import {
11
11
  import { refreshConfigDerivedRegistries } from "./config/derived-registries";
12
12
  import {
13
13
  clearPendingConfigDeletions,
14
+ recordFileBackedConfig,
14
15
  projectConfigRebaseProvenance,
15
16
  } from "./config/rebase-provenance";
16
17
  import { getConfigDir, getConfigPath, hardenConfigDir } from "./config/paths";
@@ -249,7 +250,7 @@ export function loadConfig(): OcxConfig {
249
250
  warnDegradedCatalogAutoRefresh(parsed);
250
251
  warnDegradedCodexPool(parsed);
251
252
  warnDegradedCredentialGroups(parsed);
252
- return withRefreshedCostOverlays(normalizeClaudeSubagentEffort(normalizeNativeSubagentSync(config, parsed), parsed));
253
+ return recordFileBackedConfig(withRefreshedCostOverlays(normalizeClaudeSubagentEffort(normalizeNativeSubagentSync(config, parsed), parsed)), configPath);
253
254
  }
254
255
  // Only object-shaped configs are repairable. Spreading another JSON value
255
256
  // into defaults can manufacture a valid config and bypass the invalid-file
@@ -285,7 +286,7 @@ export function loadConfig(): OcxConfig {
285
286
  warnDegradedCatalogAutoRefresh(parsed);
286
287
  warnDegradedCodexPool(parsed);
287
288
  warnDegradedCredentialGroups(parsed);
288
- return withRefreshedCostOverlays(normalizeClaudeSubagentEffort(normalizeNativeSubagentSync(config, parsed), parsed));
289
+ return recordFileBackedConfig(withRefreshedCostOverlays(normalizeClaudeSubagentEffort(normalizeNativeSubagentSync(config, parsed), parsed)), configPath);
289
290
  }
290
291
  // Still failing, but if every complaint is about one or more named entries
291
292
  // in an independent section, drop exactly those and keep the rest. Falling
@@ -315,7 +316,7 @@ export function loadConfig(): OcxConfig {
315
316
  warnDegradedCatalogAutoRefresh(parsed);
316
317
  warnDegradedCodexPool(parsed);
317
318
  warnDegradedCredentialGroups(parsed);
318
- return withRefreshedCostOverlays(normalizeClaudeSubagentEffort(normalizeNativeSubagentSync(config, parsed), parsed));
319
+ return recordFileBackedConfig(withRefreshedCostOverlays(normalizeClaudeSubagentEffort(normalizeNativeSubagentSync(config, parsed), parsed)), configPath);
319
320
  }
320
321
  }
321
322
  // Merge couldn't fix it — truly broken config