@bitkyc08/opencodex 2.11.1 → 2.12.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 (175) hide show
  1. package/README.md +1 -1
  2. package/bin/ocx.mjs +4 -0
  3. package/gui/dist/assets/index-BNVYzdn0.css +1 -0
  4. package/gui/dist/assets/index-Cw1_mxO8.js +70 -0
  5. package/gui/dist/index.html +2 -2
  6. package/package.json +1 -1
  7. package/src/adapters/command-code.ts +94 -5
  8. package/src/adapters/cursor/live-transport.ts +14 -1
  9. package/src/adapters/google-tool-schema.ts +80 -15
  10. package/src/adapters/google.ts +24 -7
  11. package/src/adapters/openai-chat.ts +233 -305
  12. package/src/adapters/openai-responses.ts +62 -0
  13. package/src/adapters/tool-catalog-nudge.ts +2 -11
  14. package/src/claude/outbound.ts +42 -14
  15. package/src/cli/claude.ts +61 -9
  16. package/src/cli/codex-shim-autorestore.ts +2 -0
  17. package/src/cli/codex-shim-readiness.ts +8 -1
  18. package/src/cli/config-command.ts +4 -1
  19. package/src/cli/doctor.ts +10 -0
  20. package/src/cli/help.ts +15 -0
  21. package/src/cli/index.ts +27 -11
  22. package/src/cli/lab.ts +367 -0
  23. package/src/cli/provider.ts +9 -1
  24. package/src/clients/config-export.ts +100 -3
  25. package/src/codex/app-server-processes.ts +11 -8
  26. package/src/codex/auth-api.ts +92 -28
  27. package/src/codex/auth-collision.ts +5 -3
  28. package/src/codex/catalog/metadata.ts +7 -3
  29. package/src/codex/catalog/provider-fetch.ts +191 -47
  30. package/src/codex/catalog.ts +2 -2
  31. package/src/codex/history-job.ts +83 -18
  32. package/src/codex/history-migration-guardian.ts +9 -16
  33. package/src/codex/history-provider.ts +178 -1
  34. package/src/codex/history-transition.ts +5 -5
  35. package/src/codex/history-worker.ts +22 -2
  36. package/src/codex/native-profile-store.ts +1 -1
  37. package/src/codex/plan.ts +15 -0
  38. package/src/codex/quota.ts +6 -6
  39. package/src/codex/routing.ts +3 -3
  40. package/src/codex/subagent-model-fallback.ts +246 -22
  41. package/src/codex/user-identity.ts +63 -12
  42. package/src/config.ts +265 -32
  43. package/src/images/loop.ts +3 -1
  44. package/src/images/plan.ts +23 -13
  45. package/src/integrations/omp-yaml-source.ts +225 -0
  46. package/src/integrations/ownership.ts +5 -5
  47. package/src/integrations/state.ts +45 -6
  48. package/src/integrations/writer.ts +38 -3
  49. package/src/lab/artifacts/sanitize.ts +586 -0
  50. package/src/lab/artifacts/secure-fs.ts +475 -0
  51. package/src/lab/artifacts/store.ts +310 -0
  52. package/src/lab/conformance/assertion.ts +325 -0
  53. package/src/lab/conformance/digest.ts +22 -0
  54. package/src/lab/conformance/executor.ts +741 -0
  55. package/src/lab/conformance/fixture-provider.ts +27 -0
  56. package/src/lab/conformance/fixtures/live-v1-cases.json +175 -0
  57. package/src/lab/conformance/fixtures/protocol-v1-cases.json +461 -0
  58. package/src/lab/conformance/harness-budget.ts +47 -0
  59. package/src/lab/conformance/index.ts +5 -0
  60. package/src/lab/conformance/jcs.ts +24 -0
  61. package/src/lab/conformance/json-pointer.ts +39 -0
  62. package/src/lab/conformance/manifest.ts +180 -0
  63. package/src/lab/conformance/mcp-stub.ts +179 -0
  64. package/src/lab/conformance/negative-controls.ts +160 -0
  65. package/src/lab/conformance/observation.ts +355 -0
  66. package/src/lab/conformance/runner.ts +57 -0
  67. package/src/lab/conformance/sse-normalize.ts +59 -0
  68. package/src/lab/conformance/suite-manifest.ts +78 -0
  69. package/src/lab/conformance/types.ts +191 -0
  70. package/src/lab/constants.ts +126 -0
  71. package/src/lab/digest.ts +64 -0
  72. package/src/lab/events/errors.ts +9 -0
  73. package/src/lab/events/limits.ts +117 -0
  74. package/src/lab/events/types.ts +228 -0
  75. package/src/lab/events/validate.ts +780 -0
  76. package/src/lab/index.ts +36 -0
  77. package/src/lab/ledger/artifact-refs.ts +127 -0
  78. package/src/lab/ledger/invalidation.ts +136 -0
  79. package/src/lab/ledger/purge.ts +241 -0
  80. package/src/lab/ledger/store.ts +263 -0
  81. package/src/lab/live/credential-lease.ts +53 -0
  82. package/src/lab/live/destination.ts +155 -0
  83. package/src/lab/live/executor.ts +311 -0
  84. package/src/lab/live/inert-tools.ts +56 -0
  85. package/src/lab/live/manifest.ts +85 -0
  86. package/src/lab/live/mcp-loopback.ts +57 -0
  87. package/src/lab/live/runner.ts +19 -0
  88. package/src/lab/live/sandbox.ts +61 -0
  89. package/src/lab/live/suite-manifest.ts +41 -0
  90. package/src/lab/live/transport.ts +116 -0
  91. package/src/lab/live/types.ts +197 -0
  92. package/src/lab/observe/from-conformance.ts +344 -0
  93. package/src/lab/observe/from-live.ts +103 -0
  94. package/src/lab/paths.ts +65 -0
  95. package/src/lab/projection/rebuild.ts +477 -0
  96. package/src/lab/projection/schema.ts +122 -0
  97. package/src/lab/projection/verdicts.ts +438 -0
  98. package/src/lab/projection/verification.ts +332 -0
  99. package/src/lab/query/catalog.ts +72 -0
  100. package/src/lab/query/connection.ts +107 -0
  101. package/src/lab/query/constants.ts +4 -0
  102. package/src/lab/query/cursor.ts +132 -0
  103. package/src/lab/query/dto-map.ts +277 -0
  104. package/src/lab/query/errors.ts +22 -0
  105. package/src/lab/query/index.ts +34 -0
  106. package/src/lab/query/queries.ts +444 -0
  107. package/src/lab/query/types.ts +266 -0
  108. package/src/lab/subject/behavior-fingerprint.ts +77 -0
  109. package/src/lab/subject/installation-salt.ts +81 -0
  110. package/src/lab/subject/route-subject.ts +73 -0
  111. package/src/lib/bounded-body.ts +104 -0
  112. package/src/lib/lab-live-execution-authority.ts +13 -0
  113. package/src/lib/lab-live-host.ts +30 -0
  114. package/src/lib/lab-live-pinned-sender.ts +45 -0
  115. package/src/lib/pinned-http.ts +36 -7
  116. package/src/oauth/index.ts +20 -1
  117. package/src/oauth/local-token-detect.ts +11 -2
  118. package/src/oauth/login-cli.ts +38 -4
  119. package/src/providers/alibaba-region-migration.ts +1 -1
  120. package/src/providers/codex-capacity.ts +7 -4
  121. package/src/providers/command-code-efforts.ts +5 -2
  122. package/src/providers/derive.ts +105 -0
  123. package/src/providers/label.ts +1 -1
  124. package/src/providers/openai-tiers.ts +59 -3
  125. package/src/providers/opencode-zen-rate-limit.ts +102 -0
  126. package/src/providers/quota.ts +67 -21
  127. package/src/providers/registry.ts +118 -5
  128. package/src/router.ts +15 -1
  129. package/src/routing/analytics.ts +8 -7
  130. package/src/routing/evaluator.ts +51 -5
  131. package/src/routing/profile.ts +10 -1
  132. package/src/routing/trace.ts +42 -6
  133. package/src/server/auth-cors.ts +15 -0
  134. package/src/server/background-lifecycle.ts +169 -0
  135. package/src/server/chat-completions.ts +18 -3
  136. package/src/server/claude-messages.ts +1 -0
  137. package/src/server/images.ts +78 -11
  138. package/src/server/index.ts +72 -18
  139. package/src/server/lifecycle.ts +6 -0
  140. package/src/server/live.ts +5 -1
  141. package/src/server/management/agent-settings-routes.ts +41 -24
  142. package/src/server/management/body.ts +6 -0
  143. package/src/server/management/config-routes.ts +51 -8
  144. package/src/server/management/lab-routes.ts +388 -0
  145. package/src/server/management/logs-usage-routes.ts +19 -1
  146. package/src/server/management/model-rows.ts +25 -11
  147. package/src/server/management/native-integration-routes.ts +3 -3
  148. package/src/server/management/provider-routes.ts +5 -0
  149. package/src/server/management/shared.ts +8 -1
  150. package/src/server/management/usage-summary-cache.ts +2 -0
  151. package/src/server/management/vision-sidecar-options.ts +125 -0
  152. package/src/server/management-api.ts +2 -0
  153. package/src/server/request-decompress.ts +106 -5
  154. package/src/server/responses/collaboration.ts +4 -1
  155. package/src/server/responses/core.ts +117 -31
  156. package/src/server/responses-json-events.ts +50 -12
  157. package/src/server/search.ts +23 -7
  158. package/src/server/ws-bridge.ts +3 -1
  159. package/src/service.ts +40 -20
  160. package/src/types.ts +74 -0
  161. package/src/update/job.ts +10 -2
  162. package/src/usage/cost.ts +0 -0
  163. package/src/usage/expected-prices.ts +9 -0
  164. package/src/usage/log.ts +9 -9
  165. package/src/usage/user-cost-overlay-reconciler.ts +313 -0
  166. package/src/usage/user-cost-overlays.ts +314 -0
  167. package/src/vision/describe.ts +2 -2
  168. package/src/vision/eligibility.ts +229 -0
  169. package/src/vision/index.ts +20 -2
  170. package/src/web-search/executor.ts +2 -2
  171. package/src/web-search/index.ts +4 -2
  172. package/src/web-search/loop.ts +3 -1
  173. package/src/web-search/parse.ts +72 -3
  174. package/gui/dist/assets/index-BA1dgo4q.css +0 -1
  175. package/gui/dist/assets/index-DDZpgzKk.js +0 -70
