@bitkyc08/opencodex 2.38.0 → 2.39.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 (81) hide show
  1. package/bin/ocx.mjs +58 -6
  2. package/gui/dist/assets/index-D-lchsPw.js +112 -0
  3. package/gui/dist/assets/index-uvENYLin.css +1 -0
  4. package/gui/dist/index.html +2 -2
  5. package/gui/dist/provider-icons/baseten.svg +13 -0
  6. package/gui/dist/provider-icons/bizrouter.svg +41 -0
  7. package/gui/dist/provider-icons/cerebras.svg +26 -0
  8. package/gui/dist/provider-icons/deepinfra.svg +75 -0
  9. package/gui/dist/provider-icons/digitalocean.svg +10 -0
  10. package/gui/dist/provider-icons/featherless.svg +4 -0
  11. package/gui/dist/provider-icons/gajae-code.svg +410 -0
  12. package/gui/dist/provider-icons/hermes-agent.svg +207 -0
  13. package/gui/dist/provider-icons/hyperbolic.svg +18 -0
  14. package/gui/dist/provider-icons/kilo.svg +13 -0
  15. package/gui/dist/provider-icons/litellm.svg +1 -0
  16. package/gui/dist/provider-icons/minimax.svg +1 -0
  17. package/gui/dist/provider-icons/nanogpt.svg +74 -0
  18. package/gui/dist/provider-icons/nebius.svg +1 -0
  19. package/gui/dist/provider-icons/neuralwatt.svg +27 -0
  20. package/gui/dist/provider-icons/nous.svg +149 -0
  21. package/gui/dist/provider-icons/novita.svg +32 -0
  22. package/gui/dist/provider-icons/orcarouter.svg +175 -0
  23. package/gui/dist/provider-icons/parallel.svg +13 -0
  24. package/gui/dist/provider-icons/sambanova.svg +276 -0
  25. package/gui/dist/provider-icons/scaleway.svg +11 -0
  26. package/gui/dist/provider-icons/siliconflow.svg +18 -0
  27. package/gui/dist/provider-icons/synthetic.svg +12 -0
  28. package/gui/dist/provider-icons/together.svg +18 -0
  29. package/gui/dist/provider-icons/umans.svg +30 -0
  30. package/gui/dist/provider-icons/venice.svg +165 -0
  31. package/gui/dist/provider-icons/vultr.svg +15 -0
  32. package/gui/dist/provider-icons/zai.svg +218 -0
  33. package/gui/dist/provider-icons/zenmux.svg +1 -0
  34. package/package.json +1 -1
  35. package/src/adapters/cursor/live-models.ts +1 -0
  36. package/src/adapters/openai-responses.ts +72 -10
  37. package/src/bridge.ts +15 -15
  38. package/src/cli/dispatch.ts +54 -18
  39. package/src/cli/index.ts +420 -30
  40. package/src/cli/integrations.ts +24 -2
  41. package/src/cli/uninstall-plan.ts +86 -0
  42. package/src/codex/account-store.ts +121 -8
  43. package/src/codex/auth-api.ts +202 -33
  44. package/src/codex/catalog/provider-fetch.ts +76 -9
  45. package/src/codex/history-job.ts +10 -0
  46. package/src/codex/history-manifest.ts +35 -2
  47. package/src/codex/history-provider.ts +196 -19
  48. package/src/codex/history-worker.ts +3 -0
  49. package/src/codex/quota-401-recovery.ts +190 -0
  50. package/src/codex/quota-recovery-timing.ts +28 -0
  51. package/src/codex/quota.ts +6 -0
  52. package/src/codex/routing.ts +68 -16
  53. package/src/codex/subagent-model-fallback.ts +4 -1
  54. package/src/config/pending-teardown-names.d.mts +8 -0
  55. package/src/config/pending-teardown-names.mjs +69 -0
  56. package/src/config/pending-teardown.ts +286 -0
  57. package/src/generated/compatibility-version.json +84 -32
  58. package/src/integrations/journal.ts +12 -1
  59. package/src/integrations/writer.ts +82 -7
  60. package/src/lib/process-control.ts +30 -5
  61. package/src/lib/state-store-registrations.ts +8 -0
  62. package/src/oauth/index.ts +220 -13
  63. package/src/oauth/store.ts +220 -18
  64. package/src/responses/spill-store.ts +20 -0
  65. package/src/responses/state.ts +288 -2
  66. package/src/server/management/integration-routes.ts +36 -5
  67. package/src/server/management-api.ts +66 -12
  68. package/src/server/responses/encrypted-payload.ts +18 -1
  69. package/src/server/stop-teardown.ts +84 -0
  70. package/src/service.ts +155 -20
  71. package/src/update/index.ts +36 -4
  72. package/src/update/proxy-liveness-probe.d.mts +6 -0
  73. package/src/update/proxy-liveness-probe.mjs +84 -0
  74. package/src/update/stop-contract.d.mts +2 -0
  75. package/src/update/stop-contract.mjs +15 -0
  76. package/src/update/stop-decision.d.mts +10 -0
  77. package/src/update/stop-decision.mjs +34 -0
  78. package/src/vision/eligibility.ts +19 -1
  79. package/src/vision/index.ts +4 -18
  80. package/gui/dist/assets/index-C14iCj_Q.js +0 -112
  81. package/gui/dist/assets/index-D7PIz7_g.css +0 -1
