@bitkyc08/opencodex 2.10.2 → 2.11.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 (121) hide show
  1. package/README.md +31 -0
  2. package/bin/ocx.mjs +10 -0
  3. package/gui/dist/assets/index-Bk-PN-70.css +1 -0
  4. package/gui/dist/assets/index-BynIEIV-.js +70 -0
  5. package/gui/dist/index.html +2 -2
  6. package/package.json +4 -2
  7. package/src/adapters/cursor/effort-map.ts +11 -0
  8. package/src/adapters/cursor/live-transport.ts +11 -0
  9. package/src/adapters/cursor/native-exec-fs.ts +9 -6
  10. package/src/adapters/cursor/native-exec.ts +4 -2
  11. package/src/adapters/cursor/protobuf-events.ts +176 -4
  12. package/src/adapters/cursor/request-builder.ts +15 -4
  13. package/src/adapters/cursor/tool-definitions.ts +118 -2
  14. package/src/adapters/google.ts +15 -5
  15. package/src/adapters/openai-chat.ts +24 -2
  16. package/src/adapters/openai-responses.ts +2 -1
  17. package/src/bridge.ts +9 -5
  18. package/src/chat/outbound.ts +4 -3
  19. package/src/claude/desktop-3p.ts +222 -2
  20. package/src/claude/outbound.ts +15 -6
  21. package/src/cli/account-api.ts +4 -0
  22. package/src/cli/account-extended.ts +112 -0
  23. package/src/cli/account.ts +23 -6
  24. package/src/cli/claude-desktop.ts +26 -3
  25. package/src/cli/config-command.ts +9 -0
  26. package/src/cli/help.ts +18 -2
  27. package/src/cli/index.ts +277 -55
  28. package/src/cli/models.ts +5 -1
  29. package/src/cli/provider.ts +8 -2
  30. package/src/cli/ready.ts +301 -0
  31. package/src/cli/system-restart-client.ts +146 -0
  32. package/src/cli/tray-proxy.ts +153 -6
  33. package/src/clients/config-export.ts +12 -19
  34. package/src/codex/account-lifecycle.ts +3 -0
  35. package/src/codex/account-namespaces.ts +49 -3
  36. package/src/codex/account-priority.ts +83 -0
  37. package/src/codex/auth-api.ts +83 -0
  38. package/src/codex/auth-context.ts +5 -2
  39. package/src/codex/catalog/provider-fetch.ts +11 -0
  40. package/src/codex/catalog/sync.ts +23 -1
  41. package/src/codex/codex-write-lock.ts +16 -4
  42. package/src/codex/desired-state.ts +37 -4
  43. package/src/codex/history-job.ts +15 -5
  44. package/src/codex/history-provider.ts +31 -14
  45. package/src/codex/history-worker.ts +28 -4
  46. package/src/codex/inject-coordination.ts +13 -1
  47. package/src/codex/inject.ts +360 -66
  48. package/src/codex/internal/history-writer.ts +1 -1
  49. package/src/codex/native-main-lock-file.ts +5 -1
  50. package/src/codex/native-main-owner.ts +17 -3
  51. package/src/codex/native-profile-manager.ts +19 -0
  52. package/src/codex/native-profile-startup.ts +8 -0
  53. package/src/codex/native-residue.ts +140 -27
  54. package/src/codex/pool-rotation.ts +74 -4
  55. package/src/codex/refresh.ts +7 -0
  56. package/src/codex/routing.ts +177 -36
  57. package/src/codex/subagent-model-fallback.ts +34 -4
  58. package/src/codex/sync.ts +61 -0
  59. package/src/codex/upstream-host-health.ts +329 -31
  60. package/src/combos/request.ts +2 -0
  61. package/src/config.ts +221 -2
  62. package/src/images/loop.ts +1 -1
  63. package/src/integrations/native/ownership-preflight.ts +39 -2
  64. package/src/lib/bun-stream-caps.ts +3 -3
  65. package/src/lib/sse-decoder.ts +41 -0
  66. package/src/lib/system-restart-contract.ts +73 -0
  67. package/src/lib/windows-secret-acl.ts +141 -39
  68. package/src/lib/windows-user-principal.ts +283 -0
  69. package/src/lib/winsw.ts +18 -2
  70. package/src/oauth/key-providers.ts +12 -0
  71. package/src/providers/derive.ts +54 -2
  72. package/src/providers/free-directory.ts +6 -5
  73. package/src/providers/model-discovery.ts +9 -3
  74. package/src/providers/quota.ts +592 -0
  75. package/src/providers/registry.ts +316 -13
  76. package/src/responses/parser.ts +26 -10
  77. package/src/responses/reasoning-replay-cache.ts +1 -0
  78. package/src/routing/profile-namespace.ts +15 -0
  79. package/src/routing/profile.ts +2 -1
  80. package/src/server/auth-cors.ts +44 -13
  81. package/src/server/chat-completions.ts +0 -4
  82. package/src/server/claude-messages.ts +73 -15
  83. package/src/server/github-copilot-responses-repair.ts +338 -0
  84. package/src/server/index.ts +328 -111
  85. package/src/server/lifecycle.ts +36 -0
  86. package/src/server/management/agent-settings-routes.ts +147 -56
  87. package/src/server/management/config-routes.ts +7 -2
  88. package/src/server/management/context.ts +4 -0
  89. package/src/server/management/native-integration-routes.ts +199 -20
  90. package/src/server/management/provider-routes.ts +41 -0
  91. package/src/server/management/routing-profile-routes.ts +234 -5
  92. package/src/server/management/system-restart.ts +12 -10
  93. package/src/server/management/system-routes.ts +20 -0
  94. package/src/server/management-auth.ts +51 -3
  95. package/src/server/ports.ts +41 -1
  96. package/src/server/proxy-liveness.ts +129 -4
  97. package/src/server/readiness.ts +99 -0
  98. package/src/server/relay.ts +113 -97
  99. package/src/server/request-log.ts +10 -4
  100. package/src/server/responses/compact.ts +107 -12
  101. package/src/server/responses/core.ts +220 -39
  102. package/src/server/responses-item-id-repair.ts +22 -3
  103. package/src/server/responses-model-rewrite.ts +29 -0
  104. package/src/server/sse-frame-buffer.ts +292 -0
  105. package/src/server/sse-payload-rewrite.ts +25 -14
  106. package/src/server/ws-bridge.ts +27 -22
  107. package/src/service-manager-probe.ts +520 -10
  108. package/src/service.ts +134 -2
  109. package/src/storage/worker-lifecycle.ts +14 -14
  110. package/src/tray/windows-tray.ps1 +74 -9
  111. package/src/types.ts +68 -2
  112. package/src/update/index.ts +12 -0
  113. package/src/update/job.ts +392 -18
  114. package/src/update/npm-cache-preflight.d.mts +47 -0
  115. package/src/update/npm-cache-preflight.mjs +201 -0
  116. package/src/usage/log.ts +1 -1
  117. package/src/vision/index.ts +77 -2
  118. package/src/web-search/loop.ts +1 -1
  119. package/src/web-search/parse.ts +4 -1
  120. package/gui/dist/assets/index-BKVqyYqT.js +0 -70
  121. package/gui/dist/assets/index-Ca_3269W.css +0 -1
