@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
@@ -925,12 +925,32 @@ async function poolSetting(
925
925
  if (response.status !== 200) return apiError(response.json, `failed to read ${label}`, response.status);
926
926
  const strategy = response.json[transport.strategyKey];
927
927
  const sticky = response.json[transport.stickyKey];
928
+ const autoSwitchThreshold = typeof response.json.autoSwitchThreshold === "number"
929
+ ? response.json.autoSwitchThreshold
930
+ : undefined;
928
931
  if (wantsJson) {
929
932
  // Pool-neutral key names: the two routes spell the same two settings differently, and a
930
933
  // `--json` consumer should not have to branch on which pool answered.
931
- console.log(JSON.stringify({ ok: true, provider: name, strategy, stickyLimit: sticky }, null, 2));
934
+ const payload: Record<string, unknown> = { ok: true, provider: name, strategy, stickyLimit: sticky };
935
+ if (autoSwitchThreshold !== undefined) {
936
+ payload.autoSwitchThreshold = autoSwitchThreshold;
937
+ }
938
+ console.log(JSON.stringify(payload, null, 2));
932
939
  } else {
933
- console.log(`${name}: ${label} is ${String(field === "strategy" ? strategy : sticky)}`);
940
+ if (field === "strategy" && autoSwitchThreshold !== undefined) {
941
+ const thresholdSummary = strategy === "round-robin"
942
+ ? "threshold not used"
943
+ : autoSwitchThreshold > 0
944
+ ? (strategy === "fill-first"
945
+ ? `drain at ${autoSwitchThreshold}%`
946
+ : strategy === "reset-first"
947
+ ? `nearest reset below ${autoSwitchThreshold}%`
948
+ : `switch at ${autoSwitchThreshold}%`)
949
+ : "proactive switching off";
950
+ console.log(`${name}: ${label} is ${String(strategy)} (${thresholdSummary})`);
951
+ } else {
952
+ console.log(`${name}: ${label} is ${String(field === "strategy" ? strategy : sticky)}`);
953
+ }
934
954
  }
935
955
  return 0;
936
956
  }
