@bitkyc08/opencodex 2.61.0 → 2.63.0-preview.20260923

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 (80) hide show
  1. package/gui/dist/assets/App-EJxiUFMq.js +50 -0
  2. package/gui/dist/assets/{Tray-CLZh48fM.js → Tray-B03pW-Uf.js} +1 -1
  3. package/gui/dist/assets/index-BmJwNBHL.js +86 -0
  4. package/gui/dist/assets/index-DdDunwDb.css +1 -0
  5. package/gui/dist/assets/{usage-companion-chart-a0N58rRI.js → usage-companion-chart-IftE60UK.js} +1 -1
  6. package/gui/dist/index.html +2 -2
  7. package/package.json +1 -1
  8. package/src/adapters/cursor/catalog.ts +15 -0
  9. package/src/adapters/cursor/effort-map.ts +7 -0
  10. package/src/adapters/cursor/envelope-echo.ts +51 -25
  11. package/src/adapters/cursor/protobuf-request.ts +39 -13
  12. package/src/adapters/cursor.ts +52 -4
  13. package/src/adapters/devin/cloud-direct/chat.ts +50 -16
  14. package/src/adapters/devin/cloud-direct/stated-reset-retry.ts +4 -1
  15. package/src/adapters/devin/live-models.ts +7 -0
  16. package/src/adapters/devin.ts +7 -5
  17. package/src/adapters/kiro/reasoning.ts +5 -0
  18. package/src/adapters/openai-responses/passthrough.ts +4 -4
  19. package/src/adapters/openai-responses/tool-output-recovery.ts +5 -3
  20. package/src/claude/desktop-gateway-state.ts +7 -2
  21. package/src/claude/desktop-policy.ts +105 -1
  22. package/src/cli/config-command.ts +22 -7
  23. package/src/cli/doctor.ts +14 -4
  24. package/src/codex/catalog/effort.ts +35 -4
  25. package/src/codex/catalog/metadata.ts +33 -5
  26. package/src/codex/catalog/native-models.ts +43 -2
  27. package/src/codex/catalog/pinned-models.ts +37 -0
  28. package/src/codex/catalog-auto-refresh.ts +6 -0
  29. package/src/codex/data/roster-pinned-models.json +359 -0
  30. package/src/codex/data/upstream-models.json +365 -271
  31. package/src/codex/inject/provider-table.ts +108 -0
  32. package/src/codex/inject/remove.ts +2 -114
  33. package/src/codex/model-entitlements.ts +14 -10
  34. package/src/codex/subagent-defaults.ts +2 -109
  35. package/src/codex/toml-source-lines.ts +112 -0
  36. package/src/config/live-reconcile.ts +145 -27
  37. package/src/config/load-degrade.ts +3 -4
  38. package/src/config.ts +2 -2
  39. package/src/generated/compatibility-version.json +83 -67
  40. package/src/generated/model-metadata.ts +5 -5
  41. package/src/lab/artifacts/sanitize.ts +60 -19
  42. package/src/lib/app-owned-memory-stores.ts +7 -0
  43. package/src/lib/app-owned-memory.ts +13 -2
  44. package/src/lib/errors.ts +6 -4
  45. package/src/lib/upstream-retry.ts +35 -5
  46. package/src/oauth/devin.ts +43 -37
  47. package/src/providers/codebuddy-models.ts +11 -0
  48. package/src/providers/kiro-models.ts +9 -0
  49. package/src/providers/quota/vendor-probes-oauth.ts +9 -2
  50. package/src/providers/registry/entries-core.ts +19 -8
  51. package/src/providers/registry/entries-extended.ts +4 -1
  52. package/src/providers/registry/model-seeds.ts +22 -2
  53. package/src/responses/bridge-search-replay-cache.ts +20 -10
  54. package/src/responses/plaintext-v2-agent-messages.ts +10 -1
  55. package/src/routing/identity-domains.ts +22 -15
  56. package/src/server/index/websocket-handler.ts +22 -2
  57. package/src/server/management/agent-settings-routes.ts +19 -10
  58. package/src/server/management/context.ts +4 -2
  59. package/src/server/responses/codex-ws-exchange.ts +24 -8
  60. package/src/server/responses/core-codex-account.ts +4 -0
  61. package/src/server/responses/core-combo-failure.ts +16 -9
  62. package/src/server/responses/core-combo.ts +7 -3
  63. package/src/server/responses/core-options.ts +6 -0
  64. package/src/server/responses/native-injection-replay.ts +13 -1
  65. package/src/server/responses/native-injection.ts +66 -7
  66. package/src/server/responses/native-response-control.ts +6 -2
  67. package/src/server/responses/native-steering-replay.ts +60 -0
  68. package/src/server/responses/native-steering.ts +9 -1
  69. package/src/server/responses/passthrough-delivery.ts +3 -4
  70. package/src/server/responses/passthrough-dispatch.ts +7 -0
  71. package/src/server/responses/request-prepare.ts +12 -0
  72. package/src/server/responses/request-transport.ts +5 -2
  73. package/src/server/responses/ws-upstream.ts +1 -1
  74. package/src/server/ws-bridge.ts +17 -1
  75. package/src/types/request.ts +5 -0
  76. package/src/usage/expected-prices.ts +38 -8
  77. package/src/web-search/executor.ts +38 -13
  78. package/gui/dist/assets/App-CH6C5H7x.js +0 -50
  79. package/gui/dist/assets/index-_bpvxJu0.css +0 -1
  80. package/gui/dist/assets/index-wpTOyepx.js +0 -86
