@bitkyc08/opencodex 2.63.0 → 2.64.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 (262) hide show
  1. package/AGENTS_INSTALL.md +4 -3
  2. package/README.md +46 -33
  3. package/assets/download-linux.svg +10 -0
  4. package/assets/download-macos.svg +10 -0
  5. package/assets/download-windows.svg +10 -0
  6. package/bin/ocx.mjs +12 -4
  7. package/gui/dist/assets/App-CpuDF3ci.js +50 -0
  8. package/gui/dist/assets/App-I5AnaSLh.css +1 -0
  9. package/gui/dist/assets/{Tray-CncKDBTp.js → Tray-B4uEIa1O.js} +1 -1
  10. package/gui/dist/assets/index-DiBRuK-d.css +1 -0
  11. package/gui/dist/assets/index-SggB6t3z.js +86 -0
  12. package/gui/dist/assets/usage-companion-chart-DLbKOJml.js +1 -0
  13. package/gui/dist/index.html +2 -2
  14. package/native/remote-workspace-helper/src/protocol.rs +5 -0
  15. package/package.json +4 -1
  16. package/src/adapters/anthropic.ts +66 -7
  17. package/src/adapters/codebuddy/adapter.ts +112 -16
  18. package/src/adapters/codebuddy/mcp-server.ts +180 -0
  19. package/src/adapters/codebuddy/scaffold-guard.ts +25 -8
  20. package/src/adapters/codebuddy/tool-bridge.ts +597 -0
  21. package/src/adapters/coding-agent/protocol.ts +123 -10
  22. package/src/adapters/coding-agent/turn.ts +324 -2
  23. package/src/adapters/command-code-restored-schema.ts +112 -0
  24. package/src/adapters/command-code-tool-text.ts +589 -0
  25. package/src/adapters/command-code.ts +80 -6
  26. package/src/adapters/cursor/catalog.ts +12 -0
  27. package/src/adapters/cursor/current-request.ts +46 -0
  28. package/src/adapters/cursor/discovery.ts +1 -1
  29. package/src/adapters/cursor/effort-map.ts +4 -0
  30. package/src/adapters/cursor/native-exec.ts +15 -0
  31. package/src/adapters/cursor/protobuf-events.ts +40 -12
  32. package/src/adapters/cursor/protobuf-request.ts +83 -18
  33. package/src/adapters/cursor/request-builder.ts +5 -4
  34. package/src/adapters/cursor/tool-guidance.ts +1 -1
  35. package/src/adapters/devin/live-models.ts +7 -0
  36. package/src/adapters/inline-think-tags.ts +251 -0
  37. package/src/adapters/kiro/adapter.ts +1 -0
  38. package/src/adapters/kiro/stream.ts +5 -3
  39. package/src/adapters/kiro/usage.ts +4 -3
  40. package/src/adapters/mimo-free.ts +37 -18
  41. package/src/adapters/openai-chat/messages.ts +5 -5
  42. package/src/adapters/openai-chat/tool-schema.ts +124 -2
  43. package/src/adapters/openai-chat.ts +13 -3
  44. package/src/adapters/openai-responses/passthrough.ts +15 -2
  45. package/src/adapters/openai-responses/reasoning.ts +14 -1
  46. package/src/adapters/openai-responses/request-strips.ts +29 -3
  47. package/src/adapters/openai-responses/tool-output-recovery.ts +9 -3
  48. package/src/adapters/qoder/adapter.ts +15 -12
  49. package/src/adapters/qoder/scaffold-guard.ts +45 -13
  50. package/src/bridge/internal.ts +20 -0
  51. package/src/bridge/sse.ts +14 -5
  52. package/src/claude/agents-inject.ts +2 -1
  53. package/src/claude/desktop-applied-marker.ts +44 -0
  54. package/src/claude/desktop-profile.ts +22 -12
  55. package/src/claude/inbound.ts +8 -3
  56. package/src/cli/access.ts +22 -5
  57. package/src/cli/account-api.ts +6 -0
  58. package/src/cli/account-auth.ts +5 -2
  59. package/src/cli/account.ts +5 -6
  60. package/src/cli/aside-profiles.ts +61 -1
  61. package/src/cli/claude-desktop.ts +3 -2
  62. package/src/cli/claude.ts +3 -1
  63. package/src/cli/codex-cli-update.ts +2 -2
  64. package/src/cli/codex-shim-autorestore.ts +3 -0
  65. package/src/cli/dispatch.ts +12 -4
  66. package/src/cli/index.ts +98 -10
  67. package/src/cli/registry.ts +1 -1
  68. package/src/cli/resolve.ts +113 -1
  69. package/src/cli/root.ts +5 -0
  70. package/src/cli/stop-approval.ts +186 -0
  71. package/src/cli/stop-report.ts +1 -1
  72. package/src/cli/system-restart-client.ts +19 -4
  73. package/src/client/hub-client.ts +25 -18
  74. package/src/clients/config-export.ts +12 -4
  75. package/src/codex/account-auto-switch.ts +59 -0
  76. package/src/codex/account-lifecycle.ts +7 -0
  77. package/src/codex/account-priority.ts +10 -0
  78. package/src/codex/account-usability.ts +9 -0
  79. package/src/codex/auth-api/account-list.ts +5 -0
  80. package/src/codex/auth-api/login-flow.ts +15 -5
  81. package/src/codex/auth-api/login-state.ts +5 -16
  82. package/src/codex/auth-api/pool-mode-gate.ts +30 -7
  83. package/src/codex/auth-api/routes.ts +39 -3
  84. package/src/codex/auth-context.ts +145 -17
  85. package/src/codex/catalog/build-entries.ts +2 -1
  86. package/src/codex/catalog/effort.ts +8 -2
  87. package/src/codex/catalog/metadata.ts +33 -7
  88. package/src/codex/catalog/native-models.ts +102 -5
  89. package/src/codex/catalog/parsing.ts +2 -0
  90. package/src/codex/catalog/provider-models.ts +19 -6
  91. package/src/codex/cli-installation-targets.ts +44 -40
  92. package/src/codex/codex-write-lock.ts +32 -2
  93. package/src/codex/inject/multi-agent-v2.ts +90 -0
  94. package/src/codex/inject/plan.ts +365 -0
  95. package/src/codex/inject.ts +237 -362
  96. package/src/codex/log-guard/maintenance.ts +30 -13
  97. package/src/codex/project-config-warnings.ts +47 -7
  98. package/src/codex/prompt-layers/encoding.ts +41 -0
  99. package/src/codex/prompt-layers/toml-read.ts +1 -1
  100. package/src/codex/prompt-layers.ts +17 -13
  101. package/src/codex/prompt-text-probe.ts +20 -10
  102. package/src/codex/quota-observation-freshness.ts +34 -0
  103. package/src/codex/quota-rejection.ts +8 -4
  104. package/src/codex/quota.ts +4 -2
  105. package/src/codex/routing/selection.ts +32 -9
  106. package/src/codex/routing.ts +53 -11
  107. package/src/codex/subagent-model-fallback.ts +4 -3
  108. package/src/codex/sync.ts +11 -0
  109. package/src/codex/windows-installation-files.ts +33 -11
  110. package/src/codex/write-coordination.ts +4 -0
  111. package/src/combos/request.ts +6 -2
  112. package/src/companion/settings.ts +6 -1
  113. package/src/config/atomic-write.ts +12 -1
  114. package/src/config/derived-registries.ts +29 -0
  115. package/src/config/diagnostics.ts +17 -0
  116. package/src/config/live-reconcile.ts +11 -2
  117. package/src/config/load-degrade.ts +9 -2
  118. package/src/config/persist-unlocked.ts +4 -3
  119. package/src/config/persisted-mutation.ts +94 -0
  120. package/src/config/provider-validation.ts +1 -1
  121. package/src/config/proxy-env.ts +62 -12
  122. package/src/config/rebase-provenance.ts +80 -1
  123. package/src/config/schema/config-schema.ts +5 -0
  124. package/src/config/schema/leaf-validators.ts +50 -1
  125. package/src/config/subagent-models.ts +41 -15
  126. package/src/config.ts +18 -95
  127. package/src/generated/compatibility-version.json +330 -218
  128. package/src/generated/model-metadata.ts +8 -5
  129. package/src/github/star-state.ts +46 -6
  130. package/src/grok/inject.ts +74 -40
  131. package/src/grok/status.ts +11 -4
  132. package/src/integrations/cursor-effort-table.ts +50 -13
  133. package/src/integrations/raycast-detect.ts +19 -4
  134. package/src/integrations/serialize.ts +11 -1
  135. package/src/lib/bounded-body.ts +30 -0
  136. package/src/lib/crash-guard.ts +52 -4
  137. package/src/lib/local-aside-sync-contract.ts +41 -0
  138. package/src/lib/package-tree-integrity.ts +181 -9
  139. package/src/lib/proxy-env.ts +18 -1
  140. package/src/lib/request-failure-attribution.ts +1 -0
  141. package/src/lib/request-failure-model.ts +3 -0
  142. package/src/lib/service-secrets.ts +99 -2
  143. package/src/lib/socks5-fetch.ts +43 -14
  144. package/src/lib/token-estimate.ts +17 -2
  145. package/src/oauth/command-code.ts +3 -2
  146. package/src/oauth/generic-account-failover.ts +29 -1
  147. package/src/oauth/index.ts +24 -16
  148. package/src/oauth/login-flow-state.ts +5 -5
  149. package/src/oauth/meta-muse-device.ts +49 -7
  150. package/src/oauth/store.ts +74 -9
  151. package/src/providers/alibaba-region-backup.ts +16 -1
  152. package/src/providers/anthropic-fast.ts +89 -0
  153. package/src/providers/anthropic-reset-grant-ledger.ts +288 -0
  154. package/src/providers/anthropic-reset-grants.ts +329 -0
  155. package/src/providers/claude-cli-identity.ts +12 -0
  156. package/src/providers/command-code-efforts.ts +184 -102
  157. package/src/providers/derive.ts +11 -3
  158. package/src/providers/fastwire.ts +16 -3
  159. package/src/providers/label.ts +8 -3
  160. package/src/providers/model-rename-fields.ts +1 -0
  161. package/src/providers/openai-virtual-models.ts +1 -0
  162. package/src/providers/quota/vendor-probes-oauth.ts +2 -1
  163. package/src/providers/reasoning-metadata.ts +38 -15
  164. package/src/providers/registry/entries-core.ts +95 -7
  165. package/src/providers/registry/entries-extended.ts +21 -10
  166. package/src/providers/registry/model-ids.ts +1 -0
  167. package/src/providers/registry/model-seeds.ts +31 -3
  168. package/src/providers/registry/types.ts +3 -1
  169. package/src/providers/resolved-model-policy-merge.ts +38 -8
  170. package/src/providers/resolved-model-policy.ts +4 -2
  171. package/src/providers/service-tier.ts +3 -1
  172. package/src/reasoning-effort.ts +6 -5
  173. package/src/responses/code-mode-helper-compat.ts +14 -4
  174. package/src/responses/code-mode-shell-input.ts +54 -0
  175. package/src/responses/custom-tool-compat.ts +173 -5
  176. package/src/responses/parser.ts +38 -2
  177. package/src/responses/reasoning-replay-cache.ts +26 -0
  178. package/src/router.ts +11 -1
  179. package/src/routing/history/indexer.ts +7 -2
  180. package/src/routing/history/schema.ts +3 -1
  181. package/src/server/adapter-resolve.ts +2 -2
  182. package/src/server/auth-cors.ts +3 -0
  183. package/src/server/chat-completions.ts +14 -1
  184. package/src/server/chat-native.ts +17 -0
  185. package/src/server/claude-messages.ts +4 -3
  186. package/src/server/direct-local-http.ts +45 -19
  187. package/src/server/gui-session.ts +6 -15
  188. package/src/server/index/package-tree-guard.ts +53 -0
  189. package/src/server/index/serve-options.ts +17 -3
  190. package/src/server/index/startup-warnings.ts +17 -0
  191. package/src/server/index.ts +15 -17
  192. package/src/server/lifecycle.ts +8 -0
  193. package/src/server/live.ts +54 -4
  194. package/src/server/management/agent-settings-routes.ts +44 -8
  195. package/src/server/management/anthropic-reset-grant-routes.ts +252 -0
  196. package/src/server/management/codex-prompt-routes.ts +5 -1
  197. package/src/server/management/config-routes.ts +14 -4
  198. package/src/server/management/oauth-account-routes.ts +17 -1
  199. package/src/server/management/provider-overwrite-carry.ts +164 -0
  200. package/src/server/management/provider-routes.ts +36 -6
  201. package/src/server/management/remote-workspace-routes.ts +2 -2
  202. package/src/server/management/route-registry.ts +2 -0
  203. package/src/server/management/shadow-call-validation.ts +39 -0
  204. package/src/server/management/system-restart.ts +72 -6
  205. package/src/server/management-api.ts +16 -2
  206. package/src/server/management-auth.ts +52 -8
  207. package/src/server/proxy-liveness.ts +110 -4
  208. package/src/server/request-log.ts +26 -9
  209. package/src/server/request-metrics.ts +5 -0
  210. package/src/server/responses/adapter-continuation.ts +6 -3
  211. package/src/server/responses/adapter-dispatch.ts +65 -1
  212. package/src/server/responses/compact.ts +31 -1
  213. package/src/server/responses/compaction-routing.ts +28 -3
  214. package/src/server/responses/core-codex-account.ts +85 -26
  215. package/src/server/responses/core-combo-failure.ts +16 -15
  216. package/src/server/responses/core-normalize.ts +5 -1
  217. package/src/server/responses/core-opaque-recovery.ts +50 -2
  218. package/src/server/responses/core-options.ts +2 -0
  219. package/src/server/responses/core-replay.ts +7 -0
  220. package/src/server/responses/core.ts +1 -1
  221. package/src/server/responses/encrypted-payload.ts +23 -9
  222. package/src/server/responses/passthrough-delivery.ts +55 -35
  223. package/src/server/responses/passthrough-dispatch.ts +12 -13
  224. package/src/server/responses/policy-fallback.ts +12 -1
  225. package/src/server/responses/request-prepare.ts +46 -7
  226. package/src/server/responses/request-spend.ts +31 -15
  227. package/src/server/responses/run-turn-execution.ts +6 -5
  228. package/src/server/responses/shadow-target-availability.ts +61 -0
  229. package/src/server/responses-custom-tool-repair.ts +2 -0
  230. package/src/service/claim.ts +185 -0
  231. package/src/service/cli.ts +10 -1
  232. package/src/service/guarded-manager-target.ts +151 -0
  233. package/src/service/guards.ts +47 -48
  234. package/src/service/managing-cli.ts +170 -0
  235. package/src/service/orchestration.ts +3 -1
  236. package/src/service/state.ts +4 -0
  237. package/src/service/systemd.ts +16 -1
  238. package/src/service.ts +1 -1
  239. package/src/types/config.ts +8 -0
  240. package/src/types/provider.ts +16 -0
  241. package/src/types/request.ts +10 -0
  242. package/src/types/tools.ts +9 -1
  243. package/src/types/wire.ts +68 -4
  244. package/src/types.ts +1 -0
  245. package/src/update/job.ts +10 -16
  246. package/src/update/npm-invocation.mjs +17 -16
  247. package/src/update/transactional-install.d.mts +22 -1
  248. package/src/update/transactional-install.mjs +176 -19
  249. package/src/update/update-failure-guidance.d.mts +8 -0
  250. package/src/update/update-failure-guidance.mjs +47 -0
  251. package/src/usage/expected-prices.ts +54 -12
  252. package/src/usage/log.ts +117 -3
  253. package/src/usage/telemetry-contract.ts +1 -0
  254. package/src/usage/timeline.ts +34 -6
  255. package/src/usage/user-cost-overlay-reconciler.ts +3 -3
  256. package/src/vision/reasoning.ts +2 -4
  257. package/src/web-search/xai-executor.ts +14 -4
  258. package/gui/dist/assets/App-BqrsSrIR.js +0 -50
  259. package/gui/dist/assets/index-C6SJrh0N.js +0 -86
  260. package/gui/dist/assets/index-DdDunwDb.css +0 -1
  261. package/gui/dist/assets/usage-companion-chart-CzAAAB1o.js +0 -1
  262. package/src/adapters/kiro-thinking.ts +0 -112