@@ -0,0 +1,63 @@
1
+ import type { importOrcaAccounts } from "../codex/orca-import";
2
+
3
+ const USAGE = "Usage: ocx account import-orca --source <orca-data-directory> --registry <orca-data.json> [--apply] [--json]";
4
+
5
+ export interface OrcaImportCommandDeps {
6
+ importAccounts?: typeof importOrcaAccounts;
7
+ }
8
+
9
+ /** Local-only: never sends source paths or credentials to a management listener. */
10
+ export async function cmdOrcaImport(args: string[], deps: OrcaImportCommandDeps = {}): Promise<number> {
11
+ let sourceDir: string | undefined;
12
+ let registryPath: string | undefined;
13
+ let apply = false;
14
+ const wantsJson = args.includes("--json");
15
+ const seen = new Set<string>();
16
+ for (let index = 0; index < args.length; index++) {
17
+ const arg = args[index]!;
18
+ if (seen.has(arg) || !["--source", "--registry", "--apply", "--json"].includes(arg)) {
19
+ console.error(USAGE);
20
+ return 1;
21
+ }
22
+ seen.add(arg);
23
+ if (arg === "--source" || arg === "--registry") {
24
+ const value = args[++index];
25
+ if (!value || value.startsWith("--")) {
26
+ console.error(USAGE);
27
+ return 1;
28
+ }
29
+ if (arg === "--source") sourceDir = value;
30
+ else registryPath = value;
31
+ } else if (arg === "--apply") apply = true;
32
+ }
33
+ if (!sourceDir || !registryPath) {
34
+ console.error(USAGE);
35
+ return 1;
36
+ }
37
+ try {
38
+ const run = deps.importAccounts ?? (await import("../codex/orca-import")).importOrcaAccounts;
39
+ const result = await run({ sourceDir, registryPath, apply });
40
+ if (wantsJson) console.log(JSON.stringify({
41
+ mode: result.mode, discovered: result.discovered, eligible: result.eligible,
42
+ imported: result.imported, duplicates: result.duplicates, invalid: result.invalid,
43
+ invalidReasons: result.invalidReasons,
44
+ }));
45
+ else {
46
+ console.log(`Orca: ${result.discovered} discovered, ${result.eligible} eligible, ${result.imported} imported, ${result.duplicates} duplicates, ${result.invalid} invalid.`);
47
+ if (result.invalid > 0) {
48
+ const reasons = Object.entries(result.invalidReasons).map(([reason, count]) => `${reason}=${count}`).join(", ");
49
+ console.log(`Invalid reasons: ${reasons}`);
50
+ }
51
+ console.log(apply
52
+ ? "Start the proxy and use Refresh quotas in Codex Auth to validate new accounts. Orca retains refresh ownership."
53
+ : "Preview only. Stop the proxy before repeating with --apply. Orca authentication files stay read-only.");
54
+ }
55
+ return result.invalid > 0 && result.eligible === 0 ? 1 : 0;
56
+ } catch {
57
+ // Filesystem/JSON exceptions may contain source paths or token fragments.
58
+ const error = "Orca import failed. Check the source, destination config, and stopped proxy; no successful completion is confirmed.";
59
+ if (wantsJson) console.log(JSON.stringify({ error: "orca_import_failed" }));
60
+ else console.error(error);
61
+ return 1;
62
+ }
63
+ }
@@ -57,6 +57,7 @@ const ACCOUNT_USAGE = `Usage:
57
57
  ocx account clear-cooldown <provider> <account-id|main> [--json]
58
58
  ocx account add-key <provider> [--label <label>] [--json]
59
59
  ocx account import <provider> --format <format> (--file <path>|--stdin) [--json]
60
+ ocx account import-orca --source <orca-data-directory> --registry <orca-data.json> [--apply] [--json]
60
61
  ocx account login <provider> [--id <account-id>] [--reauth] [--code -] [--no-wait] [--json]
61
62
  ocx account code <provider> [--flow <flow-id>] [--json] (reads the code from stdin)
62
63
  ocx account cancel <provider> [--flow <flow-id>] [--json]
@@ -323,13 +324,36 @@ async function cmdUse(rest: string[], deps: AccountDeps): Promise<number> {
323
324
  if (res.status === 0) return proxyUnreachable(res.transportError);
324
325
  if (res.status !== 200) return apiError(res.json, `failed to switch ${name}`, res.status);
325
326
 
326
- if (wantsJson) console.log(JSON.stringify({ ok: true, provider: name, type: c.type, activeId }, null, 2));
327
- else console.log(`${name}: active ${c.type === "api-key" ? "key" : "account"} is now ${displayId(activeId)}`);
327
+ // The route reports this only when routing would drop the pin it just recorded, so an
328
+ // absent field means the pin survives (#4521).
329
+ const pinDrainReason = typeof res.json.pinDrainReason === "string" ? res.json.pinDrainReason : undefined;
330
+ if (wantsJson) {
331
+ console.log(JSON.stringify({
332
+ ok: true,
333
+ provider: name,
334
+ type: c.type,
335
+ activeId,
336
+ ...(pinDrainReason !== undefined ? { pinDrained: true, pinDrainReason } : {}),
337
+ }, null, 2));
338
+ } else {
339
+ console.log(`${name}: active ${c.type === "api-key" ? "key" : "account"} is now ${displayId(activeId)}`);
340
+ }
328
341
  if (c.type === "codex") {
329
342
  console.error("Takes effect immediately; running threads move on their next request, and in-flight requests keep the account they captured.");
330
343
  const active = await apiJson(deps, baseUrl, "GET", "/api/codex-auth/active");
331
- if (active.status === 200 && typeof active.json.autoSwitchThreshold === "number" && active.json.autoSwitchThreshold > 0) {
332
- console.error(`Note: auto-switch (threshold ${active.json.autoSwitchThreshold}%) may override this pin.`);
344
+ const threshold = active.status === 200 && typeof active.json.autoSwitchThreshold === "number"
345
+ ? active.json.autoSwitchThreshold
346
+ : undefined;
347
+ if (pinDrainReason !== undefined) {
348
+ // "may override" is the right caveat for a pin that is currently fine and could be
349
+ // overtaken later. It is the wrong sentence for one the next request will discard, and
350
+ // printing only that is what left the operator believing the account was pinned.
351
+ const because = pinDrainReason === "quota_threshold"
352
+ ? `is at or above the auto-switch threshold${threshold !== undefined ? ` (${threshold}%)` : ""}`
353
+ : `cannot currently be selected (${pinDrainReason})`;
354
+ console.error(`Note: ${displayId(activeId)} ${because}, so routing releases this pin on its next request.`);
355
+ } else if (threshold !== undefined && threshold > 0) {
356
+ console.error(`Note: auto-switch (threshold ${threshold}%) may override this pin.`);
333
357
  }
334
358
  }
335
359
  return 0;
@@ -360,6 +384,10 @@ export async function cmdAccount(args: string[], deps: AccountDeps = {}): Promis
360
384
  if (sub === "clear-cooldown") return await cmdClearCooldown(rest, deps);
361
385
  if (sub === "add-key") return await cmdAddKey(rest, deps);
362
386
  if (sub === "import") return await cmdImport(rest, deps);
387
+ if (sub === "import-orca") {
388
+ const { cmdOrcaImport } = await import("./account-orca-import");
389
+ return await cmdOrcaImport(rest);
390
+ }
363
391
  if (sub === "main") {
364
392
  const { cmdNativeMainAccount } = await import("./account-main");
365
393
  return await cmdNativeMainAccount(rest, deps);
@@ -360,6 +360,25 @@ export const CAPABILITIES: readonly Capability[] = [
360
360
  "`--quota` shows cached Codex windows (including 5h); `--refresh` bypasses the server TTL.",
361
361
  ],
362
362
  },
363
+ {
364
+ command: ["account", "import-orca"],
365
+ summary: "Preview or register read-only links to Orca-managed Codex accounts without another login.",
366
+ routes: [],
367
+ flags: [
368
+ { name: "--source", value: "string", required: true, summary: "Orca data directory containing codex-accounts." },
369
+ { name: "--registry", value: "string", required: true, summary: "The chosen Orca profile's orca-data.json account registry." },
370
+ { name: "--apply", value: "boolean", summary: "Register new accounts; requires a stopped proxy. Default is preview." },
371
+ { name: "--json", value: "boolean", summary: "Emit counts and fixed invalid-reason codes without credentials or source paths." },
372
+ ],
373
+ mutates: true,
374
+ json: "envelope",
375
+ details: [
376
+ "Local files only; never copies refresh tokens or changes Orca authentication files.",
377
+ "Skips existing ChatGPT identities. New accounts remain pending until dashboard validation.",
378
+ "Orca must keep the source login available and refreshed; a missing or expired source fails closed.",
379
+ "Mixed eligible and invalid entries exit successfully; an all-invalid result exits nonzero.",
380
+ ],
381
+ },
363
382
  {
364
383
  command: ["account", "refresh"],
365
384
  summary: "Refresh account quotas without model validation; pending Codex accounts require dashboard consent.",
@@ -708,6 +727,27 @@ export const CAPABILITIES: readonly Capability[] = [
708
727
  "Does not execute Codex or npm, install or repair software, control a process, or write configuration or cache state.",
709
728
  ],
710
729
  },
730
+ {
731
+ command: ["system", "codex-cli-update", "attest"],
732
+ summary: "Observe the selected or explicitly named Windows npm Codex installation files without enabling updates.",
733
+ routes: [],
734
+ flags: [
735
+ { name: "--candidate", value: "string", summary: "Absolute npm codex.cmd or package bin/codex.js path; all four paths are all-or-none." },
736
+ { name: "--npm-prefix", value: "string", summary: "Absolute prefix containing node_modules/@openai/codex." },
737
+ { name: "--npm-cli", value: "string", summary: "Absolute node_modules/npm/bin/npm-cli.js path." },
738
+ { name: "--node", value: "string", summary: "Absolute node.exe path; observed, never executed." },
739
+ { name: "--json", value: "boolean", summary: "Emit the path-free installation identity observation." },
740
+ ],
741
+ mutates: false,
742
+ json: "envelope",
743
+ details: [
744
+ "Opt-in Windows x64 local-volume inspection using held native file handles; refuses reparse points, active writers and unsupported layouts.",
745
+ "Without explicit paths, the proof-bound launcher snapshot identifies the selected candidate: the configured CODEX_CLI_PATH or the first codex on the captured PATH, with an OpenCodex wrapper resolving to its codex.opencodex-real backing. Discovery only proposes paths; the held-handle observation remains the authority.",
746
+ "Success binds observed file identities and bytes, not selected-runtime admission or installer ownership.",
747
+ "selectionAttested, managed and applyAllowed remain false. The digest is an observation, not a durable update permit.",
748
+ "Does not run the named Codex/npm/Node files, query a registry, install software, control processes or persist state.",
749
+ ],
750
+ },
711
751
  {
712
752
  command: ["system", "codex-restart"],
713
753
  summary: "Restart the Codex desktop app and app-servers.",
package/src/cli/claude.ts CHANGED
@@ -11,7 +11,7 @@ import { spawn } from "node:child_process";
11
11
  import { loadConfig } from "../config";
12
12
  import { injectClaudeAgentDefs } from "../claude/agents-inject";
13
13
  import { CLAUDE_ALIAS_PREFIX_V1, CLAUDE_ALIAS_PREFIX_V2 } from "../claude/alias";
14
- import { effectiveModelEnv, resolveAutoContext } from "../claude/context-windows";
14
+ import { claudeToolSearchEnv, effectiveModelEnv, resolveAutoContext } from "../claude/context-windows";
15
15
  import { claudeConfigDir, refreshGatewayModelCacheFromProxy } from "../claude/gateway-cache";
16
16
  import { commandInvocation } from "../lib/win-exec";
17
17
  import { isProxyAdmissionSecret } from "../server/auth-cors";
@@ -357,6 +357,22 @@ export function buildClaudeEnv(
357
357
  if (config.claudeCode?.alwaysEnableEffort === true) {
358
358
  setDefault("CLAUDE_CODE_ALWAYS_ENABLE_EFFORT", "1");
359
359
  }
360
+ // Tool-search deferral (#4838). Claude Code disables MCP tool deferral whenever
361
+ // ANTHROPIC_BASE_URL is not a first-party Anthropic host — keyed on the host, not
362
+ // the model — so every routed session inlines all MCP tool schemas. Its own log
363
+ // line states the precondition: "Set ENABLE_TOOL_SEARCH=true (or auto / auto:N)
364
+ // if your proxy forwards tool_reference blocks."
365
+ //
366
+ // We forward them on the native Anthropic passthrough route only. A translated
367
+ // route cannot honour the shape: deferred tools still carry input_schema on the
368
+ // wire (deferral is a server-side context optimisation, not a smaller request),
369
+ // toolsToResponses drops the tool_search server tool and ignores defer_loading,
370
+ // and compatibility.ts lists tool_search/tool_reference/deferred_tools as
371
+ // unsupported. Turning it on there leaves the routed provider holding every
372
+ // schema while Claude Code stops counting them and stops compacting, which is
373
+ // worse than the problem. So this stays opt-in per config rather than
374
+ // unconditional, and setDefault keeps an operator's own export.
375
+ setDefault("ENABLE_TOOL_SEARCH", claudeToolSearchEnv(config.claudeCode?.toolSearch));
360
376
  // Context-window override: the official pair — MAX_CONTEXT_TOKENS alone is ignored
361
377
  // for recognized claude-shaped ids unless DISABLE_COMPACT=1 rides along (devlog 135).
362
378
  const maxCtx = config.claudeCode?.maxContextTokens;
@@ -542,6 +558,18 @@ export function claudeLaunchPreflight(
542
558
  : { kind: "native", notice: CLAUDE_NATIVE_ROUTING_OFF };
543
559
  }
544
560
 
561
+ /**
562
+ * Levers a native (non-routed) launch must shed. The loop below deletes them
563
+ * unconditionally, because each one either points Claude Code at a gateway that
564
+ * is not there or asserts host ownership that a native session does not have.
565
+ *
566
+ * ENABLE_TOOL_SEARCH is deliberately NOT in this list (#4838). It is the only
567
+ * one of these whose value is meaningful to a native session: natively the base
568
+ * URL is first-party, where deferral is already Claude Code's default and the
569
+ * variable is the user's own tuning knob (auto:N, force). Stripping it would
570
+ * delete a preference that works, to protect a session that does not need
571
+ * protecting.
572
+ */
545
573
  const NATIVE_STRIPPED_LEVERS = [
546
574
  "CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY",
547
575
  "CLAUDE_CODE_PROVIDER_MANAGED_BY_HOST",
@@ -3,18 +3,53 @@ import {
3
3
  type CodexCliInstallProvenanceDeps,
4
4
  type CodexCliInstallReport,
5
5
  } from "../codex/cli-install-provenance";
6
+ import type {
7
+ CodexCliInstallationIdentityInput,
8
+ CodexCliInstallationIdentityReport,
9
+ } from "../codex/cli-installation-identity";
10
+ import type {
11
+ CodexCliInstallationSnapshot,
12
+ CodexCliInstallationTargetDerivation,
13
+ } from "../codex/cli-installation-targets";
6
14
  import { CliUsageError, isJsonOption, printData, runCliAction } from "./runtime-api";
7
15
  import { trustedNodeLauncherContext } from "./launcher-context";
8
16
 
9
17
  export const CODEX_CLI_UPDATE_USAGE = `Usage:
