@bitkyc08/opencodex 2.60.0 → 2.61.0-preview.20260922

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 (249) hide show
  1. package/AGENTS_INSTALL.md +64 -0
  2. package/README.md +28 -1
  3. package/bin/ocx.mjs +382 -209
  4. package/gui/dist/assets/App-E64Rzjap.js +50 -0
  5. package/gui/dist/assets/Tray-_nfzD8k4.js +1 -0
  6. package/gui/dist/assets/index-DpdfZWMK.js +86 -0
  7. package/gui/dist/assets/index-_bpvxJu0.css +1 -0
  8. package/gui/dist/assets/usage-companion-chart-DtoK7T6h.js +1 -0
  9. package/gui/dist/favicon.png +0 -0
  10. package/gui/dist/index.html +2 -2
  11. package/gui/dist/provider-icons/stepfun-color.svg +1 -0
  12. package/package.json +5 -1
  13. package/src/adapters/anthropic.ts +16 -0
  14. package/src/adapters/coding-agent/protocol.ts +36 -6
  15. package/src/adapters/coding-agent/turn.ts +10 -2
  16. package/src/adapters/command-code.ts +2 -1
  17. package/src/adapters/cursor/catalog.ts +51 -7
  18. package/src/adapters/cursor/protobuf-request.ts +6 -3
  19. package/src/adapters/cursor/request-builder.ts +13 -3
  20. package/src/adapters/cursor.ts +11 -2
  21. package/src/adapters/declaration-carrier.ts +45 -0
  22. package/src/adapters/devin.ts +75 -23
  23. package/src/adapters/google-antigravity-wire.ts +5 -2
  24. package/src/adapters/google-errors.ts +7 -1
  25. package/src/adapters/google.ts +29 -5
  26. package/src/adapters/image.ts +4 -1
  27. package/src/adapters/input-media-guard.ts +21 -9
  28. package/src/adapters/kiro/usage.ts +3 -2
  29. package/src/adapters/kiro-tool-fallback.ts +1 -1
  30. package/src/adapters/ollama-native.ts +6 -0
  31. package/src/adapters/openai-chat/developer-role.ts +61 -0
  32. package/src/adapters/openai-chat/messages.ts +46 -27
  33. package/src/adapters/openai-chat/parallel-tool-calls.ts +32 -0
  34. package/src/adapters/openai-chat/passthrough.ts +33 -9
  35. package/src/adapters/openai-chat/reasoning-wire.ts +89 -0
  36. package/src/adapters/openai-chat.ts +18 -57
  37. package/src/adapters/openai-responses/passthrough.ts +2 -0
  38. package/src/adapters/registry.ts +3 -2
  39. package/src/adapters/run-turn-queue.ts +178 -29
  40. package/src/adapters/xai-web-search.ts +16 -1
  41. package/src/bridge/errors.ts +8 -2
  42. package/src/bridge/response-json.ts +9 -1
  43. package/src/bridge/sse.ts +10 -0
  44. package/src/chat/inbound.ts +141 -5
  45. package/src/claude/desktop-3p.ts +7 -1
  46. package/src/claude/desktop-first-party.ts +183 -0
  47. package/src/claude/desktop-gateway-state.ts +41 -0
  48. package/src/claude/inbound-content-options.ts +6 -0
  49. package/src/claude/inbound.ts +32 -6
  50. package/src/claude/intercept/connect-proxy.ts +179 -0
  51. package/src/claude/intercept/listener.ts +122 -0
  52. package/src/claude/intercept/local-ca.ts +298 -0
  53. package/src/claude/intercept/runtime.ts +98 -0
  54. package/src/claude/intercept/settings.ts +189 -0
  55. package/src/cli/access.ts +87 -0
  56. package/src/cli/account-auth.ts +19 -0
  57. package/src/cli/capabilities.ts +31 -0
  58. package/src/cli/claude-desktop.ts +206 -16
  59. package/src/cli/codex-shim-autorestore.ts +3 -0
  60. package/src/cli/companion.ts +56 -0
  61. package/src/cli/dispatch.ts +43 -4
  62. package/src/cli/ensure-desired-integrations.ts +43 -5
  63. package/src/cli/help.ts +7 -9
  64. package/src/cli/index.ts +200 -61
  65. package/src/cli/init.ts +8 -0
  66. package/src/cli/integrations.ts +7 -1
  67. package/src/cli/registry.ts +41 -2
  68. package/src/cli/resolve.ts +230 -0
  69. package/src/cli/root.ts +24 -1
  70. package/src/cli/start-ownership-publication.ts +56 -0
  71. package/src/cli/status-probes.ts +2 -18
  72. package/src/cli/status.ts +62 -0
  73. package/src/cli/stop-report.ts +143 -0
  74. package/src/cli/uninstall-plan.ts +9 -0
  75. package/src/client/machine-listener.ts +2 -5
  76. package/src/clients/aside-profiles.ts +4 -0
  77. package/src/clients/config-export/zcode-store.ts +157 -0
  78. package/src/clients/config-export.ts +36 -0
  79. package/src/codex/app-server-processes.ts +72 -40
  80. package/src/codex/auth-api/login-flow.ts +6 -1
  81. package/src/codex/autostart-health.ts +28 -0
  82. package/src/codex/catalog/build-entries.ts +2 -2
  83. package/src/codex/catalog/effort.ts +3 -3
  84. package/src/codex/catalog/provider-models.ts +24 -15
  85. package/src/codex/catalog/retained-sync.ts +2 -2
  86. package/src/codex/convergence.ts +2 -2
  87. package/src/codex/history-provider.ts +12 -1
  88. package/src/codex/inject/config-toml.ts +41 -6
  89. package/src/codex/inject/paginated-openai-compat.ts +90 -0
  90. package/src/codex/inject.ts +18 -15
  91. package/src/codex/injected-marker.ts +18 -0
  92. package/src/codex/main-account.ts +6 -0
  93. package/src/codex/model-cache.ts +52 -6
  94. package/src/codex/model-entitlement-admission.ts +59 -0
  95. package/src/codex/model-entitlements.ts +87 -44
  96. package/src/codex/native-main-admission.ts +83 -0
  97. package/src/codex/routing/health-store.ts +39 -0
  98. package/src/codex/routing/selection.ts +37 -1
  99. package/src/codex/routing.ts +5 -41
  100. package/src/codex/shim-templates.ts +29 -3
  101. package/src/companion/settings.ts +132 -0
  102. package/src/config/atomic-write.ts +117 -5
  103. package/src/config/load-degrade.ts +34 -7
  104. package/src/config/process-state.ts +1 -1
  105. package/src/config/schema/config-schema.ts +27 -1
  106. package/src/config/schema/leaf-validators.ts +47 -0
  107. package/src/config.ts +1 -1
  108. package/src/generated/compatibility-version.json +418 -174
  109. package/src/integrations/config-io.ts +44 -10
  110. package/src/integrations/merge.ts +120 -13
  111. package/src/integrations/mutation-plan.ts +124 -18
  112. package/src/integrations/registry.ts +38 -0
  113. package/src/integrations/state.ts +78 -45
  114. package/src/integrations/target.ts +208 -0
  115. package/src/integrations/writer.ts +49 -11
  116. package/src/lab/conformance/fixture-provider.ts +5 -0
  117. package/src/lib/browser-launch-notice.ts +59 -0
  118. package/src/lib/bun-runtime.ts +6 -2
  119. package/src/lib/debug.ts +40 -0
  120. package/src/lib/open-url.ts +51 -7
  121. package/src/lib/package-tree-integrity.ts +2 -1
  122. package/src/lib/package-version.ts +8 -0
  123. package/src/lib/provider-egress.ts +310 -0
  124. package/src/lib/provider-outbound.ts +59 -14
  125. package/src/lib/proxy-env.ts +82 -7
  126. package/src/lib/request-execution-budget.ts +72 -0
  127. package/src/lib/request-failure-attribution.ts +183 -0
  128. package/src/lib/request-failure-model.ts +236 -0
  129. package/src/lib/request-resend-gate.ts +138 -0
  130. package/src/lib/standalone.ts +16 -0
  131. package/src/lib/upstream-retry.ts +167 -16
  132. package/src/lib/winsw.ts +2 -2
  133. package/src/oauth/index.ts +24 -1
  134. package/src/oauth/login-cli.ts +80 -29
  135. package/src/providers/api-key-resolve.ts +133 -0
  136. package/src/providers/api-key-selection.ts +5 -1
  137. package/src/providers/key-failover.ts +31 -1
  138. package/src/providers/key-store.ts +34 -110
  139. package/src/providers/model-rename-fields.ts +147 -0
  140. package/src/providers/model-rename-migration.ts +124 -37
  141. package/src/providers/quota/vendor-probes-key.ts +37 -22
  142. package/src/providers/reasoning-metadata.ts +43 -18
  143. package/src/providers/registry/entries-core.ts +9 -4
  144. package/src/providers/registry/entries-extended.ts +29 -4
  145. package/src/providers/registry/model-seeds.ts +47 -10
  146. package/src/providers/xai-transport.ts +12 -1
  147. package/src/reasoning-effort.ts +8 -0
  148. package/src/responses/function-call-compat.ts +38 -1
  149. package/src/responses/inline-document.ts +65 -0
  150. package/src/responses/input-media.ts +42 -8
  151. package/src/responses/muse-tool-name-alias.ts +19 -0
  152. package/src/responses/parser-content.ts +8 -2
  153. package/src/responses/parser-tools.ts +3 -0
  154. package/src/responses/parser.ts +3 -1
  155. package/src/responses/schema.ts +3 -0
  156. package/src/router.ts +17 -2
  157. package/src/server/admission-model-scope.ts +219 -0
  158. package/src/server/audio-live.ts +9 -3
  159. package/src/server/audio-upstream.ts +18 -0
  160. package/src/server/auth-cors.ts +26 -0
  161. package/src/server/chat-completions.ts +55 -2
  162. package/src/server/chat-native.ts +19 -4
  163. package/src/server/claude-messages.ts +55 -17
  164. package/src/server/grok-responses-snapshot-repair.ts +113 -11
  165. package/src/server/gui-freshness.ts +103 -0
  166. package/src/server/gui-static.ts +7 -9
  167. package/src/server/images.ts +59 -6
  168. package/src/server/index/claude-intercept-lifecycle.ts +49 -0
  169. package/src/server/index/serve-options.ts +56 -10
  170. package/src/server/index/spend-ledger-lifecycle.ts +34 -8
  171. package/src/server/index/startup-warnings.ts +24 -0
  172. package/src/server/index.ts +21 -28
  173. package/src/server/lifecycle.ts +4 -4
  174. package/src/server/live-call-bindings.ts +6 -0
  175. package/src/server/live.ts +88 -3
  176. package/src/server/management/agent-settings-routes.ts +121 -36
  177. package/src/server/management/companion-routes.ts +77 -0
  178. package/src/server/management/logs-usage-routes.ts +19 -0
  179. package/src/server/management/native-integration-routes.ts +103 -6
  180. package/src/server/management/oauth-account-routes.ts +45 -7
  181. package/src/server/management/route-registry.ts +6 -0
  182. package/src/server/management/shared.ts +18 -1
  183. package/src/server/management/usage-timeline-routes.ts +44 -0
  184. package/src/server/management-api.ts +8 -9
  185. package/src/server/proxy-liveness.ts +75 -0
  186. package/src/server/relay.ts +19 -2
  187. package/src/server/request-log-failure-attribution.ts +99 -0
  188. package/src/server/request-log.ts +114 -0
  189. package/src/server/request-metrics.ts +92 -30
  190. package/src/server/responses/codex-ws-wire.ts +34 -8
  191. package/src/server/responses/combo-stream-preflight.ts +168 -6
  192. package/src/server/responses/compact.ts +11 -0
  193. package/src/server/responses/core-opaque-recovery.ts +90 -0
  194. package/src/server/responses/fetch-helpers.ts +124 -8
  195. package/src/server/responses/input-admission.ts +10 -0
  196. package/src/server/responses/passthrough-delivery.ts +14 -1
  197. package/src/server/responses/passthrough-dispatch.ts +179 -35
  198. package/src/server/responses/passthrough-error.ts +27 -8
  199. package/src/server/responses/request-prepare.ts +42 -1
  200. package/src/server/responses/request-send-budget.ts +12 -0
  201. package/src/server/responses/request-transport.ts +24 -4
  202. package/src/server/responses/reset-replay.ts +108 -0
  203. package/src/server/responses-request-tool-scope.ts +214 -0
  204. package/src/server/responses-undeclared-tool-guard.ts +4 -1
  205. package/src/server/search.ts +25 -1
  206. package/src/server/usage-ledger-retention.ts +73 -0
  207. package/src/service/cli.ts +48 -2
  208. package/src/service/health.ts +3 -2
  209. package/src/service/install-state-contract.d.mts +27 -0
  210. package/src/service/install-state-contract.mjs +34 -0
  211. package/src/service/launchd.ts +1 -1
  212. package/src/service/orchestration.ts +2 -4
  213. package/src/service/ownership-compatibility.ts +164 -0
  214. package/src/service/ownership-mutation-lease.d.mts +32 -0
  215. package/src/service/ownership-mutation-lease.mjs +211 -0
  216. package/src/service/repair.ts +45 -1
  217. package/src/service/state-lock.ts +269 -0
  218. package/src/service/state-record.d.mts +36 -0
  219. package/src/service/state-record.mjs +138 -0
  220. package/src/service/state.ts +582 -68
  221. package/src/service/windows-taskxml.ts +11 -10
  222. package/src/service.ts +7 -3
  223. package/src/tray/windows-tray.ps1 +1 -1
  224. package/src/types/config.ts +37 -0
  225. package/src/types/provider.ts +73 -0
  226. package/src/types/request.ts +28 -2
  227. package/src/types/tools.ts +19 -0
  228. package/src/types.ts +3 -0
  229. package/src/update/index.ts +207 -63
  230. package/src/update/job.ts +9 -5
  231. package/src/update/ownership-transaction.ts +47 -0
  232. package/src/update/restart-ownership.ts +54 -0
  233. package/src/update/runtime-ownership.d.mts +40 -0
  234. package/src/update/runtime-ownership.mjs +122 -0
  235. package/src/usage/attempt-delivery.ts +198 -0
  236. package/src/usage/cache-diagnostic.ts +305 -0
  237. package/src/usage/failure-fingerprint.ts +118 -0
  238. package/src/usage/failure-projection-cache.ts +174 -0
  239. package/src/usage/failure-projection.ts +174 -0
  240. package/src/usage/ledger-retention.ts +165 -0
  241. package/src/usage/log.ts +126 -79
  242. package/src/usage/request-outcome.ts +150 -0
  243. package/src/usage/retention-contract.ts +28 -0
  244. package/src/usage/summary.ts +2 -2
  245. package/src/usage/telemetry-contract.ts +237 -0
  246. package/src/usage/timeline.ts +236 -0
  247. package/src/web-search/alpha-search.ts +21 -1
  248. package/gui/dist/assets/index-BTuCbqQd.css +0 -1
  249. package/gui/dist/assets/index-DoBVdPHP.js +0 -134
