@bitkyc08/opencodex 2.37.0 → 2.39.0-preview.20260901

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 (131) hide show
  1. package/bin/ocx.mjs +127 -16
  2. package/gui/dist/assets/index-6HZDkIpX.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/aside.svg +3 -0
  6. package/gui/dist/provider-icons/baseten.svg +13 -0
  7. package/gui/dist/provider-icons/bizrouter.svg +41 -0
  8. package/gui/dist/provider-icons/cerebras.svg +26 -0
  9. package/gui/dist/provider-icons/deepinfra.svg +75 -0
  10. package/gui/dist/provider-icons/deepseek-harness.svg +3 -0
  11. package/gui/dist/provider-icons/digitalocean.svg +10 -0
  12. package/gui/dist/provider-icons/featherless.svg +4 -0
  13. package/gui/dist/provider-icons/gajae-code.svg +410 -0
  14. package/gui/dist/provider-icons/hermes-agent.svg +207 -0
  15. package/gui/dist/provider-icons/hyperbolic.svg +18 -0
  16. package/gui/dist/provider-icons/kilo.svg +13 -0
  17. package/gui/dist/provider-icons/litellm.svg +1 -0
  18. package/gui/dist/provider-icons/minimax.svg +1 -0
  19. package/gui/dist/provider-icons/nanogpt.svg +74 -0
  20. package/gui/dist/provider-icons/nebius.svg +1 -0
  21. package/gui/dist/provider-icons/neuralwatt.svg +27 -0
  22. package/gui/dist/provider-icons/nous.svg +149 -0
  23. package/gui/dist/provider-icons/novita.svg +32 -0
  24. package/gui/dist/provider-icons/oh-my-pi.svg +11 -0
  25. package/gui/dist/provider-icons/openclaw.svg +54 -0
  26. package/gui/dist/provider-icons/orcarouter.svg +175 -0
  27. package/gui/dist/provider-icons/parallel.svg +13 -0
  28. package/gui/dist/provider-icons/prime-agent.svg +21 -0
  29. package/gui/dist/provider-icons/sambanova.svg +276 -0
  30. package/gui/dist/provider-icons/scaleway.svg +11 -0
  31. package/gui/dist/provider-icons/siliconflow.svg +18 -0
  32. package/gui/dist/provider-icons/synthetic.svg +12 -0
  33. package/gui/dist/provider-icons/together.svg +18 -0
  34. package/gui/dist/provider-icons/umans.svg +30 -0
  35. package/gui/dist/provider-icons/venice.svg +165 -0
  36. package/gui/dist/provider-icons/vultr.svg +15 -0
  37. package/gui/dist/provider-icons/zai.svg +218 -0
  38. package/gui/dist/provider-icons/zcode.svg +219 -0
  39. package/gui/dist/provider-icons/zenmux.svg +1 -0
  40. package/package.json +1 -1
  41. package/src/adapters/cursor/live-models.ts +1 -0
  42. package/src/adapters/cursor/protobuf-request.ts +4 -1
  43. package/src/adapters/cursor/tool-definitions.ts +36 -4
  44. package/src/adapters/openai-responses.ts +72 -10
  45. package/src/bridge.ts +15 -15
  46. package/src/cli/capabilities.ts +14 -0
  47. package/src/cli/codex-cli-update.ts +96 -0
  48. package/src/cli/codex-shim-autorestore.ts +3 -0
  49. package/src/cli/dispatch.ts +54 -18
  50. package/src/cli/export-command.ts +18 -17
  51. package/src/cli/help.ts +2 -2
  52. package/src/cli/index.ts +423 -32
  53. package/src/cli/integrations.ts +24 -2
  54. package/src/cli/launcher-context.ts +53 -2
  55. package/src/cli/opencode.ts +126 -33
  56. package/src/cli/registry.ts +16 -10
  57. package/src/cli/system-command.ts +6 -1
  58. package/src/cli/uninstall-plan.ts +86 -0
  59. package/src/clients/config-export.ts +293 -28
  60. package/src/codex/account-store.ts +131 -12
  61. package/src/codex/auth-api.ts +202 -33
  62. package/src/codex/autostart-health.ts +3 -3
  63. package/src/codex/catalog/provider-fetch.ts +96 -10
  64. package/src/codex/catalog/sync.ts +4 -3
  65. package/src/codex/cli-install-provenance.ts +795 -0
  66. package/src/codex/convergence.ts +4 -3
  67. package/src/codex/credential-mutation-epoch.ts +11 -0
  68. package/src/codex/history-job.ts +10 -0
  69. package/src/codex/history-manifest.ts +35 -2
  70. package/src/codex/history-provider.ts +196 -19
  71. package/src/codex/history-worker.ts +3 -0
  72. package/src/codex/main-account.ts +2 -0
  73. package/src/codex/model-entitlements.ts +430 -27
  74. package/src/codex/native-profile-manager.ts +4 -0
  75. package/src/codex/quota-401-recovery.ts +190 -0
  76. package/src/codex/quota-recovery-timing.ts +28 -0
  77. package/src/codex/quota.ts +6 -0
  78. package/src/codex/reset-credit-operation-ledger.ts +1411 -0
  79. package/src/codex/reset-credit-recovery.ts +20 -2
  80. package/src/codex/routing.ts +68 -16
  81. package/src/codex/shim.ts +204 -18
  82. package/src/codex/subagent-model-fallback.ts +4 -1
  83. package/src/codex/user-identity.ts +2 -1
  84. package/src/config/paths.ts +18 -3
  85. package/src/config/pending-teardown-names.d.mts +8 -0
  86. package/src/config/pending-teardown-names.mjs +69 -0
  87. package/src/config/pending-teardown.ts +286 -0
  88. package/src/config.ts +23 -0
  89. package/src/generated/compatibility-version.json +155 -67
  90. package/src/integrations/journal.ts +12 -1
  91. package/src/integrations/registry.ts +112 -0
  92. package/src/integrations/state.ts +67 -5
  93. package/src/integrations/writer.ts +107 -16
  94. package/src/lib/bounded-subprocess.ts +36 -0
  95. package/src/lib/process-control.ts +30 -5
  96. package/src/lib/state-store-registrations.ts +8 -0
  97. package/src/lib/strict-semver.ts +47 -0
  98. package/src/lib/windows-elevation.ts +32 -1
  99. package/src/lib/windows-secret-acl.ts +47 -25
  100. package/src/lib/windows-service-mutation-lock.ts +133 -0
  101. package/src/lib/windows-user-principal.ts +15 -17
  102. package/src/oauth/index.ts +220 -13
  103. package/src/oauth/store.ts +220 -18
  104. package/src/responses/spill-store.ts +354 -29
  105. package/src/responses/state.ts +775 -8
  106. package/src/server/index.ts +4 -3
  107. package/src/server/lifecycle.ts +5 -1
  108. package/src/server/management/integration-routes.ts +36 -5
  109. package/src/server/management/model-rows.ts +11 -2
  110. package/src/server/management/provider-routes.ts +4 -0
  111. package/src/server/management/system-restart.ts +5 -5
  112. package/src/server/management-api.ts +72 -13
  113. package/src/server/responses/encrypted-payload.ts +18 -1
  114. package/src/server/startup-action-control.ts +3 -2
  115. package/src/server/stop-teardown.ts +84 -0
  116. package/src/service.ts +749 -53
  117. package/src/sidecar/candidates.ts +1 -1
  118. package/src/update/codex-cli-update-launch-policy.d.mts +18 -0
  119. package/src/update/codex-cli-update-launch-policy.mjs +30 -0
  120. package/src/update/index.ts +39 -6
  121. package/src/update/job.ts +10 -11
  122. package/src/update/proxy-liveness-probe.d.mts +6 -0
  123. package/src/update/proxy-liveness-probe.mjs +84 -0
  124. package/src/update/stop-contract.d.mts +2 -0
  125. package/src/update/stop-contract.mjs +15 -0
  126. package/src/update/stop-decision.d.mts +10 -0
  127. package/src/update/stop-decision.mjs +34 -0
  128. package/src/vision/eligibility.ts +19 -1
  129. package/src/vision/index.ts +4 -18
  130. package/gui/dist/assets/index-CowztZdo.js +0 -112
  131. package/gui/dist/assets/index-jqE_VOKI.css +0 -1
