@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
@@ -11,10 +11,18 @@
11
11
  import { createClineIO } from "./cline-io";
12
12
  import { parseClineDocument } from "./cline-document";
13
13
  import { ClientPathError, EXPORT_CLIENTS, opencodeProxyBaseUrl, type ExportModel, type ManagedContribution } from "../clients/config-export";
14
+ import type { ConfigFormat } from "../clients/config-export";
14
15
  import type { OcxConfig } from "../types";
15
16
  import { PARSE_FAILED, loadTarget, parseConfig, type IntegrationIO } from "./config-io";
16
17
  import { SNAPSHOT_RETENTION } from "./journal";
17
- import { AmbiguousSelectorError, parseSegment, selectIndex, type PathSegment } from "./merge";
18
+ import {
19
+ AmbiguousSelectorError,
20
+ InvalidSelectorError,
21
+ parseSegment,
22
+ readPath,
23
+ selectIndex,
24
+ type PathSegment,
25
+ } from "./merge";
18
26
  import { canonicalContribution, fingerprint, semanticContribution, type OwnershipRecord } from "./ownership";
19
27
  import {
20
28
  protectedContributionFingerprint,
@@ -28,6 +36,7 @@ import {
28
36
  unresolvedPathHintFor,
29
37
  type IntegrationClientId,
30
38
  } from "./registry";
39
+ import { resolveIntegrationTarget, type IntegrationTarget } from "./target";
31
40
  import { createIntegrationStateStore, type IntegrationStateStore } from "./store";
32
41
 
33
42
  export type IntegrationState = "absent" | "current" | "stale" | "conflict" | "unsafe";
@@ -50,6 +59,21 @@ export interface IntegrationStatus {
50
59
  appliedAt?: string;
51
60
  lastOpId?: string;
52
61
  reason?: StateReason;
62
+ /**
63
+ * The store this client reads instead of `configPath`.
64
+ *
65
+ * Present only when the integration is NOT writing that store: either our
66
+ * block is still in the config file, or the store is not a document whose
67
+ * shape has been observed. Where the store is written, `configPath` names it
68
+ * and there is nothing to report beside the state.
69
+ *
70
+ * Orthogonal to `state`, which answers "what is on disk, and did we put it
71
+ * there?" — and answers it correctly here: the block can be byte-for-byte
72
+ * current in a file the client stopped opening. That pair is not a
73
+ * contradiction, it is the whole of #5348, so the surface that reports
74
+ * `current` has to be able to report this beside it.
75
+ */
76
+ supersededBy?: string;
53
77
  /** Snapshot files retained for this client; -1 when they cannot be inspected. */
54
78
  snapshotCount: number;
55
79
  /** Pruning is behind, so older (possibly credential-bearing) snapshots remain. */
@@ -64,37 +88,14 @@ function assertNever(segment: never): never {
64
88
  throw new Error(`unknown path segment ${JSON.stringify(segment)}`);
65
89
  }
66
90
 
67
- /** The element a selector names, or `undefined` when none matches. */
68
- function selectElement(items: readonly unknown[], segment: PathSegment & { kind: "select" }): unknown {
69
- return items[selectIndex(items, segment.field, segment.value)];
70
- }
71
-
72
91
  /**
73
- * Same segment grammar as `setPath`: a plain key reads through a record, a
74
- * `[field=value]` selector reads through an array. Because the classifier and
75
- * the writer share this one function, status and mutation cannot disagree
76
- * about which element is ours.
92
+ * The path reader lives with the path grammar, in `merge`, and is re-exported
93
+ * here because the classifier was its original home and every caller imports
94
+ * it from this module. One implementation is the point: a reader that resolved
95
+ * a selector differently from the writer would report one element as ours and
96
+ * then rewrite another.
77
97
  */
78
- export function readPath(doc: unknown, path: readonly string[]): unknown {
79
- let cursor: unknown = doc;
80
- for (const raw of path) {
81
- const segment = parseSegment(raw);
82
- switch (segment.kind) {
83
- case "key":
84
- if (!isPlainRecord(cursor)) return undefined;
85
- cursor = cursor[segment.key];
86
- break;
87
- case "select":
88
- if (!Array.isArray(cursor)) return undefined;
89
- cursor = selectElement(cursor, segment);
90
- break;
91
- default:
92
- return assertNever(segment);
93
- }
94
- if (cursor === undefined) return undefined;
95
- }
96
- return cursor;
97
- }
98
+ export { readPath };
98
99
 
99
100
  /** Does the document carry any fragment we would write? */
100
101
  export function hasOurFragments(doc: unknown, contribution: ManagedContribution): boolean {
@@ -136,7 +137,7 @@ export function blockedContainerPath(
136
137
  case "key":
137
138
  return (value as Record<string, unknown>)[segment.key];
138
139
  case "select":
139
- return selectElement(value as readonly unknown[], segment);
140
+ return (value as readonly unknown[])[selectIndex(value as readonly unknown[], segment.criteria)];
140
141
  default:
141
142
  return assertNever(segment);
142
143
  }
@@ -285,6 +286,14 @@ export function classifyIntegration(input: {
285
286
  */
286
287
  configPath?: string;
287
288
  clientId?: IntegrationClientId;
289
+ /**
290
+ * Text format of the file being classified, which is not always the client's
291
+ * config format: a client that moved its providers keeps a second document
292
+ * whose format is declared with the store. Only the comment-capability of the
293
+ * format is read here, and reading the wrong one would decide a sibling edit
294
+ * the wrong way.
295
+ */
296
+ format?: ConfigFormat;
288
297
  }): { state: IntegrationState; reason?: StateReason } {
289
298
  if (input.fileText !== null && !input.fileIsRegular) {
290
299
  return { state: "unsafe", reason: "not-regular-file" };
@@ -310,8 +319,13 @@ export function classifyIntegration(input: {
310
319
  }
311
320
  }
312
321
  } catch (error) {
313
- if (!(error instanceof AmbiguousSelectorError)) throw error;
314
- return { state: "unsafe", reason: "ambiguous-selector" };
322
+ if (error instanceof AmbiguousSelectorError) {
323
+ return { state: "unsafe", reason: "ambiguous-selector" };
324
+ }
325
+ if (error instanceof InvalidSelectorError) {
326
+ return { state: "unsafe", reason: "unparseable" };
327
+ }
328
+ throw error;
315
329
  }
316
330
  if (!hasOurFragments(input.parsed, input.contribution)) return { state: "absent" };
317
331
 
@@ -393,7 +407,7 @@ export function classifyIntegration(input: {
393
407
  * stands and re-owns the file. This also lets disable proceed on a
394
408
  * drifted file — removal still touches only the recorded fragment paths.
395
409
  */
396
- if (EXPORT_CLIENTS[clientId].format !== "json") {
410
+ if ((input.format ?? EXPORT_CLIENTS[clientId].format) !== "json") {
397
411
  return { state: "conflict", reason: "foreign-edit" };
398
412
  }
399
413
  return { state: "stale" };
@@ -491,7 +505,6 @@ export function readIntegrationState(input: IntegrationStateInput): IntegrationS
491
505
  retryPendingPrunesOnce(store);
492
506
  let io = input.io ?? store.io();
493
507
  const spec = INTEGRATION_CLIENTS[input.clientId];
494
- const exportSpec = EXPORT_CLIENTS[input.clientId];
495
508
  const retention = retentionOf(input.clientId, store);
496
509
  /*
497
510
  * Resolution can refuse — a relative OPENCLAW_* selector names a file whose
@@ -499,14 +512,25 @@ export function readIntegrationState(input: IntegrationStateInput): IntegrationS
499
512
  * every client for its state, so letting that escape would answer 500 for
500
513
  * the whole Integrations page because one client is misconfigured.
501
514
  */
502
- let configPath: string;
503
515
  let installed: boolean;
516
+ let effective: IntegrationTarget;
517
+ let record: OwnershipRecord | null;
504
518
  try {
505
519
  // One resolution for both, so a client whose paths come from mutable state
506
520
  // cannot report one account's install beside another account's config path.
507
521
  const paths = input.resolvedPaths ?? resolveIntegrationPaths(input.clientId, input.env, input.home);
508
- configPath = paths.configPath;
509
522
  installed = io.statKind(paths.detectDir) === "dir";
523
+ if (input.clientId === "cline") io = createClineIO(io, paths.configPath, store);
524
+ /*
525
+ * The record is one of the inputs the target is chosen from, so it is read
526
+ * here rather than after the file. The status this function reports is about
527
+ * whichever file the next mutation would act on; reading a different one
528
+ * would let the badge and the switch disagree.
529
+ */
530
+ record = store.readRecords()[input.clientId] ?? null;
531
+ effective = resolveIntegrationTarget({
532
+ clientId: input.clientId, configPath: paths.configPath, io, record, env: input.env, home: input.home,
533
+ });
510
534
  } catch (error) {
511
535
  if (!(error instanceof ClientPathError)) throw error;
512
536
  /*
@@ -531,30 +555,32 @@ export function readIntegrationState(input: IntegrationStateInput): IntegrationS
531
555
  };
532
556
  }
533
557
 
534
- if (input.clientId === "cline") io = createClineIO(io, configPath, store);
535
- const target = loadTarget(io, configPath);
536
- if (!target.ok) {
558
+ const configPath = effective.configPath;
559
+ const loaded = loadTarget(io, configPath);
560
+ if (!loaded.ok) {
537
561
  return {
538
562
  clientId: input.clientId,
539
563
  state: "unsafe",
540
564
  installed,
541
565
  configPath,
542
- reason: target.why === "read-failed" ? "unparseable" : "not-regular-file",
566
+ reason: loaded.why === "read-failed" ? "unparseable" : "not-regular-file",
543
567
  ...retention,
544
568
  };
545
569
  }
546
570
 
547
- const parsed = input.clientId === "cline" ? parseClineDocument(target.before) : parseConfig(target.before, exportSpec.format);
548
- const contribution = exportSpec.buildContribution(exportContextOf(input));
549
- const record = store.readRecords()[input.clientId] ?? null;
571
+ const parsed = input.clientId === "cline"
572
+ ? parseClineDocument(loaded.before)
573
+ : parseConfig(loaded.before, effective.format);
574
+ const contribution = effective.buildContribution(exportContextOf(input));
550
575
  const { state, reason } = classifyIntegration({
551
- fileText: target.before,
576
+ fileText: loaded.before,
552
577
  fileIsRegular: true,
553
578
  parsed,
554
579
  record,
555
580
  contribution,
556
581
  configPath,
557
582
  clientId: input.clientId,
583
+ format: effective.format,
558
584
  });
559
585
 
560
586
  return {
@@ -563,6 +589,13 @@ export function readIntegrationState(input: IntegrationStateInput): IntegrationS
563
589
  installed,
564
590
  configPath,
565
591
  ...(reason ? { reason } : {}),
592
+ /*
593
+ * Only when the client reads a DIFFERENT file than the one this status is
594
+ * about. A store we are writing needs no notice; the path already names it.
595
+ */
596
+ ...(effective.ineffective && effective.ineffective.store !== configPath
597
+ ? { supersededBy: effective.ineffective.store }
598
+ : {}),
566
599
  ...(record ? { appliedAt: record.appliedAt, lastOpId: record.opId } : {}),
567
600
  ...retention,
568
601
  };
@@ -0,0 +1,208 @@
1
+ /**
2
+ * Which file an integration operation reads, writes and records — and, when
3
+ * that is not the file the client reads, which file is.
4
+ *
5
+ * A client may move its provider list between releases and keep the old file
6
+ * reachable only through a one-shot import. Everything the integration checks
7
+ * still passes against the old file: it is writable, the block merges, the
8
+ * ownership record describes it correctly. The only thing that changed is
9
+ * whether anything reads it (#5348).
10
+ *
11
+ * So the target is chosen before anything is classified, and it is chosen by
12
+ * three facts in this order:
13
+ *
14
+ * 1. No declared store, or no store on disk — the config file, exactly as
15
+ * before. A client that has never run still imports what we write there.
16
+ * 2. Our own block already sits in the config file this record names. Stay
17
+ * there. Disable removes what we wrote from where we wrote it, and the
18
+ * caller reports the write as ineffective rather than orphaning a block in
19
+ * one file while writing another.
20
+ * 3. Otherwise the store, but only when its schema can be established. A store
21
+ * we cannot parse or whose version we have not observed is reported as the
22
+ * reason the write cannot reach the client, because merging an invented
23
+ * nesting into a file holding the user's other providers would trade a
24
+ * silent no-op for a silent loss.
25
+ */
26
+ import { EXPORT_CLIENTS, ClientPathError, type BuildContribution, type ConfigFormat } from "../clients/config-export";
27
+ import { PARSE_FAILED, loadTarget, parseConfig, type IntegrationIO } from "./config-io";
28
+ import { AmbiguousSelectorError, InvalidSelectorError, readPath } from "./merge";
29
+ import type { OwnershipRecord } from "./ownership";
30
+ import { INTEGRATION_CLIENTS, type IntegrationClientId } from "./registry";
31
+
32
+ export interface IntegrationTarget {
33
+ /** The file this operation reads, writes, journals and records ownership for. */
34
+ readonly configPath: string;
35
+ /** Text format of that file, which is not always the client's config format. */
36
+ readonly format: ConfigFormat;
37
+ /** The contribution shape that file's reader understands. */
38
+ readonly buildContribution: BuildContribution;
39
+ /**
40
+ * Set when a write to the file above would not reach the client.
41
+ *
42
+ * `why` is carried rather than re-derived because the cases have different
43
+ * remedies and only this function knows which one it took. Disable is not
44
+ * gated on it: removing bytes this project wrote from the file it wrote them
45
+ * to is as effective as it ever was.
46
+ */
47
+ readonly ineffective: IneffectiveWrite | null;
48
+ }
49
+
50
+ export interface IneffectiveWrite {
51
+ /** The provider store the client reads. */
52
+ readonly store: string;
53
+ /**
54
+ * `owned-config-file` — this project's block is still in the config file, so
55
+ * the operation stays there and the remedy is to remove it first, rather than
56
+ * leave a block in one file while writing another.
57
+ *
58
+ * `unestablished-schema` — the store is not a document whose shape has been
59
+ * observed, so there is nothing safe to merge into it. This one is reachable
60
+ * with the store itself as the target: a client that bumps its schema after
61
+ * we wrote the store leaves our block there, removable, and the file no
62
+ * longer one we may merge into.
63
+ */
64
+ readonly why: "owned-config-file" | "unestablished-schema";
65
+ }
66
+
67
+ function configFileTarget(
68
+ clientId: IntegrationClientId,
69
+ configPath: string,
70
+ ineffective: IneffectiveWrite | null,
71
+ ): IntegrationTarget {
72
+ const exportSpec = EXPORT_CLIENTS[clientId];
73
+ return {
74
+ configPath,
75
+ format: exportSpec.format,
76
+ buildContribution: exportSpec.buildContribution,
77
+ ineffective,
78
+ };
79
+ }
80
+
81
+ function storeTarget(
82
+ declared: NonNullable<(typeof INTEGRATION_CLIENTS)[IntegrationClientId]["currentStore"]>,
83
+ configPath: string,
84
+ ineffective: IneffectiveWrite | null = null,
85
+ ): IntegrationTarget {
86
+ return {
87
+ configPath,
88
+ format: declared.format,
89
+ buildContribution: declared.buildContribution,
90
+ ineffective,
91
+ };
92
+ }
93
+
94
+ /**
95
+ * Does the file this record names still hold any fragment it claims?
96
+ *
97
+ * Uncertainty answers yes. An unreadable or unparseable config file is a state
98
+ * the classifier is about to refuse on, and it must refuse on the file our
99
+ * record is about rather than silently move the operation to a different one.
100
+ * An ambiguous or invalid selector is the same kind of answer.
101
+ */
102
+ function recordedBlockStillPresent(
103
+ io: IntegrationIO,
104
+ format: ConfigFormat,
105
+ configPath: string,
106
+ record: OwnershipRecord,
107
+ ): boolean {
108
+ const loaded = loadTarget(io, configPath);
109
+ if (!loaded.ok) return true;
110
+ const parsed = parseConfig(loaded.before, format);
111
+ if (parsed === PARSE_FAILED) return true;
112
+ try {
113
+ return record.fragmentPaths.some(path => readPath(parsed, path) !== undefined);
114
+ } catch (error) {
115
+ if (error instanceof AmbiguousSelectorError || error instanceof InvalidSelectorError) return true;
116
+ throw error;
117
+ }
118
+ }
119
+
120
+ /**
121
+ * Resolve the target for one operation.
122
+ *
123
+ * `configPath` is the client's config file as the registry resolves it now, and
124
+ * `record` is the stored ownership as read, before any path filtering — the
125
+ * filtering depends on the answer this function returns.
126
+ *
127
+ * The store is observed through the caller's own `IntegrationIO`, never the
128
+ * real filesystem directly, so status and mutation cannot disagree about
129
+ * whether a write can land.
130
+ */
131
+ export function resolveIntegrationTarget(args: {
132
+ clientId: IntegrationClientId;
133
+ configPath: string;
134
+ io: IntegrationIO;
135
+ record: OwnershipRecord | null;
136
+ env?: NodeJS.ProcessEnv;
137
+ home?: string;
138
+ }): IntegrationTarget {
139
+ const { clientId, configPath, io, record } = args;
140
+ const declared = INTEGRATION_CLIENTS[clientId].currentStore;
141
+ if (!declared) return configFileTarget(clientId, configPath, null);
142
+ const storePath = declared.path(args.env, args.home);
143
+ const kind = io.statKind(storePath);
144
+ if (kind === "missing") return configFileTarget(clientId, configPath, null);
145
+ // Only proven absence permits a legacy write. Unreadable or non-file stores
146
+ // cannot establish what the client reads; preserve the recorded removal target.
147
+ if (kind !== "file") {
148
+ const ineffective: IneffectiveWrite = { store: storePath, why: "unestablished-schema" };
149
+ return record?.clientId === clientId && record.configPath === storePath
150
+ ? storeTarget(declared, storePath, ineffective)
151
+ : configFileTarget(clientId, configPath, ineffective);
152
+ }
153
+ const loaded = loadTarget(io, storePath);
154
+ const parsed = loaded.ok ? parseConfig(loaded.before, declared.format) : PARSE_FAILED;
155
+ const established = parsed !== PARSE_FAILED && declared.establishes(parsed);
156
+ const unestablished: IneffectiveWrite = { store: storePath, why: "unestablished-schema" };
157
+ /*
158
+ * Our own block decides the target before the store's schema does, whichever
159
+ * file holds it. Disable has to remove what we wrote from where we wrote it,
160
+ * and an apply must never leave a block in one file while writing another.
161
+ */
162
+ const owned = record !== null && record.clientId === clientId ? record : null;
163
+ if (owned?.configPath === storePath && recordedBlockStillPresent(io, declared.format, storePath, owned)) {
164
+ return storeTarget(declared, storePath, established ? null : unestablished);
165
+ }
166
+ if (
167
+ owned?.configPath === configPath
168
+ && recordedBlockStillPresent(io, EXPORT_CLIENTS[clientId].format, configPath, owned)
169
+ ) {
170
+ return configFileTarget(clientId, configPath, { store: storePath, why: "owned-config-file" });
171
+ }
172
+ if (!established) {
173
+ return configFileTarget(clientId, configPath, unestablished);
174
+ }
175
+ return storeTarget(declared, storePath);
176
+ }
177
+
178
+ /**
179
+ * The target a path NAMES, or null when this client may not write there.
180
+ *
181
+ * Undo acts on the file its journal row recorded, so it needs the meaning of a
182
+ * historical path rather than the choice above: which contribution shape
183
+ * describes those bytes, and whether this client still resolves that location
184
+ * at all. Resolving a path the client no longer names is what would let a row
185
+ * recorded for one home delete a file in another, so an unrecognised path is
186
+ * null and the caller refuses.
187
+ */
188
+ export function declaredIntegrationTarget(args: {
189
+ clientId: IntegrationClientId;
190
+ configPath: string;
191
+ resolvedConfigPath: string;
192
+ env?: NodeJS.ProcessEnv;
193
+ home?: string;
194
+ }): IntegrationTarget | null {
195
+ const { clientId, configPath, resolvedConfigPath } = args;
196
+ if (configPath === resolvedConfigPath) return configFileTarget(clientId, configPath, null);
197
+ const declared = INTEGRATION_CLIENTS[clientId].currentStore;
198
+ if (!declared) return null;
199
+ try {
200
+ if (declared.path(args.env, args.home) !== configPath) return null;
201
+ } catch (error) {
202
+ // A store the operator relocated with a path we cannot resolve proves
203
+ // nothing about the row, so the row is not a legal target.
204
+ if (error instanceof ClientPathError) return null;
205
+ throw error;
206
+ }
207
+ return storeTarget(declared, configPath);
208
+ }
@@ -13,7 +13,7 @@ import { homedir } from "node:os";
13
13
  import { createClineIO, ClineTransactionError } from "./cline-io";
14
14
  import { serializeClineDocument, preserveClineSelection } from "./cline-document";
15
15
  import { dirname } from "node:path";
16
- import { EXPORT_CLIENTS, type ExportModel, type ManagedContribution } from "../clients/config-export";
16
+ import type { ExportModel, ManagedContribution } from "../clients/config-export";
17
17
  import { shouldInjectApiAuthHeader } from "../codex/inject";
18
18
  import { detachedConfigSnapshot } from "../config/admitted-identity";
19
19
  import { copyPlainData } from "../lib/plain-data";
@@ -33,6 +33,7 @@ import {
33
33
  } from "./ownership-policy";
34
34
  import { AmbiguousSelectorError, createdContainerPaths, mergeContribution, removeFragments } from "./merge";
35
35
  import { INTEGRATION_CLIENTS, isLoopbackOnly, resolveIntegrationPaths, type IntegrationClientId } from "./registry";
36
+ import { declaredIntegrationTarget } from "./target";
36
37
  import { exportContextOf } from "./state";
37
38
  import type { IntegrationState } from "./state";
38
39
  import { serializeDocument, UnserializableValueError } from "./serialize";
@@ -251,7 +252,7 @@ function applyOrRefreshIntegration(
251
252
  ): WriteOutcome {
252
253
  const pre = preflight(input);
253
254
  if (pre.failed) return pre.failed;
254
- const { store, io, clientId, spec, exportSpec, configPath, detectDir, before, parsed, contribution, record, classified } = pre;
255
+ const { store, io, clientId, spec, target, configPath, detectDir, before, parsed, contribution, record, classified } = pre;
255
256
 
256
257
  // The detect directory preflight already resolved, so it cannot name a
257
258
  // different account than the config path this operation is about to write.
@@ -262,6 +263,35 @@ function applyOrRefreshIntegration(
262
263
  return refuse(clientId, "non_loopback", classified.state,
263
264
  `The generated ${clientId} integration is loopback-only and does not emit the admission header a non-loopback bind requires. Give it loopback access instead, through a tunnel or a local forwarder.`);
264
265
  }
266
+ /*
267
+ * The write would land, and nothing would read it.
268
+ *
269
+ * This is deliberately a refusal rather than a warning attached to a success.
270
+ * The file is writable, our block merges cleanly, and the ownership record
271
+ * that follows would describe a real state of a real file — which is exactly
272
+ * how the original defect stayed invisible: every check the integration runs
273
+ * passed, the journal recorded a correct apply, and no model ever appeared in
274
+ * the client (#5348). Reporting the operation as done is the part that is
275
+ * wrong, so the operation does not report at all.
276
+ *
277
+ * Reached only when the store cannot be written: either this project's block
278
+ * is still in the config file, where removing it is the way forward, or the
279
+ * store is not a document whose shape has been observed. Where the store can
280
+ * be written the operation targets it and never arrives here.
281
+ *
282
+ * Apply, overwrite and refresh only. Disable removes bytes this project put
283
+ * in this file, and that removal is as effective as it ever was.
284
+ */
285
+ if (target.ineffective !== null) {
286
+ const readsFrom = target.ineffective.store;
287
+ const fallback = `Add the proxy as a provider in ${clientId}'s own settings instead; \`ocx export --client ${clientId}\` prints the model list to copy.`;
288
+ return refuse(clientId, "superseded_store", classified.state,
289
+ target.ineffective.why === "owned-config-file"
290
+ ? `${clientId} now reads its providers from ${readsFrom}, and opencodex still has a block in ${configPath}, which it no longer reads. Disable the ${clientId} integration to remove that block, then enable it again to write ${readsFrom}.`
291
+ : readsFrom === configPath
292
+ ? `opencodex does not recognise the schema of ${readsFrom}, the file ${clientId} reads its providers from, so it will not merge into it. ${fallback}`
293
+ : `${clientId} now reads its providers from ${readsFrom}, whose schema opencodex does not recognise, so writing ${configPath} would change nothing it loads. ${fallback}`);
294
+ }
265
295
  if (classified.state === "conflict") {
266
296
  if (conflictPolicy === "refuse") {
267
297
  return refuse(clientId, "conflict", "conflict",
@@ -372,7 +402,7 @@ function applyOrRefreshIntegration(
372
402
  }
373
403
  text = patched;
374
404
  } else {
375
- text = clientId === "cline" ? serializeClineDocument(nextDocument) : serializeDocument(nextDocument, exportSpec.format);
405
+ text = clientId === "cline" ? serializeClineDocument(nextDocument) : serializeDocument(nextDocument, target.format);
376
406
  }
377
407
  } catch (error) {
378
408
  if (error instanceof AmbiguousSelectorError) {
@@ -460,7 +490,7 @@ export function refreshIntegration(input: IntegrationWriteInput): WriteOutcome {
460
490
  export function disableIntegration(input: IntegrationWriteInput): WriteOutcome {
461
491
  const pre = preflight(input);
462
492
  if (pre.failed) return pre.failed;
463
- const { store, io, clientId, spec, exportSpec, configPath, before, parsed, record, classified } = pre;
493
+ const { store, io, clientId, spec, target, configPath, before, parsed, record, classified } = pre;
464
494
 
465
495
  if (classified.state === "absent") {
466
496
  return { ok: true, changed: false, state: "absent", clientId, message: "not applied" };
@@ -532,7 +562,7 @@ export function disableIntegration(input: IntegrationWriteInput): WriteOutcome {
532
562
  }
533
563
  text = patched;
534
564
  } else {
535
- text = clientId === "cline" ? serializeClineDocument(doc) : serializeDocument(doc, exportSpec.format);
565
+ text = clientId === "cline" ? serializeClineDocument(doc) : serializeDocument(doc, target.format);
536
566
  }
537
567
  } catch (error) {
538
568
  if (!(error instanceof UnserializableValueError)) throw error;
@@ -570,13 +600,21 @@ export function restoreIntegration(input: IntegrationRestoreInput): WriteOutcome
570
600
  const clientId = entry.clientId;
571
601
  const resolvedPath = input.resolvedPaths?.configPath
572
602
  ?? INTEGRATION_CLIENTS[clientId].configPath(input.env, input.home);
573
- // Restore acts on the path the operation was journaled against. Resolving a
574
- // different path here would let an operation recorded for one home delete a
575
- // file in another.
576
603
  const configPath = entry.configPath;
577
- if (resolvedPath !== configPath) {
604
+ /*
605
+ * Restore acts on the path the operation was journaled against. Resolving a
606
+ * different path here would let an operation recorded for one home delete a
607
+ * file in another — but a client may legally have written more than one file,
608
+ * so the question is whether this client still names that location, not
609
+ * whether it is the config file. The answer also carries the contribution
610
+ * shape those bytes are in, which is what the state below is measured against.
611
+ */
612
+ const rowTarget = declaredIntegrationTarget({
613
+ clientId, configPath, resolvedConfigPath: resolvedPath, env: input.env, home: input.home,
614
+ });
615
+ if (rowTarget === null) {
578
616
  return refuse(clientId, "conflict", "conflict",
579
- `that operation was recorded for ${configPath}, but this client now resolves to ${resolvedPath}`);
617
+ `that operation was recorded for ${configPath}, which this client no longer writes; it now resolves to ${resolvedPath}`);
580
618
  }
581
619
  if (clientId === "cline") {
582
620
  try { io = createClineIO(io, configPath, store, true); }
@@ -625,7 +663,7 @@ export function restoreIntegration(input: IntegrationRestoreInput): WriteOutcome
625
663
  // exact bytes when the snapshot was taken. Re-deriving it from the file would
626
664
  // mean guessing which entries are ours, and a wrong guess deletes a user's.
627
665
  const restoredRecord = entry.priorRecord;
628
- const fresh = EXPORT_CLIENTS[clientId].buildContribution(exportContextOf(input));
666
+ const fresh = rowTarget.buildContribution(exportContextOf(input));
629
667
  /*
630
668
  * Does the restored record actually describe the restored bytes?
631
669
  *
@@ -12,6 +12,11 @@ export function fixtureProviderConfig(adapter: string): OcxProviderConfig {
12
12
  models: ["fixture-model"],
13
13
  defaultModel: "fixture-model",
14
14
  liveModels: false,
15
+ // The wire role is no longer read from the hostname, and an undeclared destination folds
16
+ // `developer` to `system` because one that rejects the role answers 400 and the turn never
17
+ // starts. This fixture is the one place that asserts the forwarded role, so the destination
18
+ // it stands for records that it accepts it. Only the Chat adapter reaches that decision.
19
+ ...(adapter === "openai-chat" ? { foldDeveloperRoleToSystem: false } : {}),
15
20
  };
16
21
  }
17
22
 
@@ -0,0 +1,59 @@
1
+ import type { OpenUrlResult } from "./open-url";
2
+
3
+ /**
4
+ * The one sentence a terminal login says when nothing opened.
5
+ *
6
+ * Stated once because it is now said from three places — the Codex account login, the generic
7
+ * OAuth login and the key login — and a sentence restated three times drifts three ways. Callers
8
+ * that have something more specific to add append to it rather than rewriting it, so the part a
9
+ * user learns to recognize stays identical everywhere.
10
+ */
11
+ export const BROWSER_LAUNCH_FAILED_NOTICE =
12
+ "⚠️ No browser could be opened here — open the URL above yourself.";
13
+
14
+ /**
15
+ * Report a browser launch whose answer arrives after the code that started it has moved on.
16
+ *
17
+ * The OAuth controller does not await `onAuth`, so a CLI login cannot simply await the launcher
18
+ * there: the flow continues, and on a callback-server provider the very next thing it does is
19
+ * draw a readline prompt. A warning written at that moment lands on the line the user is typing
20
+ * on, which is worse than not warning at all.
21
+ *
22
+ * So the launch reports itself as soon as it settles, and anything that would collide with it
23
+ * waits on {@link BrowserLaunchReport.settled} first. The launcher answers within its own settle
24
+ * window, so the wait costs a fraction of a second and buys a deterministic order.
25
+ */
26
+ export interface BrowserLaunchReport {
27
+ /** Adopt a launch already in flight. Its failure is reported once, when it settles. */
28
+ track(launch: Promise<OpenUrlResult>): void;
29
+ /**
30
+ * Resolves once every launch tracked BEFORE this call has been reported, and immediately when
31
+ * none was. A login publishes one URL, so "before this call" and "at all" are the same set
32
+ * here; the narrower promise is the one this actually keeps.
33
+ */
34
+ settled(): Promise<void>;
35
+ }
36
+
37
+ export function createBrowserLaunchReport(
38
+ warn: (message: string) => void = message => { console.warn(message); },
39
+ ): BrowserLaunchReport {
40
+ let pending: Promise<void> = Promise.resolve();
41
+ return {
42
+ track(launch) {
43
+ pending = pending
44
+ .then(() => launch)
45
+ .then(
46
+ result => {
47
+ if (result.status !== "started") warn(`\n${BROWSER_LAUNCH_FAILED_NOTICE}`);
48
+ },
49
+ // openUrl is documented never to reject, and a launcher that did would mean the same
50
+ // thing as one that failed. Swallowing it here is not politeness: this chain is what
51
+ // `settled()` hands to a prompt, so a rejection would propagate out of a login that
52
+ // is still perfectly able to continue, and would go unhandled in the polling flows
53
+ // that do not reach that await until minutes later.
54
+ () => { warn(`\n${BROWSER_LAUNCH_FAILED_NOTICE}`); },
55
+ );
56
+ },
57
+ settled: () => pending,
58
+ };
59
+ }
@@ -14,6 +14,7 @@ import { createRequire } from "node:module";
14
14
  import { realpathSync } from "node:fs";
15
15
  import { dirname, join } from "node:path";
16
16
  import { isRealBunBinary } from "./bun-binary-validator.mjs";
17
+ import { isStandaloneBinary } from "./standalone";
17
18
 
18
19
  export { isRealBunBinary };
19
20
 
@@ -38,10 +39,10 @@ export const BUN_RUNTIME_SOURCE_ENV = "OCX_BUN_RUNTIME_SOURCE";
38
39
  */
39
40
  export const BUN_RUNTIME_PATH_ENV = "OCX_BUN_RUNTIME_PATH";
40
41
 
41
- export type BunRuntimeSource = "override" | "bundled" | "process";
42
+ export type BunRuntimeSource = "override" | "bundled" | "process" | "standalone";
42
43
 
43
44
  /** The only provenance values any surface may accept off the wire or out of the env. */
44
- export const BUN_RUNTIME_SOURCES: readonly BunRuntimeSource[] = ["override", "bundled", "process"];
45
+ export const BUN_RUNTIME_SOURCES: readonly BunRuntimeSource[] = ["override", "bundled", "process", "standalone"];
45
46
 
46
47
  export type DurableBunRuntime = {
47
48
  path: string;
@@ -165,6 +166,9 @@ function unmarkedDurableBunRuntime(): DurableBunRuntime {
165
166
  }
166
167
 
167
168
  export function durableBunRuntime(): DurableBunRuntime {
169
+ if (isStandaloneBinary()) {
170
+ return { path: process.execPath, source: "standalone", overrideEnv: BUN_OVERRIDE_ENV };
171
+ }
168
172
  // A durable artifact must use the runtime selected BEFORE Bun auto-loaded a
169
173
  // project dotenv. The Node launcher and owned service/shim launchers stamp the
170
174
  // selected source/path pair; it is accepted only when it names this exact