package/src/cli/index.ts CHANGED
@@ -21,11 +21,21 @@ import {
21
21
  } from "../config";
22
22
  import { collectStatus } from "./status";
23
23
  import { dispatchInternalCliCommand, type InternalCliCommand } from "./internal-dispatch";
24
- import { runTrayProxyRestart, runTrayProxyStart } from "./tray-proxy";
24
+ import {
25
+ discoverStableProxyForRestart,
26
+ isProxyReplacement,
27
+ runProxyRestart,
28
+ runTrayProxyStart,
29
+ type ProxyRestartLive,
30
+ type ProxyRestartResult,
31
+ } from "./tray-proxy";
32
+ import { requestBoundSystemRestart } from "./system-restart-client";
25
33
  import { installCrashGuards } from "../lib/crash-guard";
26
34
  import { hasHelpFlag, printSubcommandUsage, printUsage, printVersion } from "./help";
27
35
  import { findAvailablePort, isAddrInUse, PortUnavailableError, shouldPersistSelectedPort, waitForPortAvailable } from "../server/ports";
28
36
  import { findLiveProxy, probeHostname, type LiveProxy } from "../server/proxy-liveness";
37
+ import { createReadinessGate } from "../server/readiness";
38
+ import { parseReadyArgs, runReady, type ReadyArgs } from "./ready";
29
39
  import { stopProxy } from "../lib/process-control";
30
40
  import { loadServiceTokenFromFile } from "../lib/service-secrets";
31
41
  import { diagnoseService, isServiceOwnershipError, serviceCommand, serviceEnvironmentOwnedHere, serviceStartableFromTray, serviceStatusSummary, stopServiceIfInstalled, uninstallServiceIfInstalled } from "../service";
@@ -41,13 +51,14 @@ import { maybeShowStarPrompt } from "./star-prompt";
41
51
  import { scheduleCatalogPrewarm } from "./catalog-prewarm";
42
52
  import { maybeShowUpdatePrompt } from "../update/notify";
43
53
  import { syncModelsToCodex } from "../codex/sync";
44
- import { shouldSyncGrokOnStart, syncCodexOnStartIfEnabled } from "../codex/desired-state";
54
+ import { setIntegrationEnabled, shouldSyncCodexOnStart, shouldSyncGrokOnStart, syncCodexOnStartIfEnabled } from "../codex/desired-state";
45
55
  import { normalizeUpdateChannel, runGuiUpdateWorker } from "../update/job";
46
56
  import { collectOrcaCodexHomeDiagnostic } from "../codex/home";
47
57
  import { removeOwnedConfigState } from "../lib/config-ownership";
48
58
  import { withProcessRuntimeProvenance } from "../lib/bun-runtime";
49
59
  import { initializeNodeLauncherContext } from "./launcher-context";
50
60
  import { createLocalAttestationSecret } from "../lib/local-management-attestation";
61
+ import { MEMORY_DRAIN_RESTART_MS, REPLACEMENT_READY_TIMEOUT_MS } from "../lib/system-restart-contract";
51
62
 
