@bitkyc08/opencodex 2.35.0 → 2.36.0-preview.20260830

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 (155) hide show
  1. package/gui/dist/assets/index-Cy7Z_pl0.css +1 -0
  2. package/gui/dist/assets/index-DPl4nBMA.js +112 -0
  3. package/gui/dist/index.html +2 -2
  4. package/package.json +2 -1
  5. package/src/AGENTS.md +2 -1
  6. package/src/adapters/agentrouter.ts +50 -0
  7. package/src/adapters/anthropic.ts +1 -51
  8. package/src/adapters/cursor/call-id.ts +76 -8
  9. package/src/adapters/cursor/checkpoint-store.ts +6 -1
  10. package/src/adapters/cursor/cursor-errors.ts +44 -0
  11. package/src/adapters/cursor/native-exec.ts +13 -0
  12. package/src/adapters/cursor/protobuf-request.ts +651 -29
  13. package/src/adapters/cursor/tool-result-normalize.ts +3 -3
  14. package/src/adapters/cursor/transport-retry.ts +5 -1
  15. package/src/adapters/cursor.ts +15 -1
  16. package/src/adapters/empty-tool-output-annotation.ts +43 -0
  17. package/src/adapters/exec-tool-result-normalize.ts +70 -5
  18. package/src/adapters/google.ts +22 -2
  19. package/src/adapters/kiro.ts +26 -2
  20. package/src/adapters/ollama-native-url.ts +111 -0
  21. package/src/adapters/ollama-native.ts +1131 -0
  22. package/src/adapters/openai-chat.ts +30 -7
  23. package/src/adapters/openai-responses.ts +72 -4
  24. package/src/adapters/registry.ts +7 -0
  25. package/src/adapters/xai-web-search.ts +58 -0
  26. package/src/claude/desktop-3p.ts +21 -1
  27. package/src/claude/desktop-policy.ts +149 -0
  28. package/src/cli/account.ts +16 -2
  29. package/src/cli/claude-desktop.ts +13 -3
  30. package/src/cli/combo.ts +8 -5
  31. package/src/cli/doctor.ts +77 -11
  32. package/src/cli/help.ts +1 -1
  33. package/src/cli/index.ts +16 -0
  34. package/src/cli/models.ts +20 -3
  35. package/src/cli/registry.ts +2 -1
  36. package/src/cli/status.ts +140 -2
  37. package/src/cli/storage.ts +10 -1
  38. package/src/codex/account-runtime-state.ts +39 -5
  39. package/src/codex/account-store.ts +393 -13
  40. package/src/codex/account-usability.ts +11 -4
  41. package/src/codex/app-server-processes.ts +46 -5
  42. package/src/codex/auth-context.ts +160 -32
  43. package/src/codex/catalog/bundled.ts +7 -5
  44. package/src/codex/catalog/metadata.ts +1 -1
  45. package/src/codex/catalog/parsing.ts +57 -1
  46. package/src/codex/catalog/provider-fetch.ts +61 -4
  47. package/src/codex/catalog/sync.ts +4 -3
  48. package/src/codex/convergence.ts +3 -2
  49. package/src/codex/data/upstream-models.json +40 -8
  50. package/src/codex/inject-coordination.ts +111 -14
  51. package/src/codex/integration-record.ts +12 -2
  52. package/src/codex/main-account.ts +225 -1
  53. package/src/codex/model-entitlements.ts +339 -27
  54. package/src/codex/prompt-layers.ts +346 -7
  55. package/src/codex/prompt-text-probe.ts +272 -21
  56. package/src/codex/routing.ts +693 -132
  57. package/src/codex/runtime.ts +12 -0
  58. package/src/codex/subagent-model-fallback.ts +62 -24
  59. package/src/codex/user-identity.ts +33 -25
  60. package/src/combos/index.ts +1 -0
  61. package/src/combos/reset-window.ts +46 -0
  62. package/src/combos/resolve.ts +84 -2
  63. package/src/combos/types.ts +5 -2
  64. package/src/config/atomic-write.ts +104 -22
  65. package/src/config/provider-validation.ts +11 -0
  66. package/src/config.ts +75 -3
  67. package/src/generated/compatibility-version.json +207 -131
  68. package/src/generated/model-metadata.ts +1 -1
  69. package/src/grok/catalog.ts +71 -0
  70. package/src/grok/effort.ts +83 -0
  71. package/src/grok/inject.ts +952 -127
  72. package/src/grok/models.ts +56 -0
  73. package/src/grok/status.ts +21 -8
  74. package/src/grok/sync.ts +10 -18
  75. package/src/images/loop.ts +6 -3
  76. package/src/integrations/native/ownership-preflight.ts +4 -1
  77. package/src/lab/fabric/producer-isolate.ts +36 -3
  78. package/src/lib/destination-policy.ts +93 -7
  79. package/src/lib/redact.ts +6 -1
  80. package/src/lib/shadow-call.ts +38 -3
  81. package/src/lib/test-home-guard.ts +18 -3
  82. package/src/lib/upstream-retry.ts +43 -6
  83. package/src/lib/windows-secret-acl.ts +66 -0
  84. package/src/lib/windows-text.ts +28 -2
  85. package/src/lib/windows-user-principal.ts +35 -23
  86. package/src/oauth/account-quota-rank.ts +107 -0
  87. package/src/oauth/anthropic-routing.ts +125 -30
  88. package/src/oauth/chatgpt.ts +5 -1
  89. package/src/oauth/generic-account-failover.ts +114 -7
  90. package/src/oauth/index.ts +15 -8
  91. package/src/oauth/store.ts +16 -0
  92. package/src/providers/account-quota-disk.ts +79 -0
  93. package/src/providers/command-code-efforts.ts +24 -0
  94. package/src/providers/derive.ts +6 -0
  95. package/src/providers/key-failover.ts +33 -1
  96. package/src/providers/kiro-usage.ts +272 -0
  97. package/src/providers/ollama-show.ts +311 -0
  98. package/src/providers/openai-sidecar.ts +5 -0
  99. package/src/providers/quota-routing-cache.ts +32 -0
  100. package/src/providers/quota-types.ts +36 -0
  101. package/src/providers/quota-wire.ts +102 -0
  102. package/src/providers/quota.ts +208 -147
  103. package/src/providers/registry.ts +68 -8
  104. package/src/providers/slug-codec.ts +12 -4
  105. package/src/providers/vercel-gateway-routing.ts +108 -0
  106. package/src/router.ts +22 -12
  107. package/src/server/auth-cors.ts +26 -0
  108. package/src/server/catalog-download.ts +73 -0
  109. package/src/server/chat-native.ts +12 -2
  110. package/src/server/gui-static.ts +4 -1
  111. package/src/server/index.ts +132 -9
  112. package/src/server/management/agent-settings-routes.ts +38 -5
  113. package/src/server/management/codex-prompt-routes.ts +7 -1
  114. package/src/server/management/combo-routes.ts +10 -1
  115. package/src/server/management/config-routes.ts +9 -1
  116. package/src/server/management/context.ts +5 -0
  117. package/src/server/management/model-routes.ts +16 -6
  118. package/src/server/management/native-integration-routes.ts +12 -17
  119. package/src/server/management/oauth-account-routes.ts +13 -0
  120. package/src/server/management/provider-routes.ts +32 -5
  121. package/src/server/management/routing-profile-routes.ts +15 -0
  122. package/src/server/management/shadow-call-validation.ts +29 -0
  123. package/src/server/management-api.ts +7 -3
  124. package/src/server/request-log.ts +3 -5
  125. package/src/server/responses/agent-task-recovery-cache.ts +8 -0
  126. package/src/server/responses/agent-task-recovery.ts +52 -20
  127. package/src/server/responses/codex-auth-error.ts +26 -0
  128. package/src/server/responses/compact.ts +345 -10
  129. package/src/server/responses/core.ts +736 -108
  130. package/src/server/responses/empty-completion-guard.ts +16 -0
  131. package/src/server/responses/fetch-helpers.ts +42 -0
  132. package/src/server/responses/policy-fallback.ts +11 -6
  133. package/src/server/responses-undeclared-tool-guard.ts +16 -3
  134. package/src/server/startup-health-cache.ts +59 -13
  135. package/src/service-manager-probe.ts +115 -9
  136. package/src/service.ts +139 -40
  137. package/src/storage/cleanup.ts +10 -0
  138. package/src/storage/storage-mutation-coordinator.ts +14 -3
  139. package/src/tray/windows-tray.ps1 +10 -4
  140. package/src/tray/windows.ts +30 -2
  141. package/src/types/config.ts +27 -14
  142. package/src/types/provider.ts +54 -0
  143. package/src/types/tools.ts +13 -3
  144. package/src/types.ts +4 -0
  145. package/src/usage/summary.ts +421 -177
  146. package/src/vision/anthropic-describe.ts +3 -3
  147. package/src/vision/describe.ts +5 -3
  148. package/src/web-search/anthropic-executor.ts +9 -2
  149. package/src/web-search/exa-executor.ts +3 -3
  150. package/src/web-search/executor.ts +8 -3
  151. package/src/web-search/gemini-executor.ts +3 -3
  152. package/src/web-search/loop.ts +11 -3
  153. package/src/web-search/xai-executor.ts +3 -3
  154. package/gui/dist/assets/index-DNdRKXK9.js +0 -112
  155. package/gui/dist/assets/index-DQ-Ie18T.css +0 -1