10
- ocx system codex-cli-update check [--json]`;
18
+ ocx system codex-cli-update check [--json]
19
+ ocx system codex-cli-update attest [--json]
20
+ ocx system codex-cli-update attest --candidate <absolute-path> --npm-prefix <absolute-path> --npm-cli <absolute-path> --node <absolute-path> [--json]`;
11
21
 
12
22
  export type ParsedCodexCliUpdateArgs = Readonly<{
13
23
  json: boolean;
24
+ }> | Readonly<{
25
+ json: boolean;
26
+ attest: CodexCliInstallationIdentityInput | "selected";
14
27
  }>;
15
28
 
16
29
  export interface CodexCliUpdateCommandDeps {
17
30
  readonly inspectInstall?: (deps: CodexCliInstallProvenanceDeps) => Promise<CodexCliInstallReport>;
31
+ readonly inspectIdentity?: (input: CodexCliInstallationIdentityInput) => Promise<CodexCliInstallationIdentityReport>;
32
+ readonly deriveInstallationInput?: (
33
+ snapshot: CodexCliInstallationSnapshot,
34
+ ) => CodexCliInstallationTargetDerivation;
35
+ }
36
+
37
+ function identitySummary(report: CodexCliInstallationIdentityReport): string[] {
38
+ return [
39
+ `status: ${report.status}`,
40
+ `reason: ${report.reason}`,
41
+ `candidate-source: ${report.candidateSource}`,
42
+ `installation-identity-observed: ${report.installationIdentityObserved ? "yes" : "no"}`,
43
+ `selection-attested: ${report.selectionAttested ? "yes" : "no"}`,
44
+ `managed: ${report.managed ? "yes" : "no"}`,
45
+ `apply-allowed: ${report.applyAllowed ? "yes" : "no"}`,
46
+ `package-version: ${report.packageVersion ?? "unavailable"}`,
47
+ `npm-version: ${report.npmVersion ?? "unavailable"}`,
48
+ `identity-digest: ${report.identityDigest ?? "unavailable"}`,
49
+ `proof: ${report.proof ?? "unavailable"}`,
50
+ `toolchain: ${report.toolchain}`,
51
+ "scope: installation identity only; runtime selection and update ownership are not attested",
52
+ ];
18
53
  }
