@bitkyc08/opencodex 2.49.0 → 2.51.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (141) hide show
  1. package/AGENTS_INSTALL.md +9 -1
  2. package/README.md +3 -0
  3. package/bin/ocx.mjs +222 -71
  4. package/gui/dist/assets/index-D7BdZpZm.js +115 -0
  5. package/gui/dist/index.html +1 -1
  6. package/package.json +1 -1
  7. package/src/adapters/qoder/adapter.ts +69 -1
  8. package/src/adapters/qoder/scaffold-guard.ts +233 -0
  9. package/src/claude/agents-inject.ts +29 -5
  10. package/src/claude/desktop-3p.ts +31 -3
  11. package/src/claude/gateway-cache.ts +12 -21
  12. package/src/claude/inbound.ts +17 -5
  13. package/src/cli/account-api.ts +18 -3
  14. package/src/cli/account-auth.ts +8 -1
  15. package/src/cli/account-extended.ts +2 -1
  16. package/src/cli/account.ts +1 -0
  17. package/src/cli/capabilities.ts +43 -1
  18. package/src/cli/claude-agent-startup-sync.ts +26 -1
  19. package/src/cli/claude.ts +138 -20
  20. package/src/cli/config-command.ts +67 -1
  21. package/src/cli/connect.ts +181 -14
  22. package/src/cli/dispatch.ts +53 -9
  23. package/src/cli/doctor.ts +9 -2
  24. package/src/cli/ensure-desired-integrations.ts +10 -0
  25. package/src/cli/gui-pair-client.ts +1 -12
  26. package/src/cli/help.ts +4 -1
  27. package/src/cli/hub.ts +367 -0
  28. package/src/cli/index.ts +99 -31
  29. package/src/cli/launcher-context.ts +1 -1
  30. package/src/cli/models-runtime.ts +8 -3
  31. package/src/cli/observe.ts +13 -3
  32. package/src/cli/registry.ts +43 -3
  33. package/src/cli/status.ts +325 -5
  34. package/src/cli/version-skew.ts +4 -1
  35. package/src/cli.ts +2 -2
  36. package/src/client/catalog-compatibility.ts +192 -0
  37. package/src/client/connect.ts +31 -0
  38. package/src/client/hub-client.ts +52 -0
  39. package/src/client/hub-state.ts +214 -0
  40. package/src/clients/config-export/zcode.ts +24 -0
  41. package/src/codex/account-runtime-state.ts +6 -1
  42. package/src/codex/account-store.ts +72 -9
  43. package/src/codex/account-usability.ts +50 -13
  44. package/src/codex/auth-api.ts +156 -28
  45. package/src/codex/auth-context.ts +21 -0
  46. package/src/codex/catalog/effort.ts +67 -8
  47. package/src/codex/catalog/parsing.ts +23 -0
  48. package/src/codex/catalog/provider-fetch.ts +71 -2
  49. package/src/codex/catalog/sync.ts +99 -0
  50. package/src/codex/codex-write-lock.ts +11 -2
  51. package/src/codex/desired-state.ts +47 -1
  52. package/src/codex/inject-coordination.ts +10 -5
  53. package/src/codex/inject.ts +29 -12
  54. package/src/codex/loopback-target.ts +45 -0
  55. package/src/codex/quota-auto-refresh.ts +6 -1
  56. package/src/codex/quota.ts +54 -8
  57. package/src/codex/routing.ts +48 -1
  58. package/src/codex/runtime.ts +37 -3
  59. package/src/codex/sync.ts +29 -9
  60. package/src/codex/warmup.ts +21 -4
  61. package/src/combos/index.ts +2 -0
  62. package/src/combos/resolve.ts +52 -0
  63. package/src/config/pending-teardown.ts +1 -1
  64. package/src/config.ts +184 -12
  65. package/src/generated/compatibility-version.json +188 -116
  66. package/src/grok/status.ts +9 -1
  67. package/src/integrations/config-io.ts +54 -1
  68. package/src/lib/bun-runtime.ts +1 -1
  69. package/src/lib/errors.ts +8 -0
  70. package/src/lib/gui-pair-capability.ts +27 -0
  71. package/src/lib/local-destinations.ts +162 -0
  72. package/src/lib/package-tree-integrity.ts +1 -1
  73. package/src/lib/privacy.ts +25 -0
  74. package/src/lib/process-control.ts +130 -20
  75. package/src/lib/service-secrets.ts +28 -0
  76. package/src/lib/test-home-guard.ts +49 -0
  77. package/src/oauth/health.ts +47 -12
  78. package/src/oauth/index.ts +46 -8
  79. package/src/oauth/token-guardian.ts +32 -6
  80. package/src/providers/google-ai-studio-model-discovery.ts +74 -0
  81. package/src/providers/opencode-go-transport.ts +9 -1
  82. package/src/providers/opencode-zen-rate-limit.ts +75 -0
  83. package/src/providers/quota.ts +20 -1
  84. package/src/providers/registry.ts +35 -6
  85. package/src/remote/hub-state.ts +182 -0
  86. package/src/server/auth-cors.ts +11 -0
  87. package/src/server/chat-completions.ts +10 -7
  88. package/src/server/chat-native.ts +10 -1
  89. package/src/server/claude-messages.ts +12 -6
  90. package/src/server/hub-state.ts +98 -0
  91. package/src/server/images.ts +2 -2
  92. package/src/server/index.ts +149 -8
  93. package/src/server/management/api-access.ts +14 -3
  94. package/src/server/management/config-routes.ts +2 -2
  95. package/src/server/management/cursor-integration-routes.ts +13 -4
  96. package/src/server/management/logs-usage-routes.ts +4 -1
  97. package/src/server/management/model-rows.ts +16 -1
  98. package/src/server/management/oauth-account-routes.ts +6 -2
  99. package/src/server/management/provider-routes.ts +9 -2
  100. package/src/server/management/request-history-routes.ts +4 -2
  101. package/src/server/management/route-registry.ts +5 -4
  102. package/src/server/management/shared.ts +66 -3
  103. package/src/server/management-api.ts +1 -1
  104. package/src/server/proxy-liveness.ts +7 -1
  105. package/src/server/request-decompress.ts +91 -3
  106. package/src/server/request-log-conversation.ts +41 -1
  107. package/src/server/request-log.ts +10 -0
  108. package/src/server/responses/codex-auth-error.ts +18 -1
  109. package/src/server/responses/codex-ws-exchange.ts +36 -4
  110. package/src/server/responses/codex-ws-wire.ts +76 -5
  111. package/src/server/responses/compact.ts +28 -11
  112. package/src/server/responses/context-overflow.ts +11 -0
  113. package/src/server/responses/core.ts +201 -48
  114. package/src/server/responses/policy-fallback.ts +13 -3
  115. package/src/server/search.ts +2 -2
  116. package/src/server/system-env-shell.ts +14 -2
  117. package/src/server/system-env.ts +106 -14
  118. package/src/service.ts +965 -68
  119. package/src/types/accounts.ts +18 -0
  120. package/src/types/config.ts +93 -4
  121. package/src/types/provider.ts +56 -0
  122. package/src/types.ts +4 -0
  123. package/src/update/badge.ts +3 -2
  124. package/src/update/index.ts +317 -64
  125. package/src/update/install-detection.d.mts +6 -0
  126. package/src/update/install-detection.mjs +73 -0
  127. package/src/update/job.ts +101 -49
  128. package/src/update/pnpm-global-install.d.mts +144 -0
  129. package/src/update/pnpm-global-install.mjs +591 -0
  130. package/src/update/pnpm-invocation.d.mts +43 -0
  131. package/src/update/pnpm-invocation.mjs +141 -0
  132. package/src/update/registry-integrity.d.mts +16 -0
  133. package/src/update/registry-integrity.mjs +37 -0
  134. package/src/update/transactional-install.d.mts +1 -1
  135. package/src/update/transactional-install.mjs +101 -7
  136. package/src/update/tray-update-plan.mjs +1 -1
  137. package/src/vision/plan.ts +13 -3
  138. package/src/vision/routed-describe.ts +51 -20
  139. package/src/web-search/ollama-executor.ts +127 -0
  140. package/src/web-search/passthrough-bridge.ts +761 -0
  141. package/gui/dist/assets/index-BtyONQrZ.js +0 -115
