@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.
- package/gui/dist/assets/{App-KLz1TAXi.css → App-ia4U0ufY.css} +1 -1
- package/gui/dist/assets/{App-B00Yq11u.js → App-xm0zGNOe.js} +3 -3
- package/gui/dist/assets/{Tray-D3Ij1Cvf.js → Tray-CGAD2AGk.js} +1 -1
- package/gui/dist/assets/{index-BYQQfBGK.js → index-D_RGAT6Q.js} +2 -2
- package/gui/dist/assets/{quota-summary-yPtcEhGI.js → quota-summary-CUFq48t-.js} +1 -1
- package/gui/dist/index.html +1 -1
- package/package.json +1 -1
- package/src/adapters/devin/cloud-direct/chat.ts +38 -6
- package/src/adapters/devin/live-models.ts +28 -8
- package/src/adapters/devin.ts +1 -1
- package/src/claude/devin-output-order.ts +98 -0
- package/src/codex/auth-api/pool-quota-probe.ts +2 -1
- package/src/codex/catalog/retained-sync.ts +9 -0
- package/src/codex/inject/config-toml.ts +179 -248
- package/src/codex/inject/provider-table.ts +5 -2
- package/src/codex/inject/remove.ts +23 -8
- package/src/codex/injected-marker.ts +28 -23
- package/src/codex/quota-auto-refresh.ts +1 -1
- package/src/codex/quota.ts +12 -4
- package/src/codex/toml-source-lines.ts +93 -1
- package/src/config/live-reconcile.ts +18 -0
- package/src/config/rebase-provenance.ts +12 -0
- package/src/config.ts +4 -3
- package/src/generated/compatibility-version.json +50 -38
- package/src/images/loop.ts +2 -0
- package/src/integrations/cursor-local-installer.ts +29 -2
- package/src/lib/redact.ts +54 -8
- package/src/oauth/anthropic-account-refusal.ts +76 -0
- package/src/oauth/anthropic-routing.ts +38 -10
- package/src/providers/new-model-policy-runtime.ts +83 -0
- package/src/providers/new-model-policy.ts +25 -0
- package/src/providers/registry/entries-core.ts +3 -0
- package/src/providers/registry/model-seeds.ts +31 -2
- package/src/providers/xai-transport.ts +2 -2
- package/src/server/claude-messages.ts +33 -10
- package/src/server/management/agent-settings-routes.ts +3 -1
- package/src/server/management/model-rows.ts +34 -4
- package/src/server/management/shared.ts +18 -2
- package/src/server/responses/adapter-continuation.ts +13 -21
- package/src/server/responses/adapter-delivery.ts +2 -2
- package/src/server/responses/adapter-dispatch.ts +10 -21
- package/src/server/responses/compact.ts +3 -2
- package/src/server/responses/core-codex-account.ts +4 -2
- package/src/server/responses/passthrough-delivery.ts +2 -1
- package/src/server/responses/request-transport.ts +2 -0
- package/src/server/responses/run-turn-execution.ts +25 -5
- package/src/server/responses/sidecar-execution.ts +9 -14
- 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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
.
|
|
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
|
|
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
|
-
|
|
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
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
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
|
|
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
|
|
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
|
|
62
|
-
const
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
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
|
|
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
|
-
|
|
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]!.
|
|
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))
|
|
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
|
|
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 (
|
|
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) {
|
package/src/codex/quota.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|