@bitkyc08/opencodex 2.60.0 → 2.61.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 (249) hide show
  1. package/AGENTS_INSTALL.md +64 -0
  2. package/README.md +28 -1
  3. package/bin/ocx.mjs +382 -209
  4. package/gui/dist/assets/App-CH6C5H7x.js +50 -0
  5. package/gui/dist/assets/Tray-CLZh48fM.js +1 -0
  6. package/gui/dist/assets/index-_bpvxJu0.css +1 -0
  7. package/gui/dist/assets/index-wpTOyepx.js +86 -0
  8. package/gui/dist/assets/usage-companion-chart-a0N58rRI.js +1 -0
  9. package/gui/dist/favicon.png +0 -0
  10. package/gui/dist/index.html +2 -2
  11. package/gui/dist/provider-icons/stepfun-color.svg +1 -0
  12. package/package.json +5 -1
  13. package/src/adapters/anthropic.ts +16 -0
  14. package/src/adapters/coding-agent/protocol.ts +36 -6
  15. package/src/adapters/coding-agent/turn.ts +10 -2
  16. package/src/adapters/command-code.ts +2 -1
  17. package/src/adapters/cursor/catalog.ts +51 -7
  18. package/src/adapters/cursor/protobuf-request.ts +6 -3
  19. package/src/adapters/cursor/request-builder.ts +13 -3
  20. package/src/adapters/cursor.ts +11 -2
  21. package/src/adapters/declaration-carrier.ts +45 -0
  22. package/src/adapters/devin.ts +75 -23
  23. package/src/adapters/google-antigravity-wire.ts +5 -2
  24. package/src/adapters/google-errors.ts +7 -1
  25. package/src/adapters/google.ts +29 -5
  26. package/src/adapters/image.ts +4 -1
  27. package/src/adapters/input-media-guard.ts +21 -9
  28. package/src/adapters/kiro/usage.ts +3 -2
  29. package/src/adapters/kiro-tool-fallback.ts +1 -1
  30. package/src/adapters/ollama-native.ts +6 -0
  31. package/src/adapters/openai-chat/developer-role.ts +61 -0
  32. package/src/adapters/openai-chat/messages.ts +46 -27
  33. package/src/adapters/openai-chat/parallel-tool-calls.ts +32 -0
  34. package/src/adapters/openai-chat/passthrough.ts +33 -9
  35. package/src/adapters/openai-chat/reasoning-wire.ts +89 -0
  36. package/src/adapters/openai-chat.ts +18 -57
  37. package/src/adapters/openai-responses/passthrough.ts +2 -0
  38. package/src/adapters/registry.ts +3 -2
  39. package/src/adapters/run-turn-queue.ts +178 -29
  40. package/src/adapters/xai-web-search.ts +16 -1
  41. package/src/bridge/errors.ts +8 -2
  42. package/src/bridge/response-json.ts +9 -1
  43. package/src/bridge/sse.ts +10 -0
  44. package/src/chat/inbound.ts +141 -5
  45. package/src/claude/desktop-3p.ts +7 -1
  46. package/src/claude/desktop-first-party.ts +183 -0
  47. package/src/claude/desktop-gateway-state.ts +41 -0
  48. package/src/claude/inbound-content-options.ts +6 -0
  49. package/src/claude/inbound.ts +32 -6
  50. package/src/claude/intercept/connect-proxy.ts +179 -0
  51. package/src/claude/intercept/listener.ts +122 -0
  52. package/src/claude/intercept/local-ca.ts +298 -0
  53. package/src/claude/intercept/runtime.ts +98 -0
  54. package/src/claude/intercept/settings.ts +189 -0
  55. package/src/cli/access.ts +87 -0
  56. package/src/cli/account-auth.ts +19 -0
  57. package/src/cli/capabilities.ts +31 -0
  58. package/src/cli/claude-desktop.ts +206 -16
  59. package/src/cli/codex-shim-autorestore.ts +3 -0
  60. package/src/cli/companion.ts +56 -0
  61. package/src/cli/dispatch.ts +43 -4
  62. package/src/cli/ensure-desired-integrations.ts +43 -5
  63. package/src/cli/help.ts +7 -9
  64. package/src/cli/index.ts +200 -61
  65. package/src/cli/init.ts +8 -0
  66. package/src/cli/integrations.ts +7 -1
  67. package/src/cli/registry.ts +41 -2
  68. package/src/cli/resolve.ts +230 -0
  69. package/src/cli/root.ts +24 -1
  70. package/src/cli/start-ownership-publication.ts +56 -0
  71. package/src/cli/status-probes.ts +2 -18
  72. package/src/cli/status.ts +62 -0
  73. package/src/cli/stop-report.ts +143 -0
  74. package/src/cli/uninstall-plan.ts +9 -0
  75. package/src/client/machine-listener.ts +2 -5
  76. package/src/clients/aside-profiles.ts +4 -0
  77. package/src/clients/config-export/zcode-store.ts +157 -0
  78. package/src/clients/config-export.ts +36 -0
  79. package/src/codex/app-server-processes.ts +72 -40
  80. package/src/codex/auth-api/login-flow.ts +6 -1
  81. package/src/codex/autostart-health.ts +28 -0
  82. package/src/codex/catalog/build-entries.ts +2 -2
  83. package/src/codex/catalog/effort.ts +3 -3
  84. package/src/codex/catalog/provider-models.ts +24 -15
  85. package/src/codex/catalog/retained-sync.ts +2 -2
  86. package/src/codex/convergence.ts +2 -2
  87. package/src/codex/history-provider.ts +12 -1
  88. package/src/codex/inject/config-toml.ts +41 -6
  89. package/src/codex/inject/paginated-openai-compat.ts +90 -0
  90. package/src/codex/inject.ts +18 -15
  91. package/src/codex/injected-marker.ts +18 -0
  92. package/src/codex/main-account.ts +6 -0
  93. package/src/codex/model-cache.ts +52 -6
  94. package/src/codex/model-entitlement-admission.ts +59 -0
  95. package/src/codex/model-entitlements.ts +87 -44
  96. package/src/codex/native-main-admission.ts +83 -0
  97. package/src/codex/routing/health-store.ts +39 -0
  98. package/src/codex/routing/selection.ts +37 -1
  99. package/src/codex/routing.ts +5 -41
  100. package/src/codex/shim-templates.ts +29 -3
  101. package/src/companion/settings.ts +132 -0
  102. package/src/config/atomic-write.ts +117 -5
  103. package/src/config/load-degrade.ts +34 -7
  104. package/src/config/process-state.ts +1 -1
  105. package/src/config/schema/config-schema.ts +27 -1
  106. package/src/config/schema/leaf-validators.ts +47 -0
  107. package/src/config.ts +1 -1
  108. package/src/generated/compatibility-version.json +418 -174
  109. package/src/integrations/config-io.ts +44 -10
  110. package/src/integrations/merge.ts +120 -13
  111. package/src/integrations/mutation-plan.ts +124 -18
  112. package/src/integrations/registry.ts +38 -0
  113. package/src/integrations/state.ts +78 -45
  114. package/src/integrations/target.ts +208 -0
  115. package/src/integrations/writer.ts +49 -11
  116. package/src/lab/conformance/fixture-provider.ts +5 -0
  117. package/src/lib/browser-launch-notice.ts +59 -0
  118. package/src/lib/bun-runtime.ts +6 -2
  119. package/src/lib/debug.ts +40 -0
  120. package/src/lib/open-url.ts +51 -7
  121. package/src/lib/package-tree-integrity.ts +2 -1
  122. package/src/lib/package-version.ts +8 -0
  123. package/src/lib/provider-egress.ts +310 -0
  124. package/src/lib/provider-outbound.ts +59 -14
  125. package/src/lib/proxy-env.ts +82 -7
  126. package/src/lib/request-execution-budget.ts +72 -0
  127. package/src/lib/request-failure-attribution.ts +183 -0
  128. package/src/lib/request-failure-model.ts +236 -0
  129. package/src/lib/request-resend-gate.ts +138 -0
  130. package/src/lib/standalone.ts +16 -0
  131. package/src/lib/upstream-retry.ts +167 -16
  132. package/src/lib/winsw.ts +2 -2
  133. package/src/oauth/index.ts +24 -1
  134. package/src/oauth/login-cli.ts +80 -29
  135. package/src/providers/api-key-resolve.ts +133 -0
  136. package/src/providers/api-key-selection.ts +5 -1
  137. package/src/providers/key-failover.ts +31 -1
  138. package/src/providers/key-store.ts +34 -110
  139. package/src/providers/model-rename-fields.ts +147 -0
  140. package/src/providers/model-rename-migration.ts +124 -37
  141. package/src/providers/quota/vendor-probes-key.ts +37 -22
  142. package/src/providers/reasoning-metadata.ts +43 -18
  143. package/src/providers/registry/entries-core.ts +9 -4
  144. package/src/providers/registry/entries-extended.ts +29 -4
  145. package/src/providers/registry/model-seeds.ts +47 -10
  146. package/src/providers/xai-transport.ts +12 -1
  147. package/src/reasoning-effort.ts +8 -0
  148. package/src/responses/function-call-compat.ts +38 -1
  149. package/src/responses/inline-document.ts +65 -0
  150. package/src/responses/input-media.ts +42 -8
  151. package/src/responses/muse-tool-name-alias.ts +19 -0
  152. package/src/responses/parser-content.ts +8 -2
  153. package/src/responses/parser-tools.ts +3 -0
  154. package/src/responses/parser.ts +3 -1
  155. package/src/responses/schema.ts +3 -0
  156. package/src/router.ts +17 -2
  157. package/src/server/admission-model-scope.ts +219 -0
  158. package/src/server/audio-live.ts +9 -3
  159. package/src/server/audio-upstream.ts +18 -0
  160. package/src/server/auth-cors.ts +26 -0
  161. package/src/server/chat-completions.ts +55 -2
  162. package/src/server/chat-native.ts +19 -4
  163. package/src/server/claude-messages.ts +55 -17
  164. package/src/server/grok-responses-snapshot-repair.ts +113 -11
  165. package/src/server/gui-freshness.ts +103 -0
  166. package/src/server/gui-static.ts +7 -9
  167. package/src/server/images.ts +59 -6
  168. package/src/server/index/claude-intercept-lifecycle.ts +49 -0
  169. package/src/server/index/serve-options.ts +56 -10
  170. package/src/server/index/spend-ledger-lifecycle.ts +34 -8
  171. package/src/server/index/startup-warnings.ts +24 -0
  172. package/src/server/index.ts +21 -28
  173. package/src/server/lifecycle.ts +4 -4
  174. package/src/server/live-call-bindings.ts +6 -0
  175. package/src/server/live.ts +88 -3
  176. package/src/server/management/agent-settings-routes.ts +121 -36
  177. package/src/server/management/companion-routes.ts +77 -0
  178. package/src/server/management/logs-usage-routes.ts +19 -0
  179. package/src/server/management/native-integration-routes.ts +103 -6
  180. package/src/server/management/oauth-account-routes.ts +45 -7
  181. package/src/server/management/route-registry.ts +6 -0
  182. package/src/server/management/shared.ts +18 -1
  183. package/src/server/management/usage-timeline-routes.ts +44 -0
  184. package/src/server/management-api.ts +8 -9
  185. package/src/server/proxy-liveness.ts +75 -0
  186. package/src/server/relay.ts +19 -2
  187. package/src/server/request-log-failure-attribution.ts +99 -0
  188. package/src/server/request-log.ts +114 -0
  189. package/src/server/request-metrics.ts +92 -30
  190. package/src/server/responses/codex-ws-wire.ts +34 -8
  191. package/src/server/responses/combo-stream-preflight.ts +168 -6
  192. package/src/server/responses/compact.ts +11 -0
  193. package/src/server/responses/core-opaque-recovery.ts +90 -0
  194. package/src/server/responses/fetch-helpers.ts +124 -8
  195. package/src/server/responses/input-admission.ts +10 -0
  196. package/src/server/responses/passthrough-delivery.ts +14 -1
  197. package/src/server/responses/passthrough-dispatch.ts +179 -35
  198. package/src/server/responses/passthrough-error.ts +27 -8
  199. package/src/server/responses/request-prepare.ts +42 -1
  200. package/src/server/responses/request-send-budget.ts +12 -0
  201. package/src/server/responses/request-transport.ts +24 -4
  202. package/src/server/responses/reset-replay.ts +108 -0
  203. package/src/server/responses-request-tool-scope.ts +214 -0
  204. package/src/server/responses-undeclared-tool-guard.ts +4 -1
  205. package/src/server/search.ts +25 -1
  206. package/src/server/usage-ledger-retention.ts +73 -0
  207. package/src/service/cli.ts +48 -2
  208. package/src/service/health.ts +3 -2
  209. package/src/service/install-state-contract.d.mts +27 -0
  210. package/src/service/install-state-contract.mjs +34 -0
  211. package/src/service/launchd.ts +1 -1
  212. package/src/service/orchestration.ts +2 -4
  213. package/src/service/ownership-compatibility.ts +164 -0
  214. package/src/service/ownership-mutation-lease.d.mts +32 -0
  215. package/src/service/ownership-mutation-lease.mjs +211 -0
  216. package/src/service/repair.ts +45 -1
  217. package/src/service/state-lock.ts +269 -0
  218. package/src/service/state-record.d.mts +36 -0
  219. package/src/service/state-record.mjs +138 -0
  220. package/src/service/state.ts +582 -68
  221. package/src/service/windows-taskxml.ts +11 -10
  222. package/src/service.ts +7 -3
  223. package/src/tray/windows-tray.ps1 +1 -1
  224. package/src/types/config.ts +37 -0
  225. package/src/types/provider.ts +73 -0
  226. package/src/types/request.ts +28 -2
  227. package/src/types/tools.ts +19 -0
  228. package/src/types.ts +3 -0
  229. package/src/update/index.ts +207 -63
  230. package/src/update/job.ts +9 -5
  231. package/src/update/ownership-transaction.ts +47 -0
  232. package/src/update/restart-ownership.ts +54 -0
  233. package/src/update/runtime-ownership.d.mts +40 -0
  234. package/src/update/runtime-ownership.mjs +122 -0
  235. package/src/usage/attempt-delivery.ts +198 -0
  236. package/src/usage/cache-diagnostic.ts +305 -0
  237. package/src/usage/failure-fingerprint.ts +118 -0
  238. package/src/usage/failure-projection-cache.ts +174 -0
  239. package/src/usage/failure-projection.ts +174 -0
  240. package/src/usage/ledger-retention.ts +165 -0
  241. package/src/usage/log.ts +126 -79
  242. package/src/usage/request-outcome.ts +150 -0
  243. package/src/usage/retention-contract.ts +28 -0
  244. package/src/usage/summary.ts +2 -2
  245. package/src/usage/telemetry-contract.ts +237 -0
  246. package/src/usage/timeline.ts +236 -0
  247. package/src/web-search/alpha-search.ts +21 -1
  248. package/gui/dist/assets/index-BTuCbqQd.css +0 -1
  249. package/gui/dist/assets/index-DoBVdPHP.js +0 -134