package/src/service.ts CHANGED
@@ -7,16 +7,16 @@
7
7
  */
8
8
  import { execFileSync, execSync, spawnSync } from "node:child_process";
9
9
  import { findLiveProxy, proxyIdentityAt, SERVICE_STOP_LIVENESS } from "./server/proxy-liveness";
10
- import { chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmdirSync, unlinkSync, writeFileSync } from "node:fs";
10
+ import { accessSync, chmodSync, constants as fsConstants, existsSync, mkdirSync, mkdtempSync, readFileSync, rmdirSync, statSync, unlinkSync, writeFileSync } from "node:fs";
11
11
  import { homedir, tmpdir } from "node:os";
12
- import { dirname, join, posix, resolve, win32 } from "node:path";
12
+ import { delimiter, dirname, isAbsolute, join, posix, resolve, win32 } from "node:path";
13
13
  import { expandUserPath, getConfigDir, loadConfig } from "./config";
14
14
  import { readPid, removePid, removeRuntimePort, verifyPidIdentity } from "./config/process-state";
15
15
  import { restoreNativeCodex, restoreNativeCodexAsync } from "./codex/inject";
16
16
  import { stripGrokConfig } from "./grok/inject";
17
17
  import { isWslRuntime, resolveCodexHomeDir, type CodexHomeDeps } from "./codex/home";
18
18
  import { BUN_RUNTIME_PATH_ENV, BUN_RUNTIME_SOURCE_ENV, durableBunRuntime } from "./lib/bun-runtime";
19
- import type { BunRuntimeSource } from "./lib/bun-runtime";
19
+ import type { BunRuntimeSource, DurableBunRuntime } from "./lib/bun-runtime";
20
20
  import { isProcessAlive, stopProxy } from "./lib/process-control";
21
21
  import { serviceApiTokenFilePath } from "./lib/service-secrets";
22
22
  import { tokenCollidesWithAdmin } from "./lib/admin-secrets";
@@ -49,23 +49,66 @@ import { windowsEnvIndirectBatchPathList, windowsEnvIndirectBatchValue } from ".
49
49
  import { recordOwnedConfigPath } from "./lib/config-ownership";
50
50
  import { killWindowsSchedulerWrappers } from "./lib/windows-service-wrappers";
51
51
  import { maybeShowStarPrompt } from "./cli/star-prompt";
52
+ import { systemdProperty } from "./service-manager-probe";
52
53
 
53
54
  const LABEL = "com.opencodex.proxy";
54
55
  const TASK = "opencodex-proxy";
55
56
 
56
57
  export type ServiceBackend = "scheduler" | "native";
57
58
 