52
63
  initializeNodeLauncherContext();
53
64
  const args = process.argv.slice(2);
@@ -69,6 +80,23 @@ if (command !== undefined && command !== "help" && hasHelpFlag(args.slice(1))) {
69
80
  process.exit(0);
70
81
  }
71
82
 
83
+ // P1: pre-parse `ocx ready` and reject invalid arguments with exit 64 BEFORE
84
+ // maybeAutoRestoreCodexShim (or any discovery/probe/filesystem-capable global
85
+ // preflight) runs. `ready --help` / `help ready` already exited above, so this
86
+ // only sees ready args without a help flag. Valid args are stashed so the
87
+ // switch dispatch can call runReady without a second parse.
88
+ let readyArgs: ReadyArgs | undefined;
89
+ if (command === "ready") {
90
+ const parsed = parseReadyArgs(args.slice(1));
91
+ if (!parsed.ok) {
92
+ console.error("Usage: ocx ready [--json] [--wait [--timeout <seconds>]]");
93
+ console.error(" --timeout requires --wait; <seconds> must be a positive integer (1..300).");
94
+ console.error(" Default wait timeout is 45 seconds.");
95
+ process.exit(parsed.code);
96
+ }
97
+ readyArgs = parsed.args;
98
+ }
99
+
72
100
  maybeAutoRestoreCodexShim(command, args);
73
101
 