@@ -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;
@@ -0,0 +1,73 @@
1
+ import { realpathSync } from "node:fs";
2
+
3
+ /**
4
+ * Infer the package manager from the path of the running package.
5
+ *
6
+ * PATH is deliberately not consulted here. A machine can have npm, pnpm, and Bun
7
+ * installed at the same time; the package layout is the evidence of which manager owns
8
+ * the files that the updater must change. The legacy `global/<version>` spelling is only
9
+ * accepted when the adjacent filesystem metadata also looks like a pnpm global group.
10
+ *
11
+ * The real path is considered in addition to the spelling visible to the module loader.
12
+ * This matters when Node is launched with preserved symlinks: a pnpm package can be
13
+ * exposed through an npm-looking prefix while its target is still under pnpm's global
14
+ * virtual store.
15
+ */
16
+ export function detectInstallFromPath(packagePath, deps = {}) {
17
+ const exists = deps.exists;
18
+ const candidates = [String(packagePath)];
19
+ try {
20
+ const resolved = (deps.realpath ?? realpathSync)(String(packagePath));
21
+ if (resolved && !candidates.includes(resolved)) candidates.push(resolved);
22
+ } catch {
23
+ // Synthetic paths in source-level checks, and a partially removed install, have no
24
+ // realpath. The lexical path still carries the evidence when it is available.
25
+ }
26
+
27
+ let sawNodeModules = false;
28
+ for (const candidate of candidates) {
29
+ const detected = detectInstallCandidate(candidate, exists);
30
+ if (detected === "pnpm" || detected === "bun") return detected;
31
+ if (detected === "npm") sawNodeModules = true;
32
+ }
33
+ return sawNodeModules ? "npm" : "source";
34
+ }
35
+
36
+ function detectInstallCandidate(packagePath, exists) {
37
+ const normalized = String(packagePath).replaceAll("\\", "/");
38
+ const segments = normalized.split("/").filter(Boolean);
39
+ // Windows paths are case-insensitive. Treating the structural marker this way also
40
+ // keeps a preserved-symlink path from being downgraded merely because its casing came
41
+ // from a Windows API or a user-created junction.
42
+ if (!segments.some(segment => segment.toLowerCase() === "node_modules")) return "source";
43
+
44
+ // Strong signatures survive normal symlink resolution: the v10 isolated virtual store
45
+ // and v11 global virtual store are both manager-owned paths. Do not classify an arbitrary
46
+ // npm prefix such as `/opt/global/v11` from its directory name alone.
47
+ if (
48
+ /(?:^|\/)node_modules\/\.pnpm(?:\/|$)/i.test(normalized)
49
+ || /(?:^|\/)store\/v\d+\/links(?:\/|$)/i.test(normalized)
50
+ ) return "pnpm";
51
+
52
+ // `--preserve-symlinks` can leave a v10/v11 group path visible. Corroborate it with the
53
+ // group's virtual store or the v11 global store before selecting pnpm.
54
+ const globalMatch = normalized.match(/^(.*\/global\/(?:v)?\d+)(?:\/[^/]+)*\/node_modules(?:\/|$)/i);
55
+ if (globalMatch && exists) {
56
+ const globalRoot = globalMatch[1];
57
+ const groupRoot = normalized.slice(0, normalized.toLowerCase().indexOf("/node_modules"));
58
+ if (
59
+ exists(`${groupRoot}/node_modules/.pnpm`)
60
+ || exists(`${groupRoot}/node_modules/.modules.yaml`)
61
+ || exists(`${groupRoot}/node_modules/.pnpm/lock.yaml`)
62
+ || exists(`${globalRoot}/store`)
63
+ || exists(`${globalRoot}/pnpm-lock.yaml`)
64
+ ) return "pnpm";
65
+ }
66
+
67
+ // Bun's global layout is specifically `.bun/install/global/node_modules`. A bare `.bun`
68
+ // directory is not enough: npm projects can quite legitimately live under a dot-directory
69
+ // with that name, especially on Windows where package paths are often user-selected.
70
+ if (/(?:^|\/)\.bun\/install\/global\/node_modules(?:\/|$)/i.test(normalized)) return "bun";
71
+
72
+ return "npm";
73
+ }