@bitkyc08/opencodex 2.50.0 → 2.52.0-preview.20260911

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 (95) hide show
  1. package/bin/ocx.mjs +222 -71
  2. package/gui/dist/assets/{index-C39tnjXO.js → index-Dx0xv2EA.js} +1 -1
  3. package/gui/dist/index.html +1 -1
  4. package/package.json +1 -1
  5. package/src/adapters/qoder/adapter.ts +69 -1
  6. package/src/adapters/qoder/scaffold-guard.ts +233 -0
  7. package/src/claude/agents-inject.ts +29 -5
  8. package/src/claude/desktop-3p.ts +31 -3
  9. package/src/claude/gateway-cache.ts +12 -21
  10. package/src/cli/capabilities.ts +28 -0
  11. package/src/cli/claude-agent-startup-sync.ts +26 -1
  12. package/src/cli/claude.ts +138 -20
  13. package/src/cli/config-command.ts +67 -1
  14. package/src/cli/connect.ts +181 -14
  15. package/src/cli/dispatch.ts +53 -9
  16. package/src/cli/doctor.ts +9 -2
  17. package/src/cli/ensure-desired-integrations.ts +10 -0
  18. package/src/cli/gui-pair-client.ts +1 -12
  19. package/src/cli/help.ts +4 -1
  20. package/src/cli/hub.ts +367 -0
  21. package/src/cli/index.ts +94 -30
  22. package/src/cli/launcher-context.ts +1 -1
  23. package/src/cli/registry.ts +43 -3
  24. package/src/cli/status.ts +325 -5
  25. package/src/cli/version-skew.ts +4 -1
  26. package/src/cli.ts +2 -2
  27. package/src/client/catalog-compatibility.ts +192 -0
  28. package/src/client/connect.ts +31 -0
  29. package/src/client/hub-client.ts +52 -0
  30. package/src/client/hub-state.ts +214 -0
  31. package/src/codex/account-usability.ts +48 -12
  32. package/src/codex/auth-api.ts +49 -5
  33. package/src/codex/catalog/effort.ts +67 -8
  34. package/src/codex/catalog/sync.ts +85 -0
  35. package/src/codex/codex-write-lock.ts +11 -2
  36. package/src/codex/desired-state.ts +47 -1
  37. package/src/codex/inject-coordination.ts +10 -5
  38. package/src/codex/inject.ts +26 -10
  39. package/src/codex/loopback-target.ts +45 -0
  40. package/src/codex/routing.ts +48 -1
  41. package/src/codex/runtime.ts +37 -3
  42. package/src/codex/sync.ts +29 -9
  43. package/src/codex/warmup.ts +21 -4
  44. package/src/config/pending-teardown.ts +1 -1
  45. package/src/config.ts +126 -12
  46. package/src/generated/compatibility-version.json +136 -76
  47. package/src/grok/status.ts +9 -1
  48. package/src/integrations/config-io.ts +54 -1
  49. package/src/lib/bun-runtime.ts +1 -1
  50. package/src/lib/gui-pair-capability.ts +27 -0
  51. package/src/lib/local-destinations.ts +162 -0
  52. package/src/lib/package-tree-integrity.ts +1 -1
  53. package/src/lib/process-control.ts +130 -20
  54. package/src/lib/service-secrets.ts +28 -0
  55. package/src/lib/test-home-guard.ts +49 -0
  56. package/src/providers/opencode-go-transport.ts +9 -1
  57. package/src/providers/quota.ts +5 -1
  58. package/src/providers/registry.ts +34 -5
  59. package/src/remote/hub-state.ts +182 -0
  60. package/src/server/auth-cors.ts +5 -0
  61. package/src/server/chat-completions.ts +6 -3
  62. package/src/server/claude-messages.ts +7 -1
  63. package/src/server/hub-state.ts +98 -0
  64. package/src/server/index.ts +124 -6
  65. package/src/server/management/api-access.ts +14 -3
  66. package/src/server/management/config-routes.ts +2 -2
  67. package/src/server/management/cursor-integration-routes.ts +13 -4
  68. package/src/server/proxy-liveness.ts +7 -1
  69. package/src/server/request-log-conversation.ts +41 -1
  70. package/src/server/responses/codex-auth-error.ts +18 -1
  71. package/src/server/responses/codex-ws-exchange.ts +36 -4
  72. package/src/server/responses/codex-ws-wire.ts +75 -4
  73. package/src/server/responses/compact.ts +20 -9
  74. package/src/server/responses/core.ts +57 -10
  75. package/src/server/responses/policy-fallback.ts +7 -1
  76. package/src/server/system-env-shell.ts +14 -2
  77. package/src/server/system-env.ts +106 -14
  78. package/src/service.ts +906 -94
  79. package/src/types/config.ts +57 -4
  80. package/src/update/badge.ts +3 -2
  81. package/src/update/index.ts +317 -64
  82. package/src/update/install-detection.d.mts +6 -0
  83. package/src/update/install-detection.mjs +73 -0
  84. package/src/update/job.ts +101 -49
  85. package/src/update/pnpm-global-install.d.mts +144 -0
  86. package/src/update/pnpm-global-install.mjs +591 -0
  87. package/src/update/pnpm-invocation.d.mts +43 -0
  88. package/src/update/pnpm-invocation.mjs +141 -0
  89. package/src/update/registry-integrity.d.mts +16 -0
  90. package/src/update/registry-integrity.mjs +37 -0
  91. package/src/update/transactional-install.d.mts +1 -1
  92. package/src/update/transactional-install.mjs +101 -7
  93. package/src/update/tray-update-plan.mjs +1 -1
  94. package/src/vision/plan.ts +13 -3
  95. package/src/vision/routed-describe.ts +51 -20
