@bitkyc08/opencodex 2.15.1 → 2.17.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 (41) hide show
  1. package/gui/dist/assets/{index-CMCDkQ7U.js → index-DOKr6RBR.js} +10 -10
  2. package/gui/dist/index.html +1 -1
  3. package/package.json +1 -1
  4. package/src/adapters/base.ts +2 -0
  5. package/src/adapters/google-antigravity-replay.ts +9 -1
  6. package/src/adapters/kiro-thinking.ts +8 -0
  7. package/src/adapters/kiro.ts +45 -42
  8. package/src/adapters/openai-chat.ts +5 -2
  9. package/src/adapters/openai-responses.ts +5 -1
  10. package/src/cli/dispatch.ts +6 -3
  11. package/src/cli/export-command.ts +19 -7
  12. package/src/cli/help.ts +1 -1
  13. package/src/cli/index.ts +1 -0
  14. package/src/cli/registry.ts +2 -2
  15. package/src/clients/config-export.ts +165 -3
  16. package/src/generated/compatibility-version.json +59 -31
  17. package/src/integrations/config-io.ts +119 -1
  18. package/src/integrations/omp-yaml-source.ts +232 -99
  19. package/src/integrations/registry.ts +14 -0
  20. package/src/integrations/serialize.ts +80 -1
  21. package/src/integrations/state.ts +38 -6
  22. package/src/integrations/writer-lock.ts +98 -0
  23. package/src/integrations/writer.ts +152 -19
  24. package/src/lab/automation/orchestrator.ts +19 -0
  25. package/src/lib/lab-activation.ts +161 -0
  26. package/src/lib/lab-passive-linker-registration.ts +26 -0
  27. package/src/lib/optional-shutdown-hooks.ts +57 -0
  28. package/src/lib/shadow-call.ts +6 -14
  29. package/src/lib/translator-budget.ts +34 -0
  30. package/src/providers/antigravity-models.ts +65 -10
  31. package/src/routing/compatibility/assemble.ts +21 -107
  32. package/src/routing/compatibility/lab-evidence-provider.ts +130 -0
  33. package/src/routing/compatibility/provider-slot.ts +56 -0
  34. package/src/server/index.ts +8 -17
  35. package/src/server/lifecycle.ts +5 -3
  36. package/src/server/management/integration-routes.ts +21 -14
  37. package/src/server/management/routing-profile-routes.ts +9 -1
  38. package/src/server/management-api.ts +37 -6
  39. package/src/server/passive-route-linker.ts +66 -0
  40. package/src/server/responses/core.ts +20 -21
  41. package/src/types.ts +15 -5
@@ -1,14 +1,11 @@
1
1
  /**
2
- * Source-preserving mutation for the one YAML fragment managed by OMP.
2
+ * Source-preserving mutation for one plain block-map YAML fragment.
3
3
  *
4
- * The general integration writer operates on parsed documents. Re-rendering a
5
- * shared YAML file would preserve values but destroy comments and formatting
6
- * outside `providers.opencodex`. OMP is the only client whose ownership model
7
- * deliberately permits those unrelated source edits, so its writer replaces
8
- * or removes only the exact block-style mapping entry it owns.
9
- *
10
- * Unsupported or ambiguous YAML returns `null`. The caller treats that as an
11
- * unsafe refusal instead of falling back to whole-document serialization.
4
+ * Shared client settings are not ours to re-render. This scanner accepts only
5
+ * the small, unambiguous source shape we can patch byte-for-byte around the
6
+ * owned leaf. Every candidate is parsed again and compared with the complete
7
+ * expected document; unsupported YAML fails closed and never falls back to a
8
+ * whole-document serializer.
12
9
  */
13
10
  import { renderYaml } from "./serialize";
14
11
 
@@ -18,6 +15,26 @@ interface SourceLine {
18
15
  body: string;
19
16
  }
20
17
 
