@bitkyc08/opencodex 2.67.0 → 2.68.0-preview.20260927

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 (257) hide show
  1. package/bin/ocx.mjs +3 -0
  2. package/gui/dist/assets/App-B5-cAEd2.js +51 -0
  3. package/gui/dist/assets/App-DCBismRi.css +1 -0
  4. package/gui/dist/assets/Tray-DFnMiyD0.js +1 -0
  5. package/gui/dist/assets/{index-DtNmX7hW.css → index-BDUBS8PW.css} +1 -1
  6. package/gui/dist/assets/index-BcXblvem.js +86 -0
  7. package/gui/dist/assets/quota-summary-i84eOU3o.js +1 -0
  8. package/gui/dist/index.html +21 -2
  9. package/package.json +1 -1
  10. package/src/adapters/anthropic-image-guard.ts +13 -1
  11. package/src/adapters/anthropic-image-normalize.ts +3 -2
  12. package/src/adapters/anthropic-output-schema.ts +36 -0
  13. package/src/adapters/anthropic.ts +10 -2
  14. package/src/adapters/base.ts +7 -0
  15. package/src/adapters/codebuddy/live-models.ts +180 -0
  16. package/src/adapters/coding-agent/protocol.ts +91 -12
  17. package/src/adapters/coding-agent/turn.ts +32 -27
  18. package/src/adapters/devin/cloud-direct/index.ts +1 -0
  19. package/src/adapters/devin/cloud-direct/stated-reset-retry.ts +18 -4
  20. package/src/adapters/devin.ts +9 -6
  21. package/src/adapters/google-http.ts +4 -4
  22. package/src/adapters/google.ts +93 -3
  23. package/src/adapters/kiro/adapter.ts +6 -2
  24. package/src/adapters/kiro/stream.ts +12 -2
  25. package/src/adapters/kiro/usage.ts +3 -1
  26. package/src/adapters/kiro-errors.ts +11 -1
  27. package/src/adapters/kiro-events.ts +27 -1
  28. package/src/adapters/kiro-refusal.ts +30 -0
  29. package/src/adapters/kiro-retry.ts +71 -31
  30. package/src/adapters/openai-chat/deepseek-artifact-schema.ts +45 -0
  31. package/src/adapters/openai-chat/messages.ts +31 -4
  32. package/src/adapters/openai-chat/serialized-tool-call-content.ts +52 -7
  33. package/src/adapters/openai-chat/tool-call-id-remint.ts +65 -0
  34. package/src/adapters/openai-chat/tool-schema.ts +6 -2
  35. package/src/adapters/openai-chat.ts +2 -2
  36. package/src/adapters/openai-responses/muse-tool-choice.ts +31 -0
  37. package/src/adapters/openai-responses/passthrough.ts +9 -3
  38. package/src/adapters/opencode-go-additional-tools.ts +19 -10
  39. package/src/adapters/physical-send.ts +10 -3
  40. package/src/adapters/registry.ts +2 -1
  41. package/src/adapters/responses-tool-schema.ts +31 -3
  42. package/src/adapters/run-turn-queue.ts +63 -23
  43. package/src/adapters/unique-tool-call-ids.ts +63 -0
  44. package/src/adapters/xai-web-search.ts +32 -2
  45. package/src/bridge/sse.ts +14 -2
  46. package/src/chat/inbound.ts +20 -6
  47. package/src/claude/agents-inject.ts +4 -0
  48. package/src/claude/desktop-3p.ts +53 -13
  49. package/src/claude/desktop-profile.ts +41 -4
  50. package/src/claude/inbound-model-options.ts +14 -2
  51. package/src/claude/inbound.ts +1 -1
  52. package/src/claude/intercept/connect-proxy.ts +17 -1
  53. package/src/claude/intercept/local-ca.ts +7 -1
  54. package/src/claude/message-threads.ts +28 -0
  55. package/src/cli/account-api.ts +13 -0
  56. package/src/cli/account-auth.ts +49 -2
  57. package/src/cli/account-extended.ts +4 -2
  58. package/src/cli/account.ts +4 -2
  59. package/src/cli/capabilities.ts +17 -0
  60. package/src/cli/claude.ts +3 -1
  61. package/src/cli/dispatch.ts +62 -5
  62. package/src/cli/index.ts +84 -79
  63. package/src/cli/minimax.ts +4 -2
  64. package/src/cli/opencode.ts +6 -1
  65. package/src/cli/restart-handoff.ts +110 -0
  66. package/src/cli/status.ts +51 -0
  67. package/src/client/connect.ts +34 -15
  68. package/src/client/link-ingress.ts +102 -0
  69. package/src/client/link-join.ts +37 -16
  70. package/src/client/link-relay.ts +228 -45
  71. package/src/client/link-state.ts +54 -0
  72. package/src/client/link-status.ts +39 -0
  73. package/src/client/link-teardown.ts +2 -2
  74. package/src/client/link-tunnel.ts +615 -86
  75. package/src/client/machine-api.ts +10 -4
  76. package/src/client/machine-listener.ts +57 -10
  77. package/src/client/runtime.ts +210 -35
  78. package/src/clients/config-export/constants.ts +1 -12
  79. package/src/clients/config-export/contracts.ts +2 -0
  80. package/src/clients/config-export/model-metadata.ts +9 -3
  81. package/src/clients/config-export/omp.ts +1 -1
  82. package/src/clients/config-export/zcode-store.ts +2 -2
  83. package/src/clients/config-export.ts +36 -3
  84. package/src/codex/app-server-processes.ts +32 -0
  85. package/src/codex/app-server-restart-service.ts +20 -3
  86. package/src/codex/catalog/access-programs.ts +50 -0
  87. package/src/codex/catalog/build-entries.ts +13 -0
  88. package/src/codex/catalog/derive-entry.ts +1 -0
  89. package/src/codex/catalog/model-hints.ts +11 -0
  90. package/src/codex/catalog/parsing.ts +6 -2
  91. package/src/codex/catalog/provider-models.ts +81 -0
  92. package/src/codex/catalog/retained-sync.ts +3 -0
  93. package/src/codex/codex-write-lock.ts +2 -2
  94. package/src/codex/convergence.ts +2 -0
  95. package/src/codex/desired-state.ts +14 -4
  96. package/src/codex/home.ts +21 -3
  97. package/src/codex/inject/restore.ts +17 -0
  98. package/src/codex/inject/routing-classify.ts +3 -2
  99. package/src/codex/inject-coordination.ts +4 -1
  100. package/src/codex/inject.ts +8 -1
  101. package/src/codex/journal.ts +6 -2
  102. package/src/codex/management-convergence.ts +9 -0
  103. package/src/codex/model-entitlements.ts +28 -5
  104. package/src/codex/routing/idle-window.ts +58 -0
  105. package/src/codex/routing-drift.ts +134 -0
  106. package/src/codex/routing-healer.ts +419 -0
  107. package/src/codex/routing.ts +10 -0
  108. package/src/codex/runtime.ts +188 -57
  109. package/src/codex/sibling-handoff.ts +71 -0
  110. package/src/codex/sibling-start.ts +139 -0
  111. package/src/codex/sync.ts +5 -3
  112. package/src/combos/failover.ts +32 -5
  113. package/src/combos/request.ts +11 -3
  114. package/src/combos/reset-window.ts +10 -5
  115. package/src/combos/resolve.ts +36 -6
  116. package/src/config/diagnostics.ts +2 -1
  117. package/src/config/load-degrade.ts +2 -0
  118. package/src/config/paths.ts +7 -1
  119. package/src/config/process-state.ts +10 -1
  120. package/src/config/schema/compaction-recovery.ts +15 -0
  121. package/src/config/schema/config-schema.ts +2 -0
  122. package/src/config/schema/leaf-validators.ts +9 -3
  123. package/src/generated/compatibility-version.json +360 -204
  124. package/src/images/loop.ts +47 -32
  125. package/src/integrations/catalog-refresh.ts +4 -0
  126. package/src/integrations/omp-yaml-source.ts +1 -0
  127. package/src/lib/claude-request-projection.ts +101 -0
  128. package/src/lib/local-account-switch-capability.ts +48 -0
  129. package/src/lib/local-upstream.ts +146 -0
  130. package/src/lib/package-tree-integrity.ts +25 -3
  131. package/src/lib/package-tree-retarget.ts +130 -0
  132. package/src/lib/process-control.ts +4 -1
  133. package/src/lib/system-restart-contract.ts +106 -0
  134. package/src/lib/upstream-retry.ts +57 -1
  135. package/src/link/ports.ts +9 -0
  136. package/src/link/ssh-argv.ts +16 -0
  137. package/src/link/ssh-runner.ts +70 -2
  138. package/src/link/tunnel-state.ts +58 -9
  139. package/src/oauth/account-quota-rank.ts +29 -18
  140. package/src/oauth/generic-account-failover.ts +179 -22
  141. package/src/oauth/index.ts +17 -4
  142. package/src/oauth/kiro-account-load.ts +107 -0
  143. package/src/oauth/kiro-device-login.ts +312 -0
  144. package/src/oauth/kiro-terminal-failover.ts +28 -0
  145. package/src/oauth/login-flow-state.ts +7 -1
  146. package/src/oauth/pool-settings-capability.ts +23 -14
  147. package/src/oauth/store.ts +56 -3
  148. package/src/oauth/types.ts +4 -0
  149. package/src/plugins/loader.ts +350 -0
  150. package/src/plugins/upstream-hooks.ts +119 -0
  151. package/src/protocols/encoders/adapter-events.ts +9 -2
  152. package/src/providers/account-quota-disk.ts +42 -4
  153. package/src/providers/codebuddy-models.ts +6 -2
  154. package/src/providers/command-code-efforts.ts +33 -10
  155. package/src/providers/kiro-account-state-disk.ts +44 -0
  156. package/src/providers/kiro-model-catalog.ts +162 -0
  157. package/src/providers/kiro-models.ts +5 -4
  158. package/src/providers/kiro-quota-metrics.ts +35 -0
  159. package/src/providers/kiro-usage.ts +118 -14
  160. package/src/providers/quota/account-cache.ts +43 -7
  161. package/src/providers/quota/antigravity.ts +8 -3
  162. package/src/providers/quota/kiro-account-probe.ts +12 -0
  163. package/src/providers/quota/vendor-probes-key.ts +31 -26
  164. package/src/providers/quota/vendor-probes-oauth.ts +16 -7
  165. package/src/providers/quota-types.ts +14 -0
  166. package/src/providers/quota.ts +21 -21
  167. package/src/providers/registry/entries-extended.ts +5 -3
  168. package/src/providers/request-pacing.ts +410 -12
  169. package/src/remote-control/workspace-codex-runtime.ts +4 -3
  170. package/src/responses/citation-markers.ts +132 -65
  171. package/src/responses/hosted-tool-policy.ts +14 -3
  172. package/src/responses/parser-content.ts +3 -2
  173. package/src/responses/parser.ts +4 -1
  174. package/src/responses/schema.ts +4 -0
  175. package/src/responses/visualization-directives.ts +182 -0
  176. package/src/server/chat-native-sse.ts +24 -12
  177. package/src/server/chat-native.ts +137 -68
  178. package/src/server/claude-messages.ts +147 -12
  179. package/src/server/index/link-listener.ts +7 -0
  180. package/src/server/index/optional-listeners.ts +4 -1
  181. package/src/server/index/package-tree-guard.ts +35 -16
  182. package/src/server/index/serve-options.ts +4 -2
  183. package/src/server/index/startup-warnings.ts +6 -0
  184. package/src/server/index.ts +7 -7
  185. package/src/server/inference/client-encoder-delivery.ts +3 -0
  186. package/src/server/inference/context.ts +30 -2
  187. package/src/server/local-account-switch-auth.ts +79 -0
  188. package/src/server/management/agent-settings-routes.ts +11 -3
  189. package/src/server/management/config-routes.ts +28 -11
  190. package/src/server/management/context.ts +2 -0
  191. package/src/server/management/link-routes.ts +123 -39
  192. package/src/server/management/logs-usage-routes.ts +9 -42
  193. package/src/server/management/model-rows.ts +1 -0
  194. package/src/server/management/oauth-account-routes.ts +67 -12
  195. package/src/server/management/route-registry.ts +5 -5
  196. package/src/server/management/sibling-guard.ts +60 -0
  197. package/src/server/management/storage-log-guard-routes.ts +72 -6
  198. package/src/server/management/system-restart.ts +92 -135
  199. package/src/server/management/system-routes.ts +1 -1
  200. package/src/server/management-api.ts +31 -3
  201. package/src/server/management-auth.ts +3 -0
  202. package/src/server/port-reclaim.ts +111 -1
  203. package/src/server/proxy-liveness.ts +16 -0
  204. package/src/server/relay.ts +16 -15
  205. package/src/server/request-log-filter.ts +28 -27
  206. package/src/server/request-log.ts +7 -3
  207. package/src/server/request-metrics.ts +28 -0
  208. package/src/server/responses/adapter-continuation.ts +151 -21
  209. package/src/server/responses/adapter-delivery.ts +21 -7
  210. package/src/server/responses/adapter-dispatch.ts +239 -51
  211. package/src/server/responses/codex-ws-pool.ts +5 -4
  212. package/src/server/responses/codex-ws-request.ts +4 -1
  213. package/src/server/responses/compact.ts +8 -4
  214. package/src/server/responses/compaction-recovery-policy.ts +116 -0
  215. package/src/server/responses/compaction-recovery.ts +330 -0
  216. package/src/server/responses/core-combo-failure.ts +1 -0
  217. package/src/server/responses/core-combo.ts +5 -1
  218. package/src/server/responses/core-lifetime.ts +22 -0
  219. package/src/server/responses/core-options.ts +16 -1
  220. package/src/server/responses/core.ts +14 -4
  221. package/src/server/responses/empty-completion-guard.ts +2 -0
  222. package/src/server/responses/fetch-helpers.ts +82 -31
  223. package/src/server/responses/passthrough-delivery.ts +3 -2
  224. package/src/server/responses/passthrough-dispatch.ts +12 -2
  225. package/src/server/responses/passthrough-execution.ts +2 -2
  226. package/src/server/responses/request-send-budget.ts +21 -5
  227. package/src/server/responses/request-transport.ts +116 -18
  228. package/src/server/responses/reset-replay.ts +32 -0
  229. package/src/server/responses/run-turn-execution.ts +136 -19
  230. package/src/server/responses/sidecar-execution.ts +41 -8
  231. package/src/server/responses/terminal-guard.ts +2 -0
  232. package/src/server/responses/ws-upstream.ts +44 -4
  233. package/src/server/restart-replacement.ts +382 -0
  234. package/src/server/startup-health-cache.ts +23 -4
  235. package/src/server/stop-teardown.ts +35 -1
  236. package/src/server/system-env.ts +3 -0
  237. package/src/service/windows-taskxml.ts +11 -7
  238. package/src/service/windows-wrapper-exit.ts +8 -0
  239. package/src/stall-timeout.ts +35 -10
  240. package/src/storage/policy-job.ts +4 -0
  241. package/src/storage/scanner.ts +177 -29
  242. package/src/storage/storage-mutation-coordinator.ts +17 -0
  243. package/src/tray/windows-tray.ps1 +50 -23
  244. package/src/types/config.ts +4 -0
  245. package/src/types/provider.ts +5 -1
  246. package/src/types/request.ts +13 -2
  247. package/src/update/index.ts +3 -1
  248. package/src/update/job.ts +6 -2
  249. package/src/update/mise-launcher-target.ts +100 -0
  250. package/src/update/worker-launch.ts +46 -0
  251. package/src/usage/log.ts +2 -0
  252. package/src/web-search/loop.ts +5 -2
  253. package/gui/dist/assets/App-BJsT8Icc.css +0 -1
  254. package/gui/dist/assets/App-D3pNGiN4.js +0 -51
  255. package/gui/dist/assets/Tray-yb05wCjb.js +0 -1
  256. package/gui/dist/assets/index-CouvvtMV.js +0 -86
  257. package/gui/dist/assets/tray-data-CYGdjCJ7.js +0 -1