package/bin/ocx.mjs CHANGED
@@ -12,6 +12,21 @@ import { spawn, spawnSync } from "node:child_process";
12
12
  import { STOP_HISTORY_INCOMPLETE_EXIT_CODE } from "../src/update/stop-contract.mjs";
13
13
  import { probeProxyLiveness } from "../src/update/proxy-liveness-probe.mjs";
14
14
  import { decidePostStopUpdate } from "../src/update/stop-decision.mjs";
15
+ import {
16
+ inspectPackageRuntimeLiveness,
17
+ planStoppedRuntimeRecovery,
18
+ planUpdateRuntimeHandling,
19
+ } from "../src/update/runtime-ownership.mjs";
20
+ import {
21
+ inspectInstallStateBytes,
22
+ selectAuthoritativeServiceState,
23
+ serviceStateFilesFor,
24
+ } from "../src/service/install-state-contract.mjs";
25
+ import {
26
+ acquireOwnershipMutationLease,
27
+ ownershipMutationLeaseChildEnvironment,
28
+ unprivilegedOwnershipMutationEnvironment,
29
+ } from "../src/service/ownership-mutation-lease.mjs";
15
30
  import { randomBytes } from "node:crypto";
16
31
  import { createRequire } from "node:module";
17
32
  import { existsSync, readFileSync, readdirSync } from "node:fs";