19
54
 
20
55
  function installSummary(report: CodexCliInstallReport): string[] {
@@ -47,8 +82,38 @@ export function parseCodexCliUpdateArgs(argv: readonly string[]): ParsedCodexCli
47
82
  }
48
83
  positional.push(token);
49
84
  }
85
+ if (positional[0] === "attest") {
86
+ // No options: attest the selected candidate identified from the proof-bound
87
+ // launcher snapshot. The four explicit paths remain all-or-none.
88
+ if (positional.length === 1) {
89
+ return Object.freeze({ json, attest: "selected" as const });
90
+ }
91
+ const options = new Map<string, keyof CodexCliInstallationIdentityInput>([
92
+ ["--candidate", "candidate"], ["--npm-prefix", "npmPrefix"],
93
+ ["--npm-cli", "npmCli"], ["--node", "node"],
94
+ ]);
95
+ const input: Partial<Record<keyof CodexCliInstallationIdentityInput, string>> = {};
96
+ for (let index = 1; index < positional.length; index += 2) {
97
+ const key = options.get(positional[index]!);
98
+ if (!key || input[key] !== undefined) {
99
+ throw new CliUsageError("unsupported or duplicate attest option", CODEX_CLI_UPDATE_USAGE);
100
+ }
101
+ const value = positional[index + 1];
102
+ if (!value || !value.trim() || /[\0\r\n]/.test(value)
103
+ || !(value.startsWith("/") || /^[a-z]:[\\/]/i.test(value))) {
104
+ throw new CliUsageError("attest options require explicit absolute paths", CODEX_CLI_UPDATE_USAGE);
105
+ }
106
+ input[key] = value;
107
+ }
108
+ if (!input.candidate || !input.npmPrefix || !input.npmCli || !input.node) {
109
+ throw new CliUsageError("attest requires --candidate, --npm-prefix, --npm-cli and --node", CODEX_CLI_UPDATE_USAGE);
110
+ }
111
+ return Object.freeze({ json, attest: Object.freeze({
112
+ candidate: input.candidate, npmPrefix: input.npmPrefix, npmCli: input.npmCli, node: input.node,
113
+ }) });
114
+ }
50
115
  if (positional[0] !== "check") {
51
- throw new CliUsageError("codex-cli-update action must be check", CODEX_CLI_UPDATE_USAGE);
116
+ throw new CliUsageError("codex-cli-update action must be check or attest", CODEX_CLI_UPDATE_USAGE);
52
117
  }