@@ -0,0 +1,84 @@
1
+ import type { CodexNativeRestoreResult } from "../codex/inject";
2
+ import { deferralMatchesReceipt } from "../config/pending-teardown";
3
+
4
+ /**
5
+ * Shared-teardown decision and execution for `POST /api/stop` (#3008).
6
+ *
7
+ * Lives outside the route handler because the handler schedules `process.exit` 200ms
8
+ * after it answers, which makes it uncallable from a test. The part worth testing is
9
+ * exactly this: whether the deferral is honoured, whether the restores actually run, and
10
+ * whether the response says what happened.
11
+ */
12
+
13
+ export type GrokStripResult = { ok: boolean; changed: boolean; message: string };
14
+
15
+ export type StopTeardownIo = {
16
+ /** Does the nonce this request carries name a readable obligation on disk? */
17
+ ownsReceipt?: (nonce: string | null) => boolean;
18
+ restoreNativeCodex?: () => Promise<CodexNativeRestoreResult>;
19
+ stripGrok?: () => GrokStripResult;
20
+ };
21
+
22
+ export type StopTeardownBody = {
23
+ success: boolean;
24
+ message: string;
25
+ sharedTeardown: "deferred" | "performed";
26
+ };
27
+
28
+ /**
29
+ * A deferral is honoured only when the caller proves it owns the obligation.
30
+ *
31
+ * The query flag names an intention; the receipt is the obligation. Without the second
32
+ * half any authenticated caller could ask the proxy to skip teardown and then exit,
33
+ * leaving native Codex and the Grok fence pointed at a proxy that no longer exists.
34
+ *
35
+ * "A receipt exists" is not that proof either: it would let any caller ride on another
36
+ * stop's outstanding obligation and get a deferral it never owns. The request has to name
37
+ * the receipt's nonce, which only the process that wrote it (and anything that can read
38
+ * the 0700 config directory, which is already the trust boundary for the admin token)
39
+ * can know.
40
+ */
41
+ export function deferralHonored(url: URL, ownsReceipt: (nonce: string | null) => boolean): boolean {
42
+ if (url.searchParams.get("deferSharedTeardown") !== "1") return false;
43
+ return ownsReceipt(url.searchParams.get("teardownNonce"));
44
+ }
45
+
46
+ /** Run (or skip) the shared teardown and describe the outcome truthfully. */
47
+ export async function performStopTeardown(url: URL, io: StopTeardownIo = {}): Promise<StopTeardownBody> {
48
+ const ownsReceipt = io.ownsReceipt ?? deferralMatchesReceipt;
49
+ if (deferralHonored(url, ownsReceipt)) {
50
+ // Not "native Codex restored": nothing was restored here, and claiming otherwise
51
+ // would be a success message the operator cannot verify.
52
+ return {
53
+ success: true,
54
+ message: "Proxy stopping; shared teardown deferred to the stopping client.",
55
+ sharedTeardown: "deferred",
56
+ };
57
+ }
58
+ const restore = io.restoreNativeCodex
59
+ ? await io.restoreNativeCodex()
60
+ : await (await import("../codex/inject")).restoreNativeCodexAsync();
61
+ const grok = io.stripGrok
62
+ ? io.stripGrok()
63
+ : (await import("../grok/inject")).stripGrokConfig();
64
+ // Success means BOTH halves came down. Deciding it from the native restore alone and
65
+ // appending the Grok text let a caller read `success: true` while the fence still
66
+ // pointed at a proxy that was exiting — the teardown reported done with half of it
67
+ // undone (#3008).
68
+ const grokNote = grok.ok ? "" : ` Grok config cleanup failed: ${grok.message}`;
69
+ if (restore.success && grok.ok) {
70
+ return { success: true, message: "Proxy stopping, native Codex restored.", sharedTeardown: "performed" };
71
+ }
72
+ if (restore.success) {
73
+ return {
74
+ success: false,
75
+ message: `Proxy stopping, native Codex restored, but the Grok fence was not removed:${grokNote} Run \`ocx restore\`.`,
76
+ sharedTeardown: "performed",
77
+ };
78
+ }
79
+ return {
80
+ success: false,
81
+ message: `Proxy stopping, but native Codex restore failed: ${restore.message}. Run \`ocx restore\`.${grokNote}`,
82
+ sharedTeardown: "performed",
83
+ };
84
+ }
package/src/service.ts CHANGED
@@ -2998,6 +2998,22 @@ export function stopWindows(): void {
2998
2998
  if (isWindowsSchedulerEndBenign(error)) return;
2999
2999
  }
3000
3000
  }
3001
+
3002
+ /**
3003
+ * `stopWindows` for callers that need to know whether it worked.
3004
+ *
3005
+ * The void form swallows a non-benign `/end` failure, which is right for best-effort
3006
+ * teardown and wrong for deciding whether an update may replace files: a scheduler that
3007
+ * refused to stop can respawn the proxy on top of a half-written install (#3008).
3008
+ */
3009
+ export function stopWindowsChecked(): boolean {
3010
+ try {
3011
+ schtasks(["/end", "/tn", TASK]);
3012
+ return true;
3013
+ } catch (error) {
3014
+ return isWindowsSchedulerEndBenign(error);
3015
+ }
3016
+ }
3001
3017
  function statusWindows(): string { try { return schtasks(["/query", "/tn", TASK]); } catch { return ""; } }
3002
3018
  function statusWindowsXml(): string { try { return schtasks(["/query", "/tn", TASK, "/xml"]); } catch { return ""; } }
3003
3019
 
@@ -3604,36 +3620,129 @@ export async function installFreshWindowsSchedulerSafely(
3604
3620
  }
3605
3621
  }
3606
3622
 