@@ -0,0 +1,108 @@
1
+ import { isDeepStrictEqual } from "node:util";
2
+ import { OCX_SECTION_MARKER } from "../injected-marker";
3
+ import { splitSourceLines, type SourceLine } from "../toml-source-lines";
4
+
5
+ function record(value: unknown): Record<string, unknown> | null {
6
+ return value !== null && typeof value === "object" && !Array.isArray(value)
7
+ ? value as Record<string, unknown> : null;
8
+ }
9
+
10
+ function providerValue(text: string): Record<string, unknown> {
11
+ let parsed: unknown;
12
+ try { parsed = Bun.TOML.parse(text); }
13
+ catch { throw new Error("Codex provider table could not be read safely."); }
14
+ const root = record(parsed);
15
+ const providers = record(root?.model_providers);
16
+ const provider = record(providers?.opencodex);
17
+ if (!provider || Object.keys(root!).length !== 1 || Object.keys(providers!).length !== 1) {
18
+ throw new Error("Codex provider table could not be read safely.");
19
+ }
20
+ return provider;
21
+ }
22
+
23
+ const KEY = String.raw`(?:[A-Za-z0-9_-]+|"(?:[^"\\]|\\.)*"|'[^']*')`;
24
+ const ASSIGNMENT = new RegExp(`^\\s*(${KEY}(?:\\s*\\.\\s*${KEY})*)\\s*=`);
25
+
26
+ function unsupportedProviderAssignment(text: string, section: string): boolean {
27
+ if (section !== "root" && section !== "providers") return false;
28
+ const match = ASSIGNMENT.exec(text);
29
+ if (!match) return false;
30
+ // Parsing only the key path handles quoted/escaped components without reading
31
+ // unrelated large integer values or allowing strings to impersonate a header.
32
+ const parsed = record(Bun.TOML.parse(`${match[1]} = {}\n`))!;
33
+ if (section === "providers") return Object.hasOwn(parsed, "opencodex");
34
+ const providers = record(parsed.model_providers);
35
+ return providers !== null && (Object.keys(providers).length === 0 || Object.hasOwn(providers, "opencodex"));
36
+ }
37
+
38
+ /** Recognize real table headers, including quoted keys, without parsing unrelated values. */
39
+ function headerKind(text: string): "provider" | "providers" | "foreign" {
40
+ let parsed: Record<string, unknown> | null;
41
+ try { parsed = record(Bun.TOML.parse(text + "\n")); }
42
+ catch { throw new Error("Codex table header could not be read safely."); }
43
+ const providers = record(parsed?.model_providers);
44
+ if (providers && Object.hasOwn(providers, "opencodex")) {
45
+ if (!record(providers.opencodex)) throw new Error("Codex provider array tables cannot be retained safely.");
46
+ return "provider";
47
+ }
48
+ return providers && Object.keys(providers).length === 0 ? "providers" : "foreign";
49
+ }
50
+
51
+ /** Physical spans are shared by capture and removal so neither sees headers inside values. */
52
+ function providerLines(content: string): { bom: string; lines: SourceLine[]; owned: Set<number> } {
53
+ const bom = content.startsWith("\uFEFF") ? "\uFEFF" : "";
54
+ const lines = splitSourceLines(content.slice(bom.length));
55
+ const owned = new Set<number>();
56
+ let section: "root" | "provider" | "providers" | "foreign" = "root";
57
+ for (let index = 0; index < lines.length; index++) {
58
+ const line = lines[index]!;
59
+ const text = line.text;
60
+ if (line.structural && /^\s*\[/.test(text)) {
61
+ section = headerKind(text);
62
+ if (section === "provider") {
63
+ const previous = lines[index - 1];
64
+ if (previous?.structural && previous.text.trim() === OCX_SECTION_MARKER) owned.add(index - 1);
65
+ }
66
+ } else if (line.structural && unsupportedProviderAssignment(text, section)) {
67
+ // These forms have no independently removable table span. Do not append a
68
+ // competing definition or silently treat a user-owned inline value as absent.
69
+ throw new Error("Codex provider retention requires an explicit provider table.");
70
+ }
71
+ if (section === "provider") owned.add(index);
72
+ }
73
+ return { bom, lines, owned };
74
+ }
75
+
76
+ export function hasOcxProviderTable(content: string): boolean {
77
+ return providerLines(content).owned.size > 0;
78
+ }
79
+
80
+ /** Preserve value bytes, including multiline newlines and separated child tables. */
81
+ export function extractOcxProviderTableBlock(content: string): string | null {
82
+ const { lines, owned } = providerLines(content);
83
+ if (owned.size === 0) return null;
84
+ const captured = lines.filter((_, index) => owned.has(index));
85
+ while (captured.length && captured.at(-1)!.structural && !captured.at(-1)!.text.trim()) captured.pop();
86
+ const text = captured.map(line => line.text + line.eol).join("");
87
+ providerValue(text); // Ambiguous or incomplete owned spans never become a retained block.
88
+ return text.endsWith("\n") ? text : text + "\n";
89
+ }
90
+
91
+ export function removeOcxSection(content: string): string {
92
+ const { bom, lines, owned } = providerLines(content);
93
+ if (owned.size) providerValue(lines.filter((_, index) => owned.has(index)).map(line => line.text + line.eol).join(""));
94
+ return bom + lines.filter((_, index) => !owned.has(index)).map(line => line.text + line.eol).join("");
95
+ }
96
+
97
+ /** Compare decoded values; cosmetic layout never rewrites the existing file. */
98
+ export function appendOcxProviderTableBlock(content: string, block: string): string {
99
+ const captured = providerValue(block);
100
+ const existing = extractOcxProviderTableBlock(content);
101
+ if (existing !== null) {
102
+ if (!isDeepStrictEqual(providerValue(existing), captured)) {
103
+ throw new Error("Codex restore refused: the native config already defines a different [model_providers.opencodex] table.");
104
+ }
105
+ return content;
106
+ }
107
+ return `${content}${content.endsWith("\n") ? "\n" : "\n\n"}${block}${block.endsWith("\n") ? "" : "\n"}`;
108
+ }
@@ -1,7 +1,6 @@
1
1
  import { existsSync, readFileSync, unlinkSync } from "node:fs";
2
2
  import { atomicWriteFile } from "../../config";
3
3
  import {
4
- OCX_SECTION_MARKER,
5
4
  REALTIME_WS_BASE_URL_KEY,
6
5
  hasInjectedOpenaiBaseUrl,
7
6
  rootTomlString,
@@ -23,119 +22,8 @@ import {
23
22
  stripRootRoutedModel,
24
23
  } from "./config-toml";
25
24
 
26
- /**
27
- * Sub-table headers like `[model_providers.opencodex.env_http_headers]` appear when a Codex app
28
- * config rewrite re-serializes the provider's inline `env_http_headers` table. They define the
29
- * same `model_providers.opencodex` provider, so cleanup must remove them too — otherwise the
30
- * provider survives with no `name` and Codex rejects the whole config
31
- * ("provider name must not be empty"). The dot terminator keeps a user's
32
- * `[model_providers.opencodex_backup]`-style tables out of scope.
33
- */
34
- function isOcxProviderHeaderLine(trimmedLine: string): boolean {
35
- // Root form matched by regex, not equality: TOML v1.0 allows a trailing comment
36
- // (`[model_providers.opencodex] # comment`), and an exact compare would miss that form.
37
- // The sub-table prefix check already tolerates trailing comments by construction.
38
- return (
39
- /^\[model_providers\.opencodex\]\s*(?:#.*)?$/.test(trimmedLine) ||
40
- trimmedLine.startsWith("[model_providers.opencodex.")
41
- );
42
- }
43
-
44
- export function hasOcxProviderTable(content: string): boolean {
45
- return content
46
- .split("\n")
47
- .some((line) => isOcxProviderHeaderLine(line.trim()));
48
- }
49
-
50
- export function removeOcxSection(content: string): string {
51
- const lines = content.split("\n");
52
- const filtered: string[] = [];
53
- let inOcxSection = false;
54
- for (const line of lines) {
55
- if (
56
- line.includes(OCX_SECTION_MARKER) ||
57
- isOcxProviderHeaderLine(line.trim())
58
- ) {
59
- inOcxSection = true;
60
- continue;
61
- }
62
- if (inOcxSection) {
63
- // End the injected section at the next table header that ISN'T our own. Exact match on the
64
- // provider name (plus our own sub-tables) so a user's
65
- // "[model_providers.opencodex_backup]" (or similar) is preserved, not swallowed.
66
- if (/^\s*\[/.test(line) && !isOcxProviderHeaderLine(line.trim())) {
67
- inOcxSection = false;
68
- filtered.push(line);
69
- }
70
- continue;
71
- }
72
- filtered.push(line);
73
- }
74
- return (
75
- filtered
76
- .join("\n")
77
- .replace(/\n{3,}/g, "\n\n")
78
- .trimEnd() + "\n"
79
- );
80
- }
81
-
82
- /**
83
- * Capture `[model_providers.opencodex]` verbatim so it can survive a restore that only
84
- * takes routing down (#4812).
85
- *
86
- * This is deliberately NOT a mirror of `removeOcxSection`'s scan. That one opens a
87
- * section on any line containing `OCX_SECTION_MARKER`, which is safe there only because
88
- * `stripInjectedOpenaiBaseUrl` has already consumed the identical marker that annotates
89
- * the root `openai_base_url`. Capture runs against the untouched file, so the same rule
90
- * would collect that marker and the routing line under it — and re-appending the result
91
- * would restore the exact base-url override the caller just removed.
92
- *
93
- * So the anchor is the provider header itself, via the shared `isOcxProviderHeaderLine`,
94
- * with an immediately preceding marker line pulled in as its comment. Sharing that
95
- * predicate is what keeps capture and removal from disagreeing about what our table is.
96
- */
97
- export function extractOcxProviderTableBlock(content: string): string | null {
98
- const lines = content.split("\n");
99
- const collected: string[] = [];
100
- let capturing = false;
101
- for (let index = 0; index < lines.length; index++) {
102
- const line = lines[index]!;
103
- if (isOcxProviderHeaderLine(line.trim())) {
104
- if (!capturing) {
105
- const previous = lines[index - 1];
106
- if (previous !== undefined && previous.includes(OCX_SECTION_MARKER)) collected.push(previous);
107
- capturing = true;
108
- }
109
- collected.push(line);
110
- continue;
111
- }
112
- if (!capturing) continue;
113
- // A foreign table header closes ours, exactly as in `removeOcxSection`. A later
114
- // `[model_providers.opencodex.*]` sub-table reopens capture on the next iteration,
115
- // which is why the two are separate passes over the same predicate.
116
- if (/^\s*\[/.test(line)) {
117
- capturing = false;
118
- continue;
119
- }
120
- collected.push(line);
121
- }
122
- if (collected.length === 0) return null;
123
- return collected.join("\n").replace(/\n{3,}/g, "\n\n").trimEnd() + "\n";
124
- }
125
-
126
- /**
127
- * Append a captured provider table to stripped content, as one buffer.
128
- *
129
- * Pure on purpose. Upstream resolves `model_provider` against the merged provider map and
130
- * fails the WHOLE config load on a miss — not the one thread — so a config carrying root
131
- * `model_provider = "opencodex"` without this table breaks every `codex` invocation. The
132
- * strip and the re-append therefore have to reach disk in a single write, which they can
133
- * only do if the append is a transform rather than a second file operation.
134
- */
135
- export function appendOcxProviderTableBlock(content: string, block: string): string {
136
- if (hasOcxProviderTable(content)) return content;
137
- return `${content.replace(/\n+$/, "")}\n\n${block.replace(/\n+$/, "")}\n`;
138
- }
25
+ import { appendOcxProviderTableBlock, extractOcxProviderTableBlock, hasOcxProviderTable, removeOcxSection } from "./provider-table";
26
+ export { appendOcxProviderTableBlock, extractOcxProviderTableBlock, hasOcxProviderTable, removeOcxSection } from "./provider-table";
139
27
 
140
28
  /** Read the provider table straight off disk, before anything has transformed it. */
141
29
  export function readOcxProviderTableBlock(): string | null {
@@ -20,7 +20,7 @@ import {
20
20
  } from "./catalog/native-models";
21
21
  import { loadPersistedCodexRuntime } from "./runtime";
22
22
  import { codexRuntimeStateEpoch } from "./runtime";
23
- import upstreamModelsSnapshot from "./data/upstream-models.json";
23
+ import { pinnedNativeModelRows } from "./catalog/pinned-models";
24
24
  import { codexCredentialMutationEpoch } from "./credential-mutation-epoch";
25
25
  import {
26
26
  clearObservedCodexModelDenial,
@@ -90,9 +90,10 @@ export function deriveGatedClientVersionFloor(
90
90
  /**
91
91
  * Lowest `client_version` MEASURED to actually return the account-gated rows.
92
92
  *
93
- * The bundled snapshot is not sufficient on its own. It records `0.142.2` for the gpt-5.6
94
- * rows, and `0.142.2` is a version upstream answers with 200 and five models, none of them
95
- * gpt-5.6; `0.144.0` and above answer with the gated rows present
93
+ * The bundled snapshot was not sufficient on its own. Until the 2026-09-23 re-pin it recorded
94
+ * `0.142.2` for the gpt-5.6 rows (upstream now records `0.144.0`), and `0.142.2` is a version
95
+ * upstream answers with 200 and five models, none of them gpt-5.6; `0.144.0` and above answer
96
+ * with the gated rows present
96
97
  * (devlog/_fin/260817_native_gpt56_1m_context/001_measurement_evidence.md, independently
97
98
  * reproduced by the #2886 and #3022 reporters). So a floor derived from the snapshot alone
98
99
  * asks a question whose honest answer is an empty gated set — and the fail-closed gate then
@@ -107,9 +108,9 @@ const MEASURED_GATED_CLIENT_VERSION_MINIMUM = "0.144.0";
107
108
  /**
108
109
  * Lowest versions measured to return each account-gated model when the account owns it.
109
110
  *
110
- * This is deliberately independent of the bundled upstream snapshot. The snapshot still
111
- * records 0.142.2 for sol/terra/luna, while live measurements show that upstream omits them
112
- * below 0.144.0. Daybreak has no snapshot row or independent minimum, so its omission remains
111
+ * This is deliberately independent of the bundled upstream snapshot, which can lag or lead the
112
+ * live roster: live measurements show that upstream omits sol/terra/luna below 0.144.0.
113
+ * Daybreak's shipped row records 0.142.2 and has no independent measured minimum, so its omission remains
113
114
  * authoritative instead of inheriting a guessed floor from another model.
114
115
  */
115
116
  export const ACCOUNT_GATED_NATIVE_MODEL_MINIMUM_CLIENT_VERSIONS: ReadonlyMap<string, string> = new Map([
@@ -121,8 +122,8 @@ export const ACCOUNT_GATED_NATIVE_MODEL_MINIMUM_CLIENT_VERSIONS: ReadonlyMap<str
121
122
  /**
122
123
  * Fallback when the snapshot records no usable gated floor.
123
124
  *
124
- * Not every gated slug carries a `minimal_client_version` — `gpt-daybreak-blue-latest` has no
125
- * row in the current snapshot at all — so the derivation can legitimately come back empty as the
125
+ * Not every gated slug is guaranteed a `minimal_client_version` — `gpt-daybreak-blue-latest` had
126
+ * no row until the 2026-09-23 re-pin — so the derivation can legitimately come back empty as the
126
127
  * gated set changes. This is the last resort behind it, and it is still a version upstream can
127
128
  * filter on rather than the placeholder that caused #2886.
128
129
  */
@@ -147,8 +148,11 @@ function composeGatedClientVersionFloor(
147
148
  : MEASURED_GATED_CLIENT_VERSION_MINIMUM;
148
149
  }
149
150
 
151
+ // Reads every pinned row (codex-rs snapshot plus roster-captured rows), so a gated slug whose row
152
+ // arrives through roster-pinned-models.json still contributes its floor. None does today:
153
+ // gpt-6-sol/luna are ungated, and gpt-6-astra-minor has no row and no measured minimum.
150
154
  export const GATED_MODEL_CLIENT_VERSION_FLOOR: string = composeGatedClientVersionFloor(
151
- (upstreamModelsSnapshot as { models?: Array<Record<string, unknown>> }).models ?? [],
155
+ pinnedNativeModelRows(),
152
156
  );
153
157
 
154
158
  /** Test-only seam: the composition on synthetic rows, so both directions can be proven. */
@@ -1,3 +1,5 @@
1
+ import { splitSourceLines, type SourceLine } from "./toml-source-lines";
2
+
1
3
  /**
2
4
  * Pure, ownership-aware edits for Codex's native `[agents]` defaults.
3
5
  *
@@ -40,12 +42,6 @@ export type ManagedSubagentDefaultsTransformResult =
40
42
  error: string;
41
43
  };
42
44
 
43
- interface SourceLine {
44
- text: string;
45
- eol: "\r\n" | "\n" | "";
46
- /** False when this physical line began inside a TOML multiline string. */
47
- structural: boolean;
48
- }
49
45
 
50
46
  interface TargetDefinition {
51
47
  key: ManagedSubagentDefaultKey;
@@ -65,109 +61,6 @@ const TARGET_KEYS: readonly ManagedSubagentDefaultKey[] = [
65
61
  "default_subagent_reasoning_effort",
66
62
  ];
67
63
 
68
- function splitSourceLines(content: string): SourceLine[] {
69
- const lines: SourceLine[] = [];
70
- let offset = 0;
71
- while (offset < content.length) {
72
- const lf = content.indexOf("\n", offset);
73
- if (lf === -1) {
74
- lines.push({ text: content.slice(offset), eol: "", structural: true });
75
- break;
76
- }
77
- const crlf = lf > offset && content[lf - 1] === "\r";
78
- lines.push({
79
- text: content.slice(offset, crlf ? lf - 1 : lf),
80
- eol: crlf ? "\r\n" : "\n",
81
- structural: true,
82
- });
83
- offset = lf + 1;
84
- }
85
- markStructuralLines(lines);
86
- return lines;
87
- }
88
-
89
- type MultilineStringKind = "basic" | "literal" | null;
90
-
91
- /**
92
- * TOML table-looking text inside a multiline string is data, not syntax. Keep
93
- * a deliberately small lexical state machine so the format-preserving editor
94
- * never treats those physical lines as headers, keys, or ownership markers.
95
- */
96
- function markStructuralLines(lines: SourceLine[]): void {
97
- let multiline: MultilineStringKind = null;
98
- let squareDepth = 0;
99
- let curlyDepth = 0;
100
-
101
- for (const line of lines) {
102
- line.structural = multiline === null && squareDepth === 0 && curlyDepth === 0;
103
- let single: "basic" | "literal" | null = null;
104
-
105
- for (let index = 0; index < line.text.length;) {
106
- if (multiline === "basic") {
107
- if (line.text.startsWith('"""', index)) {
108
- multiline = null;
109
- index += 3;
110
- } else if (line.text[index] === "\\") {
111
- index += 2;
112
- } else {
113
- index += 1;
114
- }
115
- continue;
116
- }
117
- if (multiline === "literal") {
118
- if (line.text.startsWith("'''", index)) {
119
- multiline = null;
120
- index += 3;
121
- } else {
122
- index += 1;
123
- }
124
- continue;
125
- }
126
- if (single === "basic") {
127
- if (line.text[index] === "\\") index += 2;
128
- else if (line.text[index] === '"') {
129
- single = null;
130
- index += 1;
131
- } else index += 1;
132
- continue;
133
- }
134
- if (single === "literal") {
135
- if (line.text[index] === "'") single = null;
136
- index += 1;
137
- continue;
138
- }
139
-
140
- if (line.text[index] === "#") break;
141
- if (line.text.startsWith('"""', index)) {
142
- multiline = "basic";
143
- index += 3;
144
- } else if (line.text.startsWith("'''", index)) {
145
- multiline = "literal";
146
- index += 3;
147
- } else if (line.text[index] === '"') {
148
- single = "basic";
149
- index += 1;
150
- } else if (line.text[index] === "'") {
151
- single = "literal";
152
- index += 1;
153
- } else if (line.text[index] === "[") {
154
- squareDepth += 1;
155
- index += 1;
156
- } else if (line.text[index] === "]") {
157
- squareDepth = Math.max(0, squareDepth - 1);
158
- index += 1;
159
- } else if (line.text[index] === "{") {
160
- curlyDepth += 1;
161
- index += 1;
162
- } else if (line.text[index] === "}") {
163
- curlyDepth = Math.max(0, curlyDepth - 1);
164
- index += 1;
165
- } else {
166
- index += 1;
167
- }
168
- }
169
- }
170
- }
171
64
 
172
65
  function joinSourceLines(lines: readonly SourceLine[]): string {
173
66
  return lines.map(line => `${line.text}${line.eol}`).join("");
@@ -0,0 +1,112 @@
1
+ /** Lossless physical TOML lines with lexical structural boundaries. */
2
+ export interface SourceLine {
3
+ text: string;
4
+ eol: "\r\n" | "\n" | "";
5
+ /** False when this physical line began inside a TOML multiline string. */
6
+ structural: boolean;
7
+ }
8
+
9
+
10
+ export function splitSourceLines(content: string): SourceLine[] {
11
+ const lines: SourceLine[] = [];
12
+ let offset = 0;
13
+ while (offset < content.length) {
14
+ const lf = content.indexOf("\n", offset);
15
+ if (lf === -1) {
16
+ lines.push({ text: content.slice(offset), eol: "", structural: true });
17
+ break;
18
+ }
19
+ const crlf = lf > offset && content[lf - 1] === "\r";
20
+ lines.push({
21
+ text: content.slice(offset, crlf ? lf - 1 : lf),
22
+ eol: crlf ? "\r\n" : "\n",
23
+ structural: true,
24
+ });
25
+ offset = lf + 1;
26
+ }
27
+ markStructuralLines(lines);
28
+ return lines;
29
+ }
30
+
31
+ type MultilineStringKind = "basic" | "literal" | null;
32
+
33
+ /**
34
+ * TOML table-looking text inside a multiline string is data, not syntax. Keep
35
+ * a deliberately small lexical state machine so the format-preserving editor
36
+ * never treats those physical lines as headers, keys, or ownership markers.
37
+ */
38
+ function markStructuralLines(lines: SourceLine[]): void {
39
+ let multiline: MultilineStringKind = null;
40
+ let squareDepth = 0;
41
+ let curlyDepth = 0;
42
+
43
+ for (const line of lines) {
44
+ line.structural = multiline === null && squareDepth === 0 && curlyDepth === 0;
45
+ let single: "basic" | "literal" | null = null;
46
+
47
+ for (let index = 0; index < line.text.length;) {
48
+ if (multiline === "basic") {
49
+ if (line.text.startsWith('"""', index)) {
50
+ multiline = null;
51
+ index += 3;
52
+ } else if (line.text[index] === "\\") {
53
+ index += 2;
54
+ } else {
55
+ index += 1;
56
+ }
57
+ continue;
58
+ }
59
+ if (multiline === "literal") {
60
+ if (line.text.startsWith("'''", index)) {
61
+ multiline = null;
62
+ index += 3;
63
+ } else {
64
+ index += 1;
65
+ }
66
+ continue;
67
+ }
68
+ if (single === "basic") {
69
+ if (line.text[index] === "\\") index += 2;
70
+ else if (line.text[index] === '"') {
71
+ single = null;
72
+ index += 1;
73
+ } else index += 1;
74
+ continue;
75
+ }
76
+ if (single === "literal") {
77
+ if (line.text[index] === "'") single = null;
78
+ index += 1;
79
+ continue;
80
+ }
81
+
82
+ if (line.text[index] === "#") break;
83
+ if (line.text.startsWith('"""', index)) {
84
+ multiline = "basic";
85
+ index += 3;
86
+ } else if (line.text.startsWith("'''", index)) {
87
+ multiline = "literal";
88
+ index += 3;
89
+ } else if (line.text[index] === '"') {
90
+ single = "basic";
91
+ index += 1;
92
+ } else if (line.text[index] === "'") {
93
+ single = "literal";
94
+ index += 1;
95
+ } else if (line.text[index] === "[") {
96
+ squareDepth += 1;
97
+ index += 1;
98
+ } else if (line.text[index] === "]") {
99
+ squareDepth = Math.max(0, squareDepth - 1);
100
+ index += 1;
101
+ } else if (line.text[index] === "{") {
102
+ curlyDepth += 1;
103
+ index += 1;
104
+ } else if (line.text[index] === "}") {
105
+ curlyDepth = Math.max(0, curlyDepth - 1);
106
+ index += 1;
107
+ } else {
108
+ index += 1;
109
+ }
110
+ }
111
+ }
112
+ }