53
118
  if (positional.length > 1) {
54
119
  throw new CliUsageError("unsupported codex-cli-update argument", CODEX_CLI_UPDATE_USAGE);
@@ -72,6 +137,36 @@ export async function handleCodexCliUpdateCommand(
72
137
  throw error;
73
138
  }
74
139
  return runCliAction(async () => {
140
+ if ("attest" in parsed) {
141
+ let report: CodexCliInstallationIdentityReport;
142
+ try {
143
+ const identityModule = await import("../codex/cli-installation-identity");
144
+ const inspectIdentity = deps.inspectIdentity ?? identityModule.inspectCodexCliInstallationIdentity;
145
+ if (parsed.attest === "selected") {
146
+ // The proof-bound launcher snapshot is the only trusted source for the
147
+ // selected candidate; a direct Bun/source launch has none and refuses.
148
+ const snapshot = trustedNodeLauncherContext()?.codexCliInspectionEnv;
149
+ const derive = deps.deriveInstallationInput
150
+ ?? (await import("../codex/cli-installation-targets")).deriveCodexCliInstallationInput;
151
+ const derived = derive({
152
+ codexCliPath: snapshot?.codexCliPath ?? null,
153
+ path: snapshot?.path ?? null,
154
+ pathExt: snapshot?.pathExt ?? null,
155
+ });
156
+ report = derived.kind === "derived"
157
+ ? await inspectIdentity(derived.input)
158
+ : identityModule.codexCliInstallationRefusal(derived.reason, "selected");
159
+ } else {
160
+ report = await inspectIdentity(parsed.attest);
161
+ }
162
+ } catch {
163
+ // Filesystem/native errors can contain the explicit private paths. The
164
+ // read-only inspector normally returns a refusal; unexpected errors stay redacted.
165
+ throw new Error("Installation identity inspection failed");
166
+ }
167
+ printData(report, parsed.json, identitySummary(report));
168
+ return;
169
+ }
75
170
  const trustedInspectionEnv = trustedNodeLauncherContext()?.codexCliInspectionEnv;
76
171
  const inspectionDeps: CodexCliInstallProvenanceDeps = trustedInspectionEnv
77
172
  && trustedInspectionEnv.managerRoots !== null ? {
@@ -4,7 +4,7 @@ import { getConfigPath, mutatePersistedConfig, readConfigDiagnostics, sanitizeMo
4
4
  import { VISION_REASONING_EFFORTS, isVisionReasoningEffort } from "../reasoning-effort";
5
5
  import type { OcxConfig } from "../types";
6
6
  import { normalizeVisionReasoningForModel } from "../vision/reasoning";
7
- import type { ClientConnectionStatus } from "./connect";
7
+ import type { ServiceApiTokenState } from "../lib/service-secrets";
8
8
  import { CliUsageError, printData, rejectArgs, runCliAction, takeFlag } from "./runtime-api";
9
9
 
10
10
  const USAGE = `Usage:
@@ -38,9 +38,8 @@ const BLOCKED_SEGMENTS = new Set(["__proto__", "prototype", "constructor"]);
38
38
  * `client` block, which is the same defect in miniature: the presence of configuration is not
39
39
  * evidence that the connection works, and a machine whose data-plane token was revoked, rotated
40
40
  * away or deleted would have been labelled `connected: true` while it could not reach the hub at
41
- * all. `collectClientConnectionStatus` is the one reader that knows — it compares the token file's
42
- * fingerprint against the connection record — so the caller passes its answer in and this stays
43
- * pure and testable.
41
+ * all. The read-only projection compares the bounded token file's fingerprint against the
42
+ * connection record, and passes that answer in so this formatter stays pure and testable.
44
43
  *
45
44
  * Synthetic and NOT persisted, for two reasons. `clientConnectionSchema` is `.strict()`, so a
46
45
  * `client.note` field would not validate; and persisted prose drifts from the behaviour it
@@ -49,7 +48,7 @@ const BLOCKED_SEGMENTS = new Set(["__proto__", "prototype", "constructor"]);
49
48
  */
50
49
  export function remoteHubConfigNote(
51
50
  config: OcxConfig,
52
- readConnection: () => Pick<ClientConnectionStatus, "state" | "reason" | "token">,
51
+ readConnection: () => RemoteHubConnectionObservation,
53
52
  ): { connected: boolean; origin: string; note: string } | null {
54
53
  if (config.runtimeRole !== "client" || !config.client) return null;
55
54
  // A thunk, so a standalone or hub install pays nothing: the guard above returns first and the
@@ -67,6 +66,36 @@ export function remoteHubConfigNote(
67
66
  return { connected, origin: config.client.serverUrl, note };
68
67
  }
69
68
 
69
+ export type RemoteHubConnectionObservation = {
70
+ state: "disconnected" | "connected" | "invalid" | "mismatched";
71
+ reason?: string;
72
+ token: "owned" | "missing" | "changed" | "unsafe";
73
+ };
74
+
75
+ export function remoteHubConnectionFromTokenState(
76
+ config: Pick<OcxConfig, "client">,
77
+ tokenState: ServiceApiTokenState,
78
+ ): RemoteHubConnectionObservation {
79
+ const token = tokenState.kind === "absent"
80
+ ? "missing"
81
+ : tokenState.kind === "unsafe"
82
+ ? "unsafe"
83
+ : tokenState.fingerprint === config.client?.tokenFingerprint ? "owned" : "changed";
84
+ return { state: "connected", token };
85
+ }
86
+
87
+ async function readRemoteHubConfigNote(config: OcxConfig): Promise<ReturnType<typeof remoteHubConfigNote>> {
88
+ if (config.runtimeRole !== "client" || !config.client) return null;
89
+ // This display command needs only connection ownership, not lifecycle recovery, catalog
90
+ // readiness, or any write-capable connect machinery. Keep the read on the bounded token
91
+ // observer so a cold `config show` never imports the full connect command graph.
92
+ const { readServiceApiTokenState } = await import("../lib/service-secrets");
93
+ return remoteHubConfigNote(
94
+ config,
95
+ () => remoteHubConnectionFromTokenState(config, readServiceApiTokenState()),
96
+ );
97
+ }
98
+
70
99
  function redact(value: unknown, key = ""): unknown {
71
100
  if (SECRET_KEYS.test(key) && typeof value === "string") return value ? "********" : value;
72
101
  // `client.priorCatalog` is the base64 catalog snapshot connect took before overwriting the
@@ -168,19 +197,7 @@ export async function handleConfigCommand(argv: string[]): Promise<number> {
168
197
  rejectArgs(args, USAGE);
169
198
  const diagnostics = readConfigDiagnostics();
170
199
  const redacted = redact(diagnostics.config);
171
- // Imported here rather than at module scope: `./connect` pulls the whole client lifecycle
172
- // in, and `ocx config get/set` has no use for it.
173
- const { collectClientConnectionStatus } = await import("./connect");
174
- // The readiness probe is declined explicitly. `collectClientConnectionStatus` observes the
175
- // local Codex ladder for a connected client, and observing it spawns `codex debug models`
176
- // under a 45s budget. `ocx config show` reads only `state`, `reason` and `token` from the
177
- // result, so paying for a subprocess here would buy nothing and would quietly turn a
178
- // read-only config dump into a runtime probe. Returning no ladder resolves readiness to
179
- // `unverified`, which is the honest answer for a caller that never asked.
180
- const note = remoteHubConfigNote(
181
- diagnostics.config,
182
- () => collectClientConnectionStatus(undefined, undefined, { supportedEfforts: () => null }),
183
- );
200
+ const note = await readRemoteHubConfigNote(diagnostics.config);
184
201
  // First key, not last: it has to be read before the empty `providers` map that misled a
185
202
  // reader into concluding nothing was configured anywhere.
186
203
  const config = note && redacted && typeof redacted === "object" && !Array.isArray(redacted)
@@ -24,7 +24,7 @@ import {
24
24
  } from "../codex/desired-state";
25
25
  import { syncModelsToCodex } from "../codex/sync";
26
26
  import { collectOrcaCodexHomeDiagnostic } from "../codex/home";
27
- import { restoreNativeCodexAsync } from "../codex/inject";
27
+ import { restoreNativeCodexAsync, type CodexNativeRestoreResult } from "../codex/inject";
28
28
  import { stripGrokConfig } from "../grok/inject";
29
29
  import { handleRestartScopeAfterWrite, readRestartScope, type RestartScope } from "./restart-scope";
30
30
  import { normalizeUpdateChannel, runGuiUpdateWorker } from "../update/job";
@@ -106,6 +106,7 @@ const commandRunners: Record<string, CommandRunner> = {
106
106
  restore: async deps => {
107
107
  const restoreArgs = deps.args.slice(1);
108
108
  const restoreJson = takeFlag(restoreArgs, "--json");
109
+ const removeProviderTable = takeFlag(restoreArgs, "--remove-codex-provider-table");
109
110
  if (restoreArgs[0] === "back") {
110
111
  // Reverse switch: re-point plain `codex` at the RUNNING proxy without touching its
111
112
  // lifecycle — the counterpart of `ocx restore`. Start/stop triggers are unchanged;
@@ -146,6 +147,9 @@ const commandRunners: Record<string, CommandRunner> = {
146
147
  const target = collectOrcaCodexHomeDiagnostic();
147
148
  return emitBack(true, `Plain \`codex\` now routes through opencodex in ${target.effectiveCodexHome} (undo with: ocx restore).`, 0);
148
149
  }
150
+ if (removeProviderTable && !restoreJson) {
151
+ console.log("⚠️ Removing [model_providers.opencodex] means conversations already tagged opencodex will stop opening.");
152
+ }
149
153
  const desired = setIntegrationEnabled("codex", false);
150
154
  if (!desired.ok) {
151
155
  if (restoreJson) {
@@ -191,9 +195,9 @@ const commandRunners: Record<string, CommandRunner> = {
191
195
  return grokCode;
192
196
  }
193
197
  }
194
- let r: { success: boolean; message: string };
198
+ let r: CodexNativeRestoreResult | Pick<CodexNativeRestoreResult, "success" | "message">;
195
199
  try {
196
- r = await restoreNativeCodexAsync({ revalidateDesiredState: true });
200
+ r = await restoreNativeCodexAsync({ revalidateDesiredState: true, removeProviderTable });
197
201
  } catch (err) {
198
202
  r = { success: false, message: err instanceof Error ? err.message : String(err) };
199
203
  }
@@ -232,7 +236,16 @@ const commandRunners: Record<string, CommandRunner> = {
232
236
  code = 1;
233
237
  }
234
238
  if (r.success) {
235
- console.log("Codex integration is OFF and plain `codex` now runs natively. Switch back with: ocx restore back");
239
+ const retained = "retainedCodexProviderTable" in r ? r.retainedCodexProviderTable : undefined;
240
+ if (retained) {
241
+ console.log("Codex integration is OFF and plain `codex` now runs natively.");
242
+ console.log("The following lines remain in $CODEX_HOME/config.toml because conversations already tagged opencodex resolve their provider only through this table:");
243
+ console.log(retained.lines.join("\n"));
244
+ console.log(`Follow-up: ${retained.followUp}`);
245
+ console.log("Switch back with: ocx restore back");
246
+ } else {
247
+ console.log("Codex integration is OFF and plain `codex` now runs natively. Switch back with: ocx restore back");
248
+ }
236
249
  console.log(`Note: ${OCX_NATIVE_REPLAY_RECOVERY_NOTE}`);
237
250
  } else {
238
251
  console.error("Plain `codex` was not fully restored. Inspect $CODEX_HOME/config.toml before using native Codex.");
@@ -952,6 +965,60 @@ export function decideStartWithLiveOwner(input: {
952
965
  return input.ocxService === "1" ? "service-stay-out" : "refuse";
953
966
  }
954
967
 
968
+ /** What `chooseListenPort` does when the preferred port stayed busy through prefer-retry. */
969
+ export type BusyPreferredPortDecision =
970
+ | "hop"
971
+ | "refuse-live-proxy"
972
+ | "service-stay-out"
973
+ | "refuse-unidentified-holder";
974
+
975
+ /**
976
+ * Pure decision for a soft `start` whose preferred port is busy and whose only remaining
977
+ * option is an ephemeral port.
978
+ *
979
+ * The hop exists so a first start is not defeated by a port this machine happens to be
980
+ * using. What it must never be is a silent answer to "someone is already here": a start
981
+ * that hops takes over this home's pid and runtime-port records and re-points Codex at
982
+ * itself, so hopping past a live opencodex leaves two proxies running and the editor
983
+ * talking to the one the user did not mean (#5004). The hop path never asked who held the
984
+ * port, and `findLiveProxy` returning null — a stale record, a probe that lost a race, a
985
+ * loopback family split — was enough to reach it.
986
+ *
987
+ * So the decision is made from the holder's own answer rather than from this home's
988
+ * bookkeeping, and both outcomes stop the start. An opencodex answer is the duplicate this
989
+ * closes. A holder that does not answer as opencodex is deliberately NOT called foreign:
990
+ * an identity probe returns the same nothing for a foreign server, an unreachable one, and
991
+ * one that lost a race, so all the start can honestly say is that the port it was told to
992
+ * use is taken by something it could not identify — and moving to an arbitrary port is the
993
+ * one response that hides that from the user while re-pointing Codex. An explicit
994
+ * `--port` never reaches here (`findAvailablePort` refuses the fallback instead), and a
995
+ * configured port of 0 is a request for an ephemeral port, not a collision.
996
+ *
997
+ * Service-wrapper context keeps the semantics `decideStartWithLiveOwner` gives it: a
998
+ * healthy proxy on the port means the port is served, and the wrapper's
999
+ * `if %ERRORLEVEL% NEQ 0` loop must see a zero exit rather than respawn every 5 seconds.
1000
+ */
1001
+ export function decideBusyPreferredPort(input: {
1002
+ preferredPort: number;
1003
+ selectedPort: number;
1004
+ hardPin: boolean;
1005
+ holderIsOpencodex: boolean;
1006
+ ocxService: string | undefined;
1007
+ }): BusyPreferredPortDecision {
1008
+ // Port 0 (or an unusable preference) asked the OS to choose; nothing was taken away.
1009
+ if (input.preferredPort <= 0) return "hop";
1010
+ // The preferred port was obtained — no hop happened, nothing to decide.
1011
+ if (input.selectedPort === input.preferredPort) return "hop";
1012
+ // Defensive: a hard pin cannot reach a different port, and if it ever did, the pin is
1013
+ // the user's explicit instruction and not something to answer with a refusal here.
1014
+ if (input.hardPin) return "hop";
1015
+ if (input.holderIsOpencodex) {
1016
+ // Same sentinel rule as decideStartWithLiveOwner: only the exact "1" is service context.
1017
+ return input.ocxService === "1" ? "service-stay-out" : "refuse-live-proxy";
1018
+ }
1019
+ return "refuse-unidentified-holder";
1020
+ }
1021
+
955
1022
  export function resolveDispatchCommand(command: string | undefined): string | undefined {
956
1023
  if (command === undefined) return undefined;
957
1024
  if (Object.prototype.hasOwnProperty.call(commandRunners, command)) return command;