@@ -0,0 +1,225 @@
1
+ /**
2
+ * Source-preserving mutation for the one YAML fragment managed by OMP.
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.
12
+ */
13
+ import { renderYaml } from "./serialize";
14
+
15
+ interface SourceLine {
16
+ start: number;
17
+ end: number;
18
+ body: string;
19
+ }
20
+
21
+ export type OmpYamlMutation =
22
+ | { kind: "upsert"; value: unknown }
23
+ | { kind: "remove"; removeEmptyProviders: boolean };
24
+
25
+ function sourceLines(text: string): SourceLine[] {
26
+ const lines: SourceLine[] = [];
27
+ const matcher = /[^\r\n]*(?:\r\n|\n|$)/gu;
28
+ for (const match of text.matchAll(matcher)) {
29
+ const raw = match[0];
30
+ if (raw.length === 0) continue;
31
+ const start = match.index;
32
+ const body = raw.endsWith("\r\n")
33
+ ? raw.slice(0, -2)
34
+ : raw.endsWith("\n") ? raw.slice(0, -1) : raw;
35
+ lines.push({ start, end: start + raw.length, body });
36
+ }
37
+ return lines;
38
+ }
39
+
40
+ function leadingSpaces(line: string): number | null {
41
+ const leading = line.match(/^[ \t]*/u)?.[0] ?? "";
42
+ return leading.includes("\t") ? null : leading.length;
43
+ }
44
+
45
+ function isBlank(line: string): boolean {
46
+ return line.trim().length === 0;
47
+ }
48
+
49
+ function isComment(line: string): boolean {
50
+ return line.trimStart().startsWith("#");
51
+ }
52
+
53
+ function hasInlineComment(line: string): boolean {
54
+ return line.includes("#");
55
+ }
56
+
57
+ function isPlainBlockKey(line: string, indent: number, key: string): boolean {
58
+ const spaces = leadingSpaces(line);
59
+ if (spaces !== indent) return false;
60
+ const rest = line.slice(indent);
61
+ return new RegExp(`^${key}:[ ]*(?:#.*)?$`, "u").test(rest);
62
+ }
63
+
64
+ function containerEnd(lines: readonly SourceLine[], start: number, indent: number): number | null {
65
+ for (let index = start + 1; index < lines.length; index += 1) {
66
+ const body = lines[index]!.body;
67
+ const spaces = leadingSpaces(body);
68
+ if (spaces === null) return null;
69
+ if (isBlank(body)) continue;
70
+ if (isComment(body)) {
71
+ if (spaces <= indent) return index;
72
+ continue;
73
+ }
74
+ if (spaces <= indent) return index;
75
+ }
76
+ return lines.length;
77
+ }
78
+
79
+ function childEnd(
80
+ lines: readonly SourceLine[],
81
+ start: number,
82
+ parentEnd: number,
83
+ indent: number,
84
+ ): number | null {
85
+ for (let index = start + 1; index < parentEnd; index += 1) {
86
+ const body = lines[index]!.body;
87
+ const spaces = leadingSpaces(body);
88
+ 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.
92
+ if (isBlank(body)) return index;
93
+ if (isComment(body)) return spaces <= indent ? index : null;
94
+ if (spaces <= indent) return index;
95
+ if (hasInlineComment(body)) return null;
96
+ }
97
+ return parentEnd;
98
+ }
99
+
100
+ function canonicalValue(value: unknown): unknown {
101
+ if (Array.isArray(value)) return value.map(canonicalValue);
102
+ if (value && typeof value === "object") {
103
+ return Object.fromEntries(
104
+ Object.entries(value as Record<string, unknown>)
105
+ .sort(([left], [right]) => left.localeCompare(right))
106
+ .map(([key, child]) => [key, canonicalValue(child)]),
107
+ );
108
+ }
109
+ return value;
110
+ }
111
+
112
+ function semanticallyMatches(text: string, expected: unknown): boolean {
113
+ try {
114
+ const parsed = text.trim().length === 0 ? {} : Bun.YAML.parse(text);
115
+ return JSON.stringify(canonicalValue(parsed))
116
+ === JSON.stringify(canonicalValue(expected));
117
+ } catch {
118
+ return false;
119
+ }
120
+ }
121
+
122
+ function lineEnding(text: string): "\n" | "\r\n" {
123
+ return text.includes("\r\n") ? "\r\n" : "\n";
124
+ }
125
+
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;
151
+ }
152
+ if (providerIndexes.length !== 1) return null;
153
+
154
+ const providersIndex = providerIndexes[0]!;
155
+ const providersEnd = containerEnd(lines, providersIndex, 0);
156
+ if (providersEnd === null) return null;
157
+
158
+ let inferredIndent: number | null = null;
159
+ for (let index = providersIndex + 1; index < providersEnd; index += 1) {
160
+ const body = lines[index]!.body;
161
+ const spaces = leadingSpaces(body);
162
+ if (spaces === null) return null;
163
+ if (!isBlank(body) && !isComment(body) && spaces > 0) {
164
+ inferredIndent = inferredIndent === null ? spaces : Math.min(inferredIndent, spaces);
165
+ }
166
+ }
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;
186
+ }
187
+
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;
194
+ const endOffset = endIndex < lines.length ? lines[endIndex]!.start : text.length;
195
+
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;
199
+ }
200
+
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
+ }
223
+ }
224
+ return semanticallyMatches(patched, expected) ? patched : null;
225
+ }
@@ -2,10 +2,10 @@
2
2
  * What opencodex remembers about a client between operations.