package/src/cli/index.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  #!/usr/bin/env bun
2
+ import { serviceStayOutExitCode } from "../service/windows-wrapper-exit";
2
3
  import { spawn } from "node:child_process";
3
4
  import { homedir } from "node:os";
4
5
  import { join } from "node:path";
@@ -83,10 +84,12 @@ import { requestBoundSystemRestart } from "./system-restart-client";
83
84
  import { installCrashGuards } from "../lib/crash-guard";
84
85
  import { SpendLedgerOwnerError } from "../lib/spend-ledger-owner";
85
86
  import { redactUrlForLog } from "../lib/redact";
86
- import { dispatchCommand, decideBusyPreferredPort, decideStartWithLiveOwner } from "./dispatch";
87
+ import { dispatchCommand, decideBusyPreferredPort, decideStartExitTeardown, decideStartWithLiveOwner, startupLeftCodexNativeLine } from "./dispatch";
88
+ import { probeOwnerPastRestartParent, takeRestartHandoffMarkers } from "./restart-handoff";
87
89
  import { AuxiliaryListenerBindError, findAvailablePort, isAddrInUse, PortUnavailableError, shouldPersistSelectedPort, waitForPortAvailable } from "../server/ports";
88
90
  import {
89
91
  findLiveProxy,
92
+ proveLiveProxyOwnedByHome,
90
93
  probeEndpointLiveness,
91
94
  probeHostname,
92
95
  probePortOwner,
@@ -127,13 +130,9 @@ import {
127
130
  StartOwnershipRollbackUncertainError,
128
131
  } from "./start-ownership-publication";
129
132
  import { syncModelsToCodex } from "../codex/sync";
130
- import {
131
- HUB_GATED_SKIP_MESSAGE,
132
- localClientSkipReason,
133
- shouldSyncGrokOnStart,
134
- syncCodexOnStartIfEnabled,
135
- type LocalClientSkipReason,
136
- } from "../codex/desired-state";
133
+ import { localClientSkipReason, shouldSyncGrokOnStart, syncCodexOnStartIfEnabled } from "../codex/desired-state";
134
+ import { honorSiblingMarker, markSiblingStart, siblingOfLivePort, siblingRuntimeField, siblingStopFoundOwner, withoutSiblingMarker } from "../codex/sibling-start";
135
+ import { consumeSiblingHandoff } from "../codex/sibling-handoff";
137
136
  import {
138
137
  reconcileClientStartupBeforeReady,
139
138
  syncClaudeAgentDefsAtProxyStartup,
@@ -232,19 +231,6 @@ async function waitForProxy(timeoutMs = 8_000): Promise<LiveProxy | null> {
232
231
  return null;
233
232
  }
234
233
 
235
- /**
236
- * The one startup line for "nothing was written to Codex".
237
- *
238
- * Two very different facts reached it: the user's own OFF switch, and a hub declining to
239
- * rewrite its own local clients. Printing the toggle's wording for the gate is what made
240
- * operators hunt for a switch they never set (#4236).
241
- */
242
- function startupLeftCodexNativeLine(reason: LocalClientSkipReason): string {
243
- return reason === "hub-gated"
244
- ? ` ${HUB_GATED_SKIP_MESSAGE} Startup left Codex native.`
245
- : " Codex integration OFF; startup left Codex native.";
246
- }
247
-
248
234
  /** Argv for detached `start`, optionally hard-pinning the listen port. */
249
235
  function startArgv(port?: number): string[] {
250
236
  const args = ["start"];
@@ -330,10 +316,9 @@ async function chooseListenPort(
330
316
  ocxService: process.env.OCX_SERVICE,
331
317
  });
332
318
  if (decision === "service-stay-out") {
333
- // Same contract as the pre-bind owner check: the wrapper's retry loop terminates
334
- // on a zero exit, and the port it was asked to serve is already served.
319
+ // Signal intentional stay-out to wrappers that support the exit protocol.
335
320
  console.log(`Proxy already running (PID ${holder?.pid ?? "unknown"}, port ${preferred}); service wrapper staying out of the way.`);
336
- throw new StartCommandExit(0);
321
+ throw new StartCommandExit(serviceStayOutExitCode());
337
322
  }
338
323
  if (decision === "refuse-live-proxy") {
339
324
  console.error(`⚠️ Proxy already running (PID ${holder?.pid ?? "unknown"}, port ${preferred}). Use 'ocx stop' first.`);
@@ -381,7 +366,8 @@ async function findProxyOwnerBeforeJournalRecovery(
381
366
  // The probe established that the snapshotted owner is stale. Compare before
382
367
  // deleting so a concurrent start that rewrote the PID file keeps its state.
383
368
  removePidIfValueIs(pidSnapshot);
384
- if (!currentExternalCodexModelProvider()) {
369
+ // A marked sibling's owner can be down mid-restart; its journal is still not ours to replay.
370
+ if (!currentExternalCodexModelProvider() && siblingOfLivePort() === null) {
385
371
  const clientState = readClientConnectionState();
386
372
  reconcileJournal(clientState.kind === "connected"
387
373
  ? { activeClientApiKeyId: clientState.value.apiKeyId }
@@ -427,42 +413,36 @@ async function handleStart(options: { block?: boolean } = {}) {
427
413
  }
428
414
  }
429
415
  const requestedPort = startOpts.port;
430
- // Always probe the configured port, even when both state files are absent. A
431
- // fallback-port sibling overwrites the pid/runtime records when it starts and
432
- // removes them on its own shutdown, so their absence proves nothing about the
433
- // configured port. Without the probe, `start` shadowed a healthy proxy with an
434
- // ephemeral-port copy and re-pointed client config at the copy; the next sibling
435
- // shutdown then left no runtime record for discovery at all. `handleEnsure`
436
- // already passes this; `handleStart` is the path that did not.
437
- const owner = await findProxyOwnerBeforeJournalRecovery({ probeConfiguredPort: true });
438
- let siblingStart = false;
416
+ // Probe the configured port even without state files: a fallback sibling can remove them,
417
+ // and an unprobed start could shadow the owner and reroute Codex to a short-lived port.
418
+ // Consume a sibling replacement's handoff before probing, even if its owner is momentarily down.
419
+ let siblingStart = honorSiblingMarker(process.env, consumeSiblingHandoff) !== null;
420
+ // A restart replacement waits out its draining parent instead of refusing it, and bounds its handoff log (restart-handoff.ts).
421
+ const restartParent = { restartParentPid: takeRestartHandoffMarkers(process.env), requestedPort, ocxService: process.env.OCX_SERVICE };
422
+ const owner = await probeOwnerPastRestartParent(() => findProxyOwnerBeforeJournalRecovery({ probeConfiguredPort: true }), restartParent);
439
423
  if (owner.live) {
440
424
  // Rationale and the full decision table live on `decideStartWithLiveOwner`.
441
425
  const decision = decideStartWithLiveOwner({
442
426
  livePort: owner.live.port,
443
- requestedPort,
444
- ocxService: process.env.OCX_SERVICE,
427
+ livePid: owner.live.pid, ...restartParent,
445
428
  });
446
429
  if (decision === "service-stay-out") {
447
- // Service-wrapper context (opencodex-service.cmd `:loop`): a healthy proxy from
448
- // ANY source means the requested port is already served. Exit 0 so the wrapper's
449
- // `if %ERRORLEVEL% NEQ 0` retry loop terminates instead of respawning every 5s
450
- // against a listener it can never claim (observed as an endless
451
- // "Proxy already running" service.log loop).
430
+ // A live owner is an intentional stay-out, not an unexpected child exit.
452
431
  console.log(`Proxy already running (PID ${owner.live.pid ?? owner.pidSnapshot ?? "unknown"}, port ${owner.live.port}); service wrapper staying out of the way.`);
453
- process.exit(0);
432
+ process.exit(serviceStayOutExitCode());
454
433
  }
455
- if (decision === "refuse") {
434
+ if (decision === "refuse" || decision === "await-parent") {
456
435
  console.error(`⚠️ Proxy already running (PID ${owner.live.pid ?? owner.pidSnapshot ?? "unknown"}, port ${owner.live.port}). Use 'ocx stop' first.`);
457
436
  process.exit(1);
458
437
  }
459
- // Sibling path. Honest about the side effects it shares with any start in this home:
460
- // the new instance takes over this home's ocx.pid / runtime-port.json while it runs,
461
- // and re-points this home's Codex config at the new port when injection applies.
462
- // What it must NOT do is persist its port into config.port: the configured-port
463
- // proxy is still the owner of this home, and a later `ocx service` reads config.port
464
- // to bake the service (observed: a probe on 10198 left the service pinned there).
438
+ // Sibling path. The new instance takes over this home's ocx.pid / runtime-port.json while
439
+ // it runs, and nothing else: Codex, Grok, Claude and the system env keep pointing at the
440
+ // live proxy for this process's whole lifetime, its exit and `ocx stop` included, so the
441
+ // mark goes down before any client write (`src/codex/sibling-start.ts`). Nor may it persist
442
+ // its port into config.port: the configured-port proxy still owns this home, and a later
443
+ // `ocx service` reads config.port to bake the service (a probe on 10198 pinned it there).
465
444
  siblingStart = true;
445
+ markSiblingStart(owner.live.port);
466
446
  console.warn(
467
447
  `Proxy already running on port ${owner.live.port}; requested a second instance on port ${requestedPort}. `
468
448
  + `Startup continues only for an independent OPENCODEX_HOME; one state directory has one spend-ledger writer.`,
@@ -485,8 +465,9 @@ async function handleStart(options: { block?: boolean } = {}) {
485
465
 
486
466
  // Interactive-only update prompt. Must run BEFORE we bind a port / write a
487
467
  // PID: choosing "Update now" installs globally and exits, so we never want a
488
- // live daemon holding resources while it overwrites its own binary.
489
- await maybeShowUpdatePrompt();
468
+ // live daemon holding resources while it overwrites its own binary. Never from a
469
+ // sibling: replacing the global package makes the live proxy drain and restart.
470
+ if (!siblingStart) await maybeShowUpdatePrompt();
490
471
 
491
472
  type StartServerModule = typeof import("../server");
492
473
  type BoundStart = {
@@ -508,18 +489,20 @@ async function handleStart(options: { block?: boolean } = {}) {
508
489
  if (fencedLive) {
509
490
  const decision = decideStartWithLiveOwner({
510
491
  livePort: fencedLive.port,
492
+ livePid: fencedLive.pid, restartParentPid: restartParent.restartParentPid,
511
493
  requestedPort,
512
494
  ocxService: process.env.OCX_SERVICE,
513
495
  });
514
496
  if (decision === "service-stay-out") {
515
497
  console.log(`Proxy already running (PID ${fencedLive.pid ?? "unknown"}, port ${fencedLive.port}); service wrapper staying out of the way.`);
516
- throw new StartCommandExit(0);
498
+ throw new StartCommandExit(serviceStayOutExitCode());
517
499
  }
518
- if (decision === "refuse") {
500
+ if (decision === "refuse" || decision === "await-parent") {
519
501
  console.error(`⚠️ Proxy appeared before bind (PID ${fencedLive.pid ?? "unknown"}, port ${fencedLive.port}). Use 'ocx stop' first.`);
520
502
  throw new StartCommandExit(1);
521
503
  }
522
504
  siblingStart = true;
505
+ markSiblingStart(fencedLive.port);
523
506
  }
524
507
 
525
508
  // Port selection is check-then-bind. The lease prevents every cooperating start or
@@ -529,6 +512,7 @@ async function handleStart(options: { block?: boolean } = {}) {
529
512
  const readinessGate = createReadinessGate();
530
513
  const localAttestationSecret = createLocalAttestationSecret();
531
514
  const config = loadConfig();
515
+ await (await import("../plugins/loader")).loadAndReportOcxPlugins();
532
516
  let server: ReturnType<typeof serverModule.startServer>;
533
517
  for (let attempt = 0; ; attempt++) {
534
518
  try {
@@ -566,6 +550,7 @@ async function handleStart(options: { block?: boolean } = {}) {
566
550
  port: bound.port,
567
551
  hostname: bound.config.hostname,
568
552
  attestationSecret: bound.localAttestationSecret,
553
+ ...siblingRuntimeField(),
569
554
  }),
570
555
  stopBound: bound => bound.server.stop(true),
571
556
  removeRuntime: () => removeRuntimePortIfPidIs(process.pid),
@@ -592,24 +577,25 @@ async function handleStart(options: { block?: boolean } = {}) {
592
577
  // background — the first `ocx start` after an update usually races the Codex app's DB lock.
593
578
  // Loopback-only (legacy mode still forward-tags) and respects syncResumeHistory opt-out.
594
579
  let historyGuardian: ReturnType<typeof startHistoryMigrationGuardian> | undefined;
580
+ let routingHealer: { stop(): void } | undefined; // routing-healer.ts; stopped first in syncCleanup
595
581
 
596
582
  let cleaned = false;
597
583
  let cleanupSucceeded = true;
598
584
  const syncCleanup = () => {
599
585
  if (cleaned) return cleanupSucceeded;
600
586
  cleaned = true;
587
+ try { routingHealer?.stop(); } catch { /* best-effort */ }
601
588
  try { guardian.stop(); } catch { /* best-effort */ }
602
589
  try { historyGuardian?.stop(); } catch { /* best-effort */ }
603
590
  // Dashboard drain-and-restart (#563) must not tear down injection: the replacement
604
- // process expects Codex/Grok/env fences to still be in place.
605
- const recycling = isRecyclingForExit();
606
- if (!recycling) {
591
+ // process expects Codex/Grok/env fences to still be in place. A sibling owns none of them.
592
+ const teardown = decideStartExitTeardown({ sibling: siblingStart, recycling: isRecyclingForExit(), ocxService: process.env.OCX_SERVICE });
593
+ if (teardown.revertSystemEnv) {
607
594
  try { revertSystemEnv(); } catch { /* best-effort */ }
608
595
  }
609
596
  removePid(process.pid);
610
597
  removeRuntimePort(process.pid);
611
- const preserveRouting = process.env.OCX_SERVICE === "1";
612
- if (!recycling && !preserveRouting && !currentExternalCodexModelProvider()) {
598
+ if (teardown.restoreNativeCodex && !currentExternalCodexModelProvider()) {
613
599
  try {
614
600
  const restored = restoreNativeCodex();
615
601
  if (!restored.success) {
@@ -625,7 +611,7 @@ async function handleStart(options: { block?: boolean } = {}) {
625
611
  // Grok fence is shared state we must not remove — that service keeps running and would be
626
612
  // left pointing nowhere. This guard also covers signal-driven exits, which is the path that
627
613
  // would otherwise bypass handleStop's gate entirely.
628
- if (!recycling && !preserveRouting && serviceEnvironmentOwnedHere()) {
614
+ if (teardown.stripGrokConfig && serviceEnvironmentOwnedHere()) {
629
615
  try { stripGrokConfig(); } catch { /* best-effort restore */ }
630
616
  }
631
617
  return cleanupSucceeded;
@@ -672,8 +658,9 @@ async function handleStart(options: { block?: boolean } = {}) {
672
658
  // syncCleanup reverts even if injection itself or subsequent startup steps fail).
673
659
  const systemEnv = await injectSystemEnv(port, config).catch(() => ({ injected: false }));
674
660
  // The hook is useful only for an installed Claude Code CLI. Reconcile instead of
675
- // appending unconditionally so stale OpenCodex-owned hooks are removed as well.
676
- reportShellHookFailure(reconcileShellHook(systemEnv.injected));
661
+ // appending unconditionally so stale OpenCodex-owned hooks are removed as well. A sibling
662
+ // skips it: reconciling to "not injected" would uninstall the live owner's ~/.zshrc hook.
663
+ if (!siblingStart) reportShellHookFailure(reconcileShellHook(systemEnv.injected));
677
664
  await maybeShowStarPrompt(); // once-only Yes/No GitHub-star prompt on first interactive start
678
665
  // Codex sync owns the ready/failed verdict, but its successful transition is
679
666
  // deferred until the best-effort Claude roster and Desktop registry settle. This
@@ -709,7 +696,7 @@ async function handleStart(options: { block?: boolean } = {}) {
709
696
  }
710
697
  },
711
698
  );
712
- if (!startupSync.ran) console.log(startupLeftCodexNativeLine(localClientSkipReason(config)));
699
+ if (!startupSync.ran) console.log(startupLeftCodexNativeLine(localClientSkipReason(config), server.port ?? port));
713
700
  await refreshOwnedRaycastCatalog(config, port);
714
701
  // #1046: one warning per startup, after BOTH writes. The server's cache
715
702
  // invalidation happens first and the catalog sync second, so the mtime is only
@@ -720,9 +707,11 @@ async function handleStart(options: { block?: boolean } = {}) {
720
707
  const { warnIfStaleCodexAppServersAfterStartupWrite } = await import("../codex/app-server-processes");
721
708
  warnIfStaleCodexAppServersAfterStartupWrite({ log: console });
722
709
  }
723
- if (!currentExternalCodexModelProvider() && !shouldInjectApiAuthHeader(config) && config.syncResumeHistory !== false) {
710
+ if (!siblingStart && !currentExternalCodexModelProvider() && !shouldInjectApiAuthHeader(config) && config.syncResumeHistory !== false) {
724
711
  historyGuardian = startHistoryMigrationGuardian();
725
712
  }
713
+ const routingHealerModule = siblingStart ? null : await import("../codex/routing-healer");
714
+ if (routingHealerModule && !cleaned) routingHealer = routingHealerModule.startCodexRoutingHealer({ port, config }); // `cleaned` read once the import settled
726
715
  // Grok Build auto-registration: additive fenced block in ~/.grok/config.toml so an installed
727
716
  // grok CLI can pick opencodex-routed models without manual config. No-op when ~/.grok is
728
717
  // absent or the bind is non-loopback; removed again by stop/eject/uninstall/shutdown.
@@ -751,9 +740,9 @@ async function handleStart(options: { block?: boolean } = {}) {
751
740
  }
752
741
 
753
742
  function detachedStartEnvironment(): NodeJS.ProcessEnv {
754
- const env: NodeJS.ProcessEnv = { ...process.env };
755
- // Only a real service wrapper may claim supervision. A detached ensure/tray child
756
- // is an ordinary owner: while live it maintains routing, and on exit it restores it.
743
+ const env: NodeJS.ProcessEnv = withoutSiblingMarker(process.env);
744
+ // Only a real service wrapper may claim supervision. A detached ensure/tray child is an
745
+ // ordinary owner, never a sibling: while live it maintains routing, and on exit restores it.
757
746
  delete env.OCX_SERVICE;
758
747
  return withProcessRuntimeProvenance(env);
759
748
  }
@@ -1100,6 +1089,11 @@ async function handleStopUnlocked(snapshot?: GuardedStopSnapshot) {
1100
1089
  // service — the exact failure this flag prevents. A plain stop failure is different: we
1101
1090
  // tried, so local teardown still proceeds.
1102
1091
  let ownershipBlocked = false;
1092
+ // A sibling instance's shared teardown is not this stop's to run: the client routing belongs
1093
+ // to the live proxy it ran beside (`src/codex/sibling-start.ts`). Read before any stop removes
1094
+ // the record; a record left behind by a hard-killed sibling still says so.
1095
+ const siblingOfPort = readRuntimePort()?.siblingOfPort;
1096
+ const stoppingSibling = siblingOfPort !== undefined;
1103
1097
  // Structured twin of the human lines below, for `ocx stop --json`: one document the
1104
1098
  // desktop shell can read across the process boundary (D4). Every field is assigned
1105
1099
  // where the corresponding boolean already flips — the summarizer never re-decides.
@@ -1137,7 +1131,7 @@ async function handleStopUnlocked(snapshot?: GuardedStopSnapshot) {
1137
1131
  .filter(read => isPendingTeardownAbandoned(read, teardownOwnerStillRunning));
1138
1132
  let teardownNonce: string | undefined;
1139
1133
  const claimTeardown = (endpoint: { hostname: string; port: number }, endpointSource: "exact" | "guessed") => {
1140
- if (teardownNonce) return;
1134
+ if (teardownNonce || stoppingSibling) return;
1141
1135
  try {
1142
1136
  teardownNonce = claimPendingTeardown(endpoint, endpointSource).nonce;
1143
1137
  } catch (err) {
@@ -1155,8 +1149,8 @@ async function handleStopUnlocked(snapshot?: GuardedStopSnapshot) {
1155
1149
  * silently reopening the parent-crash window on the path where the stop is a hard kill
1156
1150
  * and no child teardown runs at all.
1157
1151
  *
1158
- * So the caller supplies whatever endpoint it already discovered: the orphan path knows
1159
- * one from `findLiveProxy` even when the runtime record is gone.
1152
+ * So the caller supplies the endpoint it discovered after the orphan listener proves
1153
+ * possession of this home's runtime record, even when the pid file is gone.
1160
1154
  *
1161
1155
  * When nothing resolves, the graceful request cannot be made at all — `stopProxy` goes
1162
1156
  * straight to the kill ladder, no child teardown runs, and there is no receipt to leave
@@ -1201,7 +1195,9 @@ async function handleStopUnlocked(snapshot?: GuardedStopSnapshot) {
1201
1195
  settle: () => settleApprovedTarget(snapshot.approval),
1202
1196
  managerState: () => observeGuardedManagerStopped(snapshot.manager),
1203
1197
  })).service
1204
- : stopServiceIfInstalledDetailed();
1198
+ // A sibling never runs under a service manager: an installed one belongs to the live owner,
1199
+ // and its ownership check would fail this stop and skip the stale-record purge below.
1200
+ : stoppingSibling ? "absent" : stopServiceIfInstalledDetailed();
1205
1201
  record.service = serviceStop;
1206
1202
  stoppedService = serviceStop === "stopped" || serviceStop === "stopped-respawnable";
1207
1203
  schedulerCanRespawn = serviceStop === "stopped-respawnable";
@@ -1288,14 +1284,22 @@ async function handleStopUnlocked(snapshot?: GuardedStopSnapshot) {
1288
1284
  }
1289
1285
  }
1290
1286
  } else {
1291
- // Snapshot the stale on-disk state BEFORE the async probe: a concurrent `ocx start`
1292
- // can write fresh records mid-probe, and the purge below must never delete those.
1287
+ // Snapshot stale state before probing; purge only exact values if another start races us.
1293
1288
  const stalePidValue = readPidFileValue();
1294
1289
  const staleRuntimePid = readRuntimePort()?.pid ?? null;
1295
1290
  // Orphan recovery: a live proxy can outlive its pid file (crash, manual delete,
1296
1291
  // corrupt file). Identity-checked liveness still finds it via the runtime record.
1297
1292
  const live = await findLiveProxy({ acceptPackageTreeFenced: true });
1298
- if (live?.pid) {
1293
+ if (siblingStopFoundOwner(siblingOfPort, live)) {
1294
+ record.proxy = "not-running";
1295
+ console.log(`The sibling instance is already gone; the proxy on port ${siblingOfPort} was left running.`);
1296
+ } else if (live?.pid && !(await proveLiveProxyOwnedByHome(live))) {
1297
+ stopFailed = true;
1298
+ ownershipBlocked = true;
1299
+ record.proxy = "ownership-refused";
1300
+ console.error(`❌ A proxy answers on port ${live.port}, but it has not proved it belongs to this home; refusing to stop it.`);
1301
+ console.error(" Skipping shared teardown (native Codex restore, Grok config) while that proxy is running.");
1302
+ } else if (live?.pid) {
1299
1303
  try {
1300
1304
  // The probe already found where it answers, and on this path the runtime record is
1301
1305
  // typically what went missing in the first place.
@@ -1343,9 +1347,9 @@ async function handleStopUnlocked(snapshot?: GuardedStopSnapshot) {
1343
1347
  }
1344
1348
  }
1345
1349
  }
1346
- // Environment ownership is independent from service ownership. Always roll back
1347
- // current-home variables; the helper refuses foreign markers on its own.
1348
- try { revertSystemEnv(); } catch { /* best-effort */ }
1350
+ // Environment ownership is independent from service ownership. Roll back current-home
1351
+ // variables (the helper refuses foreign markers on its own) unless a sibling never set them.
1352
+ if (!stoppingSibling) { try { revertSystemEnv(); } catch { /* best-effort */ } }
1349
1353
  // A stopped Windows scheduler is not a proven-down proxy. `killWindowsSchedulerWrappers`
1350
1354
  // is explicitly best-effort and the `:loop` wrapper respawns its child after ~5s, so an
1351
1355
  // immediate probe can see a dead interval and an update can start replacing files right
@@ -1432,8 +1436,9 @@ async function handleStopUnlocked(snapshot?: GuardedStopSnapshot) {
1432
1436
  console.error(" The obligation is preserved; retry once the proxy is confirmed stopped.");
1433
1437
  }
1434
1438
  }
1435
- if (nativeRestoreHandledByProxy) record.sharedTeardown = "performed-by-proxy";
1436
- const restoreBlocked = ownershipBlocked || inheritedBlocks || nativeRestoreHandledByProxy;
1439
+ if (nativeRestoreHandledByProxy && !stoppingSibling) record.sharedTeardown = "performed-by-proxy";
1440
+ if (stoppingSibling) console.log(`↩️ Client routing stays on the proxy at port ${siblingOfPort}; this sibling had no shared teardown to run.`);
1441
+ const restoreBlocked = ownershipBlocked || inheritedBlocks || nativeRestoreHandledByProxy || stoppingSibling;
1437
1442
  if (!restoreBlocked) {
1438
1443
  if (recoveredNonces.length > 0) {
1439
1444
  // A previous deferred stop died before restoring, and the probe says its endpoint is
@@ -1974,7 +1979,7 @@ process.exit(await dispatchCommand(head, {
1974
1979
  detached: true,
1975
1980
  stdio: "ignore",
1976
1981
  windowsHide: true,
1977
- env: withProcessRuntimeProvenance(process.env),
1982
+ env: withProcessRuntimeProvenance(withoutSiblingMarker(process.env)),
1978
1983
  });
1979
1984
  child.unref();
1980
1985
  },
@@ -21,6 +21,7 @@ import { isLoopbackHostname } from "../server/auth-cors";
21
21
  import { findLiveProxy, probeHostname, type LiveProxy } from "../server/proxy-liveness";
22
22
  import type { OcxConfig } from "../types";
23
23
  import { opencodeProxyStartEnv } from "./opencode";
24
+ import { withoutSiblingMarker } from "../codex/sibling-start";
24
25
 
25
26
  export interface MinimaxLaunchEnv {
26
27
  [key: string]: string | undefined;
@@ -270,8 +271,9 @@ async function ensureProxy(config: OcxConfig): Promise<LiveProxy | null> {
270
271
  stdio: "ignore",
271
272
  windowsHide: true,
272
273
  // Reuse the established service-token lookup so a detached start works
273
- // when admission lives in the hardened token file rather than this shell.
274
- env: withProcessRuntimeProvenance(opencodeProxyStartEnv(process.env) as NodeJS.ProcessEnv),
274
+ // when admission lives in the hardened token file rather than this shell. An ordinary
275
+ // owner, so a stray sibling marker is dropped.
276
+ env: withProcessRuntimeProvenance(opencodeProxyStartEnv(withoutSiblingMarker(process.env)) as NodeJS.ProcessEnv),
275
277
  });
276
278
  child.on("error", () => { /* the bounded health poll reports failure */ });
277
279
  child.unref();
@@ -50,6 +50,7 @@ import { findLiveProxy, probeHostname, type LiveProxy } from "../server/proxy-li
50
50
  import type { OcxConfig } from "../types";
51
51
  import { withProcessRuntimeProvenance } from "../lib/bun-runtime";
52
52
  import { selfLaunchArgv } from "../lib/self-launch-argv";
53
+ import { withoutSiblingMarker } from "../codex/sibling-start";
53
54
 
54
55
  /**
55
56
  * The provider-block serializer, its constants, and the config-path helpers now live in
@@ -101,6 +102,8 @@ export interface OpencodeProxyModelRow {
101
102
  displayName?: string;
102
103
  displayNameSource?: "operator" | "provider" | "fallback";
103
104
  contextWindow?: number;
105
+ /** Authoritative output limit (CatalogModel.maxOutputTokens); optional. */
106
+ maxOutputTokens?: number;
104
107
  /** Declared input modalities from `/api/models`; carried into opencode model capabilities. */
105
108
  inputModalities?: string[];
106
109
  /** Declared effort ladder from `/api/models`; carried into opencode model variants. */
@@ -427,6 +430,7 @@ export function opencodeCatalogFromProxyRows(
427
430
  provider: row.provider,
428
431
  id: row.id,
429
432
  contextWindow: row.contextWindow,
433
+ ...(typeof row.maxOutputTokens === "number" ? { maxTokens: row.maxOutputTokens } : {}),
430
434
  displayName: row.displayNameSource === "fallback" ? undefined : row.displayName,
431
435
  ...(Array.isArray(row.inputModalities) && row.inputModalities.length > 0
432
436
  ? { inputModalities: [...row.inputModalities] }
@@ -641,7 +645,8 @@ async function ensureProxyForOpencode(config: OcxConfig): Promise<LiveProxy | nu
641
645
  detached: true,
642
646
  stdio: "ignore",
643
647
  windowsHide: true,
644
- env: withProcessRuntimeProvenance(opencodeProxyStartEnv(process.env) as NodeJS.ProcessEnv),
648
+ // An ordinary owner: a stray sibling marker would otherwise mark it before any probe.
649
+ env: withProcessRuntimeProvenance(opencodeProxyStartEnv(withoutSiblingMarker(process.env)) as NodeJS.ProcessEnv),
645
650
  });
646
651
  // Without a listener an 'error' (bad argv[1], EMFILE, AV denial) throws synchronously
647
652
  // and kills this process; the health poll below already reports the failure properly.
@@ -0,0 +1,110 @@
1
+ /**
2
+ * The replacement side of a restart handoff: a start that finds its own draining parent.
3
+ *
4
+ * A dashboard drain-and-restart, a join into a Child and the standalone recycle all spawn a
5
+ * detached `ocx start` and hand it `OCX_RESTART_PARENT_PID` (`src/server/restart-replacement.ts`).
6
+ * The deadline and listener-stop-fallback handoffs spawn before the old listener is certainly
7
+ * gone, so the replacement's owner probe can still reach the parent. Refusing that "proxy already
8
+ * running" left no proxy at all a moment later, when the parent exited.
9
+ *
10
+ * So the owner probe runs through {@link probeOwnerPastRestartParent}: while the live owner is
11
+ * exactly the restart parent (`decideStartWithLiveOwner` answers `"await-parent"`), it waits for
12
+ * that process to exit, re-probing once a second in case the parent stops answering first, and
13
+ * then hands back a fresh probe. The wait is bounded; a parent that outlives it is refused like
14
+ * any other live proxy. Nothing here signals the parent, and the wait costs nothing on an ordinary
15
+ * start, which carries no marker.
16
+ *
17
+ * {@link takeRestartHandoffMarkers} also consumes the handoff-log flag: a replacement whose output
18
+ * its parent sent to `restart-handoff.log` bounds that file itself from then on
19
+ * (`armRestartHandoffLogCap` in `src/server/restart-replacement.ts`). It consumes the desktop app's
20
+ * supervision marker too, so a later restart exits to the app instead of spawning past it.
21
+ */
22
+ import { isProcessAlive } from "../lib/process-control";
23
+ import { takeDesktopSupervisedMarker, takeRestartParentMarker } from "../lib/system-restart-contract";
24
+ import { armRestartHandoffLogCap, type RestartHandoffLogCapIo } from "../server/restart-replacement";
25
+ import { decideStartWithLiveOwner } from "./dispatch";
26
+
27
+ export { takeRestartParentMarker };
28
+
29
+ /**
30
+ * Consume everything a parent handed this start, before its first probe: arm the handoff log's cap
31
+ * when the flag is set, record the desktop app's supervision, and return the restart-parent pid,
32
+ * honored only for this process's real parent. Every marker is removed from `env`, so no later child
33
+ * inherits one.
34
+ */
35
+ export function takeRestartHandoffMarkers(
36
+ env: Record<string, string | undefined>,
37
+ logCap: RestartHandoffLogCapIo = {},
38
+ ): number | null {
39
+ armRestartHandoffLogCap(env, logCap);
40
+ takeDesktopSupervisedMarker(env);
41
+ return takeRestartParentMarker(env);
42
+ }
43
+
44
+ /** How long a replacement waits for its own draining parent before refusing it. */
45
+ export const RESTART_PARENT_EXIT_TIMEOUT_MS = 30_000;
46
+ const PARENT_EXIT_POLL_MS = 100;
47
+ const OWNER_REPROBE_MS = 1_000;
48
+
49
+ export interface OwnerProbeResult {
50
+ live: { pid: number | null; port: number } | null;
51
+ }
52
+
53
+ export interface RestartParentOwnerInput {
54
+ restartParentPid: number | null;
55
+ requestedPort: number | undefined;
56
+ ocxService: string | undefined;
57
+ }
58
+
59
+ export interface RestartParentWaitIo {
60
+ isAlive?: (pid: number) => boolean;
61
+ now?: () => number;
62
+ sleep?: (ms: number) => Promise<void>;
63
+ timeoutMs?: number;
64
+ log?: (line: string) => void;
65
+ }
66
+
67
+ function isRestartParent(owner: OwnerProbeResult, input: RestartParentOwnerInput): boolean {
68
+ if (owner.live === null) return false;
69
+ return decideStartWithLiveOwner({
70
+ livePort: owner.live.port,
71
+ livePid: owner.live.pid,
72
+ ...input,
73
+ }) === "await-parent";
74
+ }
75
+
76
+ /**
77
+ * Run the start's owner probe, waiting out this process's own restart parent when that is the
78
+ * owner it found. Returns the last probe result: after the parent exited or stopped answering,
79
+ * a fresh one; after the timeout, the one that still names the parent, which the caller refuses.
80
+ */
81
+ export async function probeOwnerPastRestartParent<T extends OwnerProbeResult>(
82
+ probe: () => Promise<T>,
83
+ input: RestartParentOwnerInput,
84
+ io: RestartParentWaitIo = {},
85
+ ): Promise<T> {
86
+ let owner = await probe();
87
+ if (input.restartParentPid === null || !isRestartParent(owner, input)) return owner;
88
+ const parentPid = input.restartParentPid;
89
+ const isAlive = io.isAlive ?? isProcessAlive;
90
+ const now = io.now ?? Date.now;
91
+ const sleep = io.sleep ?? Bun.sleep;
92
+ const log = io.log ?? ((line: string) => console.log(line));
93
+ const timeoutMs = io.timeoutMs ?? RESTART_PARENT_EXIT_TIMEOUT_MS;
94
+ log(`Waiting for the restarting OpenCodex process (PID ${parentPid}) to hand over its port...`);
95
+ const deadline = now() + timeoutMs;
96
+ let reprobeAt = now() + OWNER_REPROBE_MS;
97
+ while (now() < deadline) {
98
+ if (!isAlive(parentPid)) return probe();
99
+ if (now() >= reprobeAt) {
100
+ owner = await probe();
101
+ if (!isRestartParent(owner, input)) return owner;
102
+ reprobeAt = now() + OWNER_REPROBE_MS;
103
+ }
104
+ const remainingMs = deadline - now();
105
+ if (remainingMs <= 0) break;
106
+ await sleep(Math.min(PARENT_EXIT_POLL_MS, remainingMs));
107
+ }
108
+ log(`The restarting OpenCodex process (PID ${parentPid}) did not exit within ${Math.round(timeoutMs / 1000)}s.`);
109
+ return owner;
110
+ }
package/src/cli/status.ts CHANGED
@@ -18,6 +18,8 @@ import { redactSecretString, redactUserPath } from "../lib/redact";
18
18
  import { collectOrcaCodexHomeDiagnostic, type OrcaCodexHomeDiagnostic } from "../codex/home";
19
19
  import { grokFenceEndpointDrift, readGrokStatus } from "../grok/status";
20
20
  import { effectiveLoopbackListenerPort } from "../codex/loopback-target";
21
+ import { journaledInjectedOpenaiBaseUrl, journaledInjectedRealtimeWsBaseUrl } from "../codex/journal";
22
+ import { detectCodexRoutingDrift, type JournaledCodexRouting } from "../codex/routing-drift";
21
23
  import { claudeDesktopIntegrationEnabled } from "../codex/desired-state";
22
24
  import { claudeDesktopPolicyHealth, probeClaudeDesktopPolicy, type ClaudeDesktopPolicyHealth } from "../claude/desktop-policy";
23
25
  import { collectClientConnectionStatus, type ClientConnectionStatus } from "./connect";
@@ -547,6 +549,36 @@ export function detectMissingCodexCatalogPath(): string | null {
547
549
  }
548
550
  }
549
551
 
552
+ /**
553
+ * Owned Codex routing that names a local port the live proxy does not serve.
554
+ *
555
+ * `classifyCodexRouting` calls that "opencodex-local" whatever the port, so without this line the
556
+ * state behind a killed second instance was invisible here. Detection is `routing-drift.ts`, the
557
+ * same rule the owner's healer uses; this only reports and reads the journal without side effects.
558
+ * A sibling's report says nothing: its routing correctly names the owner it runs beside. Whether a
559
+ * healer runs, or is gated or paused, is not visible from here, so the line promises only `ocx sync`.
560
+ */
561
+ export function codexRoutingDriftWarning(input: {
562
+ content: string | null;
563
+ livePort: number | undefined;
564
+ loopbackPort: number | null;
565
+ /** `siblingOfPort` from this home's runtime record. */
566
+ siblingOfPort: number | undefined;
567
+ journaled: () => JournaledCodexRouting;
568
+ }): string | null {
569
+ const { content, livePort, loopbackPort } = input;
570
+ if (content === null || livePort === undefined || input.siblingOfPort !== undefined) return null;
571
+ const drift = detectCodexRoutingDrift(content, {
572
+ ownPorts: loopbackPort === null ? [livePort] : [livePort, loopbackPort],
573
+ journaled: input.journaled,
574
+ });
575
+ if (drift.kind !== "foreign") return null;
576
+ const ports = [...new Set(drift.targets.map(target => target.port))].join(", ");
577
+ return `Codex routing points at port ${ports}, but the proxy is on ${livePort}; if nothing serves that port, `
578
+ + "new Codex threads fail with \"Connection refused\". Run 'ocx sync' to re-point it now; a running "
579
+ + "owner proxy may also do that on its own once the port stays dead.";
580
+ }
581
+
550
582
  /**
551
583
  * The one state in this report where Codex is broken independently of the proxy (#5261).
552
584
  *
@@ -702,6 +734,25 @@ export async function collectStatus(): Promise<CliStatusView> {
702
734
  + `${grokDrift.livePort}; grok turns will retry against a closed port. Run 'ocx ensure' to repoint it.`,
703
735
  );
704
736
  }
737
+ const codexDrift = (() => {
738
+ if (!health.ok) return null;
739
+ try {
740
+ const siblingOfPort = readRuntimePort()?.siblingOfPort;
741
+ return codexRoutingDriftWarning({
742
+ content: siblingOfPort === undefined ? readFileSync(CODEX_CONFIG_PATH, "utf8") : null,
743
+ livePort: listen.port,
744
+ loopbackPort: effectiveLoopbackListenerPort(config, listen.port),
745
+ siblingOfPort,
746
+ journaled: () => ({
747
+ openaiBaseUrl: journaledInjectedOpenaiBaseUrl({ readOnly: true }),
748
+ realtimeWsBaseUrl: journaledInjectedRealtimeWsBaseUrl({ readOnly: true }),
749
+ }),
750
+ });
751
+ } catch {
752
+ return null; // an absent or unreadable config.toml must never break `ocx status`
753
+ }
754
+ })();
755
+ if (codexDrift) warningParts.push(codexDrift);
705
756
  const codexRuntime = {
706
757
  path: displayCodexRuntimePath(resolvedRuntime.runtime.command),
707
758
  version: resolvedRuntime.runtime.version,