@@ -10,7 +10,7 @@
10
10
  import { lstatSync, mkdirSync, readFileSync, rmSync, statSync } from "node:fs";
11
11
  import type { ConfigFormat } from "../clients/config-export";
12
12
  import { MAX_JSON_NESTING } from "./serialize";
13
- import { atomicWriteFile } from "../config";
13
+ import { atomicWriteFileNoFollow, isMissingPathError } from "../config/atomic-write";
14
14
  import type { JournalEntry } from "./journal";
15
15
  import type { OwnershipRecord } from "./ownership";
16
16
  import type { IntegrationClientId } from "./registry";
@@ -192,6 +192,15 @@ export type ReadResult =
192
192
 
193
193
  export type StatKind = "file" | "dir" | "other" | "missing" | "failed";
194
194
 
195
+ function lstatKind(path: string): StatKind {
196
+ try {
197
+ const stats = lstatSync(path);
198
+ return stats.isFile() ? "file" : stats.isDirectory() ? "dir" : "other";
199
+ } catch (error) {
200
+ return (error as NodeJS.ErrnoException).code === "ENOENT" ? "missing" : "failed";
201
+ }
202
+ }
203
+
195
204
  export interface IntegrationIO {
196
205
  /**
197
206
  * ONLY a missing file yields `missing`. Every other failure (EACCES, EPERM,
@@ -234,6 +243,18 @@ export type TargetState =
234
243
  * an unreadable config gets clobbered.
235
244
  */
236
245
  export function loadTarget(io: IntegrationIO, configPath: string): TargetState {
246
+ // Managed client paths are a lower-trust boundary. Never inspect through a
247
+ // final symlink that can be retargeted between this read and the eventual
248
+ // write: when a no-follow probe exists, the named directory entry itself must
249
+ // be a regular file or absent. `missing` stays legal so a virtual pair probe
250
+ // (such as Cline's pair-aware statKind) can still report one absent member.
251
+ const named = io.lstatKind?.(configPath);
252
+ // A failed probe is uncertainty, not evidence about the entry's shape: it
253
+ // keeps the read-failed classification the follow-probe would have produced.
254
+ if (named === "failed") return { ok: false, why: "read-failed" };
255
+ if (named !== undefined && named !== "file" && named !== "missing") {
256
+ return { ok: false, why: "not-regular-file" };
257
+ }
237
258
  const kind = io.statKind(configPath);
238
259
  if (kind === "missing") return { ok: true, before: null };
239
260
  if (kind === "failed") return { ok: false, why: "read-failed" };
@@ -252,14 +273,7 @@ export function loadTarget(io: IntegrationIO, configPath: string): TargetState {
252
273
  */
253
274
  export function fileIO(): Omit<IntegrationIO, "appendJournal" | "putRecord" | "dropRecord"> {
254
275
  return {
255
- lstatKind: path => {
256
- try {
257
- const stats = lstatSync(path);
258
- return stats.isFile() ? "file" : stats.isDirectory() ? "dir" : "other";
259
- } catch (error) {
260
- return (error as NodeJS.ErrnoException).code === "ENOENT" ? "missing" : "failed";
261
- }
262
- },
276
+ lstatKind,
263
277
  readText: path => {
264
278
  try {
265
279
  return { kind: "text", text: readFileSync(path, "utf8") };
@@ -277,8 +291,28 @@ export function fileIO(): Omit<IntegrationIO, "appendJournal" | "putRecord" | "d
277
291
  }
278
292
  },
279
293
  writeText: (path, text) => {
294
+ const kind = lstatKind(path);
295
+ if (kind !== "file" && kind !== "missing") {
296
+ throw new Error(`refusing unsafe integration write target: ${path}`);
297
+ }
280
298
  assertIntegrationWriteOwnership(path);
281
- atomicWriteFile(path, text);
299
+ atomicWriteFileNoFollow(path, text, undefined, {
300
+ // The pre-check above rejects a symlink already in place; this one runs
301
+ // inside the atomic write immediately before the rename, so a link
302
+ // exchanged after validation is refused rather than followed. Even a
303
+ // swap past this point can only replace the named entry, never redirect
304
+ // the write through it.
305
+ validateBeforeRename: target => {
306
+ try {
307
+ if (lstatSync(target).isSymbolicLink()) {
308
+ throw new Error(`refusing to replace symbolic-link integration target: ${target}`);
309
+ }
310
+ } catch (error) {
311
+ if (isMissingPathError(error)) return;
312
+ throw error;
313
+ }
314
+ },
315
+ });
282
316
  },
283
317
  removeFile: path => rmSync(path, { force: true }),
284
318
  mkdirp: path => mkdirSync(path, { recursive: true, mode: 0o700 }),
@@ -9,7 +9,7 @@
9
9
  *
10
10
  * Design of record: devlog/_fin/260802_client_toggle_api/031_wp3_writer_impl.md.
11
11
  */
12
- import type { ManagedContribution } from "../clients/config-export";
12
+ import type { ManagedContribution } from "../clients/config-export/contracts";
13
13
 
14
14
  function isPlainRecord(value: unknown): value is Record<string, unknown> {
15
15
  return typeof value === "object" && value !== null && !Array.isArray(value);
@@ -22,20 +22,87 @@ function clone<T>(value: T): T {
22
22
 
23
23
  /**
24
24
  * `[field=value]` addresses the ONE element of a sequence whose `field` equals
25
- * `value`. Raycast keeps its providers as a YAML list, so the element is the
25
+ * `value`, and `[v2:field=value,field=value]` the one whose every named field
26
+ * matches. Raycast keeps its providers as a YAML list, so the element is the
26
27
  * smallest thing we can own there; an index would move under us the moment
27
28
  * the user reordered their own entries. Any other segment is a plain key.
29
+ *
30
+ * A conjunction exists because one field is not always the identity. ZCode
31
+ * keys a model rule in its provider store by the PAIR `(providerId, modelId)`,
32
+ * so a `[modelId=…]` selector alone would match another provider's rule for the
33
+ * same model and then replace it, in a file holding every provider the user
34
+ * has. Addressing an element by less than what identifies it is the same
35
+ * defect as addressing it by index.
36
+ *
37
+ * The single-criterion spelling keeps its original grammar, where the value may
38
+ * itself contain commas and equals signs. A conjunction uses an explicit v2
39
+ * marker, so no path already written into an ownership record on disk changes
40
+ * meaning. Once the marker is present the whole segment must be valid; falling
41
+ * back to a key or v1 selector would recreate the silent reselection hazard.
28
42
  */
29
43
  const ARRAY_SELECTOR = /^\[([A-Za-z_][A-Za-z0-9_]*)=([^\]]+)\]$/u;
44
+ const SELECTOR_FIELD = /^[A-Za-z_][A-Za-z0-9_]*$/u;
45
+ const SELECTOR_CONJUNCTION_PREFIX = "[v2:";
46
+
47
+ /** One `field=value` equality a selector requires of the element it names. */
48
+ export interface SelectorCriterion {
49
+ field: string;
50
+ value: string;
51
+ }
30
52
 
31
53
  export type PathSegment =
32
54
  | { kind: "key"; key: string }
33
- | { kind: "select"; field: string; value: string };
55
+ | { kind: "select"; criteria: readonly SelectorCriterion[] };
56
+
57
+ /** A reserved v2 selector marker was present, but its complete grammar was not. */
58
+ export class InvalidSelectorError extends Error {
59
+ constructor() {
60
+ super("invalid versioned selector");
61
+ this.name = "InvalidSelectorError";
62
+ }
63
+ }
64
+
65
+ function validConjunctionCriteria(criteria: readonly SelectorCriterion[]): boolean {
66
+ return criteria.length >= 2 && criteria.every(criterion => (
67
+ SELECTOR_FIELD.test(criterion.field)
68
+ && criterion.value.length > 0
69
+ && !criterion.value.includes(",")
70
+ && !criterion.value.includes("]")
71
+ ));
72
+ }
73
+
74
+ /** Spell a multi-field selector without colliding with persisted v1 paths. */
75
+ export function formatSelectorConjunction(criteria: readonly SelectorCriterion[]): string | null {
76
+ if (!validConjunctionCriteria(criteria)) return null;
77
+ return `${SELECTOR_CONJUNCTION_PREFIX}${criteria
78
+ .map(criterion => `${criterion.field}=${criterion.value}`)
79
+ .join(",")}]`;
80
+ }
34
81
 
35
82
  export function parseSegment(raw: string): PathSegment {
83
+ if (raw.startsWith(SELECTOR_CONJUNCTION_PREFIX)) {
84
+ if (!raw.endsWith("]")) throw new InvalidSelectorError();
85
+ const parts = raw.slice(SELECTOR_CONJUNCTION_PREFIX.length, -1).split(",");
86
+ const criteria = parts.map(part => {
87
+ const equals = part.indexOf("=");
88
+ return equals < 0
89
+ ? { field: "", value: "" }
90
+ : { field: part.slice(0, equals), value: part.slice(equals + 1) };
91
+ });
92
+ if (!validConjunctionCriteria(criteria)) throw new InvalidSelectorError();
93
+ return {
94
+ kind: "select",
95
+ criteria,
96
+ };
97
+ }
36
98
  const match = ARRAY_SELECTOR.exec(raw);
37
99
  if (!match) return { kind: "key", key: raw };
38
- return { kind: "select", field: match[1]!, value: match[2]! };
100
+ return { kind: "select", criteria: [{ field: match[1]!, value: match[2]! }] };
101
+ }
102
+
103
+ /** The `field=value` list a selector segment spells, for messages and seeding. */
104
+ export function selectorPairs(criteria: readonly SelectorCriterion[]): string {
105
+ return criteria.map(criterion => `${criterion.field}=${criterion.value}`).join(", ");
39
106
  }
40
107
 
41
108
  /**
@@ -44,19 +111,21 @@ export function parseSegment(raw: string): PathSegment {
44
111
  * this to an `unsafe` refusal instead.
45
112
  */
46
113
  export class AmbiguousSelectorError extends Error {
47
- constructor(field: string, value: string) {
48
- super(`more than one entry has ${field}=${value}`);
114
+ constructor(criteria: readonly SelectorCriterion[]) {
115
+ super(`more than one entry has ${selectorPairs(criteria)}`);
49
116
  this.name = "AmbiguousSelectorError";
50
117
  }
51
118
  }
52
119
 
53
120
  /** The index of the element a selector names, -1 when none matches. */
54
- export function selectIndex(items: readonly unknown[], field: string, value: string): number {
121
+ export function selectIndex(items: readonly unknown[], criteria: readonly SelectorCriterion[]): number {
55
122
  const matches: number[] = [];
56
123
  items.forEach((item, index) => {
57
- if (isPlainRecord(item) && item[field] === value) matches.push(index);
124
+ if (isPlainRecord(item) && criteria.every(criterion => item[criterion.field] === criterion.value)) {
125
+ matches.push(index);
126
+ }
58
127
  });
59
- if (matches.length > 1) throw new AmbiguousSelectorError(field, value);
128
+ if (matches.length > 1) throw new AmbiguousSelectorError(criteria);
60
129
  return matches[0] ?? -1;
61
130
  }
62
131
 
@@ -64,6 +133,44 @@ function assertNever(segment: never): never {
64
133
  throw new Error(`unknown path segment ${JSON.stringify(segment)}`);
65
134
  }
66
135
 
136
+ /** The element a selector names, or `undefined` when none matches. */
137
+ function selectElement(items: readonly unknown[], segment: PathSegment & { kind: "select" }): unknown {
138
+ return items[selectIndex(items, segment.criteria)];
139
+ }
140
+
141
+ /**
142
+ * Read `path` through the same segment grammar `setPath` writes through.
143
+ *
144
+ * It belongs here rather than beside the classifier because the grammar does: a
145
+ * reader that resolved a selector differently from the writer would report one
146
+ * element as ours and then rewrite another. `state` re-exports it for the
147
+ * callers that have always imported it from there.
148
+ */
149
+ export function readPath(doc: unknown, path: readonly string[]): unknown {
150
+ let cursor: unknown = doc;
151
+ // Validate the complete persisted grammar before document shape can short-circuit
152
+ // the walk. Otherwise an absent early key can hide a malformed later selector,
153
+ // making an unreadable ownership record look absent and move the operation to a
154
+ // different file.
155
+ const segments = path.map(parseSegment);
156
+ for (const segment of segments) {
157
+ switch (segment.kind) {
158
+ case "key":
159
+ if (!isPlainRecord(cursor)) return undefined;
160
+ cursor = cursor[segment.key];
161
+ break;
162
+ case "select":
163
+ if (!Array.isArray(cursor)) return undefined;
164
+ cursor = selectElement(cursor, segment);
165
+ break;
166
+ default:
167
+ return assertNever(segment);
168
+ }
169
+ if (cursor === undefined) return undefined;
170
+ }
171
+ return cursor;
172
+ }
173
+
67
174
  /**
68
175
  * Write `value` at `path`, creating intermediate containers. Returns a new document.
69
176
  *
@@ -99,7 +206,7 @@ export function setPath(doc: unknown, path: readonly string[], value: unknown):
99
206
  case "select": {
100
207
  if (!Array.isArray(read())) write([]);
101
208
  const items = read() as unknown[];
102
- const found = selectIndex(items, segment.field, segment.value);
209
+ const found = selectIndex(items, segment.criteria);
103
210
  parent = items;
104
211
  if (found >= 0) {
105
212
  slot = found;
@@ -107,7 +214,7 @@ export function setPath(doc: unknown, path: readonly string[], value: unknown):
107
214
  // Seed the element so the selector stays true for whatever a deeper
108
215
  // segment writes into it; a last-position select replaces it whole.
109
216
  slot = items.length;
110
- items.push({ [segment.field]: segment.value });
217
+ items.push(Object.fromEntries(segment.criteria.map(criterion => [criterion.field, criterion.value])));
111
218
  }
112
219
  break;
113
220
  }
@@ -155,7 +262,7 @@ export function deletePath(
155
262
  }
156
263
  case "select": {
157
264
  if (!Array.isArray(container)) return { doc: root, removed: false };
158
- const found = selectIndex(container, segment.field, segment.value);
265
+ const found = selectIndex(container, segment.criteria);
159
266
  if (found < 0) return { doc: root, removed: false };
160
267
  slots.push(found);
161
268
  chain.push(container[found] as Record<string, unknown> | unknown[]);
@@ -249,7 +356,7 @@ export function createdContainerPaths(
249
356
  case "select": {
250
357
  // A selector that matches nothing means setPath will push the element.
251
358
  next = Array.isArray(cursor)
252
- ? cursor[selectIndex(cursor, segment.field, segment.value)]
359
+ ? cursor[selectIndex(cursor, segment.criteria)]
253
360
  : undefined;
254
361
  break;
255
362
  }
@@ -17,12 +17,23 @@ import { createHash } from "node:crypto";
17
17
  import { canonicalContribution, fingerprint, type OwnershipRecord } from "./ownership";
18
18
  import { ClientPathError, EXPORT_CLIENTS, type ExportModel, type ManagedContribution } from "../clients/config-export";
19
19
  import { OPENCODE_PROVIDER_ID } from "../clients/config-export/constants";
20
+ import {
21
+ ZCODE_STORE_MODEL_RULES_PATH,
22
+ ZCODE_STORE_PROVIDER_RULES_PATH,
23
+ } from "../clients/config-export/zcode-store";
20
24
  import { createClineIO, ClineTransactionError } from "./cline-io";
21
25
  import { parseClineDocument } from "./cline-document";
22
26
  import { PARSE_FAILED, defaultIntegrationIO, loadTarget, parseConfig, type IntegrationIO } from "./config-io";
23
- import { INTEGRATION_CLIENTS, isLoopbackOnly, resolveIntegrationPaths, type IntegrationClientId } from "./registry";
27
+ import {
28
+ INTEGRATION_CLIENTS,
29
+ isLoopbackOnly,
30
+ resolveIntegrationPaths,
31
+ type IntegrationClientId,
32
+ } from "./registry";
33
+ import { declaredIntegrationTarget, resolveIntegrationTarget, type IntegrationTarget } from "./target";
24
34
  import { shouldInjectApiAuthHeader } from "../codex/inject";
25
35
  import { classifyIntegration, exportContextOf, readPath, type IntegrationState, type StateReason } from "./state";
36
+ import { InvalidSelectorError } from "./merge";
26
37
  import { createIntegrationStateStore, type IntegrationStateStore } from "./store";
27
38
  import type { OcxConfig } from "../types";
28
39
  import { matchesOperationResult, type JournalEntry } from "./journal";
@@ -37,6 +48,7 @@ export type RefusalReason =
37
48
  | "conflict"
38
49
  | "unsafe"
39
50
  | "non_loopback"
51
+ | "superseded_store"
40
52
  | "drift_requires_confirm"
41
53
  | "snapshot_expired"
42
54
  | "write_failed";
@@ -114,7 +126,16 @@ const CLIENT_MANAGED_PATHS = {
114
126
  gajae: [["providers", OPENCODE_PROVIDER_ID]],
115
127
  dsh: [["llm-pi-ai", "providers", OPENCODE_PROVIDER_ID]],
116
128
  mcode: [["custom_provider", OPENCODE_PROVIDER_ID]],
117
- zcode: [["provider", OPENCODE_PROVIDER_ID]],
129
+ zcode: [
130
+ ["provider", OPENCODE_PROVIDER_ID],
131
+ /*
132
+ * The store the current client reads. The model rule carries the model id in
133
+ * its own selector, so the published segment is the dynamic one: the template
134
+ * leaves this module, never the observed path.
135
+ */
136
+ [...ZCODE_STORE_PROVIDER_RULES_PATH, `[providerId=${OPENCODE_PROVIDER_ID}]`],
137
+ [...ZCODE_STORE_MODEL_RULES_PATH, DYNAMIC_SEGMENT],
138
+ ],
118
139
  prime: [["providers", OPENCODE_PROVIDER_ID]],
119
140
  aside: [["providers", OPENCODE_PROVIDER_ID]],
120
141
  raycast: [["providers", `[id=${OPENCODE_PROVIDER_ID}]`]],
@@ -205,6 +226,18 @@ export interface PlanFingerprintInput {
205
226
  * plan that did not bind it could be confirmed after the proxy stopped being a legal target.
206
227
  */
207
228
  readonly admissionBlocked: boolean;
229
+ /**
230
+ * Why a write to the target would not reach the client, or null.
231
+ *
232
+ * Bound for the same reason `installKind` is: it can flip without touching the
233
+ * file, the record or the contribution. A client that creates its new store
234
+ * while a confirmation is outstanding has changed whether the write can reach
235
+ * it, and a plan that did not bind this would still authorize the write. The
236
+ * reason travels with the location because both can move on their own: a store
237
+ * whose schema version changes under an unchanged path is the same class of
238
+ * flip as a store appearing.
239
+ */
240
+ readonly ineffectiveWrite: string | null;
208
241
  /** Exact current bytes, or null when the target is missing. Missing and empty are not equal. */
209
242
  readonly before: string | null;
210
243
  readonly contribution: ManagedContribution | null;
@@ -226,7 +259,7 @@ export interface PlanFingerprintInput {
226
259
  };
227
260
  }
228
261
 
229
- const PLAN_FINGERPRINT_VERSION = "p1";
262
+ const PLAN_FINGERPRINT_VERSION = "p2";
230
263
 
231
264
  function digest(value: string): string {
232
265
  return createHash("sha256").update(value).digest("hex").slice(0, 32);
@@ -251,6 +284,7 @@ export function planFingerprint(input: PlanFingerprintInput): string {
251
284
  input.detectDir,
252
285
  input.installKind,
253
286
  input.admissionBlocked,
287
+ input.ineffectiveWrite,
254
288
  input.before === null ? "\u0000absent" : fingerprint(input.before),
255
289
  input.contribution === null ? null : fingerprint(canonicalContribution(input.contribution)),
256
290
  input.record === null ? null : fingerprint(JSON.stringify(input.record)),
@@ -305,6 +339,13 @@ function foreignEditOf(input: PlanInput): IntegrationPlanForeignEdit {
305
339
  function applyOutcome(input: PlanInput): PlanOutcome {
306
340
  if (input.installKind !== "dir") return deny("not_installed");
307
341
  if (input.admissionBlocked) return deny("non_loopback");
342
+ /*
343
+ * Before any file state. The document may be perfectly writable and our block
344
+ * may already be current in it; neither says anything about whether the
345
+ * client reads it, and reporting a change to a file nobody opens is the
346
+ * defect this refusal exists for.
347
+ */
348
+ if (input.ineffectiveWrite !== null) return deny("superseded_store");
308
349
  // Overwrite exists precisely to proceed through a conflict the operator has been shown.
309
350
  if (input.classified.state === "conflict" && input.operation !== "overwrite") return deny("conflict");
310
351
  if (input.classified.state === "unsafe") return deny("unsafe");
@@ -400,7 +441,20 @@ function changesOf(input: PlanInput): readonly IntegrationPlanChange[] {
400
441
  */
401
442
  const documentKnown = input.parsed !== PARSE_FAILED;
402
443
  for (const [path, fragment] of prior) {
403
- const absent = documentKnown && readPath(input.parsed, fragment) === undefined;
444
+ let absent = false;
445
+ if (documentKnown) {
446
+ try {
447
+ absent = readPath(input.parsed, fragment) === undefined;
448
+ } catch (error) {
449
+ /*
450
+ * Restore eligibility is byte-based and does not parse ownership paths.
451
+ * A malformed versioned selector therefore makes this ONE descriptive
452
+ * comparison unknown; it must not make preview execute a different
453
+ * selector, and it must not hide the backup behind an internal error.
454
+ */
455
+ if (!(error instanceof InvalidSelectorError)) throw error;
456
+ }
457
+ }
404
458
  changes.push({ kind: absent ? "add" : "replace", path });
405
459
  }
406
460
  for (const fragment of input.record?.fragmentPaths ?? []) {
@@ -514,9 +568,16 @@ export function observeRestore(
514
568
  return { failed: observationFailure("unsafe", "unsafe", "that operation cannot be undone") } as const;
515
569
  }
516
570
  const configPath = entry.configPath;
517
- // An undo acts on the path the operation was journaled against. A row recorded for one home must
518
- // never be allowed to rewrite a file in another.
519
- if (resolved.configPath !== configPath) {
571
+ /*
572
+ * An undo acts on the path the operation was journaled against. A row recorded for one home must
573
+ * never be allowed to rewrite a file in another — but a client may legally have written more than
574
+ * one file, so the test is whether this client still names that location, not whether it is the
575
+ * config file. The answer also carries the document shape those bytes are in.
576
+ */
577
+ const rowTarget = declaredIntegrationTarget({
578
+ clientId, configPath, resolvedConfigPath: resolved.configPath, env: input.env, home: input.home,
579
+ });
580
+ if (rowTarget === null) {
520
581
  return {
521
582
  failed: observationFailure("conflict", "conflict", "that operation was recorded for a different location"),
522
583
  } as const;
@@ -546,6 +607,7 @@ export function observeRestore(
546
607
  failed: undefined,
547
608
  clientId,
548
609
  configPath,
610
+ format: rowTarget.format,
549
611
  detectDir: resolved.detectDir,
550
612
  installKind: io.statKind(resolved.detectDir),
551
613
  entry,
@@ -589,6 +651,7 @@ export function previewIntegration(input: IntegrationWriteInput, request: Previe
589
651
  installKind: observed.io.statKind(observed.detectDir),
590
652
  // Loopback-only clients cannot carry the admission header a non-loopback bind requires.
591
653
  admissionBlocked: isLoopbackOnly(observed.clientId) && shouldInjectApiAuthHeader(input.config),
654
+ ineffectiveWrite: observed.ineffectiveWrite,
592
655
  before: observed.before,
593
656
  contribution: observed.contribution,
594
657
  record: observed.record,
@@ -659,6 +722,13 @@ function previewRestore(input: IntegrationWriteInput, request: PreviewRequest):
659
722
  detectDir: observed.detectDir,
660
723
  installKind: observed.installKind,
661
724
  admissionBlocked: false,
725
+ /*
726
+ * Undo puts back bytes this project already wrote to this file. Whether the
727
+ * client still reads the file does not change whether those bytes may be
728
+ * restored, and refusing here would strand a user on a state they asked to
729
+ * leave.
730
+ */
731
+ ineffectiveWrite: null,
662
732
  before: observed.before,
663
733
  contribution: null,
664
734
  // Descriptive, never decisive. The record says which places are ours now and the document
@@ -673,7 +743,7 @@ function previewRestore(input: IntegrationWriteInput, request: PreviewRequest):
673
743
  ? {}
674
744
  : observed.clientId === "cline"
675
745
  ? parseClineDocument(observed.before)
676
- : parseConfig(observed.before, EXPORT_CLIENTS[observed.clientId].format),
746
+ : parseConfig(observed.before, observed.format),
677
747
  restore: {
678
748
  opId: observed.entry.opId,
679
749
  entry: observed.entry,
@@ -757,6 +827,8 @@ export function observeIntegration(input: IntegrationWriteInput, effects: Observ
757
827
  */
758
828
  let configPath: string;
759
829
  let detectDir: string;
830
+ let effective: IntegrationTarget;
831
+ let stored: OwnershipRecord | null;
760
832
  try {
761
833
  /*
762
834
  * Resolve the PAIR, never one half.
@@ -768,9 +840,26 @@ export function observeIntegration(input: IntegrationWriteInput, effects: Observ
768
840
  * verify account 1 was installed and then write account 0's catalog.
769
841
  */
770
842
  const resolved = input.resolvedPaths ?? resolveIntegrationPaths(clientId, input.env, input.home);
771
- configPath = resolved.configPath;
772
843
  detectDir = resolved.detectDir;
773
- if (clientId === "cline") io = createClineIO(io, configPath, store, effects.recover);
844
+ if (clientId === "cline") io = createClineIO(io, resolved.configPath, store, effects.recover);
845
+ /*
846
+ * A record proves ownership of the file it was written FOR, and it is also
847
+ * one of the inputs the target is chosen from: a block we already wrote to
848
+ * the config file keeps this operation on that file, so disable removes what
849
+ * we wrote from where we wrote it. Matching by path happens after the
850
+ * target is known, because that is the path it has to match.
851
+ */
852
+ stored = store.readRecords()[clientId] ?? null;
853
+ /*
854
+ * Inside the same guard as resolution, because this resolver can refuse the
855
+ * same way: the store is named by a client env var, and a relative one is a
856
+ * misconfiguration to report rather than an exception to leak through the
857
+ * collection route.
858
+ */
859
+ effective = resolveIntegrationTarget({
860
+ clientId, configPath: resolved.configPath, io, record: stored, env: input.env, home: input.home,
861
+ });
862
+ configPath = effective.configPath;
774
863
  } catch (error) {
775
864
  if (error instanceof ClineTransactionError) {
776
865
  return { failed: { ...observationFailure("unsafe", "unsafe", error.message, error.snapshotPath), residual: true } } as const;
@@ -781,26 +870,31 @@ export function observeIntegration(input: IntegrationWriteInput, effects: Observ
781
870
  // Pruning writes, so only a mutation may perform it. Preview reports the state it finds.
782
871
  if (effects.maintenance) store.retryPendingPrunes();
783
872
 
784
- const target = loadTarget(io, configPath);
785
- if (!target.ok) {
873
+ const loaded = loadTarget(io, configPath);
874
+ if (!loaded.ok) {
786
875
  return {
787
876
  failed: observationFailure("unsafe", "unsafe",
788
- target.why === "read-failed"
877
+ loaded.why === "read-failed"
789
878
  ? `${configPath} exists but could not be read`
790
879
  : `${configPath} is not a regular file`),
791
880
  } as const;
792
881
  }
793
- const before = target.before;
794
- const parsed = clientId === "cline" ? parseClineDocument(before) : parseConfig(before, exportSpec.format);
882
+ const before = loaded.before;
883
+ const parsed = clientId === "cline" ? parseClineDocument(before) : parseConfig(before, effective.format);
795
884
  if (parsed === PARSE_FAILED) {
796
885
  return { failed: observationFailure("unsafe", "unsafe",
797
886
  `${configPath} could not be parsed, or holds something opencodex cannot rewrite without changing it (a non-finite number, a large integer or a tiny one a rewrite would round, -0, a duplicate member, or nesting deeper than 1000 levels)`) } as const;
798
887
  }
799
- const contribution = exportSpec.buildContribution(exportContextOf(input));
888
+ /*
889
+ * The shape the TARGET's reader understands, which is not always the
890
+ * client's config format: a client that moved its providers to another file
891
+ * reads a different document there, and a write in the config file's shape
892
+ * would be as unread as a write to the config file itself.
893
+ */
894
+ const contribution = effective.buildContribution(exportContextOf(input));
800
895
  // A record proves ownership of the file it was written FOR. Matching only by
801
896
  // client id let a record for one home authorize a write to another whose
802
897
  // bytes happened to hash the same — which deleted a config we never touched.
803
- const stored = store.readRecords()[clientId] ?? null;
804
898
  const record = stored && stored.clientId === clientId && stored.configPath === configPath
805
899
  ? stored
806
900
  : null;
@@ -810,6 +904,18 @@ export function observeIntegration(input: IntegrationWriteInput, effects: Observ
810
904
  // ownership here and disable would delete fragments it never wrote.
811
905
  const classified = classifyIntegration({
812
906
  fileText: before, fileIsRegular: true, parsed, record, contribution, configPath, clientId,
907
+ format: effective.format,
813
908
  });
814
- return { failed: undefined, store, io, clientId, spec, exportSpec, configPath, detectDir, before, parsed, contribution, record, classified } as const;
909
+ return {
910
+ failed: undefined, store, io, clientId, spec, exportSpec, target: effective, configPath, detectDir,
911
+ /*
912
+ * One token for the plan, because the location alone is not the input: a
913
+ * store whose schema stops being one we recognise changes the answer while
914
+ * its path stays exactly the same.
915
+ */
916
+ ineffectiveWrite: effective.ineffective === null
917
+ ? null
918
+ : `${effective.ineffective.why}\u0000${effective.ineffective.store}`,
919
+ before, parsed, contribution, record, classified,
920
+ } as const;
815
921
  }
@@ -43,6 +43,11 @@ import {
43
43
  raycastConfigPath,
44
44
  zcodeConfigPath,
45
45
  zcodeHomeDir,
46
+ zcodeProviderStorePath,
47
+ buildZcodeStoreContribution,
48
+ zcodeStoreSchemaEstablished,
49
+ type BuildContribution,
50
+ type ConfigFormat,
46
51
  type ExportClientId,
47
52
  } from "../clients/config-export";
48
53
 
@@ -58,6 +63,28 @@ export interface IntegrationClientSpec {
58
63
  configPath: (env?: NodeJS.ProcessEnv, home?: string) => string;
59
64
  /** Directory whose existence is the cheap "is it installed?" signal. */
60
65
  detectDir: (env?: NodeJS.ProcessEnv, home?: string) => string;
66
+ /**
67
+ * The provider store this client reads INSTEAD of `configPath`.
68
+ *
69
+ * A client that moves its store between releases usually keeps a one-shot
70
+ * import from the old location, and that import is exactly what makes the old
71
+ * write look like it still works: it runs once, on an install that has never
72
+ * created the new file, and never again. Everything after it lands in a file
73
+ * the client does not open.
74
+ *
75
+ * A declaration carries everything needed to write the store, not only its
76
+ * location: the text format, the contribution shape its reader understands,
77
+ * and the predicate that says whether a document on disk is a version whose
78
+ * shape has been observed. The last one is what keeps this honest — a store
79
+ * we cannot establish is reported as the reason the write cannot reach the
80
+ * client, never merged into on a guess.
81
+ */
82
+ currentStore?: {
83
+ path: (env?: NodeJS.ProcessEnv, home?: string) => string;
84
+ format: ConfigFormat;
85
+ establishes: (parsed: unknown) => boolean;
86
+ buildContribution: BuildContribution;
87
+ };
61
88
  /** Patch only this block-map YAML leaf; never re-render the shared file. */
62
89
  sourcePreservingYaml?: { path: readonly string[] };
63
90
  /** Coordinate the complete mutation through a sibling config lock. */
@@ -231,6 +258,17 @@ export const INTEGRATION_CLIENTS: Record<IntegrationClientId, IntegrationClientS
231
258
  id: "zcode",
232
259
  configPath: (env = process.env, home = homedir()) => zcodeConfigPath(env, home),
233
260
  detectDir: (env = process.env, home = homedir()) => zcodeHomeDir(env, home),
261
+ /*
262
+ * ZCode 3.14 reads its providers from `v2/provider_config.json` and reaches
263
+ * `v2/config.json` only through the import that seeded it. Where the new
264
+ * file exists the import is spent, so our write is read by nobody (#5348).
265
+ */
266
+ currentStore: {
267
+ path: (env = process.env, home = homedir()) => zcodeProviderStorePath(env, home),
268
+ format: "json",
269
+ establishes: zcodeStoreSchemaEstablished,
270
+ buildContribution: buildZcodeStoreContribution,
271
+ },
234
272
  },
235
273
  prime: {
236
274
  id: "prime",