74
102
  function parsePortOption(): number | undefined {
@@ -128,6 +156,18 @@ async function chooseListenPort(requestedPort?: number): Promise<number> {
128
156
  const config = loadConfig();
129
157
  const preferred = requestedPort ?? config.port ?? 10100;
130
158
  const hardPin = requestedPort !== undefined && requestedPort > 0;
159
+ const reservedLoopbackPort = config.unauthenticatedLoopbackListener?.enabled
160
+ ? config.unauthenticatedLoopbackListener.port
161
+ : undefined;
162
+ // Before the reclaim path, not after (#1102). Asking for the port the loopback listener is
163
+ // configured to bind is a configuration mistake, and reclaim would spend up to 60 seconds
164
+ // waiting for a socket to free before reporting "port is busy" — the wrong diagnosis for a
165
+ // collision the config can state outright.
166
+ if (reservedLoopbackPort !== undefined && preferred === reservedLoopbackPort) {
167
+ throw new Error(
168
+ `Port ${preferred} is reserved for unauthenticatedLoopbackListener; choose a different proxy port.`,
169
+ );
170
+ }
131
171
  // Soft start: brief prefer-retry then ephemeral hop.
132
172
  // Explicit `--port` (service wrappers / update restart): wait for the pinned port
133
173
  // to free without killing any listener (healthy ocx / foreign). Never hop.
@@ -151,6 +191,11 @@ async function chooseListenPort(requestedPort?: number): Promise<number> {
151
191
  preferRetryMs: hardPin ? 5_000 : 750,
152
192
  preferRetryIntervalMs: 50,
153
193
  allowEphemeralFallback: !hardPin,
194
+ // Never hand the public listener the port the loopback listener is configured to
195
+ // bind (#1102). Without this, `--port <loopback port>` binds the public listener
196
+ // first and the loopback bind then fails, rolling back a startup that was only
197
+ // ever a config collision.
198
+ ...(reservedLoopbackPort !== undefined ? { reservedPort: reservedLoopbackPort } : {}),
154
199
  });
155
200
  if (preferred > 0 && selected !== preferred) {
156
201
  console.log(`⚠️ Port ${preferred} is busy; starting opencodex on ${selected}.`);
@@ -197,11 +242,16 @@ async function handleStart(options: { block?: boolean } = {}) {
197
242
  // between the probe and Bun.serve. Soft starts may re-pick; hard-pinned `--port` retries
198
243
  // the same port only (never hop — that was the remaining PR #152 gap).
199
244
  let port = await chooseListenPort(requestedPort);
245
+ // One private readiness gate for this startServer invocation, captured by the
246
+ // listener's closure. handleStart owns it and transitions it after the
247
+ // post-startup sync settles. A second startServer in the same process would
248
+ // get its own gate and could never reset/mutate this one.
249
+ const readinessGate = createReadinessGate();
200
250
  let server: ReturnType<typeof startServer>;
201
251
  const localAttestationSecret = createLocalAttestationSecret();
202
252
  for (let attempt = 0; ; attempt++) {
203
253
  try {
204
- server = startServer(port, { localAttestationSecret });
254
+ server = startServer(port, { localAttestationSecret, readinessGate });
205
255
  // Prewarm the live provider model cache as soon as the port is bound so the
206
256
  // first GUI /v1/models (and syncModelsToCodex below) share one discovery flight
207
257
  // instead of racing duplicate upstream /models fetches.
@@ -257,7 +307,8 @@ async function handleStart(options: { block?: boolean } = {}) {
257
307
  }
258
308
  removePid(process.pid);
259
309
  removeRuntimePort(process.pid);
260
- if (!recycling && !process.env.OCX_SERVICE && !currentExternalCodexModelProvider()) {
310
+ const preserveRouting = process.env.OCX_SERVICE === "1";
311
+ if (!recycling && !preserveRouting && !currentExternalCodexModelProvider()) {
261
312
  try {
262
313
  const restored = restoreNativeCodex();
263
314
  if (!restored.success) {
@@ -273,7 +324,7 @@ async function handleStart(options: { block?: boolean } = {}) {
273
324
  // Grok fence is shared state we must not remove — that service keeps running and would be
274
325
  // left pointing nowhere. This guard also covers signal-driven exits, which is the path that
275
326
  // would otherwise bypass handleStop's gate entirely.
276
- if (!recycling && !process.env.OCX_SERVICE && serviceEnvironmentOwnedHere()) {
327
+ if (!recycling && !preserveRouting && serviceEnvironmentOwnedHere()) {
277
328
  try { stripGrokConfig(); } catch { /* best-effort restore */ }
278
329
  }
279
330
  return cleanupSucceeded;
@@ -321,7 +372,13 @@ async function handleStart(options: { block?: boolean } = {}) {
321
372
  installShellHook();
322
373
 
323
374
  await maybeShowStarPrompt(); // once-only Yes/No GitHub-star prompt on first interactive start
324
- const startupSync = await syncCodexOnStartIfEnabled(port, config);
375
+ // Post-startup sync drives the readiness gate AND the #1046 stale app-server
376
+ // warning. `syncCodexOnStartIfEnabled` respects the Codex integration toggle
377
+ // (OFF → no sync) and reports whether anything was written; the readiness gate
378
+ // observes the real sync outcome (ok/warning) so /readyz never advertises a
379
+ // half-synced proxy as ready while /healthz stays live.
380
+ const startupSync = await syncCodexOnStartIfEnabled(port, config, undefined, readinessGate);
381
+ if (!startupSync.ran) console.log(" Codex integration OFF; startup left Codex native.");
325
382
  // #1046: one warning per startup, after BOTH writes. The server's cache
326
383
  // invalidation happens first and the catalog sync second, so the mtime is only
327
384
  // final here — and neither write site warns on its own, or a boot that hits
@@ -372,18 +429,32 @@ async function handleStart(options: { block?: boolean } = {}) {
372
429
  }
373
430
  }
374
431
 
375
- async function handleEnsure() {
432
+ function detachedStartEnvironment(): NodeJS.ProcessEnv {
433
+ const env: NodeJS.ProcessEnv = { ...process.env };
434
+ // Only a real service wrapper may claim supervision. A detached ensure/tray child
435
+ // is an ordinary owner: while live it maintains routing, and on exit it restores it.
436
+ delete env.OCX_SERVICE;
437
+ return withProcessRuntimeProvenance(env);
438
+ }
439
+
440
+ async function handleEnsure(options: { existingIsSuccess?: boolean } = {}): Promise<boolean> {
376
441
  if (!currentExternalCodexModelProvider()) reconcileJournal();
377
442
  const config = loadConfig();
378
443
  if (!codexAutoStartEnabled(config)) {
379
444
  console.log("Codex autostart is disabled.");
380
- return;
445
+ return false;
381
446
  }
382
447
  const live = await findLiveProxy();
383
- if (live) {
384
- await syncModelsToCodex(live.port).catch(e => {
448
+ if (live) {
449
+ if (options.existingIsSuccess === false) {
450
+ console.error("Proxy appeared while restart was confirming absence; no start was attempted.");
451
+ return false;
452
+ }
453
+ const synced = await syncModelsToCodex(live.port).catch(e => {
385
454
  console.error(`⚠️ Model sync skipped: ${e instanceof Error ? e.message : String(e)}`);
455
+ return null;
386
456
  });
457
+ if (synced?.status === "skipped") console.log(" Codex integration OFF; startup left Codex native.");
387
458
  // Ensure env file exists for already-running proxy (may have been deleted or pre-dates this feature).
388
459
  await injectSystemEnv(live.port, config).catch(() => {});
389
460
  // Refresh the Grok Build fence too (same contract as start). live.hostname is the
@@ -395,7 +466,7 @@ async function handleEnsure() {
395
466
  else if (!g.ok) console.error(`⚠️ ${g.message}`);
396
467
  } catch (err) { console.error(`⚠️ ${grokSyncFailureMessage(err)}`); }
397
468
  console.log(`✅ Proxy running on port ${live.port}`);
398
- return;
469
+ return true;
399
470
  }
400
471
 
401
472
  const pinPort = config.port ?? 10100;
@@ -403,14 +474,15 @@ async function handleEnsure() {
403
474
  detached: true,
404
475
  stdio: "ignore",
405
476
  windowsHide: true,
406
- env: withProcessRuntimeProvenance({ ...process.env, OCX_SERVICE: "1" }),
477
+ env: detachedStartEnvironment(),
407
478
  });
408
479
  child.unref();
409
480
 
410
481
  const port = (await waitForProxy())?.port;
411
482
  if (!port) {
412
483
  console.error("❌ Proxy did not become healthy after starting.");
413
- process.exit(1);
484
+ process.exitCode = 1;
485
+ return false;
414
486
  }
415
487
  // Deterministic fence guarantee: the spawned child injects late in its own startup, but
416
488
  // this parent returns as soon as /healthz responds — inject here too (idempotent block
@@ -423,16 +495,20 @@ async function handleEnsure() {
423
495
  } catch (err) { console.error(`⚠️ ${grokSyncFailureMessage(err)}`); }
424
496
  // Always sync the LIVE port: after a fallback-port start, config.port still names the
425
497
  // busy preferred port — syncing that would point Codex at a dead listener.
426
- await syncModelsToCodex(port).catch(e => {
498
+ const synced = await syncModelsToCodex(port).catch(e => {
427
499
  console.error(`⚠️ Model sync skipped: ${e instanceof Error ? e.message : String(e)}`);
500
+ return null;
428
501
  });
502
+ if (synced?.status === "skipped") console.log(" Codex integration OFF; startup left Codex native.");
429
503
  console.log(`✅ Proxy running on port ${port}`);
504
+ return true;
430
505
  }
431
506
 
432
507
  /** Fixed tray action: start the proxy without depending on codexAutoStart. */
433
- async function handleTrayProxyStart(): Promise<void> {
508
+ async function handleTrayProxyStart(existingIsSuccess = true): Promise<boolean> {
434
509
  const ok = await runTrayProxyStart({
435
510
  findLive: findLiveProxy,
511
+ existingIsSuccess,
436
512
  diagnoseService: () => {
437
513
  const service = diagnoseService();
438
514
  return { installed: service.installed, startable: serviceStartableFromTray(service), summary: service.summary };
@@ -445,29 +521,117 @@ async function handleTrayProxyStart(): Promise<void> {
445
521
  detached: true,
446
522
  stdio: "ignore",
447
523
  windowsHide: true,
448
- env: withProcessRuntimeProvenance({ ...process.env, OCX_SERVICE: "1" }),
524
+ env: detachedStartEnvironment(),
449
525
  });
450
526
  child.unref();
451
527
  },
452
- waitForProxy,
528
+ // serviceCommand("start") already spends up to 20s confirming the supervised
529
+ // child. Slow Windows hosts can still be publishing native-main state after that
530
+ // first window, so keep one shared follow-up budget instead of returning a false
531
+ // failure while Task Scheduler is still starting the approved child.
532
+ waitForProxy: () => waitForProxy(40_000),
453
533
  info: message => console.log(message),
454
534
  error: message => console.error(message),
455
535
  });
456
- if (!ok) process.exitCode = 1;
536
+ // serviceCommand("start") can set exitCode=1 after its own 20s probe, while
537
+ // the coordinator's bounded follow-up observes the same service become live.
538
+ // The final observed state, not the earlier probe, owns this command result.
539
+ process.exitCode = ok ? 0 : 1;
540
+ return ok;
457
541
  }
458
542
 
459
- async function handleTrayProxyRestart(): Promise<void> {
460
- const ok = await runTrayProxyRestart({
461
- stop: async () => {
462
- await handleStop();
463
- return !process.exitCode || process.exitCode === 0;
464
- },
465
- start: async () => {
466
- await handleTrayProxyStart();
467
- return !process.exitCode || process.exitCode === 0;
468
- },
543
+ const PROXY_RESTART_OBSERVE_MS = MEMORY_DRAIN_RESTART_MS + REPLACEMENT_READY_TIMEOUT_MS + 15_000;
544
+
545
+ async function waitForProxyReplacement(
546
+ previous: ProxyRestartLive,
547
+ deadlineAt: number,
548
+ ): Promise<ProxyRestartLive | null> {
549
+ while (Date.now() < deadlineAt) {
550
+ const live = await findLiveProxy({ deadlineAt });
551
+ if (Date.now() >= deadlineAt) return null;
552
+ // Modern /healthz publishes a PID. Require a different, identity-verified process;
553
+ // merely seeing the old port online again is not proof that restart completed.
554
+ if (isProxyReplacement(previous, live)) {
555
+ return live;
556
+ }
557
+ const remainingMs = deadlineAt - Date.now();
558
+ if (remainingMs > 0) await Bun.sleep(Math.min(250, remainingMs));
559
+ }
560
+ return null;
561
+ }
562
+
563
+ function reportRestartFailure(result: Extract<ProxyRestartResult, { ok: false }>): void {
564
+ if (result.phase === "identity") {
565
+ console.error("❌ Refusing to restart because the running proxy identity could not be attested.");
566
+ } else if (result.phase === "request") {
567
+ const code = result.error instanceof Error ? result.error.message : "";
568
+ if (code === "restart_capability_unsupported") {
569
+ console.error("❌ The running proxy predates process-bound restart support; no unsafe fallback was attempted.");
570
+ console.error(" After confirming this home owns the proxy, run `ocx stop` and then `ocx start` once.");
571
+ } else {
572
+ console.error("❌ Proxy restart request could not be confirmed; no fallback stop/start was attempted.");
573
+ }
574
+ } else if (result.phase === "replacement") {
575
+ console.error("❌ Proxy restart was accepted, but no identity-verified replacement became healthy in time.");
576
+ } else {
577
+ console.error("❌ Proxy was not running and the fallback start did not become healthy.");
578
+ }
579
+ }
580
+
581
+ async function handleProxyRestart(
582
+ startWhenStopped: () => Promise<boolean | "skipped">,
583
+ ): Promise<boolean> {
584
+ const deadlineAt = Date.now() + PROXY_RESTART_OBSERVE_MS;
585
+ const result = await runProxyRestart({
586
+ findLive: () => discoverStableProxyForRestart({
587
+ findLive: () => findLiveProxy({ deadlineAt, attempts: 2 }),
588
+ expired: () => Date.now() >= deadlineAt,
589
+ }),
590
+ startWhenStopped,
591
+ requestInPlaceRestart: previous => requestBoundSystemRestart(previous, deadlineAt),
592
+ waitForReplacement: previous => waitForProxyReplacement(previous, deadlineAt),
469
593
  });
470
- if (!ok) process.exitCode = 1;
594
+ if (!result.ok) reportRestartFailure(result);
595
+ process.exitCode = result.ok ? 0 : 1;
596
+ return result.ok;
597
+ }
598
+
599
+ async function handleTrayProxyRestart(): Promise<void> {
600
+ await handleProxyRestart(() => handleTrayProxyStart(false));
601
+ }
602
+
603
+ async function handleRestartStartWhenStopped(): Promise<boolean | "skipped"> {
604
+ if (!codexAutoStartEnabled(loadConfig())) {
605
+ console.log("Codex autostart is disabled; no proxy was started.");
606
+ return "skipped";
607
+ }
608
+ return handleEnsure({ existingIsSuccess: false });
609
+ }
610
+
611
+ async function restoreSharedClientStateAfterStop(): Promise<boolean> {
612
+ let restored = true;
613
+ try {
614
+ const result = await restoreNativeCodexAsync();
615
+ if (result.success) console.log(`↩️ ${result.message}`);
616
+ else {
617
+ restored = false;
618
+ console.error(`⚠️ ${result.message}`);
619
+ }
620
+ } catch (error) {
621
+ restored = false;
622
+ console.error(`⚠️ Native Codex restore failed: ${error instanceof Error ? error.message : String(error)}`);
623
+ }
624
+
625
+ // A refused or thrown Grok strip is actionable because it would point Grok at a dead proxy.
626
+ try {
627
+ const grok = stripGrokConfig();
628
+ if (grok.changed) console.log(`↩️ ${grok.message}`);
629
+ else if (!grok.ok) { restored = false; console.error(`⚠️ ${grok.message}`); }
630
+ } catch (error) {
631
+ restored = false;
632
+ console.error(`⚠️ Grok config restore failed: ${error instanceof Error ? error.message : String(error)}`);
633
+ }
634
+ return restored;
471
635
  }
472
636
 
473
637
  async function handleStop() {
@@ -541,26 +705,11 @@ async function handleStop() {
541
705
  removeRuntimePortIfPidIs(staleRuntimePid);
542
706
  }
543
707
  }
544
- if (!ownershipBlocked) {
545
- const r = await restoreNativeCodexAsync();
546
- if (r.success) console.log(`↩️ ${r.message}`);
547
- else {
548
- stopFailed = true;
549
- console.error(`⚠️ ${r.message}`);
550
- }
551
- }
552
- // revertSystemEnv is NOT gated: it carries its own ownership check and concerns launchctl
553
- // user env, not CODEX_HOME. Safety net for when the daemon's syncCleanup didn't run (SIGKILL).
708
+ // Environment ownership is independent from service ownership. Always roll back
709
+ // current-home variables; the helper refuses foreign markers on its own.
554
710
  try { revertSystemEnv(); } catch { /* best-effort */ }
555
711
  if (!ownershipBlocked) {
556
- // Same safety net for the Grok Build managed block (marker-owned, idempotent).
557
- try {
558
- const g = stripGrokConfig();
559
- if (g.changed) console.log(`↩️ ${g.message}`);
560
- // A refused strip (e.g. orphaned marker) leaves the fence pointing at a dead proxy —
561
- // reporting success there hides a broken end state.
562
- else if (!g.ok) { stopFailed = true; console.error(`⚠️ ${g.message}`); }
563
- } catch { /* best-effort */ }
712
+ if (!await restoreSharedClientStateAfterStop()) stopFailed = true;
564
713
  }
565
714
  // Set the code rather than exiting inline: `restart` and the tray coordinator call this
566
715
  // function and need it to RETURN so they can decide what to do next.
@@ -757,6 +906,17 @@ async function handleRecoverHistory() {
757
906
  console.log(`Recovered ${r.rows} legacy thread(s) to openai (${r.files} rollout file(s) updated).`);
758
907
  }
759
908
 
909
+ /**
910
+ * `ocx ready` — arguments are pre-parsed above (before
911
+ * maybeAutoRestoreCodexShim) so invalid usage exits 64 before any global
912
+ * preflight. This handler only runs the dependency-injected runner in ./ready
913
+ * and exits with the returned code; it performs no parsing and no I/O of its
914
+ * own. The full behavior is unit-testable without spawning a subprocess.
915
+ */
916
+ async function handleReady(args: ReadyArgs): Promise<never> {
917
+ process.exit(await runReady(args));
918
+ }
919
+
760
920
  switch (command) {
761
921
  case "init":
762
922
  case "setup": {
@@ -777,6 +937,7 @@ switch (command) {
777
937
  }
778
938
  case "restore":
779
939
  case "eject": {
940
+ const restoreJson = args[1] === "--json";
780
941
  if (args[1] === "back") {
781
942
  // Reverse switch: re-point plain `codex` at the RUNNING proxy without touching its
782
943
  // lifecycle — the counterpart of `ocx restore`. Start/stop triggers are unchanged;
@@ -786,7 +947,18 @@ switch (command) {
786
947
  console.error("No running proxy found. Run 'ocx start' — it injects opencodex automatically.");
787
948
  process.exit(1);
788
949
  }
950
+ const desired = setIntegrationEnabled("codex", true);
951
+ if (!desired.ok) {
952
+ process.exitCode = desired.reason === "conflict" ? 2 : 1;
953
+ console.error(`Codex desired state was not saved (${desired.reason}).`);
954
+ break;
955
+ }
789
956
  const synced = await syncModelsToCodex(live.port);
957
+ if (synced.status === "skipped") {
958
+ process.exitCode = 2;
959
+ console.error("Codex integration is OFF; restore back did not change Codex. Retry after the competing integration change finishes.");
960
+ break;
961
+ }
790
962
  if (!synced.ok) {
791
963
  process.exitCode = 1;
792
964
  console.error("Plain `codex` was not switched back to opencodex. Fix the reported Codex config issue and retry.");
@@ -796,12 +968,49 @@ switch (command) {
796
968
  console.log(`Plain \`codex\` now routes through opencodex in ${target.effectiveCodexHome} (undo with: ocx restore).`);
797
969
  break;
798
970
  }
971
+ const desired = setIntegrationEnabled("codex", false);
972
+ if (!desired.ok) {
973
+ process.exitCode = desired.reason === "conflict" ? 2 : 1;
974
+ if (restoreJson) {
975
+ // Machine-readable contract: every restore --json outcome emits one
976
+ // schema-complete envelope on stdout, including pre-machinery failures.
977
+ const { skippedRestoreEnvelope } = await import("../codex/inject");
978
+ console.log(JSON.stringify(skippedRestoreEnvelope(false, `Codex desired state was not saved (${desired.reason}).`)));
979
+ } else {
980
+ console.error(`Codex desired state was not saved (${desired.reason}).`);
981
+ }
982
+ break;
983
+ }
984
+ // A repeated OFF on an already-clean home is a policy no-op. Do not enter
985
+ // restore's native-profile machinery merely to prove there is nothing to
986
+ // restore: those locks live in CODEX_HOME and a skip must create nothing.
987
+ if (desired.status === "unchanged") {
988
+ const { classifyNativeRoutedResidue } = await import("../codex/native-residue");
989
+ if (classifyNativeRoutedResidue().kind === "clean") {
990
+ const alreadyOff = "Codex integration is already OFF and native; no Codex files changed.";
991
+ if (restoreJson) {
992
+ const { skippedRestoreEnvelope } = await import("../codex/inject");
993
+ console.log(JSON.stringify(skippedRestoreEnvelope(true, alreadyOff)));
994
+ } else {
995
+ console.log(alreadyOff);
996
+ }
997
+ break;
998
+ }
999
+ }
799
1000
  let r: { success: boolean; message: string };
800
1001
  try {
801
- r = await restoreNativeCodexAsync();
1002
+ r = await restoreNativeCodexAsync({ revalidateDesiredState: true });
802
1003
  } catch (err) {
803
1004
  r = { success: false, message: err instanceof Error ? err.message : String(err) };
804
1005
  }
1006
+ if (restoreJson) {
1007
+ // Spawned callers need the artifact-level result to distinguish a busy
1008
+ // history worker from a successful native restore. Keep stdout machine
1009
+ // readable; human framing remains the default command contract.
1010
+ console.log(JSON.stringify(r));
1011
+ if (!r.success) process.exitCode = 1;
1012
+ break;
1013
+ }
805
1014
  if (r.success) console.log(`✅ ${r.message}`);
806
1015
  else {
807
1016
  console.error(`⚠️ ${r.message}`);
@@ -816,7 +1025,7 @@ switch (command) {
816
1025
  }
817
1026
  } catch { /* best-effort */ }
818
1027
  if (r.success) {
819
- console.log("Plain `codex` now runs natively (no proxy). Switch back with: ocx restore back");
1028
+ console.log("Codex integration is OFF and plain `codex` now runs natively. Switch back with: ocx restore back");
820
1029
  } else {
821
1030
  console.error("Plain `codex` was not fully restored. Inspect $CODEX_HOME/config.toml before using native Codex.");
822
1031
  }
@@ -860,7 +1069,9 @@ switch (command) {
860
1069
  case "sync": {
861
1070
  const restartCodex = args.slice(1).includes("--restart-codex");
862
1071
  const synced = await syncModelsToCodex((await findLiveProxy())?.port);
863
- if (!synced.ok) {
1072
+ if (synced.status === "skipped") {
1073
+ console.log("Codex integration is OFF; sync skipped and no Codex files changed.");
1074
+ } else if (!synced.ok) {
864
1075
  process.exitCode = 1;
865
1076
  console.error("Codex sync did not complete. Fix the reported Codex config issue and retry.");
866
1077
  }
@@ -881,6 +1092,10 @@ switch (command) {
881
1092
  }
882
1093
  case "sync-cache": {
883
1094
  const restartCodex = args.slice(1).includes("--restart-codex");
1095
+ if (!shouldSyncCodexOnStart(loadConfig())) {
1096
+ console.log("Codex integration is OFF; cache sync skipped and no Codex files changed.");
1097
+ break;
1098
+ }
884
1099
  const { withCatalogWriteSerialization } = await import("../codex/catalog-write-serialization");
885
1100
  const { invalidateCodexModelsCacheWithPermit } = await import("../codex/catalog/sync");
886
1101
  const { getCodexHome } = await import("../codex/paths");
@@ -978,7 +1193,7 @@ switch (command) {
978
1193
  case "__tray-restart":
979
1194
  case "__startup-health":
980
1195
  await dispatchInternalCliCommand(command as InternalCliCommand, {
981
- trayStart: handleTrayProxyStart,
1196
+ trayStart: async () => { await handleTrayProxyStart(); },
982
1197
  trayRestart: handleTrayProxyRestart,
983
1198
  startupHealth: async () => {
984
1199
  const { collectStartupHealth } = await import("../codex/autostart-health");
@@ -999,10 +1214,9 @@ switch (command) {
999
1214
  break;
1000
1215
  }
1001
1216
  case "restart": {
1002
- // A failed stop must not be followed by a re-inject: with a foreign service still running
1003
- // (ownership mismatch) we would rewrite shared config we just declined to touch.
1004
- if (await handleStop()) await handleEnsure();
1005
- else console.error("↩️ Restart aborted: the proxy was not stopped cleanly.");
1217
+ // The running proxy owns its drain and replacement through /api/system/restart.
1218
+ // If nothing is live, restart degrades to the documented `ensure` start behavior.
1219
+ await handleProxyRestart(handleRestartStartWhenStopped);
1006
1220
  break;
1007
1221
  }
1008
1222
  case "health": {
@@ -1016,6 +1230,14 @@ switch (command) {
1016
1230
  }
1017
1231
  process.exit(live ? 0 : 1);
1018
1232
  }
1233
+ case "ready":
1234
+ // Fail-closed impossible-state guard: readyArgs is populated by the
1235
+ // preparse block before maybeAutoRestoreCodexShim, so reaching here
1236
+ // without it means dispatch diverged. Refuse with code 64 and perform
1237
+ // NO I/O (no discovery/probe). process.exit is `never`, narrowing below.
1238
+ if (!readyArgs) process.exit(64);
1239
+ await handleReady(readyArgs);
1240
+ break;
1019
1241
  case "provider": {
1020
1242
  const { handleProviderCommand } = await import("./provider");
1021
1243
  await handleProviderCommand(args.slice(1));
package/src/cli/models.ts CHANGED
@@ -102,9 +102,13 @@ function rejectUnexpectedArgs(args: string[], usage: string): void {
102
102
  async function syncCustomModelsIfLive(): Promise<void> {
103
103
  const live = await findLiveProxy();
104
104
  if (!live) return;
105
- await syncModelsToCodex(live.port).catch(error => {
105
+ const synced = await syncModelsToCodex(live.port).catch(error => {
106
106
  console.error(`Warning: custom model saved, but catalog sync failed: ${error instanceof Error ? error.message : String(error)}`);
107
+ return null;
107
108
  });
109
+ if (synced?.status === "skipped") {
110
+ console.log("Custom model saved; Codex integration is OFF, so its catalog was not changed.");
111
+ }
108
112
  }
109
113
 
110
114
  async function handleCustomAdd(args: string[]): Promise<void> {
@@ -229,12 +229,18 @@ async function handleAdd(args: string[]): Promise<void> {
229
229
  return;
230
230
  }
231
231
 
232
+ let codexSyncSkipped = false;
232
233
  if (wantsSync) {
233
234
  const live = await findLiveProxy();
234
235
  if (live) {
235
- await syncModelsToCodex(live.port).catch(e => {
236
+ const synced = await syncModelsToCodex(live.port).catch(e => {
236
237
  console.error(`Warning: sync failed: ${e instanceof Error ? e.message : String(e)}`);
238
+ return null;
237
239
  });
240
+ if (synced?.status === "skipped") {
241
+ codexSyncSkipped = true;
242
+ console.log("Provider saved; Codex integration is OFF, so Codex sync was skipped.");
243
+ }
238
244
  }
239
245
  }
240
246
 
@@ -249,7 +255,7 @@ async function handleAdd(args: string[]): Promise<void> {
249
255
  console.log(` Set API key with: ocx provider add ${name} --api-key <key> --force`);
250
256
  console.log(` Or set env var: ${envKey}`);
251
257
  }
252
- if (wantsSync) {
258
+ if (wantsSync && !codexSyncSkipped) {
253
259
  console.log(` Models synced to Codex.`);
254
260
  } else {
255
261
  console.log(` Apply to Codex: ocx sync`);