@bitkyc08/opencodex 2.65.0 → 2.66.0-preview.20260925

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 (213) hide show
  1. package/README.md +14 -0
  2. package/gui/dist/assets/App-BVjA0T6O.js +51 -0
  3. package/gui/dist/assets/App-gkoJIEOV.css +1 -0
  4. package/gui/dist/assets/{Tray-D4bA1vff.js → Tray-BCFtiWwD.js} +1 -1
  5. package/gui/dist/assets/index-ComVsdSk.css +1 -0
  6. package/gui/dist/assets/index-vO4dgfiU.js +86 -0
  7. package/gui/dist/assets/{tray-data-BZXhB3qz.js → tray-data-C4FiNaxk.js} +1 -1
  8. package/gui/dist/index.html +2 -2
  9. package/package.json +1 -2
  10. package/src/adapters/anthropic/beta-allowlist.ts +80 -0
  11. package/src/adapters/anthropic/passthrough.ts +221 -0
  12. package/src/adapters/anthropic-image-codec.ts +56 -19
  13. package/src/adapters/anthropic-image-normalize.ts +20 -10
  14. package/src/adapters/anthropic.ts +55 -24
  15. package/src/adapters/coding-agent/protocol.ts +29 -19
  16. package/src/adapters/cursor/live-transport.ts +18 -3
  17. package/src/adapters/cursor/native-exec-shell.ts +18 -63
  18. package/src/adapters/cursor/native-exec.ts +5 -2
  19. package/src/adapters/cursor/native-foreground-shell.ts +44 -0
  20. package/src/adapters/devin/cloud-direct/chat.ts +7 -1
  21. package/src/adapters/exec-tool-result-normalize.ts +4 -1
  22. package/src/adapters/google.ts +12 -0
  23. package/src/adapters/ollama-native.ts +32 -1
  24. package/src/adapters/openai-chat/serialized-tool-call-content.ts +131 -34
  25. package/src/adapters/openai-chat.ts +6 -4
  26. package/src/adapters/openai-responses/passthrough.ts +1 -0
  27. package/src/adapters/openai-responses/reasoning.ts +45 -1
  28. package/src/bridge/response-json.ts +10 -2
  29. package/src/chat/outbound.ts +104 -52
  30. package/src/claude/claude-code-block.ts +16 -0
  31. package/src/claude/context-windows.ts +52 -4
  32. package/src/claude/desktop-first-party.ts +25 -8
  33. package/src/claude/inbound.ts +22 -0
  34. package/src/claude/intercept/connect-proxy.ts +37 -4
  35. package/src/claude/intercept/proxy-auth.ts +166 -0
  36. package/src/claude/intercept/runtime.ts +21 -0
  37. package/src/claude/intercept/settings.ts +33 -12
  38. package/src/claude/outbound.ts +45 -27
  39. package/src/cli/account-extended.ts +47 -15
  40. package/src/cli/account-target.ts +115 -0
  41. package/src/cli/account.ts +20 -28
  42. package/src/cli/agent.ts +10 -1
  43. package/src/cli/api-protocols.ts +206 -0
  44. package/src/cli/capabilities.ts +86 -0
  45. package/src/cli/combo.ts +2 -2
  46. package/src/cli/connect.ts +127 -3
  47. package/src/cli/dispatch.ts +8 -0
  48. package/src/cli/doctor.ts +33 -0
  49. package/src/cli/help.ts +2 -0
  50. package/src/cli/link.ts +288 -0
  51. package/src/cli/registry.ts +23 -0
  52. package/src/cli/runtime-api.ts +79 -0
  53. package/src/cli/system-command.ts +21 -2
  54. package/src/client/connect.ts +125 -23
  55. package/src/client/hub-relay.ts +12 -8
  56. package/src/client/link-join.ts +323 -0
  57. package/src/client/link-relay.ts +241 -0
  58. package/src/client/link-state.ts +110 -0
  59. package/src/client/link-teardown.ts +63 -0
  60. package/src/client/link-tunnel.ts +465 -0
  61. package/src/client/machine-listener.ts +14 -5
  62. package/src/client/runtime.ts +91 -42
  63. package/src/client/state.ts +4 -0
  64. package/src/codex/catalog/build-entries.ts +4 -0
  65. package/src/codex/catalog/derive-entry.ts +5 -0
  66. package/src/codex/catalog/parsing.ts +4 -0
  67. package/src/codex/catalog/retained-sync.ts +4 -1
  68. package/src/codex/desktop-switches.ts +88 -8
  69. package/src/codex/inject/plan.ts +16 -3
  70. package/src/codex/inject.ts +3 -0
  71. package/src/codex/internal/catalog-writer.ts +13 -2
  72. package/src/combos/failover.ts +3 -2
  73. package/src/combos/index.ts +12 -0
  74. package/src/combos/jev.ts +646 -0
  75. package/src/combos/request.ts +5 -0
  76. package/src/combos/resolve.ts +17 -15
  77. package/src/combos/types.ts +47 -3
  78. package/src/config/atomic-write.ts +3 -0
  79. package/src/config/live-reconcile.ts +20 -4
  80. package/src/config/persist-unlocked.ts +20 -7
  81. package/src/config/schema/config-schema.ts +16 -0
  82. package/src/config/schema/leaf-validators.ts +38 -1
  83. package/src/generated/compatibility-version.json +416 -128
  84. package/src/integrations/omp-yaml-source.ts +109 -4
  85. package/src/lab/automation/orchestrator.ts +60 -4
  86. package/src/lab/events/limits.ts +1 -1
  87. package/src/lib/bounded-body.ts +55 -38
  88. package/src/lib/config-ownership.ts +11 -22
  89. package/src/lib/local-desktop-snapshot-capability.ts +56 -0
  90. package/src/lib/local-management-capability.ts +25 -3
  91. package/src/lib/optional-shutdown-hooks.ts +17 -0
  92. package/src/lib/state-store-registrations.ts +2 -2
  93. package/src/lib/windows-elevation.ts +62 -25
  94. package/src/lib/windows-secret-acl.ts +114 -23
  95. package/src/link/admission-wait.ts +73 -0
  96. package/src/link/compensation.ts +100 -0
  97. package/src/link/fingerprint.ts +21 -0
  98. package/src/link/paths.ts +19 -0
  99. package/src/link/ports.ts +12 -0
  100. package/src/link/routes.ts +32 -0
  101. package/src/link/ssh-argv.ts +170 -0
  102. package/src/link/ssh-config.ts +152 -0
  103. package/src/link/ssh-runner.ts +161 -0
  104. package/src/link/status-projection.ts +96 -0
  105. package/src/link/store.ts +139 -0
  106. package/src/link/supervisor.ts +404 -0
  107. package/src/link/tunnel-state.ts +91 -0
  108. package/src/protocols/baseline.ts +88 -0
  109. package/src/protocols/codecs/chat.ts +17 -0
  110. package/src/protocols/codecs/messages.ts +18 -0
  111. package/src/protocols/codecs/responses.ts +17 -0
  112. package/src/protocols/contract.ts +185 -0
  113. package/src/protocols/dto.ts +267 -0
  114. package/src/protocols/encoders/adapter-events.ts +876 -0
  115. package/src/protocols/encoders/chat.ts +243 -0
  116. package/src/protocols/encoders/messages.ts +462 -0
  117. package/src/protocols/envelope.ts +43 -0
  118. package/src/protocols/features.ts +295 -0
  119. package/src/protocols/guard.ts +33 -0
  120. package/src/protocols/opaque-state.ts +132 -0
  121. package/src/protocols/path.ts +39 -0
  122. package/src/protocols/plan-snapshot.ts +226 -0
  123. package/src/protocols/plan.ts +192 -0
  124. package/src/protocols/provider-summary.ts +101 -0
  125. package/src/protocols/settings.ts +118 -0
  126. package/src/protocols/shadow-plan.ts +36 -0
  127. package/src/protocols/shadow.ts +62 -0
  128. package/src/protocols/trace.ts +335 -0
  129. package/src/providers/model-rename-migration.ts +3 -3
  130. package/src/providers/registry/entries-core.ts +6 -1
  131. package/src/providers/registry/entries-extended.ts +15 -0
  132. package/src/providers/registry/model-ids.ts +1 -0
  133. package/src/providers/registry/types.ts +6 -0
  134. package/src/providers/registry.ts +1 -1
  135. package/src/remote-control/workspace-hub.ts +5 -4
  136. package/src/responses/apply-patch-envelope.ts +6 -1
  137. package/src/responses/code-mode-shell-input.ts +4 -2
  138. package/src/responses/freeform-wrapper-scan.ts +51 -24
  139. package/src/responses/progressive-freeform-input.ts +14 -20
  140. package/src/responses/spill-store.ts +187 -33
  141. package/src/responses/state/snapshot-select.ts +49 -0
  142. package/src/responses/state/spill-inspect.ts +46 -0
  143. package/src/responses/state/spill-queue.ts +16 -0
  144. package/src/responses/state.ts +51 -27
  145. package/src/server/audio-client.ts +8 -3
  146. package/src/server/audio-upstream.ts +9 -4
  147. package/src/server/auth-cors.ts +25 -8
  148. package/src/server/chat-completions.ts +71 -11
  149. package/src/server/chat-native-eligibility.ts +74 -0
  150. package/src/server/chat-native.ts +139 -74
  151. package/src/server/claude-messages.ts +140 -22
  152. package/src/server/hub-usage.ts +4 -3
  153. package/src/server/index/link-listener.ts +195 -0
  154. package/src/server/index/optional-listeners.ts +115 -0
  155. package/src/server/index/serve-options.ts +44 -10
  156. package/src/server/index.ts +10 -9
  157. package/src/server/inference/attempt.ts +51 -0
  158. package/src/server/inference/client-encoder-delivery.ts +235 -0
  159. package/src/server/inference/client-wire-log.ts +49 -0
  160. package/src/server/inference/client-wire.ts +69 -0
  161. package/src/server/inference/context.ts +15 -0
  162. package/src/server/inference/final-log.ts +37 -0
  163. package/src/server/local-desktop-snapshot-auth.ts +57 -0
  164. package/src/server/management/agent-settings-routes.ts +15 -13
  165. package/src/server/management/api-access.ts +11 -1
  166. package/src/server/management/config-routes.ts +3 -6
  167. package/src/server/management/context.ts +25 -0
  168. package/src/server/management/link-routes.ts +535 -0
  169. package/src/server/management/logs-usage-routes.ts +31 -1
  170. package/src/server/management/native-integration-routes.ts +6 -11
  171. package/src/server/management/oauth-account-routes.ts +42 -16
  172. package/src/server/management/protocol-routes.ts +159 -0
  173. package/src/server/management/protocol-settings-patch.ts +145 -0
  174. package/src/server/management/provider-patch-transaction.ts +61 -0
  175. package/src/server/management/provider-routes.ts +39 -18
  176. package/src/server/management/route-registry.ts +12 -0
  177. package/src/server/management/sidebar-routes.ts +8 -3
  178. package/src/server/management/system-routes.ts +18 -1
  179. package/src/server/management/usage-aggregate-cache.ts +206 -6
  180. package/src/server/management-api.ts +26 -1
  181. package/src/server/management-auth.ts +19 -5
  182. package/src/server/messages-native-eligibility.ts +161 -0
  183. package/src/server/messages-native-oauth.ts +149 -0
  184. package/src/server/messages-native.ts +790 -0
  185. package/src/server/relay.ts +9 -0
  186. package/src/server/request-log-filter.ts +92 -0
  187. package/src/server/request-log.ts +31 -70
  188. package/src/server/responses/adapter-delivery.ts +43 -16
  189. package/src/server/responses/agent-task-recovery.ts +90 -22
  190. package/src/server/responses/codex-ws-exchange.ts +4 -0
  191. package/src/server/responses/codex-ws-wire.ts +10 -1
  192. package/src/server/responses/core-combo-native.ts +323 -0
  193. package/src/server/responses/core-combo.ts +224 -13
  194. package/src/server/responses/core-options.ts +22 -0
  195. package/src/server/responses/core.ts +14 -26
  196. package/src/server/responses/request-sidecar-auth.ts +8 -3
  197. package/src/server/responses/run-turn-execution.ts +257 -63
  198. package/src/server/responses/sidecar-execution.ts +7 -4
  199. package/src/server/system-env.ts +7 -0
  200. package/src/service/diagnostics.ts +7 -1
  201. package/src/service/launchd.ts +26 -37
  202. package/src/service/windows-ops.ts +24 -20
  203. package/src/types/config.ts +58 -1
  204. package/src/types.ts +2 -0
  205. package/src/update/transactional-install.d.mts +0 -1
  206. package/src/update/transactional-install.mjs +17 -17
  207. package/src/usage/jev-stats.ts +495 -0
  208. package/src/usage/log.ts +24 -3
  209. package/src/web-search/run-turn-loop.ts +568 -0
  210. package/gui/dist/assets/App-8NMiZxT0.css +0 -1
  211. package/gui/dist/assets/App-DGLte6IR.js +0 -50
  212. package/gui/dist/assets/index--EWgGQvZ.css +0 -1
  213. package/gui/dist/assets/index-Bi1K37Wl.js +0 -86