@@ -259,6 +259,21 @@ export type OcxRuntimeRole = "standalone" | "hub" | "client";
259
259
  export interface OcxHubConfig {
260
260
  /** Canonical browser-reachable management origin advertised by a hub. */
261
261
  managementPublicOrigin?: string;
262
+ /**
263
+ * Canonical client-reachable DATA origin of this hub — what a remote machine passes as the
264
+ * positional URL to `ocx connect`, and what `ocx hub invite` prints.
265
+ *
266
+ * Separate from `managementPublicOrigin` because the two are genuinely different sockets on a
267
+ * real deployment: management is a loopback-only ingress published by an HTTPS frontend, while
268
+ * the data listener is bound to the hub's tailnet/LAN address and fronted on its own port
269
+ * (`https://hub.tailnet.ts.net:8443`). Deriving one from the other produced an origin that
270
+ * answered `/readyz` and nothing else.
271
+ *
272
+ * Advisory only: it is the origin the hub ADVERTISES, never a bind address. When omitted,
273
+ * `ocx hub invite` falls back to `http://<hostname>:<port>`, which is correct for a plain
274
+ * tailnet bind with no TLS frontend.
275
+ */
276
+ dataPublicOrigin?: string;
262
277
  /**
263
278
  * Optional management-only listener for a local HTTPS frontend such as Tailscale Serve.
264
279
  * The hostname is deliberately not configurable: when enabled the socket is always bound
@@ -661,13 +676,24 @@ export interface OcxConfig {
661
676
  * surface: every process on the machine can reach it, spend account quota, and consume paid
662
677
  * provider credentials. Off by default; not for multi-tenant hosts.
663
678
  *
664
- * The port is required when enabled and must differ from the proxy port. An OS-assigned port
665
- * would change across restarts, which would break already-running app-servers holding the
666
- * previous `base_url` — the exact symptom #1102 reported and we disproved for token rotation.
679
+ * Two enabled forms:
680
+ *
681
+ * - `{ enabled: true, port: N }` — a distinct port (the #1102 form). N must differ from the
682
+ * proxy port.
683
+ * - `{ enabled: true }` — the "companion" form: bind `127.0.0.1:<proxy port>`. Legal only
684
+ * when `hostname` is a specific non-loopback, non-wildcard address (a tailnet or LAN IP),
685
+ * because otherwise the public socket already owns that loopback address. This is the
686
+ * one-port hub shape: remote clients dial `hostname:port`, local processes dial
687
+ * `127.0.0.1:port`, and every integration that hardcodes `http://127.0.0.1:<proxy port>`
688
+ * keeps working on a hub whose public bind they cannot reach (#4236).
689
+ *
690
+ * Neither form is OS-assigned. A changing port would break already-running app-servers
691
+ * holding the previous `base_url` — the exact symptom #1102 reported and we disproved for
692
+ * token rotation.
667
693
  */
668
694
  unauthenticatedLoopbackListener?:
669
695
  | { enabled: false }
670
- | { enabled: true; port: number };
696
+ | { enabled: true; port?: number };
671
697
  /**
672
698
  * Outbound HTTP(S) proxy URL for provider requests (e.g. "http://user:pass@proxy:8080", or
673
699
  * "${HTTPS_PROXY}"-style env reference). Mirrored into HTTP_PROXY/HTTPS_PROXY at startup when
@@ -746,6 +772,14 @@ export interface OcxConfig {
746
772
  codexAccounts?: CodexAccount[];
747
773
  /** Account ids administratively excluded from future pool selection until resumed. */
748
774
  pausedCodexAccountIds?: string[];
775
+ /**
776
+ * Codex pool selection policy. Absent means no policy, so an existing install rotates exactly
777
+ * as before.
778
+ *
779
+ * Not in `getDefaultConfig()` on purpose — that function carries no optional-feature keys, so
780
+ * absence is the only default state this policy has.
781
+ */
782
+ codexPool?: OcxCodexPoolConfig;
749
783
  /** Opt-in per-account activation of newly reset Codex quota windows. */
750
784
  codexQuotaAutoRefresh?: Record<string, {
751
785
  fiveHour?: boolean;
@@ -1175,6 +1209,25 @@ export interface OcxWebSearchSidecarConfig {
1175
1209
  streamRoutedModelOutput?: boolean;
1176
1210
  }
1177
1211
 
1212
+ /**
1213
+ * Codex account-pool selection policy.
1214
+ *
1215
+ * This is a selection policy, not a block. An excluded account keeps its credential, quota
1216
+ * history, and thread affinity, stays visible on the account surface, and remains reachable by
1217
+ * explicit account selection. Only automatic rotation skips it.
1218
+ */
1219
+ export interface OcxCodexPoolConfig {
1220
+ /**
1221
+ * Plan keys ordinary rotation skips, matched case-insensitively against the plan stored on each
1222
+ * account. Absent or empty means no policy.
1223
+ *
1224
+ * There is no `minimumPlan` counterpart: ranking ChatGPT plans against each other needs a total
1225
+ * ordering this repository does not have, and inventing one would silently drain a tier the
1226
+ * operator never meant to exclude.
1227
+ */
1228
+ excludedPlans?: string[];
1229
+ }
1230
+
1178
1231
  /**
1179
1232
  * Quota-reset notification settings.
1180
1233
  *
@@ -1,13 +1,14 @@
1
1
  /**
2
2
  * Cached "is an update available?" answer for the GUI sidebar badge.
3
3
  *
4
- * `/api/update/check` spawns `npm view` on every call (~1s, network-bound), so a
4
+ * `/api/update/check` spawns the installing manager's `view` command on every call
5
+ * (~1s, network-bound), so a
5
6
  * sidebar that polls it would spawn a process per tick on every page of the GUI.
6
7
  * The badge instead READS the 20h version cache the CLI update prompt already
7
8
  * maintains (`~/.opencodex/version.json`).
8
9
  *
9
10
  * This is deliberately read-only: it must never trigger a registry refresh. The GUI
10
- * polls it, so a refresh-on-read would let repeated polls launch repeated `npm view`
11
+ * polls it, so a refresh-on-read would let repeated polls launch repeated manager `view`
11
12
  * helpers with no coalescing. Cache warming stays with `ocx start`
12
13
  * (`triggerBackgroundRefreshIfStale` in `src/update/notify.ts`) and with the explicit
13
14
  * `/api/update/check` the user reaches by clicking the sidebar update button.
@@ -3,13 +3,23 @@ import { STOP_HISTORY_INCOMPLETE_EXIT_CODE } from "./stop-contract.mjs";
3
3
  import { proxyIdentityAt } from "../server/proxy-liveness";
4
4
  import { probeProxyLiveness } from "./proxy-liveness-probe.mjs";
5
5
  import { decidePostStopUpdate } from "./stop-decision.mjs";
6
- import { readFileSync, readdirSync } from "node:fs";
6
+ import { existsSync, readFileSync, readdirSync } from "node:fs";
7
7
  import { fileURLToPath } from "node:url";
8
- import { dirname, join } from "node:path";
8
+ import { dirname, join, resolve } from "node:path";
9
9
  import { getConfigDir, loadConfig } from "../config";
10
10
  import { readPid, readRuntimePort } from "../config/process-state";
11
11
  import { pendingTeardownOutstanding } from "../config/pending-teardown";
12
12
  import { npmInvocation } from "./npm-invocation.mjs";
13
+ import { pnpmInvocation, pnpmInvocationForPath, resolvePnpmCommands } from "./pnpm-invocation.mjs";
14
+ import { detectInstallFromPath } from "./install-detection.mjs";
15
+ import {
16
+ pnpmOwnerInvocation,
17
+ readPnpmGlobalPackage,
18
+ resolvePnpmGlobalOwner,
19
+ runPnpmGlobalUpdate,
20
+ } from "./pnpm-global-install.mjs";
21
+ import type { PnpmGlobalOwner, PnpmGlobalOwnerResult } from "./pnpm-global-install.mjs";
22
+ import { checkRegistryPackageIntegrity } from "./registry-integrity.mjs";
13
23
  import {
14
24
  npmCachePreflightFailureMessage,
15
25
  runNpmCachePreflight,
@@ -35,13 +45,101 @@ export function historyRestoreIncomplete(configDir = getConfigDir()): boolean {
35
45
  export const PKG = "@bitkyc08/opencodex";
36
46
  const HERE = dirname(fileURLToPath(import.meta.url)); // .../opencodex/src/update
37
47
 
38
- export type Installer = "bun" | "npm" | "source";
48
+ export type Installer = "bun" | "npm" | "pnpm" | "source";
39
49
  export type Channel = "latest" | "preview";
40
50
 
41
51
  /** Infer how opencodex is installed from the running module's path. */
42
52
  export function detectInstall(): Installer {
43
- if (!HERE.includes("node_modules")) return "source"; // a git checkout, not a global install
44
- return HERE.includes(".bun") ? "bun" : "npm";
53
+ return detectInstallFromPath(HERE, { exists: existsSync });
54
+ }
55
+
56
+ function packageRoot(): string {
57
+ return resolve(HERE, "..", "..");
58
+ }
59
+
60
+ function runningPnpmShimPath(): string | undefined {
61
+ const invoked = process.argv[1];
62
+ if (!invoked) return undefined;
63
+ const name = invoked.replaceAll("\\", "/").split("/").at(-1)?.toLowerCase();
64
+ if (!new Set(["ocx", "opencodex", "ocx.cmd", "opencodex.cmd", "ocx.ps1", "opencodex.ps1"]).has(name ?? "")) {
65
+ return undefined;
66
+ }
67
+ return resolve(invoked);
68
+ }
69
+
70
+ function runPnpmCandidate(
71
+ commandPath: string,
72
+ args: readonly string[],
73
+ capture = false,
74
+ ): { status: number | null; stdout?: string | null; stderr?: string | null } {
75
+ const invocation = pnpmInvocationForPath(commandPath, args);
76
+ if (!invocation) return { status: 1 };
77
+ return spawnSync(invocation.file, invocation.args, {
78
+ stdio: capture ? "pipe" : "ignore",
79
+ encoding: "utf8",
80
+ timeout: 20_000,
81
+ windowsHide: true,
82
+ ...invocation.options,
83
+ });
84
+ }
85
+
86
+ /** Resolve the exact pnpm executable/group/bin that own this package. */
87
+ export function resolveCurrentPnpmGlobalOwner(): PnpmGlobalOwnerResult {
88
+ return resolvePnpmGlobalOwner({
89
+ packageName: PKG,
90
+ packagePath: packageRoot(),
91
+ commandPaths: resolvePnpmCommands(),
92
+ runningShimPath: runningPnpmShimPath(),
93
+ runPnpm: runPnpmCandidate,
94
+ });
95
+ }
96
+
97
+ function ownerPnpmTarget(
98
+ owner: PnpmGlobalOwner,
99
+ args: readonly string[],
100
+ ): { bin: string; args: string[]; options: { windowsVerbatimArguments?: boolean }; env: Record<string, string | undefined> } | null {
101
+ const invocation = pnpmOwnerInvocation(owner, args);
102
+ if (!invocation) return null;
103
+ return {
104
+ bin: invocation.file,
105
+ args: invocation.args,
106
+ options: invocation.options,
107
+ env: invocation.env,
108
+ };
109
+ }
110
+
111
+ function runOwnedPnpm(
112
+ owner: PnpmGlobalOwner,
113
+ args: readonly string[],
114
+ capture: boolean,
115
+ stdio: "inherit" | "pipe" | "ignore" = capture ? "pipe" : "inherit",
116
+ ): { status: number | null; stdout?: string | null; stderr?: string | null } {
117
+ const target = ownerPnpmTarget(owner, args);
118
+ if (!target) return { status: 1 };
119
+ return spawnSync(target.bin, target.args, {
120
+ stdio,
121
+ encoding: "utf8",
122
+ timeout: 180_000,
123
+ windowsHide: true,
124
+ env: target.env,
125
+ ...target.options,
126
+ });
127
+ }
128
+
129
+ /** Re-read the owning group's active package and return its verified launcher. */
130
+ export function resolvePnpmActiveLauncher(owner: PnpmGlobalOwner): string | null {
131
+ const active = readPnpmGlobalPackage(
132
+ PKG,
133
+ (args, capture = false) => runOwnedPnpm(owner, args, capture),
134
+ undefined,
135
+ {
136
+ owner,
137
+ expectedGlobalDir: owner.globalDir,
138
+ expectedGlobalRoot: owner.globalRoot,
139
+ globalBinDir: owner.globalBinDir,
140
+ },
141
+ );
142
+ return active.ok ? join(active.path, "bin", "ocx.mjs") : null;
45
143
  }
46
144
 
47
145
  export function currentVersion(): string {
@@ -63,14 +161,57 @@ export function updateTag(current: string): Channel {
63
161
  return defaultUpdateTag(current);
64
162
  }
65
163
 
66
- function npmSpawnTarget(args: readonly string[]): { bin: string; args: string[]; options: { windowsVerbatimArguments?: boolean } } | null {
164
+ type SpawnTarget = {
165
+ bin: string;
166
+ args: string[];
167
+ options: { windowsVerbatimArguments?: boolean };
168
+ env?: Record<string, string | undefined>;
169
+ };
170
+
171
+ function npmSpawnTarget(args: readonly string[]): SpawnTarget | null {
67
172
  const invocation = npmInvocation(args);
68
173
  if (!invocation) return null;
69
174
  return { bin: invocation.file, args: invocation.args, options: invocation.options };
70
175
  }
71
176
 
72
- function updateSpawnTarget(bin: string, args: readonly string[]): { bin: string; args: string[]; options: { windowsVerbatimArguments?: boolean } } | null {
177
+ function pnpmSpawnTarget(args: readonly string[], owner?: PnpmGlobalOwner): SpawnTarget | null {
178
+ if (owner) {
179
+ const invocation = pnpmOwnerInvocation(owner, args);
180
+ if (!invocation) return null;
181
+ return {
182
+ bin: invocation.file,
183
+ args: invocation.args,
184
+ options: invocation.options,
185
+ env: invocation.env,
186
+ };
187
+ }
188
+ const invocation = pnpmInvocation(args);
189
+ if (!invocation) return null;
190
+ return { bin: invocation.file, args: invocation.args, options: invocation.options };
191
+ }
192
+
193
+ function registrySpawnTarget(
194
+ installer: Installer,
195
+ args: readonly string[],
196
+ owner?: PnpmGlobalOwner,
197
+ ): SpawnTarget | null {
198
+ // A pnpm command without an owner would silently fall back to the first PATH
199
+ // candidate. That is unsafe when multiple PNPM_HOME installations expose the
200
+ // same version, so registry queries use the same hard binding as mutation.
201
+ return installer === "pnpm"
202
+ ? owner ? pnpmSpawnTarget(args, owner) : null
203
+ : npmSpawnTarget(args);
204
+ }
205
+
206
+ function selectedPnpmOwner(owner?: PnpmGlobalOwner): PnpmGlobalOwner | undefined {
207
+ if (owner) return owner;
208
+ const result = resolveCurrentPnpmGlobalOwner();
209
+ return result.ok ? result.owner : undefined;
210
+ }
211
+
212
+ function updateSpawnTarget(bin: string, args: readonly string[]): SpawnTarget | null {
73
213
  if (bin === "npm") return npmSpawnTarget(args);
214
+ if (bin === "pnpm") return pnpmSpawnTarget(args);
74
215
  if (process.platform === "win32" && bin === "bun") {
75
216
  return { bin: process.execPath, args: [...args], options: {} };
76
217
  }
@@ -95,28 +236,46 @@ function logSpawnOutput(label: string, result: { stdout?: string | Buffer | null
95
236
  if (stderr) console.error(stderr.length > 4000 ? `${label}${stderr.slice(-4000)}` : stderr);
96
237
  }
97
238
 
98
- /** Latest published version from the registry (best-effort; null if npm isn't available). */
99
- export function latestVersion(tag: string): string | null {
100
- const npm = npmSpawnTarget(["view", `${PKG}@${tag}`, "version"]);
101
- if (!npm) return null;
102
- const r = spawnSync(npm.bin, npm.args, {
239
+ function shellQuote(value: string): string {
240
+ if (process.platform === "win32") return `"${value.replaceAll("\"", "\\\"")}"`;
241
+ return `'${value.replaceAll("'", "'\\''")}'`;
242
+ }
243
+
244
+ function launcherStartHint(launcher: string, port: number): string {
245
+ return `${shellQuote(process.execPath)} ${shellQuote(launcher)} start --port ${Math.trunc(port)}`;
246
+ }
247
+
248
+ /** Latest published version from the registry (best-effort; null if the manager isn't available). */
249
+ export function latestVersion(
250
+ tag: string,
251
+ installer: Installer = detectInstall(),
252
+ owner?: PnpmGlobalOwner,
253
+ ): string | null {
254
+ const resolvedOwner = installer === "pnpm" ? selectedPnpmOwner(owner) : undefined;
255
+ if (installer === "pnpm" && !resolvedOwner) return null;
256
+ const manager = registrySpawnTarget(installer, ["view", `${PKG}@${tag}`, "version"], resolvedOwner);
257
+ if (!manager) return null;
258
+ const r = spawnSync(manager.bin, manager.args, {
103
259
  encoding: "utf8",
104
260
  timeout: 12000,
105
261
  windowsHide: true,
106
- ...npm.options,
262
+ ...(manager.env ? { env: manager.env } : {}),
263
+ ...manager.options,
107
264
  });
108
- return r.status === 0 ? (r.stdout.trim() || null) : null;
265
+ return r.status === 0 && typeof r.stdout === "string" ? (r.stdout.trim() || null) : null;
109
266
  }
110
267
 
111
268
  /** The global-install command opencodex would run to update on this channel. */
112
269
  export function updateCommand(installer: Installer, tag: Channel, resolvedVersion?: string | null): { bin: string; args: string[] } {
113
- const bin = installer === "bun" ? "bun" : "npm";
114
270
  // Immutable target: when the registry resolved a concrete version, install exactly
115
271
  // that version — the dist-tag can move between resolution and install (TOCTOU).
116
272
  const target = resolvedVersion || tag;
117
- const args = installer === "bun"
118
- ? ["add", "-g", `${PKG}@${target}`]
119
- : ["install", "-g", `${PKG}@${target}`];
273
+ if (installer === "bun") return { bin: "bun", args: ["add", "-g", `${PKG}@${target}`] };
274
+ if (installer === "pnpm") {
275
+ return { bin: "pnpm", args: ["add", "-g", "--allow-build=bun", `${PKG}@${target}`] };
276
+ }
277
+ const bin = "npm";
278
+ const args = ["install", "-g", `${PKG}@${target}`];
120
279
  return { bin, args };
121
280
  }
122
281
 
@@ -138,26 +297,33 @@ export function updateCommandStr(installer: Installer, tag: Channel, resolvedVer
138
297
  export function checkUpdatePackageIntegrity(
139
298
  version: string | null,
140
299
  spawn: typeof spawnSync = spawnSync,
300
+ installer: Installer = detectInstall(),
301
+ owner?: PnpmGlobalOwner,
141
302
  ): { ok: true; integrity: string } | { ok: false; reason: string } | { ok: "skipped"; reason: string } {
142
- if (!version) return { ok: "skipped", reason: "no resolved version (registry unavailable)" };
143
- const npm = npmSpawnTarget(["view", `${PKG}@${version}`, "dist.integrity"]);
144
- if (!npm) return { ok: "skipped", reason: "npm executable was not found on a trusted PATH entry" };
145
- const r = spawn(
146
- npm.bin,
147
- npm.args,
148
- { encoding: "utf8", timeout: 12000, windowsHide: true, ...npm.options },
149
- );
150
- // status !== 0 covers nonzero exits AND timeouts (status === null).
151
- if (r.status !== 0) return { ok: "skipped", reason: `registry integrity query failed (status ${r.status ?? "timeout"})` };
152
- const tokens = (r.stdout ?? "").replace(/["']/g, "").trim().split(/\s+/).filter(Boolean);
153
- const match = tokens.find(token => /^sha512-[A-Za-z0-9+/=]+$/.test(token));
154
- if (!match) return { ok: false, reason: `registry returned no sha512 integrity for ${PKG}@${version}` };
155
- return { ok: true, integrity: match };
303
+ const resolvedOwner = installer === "pnpm" ? selectedPnpmOwner(owner) : undefined;
304
+ if (installer === "pnpm" && !resolvedOwner) {
305
+ return { ok: false, reason: "could not identify pnpm's owning global installation" };
306
+ }
307
+ const manager = registrySpawnTarget(installer, ["view", `${PKG}@${version}`, "dist.integrity"], resolvedOwner);
308
+ if (!manager) return { ok: "skipped", reason: `${installer} executable was not found on a trusted PATH entry` };
309
+ const result = checkRegistryPackageIntegrity(PKG, version, args => {
310
+ const target = registrySpawnTarget(installer, args, resolvedOwner);
311
+ if (!target) return { status: 1 };
312
+ return spawn(target.bin, target.args, {
313
+ encoding: "utf8",
314
+ timeout: 12000,
315
+ windowsHide: true,
316
+ ...(target.env ? { env: target.env } : {}),
317
+ ...target.options,
318
+ });
319
+ });
320
+ return result;
156
321
  }
157
322
 
158
323
  /**
159
- * `ocx update` fallback for source checkouts and Bun global installs. npm global installs are updated
160
- * in the Node bin launcher before Bun starts, so Windows does not replace the running Bun binary.
324
+ * `ocx update` fallback for source checkouts and Bun global installs. npm and pnpm global installs
325
+ * are updated in the Node bin launcher before Bun starts, so Windows does not replace the running
326
+ * Bun binary.
161
327
  */
162
328
  export async function runUpdate(): Promise<void> {
163
329
  const installer = detectInstall();
@@ -170,7 +336,13 @@ export async function runUpdate(): Promise<void> {
170
336
  return;
171
337
  }
172
338
 
173
- const latest = latestVersion(tag);
339
+ const ownerResult = installer === "pnpm" ? resolveCurrentPnpmGlobalOwner() : undefined;
340
+ if (installer === "pnpm" && (!ownerResult || !ownerResult.ok)) {
341
+ console.error(`⚠️ ${ownerResult?.reason ?? "Could not identify pnpm's owning global installation"}. Aborting before stopping the proxy.`);
342
+ process.exit(1);
343
+ }
344
+ const owner = ownerResult?.ok ? ownerResult.owner : undefined;
345
+ const latest = latestVersion(tag, installer, owner);
174
346
  if (latest && latest === current) {
175
347
  console.log(`Already on the latest ${tag} version (v${latest}).`);
176
348
  return;
@@ -178,7 +350,7 @@ export async function runUpdate(): Promise<void> {
178
350
 
179
351
  // Pre-flight integrity metadata check — runs BEFORE the proxy is stopped so an
180
352
  // anomalous registry entry aborts without unloading the running service.
181
- const integrity = checkUpdatePackageIntegrity(latest);
353
+ const integrity = checkUpdatePackageIntegrity(latest, spawnSync, installer, owner);
182
354
  if (integrity.ok === false) {
183
355
  console.error(`⚠️ ${integrity.reason} — aborting the update before stopping the proxy.`);
184
356
  process.exit(1);
@@ -198,9 +370,11 @@ export async function runUpdate(): Promise<void> {
198
370
  }
199
371
 
200
372
  const { bin, args: cmdArgs } = updateCommand(installer, tag, latest);
201
- const target = updateSpawnTarget(bin, cmdArgs);
373
+ const target = installer === "pnpm" && owner
374
+ ? pnpmSpawnTarget(cmdArgs, owner)
375
+ : updateSpawnTarget(bin, cmdArgs);
202
376
  if (!target) {
203
- console.error("⚠️ Could not resolve npm from a trusted absolute PATH entry; aborting before stopping the proxy.");
377
+ console.error(`⚠️ Could not resolve ${bin} from a trusted absolute PATH entry; aborting before stopping the proxy.`);
204
378
  process.exit(1);
205
379
  }
206
380
 
@@ -257,7 +431,9 @@ export async function runUpdate(): Promise<void> {
257
431
  // shared client config still points at a proxy that is gone; installing over that
258
432
  // silently skips the recovery the receipt was written to trigger (#3008).
259
433
  // Full `ocx stop` semantics (drain, service stop, restore).
434
+ let stopAttempted = false;
260
435
  if (serviceWasInstalled || readPid() || readRuntimePort() || pendingTeardownOutstanding()) {
436
+ stopAttempted = true;
261
437
  console.log("⏹ Stopping the running proxy before updating...");
262
438
  const stopStdio = updateChildStdio();
263
439
  const stop = spawnSync(process.execPath, selfLaunchArgv(["stop"]), {
@@ -266,7 +442,7 @@ export async function runUpdate(): Promise<void> {
266
442
  windowsHide: true,
267
443
  });
268
444
  if (stopStdio === "pipe") logSpawnOutput("", stop);
269
- // One decision, shared with the npm launcher (#3008). The two lanes disagreeing about
445
+ // One decision, shared with the package launcher (#3008). The two lanes disagreeing about
270
446
  // the same situation is how this shipped fixed on one side only. Absent PID and runtime
271
447
  // files are weak evidence - a crashed-but-listening proxy leaves none - so the captured
272
448
  // endpoint is asked, and `null` from proxyIdentityAt covers refusal AND timeout alike.
@@ -310,35 +486,96 @@ export async function runUpdate(): Promise<void> {
310
486
  console.log(`Updating${latest ? ` to v${latest}` : ""}…\n$ ${bin} ${cmdArgs.join(" ")}`);
311
487
 
312
488
  const installStdio = updateChildStdio();
313
- const r = spawnSync(target.bin, target.args, {
314
- stdio: installStdio,
315
- encoding: installStdio === "pipe" ? "utf8" : undefined,
316
- timeout: 180000,
317
- windowsHide: true,
318
- ...target.options,
319
- });
489
+ // Every post-update action below receives this path. For pnpm it is replaced only
490
+ // by a path returned after tree+shim verification; on rollback, activePath is
491
+ // likewise returned only after the old group has been verified again.
492
+ let postUpdateLauncher = join(packageRoot(), "bin", "ocx.mjs");
493
+ // The pnpm owner preflight has verified this package tree and global group. Keep that exact
494
+ // package path as the recovery starting point; the path returned by the update transaction
495
+ // replaces it only after post-update tree+shim verification succeeds.
496
+ if (installer === "pnpm" && owner) {
497
+ postUpdateLauncher = join(owner.packagePath, "bin", "ocx.mjs");
498
+ }
499
+ let postUpdateLauncherUsable = true;
500
+ let r: {
501
+ status: number | null;
502
+ signal?: NodeJS.Signals | null;
503
+ stdout?: string | Buffer | null;
504
+ stderr?: string | Buffer | null;
505
+ };
506
+ if (installer === "pnpm") {
507
+ let update: ReturnType<typeof runPnpmGlobalUpdate>;
508
+ try {
509
+ update = runPnpmGlobalUpdate({
510
+ packageName: PKG,
511
+ currentVersion: current,
512
+ targetVersion: latest || undefined,
513
+ tag,
514
+ owner: owner!,
515
+ runningPackagePath: packageRoot(),
516
+ runPnpm: (args, capture = false) => runOwnedPnpm(
517
+ owner!,
518
+ args,
519
+ capture,
520
+ capture ? "pipe" : installStdio,
521
+ ),
522
+ log: line => console.log(line),
523
+ });
524
+ } catch {
525
+ // A thrown verifier/runner error means the active group is unknown. Mark the
526
+ // launcher unusable and let the failure lane report a manual recovery path.
527
+ update = {
528
+ ok: false,
529
+ phase: "rollback",
530
+ rolledBack: false,
531
+ error: "pnpm update failed unexpectedly; active package could not be verified",
532
+ };
533
+ }
534
+ if (update.ok) {
535
+ postUpdateLauncher = join(update.path, "bin", "ocx.mjs");
536
+ postUpdateLauncherUsable = true;
537
+ r = { status: 0, signal: null, stdout: "", stderr: "" };
538
+ } else {
539
+ postUpdateLauncherUsable = Boolean(update.activePath);
540
+ if (update.activePath) postUpdateLauncher = join(update.activePath, "bin", "ocx.mjs");
541
+ console.error(`⚠️ ${update.error}${update.rolledBack ? "." : " Manual recovery may be required."}`);
542
+ r = { status: 1, signal: null, stdout: "", stderr: "" };
543
+ }
544
+ } else {
545
+ r = spawnSync(target.bin, target.args, {
546
+ stdio: installStdio,
547
+ encoding: installStdio === "pipe" ? "utf8" : undefined,
548
+ timeout: 180000,
549
+ windowsHide: true,
550
+ ...target.options,
551
+ });
552
+ }
320
553
  if (installStdio === "pipe") logSpawnOutput("", r);
321
554
  if (r.status === 0) {
322
555
  console.log(`\n✅ Updated${latest ? ` to v${latest}` : ""}.`);
323
- // Re-bake the bundled Bun path into the Codex autostart shim on every
324
- // platform when one is installed (refresh-only; never installs fresh).
556
+ // Re-enter through the verified active package launcher. This keeps the Codex
557
+ // shim, tray, service and proxy recovery paths on the same package/group that
558
+ // pnpm selected, including when the update changed the global link target.
325
559
  try {
326
- const { isCodexShimInstalled, installCodexShim } = await import("../codex/shim");
560
+ const { isCodexShimInstalled } = await import("../codex/shim");
327
561
  if (isCodexShimInstalled()) {
328
- const result = installCodexShim();
329
- if (result.installed) console.log(`🔧 ${result.message}`);
562
+ const shim = spawnSync(process.execPath, [postUpdateLauncher, "codex-shim", "install"], {
563
+ stdio: "inherit",
564
+ windowsHide: true,
565
+ });
566
+ if (shim.status !== 0) console.warn("⚠️ Shim repair skipped: run 'ocx codex-shim install'.");
330
567
  }
331
- } catch (e) {
332
- console.warn(`⚠️ Shim repair skipped: ${e instanceof Error ? e.message : e}`);
568
+ } catch {
569
+ console.warn("⚠️ Shim repair skipped; run 'ocx codex-shim install'.");
333
570
  }
334
571
  if (trayWasInstalled) {
335
- const trayArgs = selfLaunchArgv(planWindowsTrayUpdate({ installed: trayWasInstalled, running: trayWasRunning }).installArgs);
336
- const tray = spawnSync(process.execPath, trayArgs, { stdio: "inherit", windowsHide: true });
572
+ const trayArgs = planWindowsTrayUpdate({ installed: trayWasInstalled, running: trayWasRunning }).installArgs;
573
+ const tray = spawnSync(process.execPath, [postUpdateLauncher, ...trayArgs], { stdio: "inherit", windowsHide: true });
337
574
  if (tray.status === 0) {
338
575
  console.log("🔧 Refreshed Windows tray startup paths.");
339
576
  } else {
340
577
  console.warn("⚠️ Windows tray refresh failed. Run 'ocx tray install'.");
341
- if (trayWasRunning) spawnSync(process.execPath, selfLaunchArgv(["tray", "start"]), { stdio: "ignore", windowsHide: true });
578
+ if (trayWasRunning) spawnSync(process.execPath, [postUpdateLauncher, "tray", "start"], { stdio: "ignore", windowsHide: true });
342
579
  }
343
580
  }
344
581
  // The stop above unloaded any managed service; repair it with the NEW files
@@ -362,7 +599,7 @@ export async function runUpdate(): Promise<void> {
362
599
  process.env.OCX_BAKE_PORT = String(capturedListen.port);
363
600
  try {
364
601
  const svcStdio = updateChildStdio();
365
- const svc = spawnSync(process.execPath, selfLaunchArgv(serviceReinstallArgs()), {
602
+ const svc = spawnSync(process.execPath, [postUpdateLauncher, ...serviceReinstallArgs()], {
366
603
  stdio: svcStdio,
367
604
  encoding: svcStdio === "pipe" ? "utf8" : undefined,
368
605
  windowsHide: true,
@@ -407,7 +644,7 @@ export async function runUpdate(): Promise<void> {
407
644
  : " Run 'ocx service repair' to refresh the background service and see why it failed.");
408
645
  const env = { ...process.env };
409
646
  delete env.OCX_SERVICE;
410
- const child = spawn(process.execPath, selfLaunchArgv(["start", "--port", String(capturedListen.port)]), {
647
+ const child = spawn(process.execPath, [postUpdateLauncher, "start", "--port", String(capturedListen.port)], {
411
648
  detached: true,
412
649
  stdio: "ignore",
413
650
  windowsHide: true,
@@ -422,14 +659,30 @@ export async function runUpdate(): Promise<void> {
422
659
  else process.env.OCX_BAKE_PORT = prevBake;
423
660
  }
424
661
  } else {
425
- console.log(`Restart the proxy: ocx start --port ${capturedListen.port}`);
662
+ console.log(`Restart the proxy: ${launcherStartHint(postUpdateLauncher, capturedListen.port)}`);
426
663
  }
427
664
  } else {
428
- if (trayWasRunning) {
429
- try {
430
- const { startWindowsTray } = await import("../tray/windows");
431
- startWindowsTray();
432
- } catch { /* keep the primary update failure */ }
665
+ if (stopAttempted && trayWasRunning && postUpdateLauncherUsable) {
666
+ spawnSync(process.execPath, [postUpdateLauncher, "tray", "start"], { stdio: "ignore", windowsHide: true });
667
+ }
668
+ if (stopAttempted && serviceWasInstalled && postUpdateLauncherUsable) {
669
+ const service = spawnSync(process.execPath, [postUpdateLauncher, "service", "repair"], {
670
+ stdio: "inherit",
671
+ windowsHide: true,
672
+ });
673
+ if (service.status !== 0) console.warn("⚠️ Previous background service could not be restored; run 'ocx service repair'.");
674
+ } else if (stopAttempted && postUpdateLauncherUsable) {
675
+ const env = { ...process.env };
676
+ delete env.OCX_SERVICE;
677
+ const child = spawn(process.execPath, [postUpdateLauncher, "start", "--port", String(capturedListen.port)], {
678
+ detached: true,
679
+ stdio: "ignore",
680
+ windowsHide: true,
681
+ env: withProcessRuntimeProvenance(env),
682
+ });
683
+ child.unref();
684
+ } else if (stopAttempted) {
685
+ console.error("opencodex: no verified active launcher remains for automatic recovery; reinstall opencodex manually.");
433
686
  }
434
687
  console.error(`\n⚠️ Update failed (${bin} exit ${r.status ?? "?"}). Try manually: ${bin} ${cmdArgs.join(" ")}`);
435
688
  process.exit(1);
@@ -0,0 +1,6 @@
1
+ export type DetectedInstall = "bun" | "npm" | "pnpm" | "source";
2
+
3
+ export declare function detectInstallFromPath(
4
+ packagePath: string,
5
+ deps?: { exists?: (path: string) => boolean; realpath?: (path: string) => string },
6
+ ): DetectedInstall;