3623
+ // `stopServiceIfInstalled` (boolean) is deliberately gone. It collapsed "not installed",
3624
+ // "refused to stop" and "state could not be read" into the same `false`, and every caller
3625
+ // that trusted it eventually read a live manager as absence — the route, then uninstall
3626
+ // (#3008). Callers take `stopServiceIfInstalledDetailed` and handle the outcomes.
3607
3627
  /**
3608
- * If a service is installed, stop it so the process manager doesn't respawn after `ocx stop`.
3609
- * Returns true if a service was found and stopped.
3628
+ * Would stopping the installed manager leave something that can respawn the proxy?
3629
+ *
3630
+ * Answered WITHOUT stopping anything, because a caller that must refuse the stop has to
3631
+ * refuse before it acts: `POST /api/stop` briefly ended the Task Scheduler task and then
3632
+ * returned 409, which left the proxy running with its manager stopped — worse than either
3633
+ * outcome it was choosing between.
3634
+ *
3635
+ * Task Scheduler only. `schtasks /end` ends the task instance while the `cmd :loop`
3636
+ * wrapper survives and respawns its child (#764); launchd, systemd and WinSW are down when
3637
+ * they report stopped.
3610
3638
  */
3611
- export function stopServiceIfInstalled(): boolean {
3639
+ export function installedServiceRespawnRisk(
3640
+ probe: () => WindowsSchedulerTaskProbe = probeWindowsSchedulerTask,
3641
+ platform: NodeJS.Platform = process.platform,
3642
+ ): "none" | "respawnable" | "unknown" {
3643
+ // launchd, systemd and WinSW are down when they report stopped; only the Task Scheduler
3644
+ // wrapper survives its task ending (#764).
3645
+ if (platform !== "win32") return "none";
3646
+ try {
3647
+ // `probeWindowsSchedulerTask` returns "unknown" as an ordinary value when its queries
3648
+ // fail — it does not throw — so testing for "present" let an unanswerable probe
3649
+ // through, and the route then killed scheduler wrappers before refusing.
3650
+ //
3651
+ // "unknown" is kept SEPARATE from "respawnable" because the remedies differ. Telling
3652
+ // an operator whose schtasks query is broken to run `ocx stop` is circular: that
3653
+ // command maps the same unknown to a stop failure, so it cannot finish either.
3654
+ const status = probe().status;
3655
+ if (status === "absent") return "none";
3656
+ return status === "present" ? "respawnable" : "unknown";
3657
+ } catch {
3658
+ // A probe that cannot answer is not evidence of absence either.
3659
+ return "unknown";
3660
+ }
3661
+ }
3662
+
3663
+ /**
3664
+ * Outcome of stopping an installed process manager.
3665
+ *
3666
+ * `stopServiceIfInstalled` collapses "no service was installed" and "a service was
3667
+ * installed and would not stop" into the same `false`, which is fine for a caller that
3668
+ * only wants to log. It is not fine for one deciding whether an update may replace package
3669
+ * files: a manager that refused to stop can respawn the proxy on top of a half-written
3670
+ * install (#3008).
3671
+ */
3672
+ /**
3673
+ * `stopped-respawnable` is Task Scheduler specifically: `schtasks /end` ends the task
3674
+ * instance while the `cmd :loop` wrapper survives and respawns its child seconds later
3675
+ * (#764). Only that backend needs the restart-window wait — launchd, systemd and WinSW
3676
+ * are down when they report stopped, and making them pay a seven-second poll would be a
3677
+ * regression in every ordinary `ocx stop`.
3678
+ */
3679
+ /**
3680
+ * `state-unknown` is kept apart from `failed` because the remedies differ. A manager that
3681
+ * refused to stop is a stop failure the operator can retry; a scheduler whose state cannot
3682
+ * be READ is a broken query, and telling that operator "the manager did not stop" sends
3683
+ * them looking for the wrong thing (#3008).
3684
+ */
3685
+ export type ServiceStopOutcome = "absent" | "stopped" | "stopped-respawnable" | "failed" | "state-unknown";
3686
+
3687
+ /**
3688
+ * Collapse the Windows backend observations into one outcome.
3689
+ *
3690
+ * Extracted so the precedence is testable by calling it. The rule that matters: a readable
3691
+ * failure outranks an unreadable state, and an unreadable state outranks success — a
3692
+ * scheduler we cannot see may still respawn the proxy.
3693
+ */
3694
+ export function classifyWindowsServiceStop(o: {
3695
+ stopped: boolean;
3696
+ failed: boolean;
3697
+ schedulerStopped: boolean;
3698
+ stateUnknown: boolean;
3699
+ }): ServiceStopOutcome {
3700
+ if (o.failed) return "failed";
3701
+ if (o.stateUnknown) return "state-unknown";
3702
+ if (o.stopped) return o.schedulerStopped ? "stopped-respawnable" : "stopped";
3703
+ return "absent";
3704
+ }
3705
+
3706
+ export function stopServiceIfInstalledDetailed(): ServiceStopOutcome {
3612
3707
  assertServiceEnvironmentMatchesInstall();
3613
3708
  if (process.platform === "darwin") {
3614
3709
  if (existsSync(plistPath())) {
3615
- try { stopLaunchd(); return true; } catch { return false; }
3710
+ try { stopLaunchd(); return "stopped"; } catch { return "failed"; }
3616
3711
  }
3617
3712
  } else if (process.platform === "win32") {
3618
3713
  // Query BOTH backends regardless of state: a failed switch or stale state can leave
3619
3714
  // two managers installed, and either one would respawn the proxy after `ocx stop`.
3620
3715
  let stopped = false;
3621
- try {
3622
- const q = schtasks(["/query", "/tn", TASK]);
3623
- if (q.includes(TASK)) { stopWindows(); stopped = true; }
3624
- } catch { /* task not found */ }
3716
+ let failed = false;
3717
+ let schedulerStopped = false;
3718
+ let stateUnknown = false;
3719
+ // `probeWindowsSchedulerTask` is tri-state on purpose: a query that THROWS is not the
3720
+ // same as a task that is absent, and treating it as absent lets a live scheduler
3721
+ // survive a "successful" stop.
3722
+ const probe = probeWindowsSchedulerTask();
3723
+ if (probe.status === "present") {
3724
+ if (stopWindowsChecked()) { stopped = true; schedulerStopped = true; }
3725
+ else failed = true;
3726
+ } else if (probe.status === "unknown") {
3727
+ // Not "failed": nothing refused to stop. The query itself could not answer, which is
3728
+ // a different problem with a different fix.
3729
+ stateUnknown = true;
3730
+ }
3625
3731
  if (statusWinswRaw() !== "nonexistent") {
3626
- try { stopWinswService(); stopped = true; } catch { /* best-effort */ }
3732
+ try { stopWinswService(); stopped = true; } catch { failed = true; }
3627
3733
  }
3628
3734
  // `schtasks /end` ends the task instance but the cmd `:loop` wrapper survives and
3629
3735
  // respawns its child seconds later (issue #764), resurrecting the proxy during a
3630
3736
  // stop or a tray restart. Kill the launcher/wrapper processes outright.
3631
3737
  killWindowsServiceWrapperProcesses();
3632
- if (stopped) return true;
3738
+ // A failure on either backend wins: the other one stopping does not make the live one
3739
+ // safe to update over.
3740
+ const outcome = classifyWindowsServiceStop({ stopped, failed, schedulerStopped, stateUnknown });
3741
+ if (outcome !== "absent") return outcome;
3633
3742
  } else if (process.platform === "linux" && isSystemd() && existsSync(unitPath())) {
3634
- try { stopSystemd(); return true; } catch { return false; }
3743
+ try { stopSystemd(); return "stopped"; } catch { return "failed"; }
3635
3744
  }
3636
- return false;
3745
+ return "absent";
3637
3746
  }
3638
3747
 
3639
3748
  /** Delete install-state files; stale state would make `ocx update` "reinstall" a service that no longer exists. */
@@ -3666,13 +3775,22 @@ export function setUninstallServiceHooksForTests(hooks: UninstallServiceHooksFor
3666
3775
  * service or scheduler task that cannot be removed throws so the caller cannot erase state and
3667
3776
  * report success.
3668
3777
  */
3669
- export function uninstallServiceIfInstalled(): boolean {
3778
+ /**
3779
+ * Outcome of removing an installed manager.
3780
+ *
3781
+ * `false` used to mean both "nothing was installed" and "removal failed" on darwin and
3782
+ * linux, so a failed removal was reported as absence and authorized the shared teardown
3783
+ * while the service assets were still there (#3008).
3784
+ */
3785
+ export type ServiceUninstallOutcome = "absent" | "removed" | "failed";
3786
+
3787
+ export function uninstallServiceDetailed(): ServiceUninstallOutcome {
3670
3788
  const hooks = uninstallServiceHooksForTests;
3671
3789
  (hooks?.assertEnvironment ?? assertServiceEnvironmentMatchesInstall)();
3672
3790
  const platform = hooks?.platform ?? process.platform;
3673
3791
  if (platform === "darwin") {
3674
3792
  if (existsSync(plistPath())) {
3675
- try { uninstallLaunchd(); removeServiceInstallState(); return true; } catch { return false; }
3793
+ try { uninstallLaunchd(); removeServiceInstallState(); return "removed"; } catch { return "failed"; }
3676
3794
  }
3677
3795
  } else if (platform === "win32") {
3678
3796
  let removed = false;
@@ -3688,13 +3806,20 @@ export function uninstallServiceIfInstalled(): boolean {
3688
3806
  (hooks?.uninstallNative ?? uninstallWinswService)();
3689
3807
  removed = true;
3690
3808
  }
3691
- if (removed) { (hooks?.removeInstallState ?? removeServiceInstallState)(); return true; }
3809
+ if (removed) { (hooks?.removeInstallState ?? removeServiceInstallState)(); return "removed"; }
3692
3810
  } else if (platform === "linux" && existsSync(unitPath())) {
3693
- try { uninstallSystemd(); removeServiceInstallState(); return true; } catch {
3694
- try { unlinkSync(unitPath()); removeServiceInstallState(); return true; } catch { return false; }
3811
+ try { uninstallSystemd(); removeServiceInstallState(); return "removed"; } catch {
3812
+ try { unlinkSync(unitPath()); removeServiceInstallState(); return "removed"; } catch { return "failed"; }
3695
3813
  }
3696
3814
  }
3697
- return false;
3815
+ return "absent";
3816
+ }
3817
+
3818
+ /** Boolean form for callers that only distinguish "something was removed". */
3819
+ export function uninstallServiceIfInstalled(): boolean {
3820
+ const outcome = uninstallServiceDetailed();
3821
+ if (outcome === "failed") throw new Error("the installed service could not be removed");
3822
+ return outcome === "removed";
3698
3823
  }
3699
3824
 
3700
3825
  /** True if a background service (launchd/systemd/Task Scheduler) is installed. */
@@ -4204,11 +4329,17 @@ export async function serviceCommand(...args: (string | undefined)[]): Promise<v
4204
4329
  const restore = await restoreNativeCodexAsync();
4205
4330
  if (restore.success) console.log("✅ service stopped + native Codex restored.");
4206
4331
  else console.error(`⚠️ service stopped, but native Codex restore FAILED: ${restore.message}\nRun \`ocx restore\` (or check $CODEX_HOME/config.toml) before using native Codex.`);
4332
+ if (!restore.success) process.exitCode = 1;
4207
4333
  // The Grok fence is the other managed config this command owns. Leaving it behind
4208
4334
  // pointed grok at a dead endpoint while native Codex was already restored.
4209
4335
  const grok = stripGrokConfig();
4210
4336
  if (grok.changed) console.log(`↩️ ${grok.message}`);
4211
- else if (!grok.ok) console.error(`⚠️ ${grok.message}`);
4337
+ else if (!grok.ok) {
4338
+ // A failed strip leaves Grok aimed at a proxy this command just stopped. Exiting
4339
+ // 0 tells a script the teardown finished when half of it did not.
4340
+ console.error(`⚠️ ${grok.message}`);
4341
+ process.exitCode = 1;
4342
+ }
4212
4343
  }
4213
4344
  break;
4214
4345
  }
@@ -4242,10 +4373,14 @@ export async function serviceCommand(...args: (string | undefined)[]): Promise<v
4242
4373
  const restore = await restoreNativeCodexAsync();
4243
4374
  if (!restore.success) {
4244
4375
  console.error(`⚠️ native Codex restore FAILED: ${restore.message}\nRun \`ocx restore\` before using native Codex.`);
4376
+ process.exitCode = 1;
4245
4377
  }
4246
4378
  const grok = stripGrokConfig();
4247
4379
  if (grok.changed) console.log(`↩️ ${grok.message}`);
4248
- else if (!grok.ok) console.error(`⚠️ ${grok.message}`);
4380
+ else if (!grok.ok) {
4381
+ console.error(`⚠️ ${grok.message}`);
4382
+ process.exitCode = 1;
4383
+ }
4249
4384
  }
4250
4385
  removeServiceInstallState();
4251
4386
  try { if (existsSync(serviceApiTokenFilePath())) unlinkSync(serviceApiTokenFilePath()); } catch { /* best-effort */ }
@@ -1,9 +1,14 @@
1
1
  import { spawn, spawnSync } from "node:child_process";
2
+ import { STOP_HISTORY_INCOMPLETE_EXIT_CODE } from "./stop-contract.mjs";
3
+ import { proxyIdentityAt } from "../server/proxy-liveness";
4
+ import { probeProxyLiveness } from "./proxy-liveness-probe.mjs";
5
+ import { decidePostStopUpdate } from "./stop-decision.mjs";
2
6
  import { readFileSync, readdirSync } from "node:fs";
3
7
  import { fileURLToPath } from "node:url";
4
8
  import { dirname, join } from "node:path";
5
9
  import { getConfigDir, loadConfig } from "../config";
6
10
  import { readPid, readRuntimePort } from "../config/process-state";
11
+ import { pendingTeardownOutstanding } from "../config/pending-teardown";
7
12
  import { npmInvocation } from "./npm-invocation.mjs";
8
13
  import {
9
14
  npmCachePreflightFailureMessage,
@@ -246,8 +251,13 @@ export async function runUpdate(): Promise<void> {
246
251
  // modules after startup, so an in-place update leaves it executing mixed old/new code.
247
252
  // Gate on the service and the runtime-port record too, not just the pid file — a
248
253
  // service-managed or orphaned proxy can be live while ocx.pid is stale/missing.
254
+ //
255
+ // An outstanding pending-teardown receipt is a fourth reason to run the stop. After a
256
+ // parent crashed mid-deferral all three of the other signals can be absent while the
257
+ // shared client config still points at a proxy that is gone; installing over that
258
+ // silently skips the recovery the receipt was written to trigger (#3008).
249
259
  // Full `ocx stop` semantics (drain, service stop, restore).
250
- if (serviceWasInstalled || readPid() || readRuntimePort()) {
260
+ if (serviceWasInstalled || readPid() || readRuntimePort() || pendingTeardownOutstanding()) {
251
261
  console.log("⏹ Stopping the running proxy before updating...");
252
262
  const stopStdio = updateChildStdio();
253
263
  const stop = spawnSync(process.execPath, selfLaunchArgv(["stop"]), {
@@ -256,17 +266,39 @@ export async function runUpdate(): Promise<void> {
256
266
  windowsHide: true,
257
267
  });
258
268
  if (stopStdio === "pipe") logSpawnOutput("", stop);
259
- if (stop.status !== 0 || readPid() || readRuntimePort()) {
269
+ // One decision, shared with the npm launcher (#3008). The two lanes disagreeing about
270
+ // the same situation is how this shipped fixed on one side only. Absent PID and runtime
271
+ // files are weak evidence - a crashed-but-listening proxy leaves none - so the captured
272
+ // endpoint is asked, and `null` from proxyIdentityAt covers refusal AND timeout alike.
273
+ const identity = await proxyIdentityAt(capturedListen.port, { hostname: capturedListen.hostname });
274
+ const decision = decidePostStopUpdate({
275
+ status: stop.status,
276
+ hasRuntimeState: !!(readPid() || readRuntimePort()),
277
+ // Re-checked AFTER the stop: a quarantined receipt lets the stop itself succeed
278
+ // (there is nothing left to stop), so a pre-stop check alone let the retry install
279
+ // over a teardown that never ran.
280
+ teardownOutstanding: pendingTeardownOutstanding(),
281
+ liveness: identity ? "live" : probeProxyLiveness(capturedListen.port, capturedListen.hostname),
282
+ });
283
+ const historyOnlyStop = decision.reason === "history-only";
284
+ if (!decision.proceed) {
260
285
  if (trayWasRunning) {
261
286
  try {
262
287
  const { startWindowsTray } = await import("../tray/windows");
263
288
  startWindowsTray();
264
289
  } catch { /* preserve the proxy stop failure */ }
265
290
  }
266
- console.error("⚠️ Could not stop the running proxy; aborting the update. Run 'ocx stop' and retry.");
291
+ if (decision.reason === "teardown-outstanding") {
292
+ console.error("⚠️ A shared teardown from an earlier stop is still outstanding and needs manual review; aborting the update.");
293
+ console.error(" Confirm no proxy is running, run 'ocx restore', then remove the pending-teardown file in your opencodex home.");
294
+ } else {
295
+ console.error(decision.reason === "proxy-unknown"
296
+ ? `⚠️ Could not confirm the proxy on ${capturedListen.hostname}:${capturedListen.port} is stopped; aborting the update. Run 'ocx stop' and retry.`
297
+ : "⚠️ Could not stop the running proxy; aborting the update. Run 'ocx stop' and retry.");
298
+ }
267
299
  process.exit(1);
268
300
  }
269
- if (historyRestoreIncomplete()) {
301
+ if (historyOnlyStop || historyRestoreIncomplete()) {
270
302
  console.warn(
271
303
  "⚠️ Codex resume-history metadata restore is incomplete (a backup manifest remains).\n" +
272
304
  " The DB may be busy or the manifest/target may need review; untracked routed history is intentionally unchanged.\n" +
@@ -0,0 +1,6 @@
1
+ /** Declaration for the plain-ESM liveness probe shared with `bin/ocx.mjs`. */
2
+ export declare function probeProxyLiveness(
3
+ port: number,
4
+ hostname?: string,
5
+ timeoutMs?: number,
6
+ ): "live" | "dead" | "unknown";
@@ -0,0 +1,84 @@
1
+ import { spawnSync } from "node:child_process";
2
+
3
+ /**
4
+ * Is something still answering `/healthz` as an opencodex proxy on this endpoint?
5
+ *
6
+ * Absent PID and runtime-port files are weak evidence that the proxy is gone: a crashed
7
+ * but still-listening process, or one supervised outside our records, leaves no files and
8
+ * keeps the port. Replacing package files under it leaves a server running a mix of old
9
+ * and new modules, which is the hazard `ocx update` stops the proxy to avoid (#3008).
10
+ *
11
+ * Synchronous and dependency-free because it runs inside the plain-Node launcher's
12
+ * `runNpmSelfUpdate`, which is not async and cannot import the TypeScript liveness module.
13
+ * A separate Node child does the fetch so the caller keeps its straight-line control flow.
14
+ *
15
+ * Returns `"live" | "dead" | "unknown"`, and the caller treats `unknown` as a reason to
16
+ * stop. Fail-open was wrong here: a listener that accepts connections but withholds
17
+ * `/healthz`, or a probe that times out, is exactly the state where replacing package
18
+ * files is most dangerous, and "we could not tell" is not evidence the proxy is gone.
19
+ * Only a refused connection or a definitive non-OpenCodex answer earns `"dead"`.
20
+ */
21
+ export function probeProxyLiveness(port, hostname = "127.0.0.1", timeoutMs = 1500) {
22
+ // An unusable port is not an ambiguous probe: there is nothing to ask.
23
+ if (!Number.isFinite(port) || port <= 0 || port > 65535) return "dead";
24
+ // Normalize HERE rather than at each call site. Leaving it to the callers put the fix in
25
+ // one lane and not the other, and a bracketed IPv6 literal handed to node:http answers
26
+ // nothing - which the tri-state correctly reports as "unknown" and the updater correctly
27
+ // treats as a reason to abort, turning a healthy stop into a refused update.
28
+ let host = typeof hostname === "string" && hostname.trim() !== "" ? hostname.trim() : "127.0.0.1";
29
+ // A wildcard bind answers on loopback; `node:http` cannot dial the wildcard itself.
30
+ if (host === "0.0.0.0" || host === "*") host = "127.0.0.1";
31
+ if (host === "::" ) host = "::1";
32
+ // `[::1]` is a URL spelling; the socket layer wants the bare address.
33
+ if (host.startsWith("[") && host.endsWith("]")) host = host.slice(1, -1);
34
+ // `node:http` rather than `fetch`: the child inherits a parent whose event loop is
35
+ // blocked on `spawnSync`, and an aborted-before-dispatch fetch reports the same "not
36
+ // live" as a genuinely dead port. A request emitted on the socket cannot be confused
37
+ // with one that never left.
38
+ const script = [
39
+ "const http = require('node:http');",
40
+ "const [host, port, timeout] = process.argv.slice(1);",
41
+ "const req = http.get({ host, port: Number(port), path: '/healthz', timeout: Number(timeout) }, res => {",
42
+ " let body = '';",
43
+ " res.setEncoding('utf8');",
44
+ " res.on('data', chunk => { body += chunk; });",
45
+ " res.on('end', () => {",
46
+ " try {",
47
+ " const parsed = JSON.parse(body);",
48
+ " // Mirrors isOpencodexHealthz in src/server/proxy-liveness.ts. A foreign server",
49
+ " // that happens to expose /healthz must not be read as our proxy, and a",
50
+ " // pre-identity build of ours must not be read as foreign.",
51
+ " const isOpencodex = parsed && typeof parsed === 'object'",
52
+ " && (parsed.service === 'opencodex'",
53
+ " || (parsed.service === undefined",
54
+ " && parsed.status === 'ok'",
55
+ " && typeof parsed.version === 'string'",
56
+ " && typeof parsed.uptime === 'number'));",
57
+ " // Only a clean 200 decides anything. Any other status means the endpoint is",
58
+ " // answering but not telling us what it is, which is not evidence of absence.",
59
+ " if (res.statusCode !== 200) process.stdout.write('UNKNOWN');",
60
+ " else process.stdout.write(isOpencodex ? 'LIVE' : 'DEAD');",
61
+ " } catch { process.stdout.write('UNKNOWN'); }",
62
+ " });",
63
+ "});",
64
+ "req.on('timeout', () => { process.stdout.write('UNKNOWN'); req.destroy(); });",
65
+ "// ECONNREFUSED is the one error that proves nothing is listening. Everything else -",
66
+ "// reset, unreachable host, TLS confusion - leaves the question open.",
67
+ "req.on('error', err => process.stdout.write(err && err.code === 'ECONNREFUSED' ? 'DEAD' : 'UNKNOWN'));",
68
+ ].join("\n");
69
+ try {
70
+ const probe = spawnSync(
71
+ process.execPath,
72
+ ["-e", script, host, String(port), String(timeoutMs)],
73
+ { encoding: "utf8", timeout: timeoutMs + 1500, windowsHide: true },
74
+ );
75
+ const out = probe.stdout ?? "";
76
+ if (out.includes("LIVE")) return "live";
77
+ if (out.includes("DEAD")) return "dead";
78
+ // A child that produced nothing, was killed by its own timeout, or failed to spawn
79
+ // leaves the question open rather than answering it.
80
+ return "unknown";
81
+ } catch {
82
+ return "unknown";
83
+ }
84
+ }
@@ -0,0 +1,2 @@
1
+ /** Declaration for the plain-ESM stop contract shared with `bin/ocx.mjs`. */
2
+ export declare const STOP_HISTORY_INCOMPLETE_EXIT_CODE: 79;
@@ -0,0 +1,15 @@
1
+ /**
2
+ * The exit code `ocx stop` uses to say "teardown succeeded, history cleanup did not".
3
+ *
4
+ * This is plain ESM rather than TypeScript because it has two consumers on opposite sides
5
+ * of a process boundary: `src/update/index.ts` and the Node launcher `bin/ocx.mjs`, which
6
+ * cannot import a `.ts` module. A TypeScript union would not survive `spawnSync` anyway —
7
+ * the value has to be on the wire, and an exit code is the wire.
8
+ *
9
+ * 79 is deliberate. It sits above the `sysexits.h` block (64-78), below `128 + signal`,
10
+ * and outside every code this CLI already uses: `src/cli/index.ts` emits 0, 1 and 130,
11
+ * and `src/cli/dispatch.ts` adds 2, 4 and 64. Picking one of those would have made a
12
+ * history-only stop indistinguishable from a config conflict, and `bin/ocx.mjs` mirrors
13
+ * the child's code faithfully enough to propagate the confusion.
14
+ */
15
+ export const STOP_HISTORY_INCOMPLETE_EXIT_CODE = 79;
@@ -0,0 +1,10 @@
1
+ /** Declaration for the plain-ESM post-stop decision shared with `bin/ocx.mjs`. */
2
+ export declare function decidePostStopUpdate(input: {
3
+ status: number | null;
4
+ hasRuntimeState: boolean;
5
+ liveness: "live" | "dead" | "unknown";
6
+ teardownOutstanding?: boolean;
7
+ }): {
8
+ proceed: boolean;
9
+ reason: "stop-failed" | "runtime-state" | "teardown-outstanding" | "proxy-live" | "proxy-unknown" | "history-only" | "ok";
10
+ };
@@ -0,0 +1,34 @@
1
+ import { STOP_HISTORY_INCOMPLETE_EXIT_CODE } from "./stop-contract.mjs";
2
+
3
+ /**
4
+ * May an update replace package files after `ocx stop` returned?
5
+ *
6
+ * Both updaters ask this: `src/update/index.ts` on the Bun path and `bin/ocx.mjs` on the
7
+ * npm path the dashboard uses. It lives here as plain ESM so the Node launcher can import
8
+ * it, and so the two lanes cannot drift into disagreeing about the same situation — which
9
+ * is how #3008 shipped in the first place, with the fix on one side only.
10
+ *
11
+ * Returns `{ proceed, reason }`. The reasons are:
12
+ *
13
+ * - `stop-failed` — a nonzero status other than the history-only code, or a signal kill.
14
+ * A signal kill carries no evidence the teardown finished, so it is not a maybe.
15
+ * - `runtime-state` — a PID or runtime-port record survived the stop.
16
+ * - `teardown-outstanding` — a shared-teardown obligation survived the stop. That is a
17
+ * quarantined receipt awaiting a human: the stop itself can succeed (there was nothing
18
+ * left to stop), so checking only BEFORE the stop let the retry sail straight through
19
+ * and install over a teardown that never ran.
20
+ * - `proxy-live` — something is still answering as our proxy on the captured endpoint.
21
+ * - `proxy-unknown` — the probe could not answer. Absence of proof is not proof of
22
+ * absence, and replacing files under a live server leaves it running a mix of old and
23
+ * new modules.
24
+ * - `ok` / `history-only` — proceed; the second also prints the manifest warning.
25
+ */
26
+ export function decidePostStopUpdate({ status, hasRuntimeState, liveness, teardownOutstanding = false }) {
27
+ const historyOnly = status === STOP_HISTORY_INCOMPLETE_EXIT_CODE;
28
+ if (status !== 0 && !historyOnly) return { proceed: false, reason: "stop-failed" };
29
+ if (hasRuntimeState) return { proceed: false, reason: "runtime-state" };
30
+ if (teardownOutstanding) return { proceed: false, reason: "teardown-outstanding" };
31
+ if (liveness === "live") return { proceed: false, reason: "proxy-live" };
32
+ if (liveness !== "dead") return { proceed: false, reason: "proxy-unknown" };
33
+ return { proceed: true, reason: historyOnly ? "history-only" : "ok" };
34
+ }
@@ -21,6 +21,7 @@
21
21
  * explicit modalities.
22
22
  */
23
23
  import { modelInList, type OcxConfig, type OcxProviderConfig } from "../types";
24
+ import { modelRecordValue } from "../reasoning-effort";
24
25
  import { getModelMetadataCaseInsensitive, resolveMetadataProvider } from "../generated/model-metadata";
25
26
  import { nativeInputModalities } from "../codex/catalog/metadata";
26
27
  import { SUPPORTED_NATIVE_OPENAI_SLUGS } from "../codex/catalog/native-models";
@@ -68,6 +69,22 @@ export interface VisionModelOption {
68
69
 
69
70
  type EnrichedProviderCache = Map<string, OcxProviderConfig>;
70
71
 
72
+ /**
73
+ * Whether the proxy must describe images for this model before dispatching its main request.
74
+ *
75
+ * `noVisionModels` is an explicit override. A modality declaration is only evidence for this
76
+ * path when it describes a text model that excludes image input: an audio-only declaration is
77
+ * not a text-only model and must not be widened to image through the vision sidecar.
78
+ */
79
+ export function isModelVisionSidecarConsumer(
80
+ provider: Pick<OcxProviderConfig, "noVisionModels" | "modelInputModalities">,
81
+ modelId: string,
82
+ ): boolean {
83
+ if (modelInList(provider.noVisionModels, modelId)) return true;
84
+ const modalities = modelRecordValue(provider.modelInputModalities, modelId);
85
+ return Array.isArray(modalities) && modalities.includes("text") && !modalities.includes("image");
86
+ }
87
+
71
88
  function advertisesImageInput(modalities: readonly string[] | undefined): boolean | undefined {
72
89
  if (!modalities || modalities.length === 0) return undefined;
73
90
  return modalities.includes("image");
@@ -105,7 +122,8 @@ function isVisionSidecarConsumerWithCache(
105
122
  modelId: string,
106
123
  cache: EnrichedProviderCache,
107
124
  ): boolean {
108
- return modelInList(enrichedProviderForVision(config, providerName, cache)?.noVisionModels, modelId);
125
+ const provider = enrichedProviderForVision(config, providerName, cache);
126
+ return provider !== undefined && isModelVisionSidecarConsumer(provider, modelId);
109
127
  }
110
128
 
111
129
  /**
@@ -1,12 +1,10 @@
1
1
  import { createHash } from "node:crypto";
2
2
  import type { OcxConfig, OcxContentPart, OcxMessage, OcxParsedRequest, OcxProviderConfig, OcxTextContent } from "../types";
3
- import { modelInList } from "../types";
4
- import { modelRecordValue } from "../reasoning-effort";
5
3
  import type { VisionReasoningEffort } from "../reasoning-effort";
6
4
  import { describeImage, type DescribeOutcome, type VisionSettings } from "./describe";
7
5
  import { describeImageAnthropic } from "./anthropic-describe";
8
6
  import { describeImageRouted } from "./routed-describe";
9
- import { modelAcceptsImageInput } from "./eligibility";
7
+ import { isModelVisionSidecarConsumer as isModelTextOnly, modelAcceptsImageInput } from "./eligibility";
10
8
  import { normalizeVisionReasoningForModel } from "./reasoning";
11
9
  import type { CodexAuthContext } from "../codex/auth-context";
12
10
  import { resolveSidecarAuth } from "../sidecar/auth";
@@ -22,24 +20,12 @@ import {
22
20
 
23
21
  export { describeImage } from "./describe";
24
22
 
25
- /**
26
- * True when the model is explicitly known to be text-only — either listed in
27
- * `noVisionModels` or declared with `modelInputModalities` that exclude "image".
28
- * Returns false for unknown models (no evidence either way) so they fall through
29
- * to native image passthrough, which is the safe default for an unclassified model.
30
- */
31
- export function isModelTextOnly(
32
- provider: OcxProviderConfig,
33
- modelId: string,
34
- ): boolean {
35
- if (modelInList(provider.noVisionModels, modelId)) return true;
36
- const modalities = modelRecordValue(provider.modelInputModalities, modelId);
37
- if (Array.isArray(modalities) && modalities.length > 0 && !modalities.includes("image")) return true;
38
- return false;
39
- }
23
+ /** Backward-compatible request-time name for the shared vision-sidecar consumer predicate. */
24
+ export { isModelVisionSidecarConsumer as isModelTextOnly } from "./eligibility";
40
25
  export { describeImageAnthropic, parseAnthropicVisionSSE } from "./anthropic-describe";
41
26
  export {
42
27
  BASELINE_VISION_MODELS,
28
+ isModelVisionSidecarConsumer,
43
29
  isVisionEligibleModel,
44
30
  isVisionSidecarConsumer,
45
31
  modelAcceptsImageInput,