@@ -41,6 +56,9 @@ import {
41
56
  } from "../src/update/codex-cli-update-launch-policy.mjs";
42
57
 
43
58
  const PKG = "@bitkyc08/opencodex";
59
+ const UPDATE_RECOVERY_READY_MS = 30_000;
60
+ const UPDATE_RECOVERY_POLL_MS = 100;
61
+ const UPDATE_RECOVERY_SLEEP = new Int32Array(new SharedArrayBuffer(4));
44
62
  try {
45
63
  process.cwd();
46
64
  } catch {
@@ -256,8 +274,45 @@ function runPackageManagerSelfUpdate(manager) {
256
274
 
257
275
  // Remember whether a background service manages the proxy BEFORE stopping — `ocx stop`
258
276
  // unloads it, so a successful update must refresh and restart it afterwards.
259
- const serviceStatePath = join(configDir(), "service-state.json");
260
- const serviceWasInstalled = existsSync(serviceStatePath);
277
+ const allServiceStatePaths = serviceStateFilesFor(configDir(), join(homedir(), ".opencodex"));
278
+ // The test guard's legacy path is the developer's real home. Production always reads the
279
+ // same active-home + default-home observations as the Bun resolver.
280
+ const serviceStatePaths = process.env.OCX_TEST_HOME_GUARD === "1"
281
+ ? allServiceStatePaths.slice(0, 1)
282
+ : allServiceStatePaths;
283
+ const serviceWasInstalled = serviceStatePaths.some(path => existsSync(path));
284
+ // What this update may do to the runtime. The same rule the Bun updater applies, from the
285
+ // same module: a desktop takeover vetoes both the stop and the service refresh below.
286
+ const readServiceState = () => selectAuthoritativeServiceState(
287
+ serviceStatePaths.map(path => inspectInstallStateBytes(path, at => readFileSync(at, "utf8"))),
288
+ );
289
+ const readOwnership = () => {
290
+ const selected = readServiceState();
291
+ if (selected.kind === "unknown") return { ownership: null, ownershipUnknown: true, subjectToken: "unknown" };
292
+ if (selected.kind === "none") return {
293
+ ownership: null, ownershipUnknown: false, subjectToken: JSON.stringify(["none", selected.revision]),
294
+ };
295
+ const ownership = selected.state.ownership ?? null;
296
+ return {
297
+ ownership,
298
+ ownershipUnknown: false,
299
+ subjectToken: JSON.stringify(ownership
300
+ ? ["owned", selected.revision, ownership]
301
+ : ["none", selected.revision]),
302
+ };
303
+ };
304
+ const ownershipIdentity = observation => observation.ownershipUnknown
305
+ ? null
306
+ : JSON.stringify(observation.ownership
307
+ ? ["owned", observation.ownership.owner, observation.ownership.installId, observation.ownership.consentGeneration]
308
+ : ["none"]);
309
+ const initialOwnership = readOwnership();
310
+ let runtimePlan = planUpdateRuntimeHandling({ ...initialOwnership, serviceInstalled: serviceWasInstalled });
311
+ if (runtimePlan.notice) console.log(runtimePlan.notice);
312
+ if (!runtimePlan.mayReplacePackage) {
313
+ console.error("opencodex: update stopped before tray handoff, runtime stop, or package replacement because runtime ownership is unknown.");
314
+ process.exit(1);
315
+ }
261
316
  const trayBeforeUpdate = planWindowsTrayUpdate(
262
317
  process.platform === "win32" ? trayInstallState() : { installed: false, running: false },
263
318
  );
@@ -271,10 +326,11 @@ function runPackageManagerSelfUpdate(manager) {
271
326
  }
272
327
  /** Register from scratch, preserving the recorded backend. Only for a genuinely absent service. */
273
328
  function serviceInstallArgs() {
274
- try {
275
- const state = JSON.parse(readFileSync(serviceStatePath, "utf8"));
276
- if (state.backend === "native") return [postUpdateLauncher, "service", "install", "--native"];
277
- } catch { /* missing or corrupt — fall through to default */ }
329
+ const selected = readServiceState();
330
+ if (selected.kind === "unknown") throw new Error(`service backend is unknown: ${selected.reason}`);
331
+ if (selected.kind === "state" && selected.state.backend === "native") {
332
+ return [postUpdateLauncher, "service", "install", "--native"];
333
+ }
278
334
  return [postUpdateLauncher, "service", "install"];
279
335
  }
280
336
  /**
@@ -302,39 +358,45 @@ function runPackageManagerSelfUpdate(manager) {
302
358
  }
303
359
  }
304
360
 
305
- // Capture listen target before stop clears runtime-port.json (mirrors GUI/CLI update worker).
306
- // Do not treat a live runtime port of 10100 as "missing" — track whether the read succeeded.
361
+ function readCurrentRuntimeTarget() {
362
+ let raw;
363
+ try {
364
+ raw = readFileSync(join(configDir(), "runtime-port.json"), "utf8");
365
+ } catch (error) {
366
+ return error && typeof error === "object" && "code" in error && error.code === "ENOENT"
367
+ ? { kind: "absent" }
368
+ : { kind: "unknown" };
369
+ }
370
+ try {
371
+ const rt = JSON.parse(raw);
372
+ const pid = Number(rt?.pid);
373
+ if (!Number.isFinite(rt?.port) || rt.port <= 0 || rt.port > 65535
374
+ || !Number.isSafeInteger(pid) || pid <= 0) return { kind: "unknown" };
375
+ return { kind: "target", target: {
376
+ pid,
377
+ port: Math.trunc(rt.port),
378
+ hostname: typeof rt.hostname === "string" && rt.hostname.trim() !== ""
379
+ ? rt.hostname.trim()
380
+ : null,
381
+ } };
382
+ } catch { return { kind: "unknown" }; }
383
+ }
384
+
385
+ // Capture the recovery target before stop clears runtime-port.json. Replacement safety
386
+ // re-reads this record under the mutation lease instead of trusting this snapshot.
307
387
  let bakePort = 10100;
308
388
  // The hostname travels with the port: a proxy bound to ::1 or a specific interface is
309
389
  // invisible to a probe that assumes 127.0.0.1, and "no answer" would then read as
310
390
  // "stopped" for exactly the proxy the probe exists to find.
311
391
  let bakeHostname = "127.0.0.1";
312
- let sawRuntimePort = false;
313
- let sawRuntimeHostname = false;
314
- try {
315
- const rt = JSON.parse(readFileSync(join(configDir(), "runtime-port.json"), "utf8"));
316
- if (Number.isFinite(rt?.port) && rt.port > 0 && rt.port <= 65535) {
317
- // Only trust runtime when its pid still looks alive (stale crash leftovers fall back to config).
318
- const rtPid = Number(rt?.pid);
319
- let runtimeLive = false;
320
- if (Number.isSafeInteger(rtPid) && rtPid > 0) {
321
- try {
322
- process.kill(rtPid, 0);
323
- runtimeLive = true;
324
- } catch (e) {
325
- if (e && typeof e === "object" && "code" in e && e.code === "EPERM") runtimeLive = true;
326
- }
327
- }
328
- if (runtimeLive) {
329
- bakePort = Math.trunc(rt.port);
330
- if (typeof rt?.hostname === "string" && rt.hostname.trim() !== "") {
331
- bakeHostname = rt.hostname.trim();
332
- sawRuntimeHostname = true;
333
- }
334
- sawRuntimePort = true;
335
- }
336
- }
337
- } catch { /* fall through to config */ }
392
+ const initialRuntimeObservation = readCurrentRuntimeTarget();
393
+ const initialRuntimeTarget = initialRuntimeObservation.kind === "target" ? initialRuntimeObservation.target : null;
394
+ let sawRuntimePort = initialRuntimeTarget !== null;
395
+ let sawRuntimeHostname = initialRuntimeTarget?.hostname !== null && initialRuntimeTarget?.hostname !== undefined;
396
+ if (initialRuntimeTarget) {
397
+ bakePort = initialRuntimeTarget.port;
398
+ if (initialRuntimeTarget.hostname) bakeHostname = initialRuntimeTarget.hostname;
399
+ }
338
400
  // Port and hostname resolve INDEPENDENTLY: a legacy runtime record carries a port and no
339
401
  // hostname, and skipping config in that case probed 127.0.0.1 for a proxy bound to ::1.
340
402
  if (!sawRuntimePort || bakeHostname === "127.0.0.1") {
@@ -350,6 +412,18 @@ function runPackageManagerSelfUpdate(manager) {
350
412
  }
351
413
  // Wildcard and bracketed-IPv6 normalization lives in probeProxyLiveness, so both lanes
352
414
  // get it from one place.
415
+ function currentPackageRuntimeLiveness() {
416
+ return inspectPackageRuntimeLiveness({
417
+ capturedTarget: { port: bakePort, hostname: bakeHostname },
418
+ readCurrentTarget: () => {
419
+ const current = readCurrentRuntimeTarget();
420
+ return current.kind === "target"
421
+ ? { kind: "target", target: { port: current.target.port, hostname: current.target.hostname ?? bakeHostname } }
422
+ : current;
423
+ },
424
+ probe: target => probeProxyLiveness(target.port, target.hostname),
425
+ }).overall;
426
+ }
353
427
 
354
428
  const launcher = fileURLToPath(import.meta.url);
355
429
  // The pnpm owner preflight has verified this package tree and global group. Keep that exact
@@ -359,13 +433,17 @@ function runPackageManagerSelfUpdate(manager) {
359
433
  ? join(owner.packagePath, "bin", "ocx.mjs")
360
434
  : launcher;
361
435
  let postUpdateLauncherUsable = true;
436
+ let delegatedOwnershipMutationToken = null;
437
+ const mutationChildEnvironment = () => delegatedOwnershipMutationToken
438
+ ? ownershipMutationLeaseChildEnvironment(process.env, delegatedOwnershipMutationToken)
439
+ : unprivilegedOwnershipMutationEnvironment(process.env);
362
440
 
363
441
  function startProxyDirectly() {
364
442
  if (!postUpdateLauncherUsable || !existsSync(postUpdateLauncher)) {
365
443
  console.error("opencodex: cannot restart the proxy because the launcher is missing; reinstall opencodex manually.");
366
- return;
444
+ return false;
367
445
  }
368
- const env = { ...process.env };
446
+ const env = mutationChildEnvironment();
369
447
  delete env.OCX_SERVICE;
370
448
  console.log(`Attempting to restart the proxy on port ${bakePort}.`);
371
449
  const child = spawn(process.execPath, [postUpdateLauncher, "start", "--port", String(bakePort)], {
@@ -378,13 +456,24 @@ function runPackageManagerSelfUpdate(manager) {
378
456
  console.error(`opencodex: direct proxy restart failed: ${error.message}`);
379
457
  });
380
458
  child.unref();
459
+ const deadline = Date.now() + UPDATE_RECOVERY_READY_MS;
460
+ while (Date.now() < deadline) {
461
+ const current = readCurrentRuntimeTarget();
462
+ if (current.kind === "target"
463
+ && probeProxyLiveness(current.target.port, current.target.hostname ?? bakeHostname) === "live") return true;
464
+ Atomics.wait(UPDATE_RECOVERY_SLEEP, 0, 0, UPDATE_RECOVERY_POLL_MS);
465
+ }
466
+ console.error("opencodex: the recovery proxy did not publish a healthy runtime before the recovery deadline.");
467
+ return false;
381
468
  }
382
469
 
383
470
  function refreshBackgroundServiceOrStartDirect() {
384
471
  const prevBake = process.env.OCX_BAKE_PORT;
385
472
  process.env.OCX_BAKE_PORT = String(bakePort);
386
473
  try {
387
- let svc = spawnSync(process.execPath, serviceRefreshArgs(), { stdio: "inherit", windowsHide: true });
474
+ let svc = spawnSync(process.execPath, serviceRefreshArgs(), {
475
+ stdio: "inherit", windowsHide: true, env: mutationChildEnvironment(),
476
+ });
388
477
  // `serviceWasInstalled` is inferred from service-state.json alone, which can be
389
478
  // STALE — present while the registration is gone. Repair refuses that case by
390
479
  // design, and its thrown Error is indistinguishable from any other failure at
@@ -395,7 +484,9 @@ function runPackageManagerSelfUpdate(manager) {
395
484
  // could re-register a service the user just uninstalled.
396
485
  if (svc.status !== 0 && readServiceInstalledFromStatus(postUpdateLauncher) === false) {
397
486
  console.log("No registered service found — installing it instead.");
398
- svc = spawnSync(process.execPath, serviceInstallArgs(), { stdio: "inherit", windowsHide: true });
487
+ svc = spawnSync(process.execPath, serviceInstallArgs(), {
488
+ stdio: "inherit", windowsHide: true, env: mutationChildEnvironment(),
489
+ });
399
490
  }
400
491
  let needDirectStart = svc.status !== 0;
401
492
  if (!needDirectStart) {
@@ -421,6 +512,14 @@ function runPackageManagerSelfUpdate(manager) {
421
512
  }
422
513
  }
423
514
  if (needDirectStart) {
515
+ // Re-read rather than reuse the plan from before the package install: the app can
516
+ // claim the runtime during an update that takes minutes, and the refusal that repair
517
+ // just returned is indistinguishable from any other failure at this layer.
518
+ const nowOwned = planUpdateRuntimeHandling({ ...readOwnership(), serviceInstalled: true });
519
+ if (!nowOwned.mayStopRuntime) {
520
+ console.warn(nowOwned.notice ?? "opencodex: the background runtime is owned elsewhere; not starting a second proxy.");
521
+ return;
522
+ }
424
523
  // Repair normally avoids elevation for a healthy registration, but a stale Windows
425
524
  // scheduler definition can require it. It can also fail — or exit 0 while leaving
426
525
  // a non-viable manager. Fall back to a direct detached proxy start so the
@@ -439,189 +538,264 @@ function runPackageManagerSelfUpdate(manager) {
439
538
  }
440
539
  }
441
540
 
442
- // Never replace package files under a live proxy — stop it first (full `ocx stop`
443
- // semantics: graceful drain, service stop, native Codex restore). Gate on the service
444
- // and the runtime-port record too: a service-managed or orphaned proxy can be live
445
- // while ocx.pid is stale/missing.
446
- if (trayBeforeUpdate.stopBeforeReplacement) {
447
- console.log("⏹ Handing off the Windows tray before updating...");
448
- try {
449
- handoffWindowsTrayForUpdate(trayBeforeUpdate, {
450
- stop: () => {
451
- const stopped = runTrayLifecycle(launcher, "stop");
452
- return { exitStatus: stopped.status, running: trayInstallState().running };
453
- },
454
- start: () => runTrayLifecycle(launcher, "start"),
455
- });
456
- } catch {
457
- console.error("opencodex: could not stop the Windows tray; aborting before package replacement.");
541
+ const updateLease = acquireOwnershipMutationLease(serviceStatePaths);
542
+ delegatedOwnershipMutationToken = updateLease.token;
543
+ let updateLeaseReleased = false;
544
+ const releaseUpdateLease = () => {
545
+ if (updateLeaseReleased) return;
546
+ updateLeaseReleased = true;
547
+ delegatedOwnershipMutationToken = null;
548
+ updateLease.release();
549
+ };
550
+
551
+ let res;
552
+ try {
553
+ // Stop authority is decided under the same lease the child joins. A takeover between the
554
+ // earlier preflight and this boundary therefore blocks stop before it is sent.
555
+ const lockedOwnership = readOwnership();
556
+ const lockedPlan = planUpdateRuntimeHandling({ ...lockedOwnership, serviceInstalled: serviceWasInstalled });
557
+ if (lockedOwnership.subjectToken !== initialOwnership.subjectToken || !lockedPlan.mayReplacePackage) {
558
+ releaseUpdateLease();
559
+ console.error(lockedPlan.notice
560
+ ?? "opencodex: update stopped because runtime ownership changed before stop authorization; rerun from the beginning.");
458
561
  process.exit(1);
459
562
  }
460
- }
461
- const hasRuntimeState =
462
- existsSync(join(configDir(), "ocx.pid")) || existsSync(join(configDir(), "runtime-port.json"));
563
+ runtimePlan = lockedPlan;
564
+ const stoppedOwnershipIdentity = ownershipIdentity(lockedOwnership);
463
565
 
464
- function recoverStoppedRuntimeAfterFailure() {
465
- if (!postUpdateLauncherUsable) {
466
- console.error("opencodex: no verified active launcher remains for automatic recovery; reinstall opencodex manually.");
467
- return;
566
+ // Never replace package files under a live proxy — stop it first (full `ocx stop`
567
+ // semantics: graceful drain, service stop, native Codex restore). Gate on the service
568
+ // and the runtime-port record too: a service-managed or orphaned proxy can be live
569
+ // while ocx.pid is stale/missing.
570
+ if (trayBeforeUpdate.stopBeforeReplacement) {
571
+ console.log("⏹ Handing off the Windows tray before updating...");
572
+ try {
573
+ handoffWindowsTrayForUpdate(trayBeforeUpdate, {
574
+ stop: () => {
575
+ const stopped = runTrayLifecycle(launcher, "stop");
576
+ return { exitStatus: stopped.status, running: trayInstallState().running };
577
+ },
578
+ start: () => runTrayLifecycle(launcher, "start"),
579
+ });
580
+ } catch {
581
+ releaseUpdateLease();
582
+ console.error("opencodex: could not stop the Windows tray; aborting before package replacement.");
583
+ process.exit(1);
584
+ }
468
585
  }
469
- if (serviceWasInstalled) {
470
- console.warn("opencodex: update failed after stopping the proxy — restoring the previous background service.");
471
- refreshBackgroundServiceOrStartDirect();
472
- } else if (hasRuntimeState) {
473
- console.warn("opencodex: update failed after stopping the proxy — restarting the previous version directly.");
474
- startProxyDirectly();
586
+ const hasRuntimeState =
587
+ existsSync(join(configDir(), "ocx.pid")) || existsSync(join(configDir(), "runtime-port.json"));
588
+ let stopAttempted = false;
589
+
590
+ function recoverStoppedRuntimeAfterFailure(reason) {
591
+ const recoveryOwnership = readOwnership();
592
+ const recoveryLiveness = currentPackageRuntimeLiveness();
593
+ const recovery = planStoppedRuntimeRecovery({
594
+ stopAttempted,
595
+ ...recoveryOwnership,
596
+ sameOwner: ownershipIdentity(recoveryOwnership) === stoppedOwnershipIdentity,
597
+ liveness: recoveryLiveness,
598
+ serviceInstalled: serviceWasInstalled,
599
+ launcherUsable: postUpdateLauncherUsable,
600
+ hadRuntimeState: hasRuntimeState,
601
+ });
602
+ if (recovery.reason === "ownership-unknown") {
603
+ console.error(`opencodex: ${reason}; runtime ownership is unknown, so automatic recovery was refused. Run 'ocx status --json' and repair the service-state record before retrying.`);
604
+ } else if (recovery.reason === "ownership-transferred") {
605
+ console.log("opencodex: runtime ownership moved to another installation; the stopped CLI runtime was not revived.");
606
+ } else if (recovery.reason.startsWith("runtime-")) {
607
+ console.error(`opencodex: ${reason}; package runtime liveness is ${recoveryLiveness}, so automatic recovery was refused.`);
608
+ } else if (recovery.reason === "launcher-unavailable") {
609
+ console.error("opencodex: no verified active launcher remains for automatic recovery; reinstall opencodex manually.");
610
+ } else if (recovery.action === "service") {
611
+ console.warn(`opencodex: ${reason} after stopping the proxy — restoring the previous background service.`);
612
+ refreshBackgroundServiceOrStartDirect();
613
+ } else if (recovery.action === "direct") {
614
+ console.warn(`opencodex: ${reason} after stopping the proxy — restarting the previous version directly.`);
615
+ startProxyDirectly();
616
+ }
617
+ return recovery;
475
618
  }
476
- }
477
619
 
478
- // An outstanding pending-teardown receipt is a fourth reason to run the stop. After a
479
- // parent crashed mid-deferral the service, pid and runtime records can all be absent
480
- // while the shared client config still points at a proxy that is gone; installing over
481
- // that silently skips the recovery the receipt was written to trigger (#3008). Presence
482
- // is the whole test here — the launcher cannot parse it, and `ocx stop` is what decides
483
- // whether the obligation is safe to finish.
484
- const hasPendingTeardown = hasPendingTeardownIn(readdirSync, configDir());
485
- if (serviceWasInstalled || hasRuntimeState || hasPendingTeardown) {
486
- console.log("⏹ Stopping the running proxy before updating...");
487
- const stopRes = spawnSync(process.execPath, [launcher, "stop"], { stdio: "inherit", windowsHide: true });
488
- const stillHasRuntimeState =
489
- existsSync(join(configDir(), "ocx.pid")) || existsSync(join(configDir(), "runtime-port.json"));
490
- // A history-only failure means teardown succeeded and a backup manifest is waiting for
491
- // review: the proxy is down and replacing package files is safe. Every other nonzero
492
- // status is a stop that did not finish, and a signal kill (status null) says nothing
493
- // about whether it did - both abort, because replacing files under a live server
494
- // leaves it running mixed old and new modules (#3008).
495
- // The same decision the Bun updater makes, from the same module (#3008). Absent PID and
496
- // runtime files are weak evidence, so the captured endpoint is asked; "unknown" aborts
497
- // because a silent listener is exactly the state where replacing files is dangerous.
498
- const decision = decidePostStopUpdate({
499
- status: stopRes.status,
500
- hasRuntimeState: stillHasRuntimeState,
501
- // Re-checked AFTER the stop: a quarantined receipt lets the stop itself succeed
502
- // (there is nothing left to stop), so a pre-stop check alone let the retry install
503
- // over a teardown that never ran.
504
- teardownOutstanding: hasPendingTeardownIn(readdirSync, configDir()),
505
- liveness: probeProxyLiveness(bakePort, bakeHostname),
506
- });
507
- const historyOnlyStop = decision.reason === "history-only";
508
- if (!decision.proceed) {
509
- if (trayBeforeUpdate.restoreOnFailure) runTrayLifecycle(launcher, "start");
510
- if (decision.reason === "teardown-outstanding") {
511
- console.error("opencodex: a shared teardown from an earlier stop is still outstanding and needs manual review; aborting the update.");
512
- console.error("opencodex: confirm no proxy is running, run 'ocx restore', then remove the pending-teardown file in the opencodex home.");
513
- } else console.error(decision.reason === "proxy-unknown"
514
- ? `opencodex: could not confirm the proxy on ${bakeHostname}:${bakePort} is stopped; aborting the update. Run 'ocx stop' and retry.`
515
- : "opencodex: could not stop the running proxy; aborting the update. Run 'ocx stop' and retry.");
620
+ // An outstanding pending-teardown receipt is a fourth reason to run the stop. After a
621
+ // parent crashed mid-deferral the service, pid and runtime records can all be absent
622
+ // while the shared client config still points at a proxy that is gone; installing over
623
+ // that silently skips the recovery the receipt was written to trigger (#3008). Presence
624
+ // is the whole test here — the launcher cannot parse it, and `ocx stop` is what decides
625
+ // whether the obligation is safe to finish.
626
+ const hasPendingTeardown = hasPendingTeardownIn(readdirSync, configDir());
627
+ const stopNeeded = serviceWasInstalled || hasRuntimeState || hasPendingTeardown;
628
+ if (stopNeeded && !runtimePlan.mayStopRuntime) {
629
+ releaseUpdateLease();
630
+ console.error(runtimePlan.notice
631
+ ?? "opencodex: update stopped because this installation may not stop the current runtime.");
516
632
  process.exit(1);
517
633
  }
518
- if (historyOnlyStop || historyRestoreIncomplete()) {
519
- console.warn(
520
- "opencodex: WARNING — Codex resume-history metadata restore is incomplete (a backup manifest remains).\n" +
521
- " The DB may be busy or the manifest/target may need review; untracked routed history is intentionally unchanged.\n" +
522
- " After the update: close the Codex app, run 'ocx doctor', then run 'ocx stop' once to retry.",
523
- );
634
+ if (stopNeeded) {
635
+ stopAttempted = true;
636
+ console.log("⏹ Stopping the running proxy before updating...");
637
+ const stopRes = spawnSync(process.execPath, [launcher, "stop"], {
638
+ stdio: "inherit", windowsHide: true, env: mutationChildEnvironment(),
639
+ });
640
+ const stillHasRuntimeState =
641
+ existsSync(join(configDir(), "ocx.pid")) || existsSync(join(configDir(), "runtime-port.json"));
642
+ // A history-only failure means teardown succeeded and a backup manifest is waiting for
643
+ // review: the proxy is down and replacing package files is safe. Every other nonzero
644
+ // status is a stop that did not finish, and a signal kill (status null) says nothing
645
+ // about whether it did - both abort, because replacing files under a live server
646
+ // leaves it running mixed old and new modules (#3008).
647
+ // The same decision the Bun updater makes, from the same module (#3008). Absent PID and
648
+ // runtime files are weak evidence, so the captured endpoint is asked; "unknown" aborts
649
+ // because a silent listener is exactly the state where replacing files is dangerous.
650
+ const decision = decidePostStopUpdate({
651
+ status: stopRes.status,
652
+ hasRuntimeState: stillHasRuntimeState,
653
+ // Re-checked AFTER the stop: a quarantined receipt lets the stop itself succeed
654
+ // (there is nothing left to stop), so a pre-stop check alone let the retry install
655
+ // over a teardown that never ran.
656
+ teardownOutstanding: hasPendingTeardownIn(readdirSync, configDir()),
657
+ liveness: probeProxyLiveness(bakePort, bakeHostname),
658
+ });
659
+ const historyOnlyStop = decision.reason === "history-only";
660
+ if (!decision.proceed) {
661
+ if (trayBeforeUpdate.restoreOnFailure) runTrayLifecycle(launcher, "start");
662
+ if (decision.reason === "teardown-outstanding") {
663
+ console.error("opencodex: a shared teardown from an earlier stop is still outstanding and needs manual review; aborting the update.");
664
+ console.error("opencodex: confirm no proxy is running, run 'ocx restore', then remove the pending-teardown file in the opencodex home.");
665
+ } else console.error(decision.reason === "proxy-unknown"
666
+ ? `opencodex: could not confirm the proxy on ${bakeHostname}:${bakePort} is stopped; aborting the update. Run 'ocx stop' and retry.`
667
+ : "opencodex: could not stop the running proxy; aborting the update. Run 'ocx stop' and retry.");
668
+ releaseUpdateLease();
669
+ process.exit(1);
670
+ }
671
+ if (historyOnlyStop || historyRestoreIncomplete()) {
672
+ console.warn(
673
+ "opencodex: WARNING — Codex resume-history metadata restore is incomplete (a backup manifest remains).\n" +
674
+ " The DB may be busy or the manifest/target may need review; untracked routed history is intentionally unchanged.\n" +
675
+ " After the update: close the Codex app, run 'ocx doctor', then run 'ocx stop' once to retry.",
676
+ );
677
+ }
678
+ if (decision.reason === "history-deferred") {
679
+ // The reported #4718 path is this lane. Nothing was restored, so this is a different
680
+ // sentence from the manifest warning above: an operator told "history metadata is
681
+ // incomplete" would assume config and catalog already came back.
682
+ console.warn(
683
+ "opencodex: WARNING — the shared teardown was refused by the Codex history preflight and restored nothing.\n" +
684
+ " Config, catalog, history and provenance were preserved, and the teardown receipt was kept.\n" +
685
+ " The proxy is down, so the update continues; close the Codex app and run 'ocx stop' once afterwards to finish the restore.",
686
+ );
687
+ }
524
688
  }
525
- if (decision.reason === "history-deferred") {
526
- // The reported #4718 path is this lane. Nothing was restored, so this is a different
527
- // sentence from the manifest warning above: an operator told "history metadata is
528
- // incomplete" would assume config and catalog already came back.
529
- console.warn(
530
- "opencodex: WARNING — the shared teardown was refused by the Codex history preflight and restored nothing.\n" +
531
- " Config, catalog, history and provenance were preserved, and the teardown receipt was kept.\n" +
532
- " The proxy is down, so the update continues; close the Codex app and run 'ocx stop' once afterwards to finish the restore.",
533
- );
689
+
690
+ const replacementOwnership = readOwnership();
691
+ const replacementPlan = planUpdateRuntimeHandling({ ...replacementOwnership, serviceInstalled: serviceWasInstalled });
692
+ const replacementLiveness = currentPackageRuntimeLiveness();
693
+ if (replacementOwnership.subjectToken !== initialOwnership.subjectToken
694
+ || !replacementPlan.mayReplacePackage
695
+ || replacementLiveness !== "dead") {
696
+ recoverStoppedRuntimeAfterFailure("replacement was refused");
697
+ releaseUpdateLease();
698
+ if (trayBeforeUpdate.restoreOnFailure) runTrayLifecycle(launcher, "start");
699
+ console.error(replacementPlan.notice
700
+ ?? "opencodex: update stopped because runtime ownership or liveness changed after the stop decision; rerun from the beginning.");
701
+ process.exit(1);
534
702
  }
535
- }
536
703
 
537
- // npm keeps the existing stage -> verify -> swap -> rollback flow. pnpm owns a
538
- // content-addressable store and generated global shims, so its path uses pnpm's own
539
- // global update operation and verifies the active group instead of renaming files.
540
- console.log(`Updating${latest ? ` to v${latest}` : ""} (${manager === "npm" ? "transactional" : "pnpm-managed"})...`);
541
- let res;
542
- try {
543
- if (manager === "npm") {
544
- const packageDir = resolve(here, "..");
545
- const tx = transactionalNpmUpdate({
546
- packageDir,
547
- pkgName: PKG,
548
- targetVersion: latest || undefined,
549
- tag,
550
- runNpm: (args) => {
551
- const invocation = npmInvocation(args);
552
- if (!invocation) return { status: 1 };
553
- return spawnSync(invocation.file, invocation.args, {
554
- stdio: "inherit",
555
- timeout: 180000,
556
- windowsHide: true,
557
- ...invocation.options,
558
- });
559
- },
560
- log: (line) => console.log(line),
561
- });
562
- postUpdateLauncherUsable = tx.ok
563
- || tx.rolledBack === true
564
- || ["stage", "verify", "swap-backup"].includes(tx.phase);
565
- if (tx.ok) {
566
- res = { status: 0 };
567
- } else if (tx.phase === "stage" || tx.phase === "verify") {
568
- // Live tree untouched: report and stop. Nothing to roll back.
569
- console.error(`opencodex: update aborted before touching the live install (${tx.phase}): ${tx.error}`);
570
- res = { status: 1 };
571
- } else {
572
- console.error(`opencodex: update failed (${tx.phase}): ${tx.error}${tx.rolledBack ? " — previous version restored." : ""}`);
573
- res = { status: 1 };
574
- }
575
- } else {
576
- const update = runPnpmGlobalUpdate({
577
- packageName: PKG,
578
- currentVersion: current,
579
- targetVersion: latest || undefined,
580
- tag,
581
- owner,
582
- runningPackagePath: resolve(here, ".."),
583
- runPnpm: (args, capture = false) => {
584
- const invocation = pnpmOwnerInvocation(owner, args);
585
- if (!invocation) return { status: 1 };
586
- return spawnSync(invocation.file, invocation.args, {
587
- stdio: capture ? "pipe" : "inherit",
588
- encoding: "utf8",
589
- timeout: 180000,
590
- windowsHide: true,
591
- env: invocation.env,
592
- ...invocation.options,
593
- });
594
- },
595
- log: line => console.log(line),
596
- });
597
- if (update.ok) {
598
- // pnpm switches the active global group and updates its shim. Continue recovery
599
- // through that fresh package tree, not the old group whose launcher is still
600
- // executing this update.
601
- postUpdateLauncher = join(update.path, "bin", "ocx.mjs");
602
- res = { status: 0 };
704
+ // npm keeps the existing stage -> verify -> swap -> rollback flow. pnpm owns a
705
+ // content-addressable store and generated global shims, so its path uses pnpm's own
706
+ // global update operation and verifies the active group instead of renaming files.
707
+ console.log(`Updating${latest ? ` to v${latest}` : ""} (${manager === "npm" ? "transactional" : "pnpm-managed"})...`);
708
+ try {
709
+ if (manager === "npm") {
710
+ const packageDir = resolve(here, "..");
711
+ const tx = transactionalNpmUpdate({
712
+ packageDir,
713
+ pkgName: PKG,
714
+ targetVersion: latest || undefined,
715
+ tag,
716
+ runNpm: (args) => {
717
+ const invocation = npmInvocation(args);
718
+ if (!invocation) return { status: 1 };
719
+ return spawnSync(invocation.file, invocation.args, {
720
+ ...invocation.options,
721
+ stdio: "inherit",
722
+ timeout: 180000,
723
+ windowsHide: true,
724
+ env: unprivilegedOwnershipMutationEnvironment(invocation.options?.env ?? process.env),
725
+ });
726
+ },
727
+ log: (line) => console.log(line),
728
+ });
729
+ postUpdateLauncherUsable = tx.ok
730
+ || tx.rolledBack === true
731
+ || ["stage", "verify", "swap-backup"].includes(tx.phase);
732
+ if (tx.ok) {
733
+ res = { status: 0 };
734
+ } else if (tx.phase === "stage" || tx.phase === "verify") {
735
+ // Live tree untouched: report and stop. Nothing to roll back.
736
+ console.error(`opencodex: update aborted before touching the live install (${tx.phase}): ${tx.error}`);
737
+ res = { status: 1 };
738
+ } else {
739
+ console.error(`opencodex: update failed (${tx.phase}): ${tx.error}${tx.rolledBack ? " — previous version restored." : ""}`);
740
+ res = { status: 1 };
741
+ }
603
742
  } else {
604
- console.error(`opencodex: ${update.error}${update.rolledBack ? "." : " Manual recovery may be required."}`);
605
- postUpdateLauncherUsable = Boolean(update.activePath);
606
- if (update.activePath) postUpdateLauncher = join(update.activePath, "bin", "ocx.mjs");
607
- res = { status: 1 };
743
+ const update = runPnpmGlobalUpdate({
744
+ packageName: PKG,
745
+ currentVersion: current,
746
+ targetVersion: latest || undefined,
747
+ tag,
748
+ owner,
749
+ runningPackagePath: resolve(here, ".."),
750
+ runPnpm: (args, capture = false) => {
751
+ const invocation = pnpmOwnerInvocation(owner, args);
752
+ if (!invocation) return { status: 1 };
753
+ return spawnSync(invocation.file, invocation.args, {
754
+ ...invocation.options,
755
+ stdio: capture ? "pipe" : "inherit",
756
+ encoding: "utf8",
757
+ timeout: 180000,
758
+ windowsHide: true,
759
+ env: unprivilegedOwnershipMutationEnvironment(invocation.env ?? process.env),
760
+ });
761
+ },
762
+ log: line => console.log(line),
763
+ });
764
+ if (update.ok) {
765
+ // pnpm switches the active global group and updates its shim. Continue recovery
766
+ // through that fresh package tree, not the old group whose launcher is still
767
+ // executing this update.
768
+ postUpdateLauncher = join(update.path, "bin", "ocx.mjs");
769
+ res = { status: 0 };
770
+ } else {
771
+ console.error(`opencodex: ${update.error}${update.rolledBack ? "." : " Manual recovery may be required."}`);
772
+ postUpdateLauncherUsable = Boolean(update.activePath);
773
+ if (update.activePath) postUpdateLauncher = join(update.activePath, "bin", "ocx.mjs");
774
+ res = { status: 1 };
775
+ }
608
776
  }
777
+ } catch (error) {
778
+ // An unexpected throw means we cannot prove the live tree is untouched, so the
779
+ // legacy in-place install (which deletes live first) is exactly the wrong rescue —
780
+ // it recreates the #1849 destruction path. Report and stop; the boot probe and the
781
+ // recovery marker cover the swap-window states.
782
+ const manual = manager === "pnpm"
783
+ ? `pnpm add -g --allow-build=bun ${PKG}@${tag}`
784
+ : `npm install -g --allow-scripts=bun ${PKG}@${tag}`;
785
+ // An unexpected exception leaves the active package path unproven for either manager.
786
+ // Do not run service/tray/proxy recovery through a possibly half-swapped tree.
787
+ postUpdateLauncherUsable = false;
788
+ console.error(`opencodex: ${manager} update failed unexpectedly (${error?.message ?? error}). ` +
789
+ `The live install was not knowingly modified; run 'ocx update' again or reinstall with ${manual}.`);
790
+ res = { status: 1 };
609
791
  }
610
- } catch (error) {
611
- // An unexpected throw means we cannot prove the live tree is untouched, so the
612
- // legacy in-place install (which deletes live first) is exactly the wrong rescue —
613
- // it recreates the #1849 destruction path. Report and stop; the boot probe and the
614
- // recovery marker cover the swap-window states.
615
- const manual = manager === "pnpm"
616
- ? `pnpm add -g --allow-build=bun ${PKG}@${tag}`
617
- : `npm install -g --allow-scripts=bun ${PKG}@${tag}`;
618
- // An unexpected exception leaves the active package path unproven for either manager.
619
- // Do not run service/tray/proxy recovery through a possibly half-swapped tree.
620
- postUpdateLauncherUsable = false;
621
- console.error(`opencodex: ${manager} update failed unexpectedly (${error?.message ?? error}). ` +
622
- `The live install was not knowingly modified; run 'ocx update' again or reinstall with ${manual}.`);
623
- res = { status: 1 };
792
+ if (res.status !== 0) recoverStoppedRuntimeAfterFailure("update failed");
793
+ } finally {
794
+ // Expected aborts release before process.exit(); this covers every thrown or newly-added
795
+ // path and keeps token restoration coupled to the lease itself.
796
+ releaseUpdateLease();
624
797
  }
798
+ const postInstallPlan = planUpdateRuntimeHandling({ ...readOwnership(), serviceInstalled: serviceWasInstalled });
625
799
  if (res.status === 0) {
626
800
  console.log(`\nUpdated${latest ? ` to v${latest}` : ""}.`);
627
801
  repairCodexShimIfNeeded(postUpdateLauncher);
@@ -637,16 +811,15 @@ function runPackageManagerSelfUpdate(manager) {
637
811
  }
638
812
  // The stop above unloaded any managed service; refresh via the freshly-installed
639
813
  // launcher so the new files write the baked paths and the service restarts.
640
- if (serviceWasInstalled) {
814
+ if (postInstallPlan.mayRestoreService) {
641
815
  console.log("Refreshing the background service with the updated files...");
642
816
  refreshBackgroundServiceOrStartDirect();
643
- } else {
817
+ } else if (postInstallPlan.mayStopRuntime) {
644
818
  console.log(`Restart the proxy: ${launcherStartHint(postUpdateLauncher, bakePort)}`);
645
819
  }
646
820
  process.exit(0);
647
821
  }
648
822
  if (trayBeforeUpdate.restoreOnFailure && postUpdateLauncherUsable) runTrayLifecycle(postUpdateLauncher, "start");
649
- recoverStoppedRuntimeAfterFailure();
650
823
  const manual = manager === "pnpm"
651
824
  ? `pnpm add -g --allow-build=bun ${PKG}@${tag}`
652
825
  : `npm install -g --allow-scripts=bun ${PKG}@${tag}`;