18
+ interface LocatedEntry {
19
+ lines: readonly SourceLine[];
20
+ index: number;
21
+ indent: number;
22
+ endIndex: number;
23
+ }
24
+
25
+ interface MissingEntry {
26
+ lines: readonly SourceLine[];
27
+ missingDepth: number;
28
+ indent: number;
29
+ insertAt: number;
30
+ }
31
+
32
+ type LocatedPath = { kind: "existing"; entry: LocatedEntry } | { kind: "missing"; entry: MissingEntry };
33
+
34
+ export type YamlFragmentMutation =
35
+ | { kind: "upsert"; value: unknown }
36
+ | { kind: "remove"; createdContainers: readonly string[] };
37
+
21
38
  export type OmpYamlMutation =
22
39
  | { kind: "upsert"; value: unknown }
23
40
  | { kind: "remove"; removeEmptyProviders: boolean };
@@ -54,11 +71,15 @@ function hasInlineComment(line: string): boolean {
54
71
  return line.includes("#");
55
72
  }
56
73
 
74
+ function regexpEscape(value: string): string {
75
+ return value.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&");
76
+ }
77
+
57
78
  function isPlainBlockKey(line: string, indent: number, key: string): boolean {
58
79
  const spaces = leadingSpaces(line);
59
80
  if (spaces !== indent) return false;
60
81
  const rest = line.slice(indent);
61
- return new RegExp(`^${key}:[ ]*(?:#.*)?$`, "u").test(rest);
82
+ return new RegExp(`^${regexpEscape(key)}:[ ]*(?:#.*)?$`, "u").test(rest);
62
83
  }
63
84
 