@@ -10,7 +10,7 @@ import {
10
10
  } from "./failover";
11
11
  import { quotaResetRemainingMs } from "./reset-window";
12
12
  import { getCombo, resolveComboId, targetKey } from "./types";
13
- import type { NormalizedComboConfig } from "./types";
13
+ import type { NormalizedComboConfig, NormalizedComboTarget } from "./types";
14
14
  import {
15
15
  captureConfigGeneration,
16
16
  type GenerationContext,
@@ -18,7 +18,7 @@ import {
18
18
 
19
19
  export interface ComboPick {
20
20
  comboId: string;
21
- target: Required<OcxComboTarget>;
21
+ target: NormalizedComboTarget;
22
22
  targetIndex: number;
23
23
  attempted: string[];
24
24
  writerGeneration: number;
@@ -138,9 +138,9 @@ export function quotaInactiveReason(
138
138
  }
139
139
 
140
140
  function smoothWeightedIndex(
141
- targets: Required<OcxComboTarget>[],
141
+ targets: NormalizedComboTarget[],
142
142
  state: SelectionState,
143
- eligible: (target: Required<OcxComboTarget>) => boolean,
143
+ eligible: (target: NormalizedComboTarget) => boolean,
144
144
  ): number {
145
145
  let best = -1;
146
146
  let bestScore = Number.NEGATIVE_INFINITY;
@@ -175,8 +175,8 @@ function smoothWeightedIndex(
175
175
  */
176
176
  function resetWindowIndex(
177
177
  config: OcxConfig,
178
- targets: Required<OcxComboTarget>[],
179
- eligible: (target: Required<OcxComboTarget>) => boolean,
178
+ targets: NormalizedComboTarget[],
179
+ eligible: (target: NormalizedComboTarget) => boolean,
180
180
  now = Date.now(),
181
181
  ): number {
182
182
  let selected = -1;
@@ -202,7 +202,7 @@ export function pickComboTarget(
202
202
  comboId: string,
203
203
  options: {
204
204
  exclude?: Iterable<string>;
205
- eligible?: (target: Required<OcxComboTarget>) => boolean;
205
+ eligible?: (target: NormalizedComboTarget) => boolean;
206
206
  now?: number;
207
207
  } = {},
208
208
  ): ComboPick | null {
@@ -211,7 +211,7 @@ export function pickComboTarget(
211
211
  if (!combo) throw new UnknownComboError(comboId);
212
212
  const excluded = new Set(options.exclude ?? []);
213
213
  const now = options.now ?? Date.now();
214
- const eligible = (target: Required<OcxComboTarget>): boolean =>
214
+ const eligible = (target: NormalizedComboTarget): boolean =>
215
215
  targetProviderIsUsable(config, target, now)
216
216
  && !isComboTargetInCooldown(comboId, target, now)
217
217
  && !excluded.has(targetKey(target))
@@ -291,7 +291,7 @@ export function pickComboTarget(
291
291
  export function noteComboSuccess(
292
292
  comboId: string,
293
293
  combo: NormalizedComboConfig,
294
- target: Required<OcxComboTarget>,
294
+ target: NormalizedComboTarget,
295
295
  writerGeneration = captureConfigGeneration(),
296
296
  ): void {
297
297
  const key = targetKey(target);
@@ -336,8 +336,9 @@ export function advanceComboAfterFailure(
336
336
  resetAt?: unknown | unknown[];
337
337
  now?: number;
338
338
  cooldownMs?: number;
339
- eligible?: (target: Required<OcxComboTarget>) => boolean;
339
+ eligible?: (target: NormalizedComboTarget) => boolean;
340
340
  cooldownScope?: ComboFailureCooldownScope;
341
+ onCooldownRecorded?: (target: Pick<OcxComboTarget, "provider" | "model">) => void;
341
342
  status?: number;
342
343
  code?: string | null;
343
344
  message?: string;
@@ -352,11 +353,12 @@ export function advanceComboAfterFailure(
352
353
  ? combo.targets.filter(target => target.provider === pick.target.provider)
353
354
  : [pick.target];
354
355
  for (const target of cooldownTargets) {
355
- coolComboTarget(pick.comboId, target, {
356
+ const recorded = coolComboTarget(pick.comboId, target, {
356
357
  ...options,
357
358
  cooldownMs: options.cooldownMs ?? combo?.cooldownMs,
358
359
  writerGeneration: pick.writerGeneration,
359
360
  });
361
+ if (recorded) options.onCooldownRecorded?.(target);
360
362
  }
361
363
  }
362
364
  // #5691: under `cooldownWaitPolicy: "before-last-resort"` this final synchronous pick
@@ -380,7 +382,7 @@ export async function pickComboTargetWithWait(
380
382
  comboId: string,
381
383
  options: {
382
384
  exclude?: Iterable<string>;
383
- eligible?: (target: Required<OcxComboTarget>) => boolean;
385
+ eligible?: (target: NormalizedComboTarget) => boolean;
384
386
  waitForCooldownMs: number;
385
387
  abortSignal?: AbortSignal;
386
388
  now?: number;
@@ -390,10 +392,10 @@ export async function pickComboTargetWithWait(
390
392
  const now = options.now ?? Date.now();
391
393
  const excluded = new Set(options.exclude ?? []);
392
394
  const customEligible = options.eligible;
393
- const eligibleAt = (target: Required<OcxComboTarget>, at: number): boolean =>
395
+ const eligibleAt = (target: NormalizedComboTarget, at: number): boolean =>
394
396
  !isComboTargetInCooldown(comboId, target, at)
395
397
  && (customEligible?.(target) ?? true);
396
- const eligible = (target: Required<OcxComboTarget>): boolean => eligibleAt(target, now);
398
+ const eligible = (target: NormalizedComboTarget): boolean => eligibleAt(target, now);
397
399
  // Milliseconds already slept inside this call. `waitForCooldownMs` is documented as a cap
398
400
  // per *selection attempt*, so a deferral wait and the ordinary wait below must share it —
399
401
  // otherwise a 3s deferral followed by a 9s ordinary wait spends 12s against a 10s budget.
@@ -411,7 +413,7 @@ export async function pickComboTargetWithWait(
411
413
  const defersLastResort = policyCombo?.cooldownWaitPolicy === "before-last-resort"
412
414
  && policyCombo.targets.some(target => !target.lastResort);
413
415
  if (defersLastResort && !options.abortSignal?.aborted) {
414
- const normalOnly = (target: Required<OcxComboTarget>): boolean =>
416
+ const normalOnly = (target: NormalizedComboTarget): boolean =>
415
417
  !target.lastResort && eligible(target);
416
418
  const normalPick = pickComboTarget(config, comboId, {
417
419
  exclude: excluded,
@@ -20,6 +20,15 @@ export interface ComboValidationIssue {
20
20
  message: string;
21
21
  }
22
22
 
23
+ export interface NormalizedComboTarget {
24
+ provider: string;
25
+ model: string;
26
+ weight: number;
27
+ /** Emergency-only target, deferred under `cooldownWaitPolicy` (#5691). */
28
+ lastResort: boolean;
29
+ reasoningEfforts?: OcxComboDefaultEffort[];
30
+ }
31
+
23
32
  export interface NormalizedComboConfig {
24
33
  strategy: OcxComboStrategy;
25
34
  stickyLimit: number;
@@ -40,7 +49,7 @@ export interface NormalizedComboConfig {
40
49
  nativeAlias: boolean;
41
50
  /** Display-only label for the catalog row, or null when unset. */
42
51
  displayName: string | null;
43
- targets: Array<Required<OcxComboTarget>>;
52
+ targets: NormalizedComboTarget[];
44
53
  }
45
54
 
46
55
  /**
@@ -142,8 +151,9 @@ export function comboConfigIssues(
142
151
  && body.strategy !== "round-robin"
143
152
  && body.strategy !== "random"
144
153
  && body.strategy !== "least-used"
145
- && body.strategy !== "reset-window") {
146
- issues.push({ path: ["strategy"], message: 'strategy must be "failover", "round-robin", "random", "least-used", or "reset-window"' });
154
+ && body.strategy !== "reset-window"
155
+ && body.strategy !== "jev") {
156
+ issues.push({ path: ["strategy"], message: 'strategy must be "failover", "round-robin", "random", "least-used", "reset-window", or "jev"' });
147
157
  }
148
158
  if (body.stickyLimit !== undefined
149
159
  && (typeof body.stickyLimit !== "number" || !Number.isInteger(body.stickyLimit)
@@ -269,6 +279,11 @@ export function comboConfigIssues(
269
279
  path: ["targets", i, "provider"],
270
280
  message: `targets[${i}].provider "${provider}" is not configured`,
271
281
  });
282
+ } else if (providers[provider]?.adapter === "jev-decision") {
283
+ issues.push({
284
+ path: ["targets", i, "provider"],
285
+ message: `targets[${i}].provider "${provider}" is a decision service and cannot be a model target`,
286
+ });
272
287
  } else {
273
288
  configuredProviderCount += 1;
274
289
  if (providers[provider]?.disabled !== true) enabledProviderCount += 1;
@@ -286,6 +301,32 @@ export function comboConfigIssues(
286
301
  message: `targets[${i}].weight must be an integer from 1 to 10000`,
287
302
  });
288
303
  }
304
+ if (target.reasoningEfforts !== undefined) {
305
+ if (!Array.isArray(target.reasoningEfforts) || target.reasoningEfforts.length === 0) {
306
+ issues.push({
307
+ path: ["targets", i, "reasoningEfforts"],
308
+ message: `targets[${i}].reasoningEfforts must be a non-empty array`,
309
+ });
310
+ } else {
311
+ const seenEfforts = new Set<OcxComboDefaultEffort>();
312
+ for (let effortIndex = 0; effortIndex < target.reasoningEfforts.length; effortIndex++) {
313
+ const effort = target.reasoningEfforts[effortIndex];
314
+ if (typeof effort !== "string" || !isCodexReasoningEffort(effort)) {
315
+ issues.push({
316
+ path: ["targets", i, "reasoningEfforts", effortIndex],
317
+ message: `targets[${i}].reasoningEfforts[${effortIndex}] must be one of: low, medium, high, xhigh, max, ultra`,
318
+ });
319
+ } else if (seenEfforts.has(effort as OcxComboDefaultEffort)) {
320
+ issues.push({
321
+ path: ["targets", i, "reasoningEfforts", effortIndex],
322
+ message: `targets[${i}].reasoningEfforts must not contain duplicates`,
323
+ });
324
+ } else {
325
+ seenEfforts.add(effort as OcxComboDefaultEffort);
326
+ }
327
+ }
328
+ }
329
+ }
289
330
  if (target.lastResort !== undefined && typeof target.lastResort !== "boolean") {
290
331
  issues.push({
291
332
  path: ["targets", i, "lastResort"],
@@ -345,6 +386,9 @@ export function normalizeComboConfig(raw: OcxComboConfig): NormalizedComboConfig
345
386
  provider: target.provider.trim(),
346
387
  model: target.model.trim(),
347
388
  weight: target.weight ?? 1,
389
+ ...(target.reasoningEfforts !== undefined
390
+ ? { reasoningEfforts: [...target.reasoningEfforts] }
391
+ : {}),
348
392
  lastResort: target.lastResort === true,
349
393
  })),
350
394
  };
@@ -73,6 +73,8 @@ export interface AtomicWriteHooks {
73
73
  afterTempWrite?: (tempPath: string, targetPath: string) => void;
74
74
  beforeRename?: (tempPath: string, targetPath: string) => void;
75
75
  validateBeforeRename?: (targetPath: string) => void;
76
+ /** Publication receipt; runs before any post-rename cleanup can fail. */
77
+ afterRename?: (targetPath: string) => void;
76
78
  }
77
79
 
78
80
  export class AtomicWriteResidualTempError extends Error {
@@ -314,6 +316,7 @@ function atomicWriteFileToTarget(
314
316
  hooks.beforeRename?.(tmp, target);
315
317
  hooks.validateBeforeRename?.(target);
316
318
  effective.rename(tmp, target);
319
+ hooks.afterRename?.(target);
317
320
  // The rename is only as durable as the directory entry recording it. Fsyncing the temp's
318
321
  // CONTENT and then losing the entry in a power cut leaves the old file in place, or the
319
322
  // directory in an indeterminate state, while the caller was told the replacement landed.
@@ -14,7 +14,7 @@ import {
14
14
  projectConfigRebaseProvenance,
15
15
  } from "./rebase-provenance";
16
16
  import { withConfigMutationLockSync, bumpGenerationForCooperatingConfigWrite } from "./mutation-lock";
17
- import { persistConfigUnlocked, readRawConfigJson } from "./persist-unlocked";
17
+ import { ConfigWritePublishedError, persistConfigUnlocked, readRawConfigJson } from "./persist-unlocked";
18
18
  import { configDiagnosticsFromRaw, readConfigDiagnostics } from "./diagnostics";
19
19
  import { normalizePersistedClaudeCode } from "./load-degrade";
20
20
 
@@ -425,7 +425,13 @@ function readPersistedServerBinding(
425
425
  export function saveConfigPreservingClaudeCode(config: OcxConfig): void {
426
426
  const pinError = configReasoningPinsConfigError(config);
427
427
  if (pinError) throw new Error(pinError);
428
- withConfigMutationLockSync(() => {
428
+ let published = false;
429
+ const persist = (candidate: OcxConfig): void => {
430
+ const changed = persistConfigUnlocked(candidate);
431
+ published = true;
432
+ if (changed) bumpGenerationForCooperatingConfigWrite();
433
+ };
434
+ const save = () => withConfigMutationLockSync(() => {
429
435
  const childDeletions = prepareConfigObjectChildDeletionRebase(config);
430
436
  const bindingBaseline = persistedLiveServerBinding.get(config);
431
437
  // One authoritative pre-write read feeds both the live-config reconciliation and
@@ -510,10 +516,10 @@ export function saveConfigPreservingClaudeCode(config: OcxConfig): void {
510
516
  const persistedConfig: OcxConfig = { ...projectedConfig, port: persistedBinding.port };
511
517
  if (persistedBinding.hostname === undefined) delete persistedConfig.hostname;
512
518
  else persistedConfig.hostname = persistedBinding.hostname;
513
- if (persistConfigUnlocked(persistedConfig)) bumpGenerationForCooperatingConfigWrite();
519
+ persist(persistedConfig);
514
520
  persistedLiveServerBinding.set(config, persistedBinding);
515
521
  } else {
516
- if (persistConfigUnlocked(projectedConfig)) bumpGenerationForCooperatingConfigWrite();
522
+ persist(projectedConfig);
517
523
  }
518
524
  adoptCustomModelCatalogMigration(config, projectedConfig);
519
525
  if (claudeCodeBaseline.has(config)) {
@@ -527,4 +533,14 @@ export function saveConfigPreservingClaudeCode(config: OcxConfig): void {
527
533
  clearPendingConfigObjectChildDeletions(config);
528
534
  clearPendingConfigTopLevelDeletions(config);
529
535
  });
536
+ try {
537
+ save();
538
+ } catch (error) {
539
+ // Generation/baseline updates and the lock's COMMIT run after publication.
540
+ // They may fail, but the file replacement cannot be undone by a live rollback.
541
+ if (published && !(error instanceof ConfigWritePublishedError)) {
542
+ throw new ConfigWritePublishedError(error);
543
+ }
544
+ throw error;
545
+ }
530
546
  }
@@ -8,6 +8,14 @@ import { getConfigPath } from "./paths";
8
8
  import { configRebaseDeletionKeys, projectConfigRebaseProvenance } from "./rebase-provenance";
9
9
  import { clientConnectionSchema } from "./schema/leaf-validators";
10
10
 
11
+ /** The requested bytes are current; callers must not roll back only live state. */
12
+ export class ConfigWritePublishedError extends Error {
13
+ constructor(cause: unknown) {
14
+ super("Config was published, but post-publication bookkeeping failed", { cause });
15
+ this.name = "ConfigWritePublishedError";
16
+ }
17
+ }
18
+
11
19
  /** The literal file, with no schema merge or default injection. */
12
20
  export function readRawConfigJson(): Record<string, unknown> | undefined {
13
21
  try {
@@ -81,13 +89,18 @@ export function persistConfigUnlocked(config: OcxConfig): boolean {
81
89
  // including byte-identical saves: a cooperating CLI process may have written
82
90
  // the same bytes (e.g. before a proxy notification), and Logs/Usage must
83
91
  // adopt the overlay without waiting for a changed save or restart.
84
- if (unchanged) {
92
+ let published = unchanged;
93
+ try {
94
+ if (!unchanged) {
95
+ atomicWriteFile(configPath, bytes, undefined, { afterRename: () => { published = true; } });
96
+ published = true;
97
+ }
98
+ // Publication, not successful cache refresh, is the rollback boundary. A
99
+ // byte-identical save already has the requested state on disk as well.
85
100
  refreshConfigDerivedRegistries(persisted);
86
- return false;
101
+ return !unchanged;
102
+ } catch (error) {
103
+ if (published) throw new ConfigWritePublishedError(error);
104
+ throw error;
87
105
  }
88
- atomicWriteFile(configPath, bytes);
89
- // For changed saves, refresh only AFTER the write succeeded so a failed
90
- // write cannot leave estimates reflecting configuration never persisted.
91
- refreshConfigDerivedRegistries(persisted);
92
- return true;
93
106
  }
@@ -79,6 +79,22 @@ export const configSchema = z.object({
79
79
  privacy: z.object({ maskEmails: z.boolean().optional() }).strict().optional().catch(undefined),
80
80
  // Malformed hand edits disable this opt-in exporter. Live writes reject them in diagnostics.ts.
81
81
  metricsExport: z.object({ enabled: z.boolean().optional() }).strict().optional().catch(undefined),
82
+ // Kept raw on purpose: `.catch(undefined)` would turn a mistyped `enabled` into "inherit",
83
+ // which can reopen a surface the operator meant to close. src/protocols/settings.ts parses it
84
+ // and fails closed instead.
85
+ apiSurfaces: z.unknown().optional(),
86
+ // Every protocol default is the conservative one (legacy policy, rollout off), so a malformed
87
+ // block dropping to undefined cannot widen behavior.
88
+ protocols: z.object({
89
+ unrepresentable: z.enum(["legacy", "reject"]).optional(),
90
+ rollout: z.object({
91
+ nativeChatCombos: z.boolean().optional(),
92
+ managedMessagesNative: z.boolean().optional(),
93
+ managedMessagesNativeOAuth: z.boolean().optional(),
94
+ directEncoders: z.boolean().optional(),
95
+ shadowPlan: z.boolean().optional(),
96
+ }).strict().optional(),
97
+ }).strict().optional().catch(undefined),
82
98
  // A malformed present client block must remain diagnosable from raw config and
83
99
  // fail closed through src/client/state.ts; unrelated provider state still loads.
84
100
  client: clientConnectionSchema.optional().catch(undefined),
@@ -779,6 +779,7 @@ export const agentTaskRecoverySchema = z.object({
779
779
  model: z.string().trim().min(1).optional(),
780
780
  timeoutMs: z.number().int().min(1_000).max(120_000).optional(),
781
781
  cacheEntries: z.number().int().min(1).max(512).optional(),
782
+ retries: z.number().int().min(0).max(2).optional(),
782
783
  }).strict();
783
784
 
784
785
  export const runtimeRoleSchema = z.enum(["standalone", "hub", "client"]);
@@ -852,6 +853,13 @@ export const remoteGuiConfigSchema = z.object({
852
853
 
853
854
  const connectedClientIdSchema = z.enum(["codex", "claude"]);
854
855
  const clientTimestampSchema = z.string().datetime({ offset: true });
856
+ const clientTransportSchema = z.enum(["hub", "link"]);
857
+ const linkTransportSchema = z.object({
858
+ // Same range as isLinkPort in src/link/ports.ts, restated here because the config schema sits on
859
+ // every install's core path and must not import link code (tests/lab/core-link-boundary.test.ts).
860
+ tunnelPort: z.number().int().min(1024).max(65535),
861
+ linkId: z.string().regex(/^lnk_[0-9a-f]{16}$/),
862
+ }).strict();
855
863
  const clientOriginSchema = z.string().transform((value, ctx) => {
856
864
  const origin = canonicalHttpOrigin(value);
857
865
  if (!origin) {
@@ -864,6 +872,8 @@ export const clientConnectionSchema = z.object({
864
872
  serverUrl: clientOriginSchema,
865
873
  managementUrl: clientOriginSchema,
866
874
  managementTransport: z.enum(["direct", "relay"]),
875
+ transport: clientTransportSchema.optional(),
876
+ link: linkTransportSchema.optional(),
867
877
  selectedClients: z.array(connectedClientIdSchema).min(1).max(2).superRefine((clients, ctx) => {
868
878
  if (new Set(clients).size !== clients.length) {
869
879
  ctx.addIssue({ code: "custom", message: "must contain unique client ids" });
@@ -890,7 +900,34 @@ export const clientConnectionSchema = z.object({
890
900
  ctx.addIssue({ code: "custom", path: ["oldKeyBackupPath"], message: `must equal ${expected}` });
891
901
  }
892
902
  }).optional(),
893
- }).strict();
903
+ }).strict().superRefine((connection, ctx) => {
904
+ const transport = connection.transport ?? "hub";
905
+ if (transport === "hub" && connection.link !== undefined) {
906
+ ctx.addIssue({ code: "custom", path: ["link"], message: "link is allowed only when transport is link" });
907
+ return;
908
+ }
909
+ if (transport !== "link") return;
910
+ if (!connection.link) {
911
+ ctx.addIssue({ code: "custom", path: ["link"], message: "link is required when transport is link" });
912
+ return;
913
+ }
914
+ if (connection.managementTransport !== "direct") {
915
+ ctx.addIssue({ code: "custom", path: ["managementTransport"], message: "link transport requires direct management transport" });
916
+ }
917
+ if (connection.serverUrl !== connection.managementUrl) {
918
+ ctx.addIssue({ code: "custom", path: ["managementUrl"], message: "link transport requires serverUrl and managementUrl to match" });
919
+ }
920
+ let origin: URL;
921
+ try {
922
+ origin = new URL(connection.serverUrl);
923
+ } catch {
924
+ return;
925
+ }
926
+ if (origin.protocol !== "http:" || origin.hostname !== "127.0.0.1"
927
+ || origin.port !== String(connection.link.tunnelPort)) {
928
+ ctx.addIssue({ code: "custom", path: ["serverUrl"], message: "link transport requires http://127.0.0.1:<tunnelPort>" });
929
+ }
930
+ });
894
931
 
895
932
  /**
896
933
  * Codex pool selection policy section.