@bitkyc08/opencodex 2.57.0 → 2.59.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (241) hide show
  1. package/README.md +28 -10
  2. package/gui/dist/assets/index-C5IebErG.js +136 -0
  3. package/gui/dist/assets/{index-C5-RdDmD.css → index-OESInAjC.css} +1 -1
  4. package/gui/dist/index.html +2 -2
  5. package/gui/dist/provider-icons/crusoe.svg +1 -0
  6. package/gui/dist/provider-icons/opper.svg +3 -0
  7. package/package.json +2 -2
  8. package/src/adapters/base.ts +11 -1
  9. package/src/adapters/codebuddy/scaffold-guard.ts +5 -4
  10. package/src/adapters/command-code.ts +13 -4
  11. package/src/adapters/cursor/catalog.ts +11 -0
  12. package/src/adapters/cursor/cursor-errors.ts +15 -0
  13. package/src/adapters/cursor/discovery.ts +65 -1
  14. package/src/adapters/cursor/effort-map.ts +16 -2
  15. package/src/adapters/cursor/envelope-echo.ts +55 -2
  16. package/src/adapters/cursor/live-transport.ts +5 -1
  17. package/src/adapters/cursor/message-mapper.ts +3 -2
  18. package/src/adapters/cursor/protobuf-events.ts +110 -11
  19. package/src/adapters/cursor/protobuf-request.ts +27 -6
  20. package/src/adapters/cursor/request-builder.ts +14 -3
  21. package/src/adapters/cursor/text-toolcall.ts +230 -0
  22. package/src/adapters/cursor/thread-continuity.ts +141 -0
  23. package/src/adapters/cursor/tool-guidance.ts +5 -4
  24. package/src/adapters/cursor/types.ts +5 -0
  25. package/src/adapters/cursor.ts +97 -6
  26. package/src/adapters/devin/cloud-direct/chat.ts +11 -2
  27. package/src/adapters/devin/cloud-direct/index.ts +7 -0
  28. package/src/adapters/devin/cloud-direct/stated-reset-retry.ts +103 -0
  29. package/src/adapters/devin.ts +75 -13
  30. package/src/adapters/google-antigravity-wire.ts +29 -2
  31. package/src/adapters/google-http.ts +45 -13
  32. package/src/adapters/google.ts +23 -4
  33. package/src/adapters/mimo-free.ts +32 -17
  34. package/src/adapters/ollama-native.ts +42 -8
  35. package/src/adapters/openai-chat/response-events.ts +61 -0
  36. package/src/adapters/openai-chat.ts +5 -10
  37. package/src/adapters/openai-responses/passthrough.ts +40 -5
  38. package/src/adapters/openai-responses/request-strips.ts +43 -0
  39. package/src/adapters/openai-responses/tool-output-recovery.ts +75 -0
  40. package/src/adapters/openai-responses/tool-schema.ts +19 -7
  41. package/src/adapters/physical-send.ts +50 -0
  42. package/src/adapters/responses-tool-schema.ts +76 -46
  43. package/src/adapters/run-turn-queue.ts +17 -4
  44. package/src/bridge/response-json.ts +2 -2
  45. package/src/bridge/sse.ts +166 -25
  46. package/src/claude/context-windows.ts +22 -0
  47. package/src/claude/outbound.ts +46 -5
  48. package/src/cli/account-api.ts +4 -3
  49. package/src/cli/account-extended.ts +22 -2
  50. package/src/cli/account-orca-import.ts +63 -0
  51. package/src/cli/account.ts +32 -4
  52. package/src/cli/capabilities.ts +40 -0
  53. package/src/cli/claude.ts +29 -1
  54. package/src/cli/codex-cli-update.ts +97 -2
  55. package/src/cli/config-command.ts +35 -18
  56. package/src/cli/dispatch.ts +71 -4
  57. package/src/cli/doctor.ts +197 -2
  58. package/src/cli/help.ts +4 -1
  59. package/src/cli/index.ts +132 -22
  60. package/src/cli/models-runtime.ts +33 -4
  61. package/src/cli/registry.ts +11 -1
  62. package/src/cli/runtime-api.ts +44 -0
  63. package/src/cli/start-args.ts +94 -0
  64. package/src/cli/system-command.ts +72 -1
  65. package/src/cli/uninstall-client-state.ts +12 -0
  66. package/src/client/machine-api.ts +4 -3
  67. package/src/client/machine-listener.ts +14 -1
  68. package/src/clients/config-export/constants.ts +2 -3
  69. package/src/clients/config-export.ts +5 -5
  70. package/src/codex/account-store.ts +81 -5
  71. package/src/codex/auth-api/pool-quota-probe.ts +14 -3
  72. package/src/codex/auth-api/routes.ts +17 -2
  73. package/src/codex/auth-context.ts +58 -20
  74. package/src/codex/catalog/build-entries.ts +25 -4
  75. package/src/codex/catalog/derive-entry.ts +8 -1
  76. package/src/codex/catalog/effort.ts +10 -6
  77. package/src/codex/catalog/gather-capture.ts +1 -0
  78. package/src/codex/catalog/model-hints.ts +37 -5
  79. package/src/codex/catalog/parsing.ts +83 -5
  80. package/src/codex/catalog/reserve-warn.ts +96 -0
  81. package/src/codex/catalog/retained-sync.ts +19 -0
  82. package/src/codex/catalog/routed-gather.ts +42 -3
  83. package/src/codex/cli-installation-identity.ts +210 -0
  84. package/src/codex/cli-installation-targets.ts +158 -0
  85. package/src/codex/convergence.ts +5 -0
  86. package/src/codex/desktop-switches.ts +145 -0
  87. package/src/codex/history-job.ts +5 -1
  88. package/src/codex/history-provider.ts +37 -5
  89. package/src/codex/history-state-open.ts +105 -0
  90. package/src/codex/history-worker.ts +14 -1
  91. package/src/codex/inject/config-toml.ts +44 -2
  92. package/src/codex/inject/remove.ts +145 -7
  93. package/src/codex/inject/restore.ts +204 -32
  94. package/src/codex/inject.ts +6 -9
  95. package/src/codex/lineage.ts +83 -32
  96. package/src/codex/loopback-target.ts +40 -0
  97. package/src/codex/main-account-hard-lock.ts +2 -1
  98. package/src/codex/main-account.ts +10 -3
  99. package/src/codex/main-device-reauth.ts +17 -9
  100. package/src/codex/model-entitlements.ts +60 -1
  101. package/src/codex/native-profile-startup.ts +64 -20
  102. package/src/codex/observed-model-denials.ts +137 -0
  103. package/src/codex/orca-auth-source.ts +94 -0
  104. package/src/codex/orca-import.ts +219 -0
  105. package/src/codex/prompt-text-probe.ts +282 -12
  106. package/src/codex/quota-401-recovery.ts +12 -0
  107. package/src/codex/quota-types.ts +65 -0
  108. package/src/codex/quota.ts +24 -19
  109. package/src/codex/routing/cooldown-math.ts +8 -47
  110. package/src/codex/routing/pin-drain.ts +57 -0
  111. package/src/codex/routing.ts +13 -15
  112. package/src/codex/subagent-model-fallback.ts +94 -0
  113. package/src/codex/windows-installation-files.ts +224 -0
  114. package/src/combos/failover.ts +122 -5
  115. package/src/config/atomic-write.ts +83 -8
  116. package/src/config/diagnostics.ts +21 -0
  117. package/src/config/load-degrade.ts +15 -0
  118. package/src/config/pending-teardown.ts +8 -0
  119. package/src/config/process-state.ts +36 -3
  120. package/src/config/provider-relative-send-path.ts +16 -0
  121. package/src/config/proxy-env.ts +23 -5
  122. package/src/config/schema/config-schema.ts +23 -0
  123. package/src/config/schema/leaf-validators.ts +65 -17
  124. package/src/generated/compatibility-version.json +337 -201
  125. package/src/generated/model-metadata.ts +1 -1
  126. package/src/lib/bounded-body.ts +4 -2
  127. package/src/lib/bounded-subprocess.ts +62 -10
  128. package/src/lib/destination-policy.ts +48 -6
  129. package/src/lib/errors.ts +3 -15
  130. package/src/lib/local-destinations.ts +32 -5
  131. package/src/lib/provider-outbound.ts +3 -3
  132. package/src/lib/proxy-env.ts +70 -3
  133. package/src/lib/request-execution-budget.ts +11 -3
  134. package/src/lib/response-body-inactivity.ts +193 -0
  135. package/src/lib/retry-delay.ts +69 -0
  136. package/src/lib/socks5-fetch.ts +631 -0
  137. package/src/lib/spend-reservation-ledger.ts +115 -9
  138. package/src/lib/windows-secret-acl.ts +151 -15
  139. package/src/lib/windows-user-principal.ts +5 -1
  140. package/src/lib/workflow-budget.ts +145 -8
  141. package/src/oauth/account-quota-rank.ts +72 -15
  142. package/src/oauth/generic-account-failover.ts +40 -27
  143. package/src/oauth/orcarouter.ts +15 -2
  144. package/src/oauth/store.ts +8 -0
  145. package/src/providers/codex-capacity.ts +9 -0
  146. package/src/providers/derive.ts +6 -0
  147. package/src/providers/devin-provider-merge-migration.ts +33 -12
  148. package/src/providers/free-directory.ts +20 -2
  149. package/src/providers/key-failover.ts +261 -7
  150. package/src/providers/model-discovery.ts +19 -7
  151. package/src/providers/model-rename-migration.ts +1 -0
  152. package/src/providers/openai-sidecar.ts +4 -0
  153. package/src/providers/opencode-go-transport.ts +14 -5
  154. package/src/providers/quota/report-cache.ts +3 -0
  155. package/src/providers/registry/entries-core.ts +11 -0
  156. package/src/providers/registry/entries-extended.ts +146 -28
  157. package/src/providers/registry/model-seeds.ts +136 -29
  158. package/src/providers/registry/types.ts +9 -0
  159. package/src/responses/apply-patch-envelope.ts +44 -11
  160. package/src/responses/bridge-search-replay-cache.ts +152 -0
  161. package/src/responses/code-mode-helper-compat.ts +26 -16
  162. package/src/responses/custom-tool-compat.ts +1 -1
  163. package/src/responses/hosted-tool-policy.ts +85 -2
  164. package/src/responses/schema.ts +9 -2
  165. package/src/responses/spill-store.ts +17 -0
  166. package/src/responses/state/body-policy.ts +25 -0
  167. package/src/responses/state/spill-queue.ts +8 -6
  168. package/src/responses/state.ts +3 -22
  169. package/src/router.ts +4 -0
  170. package/src/server/auth-cors.ts +27 -0
  171. package/src/server/chat-completions.ts +9 -4
  172. package/src/server/chat-native-sse.ts +26 -9
  173. package/src/server/chat-native.ts +10 -4
  174. package/src/server/claude-messages.ts +24 -2
  175. package/src/server/gui-static.ts +36 -2
  176. package/src/server/inbound-body-admission.ts +187 -0
  177. package/src/server/index/websocket-handler.ts +48 -1
  178. package/src/server/index.ts +15 -19
  179. package/src/server/management/api-access.ts +3 -4
  180. package/src/server/management/config-routes.ts +57 -10
  181. package/src/server/management/provider-capability-config.ts +35 -7
  182. package/src/server/management/provider-routes.ts +70 -18
  183. package/src/server/models-capabilities.ts +24 -3
  184. package/src/server/proxy-liveness.ts +97 -2
  185. package/src/server/relay.ts +17 -24
  186. package/src/server/request-log.ts +25 -1
  187. package/src/server/responses/adapter-continuation.ts +71 -27
  188. package/src/server/responses/adapter-delivery.ts +39 -8
  189. package/src/server/responses/adapter-dispatch.ts +52 -24
  190. package/src/server/responses/codex-ws-exchange.ts +65 -4
  191. package/src/server/responses/combo-stream-preflight.ts +68 -5
  192. package/src/server/responses/compact.ts +60 -11
  193. package/src/server/responses/core-codex-account.ts +83 -22
  194. package/src/server/responses/core-combo.ts +26 -0
  195. package/src/server/responses/core-normalize.ts +12 -5
  196. package/src/server/responses/core-options.ts +3 -0
  197. package/src/server/responses/fetch-helpers.ts +72 -3
  198. package/src/server/responses/native-injection-protocol.ts +42 -0
  199. package/src/server/responses/native-injection-replay.ts +105 -0
  200. package/src/server/responses/native-injection.ts +242 -0
  201. package/src/server/responses/native-response-control.ts +56 -0
  202. package/src/server/responses/native-response-json.ts +14 -0
  203. package/src/server/responses/native-response-output.ts +37 -0
  204. package/src/server/responses/native-steering-log.ts +44 -0
  205. package/src/server/responses/native-steering-policy.ts +49 -0
  206. package/src/server/responses/native-steering-replay.ts +126 -0
  207. package/src/server/responses/native-steering-settings.ts +76 -0
  208. package/src/server/responses/native-steering.ts +400 -0
  209. package/src/server/responses/native-tool-results.ts +130 -0
  210. package/src/server/responses/passthrough-delivery.ts +21 -1
  211. package/src/server/responses/passthrough-dispatch.ts +146 -49
  212. package/src/server/responses/passthrough-execution.ts +11 -1
  213. package/src/server/responses/request-prepare.ts +70 -0
  214. package/src/server/responses/request-send-budget.ts +84 -7
  215. package/src/server/responses/request-sidecar-auth.ts +16 -8
  216. package/src/server/responses/request-spend.ts +38 -9
  217. package/src/server/responses/request-transport.ts +13 -10
  218. package/src/server/responses/run-turn-execution.ts +20 -5
  219. package/src/server/responses/sidecar-execution.ts +2 -0
  220. package/src/server/responses/ws-upstream.ts +23 -2
  221. package/src/server/responses-custom-tool-repair.ts +2 -2
  222. package/src/server/sse-frame-buffer.ts +12 -10
  223. package/src/server/sse-payload-rewrite.ts +36 -9
  224. package/src/server/stop-teardown.ts +8 -1
  225. package/src/server/system-env-shell.ts +5 -1
  226. package/src/server/system-env.ts +7 -1
  227. package/src/server/workflow-refusal.ts +56 -2
  228. package/src/server/ws-bridge.ts +16 -1
  229. package/src/service/cli.ts +29 -7
  230. package/src/service/guards.ts +10 -0
  231. package/src/service/health.ts +43 -0
  232. package/src/service/state.ts +7 -2
  233. package/src/types/accounts.ts +4 -0
  234. package/src/types/config.ts +104 -3
  235. package/src/types/provider.ts +32 -0
  236. package/src/types/request.ts +7 -1
  237. package/src/types/wire.ts +9 -1
  238. package/src/usage/expected-prices.ts +28 -0
  239. package/src/usage/log.ts +87 -4
  240. package/src/web-search/passthrough-bridge.ts +39 -5
  241. package/gui/dist/assets/index-Cz7CLdif.js +0 -128