package/src/cli/index.ts CHANGED
@@ -96,7 +96,18 @@ import {
96
96
  import { createReadinessGate } from "../server/readiness";
97
97
  import { isApiAuthRequired } from "../server/auth-cors";
98
98
  import { runReady, type ReadyArgs } from "./ready";
99
- import { runResolve, type ResolveArgs } from "./resolve";
99
+ import { runResolve, type ResolveArgs, type ResolveJson } from "./resolve";
100
+ import {
101
+ approvalChanged,
102
+ guardFinalStopSummary,
103
+ managerStillActive,
104
+ runApprovedStop,
105
+ runGuardedManagerStep,
106
+ settleApprovedTarget,
107
+ type GuardedStopSnapshot,
108
+ type StopApproval,
109
+ } from "./stop-approval";
110
+ import { inspectGuardedManagerTarget, observeGuardedManagerStopped } from "../service/guarded-manager-target";
100
111
  import { summarizeStopRun, type StopOutcome, type StopRunRecord } from "./stop-report";
101
112
  import { runCli } from "./root";
102
113
  import { isProcessAlive, ProxyOwnershipRefusedError, refusalNextStep, stopProxy } from "../lib/process-control";
@@ -181,6 +192,16 @@ async function refreshOwnedRaycastCatalog(
181
192
 
182
193
  initializeNodeLauncherContext();
183
194
 
195
+ // The compiled executable is also the capture-only MCP server's launcher.
196
+ // Handle this private entrypoint before CLI preflight or command dispatch.
197
+ if (process.argv[2] === "__codebuddy-mcp") {
198
+ const { runCodeBuddyMcpServer } = await import("../adapters/codebuddy/mcp-server");
199
+ await runCodeBuddyMcpServer(process.argv[3] ?? "");
200
+ // The MCP stdio loop owns this process until stdin closes; do not fall through
201
+ // to ordinary CLI dispatch or exit after the handshake completes.
202
+ await new Promise<never>(() => {});
203
+ }
204
+
184
205
  // Head: version/help early exits, `ready` pre-parse (exit 64 before any
185
206
  // preflight), and the bounded Codex-shim auto-restore preflight live in
186
207
  // src/cli/root.ts (Phase 1 of the CLI deepening). runCli exits for
@@ -556,7 +577,7 @@ async function handleStart(options: { block?: boolean } = {}) {
556
577
  }
557
578
 
558
579
  const { server, serverModule, port, readinessGate, config } = boundStart;
559
- const { drainAndShutdown, isRecyclingForExit } = serverModule;
580
+ const { drainAndShutdown, isRecyclingForExit, noteExplicitShutdownRequested } = serverModule;
560
581
  // Records are visible now; background work may observe this runtime without a gap.
561
582
  scheduleCatalogPrewarm();
562
583
  installCrashGuards();
@@ -627,6 +648,7 @@ async function handleStart(options: { block?: boolean } = {}) {
627
648
  }
628
649
  shuttingDown = true;
629
650
  shutdownStartedAt = now;
651
+ noteExplicitShutdownRequested(); // an automatic package restart must not hand off after this
630
652
  console.log("\n🛑 Shutting down opencodex proxy...");
631
653
  void (async () => {
632
654
  let shutdownSucceeded = false;
@@ -882,6 +904,8 @@ function reportRestartFailure(result: Extract<ProxyRestartResult, { ok: false }>
882
904
  } else if (code === "restart_version_skew") {
883
905
  console.error("❌ The running proxy reports a different OpenCodex version than this CLI; restarting in place would respawn the old installation.");
884
906
  console.error(" Run `ocx stop` and then `ocx start` from this installation instead.");
907
+ } else if (code === "restart_package_tree_unsettled") {
908
+ console.error("❌ The proxy's package files are still being replaced; wait for the install to finish, then run `ocx restart` again.");
885
909
  } else {
886
910
  console.error("❌ Proxy restart request could not be confirmed; no fallback stop/start was attempted.");
887
911
  }
@@ -898,7 +922,7 @@ async function handleProxyRestart(
898
922
  const deadlineAt = Date.now() + PROXY_RESTART_OBSERVE_MS;
899
923
  const result = await runProxyRestart({
900
924
  findLive: () => discoverStableProxyForRestart({
901
- findLive: () => findLiveProxy({ deadlineAt, attempts: 2 }),
925
+ findLive: () => findLiveProxy({ deadlineAt, attempts: 2, acceptPackageTreeFenced: true }),
902
926
  expired: () => Date.now() >= deadlineAt,
903
927
  }),
904
928
  startWhenStopped,
@@ -990,13 +1014,37 @@ async function restoreSharedClientStateAfterStop(): Promise<{ historyOnly: boole
990
1014
  return { historyOnly, historyDeferred, other };
991
1015
  }
992
1016
 
993
- async function handleStop() {
1017
+ async function handleStop(approval?: StopApproval) {
994
1018
  const lease = acquireOwnershipMutationLease(serviceStatePaths());
995
- try { return await handleStopUnlocked(); }
1019
+ try {
1020
+ if (!approval) return await handleStopUnlocked();
1021
+ return await runApprovedStop(
1022
+ approval,
1023
+ async () => {
1024
+ const lines: string[] = [];
1025
+ const code = await runResolve({ json: true }, {
1026
+ stdout: { log: line => { lines.push(line); } },
1027
+ stderr: { error: () => {} },
1028
+ });
1029
+ if (code !== 0 || lines.length !== 1) return null;
1030
+ try { return JSON.parse(lines[0]!) as ResolveJson; }
1031
+ catch { return null; }
1032
+ },
1033
+ () => {
1034
+ const pid = readPid();
1035
+ const runtime = pid === null ? null : readRuntimePort(pid);
1036
+ return pid && runtime?.port ? {
1037
+ pid, port: runtime.port, hostname: runtime.hostname ?? "",
1038
+ } : null;
1039
+ },
1040
+ () => inspectGuardedManagerTarget(approval.pid, approval.port),
1041
+ snapshot => handleStopUnlocked(snapshot),
1042
+ );
1043
+ }
996
1044
  finally { lease.release(); }
997
1045
  }
998
1046
 
999
- async function handleStopUnlocked() {
1047
+ async function handleStopUnlocked(snapshot?: GuardedStopSnapshot) {
1000
1048
  // The receipt must name the endpoint the owner was stopping — an obligation nobody can
1001
1049
  // locate cannot be proven discharged. Only the runtime record knows it; a proxy started
1002
1050
  // with an explicit --port is not on the configured one.
@@ -1137,8 +1185,23 @@ async function handleStopUnlocked() {
1137
1185
  // to this process.
1138
1186
  return graceful && !teardownNonce;
1139
1187
  };
1188
+ const approvedEndpoint = snapshot
1189
+ ? { hostname: probeHostname(snapshot.approval.hostname || undefined), port: snapshot.approval.port }
1190
+ : null;
1191
+ let guardedStep: Awaited<ReturnType<typeof runGuardedManagerStep>> | null = null;
1140
1192
  try {
1141
- const serviceStop = stopServiceIfInstalledDetailed();
1193
+ const serviceStop = snapshot
1194
+ ? (guardedStep = await runGuardedManagerStep(snapshot, {
1195
+ revalidateManager: () => inspectGuardedManagerTarget(snapshot.approval.pid, snapshot.approval.port),
1196
+ stopManager: () => {
1197
+ if (approvedEndpoint) claimTeardown(approvedEndpoint, "exact");
1198
+ return stopServiceIfInstalledDetailed();
1199
+ },
1200
+ signalApproved: () => stopWithDeferral(snapshot.approval.pid, approvedEndpoint),
1201
+ settle: () => settleApprovedTarget(snapshot.approval),
1202
+ managerState: () => observeGuardedManagerStopped(snapshot.manager),
1203
+ })).service
1204
+ : stopServiceIfInstalledDetailed();
1142
1205
  record.service = serviceStop;
1143
1206
  stoppedService = serviceStop === "stopped" || serviceStop === "stopped-respawnable";
1144
1207
  schedulerCanRespawn = serviceStop === "stopped-respawnable";
@@ -1171,6 +1234,25 @@ async function handleStopUnlocked() {
1171
1234
  }
1172
1235
  }
1173
1236
 
1237
+ if (snapshot) {
1238
+ if (guardedStep?.effect === "approval-changed") {
1239
+ return approvalChanged();
1240
+ }
1241
+ if (guardedStep?.effect === "manager-still-active") {
1242
+ return managerStillActive(record.service, record);
1243
+ }
1244
+ if (guardedStep?.effect === "stopped") {
1245
+ record.proxy = guardedStep.proxy;
1246
+ nativeRestoreHandledByProxy = guardedStep.handledByProxy;
1247
+ removePid(snapshot.approval.pid);
1248
+ removeRuntimePort(snapshot.approval.pid);
1249
+ } else {
1250
+ stopFailed = true;
1251
+ ownershipBlocked = true;
1252
+ record.proxy = "unknown";
1253
+ console.error("The approved runtime could not be verified after the guarded stop step.");
1254
+ }
1255
+ } else {
1174
1256
  const pid = readPid();
1175
1257
  if (pid) {
1176
1258
  try {
@@ -1212,7 +1294,7 @@ async function handleStopUnlocked() {
1212
1294
  const staleRuntimePid = readRuntimePort()?.pid ?? null;
1213
1295
  // Orphan recovery: a live proxy can outlive its pid file (crash, manual delete,
1214
1296
  // corrupt file). Identity-checked liveness still finds it via the runtime record.
1215
- const live = await findLiveProxy();
1297
+ const live = await findLiveProxy({ acceptPackageTreeFenced: true });
1216
1298
  if (live?.pid) {
1217
1299
  try {
1218
1300
  // The probe already found where it answers, and on this path the runtime record is
@@ -1260,6 +1342,7 @@ async function handleStopUnlocked() {
1260
1342
  removeRuntimePortIfPidIs(staleRuntimePid);
1261
1343
  }
1262
1344
  }
1345
+ }
1263
1346
  // Environment ownership is independent from service ownership. Always roll back
1264
1347
  // current-home variables; the helper refuses foreign markers on its own.
1265
1348
  try { revertSystemEnv(); } catch { /* best-effort */ }
@@ -1429,12 +1512,17 @@ async function handleStopUnlocked() {
1429
1512
  ? STOP_HISTORY_DEFERRED_EXIT_CODE
1430
1513
  : 1;
1431
1514
  }
1432
- const summary = summarizeStopRun(record, {
1515
+ const signals = {
1433
1516
  failed: stopFailed,
1434
1517
  historyOnly: historyOnlyFailure,
1435
1518
  historyDeferred: historyDeferredNonces !== null,
1436
1519
  exitCode: Number(process.exitCode ?? 0),
1437
- });
1520
+ };
1521
+ const summary = summarizeStopRun(record, signals);
1522
+ if (snapshot && !stopFailed) {
1523
+ return guardFinalStopSummary(record, () => observeGuardedManagerStopped(snapshot.manager),
1524
+ () => ({ ok: !stopFailed, summary }));
1525
+ }
1438
1526
  return { ok: !stopFailed, summary };
1439
1527
  }
1440
1528
 
@@ -83,7 +83,7 @@ export const CLI_COMMANDS: CliCommandEntry[] = [
83
83
  },
84
84
  {
85
85
  name: "service",
86
- usage: "ocx service [install|repair|restart|start|stop|status|uninstall|remove]",
86
+ usage: "ocx service [install|repair|restart|start|stop|status|uninstall|remove|claim]",
87
87
  summary: "Run as a background service.",
88
88
  details: [
89
89
  "With no subcommand, installs when absent or repairs an existing service.",
@@ -45,6 +45,20 @@ import {
45
45
  type LiveProxy,
46
46
  } from "../server/proxy-liveness";
47
47
  import { endpointsToProve, everyEndpointProvenDownAsync, type ProbeEndpoint } from "./uninstall-plan";
48
+ import {
49
+ resolveServiceOwnership,
50
+ resolveServiceState,
51
+ type ServiceInstallState,
52
+ type ServiceOwnershipResolution,
53
+ type ServiceStateResolution,
54
+ } from "../service/state";
55
+ import {
56
+ assessServiceTakeoverCompatibility,
57
+ type ManagingCliObservation,
58
+ type ManagingCliRole,
59
+ } from "../service/ownership-compatibility";
60
+ import { observeManagingClis } from "../service/managing-cli";
61
+ import { SERVICE_OWNERSHIP_MINIMUM_CLI_VERSION } from "../service/install-state-contract.mjs";
48
62
 
49
63
  /** Wire version of the resolve document. Bump only on an incompatible shape change. */
50
64
  export const RESOLVE_SCHEMA = "ocx-resolve/1";
@@ -86,8 +100,26 @@ export interface ResolveJson {
86
100
  source: LiveProxy["source"];
87
101
  };
88
102
  liveness: ResolveLivenessJson;
103
+ /**
104
+ * The recorded runtime owner, in the CLI's own three answers. `unknown` is on the wire
105
+ * deliberately: it never changes the exit code — the liveness verdict is still
106
+ * trustworthy — and the embedding shell fails closed on it rather than asking consent
107
+ * against a record it could not read.
108
+ */
109
+ ownership: ServiceOwnershipResolution;
110
+ /**
111
+ * Whether a desktop takeover can be offered, and the token that binds that approval to
112
+ * the exact subject and managing-CLI observations a later `ocx service claim` must find
113
+ * unchanged. `ownership-unknown` is produced only here: it is a wire reason, not a new
114
+ * member of `ServiceTakeoverCompatibility`'s union.
115
+ */
116
+ takeover: ResolveTakeover;
89
117
  }
90
118
 
119
+ export type ResolveTakeover =
120
+ | { kind: "supported"; protocolVersion: number; minimumCliVersion: string; token: string }
121
+ | { kind: "blocked"; reason: string; detail: string; minimumCliVersion: string };
122
+
91
123
  export interface ResolveArgs {
92
124
  json: boolean;
93
125
  }
@@ -111,6 +143,14 @@ export interface ResolveIo {
111
143
  /** Tri-state endpoint probe; production default runs in-process for compiled standalone binaries. */
112
144
  probeEndpoint?: (endpoint: ProbeEndpoint) => EndpointLiveness | Promise<EndpointLiveness>;
113
145
  cliVersion?: () => string;
146
+ /** Recorded-ownership resolver; production default is resolveServiceOwnership. */
147
+ resolveOwnership?: () => ServiceOwnershipResolution;
148
+ /** Full install-state resolver; production default is resolveServiceState. */
149
+ resolveState?: () => ServiceStateResolution;
150
+ /** Managing-CLI observer; production default is observeManagingClis. */
151
+ observeManagers?: (
152
+ state: ServiceInstallState | null,
153
+ ) => Readonly<Record<ManagingCliRole, ManagingCliObservation>>;
114
154
  stdout?: { log: (s: string) => void };
115
155
  stderr?: { error: (s: string) => void };
116
156
  }
@@ -136,6 +176,8 @@ export function buildResolveJson(
136
176
  live: LiveProxy | null,
137
177
  configHome: string,
138
178
  cliVersion: string,
179
+ ownership: ServiceOwnershipResolution,
180
+ takeover: ResolveTakeover,
139
181
  ): ResolveJson {
140
182
  const configured = config.port ?? RESOLVE_DEFAULT_PORT;
141
183
  return {
@@ -148,6 +190,8 @@ export function buildResolveJson(
148
190
  source: live ? live.source : "config",
149
191
  },
150
192
  liveness: livenessJson(live),
193
+ ownership,
194
+ takeover,
151
195
  };
152
196
  }
153
197
 
@@ -164,6 +208,19 @@ function reportHuman(json: ResolveJson, stdout: { log: (s: string) => void }): v
164
208
  } else {
165
209
  stdout.log(`No live proxy (absence proven); effective port ${json.port.effective} (configured).`);
166
210
  }
211
+ const ownership = json.ownership;
212
+ if (ownership.kind === "owned") {
213
+ stdout.log(`Owner: ${ownership.ownership.owner} (install ${ownership.ownership.installId}, generation ${ownership.ownership.consentGeneration})`);
214
+ } else if (ownership.kind === "unknown") {
215
+ stdout.log(`Owner: unknown (${ownership.reason})`);
216
+ } else {
217
+ stdout.log("Owner: none recorded");
218
+ }
219
+ stdout.log(
220
+ json.takeover.kind === "supported"
221
+ ? "Takeover: supported"
222
+ : `Takeover: blocked (${json.takeover.reason}: ${json.takeover.detail})`,
223
+ );
167
224
  }
168
225
 
169
226
  /**
@@ -181,6 +238,9 @@ export async function runResolve(args: ResolveArgs, io: ResolveIo = {}): Promise
181
238
  const readRuntime = io.readRuntime ?? readRuntimePort;
182
239
  const probeEndpoint = io.probeEndpoint ?? probeEndpointLiveness;
183
240
  const cliVersion = io.cliVersion ?? packageVersion;
241
+ const resolveOwnership = io.resolveOwnership ?? resolveServiceOwnership;
242
+ const resolveState = io.resolveState ?? resolveServiceState;
243
+ const observeManagers = io.observeManagers ?? observeManagingClis;
184
244
  const configHome = configDir();
185
245
  let diagnostics: ConfigDiagnostics;
186
246
  try {
@@ -223,7 +283,59 @@ export async function runResolve(args: ResolveArgs, io: ResolveIo = {}): Promise
223
283
  return 1;
224
284
  }
225
285
  }
226
- const json = buildResolveJson(diagnostics.config, live, configHome, cliVersion());
286
+ let ownership: ServiceOwnershipResolution;
287
+ try {
288
+ ownership = resolveOwnership();
289
+ } catch (error) {
290
+ ownership = { kind: "unknown", reason: error instanceof Error ? error.message : String(error) };
291
+ }
292
+ let takeover: ResolveTakeover;
293
+ if (!live) {
294
+ // A proven absence may authorize start, but there is no runtime to take over.
295
+ // Avoid synchronous managing-CLI version probes on this launch path.
296
+ takeover = {
297
+ kind: "blocked",
298
+ reason: "runtime-absent",
299
+ detail: "no live runtime is available for takeover",
300
+ minimumCliVersion: SERVICE_OWNERSHIP_MINIMUM_CLI_VERSION,
301
+ };
302
+ } else if (ownership.kind === "unknown") {
303
+ // The claim cannot be read, so nothing can be approved against it. This reason is a
304
+ // wire answer, not a new member of the compatibility union.
305
+ takeover = {
306
+ kind: "blocked",
307
+ reason: "ownership-unknown",
308
+ detail: ownership.reason,
309
+ minimumCliVersion: SERVICE_OWNERSHIP_MINIMUM_CLI_VERSION,
310
+ };
311
+ } else {
312
+ try {
313
+ const resolved = resolveState();
314
+ if (resolved.kind === "unknown") {
315
+ takeover = {
316
+ kind: "blocked",
317
+ reason: "ownership-unknown",
318
+ detail: resolved.reason,
319
+ minimumCliVersion: SERVICE_OWNERSHIP_MINIMUM_CLI_VERSION,
320
+ };
321
+ } else {
322
+ const resolvedState = resolved.kind === "state" ? resolved.state : null;
323
+ takeover = assessServiceTakeoverCompatibility({
324
+ state: resolvedState,
325
+ subject: ownership,
326
+ managers: observeManagers(resolvedState),
327
+ });
328
+ }
329
+ } catch (error) {
330
+ takeover = {
331
+ kind: "blocked",
332
+ reason: "managing-cli-unknown",
333
+ detail: error instanceof Error ? error.message : String(error),
334
+ minimumCliVersion: SERVICE_OWNERSHIP_MINIMUM_CLI_VERSION,
335
+ };
336
+ }
337
+ }
338
+ const json = buildResolveJson(diagnostics.config, live, configHome, cliVersion(), ownership, takeover);
227
339
  if (args.json) stdout.log(JSON.stringify(json));
228
340
  else reportHuman(json, stdout);
229
341
  return 0;
package/src/cli/root.ts CHANGED
@@ -11,6 +11,7 @@
11
11
  import { hasHelpFlag, printSubcommandUsage, printUsage, printVersion } from "./help";
12
12
  import { parseReadyArgs, type ReadyArgs } from "./ready";
13
13
  import { parseResolveArgs, type ResolveArgs } from "./resolve";
14
+ import { parseStopApproval } from "./stop-approval";
14
15
  import { maybeAutoRestoreCodexShim } from "./codex-shim-autorestore";
15
16
 
16
17
  export interface CliHead {
@@ -103,6 +104,10 @@ export async function runCli(argv: string[]): Promise<CliHead> {
103
104
  return head;
104
105
  }
105
106
  case "command":
107
+ if (head.command === "stop" && !parseStopApproval(head.args.slice(1)).ok) {
108
+ console.error("Usage: ocx stop [--json [--expect-pid <pid> --expect-port <port> --expect-hostname <host> --expect-config-home <home> --expect-cli-version <version> --expect-compatibility-token <hex>]]");
109
+ process.exit(64);
110
+ }
106
111
  maybeAutoRestoreCodexShim(head.command, head.args);
107
112
  return head;
108
113
  }
@@ -0,0 +1,186 @@
1
+ /** Opt-in consistency guard for a desktop-approved stop. Not a consent proof. */
2
+ import { waitForExit } from "../lib/process-control";
3
+ import { waitForPortAvailable } from "../server/ports";
4
+ import { probeEndpointLiveness, probeHostname } from "../server/proxy-liveness";
5
+ import type { GuardedManagerTarget } from "../service/guarded-manager-target";
6
+ import type { ResolveJson } from "./resolve";
7
+ import { STOP_SUMMARY_SCHEMA, type StopOutcome, type StopRunRecord, type StopServiceOutcome } from "./stop-report";
8
+
9
+ export interface StopApproval {
10
+ pid: number;
11
+ port: number;
12
+ hostname: string;
13
+ configHome: string;
14
+ cliVersion: string;
15
+ compatibilityToken: string;
16
+ }
17
+
18
+ export interface GuardedStopSnapshot {
19
+ approval: StopApproval;
20
+ manager: Exclude<GuardedManagerTarget, { kind: "unknown" }>;
21
+ }
22
+
23
+ function sameGuardedManager(
24
+ expected: GuardedStopSnapshot["manager"],
25
+ current: GuardedManagerTarget,
26
+ ): boolean {
27
+ if (expected.kind === "absent") return current.kind === "absent";
28
+ return current.kind === "bound" && current.pid === expected.pid
29
+ && current.managerPid === expected.managerPid && current.backend === expected.backend;
30
+ }
31
+
32
+ export type StopApprovalParse = { ok: true; json: boolean; approval: StopApproval | null } | { ok: false };
33
+
34
+ export function parseStopApproval(argv: string[]): StopApprovalParse {
35
+ if (argv.length === 0) return { ok: true, json: false, approval: null };
36
+ if (argv.every(arg => arg === "--json")) return { ok: true, json: true, approval: null };
37
+ const names = ["--expect-pid", "--expect-port", "--expect-hostname",
38
+ "--expect-config-home", "--expect-cli-version", "--expect-compatibility-token"];
39
+ if (argv.filter(arg => arg === "--json").length !== 1) return { ok: false };
40
+ const values = new Map<string, string>();
41
+ for (let index = 0; index < argv.length; index += 1) {
42
+ const flag = argv[index]!;
43
+ if (flag === "--json") continue;
44
+ if (!names.includes(flag) || values.has(flag) || index + 1 >= argv.length) return { ok: false };
45
+ values.set(flag, argv[++index]!);
46
+ }
47
+ if (values.size !== names.length) return { ok: false };
48
+ const integer = (value: string | undefined, max: number): number | null => {
49
+ if (!value || !/^\d+$/.test(value)) return null;
50
+ const parsed = Number(value);
51
+ return Number.isSafeInteger(parsed) && parsed > 0 && parsed <= max ? parsed : null;
52
+ };
53
+ const pid = integer(values.get("--expect-pid"), Number.MAX_SAFE_INTEGER);
54
+ const port = integer(values.get("--expect-port"), 65535);
55
+ const compatibilityToken = values.get("--expect-compatibility-token") ?? "";
56
+ const configHome = values.get("--expect-config-home") ?? "";
57
+ const cliVersion = values.get("--expect-cli-version") ?? "";
58
+ if (pid === null || port === null || !/^[a-f0-9]{64}$/.test(compatibilityToken)
59
+ || !configHome || !cliVersion) return { ok: false };
60
+ return { ok: true, json: true, approval: {
61
+ pid, port, hostname: values.get("--expect-hostname") ?? "",
62
+ configHome, cliVersion, compatibilityToken,
63
+ } };
64
+ }
65
+
66
+ export function matchesStopApproval(expected: StopApproval, now: ResolveJson): boolean {
67
+ return now.schema === "ocx-resolve/1" && now.liveness.status === "live"
68
+ && now.liveness.pid === expected.pid && now.liveness.port === expected.port
69
+ && now.port.effective === expected.port && (now.liveness.hostname ?? "") === expected.hostname
70
+ && now.liveness.role !== "client" && now.configHome === expected.configHome
71
+ && now.cliVersion === expected.cliVersion && now.ownership.kind !== "unknown"
72
+ && now.takeover.kind === "supported" && now.takeover.token === expected.compatibilityToken;
73
+ }
74
+
75
+ export function approvalChanged(): StopOutcome {
76
+ return { ok: false, summary: {
77
+ schema: STOP_SUMMARY_SCHEMA, ok: false, outcome: "approval-changed", exitCode: 1,
78
+ runtimeDown: false, service: "absent", proxy: "unknown", sharedTeardown: "skipped",
79
+ message: "The approved runtime or managing CLI changed; no stop was attempted.",
80
+ } };
81
+ }
82
+
83
+ export function managerStillActive(
84
+ service: StopServiceOutcome,
85
+ record?: Pick<StopRunRecord, "proxy" | "sharedTeardown">,
86
+ ): StopOutcome {
87
+ return { ok: false, summary: {
88
+ schema: STOP_SUMMARY_SCHEMA, ok: false, outcome: "manager-still-active", exitCode: 1,
89
+ runtimeDown: false, service,
90
+ proxy: record?.proxy ?? "unknown", sharedTeardown: record?.sharedTeardown ?? "skipped",
91
+ message: "The approved runtime and service manager could not both be confirmed stopped.",
92
+ } };
93
+ }
94
+
95
+ export async function runApprovedStop(
96
+ expected: StopApproval,
97
+ read: () => Promise<ResolveJson | null>,
98
+ tracked: () => { pid: number; port: number; hostname: string } | null,
99
+ managerTarget: () => GuardedManagerTarget,
100
+ stop: (snapshot: GuardedStopSnapshot) => Promise<StopOutcome>,
101
+ ): Promise<StopOutcome> {
102
+ let snapshot: GuardedStopSnapshot | null = null;
103
+ try {
104
+ const now = await read();
105
+ const target = tracked();
106
+ const manager = managerTarget();
107
+ if (!now || !matchesStopApproval(expected, now) || !target
108
+ || target.pid !== expected.pid || target.port !== expected.port
109
+ || target.hostname !== expected.hostname || manager.kind === "unknown"
110
+ || (manager.kind === "bound" && manager.pid !== expected.pid)) return approvalChanged();
111
+ snapshot = { approval: expected, manager };
112
+ } catch { return approvalChanged(); }
113
+ return snapshot ? stop(snapshot) : approvalChanged();
114
+ }
115
+
116
+ const GUARDED_SETTLE_MS = 5_000;
117
+
118
+ /** Compose the existing bounded PID/port waiters, then prove health absence. */
119
+ export async function settleApprovedTarget(
120
+ approved: StopApproval,
121
+ io: {
122
+ now?: () => number;
123
+ waitExit?: (pid: number, timeoutMs: number) => boolean;
124
+ waitPort?: typeof waitForPortAvailable;
125
+ probe?: typeof probeEndpointLiveness;
126
+ } = {},
127
+ ): Promise<boolean> {
128
+ const now = io.now ?? Date.now;
129
+ const deadline = now() + GUARDED_SETTLE_MS;
130
+ const remaining = () => Math.max(0, deadline - now());
131
+ if (!(io.waitExit ?? waitForExit)(approved.pid, remaining()) || remaining() <= 0) return false;
132
+ const hostname = probeHostname(approved.hostname || undefined);
133
+ if (!await (io.waitPort ?? waitForPortAvailable)(approved.port, hostname,
134
+ { timeoutMs: remaining(), intervalMs: 50 }) || remaining() <= 0) return false;
135
+ const verdict = await (io.probe ?? probeEndpointLiveness)(
136
+ { port: approved.port, hostname: approved.hostname || undefined },
137
+ { timeoutMs: Math.min(250, remaining()) },
138
+ );
139
+ return verdict === "dead" && now() <= deadline;
140
+ }
141
+
142
+ export async function runGuardedManagerStep(
143
+ snapshot: GuardedStopSnapshot,
144
+ io: {
145
+ revalidateManager: () => GuardedManagerTarget;
146
+ stopManager: () => StopServiceOutcome;
147
+ signalApproved: () => Promise<boolean>;
148
+ settle: () => Promise<boolean>;
149
+ managerState: () => Promise<"inactive" | "active" | "unknown">;
150
+ },
151
+ ): Promise<{ service: StopServiceOutcome; effect: "stopped" | "approval-changed" | "manager-still-active" | "failed";
152
+ proxy: "stopped" | "unknown"; handledByProxy: boolean }> {
153
+ try {
154
+ if (!sameGuardedManager(snapshot.manager, io.revalidateManager())) {
155
+ return { service: "absent", effect: "approval-changed", proxy: "unknown", handledByProxy: false };
156
+ }
157
+ } catch {
158
+ return { service: "absent", effect: "approval-changed", proxy: "unknown", handledByProxy: false };
159
+ }
160
+ const service = snapshot.manager.kind === "absent" ? "absent" : io.stopManager();
161
+ let handledByProxy = false;
162
+ if (snapshot.manager.kind === "absent") {
163
+ try { handledByProxy = await io.signalApproved(); }
164
+ catch { return { service, effect: "failed", proxy: "unknown", handledByProxy }; }
165
+ }
166
+ if (!await io.settle()) {
167
+ return { service, effect: "manager-still-active", proxy: "unknown", handledByProxy };
168
+ }
169
+ try {
170
+ if (await io.managerState() !== "inactive") {
171
+ return { service, effect: "manager-still-active", proxy: "unknown", handledByProxy };
172
+ }
173
+ } catch { return { service, effect: "manager-still-active", proxy: "unknown", handledByProxy }; }
174
+ return { service, effect: "stopped", proxy: "stopped", handledByProxy };
175
+ }
176
+
177
+ export async function guardFinalStopSummary(
178
+ record: StopRunRecord,
179
+ managerState: () => Promise<"inactive" | "active" | "unknown">,
180
+ finish: () => StopOutcome,
181
+ ): Promise<StopOutcome> {
182
+ try {
183
+ if (await managerState() !== "inactive") return managerStillActive(record.service, record);
184
+ } catch { return managerStillActive(record.service, record); }
185
+ return finish();
186
+ }
@@ -67,7 +67,7 @@ export interface StopSummaryJson {
67
67
  schema: typeof STOP_SUMMARY_SCHEMA;
68
68
  /** Strict exit-code view: true only for exit 0. */
69
69
  ok: boolean;
70
- outcome: "stopped" | "not-running" | "history-incomplete" | "history-deferred" | "failed";
70
+ outcome: "stopped" | "not-running" | "history-incomplete" | "history-deferred" | "failed" | "approval-changed" | "manager-still-active";
71
71
  exitCode: number;
72
72
  /** True when this stop left no proxy of this home running by its own paths. */
73
73
  runtimeDown: boolean;
@@ -16,7 +16,9 @@ import {
16
16
  } from "../lib/system-restart-contract";
17
17
  import {
18
18
  findLiveProxy,
19
+ isHealthzVersion,
19
20
  isOpencodexHealthz,
21
+ isPackageTreeFencedHealthz,
20
22
  probeHostname,
21
23
  type HealthzIdentity,
22
24
  type LiveProxy,
@@ -105,9 +107,12 @@ export async function requestBoundSystemRestart(
105
107
  }
106
108
  const body = await proofResponse.json().catch(() => null) as HealthzIdentity | null;
107
109
  const proof = proofResponse.headers.get(LOCAL_ATTESTATION_PROOF_HEADER);
110
+ // A package-tree fence answers /healthz with 503 but still proves its identity (#5496).
111
+ // Only that exact body is admitted as a non-OK proof response; the proof check is unchanged.
112
+ const fenced = proofResponse.status === 503 && isPackageTreeFencedHealthz(body);
108
113
  if (
109
- !proofResponse.ok
110
- || !isOpencodexHealthz(body)
114
+ !(proofResponse.ok || fenced)
115
+ || !(isOpencodexHealthz(body) || fenced)
111
116
  || body?.pid !== target.pid
112
117
  || !verifyLocalAttestationProof(runtime.attestationSecret, challenge, target.pid, target.port, proof)
113
118
  ) {
@@ -127,14 +132,24 @@ export async function requestBoundSystemRestart(
127
132
  // diagnosis compares (packageVersion vs the /healthz version), so reuse that
128
133
  // comparison and refuse before POST. Placeholder versions (unknown/0.0.0) are
129
134
  // "cannot compare", not mismatch, and keep the existing behavior.
130
- const proxyVersion = typeof body.version === "string" ? body.version : undefined;
135
+ //
136
+ // A fenced proxy booted from files that have since been replaced at the same path, so its
137
+ // boot version differs from this CLI by construction. What the respawn will run is the
138
+ // manifest now on disk, which the fence reports as installedVersion. Without a readable
139
+ // one the replacement is still in flight and restarting now could load a partial tree.
140
+ if (fenced && !isHealthzVersion(body.installedVersion)) {
141
+ return rejected("restart_package_tree_unsettled");
142
+ }
143
+ const proxyVersion = fenced
144
+ ? body.installedVersion as string
145
+ : typeof body.version === "string" ? body.version : undefined;
131
146
  if (computeVersionSkew(deps.cliVersion ?? ownCliVersion(), proxyVersion).skewed) {
132
147
  return rejected("restart_version_skew");
133
148
  }
134
149
 
135
150
  let observed: LiveProxy | null;
136
151
  try {
137
- observed = await (deps.findLive ?? findLiveProxy)({ deadlineAt, nowFn: now });
152
+ observed = await (deps.findLive ?? findLiveProxy)({ deadlineAt, nowFn: now, acceptPackageTreeFenced: true });
138
153
  } catch {
139
154
  return rejected("restart_target_recheck_failed");
140
155
  }