58
- function cliEntry(): { bun: string; bunRuntimeSource: BunRuntimeSource; cli: string } {
59
+ function cliEntry(runtime: DurableBunRuntime = durableBunRuntime()): { bun: string; bunRuntimeSource: BunRuntimeSource; cli: string } {
59
60
  // Bake the bundled Bun (npm global prefix, survives `ocx update`) rather than
60
61
  // a transient system Bun, so launchd/systemd/schtasks keep resolving even if a
61
62
  // standalone Bun is later removed. The CLI entry lives at src/cli/index.ts.
62
63
  //
63
64
  // Path and provenance come from ONE resolution so the marker can never describe a
64
65
  // different binary than the one actually baked.
65
- const runtime = durableBunRuntime();
66
66
  return { bun: runtime.path, bunRuntimeSource: runtime.source, cli: join(import.meta.dir, "cli", "index.ts") };
67
67
  }
68
68
 
69
+ /**
70
+ * The stable `ocx` launcher to bake into a systemd unit, or null to fall back to the
71
+ * Bun + CLI pair.
72
+ *
73
+ * `cliEntry()` resolves both of its paths from `import.meta.dir`, so they point INSIDE
74
+ * the installed package tree. Under a version manager that tree is a versioned directory:
75
+ * `~/.local/share/mise/installs/npm-opencodex/2.35.0/...`. An upgrade installs 2.36.0 and
76
+ * deletes 2.35.0, after which the unit's `exec <old-bun> <old-cli>` cannot resolve, and
77
+ * `Restart=on-failure` turns that into a restart loop (#2898). The shim in
78
+ * `~/.local/share/mise/shims/ocx` survives the upgrade and dispatches to whatever version
79
+ * is current, so it is the durable thing to name.
80
+ *
81
+ * Deliberately LEXICAL. Resolving the symlink would write the versioned target back into
82
+ * the unit and reintroduce the bug — the indirection is the entire point.
83
+ *
84
+ * Only an absolute path is accepted. A bare `ocx` would be re-resolved through `PATH` on
85
+ * every restart, which turns a service definition into a PATH-hijacking surface; naming
86
+ * one validated absolute file keeps the target fixed at install time.
87
+ */
88
+ export function stableLauncherEntry(deps: {
89
+ env?: NodeJS.ProcessEnv;
90
+ isExecutableFile?: (path: string) => boolean;
91
+ pathDelimiter?: string;
92
+ } = {}): string | null {
93
+ const env = deps.env ?? process.env;
94
+ const isExecutableFile = deps.isExecutableFile ?? ((path: string): boolean => {
95
+ try {
96
+ if (!statSync(path).isFile()) return false;
97
+ accessSync(path, fsConstants.X_OK);
98
+ return true;
99
+ } catch {
100
+ return false;
101
+ }
102
+ });
103
+ const entries = (env.PATH ?? "").split(deps.pathDelimiter ?? delimiter);
104
+ for (const entry of entries) {
105
+ if (!entry || !isAbsolute(entry)) continue;
106
+ const candidate = join(entry, "ocx");
107
+ if (isExecutableFile(candidate)) return candidate;
108
+ }
109
+ return null;
110
+ }
111
+
69
112
  function plistPath(): string {
70
113
  return join(homedir(), "Library", "LaunchAgents", `${LABEL}.plist`);
71
114
  }
@@ -154,6 +197,14 @@ export interface ServiceInstallState {
154
197
  /** Baked at install; lets status flag paths gone stale after npm prefix/nvm moves. */
155
198
  bunPath?: string;
156
199
  cliPath?: string;
200
+ /**
201
+ * Linux only. The stable `ocx` launcher the unit actually invokes, when one was found.
202
+ * Present means `bunPath`/`cliPath` are provenance for the install, NOT what systemd
203
+ * runs — so staleness must be judged against THIS path instead. A version-manager
204
+ * upgrade replaces the directory those two point into while the launcher survives, and
205
+ * checking the old pair would report a stale service that is in fact healthy.
206
+ */
207
+ launcherPath?: string;
157
208
  /** v2: which Windows backend was chosen at install; absent (v1/legacy) means scheduler. */
158
209
  backend?: ServiceBackend;
159
210
  winswVersion?: string;
@@ -166,7 +217,7 @@ export function parseServiceInstallState(value: unknown): ServiceInstallState |
166
217
  if (state.version !== 1 && state.version !== 2) return null;
167
218
  if (typeof state.codexHome !== "string" || state.codexHome.length === 0) return null;
168
219
  if (typeof state.opencodexHome !== "string" || state.opencodexHome.length === 0) return null;
169
- for (const key of ["bunPath", "cliPath", "winswVersion", "winswSha256"] as const) {
220
+ for (const key of ["bunPath", "cliPath", "launcherPath", "winswVersion", "winswSha256"] as const) {
170
221
  if (state[key] !== undefined && (typeof state[key] !== "string" || state[key].length === 0)) return null;
171
222
  }
172
223
  if (state.version === 1) {
@@ -177,7 +228,7 @@ export function parseServiceInstallState(value: unknown): ServiceInstallState |
177
228
  return state as unknown as ServiceInstallState;
178
229
  }
179
230
 
180
- function writeServiceInstallState(backend: ServiceBackend = "scheduler"): void {
231
+ function writeServiceInstallState(backend: ServiceBackend = "scheduler", launcherPath?: string | null): void {
181
232
  const { bun, cli } = cliEntry();
182
233
  const state: ServiceInstallState = {
183
234
  version: 2,
@@ -185,6 +236,7 @@ function writeServiceInstallState(backend: ServiceBackend = "scheduler"): void {
185
236
  opencodexHome: currentOpenCodexHome(),
186
237
  bunPath: bun,
187
238
  cliPath: cli,
239
+ ...(launcherPath ? { launcherPath } : {}),
188
240
  backend,
189
241
  ...(backend === "native" ? { winswVersion: WINSW_VERSION, winswSha256: WINSW_SHA256 } : {}),
190
242
  };
@@ -500,6 +552,17 @@ function buildServiceShellCommand(bun: string, cli: string, port = resolveServic
500
552
  return `if [ -f ${shellQuote(tokenFile)} ]; then OPENCODEX_API_AUTH_TOKEN="$(cat ${shellQuote(tokenFile)})"; export OPENCODEX_API_AUTH_TOKEN; fi; exec ${shellQuote(bun)} ${shellQuote(cli)} start --port ${port}`;
501
553
  }
502
554
 
555
+ /**
556
+ * The same command shape, launched through a stable `ocx` executable instead of an
557
+ * explicit Bun + CLI pair. The token-file preamble is identical and deliberately shared
558
+ * in form: the service still reads the token from disk at start and never carries it in
559
+ * the unit.
560
+ */
561
+ function buildServiceLauncherShellCommand(launcher: string, port = resolveServiceListenPort()): string {
562
+ const tokenFile = serviceApiTokenFilePath();
563
+ return `if [ -f ${shellQuote(tokenFile)} ]; then OPENCODEX_API_AUTH_TOKEN="$(cat ${shellQuote(tokenFile)})"; export OPENCODEX_API_AUTH_TOKEN; fi; exec ${shellQuote(launcher)} start --port ${port}`;
564
+ }
565
+
503
566
  /**
504
567
  * The `--port <n>` actually baked into the installed launchd plist, or null when it
505
568
  * cannot be read. macOS only — named for launchd rather than "service" so no caller
@@ -706,12 +769,6 @@ export function resolvedProxyEnv(env: NodeJS.ProcessEnv = process.env): { name:
706
769
  return resolved;
707
770
  }
708
771
 
709
- function systemdOutputTarget(value: string): string {
710
- // StandardOutput/StandardError use output specifiers such as append:/path.
711
- // Quoting the full specifier makes systemd reject it as an invalid output target.
712
- return value.replace(/%/g, "%%").replace(/\n/g, "\\n");
713
- }
714
-
715
772
  function sh(cmd: string): string {
716
773
  return execSync(cmd, { encoding: "utf8", stdio: ["pipe", "pipe", "pipe"] }).trim();
717
774
  }
@@ -2512,6 +2569,14 @@ function uninstallWindows(): void {
2512
2569
  */
2513
2570
  export function bakedServicePathsDiagnostic(): string | null {
2514
2571
  const state = readServiceInstallState();
2572
+ // A launcher install runs the launcher, not the baked pair, so the pair's existence says
2573
+ // nothing about whether the service can start. Judging the recorded launcher is both
2574
+ // necessary (a deleted launcher IS stale) and sufficient (a replaced version directory
2575
+ // is not, which is exactly what #2898 made routine).
2576
+ if (state?.launcherPath) {
2577
+ if (existsSync(state.launcherPath)) return null;
2578
+ return `STALE baked paths (missing: ${state.launcherPath}) — run 'ocx service repair' to re-bake`;
2579
+ }
2515
2580
  if (!state?.bunPath || !state?.cliPath) return null;
2516
2581
  const missing = [state.bunPath, state.cliPath].filter(path => !existsSync(path));
2517
2582
  if (missing.length === 0) return null;
@@ -2532,8 +2597,16 @@ function unitPath(): string {
2532
2597
  return join(unitDir(), `${TASK}.service`);
2533
2598
  }
2534
2599
 
2535
- export function buildUnit(proxyEnv: { name: string; value: string }[] = resolvedProxyEnv()): string {
2536
- const { bun, bunRuntimeSource, cli } = cliEntry();
2600
+ export function buildUnit(
2601
+ proxyEnv: { name: string; value: string }[] = resolvedProxyEnv(),
2602
+ deps: { launcher?: string | null; runtime?: DurableBunRuntime } = {},
2603
+ ): string {
2604
+ const runtime = deps.runtime ?? durableBunRuntime();
2605
+ const { bun, bunRuntimeSource, cli } = cliEntry(runtime);
2606
+ // Discovery belongs to installSystemd(), which resolves once and passes the same value to
2607
+ // both the unit and install state. Keeping this builder explicit makes tests and diagnostics
2608
+ // independent of the host PATH.
2609
+ const launcher = deps.launcher ?? null;
2537
2610
  const log = logPath();
2538
2611
  const path = process.env.PATH ?? "/usr/local/bin:/usr/bin:/bin";
2539
2612
  const codexHome = systemdEnvironmentAssignment("CODEX_HOME", process.env.CODEX_HOME?.trim());
@@ -2541,14 +2614,23 @@ export function buildUnit(proxyEnv: { name: string; value: string }[] = resolved
2541
2614
  const opencodexHome = systemdEnvironmentAssignment("OPENCODEX_HOME", process.env.OPENCODEX_HOME?.trim());
2542
2615
  const envLines = [
2543
2616
  systemdEnvironmentAssignment("OCX_SERVICE", "1"),
2544
- systemdEnvironmentAssignment(BUN_RUNTIME_SOURCE_ENV, bunRuntimeSource),
2545
- systemdEnvironmentAssignment(BUN_RUNTIME_PATH_ENV, bun),
2617
+ ...(launcher ? [] : [
2618
+ systemdEnvironmentAssignment(BUN_RUNTIME_SOURCE_ENV, bunRuntimeSource),
2619
+ systemdEnvironmentAssignment(BUN_RUNTIME_PATH_ENV, bun),
2620
+ ]),
2621
+ // A launcher normally resolves the current package's bundled Bun after every upgrade.
2622
+ // Preserve only a proof-bound shell override; otherwise writing a package-local path here
2623
+ // would recreate the version-manager pin that the launcher mode exists to remove.
2624
+ launcher && runtime.source === "override"
2625
+ ? systemdEnvironmentAssignment(runtime.overrideEnv, runtime.path)
2626
+ : null,
2546
2627
  systemdEnvironmentAssignment("PATH", path),
2547
2628
  codexHome,
2548
2629
  codexSqliteHome,
2549
2630
  opencodexHome,
2550
2631
  ...proxyEnv.map(({ name, value }) => systemdEnvironmentAssignment(name, value)),
2551
2632
  ].filter((line): line is string => Boolean(line)).join("\n");
2633
+ const command = `${launcher ? buildServiceLauncherShellCommand(launcher) : buildServiceShellCommand(bun, cli)} >> ${shellQuote(log)} 2>&1`;
2552
2634
  return `[Unit]
2553
2635
  Description=OpenCodex Proxy Server
2554
2636
  After=network-online.target
@@ -2556,12 +2638,10 @@ Wants=network-online.target
2556
2638
 
2557
2639
  [Service]
2558
2640
  Type=simple
2559
- ExecStart=${systemdQuote("/bin/sh")} -lc ${systemdQuote(buildServiceShellCommand(bun, cli))}
2641
+ ExecStart=${systemdQuote("/bin/sh")} -lc ${systemdQuote(command)}
2560
2642
  Restart=on-failure
2561
2643
  RestartSec=5
2562
2644
  ${envLines}
2563
- StandardOutput=${systemdOutputTarget(`append:${log}`)}
2564
- StandardError=${systemdOutputTarget(`append:${log}`)}
2565
2645
 
2566
2646
  [Install]
2567
2647
  WantedBy=default.target
@@ -2608,11 +2688,14 @@ function installSystemd(): void {
2608
2688
  recordOwnedConfigPath(getConfigDir(), serviceStatePath());
2609
2689
  if (!existsSync(getConfigDir())) mkdirSync(getConfigDir(), { recursive: true });
2610
2690
  writeServiceApiTokenFile();
2611
- writeServiceDefinitionFile(unitPath(), buildUnit(), "utf8");
2691
+ // Resolve ONCE and reuse: the unit and the install state must agree about what is
2692
+ // launched, or the staleness check would validate a path the unit does not run.
2693
+ const launcher = stableLauncherEntry();
2694
+ writeServiceDefinitionFile(unitPath(), buildUnit(resolvedProxyEnv(), { launcher }), "utf8");
2612
2695
  sh("systemctl --user daemon-reload");
2613
2696
  sh(`systemctl --user enable ${TASK}`);
2614
2697
  sh(`systemctl --user restart ${TASK}`);
2615
- writeServiceInstallState();
2698
+ writeServiceInstallState("scheduler", launcher);
2616
2699
  }
2617
2700
  /**
2618
2701
  * Whether systemd's in-memory unit differs from the file on disk.
@@ -2660,10 +2743,18 @@ function startSystemd(): void {
2660
2743
  }
2661
2744
  function stopSystemd(): void { try { sh(`systemctl --user stop ${TASK}`); } catch { /* not running */ } }
2662
2745
  function statusSystemd(): string { try { return sh(`systemctl --user status ${TASK}`); } catch { return ""; } }
2663
- function uninstallSystemd(): void {
2664
- try { sh(`systemctl --user disable --now ${TASK}`); } catch { /* absent */ }
2665
- if (existsSync(unitPath())) unlinkSync(unitPath());
2666
- try { sh("systemctl --user daemon-reload"); } catch { /* best-effort */ }
2746
+ export function uninstallSystemd(deps: {
2747
+ run?: (command: string) => string;
2748
+ unitExists?: () => boolean;
2749
+ removeUnit?: () => void;
2750
+ } = {}): void {
2751
+ const run = deps.run ?? sh;
2752
+ try { run(`systemctl --user stop ${TASK}`); } catch { /* not running */ }
2753
+ try { run(`systemctl --user disable ${TASK}`); } catch { /* absent */ }
2754
+ if ((deps.unitExists ?? (() => existsSync(unitPath())))()) {
2755
+ (deps.removeUnit ?? (() => unlinkSync(unitPath())))();
2756
+ }
2757
+ try { run("systemctl --user daemon-reload"); } catch { /* best-effort */ }
2667
2758
  }
2668
2759
 
2669
2760
  type ServiceOps = {
@@ -2676,6 +2767,21 @@ type ServiceInstallCleanupOps = {
2676
2767
  stop: () => void;
2677
2768
  };
2678
2769
 
2770
+ export function systemdServiceInstallCleanupOps(deps: {
2771
+ run?: (command: string) => string;
2772
+ } = {}): ServiceInstallCleanupOps {
2773
+ const run = deps.run ?? sh;
2774
+ return {
2775
+ status: () => {
2776
+ const output = run(`systemctl --user show -p LoadState ${TASK}`);
2777
+ const loadState = systemdProperty(output, "LoadState")?.toLowerCase();
2778
+ if (!loadState) throw new Error("systemd service status could not be verified.");
2779
+ return loadState === "not-found" ? null : loadState;
2780
+ },
2781
+ stop: () => { run(`systemctl --user stop ${TASK}`); },
2782
+ };
2783
+ }
2784
+
2679
2785
  function platformOps(backend: ServiceBackend = "scheduler"): ServiceOps | null {
2680
2786
  if (process.platform === "darwin")
2681
2787
  return { install: installLaunchd, start: startLaunchd, stop: stopLaunchd, status: statusLaunchd, uninstall: uninstallLaunchd };
@@ -2744,20 +2850,13 @@ function platformServiceInstallCleanupOps(backend: ServiceBackend): ServiceInsta
2744
2850
  };
2745
2851
  }
2746
2852
  if (process.platform === "linux") {
2747
- return {
2748
- status: () => {
2749
- // `list-unit-files <name>` exits non-zero when the unit has never been
2750
- // installed, which made a clean first install look like an unknown manager
2751
- // failure. `show LoadState` gives us the tri-state we actually need: a
2752
- // healthy user manager returns `not-found` for a missing unit, while an
2753
- // unreachable/permission-denied manager still makes `sh()` throw and the
2754
- // caller therefore fails closed.
2755
- const loadState = sh(`systemctl --user show ${TASK} --property=LoadState --value`).trim().toLowerCase();
2756
- if (!loadState) throw new Error("systemd service status could not be verified.");
2757
- return loadState === "not-found" ? null : loadState;
2758
- },
2759
- stop: () => { sh(`systemctl --user stop ${TASK}`); },
2760
- };
2853
+ // `list-unit-files <name>` exits non-zero when the unit has never been
2854
+ // installed, which made a clean first install look like an unknown manager
2855
+ // failure. `show LoadState` gives us the tri-state we actually need: a
2856
+ // healthy user manager returns `not-found` for a missing unit, while an
2857
+ // unreachable/permission-denied manager still makes `sh()` throw and the
2858
+ // caller therefore fails closed.
2859
+ return systemdServiceInstallCleanupOps();
2761
2860
  }
2762
2861
  return null;
2763
2862
  }
@@ -2578,6 +2578,11 @@ export interface RestoreTestHooks {
2578
2578
  * reconcile, so cleanup can race an in-flight restore.
2579
2579
  */
2580
2580
  holdAfterFileMovesMs?: number;
2581
+ /**
2582
+ * Test-only: publish a ready file after rollout moves, then wait until the
2583
+ * release file exists. This makes cross-thread race tests phase-driven.
2584
+ */
2585
+ pauseAfterFileMoves?: { readyPath: string; releasePath: string };
2581
2586
  }
2582
2587
 
2583
2588
  /**
@@ -2917,6 +2922,11 @@ export function restoreTrashEntry(
2917
2922
  return { ok: false, trashDir: id, ...partialCounts, error };
2918
2923
  };
2919
2924
 
2925
+ if (hooks?.pauseAfterFileMoves) {
2926
+ writeFileSync(hooks.pauseAfterFileMoves.readyPath, "ready\n");
2927
+ while (!existsSync(hooks.pauseAfterFileMoves.releasePath)) Bun.sleepSync(10);
2928
+ }
2929
+
2920
2930
  if (hooks?.holdAfterFileMovesMs !== undefined) {
2921
2931
  const holdMs = Math.max(0, Math.floor(hooks.holdAfterFileMovesMs));
2922
2932
  if (holdMs > 0) {
@@ -16,6 +16,12 @@ export type StorageMutationBusyError = "storage_mutation_busy";
16
16
  export interface StorageMutationCoordinatorTestHooks {
17
17
  /** Block after acquiring the slot, before mutation work (race tests). */
18
18
  blockMs?: number;
19
+ /** Test-only cross-thread handshake after one mutation kind acquires its slot. */
20
+ pauseAfterAcquire?: {
21
+ kind: StorageMutationKind;
22
+ readyPath: string;
23
+ releasePath: string;
24
+ };
19
25
  }
20
26
 
21
27
  interface ActiveSlot {
@@ -93,7 +99,12 @@ export function endStorageMutation(codexHome?: string): void {
93
99
  slot.lease.release();
94
100
  }
95
101
 
96
- async function applyCoordinatorBlock(): Promise<void> {
102
+ async function applyCoordinatorBlock(kind: StorageMutationKind): Promise<void> {
103
+ const pause = testHooks?.pauseAfterAcquire;
104
+ if (pause?.kind === kind) {
105
+ await Bun.write(pause.readyPath, "ready\n");
106
+ while (!Bun.file(pause.releasePath).size) await Bun.sleep(10);
107
+ }
97
108
  const blockMs = testHooks?.blockMs;
98
109
  if (typeof blockMs === "number" && Number.isFinite(blockMs) && blockMs > 0) {
99
110
  await Bun.sleep(Math.floor(blockMs));
@@ -114,7 +125,7 @@ export async function runPolicyStorageMutation<T>(
114
125
  return { ok: false, error: "storage_mutation_busy" };
115
126
  }
116
127
  try {
117
- await applyCoordinatorBlock();
128
+ await applyCoordinatorBlock("policy");
118
129
  return await work();
119
130
  } finally {
120
131
  gate.lease.release();
@@ -131,7 +142,7 @@ export async function withStorageMutationSlot<T>(
131
142
  return { ok: false, error: "storage_mutation_busy" };
132
143
  }
133
144
  try {
134
- await applyCoordinatorBlock();
145
+ await applyCoordinatorBlock(kind);
135
146
  return await work();
136
147
  } finally {
137
148
  gate.lease.release();
@@ -13,6 +13,7 @@ param(
13
13
  $ErrorActionPreference = "Stop"
14
14
  Add-Type -AssemblyName System.Windows.Forms
15
15
  Add-Type -AssemblyName System.Drawing
16
+ try { [System.Windows.Forms.Application]::EnableVisualStyles() } catch { $null = $_ }
16
17
 
17
18
  # Normalize aliases before deriving singleton/event names. Without this,
18
19
  # C:\path and C:\path\. create separate tray instances for the same home.
@@ -68,6 +69,7 @@ if (-not $createdNew) {
68
69
  $mutex.Dispose()
69
70
  exit 0
70
71
  }
72
+ [void]$stopEvent.Reset()
71
73
 
72
74
  $heartbeatPath = Join-Path $OpenCodexHome "tray-heartbeat.json"
73
75
  $actionLogPath = Join-Path $OpenCodexHome "tray-actions.log"
@@ -332,11 +334,15 @@ $notify.add_DoubleClick({ Start-OcxCommand @("gui") })
332
334
  $timer = New-Object System.Windows.Forms.Timer
333
335
  $timer.Interval = 3000
334
336
  $timer.add_Tick({
335
- if ($stopEvent.WaitOne(0)) {
336
- [System.Windows.Forms.Application]::Exit()
337
- return
337
+ try {
338
+ if ($stopEvent.WaitOne(0)) {
339
+ [System.Windows.Forms.Application]::Exit()
340
+ return
341
+ }
342
+ Update-TrayState
343
+ } catch {
344
+ try { Write-ActionLog "timer tick failed: $($_.Exception.GetType().Name)" } catch { $null = $_ }
338
345
  }
339
- Update-TrayState
340
346
  })
341
347
  $notify.ContextMenuStrip = $menu
342
348
  $notify.Icon = $offlineIcon
@@ -2,7 +2,7 @@ import { execFile, execFileSync, spawn } from "node:child_process";
2
2
  import { createHash } from "node:crypto";
3
3
  import { chmodSync, existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from "node:fs";
4
4
  import { homedir } from "node:os";
5
- import { join, resolve } from "node:path";
5
+ import { join, resolve, win32 as win32Path } from "node:path";
6
6
  import { expandUserPath, getConfigDir } from "../config";
7
7
  import { durableBunRuntime } from "../lib/bun-runtime";
8
8
  import type { BunRuntimeSource } from "../lib/bun-runtime";
@@ -46,6 +46,12 @@ export interface WindowsTrayStatus {
46
46
  summary: string;
47
47
  }
48
48
 
49
+ export type WindowsTrayLaunchRunner = (
50
+ file: string,
51
+ args: readonly string[],
52
+ options: { stdio: "ignore"; windowsHide: true; timeout: number },
53
+ ) => void;
54
+
49
55
  function trayStatePath(): string {
50
56
  return join(getConfigDir(), "tray-state.json");
51
57
  }
@@ -506,8 +512,10 @@ const DETACHED_TRAY_HOST_LAUNCHER = [
506
512
  "$startInfo = New-Object System.Diagnostics.ProcessStartInfo",
507
513
  "$startInfo.FileName = $env:OCX_TRAY_HOST_BUN",
508
514
  "$startInfo.Arguments = $env:OCX_TRAY_HOST_ARGS",
509
- "$startInfo.UseShellExecute = $true",
515
+ "$startInfo.UseShellExecute = $false",
516
+ "$startInfo.CreateNoWindow = $true",
510
517
  "$startInfo.WindowStyle = [System.Diagnostics.ProcessWindowStyle]::Hidden",
518
+ "$startInfo.EnvironmentVariables['OCX_TRAY_ENTRY_B64'] = $env:OCX_TRAY_ENTRY_B64",
511
519
  "$child = [System.Diagnostics.Process]::Start($startInfo)",
512
520
  "if ($null -eq $child) { throw 'Windows tray host did not start.' }",
513
521
  "$child.Dispose()",
@@ -537,7 +545,27 @@ export function launchWindowsTrayHost(state: WindowsTrayEntry): void {
537
545
  });
538
546
  }
539
547
 
548
+ export function launchInstalledWindowsTray(
549
+ launcherPath: string,
550
+ deps: { systemRoot?: string; run?: WindowsTrayLaunchRunner } = {},
551
+ ): void {
552
+ const wscript = win32Path.join(deps.systemRoot ?? process.env.SystemRoot ?? "C:\\Windows", "System32", "wscript.exe");
553
+ const run = deps.run ?? ((file, args, options) => {
554
+ execFileSync(file, [...args], options);
555
+ });
556
+ run(wscript, ["//B", "//NoLogo", safePath(launcherPath)], {
557
+ stdio: "ignore",
558
+ windowsHide: true,
559
+ timeout: 15_000,
560
+ });
561
+ }
562
+
540
563
  function spawnTray(state: WindowsTrayEntry): void {
564
+ const launcher = installedTrayLauncherPath();
565
+ if (existsSync(launcher)) {
566
+ launchInstalledWindowsTray(launcher);
567
+ return;
568
+ }
541
569
  launchWindowsTrayHost(state);
542
570
  }
543
571
 
@@ -435,17 +435,26 @@ export interface OcxConfig {
435
435
  * Shadow call intercept: redirect Codex's hard-coded helper calls (title generation,
436
436
  * commit messages, skill orchestration) to a user-chosen model. Default intercepted
437
437
  * source models: gpt-5.4-mini (older clients) and gpt-5.6-luna (Codex 0.145.0+).
438
- * Opt-in; disabled by default. Matching maintenance/helper requests are forced to low.
439
- * All requests for configured shadow source models are intercepted unconditionally.
440
- */
441
- shadowCallIntercept?: {
442
- /** When true, requests for known shadow/helper source models are rewritten to the configured model. */
443
- enabled?: boolean;
444
- /** Replacement model id (e.g. "gpt-5.5"). */
445
- model?: string;
446
- /** Optional override of intercepted source-model prefixes (default: gpt-5.4-mini, gpt-5.6-luna). */
447
- sourceModels?: string[];
448
- };
438
+ * Opt-in; disabled by default. Matching requests preserve their configured reasoning effort.
439
+ * All requests for configured shadow source models are intercepted regardless of request kind,
440
+ * except when the replacement intersects the same provider+model source set.
441
+ */
442
+ shadowCallIntercept?: {
443
+ /** When true, requests for known shadow/helper source models are rewritten to the configured model. */
444
+ enabled?: boolean;
445
+ /** Replacement model id (e.g. "gpt-5.5"). */
446
+ model?: string;
447
+ /** Optional override of intercepted source-model prefixes (default: gpt-5.4-mini, gpt-5.6-luna). */
448
+ sourceModels?: string[];
449
+ };
450
+ /**
451
+ * Optional map of blocked model IDs to their replacement model IDs.
452
+ * When configured, incoming requests targeting a blocked model (including
453
+ * account-namespaced and concrete routes) are redirected to the replacement
454
+ * model at the shared routing layer with routeReason "blocked-model-redirect".
455
+ * Unset or omitted by default.
456
+ */
457
+ blockedModelRedirects?: Record<string, string>;
449
458
  /**
450
459
  * 3-state multi-agent surface override:
451
460
  * - "v1": force ALL models to v1 surface (override upstream pins)
@@ -619,6 +628,8 @@ export interface OcxConfig {
619
628
  strategy?: OcxAccountPoolRotationStrategy;
620
629
  /** Successful new-session binds retained on one round-robin selection. Default 1; range 1..100. */
621
630
  stickyLimit?: number;
631
+ /** Usage window for quota-based scoring. Default "five-hour" (today's behaviour). */
632
+ quotaWindow?: OcxAccountPoolQuotaWindow;
622
633
  };
623
634
  /**
624
635
  * Generic OAuth multi-account 429 failover (#2568). Presence-driven by default.
@@ -653,19 +664,21 @@ export interface OcxConfig {
653
664
 
654
665
  export type OcxAccountPoolRotationStrategy = "quota" | "round-robin" | "fill-first";
655
666
 
656
- export type OcxComboStrategy = "failover" | "round-robin";
667
+ export type OcxAccountPoolQuotaWindow = "five-hour" | "weekly" | "max-utilization";
668
+
669
+ export type OcxComboStrategy = "failover" | "round-robin" | "random" | "least-used" | "reset-window";
657
670
  export type OcxComboDefaultEffort = "low" | "medium" | "high" | "xhigh" | "max" | "ultra";
658
671
 
659
672
  export interface OcxComboTarget {
660
673
  provider: string;
661
674
  model: string;
662
- /** Relative SWRR batch weight. Default 1; valid range 1..10000. */
675
+ /** Relative target weight for round-robin batches and random selection. Default 1; valid range 1..10000. */
663
676
  weight?: number;
664
677
  }
665
678
 
666
679
  export interface OcxComboConfig {
667
680
  targets: OcxComboTarget[];
668
- /** Ordered failover (default) or deterministic smooth weighted round-robin. */
681
+ /** Ordered failover (default), round-robin, weighted random, least-used, or quota reset-window selection. */
669
682
  strategy?: OcxComboStrategy;
670
683
  /** Successful requests retained on one RR selection batch. Default 1; range 1..100. */
671
684
  stickyLimit?: number;
@@ -17,6 +17,15 @@ export interface OpenRouterProviderRouting {
17
17
  allowFallbacks?: boolean;
18
18
  }
19
19
 
20
+ export interface VercelGatewayRouting {
21
+ /** Vercel AI Gateway provider slugs to try first, in priority order. */
22
+ order?: string[];
23
+ /** Restrict routing to these Vercel AI Gateway provider slugs. */
24
+ only?: string[];
25
+ /** Sort providers by "cost", "ttft", or "tps". */
26
+ sort?: "cost" | "ttft" | "tps";
27
+ }
28
+
20
29
  export interface ResponsesItemIdRepairConfig {
21
30
  /** Exact `message` item ids that the proxy should rewrite to request-local canonical ids. */
22
31
  message?: string[];
@@ -32,6 +41,25 @@ export interface ResponsesItemIdRepairConfig {
32
41
  repairInvalidIds?: boolean;
33
42
  }
34
43
 
44
+ /**
45
+ * Opt-in retry for pre-stream transient upstream statuses (500/502/503/504/520/521/522) on
46
+ * `providers.<name>.transientRetryOn5xx`.
47
+ *
48
+ * Disabled unless the object is present; a bare `{}` opts in with defaults. Separate from
49
+ * `retryOn429`, which handles rate limiting with its own waits.
50
+ */
51
+ export interface TransientRetryPolicy {
52
+ /** Master switch. Presence of the object also enables the policy (default true). */
53
+ enabled?: boolean;
54
+ /**
55
+ * TOTAL upstream sends allowed for one request, including the first (1..10, default 3).
56
+ *
57
+ * Not a per-layer retry count: the connection-reset and transient-status recovery layers
58
+ * share this single budget, so `3` means at most three real requests reach the provider.
59
+ */
60
+ attempts?: number;
61
+ }
62
+
35
63
  /**
36
64
  * Same-target 429 wait-and-retry policy (`providers.<name>.retryOn429`). When present and not
37
65
  * explicitly disabled, the proxy waits and replays the identical request on the same key before
@@ -197,6 +225,16 @@ export interface OcxProviderConfig {
197
225
  * preserved after it, and parallel calls stay together with the reasoning turn that produced them.
198
226
  */
199
227
  requiresAdjacentResponsesToolResults?: boolean;
228
+ /**
229
+ * When enabled, a tool result that is present but empty (no usable text or content
230
+ * part) is rewritten to an explicit annotation before it reaches the upstream wire,
231
+ * so models do not silently accept an empty result or re-issue the same call.
232
+ * Non-empty results and missing-result placeholders stay byte-identical.
233
+ * Seeded true for DeepSeek; absent keeps legacy behavior for every other provider.
234
+ * Only the OpenAI-family adapters (openai-chat / openai-responses) read this option;
235
+ * other adapters ignore it.
236
+ */
237
+ annotateEmptyToolOutputs?: boolean;
200
238
  /**
201
239
  * Provider fallback for canonical Fast capability over an OpenAI `service_tier` wire.
202
240
  * This pure tri-state feeds catalog publication, routing eligibility, compatibility
@@ -342,6 +380,10 @@ export interface OcxProviderConfig {
342
380
  openRouterRouting?: OpenRouterProviderRouting;
343
381
  /** Exact model-id overrides for `openRouterRouting`. Each matching entry replaces the default. */
344
382
  modelOpenRouterRouting?: Record<string, OpenRouterProviderRouting>;
383
+ /** Default provider-routing preferences for models sent through Vercel AI Gateway (issue #1406). */
384
+ vercelGatewayRouting?: VercelGatewayRouting;
385
+ /** Exact model-id overrides for `vercelGatewayRouting`. Each matching entry replaces the default. */
386
+ modelVercelGatewayRouting?: Record<string, VercelGatewayRouting>;
345
387
  /**
346
388
  * "key" (default): authenticate upstream with `apiKey`.
347
389
  * "forward": relay the caller's incoming auth headers verbatim (OAuth passthrough; gpt only).
@@ -421,6 +463,12 @@ export interface OcxProviderConfig {
421
463
  * passthrough compatibility for OpenAI and unclassified gateways.
422
464
  */
423
465
  supportsOpenAiWebSearchToolFields?: boolean;
466
+ /**
467
+ * Opt xAI Responses destinations into the provider-hosted `x_search` declaration when a live
468
+ * `web_search` tool survives final request normalization. Disabled by default. This is separate
469
+ * from the web-search sidecar's `search.xSearch` options and never widens caller tool selectors.
470
+ */
471
+ xaiResponsesXSearch?: boolean;
424
472
  /**
425
473
  * Whether the Responses upstream accepts native custom tools and custom_tool_call items.
426
474
  * Set false only for a provider whose native contract rejects them; absence preserves
@@ -537,6 +585,12 @@ export interface OcxProviderConfig {
537
585
  * before any response bytes are relayed, so the replay is lossless.
538
586
  */
539
587
  retryOn429?: RateLimitRetryPolicy;
588
+ /**
589
+ * Opt-in retry for pre-stream transient upstream statuses
590
+ * (`providers.<name>.transientRetryOn5xx`). Disabled unless present; a bare `{}` opts in
591
+ * with defaults. Key-auth `openai-chat` only.
592
+ */
593
+ transientRetryOn5xx?: TransientRetryPolicy;
540
594
  /**
541
595
  * Model ids whose OpenAI-compatible chat endpoint accepts `reasoning_split: true` and returns
542
596
  * thinking separately in `reasoning_content` / `reasoning_details` instead of visible content.