@@ -37,7 +37,7 @@ export async function pickerVisibleSidecarCandidates(
37
37
  auth: SidecarAuthState,
38
38
  ): Promise<SidecarCandidate[]> {
39
39
  let rows: Awaited<ReturnType<typeof listManagementModelRows>> = [];
40
- try { rows = await listManagementModelRows(config); } catch { rows = []; }
40
+ try { rows = await listManagementModelRows(config, { entitlementWaitMs: 0 }); } catch { rows = []; }
41
41
  const byKey = new Map<string, SidecarCandidate>();
42
42
  for (const row of rows) {
43
43
  if (row.disabled === true) continue;
@@ -0,0 +1,18 @@
1
+ export const CODEX_CLI_VERSION_MANAGER_ROOT_ENV_SLOTS: readonly [
2
+ "ASDF_DATA_DIR",
3
+ "FNM_DIR",
4
+ "FNM_MULTISHELL_PATH",
5
+ "MISE_DATA_DIR",
6
+ "NODENV_ROOT",
7
+ "NVS_HOME",
8
+ "NVS_NODE_PATH",
9
+ "N_PREFIX",
10
+ "NVM_DIR",
11
+ "NVM_HOME",
12
+ "NVM_SYMLINK",
13
+ "PROTO_HOME",
14
+ "SCOOP",
15
+ "SCOOP_GLOBAL",
16
+ "VOLTA_HOME",
17
+ ];
18
+ export function isCodexCliUpdateInspectionArgv(argv: readonly string[]): boolean;
@@ -0,0 +1,30 @@
1
+ export const CODEX_CLI_VERSION_MANAGER_ROOT_ENV_SLOTS = Object.freeze([
2
+ "ASDF_DATA_DIR",
3
+ "FNM_DIR",
4
+ "FNM_MULTISHELL_PATH",
5
+ "MISE_DATA_DIR",
6
+ "NODENV_ROOT",
7
+ "NVS_HOME",
8
+ "NVS_NODE_PATH",
9
+ "N_PREFIX",
10
+ "NVM_DIR",
11
+ "NVM_HOME",
12
+ "NVM_SYMLINK",
13
+ "PROTO_HOME",
14
+ "SCOOP",
15
+ "SCOOP_GLOBAL",
16
+ "VOLTA_HOME",
17
+ ]);
18
+
19
+ /**
20
+ * Detect the read-only Codex CLI updater inspection namespace before Bun loads.
21
+ * Keep this exact and argument-position based: malformed actions still inherit
22
+ * the zero-effect launcher contract and are rejected by the Bun-side parser.
23
+ */
24
+ export function isCodexCliUpdateInspectionArgv(argv) {
25
+ // Bun consumes every internal launch-proof argument before ordinary command
26
+ // parsing. Classify the same effective argv here so a user-supplied invalid
27
+ // proof cannot hide this namespace from the pre-Bun zero-effect policy.
28
+ const args = argv.slice(2).filter(value => !value.startsWith("--ocx-internal-launch-proof="));
29
+ return args[0] === "system" && args[1] === "codex-cli-update";
30
+ }
@@ -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" +
@@ -347,8 +379,9 @@ export async function runUpdate(): Promise<void> {
347
379
  }
348
380
  }
349
381
  if (!serviceRefreshed || !serviceViable) {
350
- // A repair needs no elevation (it never calls `schtasks /create`), but it can
351
- // still fail or exit 0 while leaving stale/missing assets that never start
382
+ // Repair normally avoids elevation for a healthy scheduler task, but a stale
383
+ // definition may require guarded create/elevation. It can also fail or exit 0
384
+ // while leaving stale/missing assets that never start
352
385
  // the proxy. Fall back to a direct detached proxy start so the update
353
386
  // never leaves the user without a running proxy — but only when the port is free.
354
387
  if (!freed) {
package/src/update/job.ts CHANGED
@@ -459,8 +459,8 @@ export function restartCommand(
459
459
  const startArgs = pinPort
460
460
  ? [launcher, "start", "--port", String(Math.trunc(port))]
461
461
  : [launcher, "start"];
462
- // Default to the non-registering refresh: an update path reaching here has an already
463
- // installed service, and `install` would demand elevation on Windows scheduler backends.
462
+ // Default to the in-place refresh: `install` always registers, while repair reuses a healthy
463
+ // Windows scheduler definition and re-registers only when the live definition is stale.
464
464
  const svcArgs = serviceInstalled ? [launcher, ...(serviceArgs ?? ["service", "repair"])] : startArgs;
465
465
  if (installer === "npm") {
466
466
  const bin = nodeBin();
@@ -1116,12 +1116,10 @@ async function restartAfterUpdate(
1116
1116
  const preServiceAllow = reclaimKillAllowlist();
1117
1117
  const freed = await waitFn(port, hostname, reclaimOptsFor(preServiceAllow));
1118
1118
  let skipServiceInstall = false;
1119
- // This skip existed because the refresh ran `ocx service install`, whose Windows
1120
- // scheduler path always reaches `schtasks /create` elevation the GUI update worker
1121
- // (OCX_SERVICE=1) never has. `service repair` rewrites the wrapper assets and
1122
- // restarts the EXISTING task with no `/create`, so the reason no longer applies and
1123
- // skipping would leave the dashboard-triggered update — the most common Windows
1124
- // path — with a stale service it could have refreshed.
1119
+ // This skip existed because refresh ran `ocx service install`, whose Windows path always
1120
+ // registers. `service repair` normally reuses the live task and can refresh a stale
1121
+ // definition through its guarded create/elevation path, so the install-only skip no longer
1122
+ // applies and would leave the common dashboard update with stale service assets.
1125
1123
  //
1126
1124
  // Only a caller that still passes install argv keeps the old behavior.
1127
1125
  const refreshRegisters = (svcArgs ?? []).includes("install");
@@ -1161,9 +1159,10 @@ async function restartAfterUpdate(
1161
1159
  const result = run(job, cmd.bin, cmd.args);
1162
1160
  serviceOk = result.status === 0;
1163
1161
  if (!serviceOk) {
1164
- // The refresh that just failed was `ocx service repair` (serviceReinstallArgs),
1165
- // which needs no elevation because it never calls `schtasks /create`. Advising
1166
- // `install` here would send the user to re-registration a UAC prompt on
1162
+ // The refresh that just failed was `ocx service repair` (serviceReinstallArgs).
1163
+ // It normally reuses a healthy registration, but a stale definition may have tried
1164
+ // guarded re-registration/elevation. Advising `install` here would unconditionally
1165
+ // send the user to re-registration — a UAC prompt on
1167
1166
  // Windows and a possible WinSW-to-scheduler backend switch — to fix a service
1168
1167
  // that is already registered. Point at the same command that failed so its
1169
1168
  // output explains why, on every platform.
@@ -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,