@@ -3,6 +3,11 @@ import { randomUUID } from "node:crypto";
3
3
  import { readFileSync } from "node:fs";
4
4
  import type { CatalogModel } from "../../codex/catalog";
5
5
  import { catalogModelSlug, invalidateCodexModelsCache, nativeContextLimits, nativeModelRows, uniqueCatalogModelsForPublicList } from "../../codex/catalog";
6
+ import {
7
+ applyCodexDesktopSwitches,
8
+ describeCodexDesktopSwitches,
9
+ type CodexDesktopSwitchApply,
10
+ } from "../../codex/desktop-switches";
6
11
  import {
7
12
  DEFAULT_SUBAGENT_MODELS,
8
13
  codexAutoStartEnabled,
@@ -105,7 +110,7 @@ import { filterRequestLogs, getRequestLogEntries, type RequestLogEntry } from ".
105
110
  import { estimateComboCost, estimateRequestCost, normalizeCostTokens, tokensPerSecond } from "../../usage/cost";
106
111
  import type { PersistedUsageAttempt } from "../../usage/log";
107
112
  import { isAllowedRequestOrigin, jsonResponse, providerManagementConfigError, publicProviderBaseUrl, safeConfigDTO } from "../auth-cors";
108
- import { withProviderServiceTierDTO } from "./provider-capability-config";
113
+ import { withProviderCatalogCapabilityDTO } from "./provider-capability-config";
109
114
  import { applySystemEnvToggle } from "../system-env";
110
115
  import { getCachedStartupHealth, invalidateStartupHealthCache } from "../startup-health-cache";
111
116
  import { runWindowsTrayAction } from "../windows-tray-control";
@@ -181,7 +186,8 @@ interface ClientIntegrationSyncOutcome {
181
186
  export async function syncEnabledClientIntegrations(
182
187
  port: number | undefined,
183
188
  config: OcxConfig,
184
- deps: Pick<ManagementContext["deps"], "fetchAllModels" | "writeDesktop3pConfig"> = {},
189
+ deps: Pick<ManagementContext["deps"],
190
+ "fetchAllModels" | "refreshOwnedCatalogIntegrations" | "writeDesktop3pConfig"> = {},
185
191
  ): Promise<ClientIntegrationSyncOutcome[]> {
186
192
  if (port === undefined) return [];
187
193
  const { claudeDesktopIntegrationEnabled, grokIntegrationEnabled } = await import("../../codex/desired-state");
@@ -230,7 +236,8 @@ export async function syncEnabledClientIntegrations(
230
236
  }
231
237
 
232
238
  const { refreshOwnedCatalogIntegrations } = await import("../../integrations/catalog-refresh");
233
- out.push(...await refreshOwnedCatalogIntegrations({
239
+ const refreshOwned = deps.refreshOwnedCatalogIntegrations ?? refreshOwnedCatalogIntegrations;
240
+ out.push(...await refreshOwned({
234
241
  models: async () => {
235
242
  const { loadExportModels } = await import("./model-rows");
236
243
  return loadExportModels(config);
@@ -261,7 +268,7 @@ export async function handleConfigRoutes(ctx: ManagementContext): Promise<Respon
261
268
  const { req, url, config, deps, convergeCodexCatalog, syncClaudeAgentDefsBestEffort } = ctx;
262
269
  const readStartupHealth = deps.getCachedStartupHealth ?? getCachedStartupHealth;
263
270
  if (url.pathname === "/api/config" && req.method === "GET") {
264
- return jsonResponse(withProviderServiceTierDTO(safeConfigDTO(config), config));
271
+ return jsonResponse(withProviderCatalogCapabilityDTO(safeConfigDTO(config), config));
265
272
  }
266
273
 
267
274
  if (url.pathname === "/api/config" && req.method === "PUT") {
@@ -319,6 +326,8 @@ export async function handleConfigRoutes(ctx: ManagementContext): Promise<Respon
319
326
  // Absent means off, same convention: the GUI renders a plain switch without
320
327
  // needing to know that `undefined` and `false` mean the same thing here.
321
328
  ultraFastTier: config.ultraFastTier === true,
329
+ // Absent means on by default: the GUI renders a switch enabled unless explicit false.
330
+ fastRows: config.fastRows !== false,
322
331
  codexMainAccountHardLock: config.codexMainAccountHardLock === true,
323
332
  mainAccountHardLock: getMainAccountHardLockStatus(config),
324
333
  // Absent means the historical auto-open, so the GUI can render the toggle
@@ -328,6 +337,11 @@ export async function handleConfigRoutes(ctx: ManagementContext): Promise<Respon
328
337
  codexDesktopAuthless: config.codexDesktopAuthless === true,
329
338
  // Absent keeps Design B remote compaction; true selects the dedicated provider identity.
330
339
  codexClientCompaction: config.codexClientCompaction === true,
340
+ codexDesktopSwitches: describeCodexDesktopSwitches(config, {
341
+ applied: false,
342
+ reason: "not_requested",
343
+ retryable: false,
344
+ }),
331
345
  startupHealth: await readStartupHealth(config),
332
346
  codexRuntime: {
333
347
  path: displayCodexRuntimePath(resolved.runtime.command),
@@ -415,6 +429,7 @@ export async function handleConfigRoutes(ctx: ManagementContext): Promise<Respon
415
429
  codexQuotaAutoRefresh?: unknown;
416
430
  oauthOpenBrowser?: unknown;
417
431
  ultraFastTier?: unknown;
432
+ fastRows?: unknown;
418
433
  codexMainAccountHardLock?: unknown;
419
434
  codexDesktopAuthless?: unknown;
420
435
  codexClientCompaction?: unknown;
@@ -426,10 +441,11 @@ export async function handleConfigRoutes(ctx: ManagementContext): Promise<Respon
426
441
  && body.codexQuotaAutoRefresh === undefined
427
442
  && body.oauthOpenBrowser === undefined
428
443
  && body.ultraFastTier === undefined
444
+ && body.fastRows === undefined
429
445
  && body.codexMainAccountHardLock === undefined
430
446
  && body.codexDesktopAuthless === undefined
431
447
  && body.codexClientCompaction === undefined) {
432
- return jsonResponse({ error: "provide codexAutoStart, streamMode, appOwnedMemoryBudgetMb, codexAccountPickerEnabled, codexQuotaAutoRefresh, oauthOpenBrowser, ultraFastTier, codexMainAccountHardLock, codexDesktopAuthless, or codexClientCompaction" }, 400);
448
+ return jsonResponse({ error: "provide codexAutoStart, streamMode, appOwnedMemoryBudgetMb, codexAccountPickerEnabled, codexQuotaAutoRefresh, oauthOpenBrowser, ultraFastTier, fastRows, codexMainAccountHardLock, codexDesktopAuthless, or codexClientCompaction" }, 400);
433
449
  }
434
450
  if (body.codexAutoStart !== undefined && typeof body.codexAutoStart !== "boolean") {
435
451
  return jsonResponse({ error: "codexAutoStart boolean is required" }, 400);
@@ -447,6 +463,9 @@ export async function handleConfigRoutes(ctx: ManagementContext): Promise<Respon
447
463
  if (body.ultraFastTier !== undefined && typeof body.ultraFastTier !== "boolean") {
448
464
  return jsonResponse({ error: "ultraFastTier boolean is required" }, 400);
449
465
  }
466
+ if (body.fastRows !== undefined && typeof body.fastRows !== "boolean") {
467
+ return jsonResponse({ error: "fastRows boolean is required" }, 400);
468
+ }
450
469
  if (body.codexMainAccountHardLock !== undefined && typeof body.codexMainAccountHardLock !== "boolean") {
451
470
  return jsonResponse({ error: "codexMainAccountHardLock boolean is required" }, 400);
452
471
  }
@@ -502,6 +521,8 @@ export async function handleConfigRoutes(ctx: ManagementContext): Promise<Respon
502
521
  hasOauthOpenBrowser: Object.hasOwn(config, "oauthOpenBrowser"),
503
522
  ultraFastTier: config.ultraFastTier,
504
523
  hasUltraFastTier: Object.hasOwn(config, "ultraFastTier"),
524
+ fastRows: config.fastRows,
525
+ hasFastRows: Object.hasOwn(config, "fastRows"),
505
526
  codexMainAccountHardLock: config.codexMainAccountHardLock,
506
527
  hasCodexMainAccountHardLock: Object.hasOwn(config, "codexMainAccountHardLock"),
507
528
  codexDesktopAuthless: config.codexDesktopAuthless,
@@ -513,6 +534,7 @@ export async function handleConfigRoutes(ctx: ManagementContext): Promise<Respon
513
534
  let pickerIsEnabled = pickerWasEnabled;
514
535
  const authlessWasEnabled = config.codexDesktopAuthless === true;
515
536
  const clientCompactionWasEnabled = config.codexClientCompaction === true;
537
+ const fastRowsWasEnabled = config.fastRows !== false;
516
538
  try {
517
539
  if (typeof body.codexAutoStart === "boolean") {
518
540
  config.codexAutoStart = body.codexAutoStart;
@@ -540,6 +562,8 @@ export async function handleConfigRoutes(ctx: ManagementContext): Promise<Respon
540
562
  // default, and a written `false` would survive as a decision nobody made.
541
563
  if (body.ultraFastTier === true) config.ultraFastTier = true;
542
564
  else if (body.ultraFastTier === false) deleteConfigTopLevelKey(config, "ultraFastTier");
565
+ if (body.fastRows === false) config.fastRows = false;
566
+ else if (body.fastRows === true) deleteConfigTopLevelKey(config, "fastRows");
543
567
  if (body.codexMainAccountHardLock === true) config.codexMainAccountHardLock = true;
544
568
  else if (body.codexMainAccountHardLock === false) deleteConfigTopLevelKey(config, "codexMainAccountHardLock");
545
569
  if (body.codexDesktopAuthless === true) config.codexDesktopAuthless = true;
@@ -582,6 +606,9 @@ export async function handleConfigRoutes(ctx: ManagementContext): Promise<Respon
582
606
  if (previousSettings.hasUltraFastTier) {
583
607
  config.ultraFastTier = previousSettings.ultraFastTier;
584
608
  } else deleteConfigTopLevelKey(config, "ultraFastTier");
609
+ if (previousSettings.hasFastRows) {
610
+ config.fastRows = previousSettings.fastRows;
611
+ } else deleteConfigTopLevelKey(config, "fastRows");
585
612
  if (previousSettings.hasCodexMainAccountHardLock) {
586
613
  config.codexMainAccountHardLock = previousSettings.codexMainAccountHardLock;
587
614
  } else deleteConfigTopLevelKey(config, "codexMainAccountHardLock");
@@ -597,15 +624,33 @@ export async function handleConfigRoutes(ctx: ManagementContext): Promise<Respon
597
624
  configureAppOwnedMemoryBudget(resolveAppOwnedMemoryBudgetBytes(body.appOwnedMemoryBudgetMb));
598
625
  enforceAppOwnedMemoryBudget();
599
626
  }
600
- // Both Desktop compatibility switches change the injected config.toml shape, so converge now
601
- // rather than waiting for the next start; the injector re-reads config and rewrites the form.
602
627
  const authlessIsEnabled = config.codexDesktopAuthless === true;
603
628
  const clientCompactionIsEnabled = config.codexClientCompaction === true;
604
- const catalogRefresh = pickerWasEnabled !== pickerIsEnabled
605
- || authlessWasEnabled !== authlessIsEnabled
606
- || clientCompactionWasEnabled !== clientCompactionIsEnabled
629
+ const fastRowsIsEnabled = config.fastRows !== false;
630
+ const fastRowsChanged = fastRowsWasEnabled !== fastRowsIsEnabled;
631
+ const desktopSwitchesChanged = authlessWasEnabled !== authlessIsEnabled
632
+ || clientCompactionWasEnabled !== clientCompactionIsEnabled;
633
+ // Catalog convergence is not config injection, and the comment that used to sit here said
634
+ // it was. `convergeCodexCatalog` rejects any scope but `catalog` and never reaches the
635
+ // injector, which is why flipping either switch left `config.toml` in its old shape until
636
+ // a separate `ocx sync` (#4809). Both halves are needed when a Desktop switch changes; a
637
+ // picker-only update still refreshes just the catalog.
638
+ const catalogRefresh = pickerWasEnabled !== pickerIsEnabled || desktopSwitchesChanged || fastRowsChanged
607
639
  ? await convergeCodexCatalog()
608
640
  : undefined;
641
+ if (fastRowsChanged) {
642
+ const { readRuntimePort } = await import("../../config/process-state");
643
+ const runtime = (deps.readRuntimePort ?? readRuntimePort)(process.pid);
644
+ await syncEnabledClientIntegrations(runtime?.port, config, deps);
645
+ }
646
+ // Injection second, matching `syncModelsToCodex`: the injected `model_catalog_json` should
647
+ // point at a catalog that has already settled. And it runs here rather than inside the save
648
+ // because coordinated Codex writes acquire the Codex write lock N before the config mutation
649
+ // lock C — awaiting N while still holding C would invert that order.
650
+ const desktopSwitchApply: CodexDesktopSwitchApply = desktopSwitchesChanged
651
+ ? await applyCodexDesktopSwitches(config)
652
+ : { applied: false, reason: "not_requested", retryable: false };
653
+ const codexDesktopSwitches = describeCodexDesktopSwitches(config, desktopSwitchApply);
609
654
  const catalogRefreshPending = catalogRefresh
610
655
  ? catalogRefreshIsPending(catalogRefresh)
611
656
  : false;
@@ -620,8 +665,10 @@ export async function handleConfigRoutes(ctx: ManagementContext): Promise<Respon
620
665
  codexQuotaAutoRefresh: quotaAutoRefreshSettings(config),
621
666
  oauthOpenBrowser: config.oauthOpenBrowser !== false,
622
667
  catalogRefreshPending,
668
+ fastRows: config.fastRows !== false,
623
669
  codexDesktopAuthless: authlessIsEnabled,
624
670
  codexClientCompaction: clientCompactionIsEnabled,
671
+ codexDesktopSwitches,
625
672
  codexMainAccountHardLock: config.codexMainAccountHardLock === true,
626
673
  mainAccountHardLock: getMainAccountHardLockStatus(config),
627
674
  startupHealth: await readStartupHealth(config),
@@ -1,11 +1,7 @@
1
1
  import { booleanRecordConfigError } from "../../config/provider-validation";
2
2
  import type { OcxConfig } from "../../types";
3
3
 
4
- /**
5
- * Provider-management validation that belongs to the provider editor, not the
6
- * request-authentication/CORS boundary. Keeping this here lets the editor
7
- * evolve its capability schema without widening the auth-cors security surface.
8
- */
4
+ /** Provider-management validation shared by provider editor write paths. */
9
5
  export function providerServiceTierConfigError(name: unknown, provider: unknown): string | null {
10
6
  if (typeof name !== "string" || !provider || typeof provider !== "object" || Array.isArray(provider)) {
11
7
  return null;
@@ -17,7 +13,20 @@ export function providerServiceTierConfigError(name: unknown, provider: unknown)
17
13
  return error ? `provider ${name} ${error}` : null;
18
14
  }
19
15
 
20
- function publicServiceTierRecord(value: unknown): Record<string, boolean> | undefined {
16
+ export function providerCatalogCapabilityConfigError(name: unknown, provider: unknown): string | null {
17
+ const serviceTierError = providerServiceTierConfigError(name, provider);
18
+ if (serviceTierError) return serviceTierError;
19
+ if (typeof name !== "string" || !provider || typeof provider !== "object" || Array.isArray(provider)) {
20
+ return null;
21
+ }
22
+ const error = booleanRecordConfigError(
23
+ (provider as { modelSuppressSyntheticMax?: unknown }).modelSuppressSyntheticMax,
24
+ "modelSuppressSyntheticMax",
25
+ );
26
+ return error ? `provider ${name} ${error}` : null;
27
+ }
28
+
29
+ function publicBooleanRecord(value: unknown): Record<string, boolean> | undefined {
21
30
  if (!value || typeof value !== "object" || Array.isArray(value)) return undefined;
22
31
  const entries = Object.entries(value).filter(([model, supported]) =>
23
32
  model.trim().length > 0 && typeof supported === "boolean",
@@ -40,9 +49,28 @@ export function withProviderServiceTierDTO(dto: unknown, config: OcxConfig): unk
40
49
  for (const [name, provider] of Object.entries(config.providers)) {
41
50
  const dtoProvider = providers[name];
42
51
  if (!dtoProvider || typeof dtoProvider !== "object" || Array.isArray(dtoProvider)) continue;
43
- const capabilities = publicServiceTierRecord(provider.modelSupportsServiceTier);
52
+ const capabilities = publicBooleanRecord(provider.modelSupportsServiceTier);
44
53
  if (capabilities === undefined) continue;
45
54
  projectedProviders[name] = { ...(dtoProvider as Record<string, unknown>), modelSupportsServiceTier: capabilities };
46
55
  }
47
56
  return { ...root, providers: projectedProviders };
48
57
  }
58
+
59
+ /** Project all catalog-only model capability maps across the dashboard config boundary. */
60
+ export function withProviderCatalogCapabilityDTO(dto: unknown, config: OcxConfig): unknown {
61
+ const projected = withProviderServiceTierDTO(dto, config);
62
+ if (!projected || typeof projected !== "object" || Array.isArray(projected)) return projected;
63
+ const root = projected as { providers?: unknown };
64
+ if (!root.providers || typeof root.providers !== "object" || Array.isArray(root.providers)) return projected;
65
+
66
+ const providers = root.providers as Record<string, unknown>;
67
+ const projectedProviders: Record<string, unknown> = { ...providers };
68
+ for (const [name, provider] of Object.entries(config.providers)) {
69
+ const dtoProvider = providers[name];
70
+ if (!dtoProvider || typeof dtoProvider !== "object" || Array.isArray(dtoProvider)) continue;
71
+ const capabilities = publicBooleanRecord(provider.modelSuppressSyntheticMax);
72
+ if (capabilities === undefined) continue;
73
+ projectedProviders[name] = { ...(dtoProvider as Record<string, unknown>), modelSuppressSyntheticMax: capabilities };
74
+ }
75
+ return { ...root, providers: projectedProviders };
76
+ }
@@ -1,4 +1,5 @@
1
1
  import { modelCapabilitiesConfigError, mergeModelCapabilities } from "../../config/provider-validation";
2
+ import { DECLARABLE_HOSTED_TOOL_TYPES } from "../../responses/hosted-tool-policy";
2
3
  import { randomUUID } from "node:crypto";
3
4
  import { readFileSync } from "node:fs";
4
5
  import { isDeepStrictEqual } from "node:util";
@@ -103,7 +104,7 @@ import {
103
104
  type ProviderEditorConfigDTO,
104
105
  type ProviderEditorProviderDTO,
105
106
  } from "../auth-cors";
106
- import { providerServiceTierConfigError } from "./provider-capability-config";
107
+ import { providerCatalogCapabilityConfigError } from "./provider-capability-config";
107
108
  import { providerEmptyToolOutputConfigError } from "../../config/provider-validation";
108
109
  import { applySystemEnvToggle } from "../system-env";
109
110
  import {
@@ -264,7 +265,7 @@ function providerEditorCandidate(
264
265
  // the seed check must only pin the canonical transport/auth keys.
265
266
  const providerError = providerManagementConfigError(name, transportCandidate, { allowOperatorOverlays: true })
266
267
  ?? providerEmptyToolOutputConfigError(name, transportCandidate)
267
- ?? providerServiceTierConfigError(name, transportCandidate);
268
+ ?? providerCatalogCapabilityConfigError(name, transportCandidate);
268
269
  if (providerError) return { ok: false, status: 400, error: providerError, code: "invalid_provider" };
269
270
  providers[name] = merged;
270
271
  }
@@ -516,7 +517,14 @@ function applyProviderPatchFields(
516
517
  delete next.modelContextWindows;
517
518
  } else {
518
519
  if (!isPlainRecord(value)) return { error: "modelContextWindows must be a plain object or null" };
519
- const windows: Record<string, number> = { ...(next.modelContextWindows ?? {}) };
520
+ // A prototype-named model id must survive the merge: assigning
521
+ // "__proto__" on an ordinary object invokes the inherited setter instead
522
+ // of creating an own property, so the PATCH would report success while
523
+ // silently dropping that override.
524
+ const windows: Record<string, number> = Object.assign(
525
+ Object.create(null),
526
+ next.modelContextWindows ?? {},
527
+ );
520
528
  for (const [model, window] of Object.entries(value)) {
521
529
  if (!model.trim()) return { error: "modelContextWindows keys must be nonblank model ids" };
522
530
  if (window === null) {
@@ -639,6 +647,29 @@ function applyProviderPatchFields(
639
647
  }
640
648
  touched = true;
641
649
  }
650
+ if (Object.hasOwn(rawBody, "modelSuppressSyntheticMax")) {
651
+ const value = rawBody.modelSuppressSyntheticMax;
652
+ if (value === null) {
653
+ delete next.modelSuppressSyntheticMax;
654
+ } else {
655
+ if (!isPlainRecord(value)) return { error: "modelSuppressSyntheticMax must be a plain object or null" };
656
+ const capabilities: Record<string, boolean> = { ...(next.modelSuppressSyntheticMax ?? {}) };
657
+ for (const [model, suppressed] of Object.entries(value)) {
658
+ if (!model.trim()) return { error: "modelSuppressSyntheticMax keys must be nonblank model ids" };
659
+ if (suppressed === null) {
660
+ delete capabilities[model];
661
+ continue;
662
+ }
663
+ if (typeof suppressed !== "boolean") {
664
+ return { error: "modelSuppressSyntheticMax values must be booleans or null" };
665
+ }
666
+ capabilities[model] = suppressed;
667
+ }
668
+ if (Object.keys(capabilities).length > 0) next.modelSuppressSyntheticMax = capabilities;
669
+ else delete next.modelSuppressSyntheticMax;
670
+ }
671
+ touched = true;
672
+ }
642
673
  if (Object.hasOwn(rawBody, "noStructuredOutputModels")) {
643
674
  const value = rawBody.noStructuredOutputModels;
644
675
  if (value === null) {
@@ -665,6 +696,26 @@ function applyProviderPatchFields(
665
696
  }
666
697
  touched = true;
667
698
  }
699
+ if (Object.hasOwn(rawBody, "unsupportedHostedTools")) {
700
+ const value = rawBody.unsupportedHostedTools;
701
+ if (value === null) {
702
+ delete next.unsupportedHostedTools;
703
+ } else {
704
+ const error = nonBlankStringArrayConfigError(value, "unsupportedHostedTools");
705
+ if (error) return { error };
706
+ const tools = normalizeNonBlankStringArray(value as string[]);
707
+ const unknownTool = tools.find(tool => !DECLARABLE_HOSTED_TOOL_TYPES.has(tool));
708
+ if (unknownTool !== undefined) {
709
+ return {
710
+ error: `unsupportedHostedTools must name only hosted tool types: `
711
+ + `${[...DECLARABLE_HOSTED_TOOL_TYPES].join(", ")}`,
712
+ };
713
+ }
714
+ if (tools.length > 0) next.unsupportedHostedTools = tools;
715
+ else delete next.unsupportedHostedTools;
716
+ }
717
+ touched = true;
718
+ }
668
719
  if (Object.hasOwn(rawBody, "retainModels")) {
669
720
  const value = rawBody.retainModels;
670
721
  if (value === null) {
@@ -864,8 +915,10 @@ export async function handleProviderRoutes(ctx: ManagementContext): Promise<Resp
864
915
  modelPinnedReasoningEfforts: p.modelPinnedReasoningEfforts,
865
916
  modelAutoCompactTokenLimits: p.modelAutoCompactTokenLimits,
866
917
  modelSupportsServiceTier: p.modelSupportsServiceTier,
918
+ modelSuppressSyntheticMax: p.modelSuppressSyntheticMax,
867
919
  noStructuredOutputModels: p.noStructuredOutputModels,
868
920
  noJsonSchemaModels: p.noJsonSchemaModels,
921
+ unsupportedHostedTools: p.unsupportedHostedTools,
869
922
  retainModels: p.retainModels,
870
923
  omitReasoningEffortWithToolsModels: p.omitReasoningEffortWithToolsModels,
871
924
  upstreamHttpVersion: p.upstreamHttpVersion,
@@ -1093,8 +1146,8 @@ export async function handleProviderRoutes(ctx: ManagementContext): Promise<Resp
1093
1146
  if (rawProvider.upstreamWebsocket !== undefined && typeof rawProvider.upstreamWebsocket !== "boolean") {
1094
1147
  return jsonResponse({ error: "upstreamWebsocket must be a boolean" }, 400);
1095
1148
  }
1096
- const serviceTierError = providerServiceTierConfigError(name, transportCandidate);
1097
- if (serviceTierError) return jsonResponse({ error: serviceTierError }, 400);
1149
+ const catalogCapabilityError = providerCatalogCapabilityConfigError(name, transportCandidate);
1150
+ if (catalogCapabilityError) return jsonResponse({ error: catalogCapabilityError }, 400);
1098
1151
  const prov = stripCodexRuntimeProviderFields(transportCandidate as unknown as OcxProviderConfig);
1099
1152
  // PATCH already clears on null; POST persisted the body as submitted, so a `null` here
1100
1153
  // reached disk and the next loadConfig() refused it. Canonicalize to absent, which is what
@@ -1257,19 +1310,18 @@ export async function handleProviderRoutes(ctx: ManagementContext): Promise<Resp
1257
1310
  || Object.hasOwn(body.provider, "modelPinnedReasoningEfforts")
1258
1311
  || latest?.pinnedReasoningEffort !== undefined || latest?.modelPinnedReasoningEfforts !== undefined;
1259
1312
  // New registration also edits discovery/disabled-model state; stage those
1260
- // side effects with the pin draft instead of mutating live state before validation.
1261
- const registrationDraft = pinsOwned && !latest ? {
1313
+ // side effects with the registration draft instead of mutating live state
1314
+ // before validation.
1315
+ const registrationDraft = !latest ? {
1262
1316
  ...config,
1263
1317
  ...(config.modelDiscovery === undefined ? {} : { modelDiscovery: structuredClone(config.modelDiscovery) }),
1264
1318
  } : undefined;
1265
1319
  initializeProviderModelSelection(name, prov, latest, registrationDraft ?? config);
1266
1320
  const candidate = stripRegistryOnlyStaticHeaders(name, prov);
1267
- if (pinsOwned) {
1268
- const draft = { ...(registrationDraft ?? config), providers: { ...config.providers, [name]: candidate },
1269
- ...(body.setDefault === true ? { defaultProvider: name } : {}) };
1270
- const validation = validateConfigCandidate(draft);
1271
- if (!validation.ok) return jsonResponse({ error: validation.error }, 400);
1272
- }
1321
+ const draft = { ...(registrationDraft ?? config), providers: { ...config.providers, [name]: candidate },
1322
+ ...(body.setDefault === true ? { defaultProvider: name } : {}) };
1323
+ const validation = validateConfigCandidate(draft);
1324
+ if (!validation.ok) return jsonResponse({ error: validation.error }, 400);
1273
1325
  const previous = Object.getOwnPropertyDescriptor(config.providers, name);
1274
1326
  const rollback = pinsOwned ? captureConfigTopLevelRollback(config, ["defaultProvider", "modelDiscovery", "disabledModels"]) : undefined;
1275
1327
  try {
@@ -1397,8 +1449,8 @@ export async function handleProviderRoutes(ctx: ManagementContext): Promise<Resp
1397
1449
  ?? providerEmptyToolOutputConfigError(name, next);
1398
1450
  if (providerError) return jsonResponse({ error: providerError }, 400);
1399
1451
  if (!canonicalBudgetOnly) {
1400
- const serviceTierError = providerServiceTierConfigError(name, next);
1401
- if (serviceTierError) return jsonResponse({ error: serviceTierError }, 400);
1452
+ const catalogCapabilityError = providerCatalogCapabilityConfigError(name, next);
1453
+ if (catalogCapabilityError) return jsonResponse({ error: catalogCapabilityError }, 400);
1402
1454
  // Same DNS gate as POST and re-enable: the canonical built-in OpenAI forward
1403
1455
  // provider may resolve through Clash/Mihomo fake-IP DNS (198.18.0.0/15), so the
1404
1456
  // ordinary PATCH must not reject the very same destination the provider was
@@ -1444,9 +1496,9 @@ export async function handleProviderRoutes(ctx: ManagementContext): Promise<Resp
1444
1496
  return;
1445
1497
  }
1446
1498
  if (!canonicalBudgetOnly) {
1447
- const serviceTierError = providerServiceTierConfigError(name, replay.next);
1448
- if (serviceTierError) {
1449
- replayError = serviceTierError;
1499
+ const catalogCapabilityError = providerCatalogCapabilityConfigError(name, replay.next);
1500
+ if (catalogCapabilityError) {
1501
+ replayError = catalogCapabilityError;
1450
1502
  return;
1451
1503
  }
1452
1504
  }
@@ -10,6 +10,11 @@ import type { CursorEffortTable } from "../integrations/cursor-effort-table";
10
10
  * Completions, Responses and Anthropic Messages, streams, and accepts tool calls, so those are
11
11
  * constants; context length and vision come from catalog data when known and are omitted
12
12
  * otherwise, matching Cursor's optional-field schema.
13
+ *
14
+ * Top-level capacity metrics (`context_window`, `context_length`, `max_output_tokens`) are
15
+ * mirrored directly on each model row for external client discovery (e.g. pi-ai, DSH,
16
+ * LibreChat) that inspects flat properties rather than Cursor's nested `capabilities.*` shape.
17
+ * A row that gains a nested capacity value must gain the top-level mirror in the same change.
13
18
  */
14
19
 
15
20
  /**
@@ -132,6 +137,19 @@ export interface ModelCapabilityFields {
132
137
  supports_vision?: boolean;
133
138
  reasoning_effort?: string[];
134
139
  };
140
+ /**
141
+ * Mirrored top-level context window for external/legacy client discovery (e.g. pi-ai, DSH)
142
+ * that reads top-level context_window / context_length instead of nested capabilities.
143
+ */
144
+ context_window?: number;
145
+ /**
146
+ * Top-level context length alias matching capabilities.context_length for clients expecting context_length.
147
+ */
148
+ context_length?: number;
149
+ /**
150
+ * Mirrored top-level max output token limit for external/legacy client discovery.
151
+ */
152
+ max_output_tokens?: number;
135
153
  /**
136
154
  * Cursor reads the long-context threshold from `pricing.overrides[].min_prompt_tokens`. That
137
155
  * key sits outside its validated capability schema, so it is the one place a threshold can
@@ -153,6 +171,7 @@ export function modelCapabilityFields(input: ModelCapabilityInput): ModelCapabil
153
171
  const longContextLength = positiveInt(input.longContextWindow);
154
172
  const maxOutputTokens = positiveInt(input.maxOutputTokens);
155
173
  const hasLongTier = contextLength !== undefined && longContextLength !== undefined && longContextLength > contextLength;
174
+ const effectiveContextLength = hasLongTier ? longContextLength : contextLength;
156
175
  const modalities = Array.isArray(input.inputModalities)
157
176
  ? input.inputModalities.filter(modality => typeof modality === "string" && modality.length > 0)
158
177
  : undefined;
@@ -160,9 +179,7 @@ export function modelCapabilityFields(input: ModelCapabilityInput): ModelCapabil
160
179
  return {
161
180
  api_types: [...OPENCODEX_MODEL_API_TYPES],
162
181
  capabilities: {
163
- ...(hasLongTier
164
- ? { context_length: longContextLength }
165
- : contextLength !== undefined ? { context_length: contextLength } : {}),
182
+ ...(effectiveContextLength !== undefined ? { context_length: effectiveContextLength } : {}),
166
183
  ...(maxOutputTokens !== undefined ? { max_output_tokens: maxOutputTokens } : {}),
167
184
  // Once a gateway advertises api_types, Cursor keeps only rows whose output_modalities
168
185
  // include "text"; omitting the key drops the row from the extended catalog.
@@ -174,6 +191,10 @@ export function modelCapabilityFields(input: ModelCapabilityInput): ModelCapabil
174
191
  ...(supportsVision !== undefined ? { supports_vision: supportsVision } : {}),
175
192
  ...(efforts.length > 0 ? { reasoning_effort: [...efforts] } : {}),
176
193
  },
194
+ ...(effectiveContextLength !== undefined
195
+ ? { context_window: effectiveContextLength, context_length: effectiveContextLength }
196
+ : {}),
197
+ ...(maxOutputTokens !== undefined ? { max_output_tokens: maxOutputTokens } : {}),
177
198
  ...(hasLongTier ? { pricing: { overrides: [{ min_prompt_tokens: contextLength }] } } : {}),
178
199
  };
179
200
  }
@@ -21,6 +21,12 @@ export interface HealthzIdentity {
21
21
  uptime?: unknown;
22
22
  pid?: unknown;
23
23
  port?: unknown;
24
+ /**
25
+ * Which listener answered: the standalone/hub server omits it, and the connected-client
26
+ * machine listener reports `"client"` (src/client/machine-listener.ts). It is the only
27
+ * on-the-wire way to tell those two apart, because both bind `config.port ?? 10100`.
28
+ */
29
+ role?: unknown;
24
30
  restartCapability?: unknown;
25
31
  providerReloadCapability?: unknown;
26
32
  guiPairCapability?: unknown;
@@ -63,6 +69,22 @@ export const SERVICE_STOP_LIVENESS: Pick<LivenessIo, "timeoutMs" | "attempts"> =
63
69
  attempts: 3,
64
70
  };
65
71
 
72
+ /**
73
+ * Probe budget for a decision whose wrong answer starts a DUPLICATE proxy (#5004).
74
+ *
75
+ * `start` used the 750ms single-attempt default for both the pre-bind owner probe and
76
+ * (implicitly) the busy-port question behind the ephemeral hop. On Windows that answered
77
+ * "nothing is listening" for a proxy the previous command had just refused to shadow, and
78
+ * the hop then spawned a second instance that took over this home's pid/runtime records
79
+ * and re-pointed Codex at itself. A single unanswered probe is not evidence of absence
80
+ * when the failure mode is a duplicate instance, so the start path borrows the numbers
81
+ * the stop path already uses for the mirror-image decision.
82
+ */
83
+ export const START_OWNERSHIP_LIVENESS: Pick<LivenessIo, "timeoutMs" | "attempts"> = {
84
+ timeoutMs: 1500,
85
+ attempts: 3,
86
+ };
87
+
66
88
  export interface LiveProxy {
67
89
  pid: number | null;
68
90
  port: number;
@@ -78,6 +100,16 @@ export interface LiveProxy {
78
100
  * healthz body predates the field.
79
101
  */
80
102
  version?: string;
103
+ /**
104
+ * Role the live listener reported on `/healthz`, when it reported one: `"client"` for the
105
+ * connected-client machine listener, absent for a standalone or hub proxy.
106
+ *
107
+ * Liveness deliberately still ACCEPTS a client-role listener — see `isOpencodexHealthz`.
108
+ * The role is carried so a caller that needs a management plane (src/cli/runtime-api.ts)
109
+ * can refuse one, while `stop` and orphan cleanup keep finding the process they must act
110
+ * on. Absent for a proxy whose healthz body predates the field.
111
+ */
112
+ role?: string;
81
113
  }
82
114
 
83
115
  /**
@@ -101,6 +133,13 @@ export function probeHostname(hostname: string | undefined): string {
101
133
  * `service: "opencodex"` marker, plus the legacy `{status, version, uptime}` trio so a
102
134
  * still-running pre-identity proxy (e.g. right after `ocx update`) is not mistaken for a
103
135
  * foreign server and shadow-started over.
136
+ *
137
+ * The connected-client machine listener answers with the same `service: "opencodex"` marker
138
+ * and an extra `role: "client"`, and it is accepted here on purpose. Liveness answers "is one
139
+ * of our processes listening on this port", which is exactly what `ocx stop`, orphan cleanup,
140
+ * and duplicate-start avoidance need: rejecting the client role would make those paths blind to
141
+ * a real opencodex process and let them shadow-start over it. Callers that additionally need a
142
+ * management plane discriminate on `LiveProxy.role` instead of narrowing this predicate.
104
143
  */
105
144
  export function isOpencodexHealthz(body: HealthzIdentity | null): boolean {
106
145
  if (!body) return false;
@@ -114,7 +153,7 @@ export async function proxyIdentityAt(
114
153
  port: number,
115
154
  opts: { hostname?: string; expectedPid?: number } = {},
116
155
  io: LivenessIo = {},
117
- ): Promise<{ pid: number | null; version?: string } | null> {
156
+ ): Promise<{ pid: number | null; version?: string; role?: string } | null> {
118
157
  const fetchFn = io.fetchFn ?? directLocalHttpFetch;
119
158
  const sleepFn = io.sleepFn ?? ((ms: number) => new Promise<void>(r => setTimeout(r, ms)));
120
159
  const nowFn = io.nowFn ?? Date.now;
@@ -139,7 +178,14 @@ export async function proxyIdentityAt(
139
178
  if (opts.expectedPid !== undefined && pid !== null && pid !== opts.expectedPid) return null;
140
179
  // Guarded the same way `pid` is: a non-string version is absent, not coerced.
141
180
  const version = typeof body?.version === "string" ? body.version : undefined;
142
- return version === undefined ? { pid } : { pid, version };
181
+ // Same guard for the role, for the same reason: absent on a standalone/hub proxy and on
182
+ // a legacy body, and never coerced from a non-string.
183
+ const role = typeof body?.role === "string" ? body.role : undefined;
184
+ return {
185
+ pid,
186
+ ...(version === undefined ? {} : { version }),
187
+ ...(role === undefined ? {} : { role }),
188
+ };
143
189
  } catch {
144
190
  // Transport failure (timeout / refused) — retry while budget remains; a proxy that
145
191
  // has only just begun listening can miss a single short probe (#764).
@@ -203,6 +249,7 @@ export async function findLiveProxy(io: LivenessIo = {}): Promise<LiveProxy | nu
203
249
  hostname: runtime.hostname,
204
250
  source: "runtime",
205
251
  ...(identity.version === undefined ? {} : { version: identity.version }),
252
+ ...(identity.role === undefined ? {} : { role: identity.role }),
206
253
  };
207
254
  }
208
255
  }
@@ -226,6 +273,7 @@ export async function findLiveProxy(io: LivenessIo = {}): Promise<LiveProxy | nu
226
273
  hostname: record.hostname,
227
274
  source: "runtime",
228
275
  ...(identity.version === undefined ? {} : { version: identity.version }),
276
+ ...(identity.role === undefined ? {} : { role: identity.role }),
229
277
  };
230
278
  }
231
279
  }
@@ -241,11 +289,58 @@ export async function findLiveProxy(io: LivenessIo = {}): Promise<LiveProxy | nu
241
289
  hostname: config.hostname,
242
290
  source: "config",
243
291
  ...(identity.version === undefined ? {} : { version: identity.version }),
292
+ ...(identity.role === undefined ? {} : { role: identity.role }),
244
293
  };
245
294
  }
246
295
  return null;
247
296
  }
248
297
 
298
+ /**
299
+ * Loopback addresses to ask about a port whose holder must be identified before a
300
+ * decision that is destructive when it answers "nobody".
301
+ *
302
+ * A listener and a probe can disagree about what "loopback" means, and on Windows they
303
+ * do. `startServer` canonicalizes a `localhost` bind to 127.0.0.1 precisely because
304
+ * Windows resolves the name IPv6-first (src/server/index.ts), while `probeHostname`
305
+ * hands the literal name back and leaves the family choice to the resolver. A probe that
306
+ * lands on `::1` while the listener holds `127.0.0.1` reports an empty port that another
307
+ * process is demonstrably serving. Both literal addresses are therefore asked, cheapest
308
+ * question first: the extra one costs a refused connection, and skipping it costs a
309
+ * duplicate proxy.
310
+ *
311
+ * A non-loopback bind (a LAN address, a named host) gets exactly one candidate — the
312
+ * address it was configured with. Guessing another interface for it would answer a
313
+ * different question than the caller asked.
314
+ */
315
+ export function loopbackProbeHosts(hostname: string | undefined): string[] {
316
+ const primary = probeHostname(hostname);
317
+ if (primary === "127.0.0.1" || /^localhost$/i.test(primary)) return ["127.0.0.1", "[::1]"];
318
+ if (primary === "[::1]") return ["[::1]", "127.0.0.1"];
319
+ return [primary];
320
+ }
321
+
322
+ /**
323
+ * Identity-checked answer to "who holds this exact port", independent of the pid file
324
+ * and the runtime-port record.
325
+ *
326
+ * `findLiveProxy` answers "is a proxy of this home alive", and it can only do that from
327
+ * recorded state plus the configured port. This answers the narrower question a start
328
+ * has to ask before it walks away from a busy port: an opencodex listening THERE, right
329
+ * now, whatever this home's records say about it. Returns null only after every
330
+ * candidate address has failed the identity check with the caller's full probe budget.
331
+ */
332
+ export async function probePortOwner(
333
+ port: number,
334
+ opts: { hostname?: string } = {},
335
+ io: LivenessIo = {},
336
+ ): Promise<{ pid: number | null; hostname: string; version?: string; role?: string } | null> {
337
+ for (const hostname of loopbackProbeHosts(opts.hostname)) {
338
+ const identity = await proxyIdentityAt(port, { hostname }, io);
339
+ if (identity) return { ...identity, hostname };
340
+ }
341
+ return null;
342
+ }
343
+
249
344
  // ─────────────────────────────────────────────────────────────────────────────
250
345
  // Readiness (/readyz) strict probe.
251
346
  //