64
85
  function containerEnd(lines: readonly SourceLine[], start: number, indent: number): number | null {
@@ -86,9 +107,8 @@ function childEnd(
86
107
  const body = lines[index]!.body;
87
108
  const spaces = leadingSpaces(body);
88
109
  if (spaces === null) return null;
89
- // Blank lines and same-level comments are conservatively outside the
90
- // managed entry. Deeper comments would be destroyed by replacement, so
91
- // refuse rather than guessing whether the user meant to keep them.
110
+ // Blank lines and same-level comments remain outside our replacement.
111
+ // Deeper comments belong to the leaf and would be destroyed, so refuse.
92
112
  if (isBlank(body)) return index;
93
113
  if (isComment(body)) return spaces <= indent ? index : null;
94
114
  if (spaces <= indent) return index;
@@ -112,8 +132,7 @@ function canonicalValue(value: unknown): unknown {
112
132
  function semanticallyMatches(text: string, expected: unknown): boolean {
113
133
  try {
114
134
  const parsed = text.trim().length === 0 ? {} : Bun.YAML.parse(text);
115
- return JSON.stringify(canonicalValue(parsed))
116
- === JSON.stringify(canonicalValue(expected));
135
+ return JSON.stringify(canonicalValue(parsed)) === JSON.stringify(canonicalValue(expected));
117
136
  } catch {
118
137
  return false;
119
138
  }
@@ -123,103 +142,217 @@ function lineEnding(text: string): "\n" | "\r\n" {
123
142
  return text.includes("\r\n") ? "\r\n" : "\n";
124
143
  }
125
144
 
126
- function renderedEntry(value: unknown, indent: number, eol: "\n" | "\r\n"): string {
127
- return renderYaml({ opencodex: value }, indent).replaceAll("\n", eol);
128
- }
129
-
130
- /**
131
- * Patch `providers.opencodex` while preserving every byte outside that entry.
132
- * The returned text is parsed and compared with `expected` before it is
133
- * accepted, so a scanner mistake fails closed rather than writing bad YAML.
134
- */
135
- export function patchOmpYamlSource(
136
- text: string,
137
- mutation: OmpYamlMutation,
138
- expected: unknown,
139
- ): string | null {
140
- const lines = sourceLines(text);
141
- const eol = lineEnding(text);
142
- const providerIndexes = lines
143
- .map((line, index) => isPlainBlockKey(line.body, 0, "providers") ? index : -1)
144
- .filter(index => index >= 0);
145
-
146
- if (providerIndexes.length === 0) {
147
- if (mutation.kind === "remove") return null;
148
- const separator = text.length === 0 || text.endsWith("\n") ? "" : eol;
149
- const patched = `${text}${separator}providers:${eol}${renderedEntry(mutation.value, 2, eol)}`;
150
- return semanticallyMatches(patched, expected) ? patched : null;
145
+ function readPath(doc: unknown, path: readonly string[]): unknown {
146
+ let cursor = doc;
147
+ for (const key of path) {
148
+ if (cursor === null || typeof cursor !== "object" || Array.isArray(cursor)) return undefined;
149
+ cursor = (cursor as Record<string, unknown>)[key];
150
+ if (cursor === undefined) return undefined;
151
151
  }
152
- if (providerIndexes.length !== 1) return null;
152
+ return cursor;
153
+ }
153
154
 
154
- const providersIndex = providerIndexes[0]!;
155
- const providersEnd = containerEnd(lines, providersIndex, 0);
156
- if (providersEnd === null) return null;
155
+ function isPlainRecord(value: unknown): value is Record<string, unknown> {
156
+ return value !== null && typeof value === "object" && !Array.isArray(value);
157
+ }
157
158
 
158
- let inferredIndent: number | null = null;
159
- for (let index = providersIndex + 1; index < providersEnd; index += 1) {
159
+ function immediateIndent(
160
+ lines: readonly SourceLine[],
161
+ start: number,
162
+ end: number,
163
+ parentIndent: number,
164
+ ): number | null {
165
+ let indent: number | null = null;
166
+ for (let index = start; index < end; index += 1) {
160
167
  const body = lines[index]!.body;
161
168
  const spaces = leadingSpaces(body);
162
169
  if (spaces === null) return null;
163
- if (!isBlank(body) && !isComment(body) && spaces > 0) {
164
- inferredIndent = inferredIndent === null ? spaces : Math.min(inferredIndent, spaces);
170
+ if (isBlank(body) || isComment(body) || spaces <= parentIndent) continue;
171
+ indent = indent === null ? spaces : Math.min(indent, spaces);
172
+ }
173
+ return indent ?? parentIndent + 2;
174
+ }
175
+
176
+ /** Locate a path only when every present segment is a plain block-map key. */
177
+ function locatePath(text: string, parsed: unknown, path: readonly string[]): LocatedPath | null {
178
+ if (path.length === 0 || !isPlainRecord(parsed)) return null;
179
+ const lines = sourceLines(text);
180
+ if (lines.some(line => leadingSpaces(line.body) === null)) return null;
181
+
182
+ let rangeStart = 0;
183
+ let rangeEnd = lines.length;
184
+ let parentIndent = -2;
185
+ const prefix: string[] = [];
186
+ for (let depth = 0; depth < path.length; depth += 1) {
187
+ const indent = depth === 0 ? 0 : immediateIndent(lines, rangeStart, rangeEnd, parentIndent);
188
+ if (indent === null) return null;
189
+ const matches: number[] = [];
190
+ for (let index = rangeStart; index < rangeEnd; index += 1) {
191
+ if (isPlainBlockKey(lines[index]!.body, indent, path[depth]!)) matches.push(index);
192
+ }
193
+ if (matches.length > 1) return null;
194
+ prefix.push(path[depth]!);
195
+ if (matches.length === 0) {
196
+ // The parser saw this key through syntax we do not patch (quoted/flow,
197
+ // merge aliases, or an ambiguous indentation shape).
198
+ if (readPath(parsed, prefix) !== undefined) return null;
199
+ const insertAt = rangeEnd < lines.length ? lines[rangeEnd]!.start : text.length;
200
+ return { kind: "missing", entry: { lines, missingDepth: depth, indent, insertAt } };
165
201
  }
202
+
203
+ const index = matches[0]!;
204
+ const end = containerEnd(lines, index, indent);
205
+ if (end === null) return null;
206
+ if (depth === path.length - 1) {
207
+ if (hasInlineComment(lines[index]!.body)) return null;
208
+ const leafEnd = childEnd(lines, index, end, indent);
209
+ if (leafEnd === null) return null;
210
+ return { kind: "existing", entry: { lines, index, indent, endIndex: leafEnd } };
211
+ }
212
+ if (!isPlainRecord(readPath(parsed, prefix))) return null;
213
+ rangeStart = index + 1;
214
+ rangeEnd = end;
215
+ parentIndent = indent;
166
216
  }
167
- const childIndexes = inferredIndent === null
168
- ? []
169
- : lines.slice(providersIndex + 1, providersEnd)
170
- .map((line, offset) => (
171
- isPlainBlockKey(line.body, inferredIndent!, "opencodex")
172
- ? providersIndex + 1 + offset
173
- : -1
174
- ))
175
- .filter(index => index >= 0);
176
- if (childIndexes.length > 1) return null;
177
-
178
- const childIndex = childIndexes[0];
179
- if (childIndex === undefined) {
180
- if (mutation.kind === "remove") return null;
181
- const indent = inferredIndent ?? 2;
182
- const insertAt = providersEnd < lines.length ? lines[providersEnd]!.start : text.length;
183
- const prefix = insertAt > 0 && !text.slice(0, insertAt).endsWith("\n") ? eol : "";
184
- const patched = `${text.slice(0, insertAt)}${prefix}${renderedEntry(mutation.value, indent, eol)}${text.slice(insertAt)}`;
185
- return semanticallyMatches(patched, expected) ? patched : null;
217
+ return null;
218
+ }
219
+
220
+ function nestedValue(path: readonly string[], value: unknown): Record<string, unknown> {
221
+ let nested: unknown = value;
222
+ for (let index = path.length - 1; index >= 0; index -= 1) nested = { [path[index]!]: nested };
223
+ return nested as Record<string, unknown>;
224
+ }
225
+
226
+ function rendered(value: unknown, indent: number, eol: "\n" | "\r\n"): string {
227
+ return renderYaml(value as Record<string, unknown>, indent).replaceAll("\n", eol);
228
+ }
229
+
230
+ function preserveFinalNewline(candidate: string, original: string, eol: "\n" | "\r\n"): string {
231
+ if (original.length > 0 && !original.endsWith("\n") && candidate.endsWith(eol)) {
232
+ return candidate.slice(0, -eol.length);
233
+ }
234
+ return candidate;
235
+ }
236
+
237
+ function upsertSource(
238
+ text: string,
239
+ parsed: unknown,
240
+ path: readonly string[],
241
+ value: unknown,
242
+ ): string | null {
243
+ const located = locatePath(text, parsed, path);
244
+ if (located === null) return null;
245
+ const eol = lineEnding(text);
246
+ if (located.kind === "existing") {
247
+ const { lines, index, indent, endIndex } = located.entry;
248
+ const startOffset = lines[index]!.start;
249
+ const endOffset = endIndex < lines.length ? lines[endIndex]!.start : text.length;
250
+ const candidate = `${text.slice(0, startOffset)}${rendered({ [path[path.length - 1]!]: value }, indent, eol)}${text.slice(endOffset)}`;
251
+ return preserveFinalNewline(candidate, text, eol);
186
252
  }
187
253
 
188
- const childIndent = leadingSpaces(lines[childIndex]!.body);
189
- if (childIndent === null || childIndent <= 0) return null;
190
- if (hasInlineComment(lines[childIndex]!.body)) return null;
191
- const endIndex = childEnd(lines, childIndex, providersEnd, childIndent);
192
- if (endIndex === null) return null;
193
- const startOffset = lines[childIndex]!.start;
254
+ const { missingDepth, indent, insertAt } = located.entry;
255
+ const prefix = insertAt > 0 && !text.slice(0, insertAt).endsWith("\n") ? eol : "";
256
+ const insertion = rendered(nestedValue(path.slice(missingDepth), value), indent, eol);
257
+ const candidate = `${text.slice(0, insertAt)}${prefix}${insertion}${text.slice(insertAt)}`;
258
+ return preserveFinalNewline(candidate, text, eol);
259
+ }
260
+
261
+ function removeExactPath(text: string, path: readonly string[], requireEmpty: boolean): string | null {
262
+ let parsed: unknown;
263
+ try {
264
+ parsed = text.trim().length === 0 ? {} : Bun.YAML.parse(text);
265
+ } catch {
266
+ return null;
267
+ }
268
+ const located = locatePath(text, parsed, path);
269
+ if (located === null || located.kind !== "existing") return null;
270
+ const { lines, index, endIndex } = located.entry;
271
+ const startOffset = lines[index]!.start;
194
272
  const endOffset = endIndex < lines.length ? lines[endIndex]!.start : text.length;
273
+ if (requireEmpty) {
274
+ const sourceBody = text.slice(lines[index]!.end, endOffset);
275
+ if (sourceBody.trim().length > 0) return null;
276
+ }
277
+ return `${text.slice(0, startOffset)}${text.slice(endOffset)}`;
278
+ }
195
279
 
196
- if (mutation.kind === "upsert") {
197
- const patched = `${text.slice(0, startOffset)}${renderedEntry(mutation.value, childIndent, eol)}${text.slice(endOffset)}`;
198
- return semanticallyMatches(patched, expected) ? patched : null;
280
+ function planSourceRemoval(
281
+ text: string,
282
+ path: readonly string[],
283
+ createdContainers: readonly string[],
284
+ ): { text: string; prunedContainers: string[] } | null {
285
+ let next = removeExactPath(text, path, false);
286
+ if (next === null) return null;
287
+ const created = new Set(createdContainers);
288
+ const prunedContainers: string[] = [];
289
+ for (let depth = path.length - 1; depth >= 1; depth -= 1) {
290
+ const containerPath = path.slice(0, depth);
291
+ const encoded = containerPath.join("\u0000");
292
+ if (!created.has(encoded)) continue;
293
+ const pruned = removeExactPath(next, containerPath, true);
294
+ // A user-added sibling/comment makes this ancestor source-owned now. Keep
295
+ // it (and every parent) while still removing our leaf.
296
+ if (pruned === null) break;
297
+ next = pruned;
298
+ prunedContainers.push(encoded);
199
299
  }
300
+ return { text: next, prunedContainers };
301
+ }
200
302
 
201
- let patched = `${text.slice(0, startOffset)}${text.slice(endOffset)}`;
202
- if (mutation.removeEmptyProviders) {
203
- const remaining = Bun.YAML.parse(patched) as { providers?: unknown } | null;
204
- if (remaining && Object.hasOwn(remaining, "providers")) {
205
- const provider = remaining.providers;
206
- const empty = provider === null || (
207
- provider && typeof provider === "object" && !Array.isArray(provider)
208
- && Object.keys(provider as Record<string, unknown>).length === 0
209
- );
210
- if (!empty) return semanticallyMatches(patched, expected) ? patched : null;
211
- if (hasInlineComment(lines[providersIndex]!.body)) return null;
212
-
213
- const providerStart = lines[providersIndex]!.start;
214
- // Removing a container we created is safe only when nothing but our
215
- // entry occupied its source range. Comments or blank formatting make
216
- // that range user-owned and therefore ambiguous.
217
- for (let index = providersIndex + 1; index < providersEnd; index += 1) {
218
- if (index >= childIndex && index < endIndex) continue;
219
- if (lines[index]!.body.length > 0) return null;
220
- }
221
- patched = `${text.slice(0, providerStart)}${text.slice(endOffset)}`;
222
- }
303
+ /** Containers whose source ranges are still empty and safe to prune. */
304
+ export function sourcePrunableYamlContainers(
305
+ text: string,
306
+ path: readonly string[],
307
+ createdContainers: readonly string[],
308
+ ): readonly string[] | null {
309
+ return planSourceRemoval(text, path, createdContainers)?.prunedContainers ?? null;
310
+ }
311
+
312
+ function removeSource(
313
+ text: string,
314
+ path: readonly string[],
315
+ createdContainers: readonly string[],
316
+ ): string | null {
317
+ return planSourceRemoval(text, path, createdContainers)?.text ?? null;
318
+ }
319
+
320
+ /**
321
+ * Patch one arbitrary plain block-map path, preserving every other byte.
322
+ */
323
+ export function patchYamlFragmentSource(
324
+ text: string,
325
+ path: readonly string[],
326
+ mutation: YamlFragmentMutation,
327
+ expected: unknown,
328
+ ): string | null {
329
+ let parsed: unknown;
330
+ try {
331
+ parsed = text.trim().length === 0 ? {} : Bun.YAML.parse(text);
332
+ } catch {
333
+ return null;
223
334
  }
224
- return semanticallyMatches(patched, expected) ? patched : null;
335
+ const patched = mutation.kind === "upsert"
336
+ ? upsertSource(text, parsed, path, mutation.value)
337
+ : removeSource(text, path, mutation.createdContainers);
338
+ return patched !== null && semanticallyMatches(patched, expected) ? patched : null;
339
+ }
340
+
341
+ /** Backward-compatible OMP wrapper around the generic path patcher. */
342
+ export function patchOmpYamlSource(
343
+ text: string,
344
+ mutation: OmpYamlMutation,
345
+ expected: unknown,
346
+ ): string | null {
347
+ return patchYamlFragmentSource(
348
+ text,
349
+ ["providers", "opencodex"],
350
+ mutation.kind === "upsert"
351
+ ? mutation
352
+ : {
353
+ kind: "remove",
354
+ createdContainers: mutation.removeEmptyProviders ? ["providers"] : [],
355
+ },
356
+ expected,
357
+ );
225
358
  }
@@ -12,6 +12,8 @@ import { homedir } from "node:os";
12
12
  import { join } from "node:path";
13
13
  import {
14
14
  EXPORT_CLIENTS,
15
+ dshConfigPath,
16
+ dshHomeDir,
15
17
  gajaeConfigPath,
16
18
  gajaeHomeDir,
17
19
  hermesConfigPath,
@@ -38,6 +40,10 @@ export interface IntegrationClientSpec {
38
40
  configPath: (env?: NodeJS.ProcessEnv, home?: string) => string;
39
41
  /** Directory whose existence is the cheap "is it installed?" signal. */
40
42
  detectDir: (env?: NodeJS.ProcessEnv, home?: string) => string;
43
+ /** Patch only this block-map YAML leaf; never re-render the shared file. */
44
+ sourcePreservingYaml?: { path: readonly string[] };
45
+ /** Coordinate the complete mutation through a sibling config lock. */
46
+ writerLock?: { suffix: ".lock" };
41
47
  }
42
48
 
43
49
  /**
@@ -74,6 +80,7 @@ export const INTEGRATION_CLIENTS: Record<IntegrationClientId, IntegrationClientS
74
80
  id: "omp",
75
81
  configPath: (env = process.env, home = homedir()) => ompModelsConfigPath(env, home),
76
82
  detectDir: (env = process.env, home = homedir()) => ompAgentDir(env, home),
83
+ sourcePreservingYaml: { path: ["providers", "opencodex"] },
77
84
  },
78
85
  hermes: {
79
86
  id: "hermes",
@@ -98,6 +105,13 @@ export const INTEGRATION_CLIENTS: Record<IntegrationClientId, IntegrationClientS
98
105
  configPath: (env = process.env, home = homedir()) => gajaeConfigPath(env, home),
99
106
  detectDir: (env = process.env, home = homedir()) => gajaeHomeDir(env, home),
100
107
  },
108
+ dsh: {
109
+ id: "dsh",
110
+ configPath: (env = process.env, home = homedir()) => dshConfigPath(env, home),
111
+ detectDir: (env = process.env, home = homedir()) => dshHomeDir(env, home),
112
+ sourcePreservingYaml: { path: ["llm-pi-ai", "providers", "opencodex"] },
113
+ writerLock: { suffix: ".lock" },
114
+ },
101
115
  };
102
116
 
103
117
  export const INTEGRATION_CLIENT_IDS: readonly IntegrationClientId[] =
@@ -221,10 +221,89 @@ export function renderToml(document: Record<string, unknown>, prefix = ""): stri
221
221
  return `${[scalars.join("\n"), tables.join("\n\n")].filter(Boolean).join("\n\n")}\n`;
222
222
  }
223
223
 
224
+ /**
225
+ * Ceiling on container nesting for json documents, shared by the parse-time
226
+ * scanner (config-io.ts) and the serializer walk below. One constant on
227
+ * purpose: the walk must accept every document the scanner admits, or a file
228
+ * the classifier reported as recoverable would refuse at rewrite time. Real
229
+ * configs nest a handful of levels.
230
+ */
231
+ export const MAX_JSON_NESTING = 1000;
232
+
233
+ /** Error messages carry the path to the offending value; keep them readable. */
234
+ function clampPath(path: string): string {
235
+ return path.length > 200 ? `${path.slice(0, 100)}…${path.slice(-100)}` : path;
236
+ }
237
+
238
+ /**
239
+ * JSON.stringify writes a non-finite number as `null` and -0 as `0`; any
240
+ * other finite double round-trips value-exactly (literal-level rounding is
241
+ * the parse-time scanner's concern), so those two are exactly what this walk
242
+ * refuses — refusing more turned a state the classifier had promised as
243
+ * recoverable into a permanent refusal. Documents read from disk are already
244
+ * guarded at parse time, and the writer's merge layer JSON-clones documents —
245
+ * normalizing these values — before serializing, so on the apply/disable path
246
+ * this walk is unreachable for them: it guards the direct serializers
247
+ * (preview/export builders), same posture as the YAML and TOML renderers
248
+ * above, and enforces the nesting ceiling for every json caller before the
249
+ * recursive JSON.stringify can turn depth into a RangeError.
250
+ *
251
+ * Iterative frames instead of recursion or a node stack: depth AND size of
252
+ * the document are inputs under the writer of the config file. Recursion made
253
+ * a deep file a RangeError-500; materializing every node with its path made a
254
+ * wide file allocate a large multiple of its size. Frames keep memory
255
+ * proportional to nesting depth, and path strings exist only for the
256
+ * containers on the current path plus the failing value itself.
257
+ */
258
+ function assertJsonNumbersRoundTrip(document: unknown, rootPath: string): void {
259
+ const refuse = (value: number, path: string): never => {
260
+ throw new UnserializableValueError(Object.is(value, -0)
261
+ ? `JSON cannot rewrite -0 at ${clampPath(path)} without changing it to 0`
262
+ : `JSON cannot rewrite the number at ${clampPath(path)} without changing it to null`);
263
+ };
264
+ if (typeof document === "number" && (!Number.isFinite(document) || Object.is(document, -0))) {
265
+ refuse(document, rootPath);
266
+ }
267
+ type Frame = { container: unknown; keys: string[] | null; index: number; prefix: string };
268
+ const frames: Frame[] = [];
269
+ const pushContainer = (value: unknown, prefix: string) => {
270
+ if (Array.isArray(value)) frames.push({ container: value, keys: null, index: 0, prefix });
271
+ else if (isPlainRecord(value)) frames.push({ container: value, keys: Object.keys(value), index: 0, prefix });
272
+ };
273
+ pushContainer(document, rootPath);
274
+ while (frames.length > 0) {
275
+ const frame = frames[frames.length - 1]!;
276
+ const length = frame.keys ? frame.keys.length : (frame.container as unknown[]).length;
277
+ if (frame.index >= length) { frames.pop(); continue; }
278
+ const i = frame.index;
279
+ frame.index += 1;
280
+ const child = frame.keys
281
+ ? (frame.container as Record<string, unknown>)[frame.keys[i]!]
282
+ : (frame.container as unknown[])[i];
283
+ const childPath = () => frame.keys
284
+ ? (frame.prefix === "$" ? frame.keys[i]! : `${frame.prefix}.${frame.keys[i]!}`)
285
+ : `${frame.prefix}[${i}]`;
286
+ if (typeof child === "number") {
287
+ if (!Number.isFinite(child) || Object.is(child, -0)) refuse(child, childPath());
288
+ continue;
289
+ }
290
+ if (typeof child === "object" && child !== null) {
291
+ if (frames.length >= MAX_JSON_NESTING) {
292
+ throw new UnserializableValueError(
293
+ `the document nests deeper than ${MAX_JSON_NESTING} levels at ${clampPath(childPath())}, which JSON serialization cannot rewrite safely`);
294
+ }
295
+ pushContainer(child, childPath());
296
+ }
297
+ }
298
+ }
299
+
224
300
  /** Every serializer returns text ending in exactly one newline. */
225
301
  export function serializeDocument(document: unknown, format: ConfigFormat): string {
226
302
  switch (format) {
227
- case "json": return `${JSON.stringify(document, null, 2)}\n`;
303
+ case "json": {
304
+ assertJsonNumbersRoundTrip(document, "$");
305
+ return `${JSON.stringify(document, null, 2)}\n`;
306
+ }
228
307
  case "json5": return `${Bun.JSON5.stringify(document, null, 2)}\n`;
229
308
  case "yaml": return renderYaml(document);
230
309
  case "toml": {
@@ -134,9 +134,15 @@ function recordedFragmentFingerprint(
134
134
  /**
135
135
  * The two-axis rule: the recorded bytes or fragments prove nobody changed
136
136
  * what we may rewrite, and the contribution hash proves our catalog has not
137
- * moved on. OMP is the sole fragment-scoped client because its writer patches
138
- * only `providers.opencodex`; every whole-document serializer retains the
139
- * whole-file fingerprint guard.
137
+ * moved on. Three classes of client (revising the unconditional whole-file
138
+ * rule of devlog 260802_client_toggle_api/021 §3 for json — #1631):
139
+ * registry-declared source-preserving YAML clients are fragment-scoped because
140
+ * their writers patch only the owned leaf, so the whole-file check is skipped;
141
+ * strict-json clients keep the whole-file check but downgrade a drift with
142
+ * intact owned fragments to `stale`, because a rewrite there can lose only
143
+ * formatting (comments cannot parse, non-round-tripping numbers are refused
144
+ * by the serializer); every comment-capable whole-document serializer (yaml,
145
+ * json5, toml) retains the whole-file fingerprint guard as a hard conflict.
140
146
  */
141
147
  export function classifyIntegration(input: {
142
148
  fileText: string | null;
@@ -180,12 +186,38 @@ export function classifyIntegration(input: {
180
186
  return { state: "conflict", reason: "unowned-key" };
181
187
  }
182
188
  const clientId = input.clientId ?? input.record.clientId;
183
- if (clientId !== "omp" && fingerprint(input.fileText ?? "") !== input.record.fileFingerprint) {
184
- return { state: "conflict", reason: "foreign-edit" };
185
- }
189
+ /*
190
+ * Checked BEFORE file-level drift: an edit INSIDE an owned fragment is a
191
+ * conflict no matter what the rest of the file looks like, so the sibling-
192
+ * edit exemption below can never mask it.
193
+ */
186
194
  if (recordedFragmentFingerprint(input.parsed, input.record) !== input.record.blockFingerprint) {
187
195
  return { state: "conflict", reason: "foreign-edit" };
188
196
  }
197
+ if (!INTEGRATION_CLIENTS[clientId].sourcePreservingYaml
198
+ && fingerprint(input.fileText ?? "") !== input.record.fileFingerprint) {
199
+ /*
200
+ * The file changed since we wrote it, but every fragment we own is still
201
+ * byte-for-byte what we put there — a sibling edit, not tampering. Apply
202
+ * rewrites the WHOLE document, so for comment-capable formats (yaml,
203
+ * json5, toml) it would drop comments the user wrote next to us: fail
204
+ * closed there. Strict JSON cannot carry comments — a commented file
205
+ * never reaches this branch because parsing already failed — so the only
206
+ * possible loss is formatting normalization: everything a rewrite would
207
+ * actually change (numbers that would not round-trip, duplicate members
208
+ * a rewrite would delete) is PARSE_FAILED in parseConfig and classifies
209
+ * as unsafe long before this branch, exactly like comments. Refusing
210
+ * forever over formatting
211
+ * dead-ends the integration on the user's first own config edit (#1631).
212
+ * Report drift instead; a re-apply merges into the parsed document as it
213
+ * stands and re-owns the file. This also lets disable proceed on a
214
+ * drifted file — removal still touches only the recorded fragment paths.
215
+ */
216
+ if (EXPORT_CLIENTS[clientId].format !== "json") {
217
+ return { state: "conflict", reason: "foreign-edit" };
218
+ }
219
+ return { state: "stale" };
220
+ }
189
221
  return input.record.blockFingerprint === fingerprint(canonicalContribution(input.contribution))
190
222
  ? { state: "current" }
191
223
  : { state: "stale" };