3
3
  *
4
4
  * Two hashes, because the two questions are genuinely independent: the FILE
5
- * hash answers "did anyone touch this after us", and the BLOCK hash answers "is
6
- * our content still what we would write today". One hash cannot do both, and
7
- * conflating them is what lets a foreign edit read as ordinary drift — which
8
- * would then be silently overwritten.
5
+ * hash identifies the exact result for restore and for clients whose writer
6
+ * re-serializes the whole document. The BLOCK hash identifies OMP's surgically
7
+ * patched fragment and detects catalog drift. One hash cannot safely answer
8
+ * both questions in a shared client config.
9
9
  *
10
10
  * Design of record: devlog/_fin/260802_client_toggle_api/021 §2.
11
11
  */
@@ -37,7 +37,7 @@ export function canonicalContribution(contribution: ManagedContribution): string
37
37
  export interface OwnershipRecord {
38
38
  clientId: IntegrationClientId;
39
39
  configPath: string;
40
- /** Hash of the WHOLE file as we left it — detects foreign edits after us. */
40
+ /** Hash of the WHOLE file as we left it — protects whole-file restore. */
41
41
  fileFingerprint: string;
42
42
  /** Hash of our contribution — detects catalog/port drift. */
43
43
  blockFingerprint: string;
@@ -2,9 +2,9 @@
2
2
  * "What is on disk, and did we put it there?"
3
3
  *
4
4
  * The classifier is deliberately ordered, and the order is load-bearing: an
5
- * unreadable file can never be reported as absent, and a foreign edit can never
6
- * be reported as ordinary drift. Getting that wrong would let `disable` delete
7
- * a user's own edits.
5
+ * unreadable file can never be reported as absent, and an edit to a fragment
6
+ * we own can never be reported as ordinary drift. Getting that wrong would let
7
+ * `disable` delete a user's own edits.
8
8
  *
9
9
  * Design of record: devlog/_fin/260802_client_toggle_api/021 §3.
10
10
  */
@@ -100,8 +100,43 @@ export function blockedContainerPath(
100
100
  }
101
101
 
102
102
  /**
103
- * The two-axis rule: the FILE hash proves nobody touched the file after us, and
104
- * the BLOCK hash proves our content is still what we would write today.
103
+ * Fingerprint the recorded fragments as they appear in the document now.
104
+ *
105
+ * The record intentionally names every path we own. Comparing just those
106
+ * values lets another integration or a user add a sibling without blocking a
107
+ * later refresh, while a change inside our block still fails closed.
108
+ */
109
+ function recordedFragmentFingerprint(
110
+ doc: unknown,
111
+ record: OwnershipRecord,
112
+ ): string | null {
113
+ if (
114
+ !Array.isArray(record.fragmentPaths)
115
+ || record.fragmentPaths.length === 0
116
+ || !record.fragmentPaths.every(path => (
117
+ Array.isArray(path)
118
+ && path.length > 0
119
+ && path.every(key => typeof key === "string")
120
+ ))
121
+ ) return null;
122
+ const fragments = [];
123
+ for (const path of record.fragmentPaths) {
124
+ const value = readPath(doc, path);
125
+ if (value === undefined) return null;
126
+ fragments.push({ path, value });
127
+ }
128
+ return fingerprint(canonicalContribution({
129
+ clientId: record.clientId,
130
+ fragments,
131
+ }));
132
+ }
133
+
134
+ /**
135
+ * The two-axis rule: the recorded bytes or fragments prove nobody changed
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.
105
140
  */
106
141
  export function classifyIntegration(input: {
107
142
  fileText: string | null;
@@ -144,7 +179,11 @@ export function classifyIntegration(input: {
144
179
  if (input.configPath !== undefined && input.record.configPath !== input.configPath) {
145
180
  return { state: "conflict", reason: "unowned-key" };
146
181
  }
147
- if (fingerprint(input.fileText ?? "") !== input.record.fileFingerprint) {
182
+ 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
+ }
186
+ if (recordedFragmentFingerprint(input.parsed, input.record) !== input.record.blockFingerprint) {
148
187
  return { state: "conflict", reason: "foreign-edit" };
149
188
  }
150
189
  return input.record.blockFingerprint === fingerprint(canonicalContribution(input.contribution))
@@ -10,7 +10,7 @@
10
10
  * Design of record: devlog/_fin/260802_client_toggle_api/030 and 031.
11
11
  */
12
12
  import { dirname } from "node:path";
13
- import { EXPORT_CLIENTS, type ExportModel } from "../clients/config-export";
13
+ import { EXPORT_CLIENTS, type ExportModel, type ManagedContribution } from "../clients/config-export";
14
14
  import { isLoopbackHostname } from "../codex/inject";
15
15
  import type { OcxConfig } from "../types";
16
16
  import { PARSE_FAILED, defaultIntegrationIO, loadTarget, parseConfig, type IntegrationIO } from "./config-io";
@@ -23,6 +23,7 @@ import { serializeDocument, UnserializableValueError } from "./serialize";
23
23
  import { ClientPathError } from "../clients/config-export";
24
24
  import { matchesOperationResult, newOpId, type JournalEntry } from "./journal";
25
25
  import { createIntegrationStateStore, type IntegrationStateStore } from "./store";
26
+ import { patchOmpYamlSource } from "./omp-yaml-source";
26
27
 
27
28
  export type RefusalReason =
28
29
  | "not_installed"
@@ -168,6 +169,15 @@ function snapshotAbsPath(store: IntegrationStateStore, entry: JournalEntry): str
168
169
  return snapshot.kind === "stored" ? snapshot.path : undefined;
169
170
  }
170
171
 
172
+ function ompFragmentValue(contribution: ManagedContribution): unknown | undefined {
173
+ const fragment = contribution.fragments.find(item => (
174
+ item.path.length === 2
175
+ && item.path[0] === "providers"
176
+ && item.path[1] === "opencodex"
177
+ ));
178
+ return fragment?.value;
179
+ }
180
+
171
181
  /** Shared preflight: detect, gate, read, parse and classify. */
172
182
  function preflight(input: IntegrationWriteInput) {
173
183
  const store = input.store ?? createIntegrationStateStore();
@@ -280,9 +290,22 @@ export function applyIntegration(input: IntegrationWriteInput): WriteOutcome {
280
290
  * user as a 500 with no path and no advice; it is a refusal like any other,
281
291
  * and the file is untouched because this happens before any write.
282
292
  */
293
+ const nextDocument = mergeContribution(base, contribution);
283
294
  let text: string;
284
295
  try {
285
- text = serializeDocument(mergeContribution(base, contribution), exportSpec.format);
296
+ if (clientId === "omp" && before !== null) {
297
+ const value = ompFragmentValue(contribution);
298
+ const patched = value === undefined
299
+ ? null
300
+ : patchOmpYamlSource(before, { kind: "upsert", value }, nextDocument);
301
+ if (patched === null) {
302
+ return refuse(clientId, "unsafe", "unsafe",
303
+ `${configPath} uses YAML source opencodex cannot patch without risking unrelated comments or formatting, so it was left alone`);
304
+ }
305
+ text = patched;
306
+ } else {
307
+ text = serializeDocument(nextDocument, exportSpec.format);
308
+ }
286
309
  } catch (error) {
287
310
  if (!(error instanceof UnserializableValueError)) throw error;
288
311
  return refuse(clientId, "unsafe", "unsafe",
@@ -357,7 +380,19 @@ export function disableIntegration(input: IntegrationWriteInput): WriteOutcome {
357
380
  }
358
381
  let text: string;
359
382
  try {
360
- text = serializeDocument(doc, exportSpec.format);
383
+ if (clientId === "omp" && before !== null) {
384
+ const patched = patchOmpYamlSource(before, {
385
+ kind: "remove",
386
+ removeEmptyProviders: record!.createdContainers?.includes("providers") === true,
387
+ }, doc);
388
+ if (patched === null) {
389
+ return refuse(clientId, "unsafe", "unsafe",
390
+ `${configPath} uses YAML source opencodex cannot patch without risking unrelated comments or formatting, so nothing was removed`);
391
+ }
392
+ text = patched;
393
+ } else {
394
+ text = serializeDocument(doc, exportSpec.format);
395
+ }
361
396
  } catch (error) {
362
397
  if (!(error instanceof UnserializableValueError)) throw error;
363
398
  return refuse(clientId, "unsafe", "unsafe",