@bitkyc08/opencodex 2.80.0 → 2.82.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 (376) hide show
  1. package/README.md +3 -3
  2. package/bin/ocx.mjs +166 -73
  3. package/gui/dist/assets/App-Ds_qL7o8.js +54 -0
  4. package/gui/dist/assets/Tray-BqHF5Trd.js +1 -0
  5. package/gui/dist/assets/{index-Brk6F3fn.css → index-BAbcj51J.css} +1 -1
  6. package/gui/dist/assets/index-D3FBCNiP.js +94 -0
  7. package/gui/dist/assets/quota-summary-DTBC8n7-.js +1 -0
  8. package/gui/dist/index.html +2 -2
  9. package/gui/dist/provider-icons/claude-green.svg +1 -0
  10. package/package.json +3 -2
  11. package/src/adapters/anthropic-model-contract.ts +8 -2
  12. package/src/adapters/anthropic.ts +8 -3
  13. package/src/adapters/codebuddy/mcp-server.ts +2 -25
  14. package/src/adapters/codebuddy/tool-bridge.ts +1 -7
  15. package/src/adapters/coding-agent/mcp-server.ts +33 -0
  16. package/src/adapters/coding-agent/protocol.ts +122 -17
  17. package/src/adapters/coding-agent/tool-catalog.ts +75 -0
  18. package/src/adapters/coding-agent/turn.ts +101 -44
  19. package/src/adapters/cursor/catalog.ts +8 -0
  20. package/src/adapters/cursor/effort-map.ts +2 -0
  21. package/src/adapters/devin/live-models.ts +2 -0
  22. package/src/adapters/kiro-retry.ts +9 -4
  23. package/src/adapters/openai-chat/messages.ts +7 -6
  24. package/src/adapters/openai-responses/compaction-search-history.ts +109 -0
  25. package/src/adapters/openai-responses/passthrough.ts +9 -4
  26. package/src/adapters/openai-responses/tool-output-recovery.ts +20 -0
  27. package/src/adapters/physical-send.ts +8 -5
  28. package/src/adapters/qoder/adapter.ts +41 -6
  29. package/src/adapters/qoder/scaffold-guard.ts +2 -3
  30. package/src/adapters/zed.ts +16 -1
  31. package/src/claude/agents-inject.ts +14 -3
  32. package/src/claude/context-windows.ts +19 -17
  33. package/src/claude/desktop-3p.ts +88 -12
  34. package/src/claude/desktop-profile.ts +2 -1
  35. package/src/claude/gateway-cache.ts +18 -1
  36. package/src/claude/inbound-cache-stabilize.ts +4 -9
  37. package/src/claude/inbound.ts +78 -3
  38. package/src/claude/intercept/cli-catalog.ts +3 -0
  39. package/src/claude/intercept/model-bindings.ts +1 -0
  40. package/src/claude/intercept/picker-ca-persistence.ts +7 -6
  41. package/src/claude/intercept/picker-listener.ts +71 -4
  42. package/src/claude/intercept/picker-models.ts +14 -6
  43. package/src/claude/intercept/settings.ts +3 -0
  44. package/src/claude/long-context.ts +66 -0
  45. package/src/claude/model-info.ts +19 -16
  46. package/src/claude/outbound.ts +94 -9
  47. package/src/claude/subagent-model.ts +40 -11
  48. package/src/cli/account-anthropic-threshold.ts +14 -5
  49. package/src/cli/account-api.ts +3 -1
  50. package/src/cli/account-extended.ts +13 -9
  51. package/src/cli/account-policy-dto.ts +1 -1
  52. package/src/cli/account-policy.ts +13 -4
  53. package/src/cli/account.ts +3 -3
  54. package/src/cli/agent.ts +9 -5
  55. package/src/cli/aside-profile-recovery.ts +33 -0
  56. package/src/cli/capabilities-accounts.ts +2 -2
  57. package/src/cli/capabilities-agents-routing.ts +24 -0
  58. package/src/cli/capabilities-base.ts +5 -5
  59. package/src/cli/capabilities-integrations.ts +18 -2
  60. package/src/cli/cli-path-diagnostics.ts +132 -0
  61. package/src/cli/codex-shim-autorestore.ts +3 -0
  62. package/src/cli/desktop-runtime-guidance.ts +65 -0
  63. package/src/cli/dispatch.ts +15 -1
  64. package/src/cli/doctor.ts +16 -3
  65. package/src/cli/help.ts +4 -1
  66. package/src/cli/index.ts +25 -14
  67. package/src/cli/integration-aside-sync.ts +2 -1
  68. package/src/cli/integration-plan-dto.ts +1 -0
  69. package/src/cli/integration-preview.ts +8 -1
  70. package/src/cli/integrations.ts +48 -6
  71. package/src/cli/message-args.ts +34 -0
  72. package/src/cli/message-command.ts +43 -0
  73. package/src/cli/message-runtime.ts +6 -0
  74. package/src/cli/observe.ts +3 -1
  75. package/src/cli/provider.ts +29 -6
  76. package/src/cli/registry.ts +24 -2
  77. package/src/cli/resolve.ts +25 -2
  78. package/src/cli/root.ts +4 -2
  79. package/src/cli/status.ts +64 -12
  80. package/src/cli/update-restart-child.ts +4 -2
  81. package/src/cli/update-restart-home.ts +28 -10
  82. package/src/cli/update-restart-service-record.ts +116 -0
  83. package/src/cli/update-restart-supervision.ts +96 -0
  84. package/src/cli/update-restart.ts +59 -13
  85. package/src/cli/v2.ts +27 -20
  86. package/src/cli/version-skew-notice.ts +77 -0
  87. package/src/clients/config-export/commandcode.ts +131 -0
  88. package/src/clients/config-export/contracts.ts +12 -0
  89. package/src/clients/config-export.ts +64 -1
  90. package/src/clients/lazycodex.ts +1 -1
  91. package/src/clients/omo-role-models.ts +32 -7
  92. package/src/codex/auth-api/account-list.ts +11 -3
  93. package/src/codex/auth-api/account-selection.ts +107 -0
  94. package/src/codex/auth-api/main-account-probe.ts +25 -8
  95. package/src/codex/auth-api/pool-mode-gate.ts +33 -9
  96. package/src/codex/auth-api/pool-quota-probe.ts +15 -1
  97. package/src/codex/auth-api/routes.ts +11 -49
  98. package/src/codex/auth-context.ts +20 -1
  99. package/src/codex/autostart-health.ts +12 -3
  100. package/src/codex/catalog/auto-review.ts +13 -6
  101. package/src/codex/catalog/build-entries.ts +33 -7
  102. package/src/codex/catalog/bundled.ts +155 -26
  103. package/src/codex/catalog/combo-member.ts +3 -2
  104. package/src/codex/catalog/control-plane.ts +45 -0
  105. package/src/codex/catalog/effort.ts +37 -6
  106. package/src/codex/catalog/gather-capture.ts +10 -3
  107. package/src/codex/catalog/model-hints.ts +15 -8
  108. package/src/codex/catalog/model-visibility.ts +3 -1
  109. package/src/codex/catalog/operator-tiers.ts +38 -0
  110. package/src/codex/catalog/parsing.ts +2 -1
  111. package/src/codex/catalog/provider-models.ts +58 -44
  112. package/src/codex/catalog/retained-sync.ts +7 -5
  113. package/src/codex/catalog/routed-gather.ts +15 -1
  114. package/src/codex/catalog-auto-refresh-sources.ts +3 -4
  115. package/src/codex/catalog-auto-refresh.ts +19 -0
  116. package/src/codex/catalog.ts +1 -1
  117. package/src/codex/codex-home-owner.ts +3 -0
  118. package/src/codex/config-write-lock.ts +333 -0
  119. package/src/codex/control-plane-models.ts +6 -0
  120. package/src/codex/convergence.ts +2 -1
  121. package/src/codex/features.ts +257 -200
  122. package/src/codex/inject/config-write-section.ts +34 -0
  123. package/src/codex/inject/multi-agent-v2.ts +14 -6
  124. package/src/codex/inject/remove.ts +36 -11
  125. package/src/codex/inject/restore.ts +85 -20
  126. package/src/codex/inject-coordination.ts +15 -6
  127. package/src/codex/inject.ts +62 -20
  128. package/src/codex/journal.ts +62 -17
  129. package/src/codex/main-account-cache.ts +43 -0
  130. package/src/codex/paths.ts +2 -0
  131. package/src/codex/prepared-home.ts +18 -0
  132. package/src/codex/prompt-journal.ts +25 -12
  133. package/src/codex/prompt-layers.ts +49 -26
  134. package/src/codex/prompt-lock-claim.ts +134 -0
  135. package/src/codex/prompt-lock-evidence.ts +26 -0
  136. package/src/codex/prompt-lock-io.ts +16 -0
  137. package/src/codex/prompt-lock-owner.ts +130 -0
  138. package/src/codex/prompt-lock.ts +84 -53
  139. package/src/codex/routing/failure-window.ts +146 -0
  140. package/src/codex/routing/selection.ts +4 -1
  141. package/src/codex/routing.ts +13 -17
  142. package/src/codex/runtime.ts +106 -18
  143. package/src/codex/shim.ts +45 -19
  144. package/src/combos/jev-decision-contract.ts +25 -2
  145. package/src/combos/jev-service-exchange.ts +281 -0
  146. package/src/combos/jev.ts +34 -219
  147. package/src/combos/types.ts +1 -1
  148. package/src/config/atomic-write.ts +1 -1
  149. package/src/config/codex-ws-reuse-setting.ts +10 -0
  150. package/src/config/diagnostics.ts +27 -16
  151. package/src/config/live-reconcile.ts +17 -0
  152. package/src/config/load-degrade.ts +42 -0
  153. package/src/config/rebase-provenance.ts +11 -0
  154. package/src/config/schema/anthropic-account-pool.ts +143 -0
  155. package/src/config/schema/config-schema.ts +11 -1
  156. package/src/config/schema/leaf-validators.ts +4 -0
  157. package/src/config.ts +2 -0
  158. package/src/generated/compatibility-version.json +566 -298
  159. package/src/generated/model-metadata.ts +7 -6
  160. package/src/grok/inject.ts +50 -4
  161. package/src/grok/sync.ts +2 -1
  162. package/src/images/loop.ts +30 -11
  163. package/src/integrations/aside-profiles.ts +10 -3
  164. package/src/integrations/catalog-refresh.ts +19 -3
  165. package/src/integrations/mutation-plan.ts +7 -0
  166. package/src/integrations/registry.ts +8 -0
  167. package/src/integrations/state.ts +9 -2
  168. package/src/integrations/writer.ts +17 -5
  169. package/src/lib/bun-path-runtime.d.mts +32 -0
  170. package/src/lib/bun-path-runtime.mjs +95 -0
  171. package/src/lib/bun-runtime-preflight.ts +140 -0
  172. package/src/lib/desktop-cli-handoff.d.mts +20 -0
  173. package/src/lib/desktop-cli-handoff.mjs +92 -0
  174. package/src/lib/desktop-cli-record.d.mts +29 -0
  175. package/src/lib/desktop-cli-record.mjs +102 -0
  176. package/src/lib/lab-live-route-production.ts +9 -1
  177. package/src/lib/request-execution-budget.ts +244 -58
  178. package/src/lib/spend-pool-alias-validation.ts +79 -0
  179. package/src/lib/spend-pool-continuity.ts +34 -0
  180. package/src/lib/spend-reservation-ledger.ts +412 -50
  181. package/src/lib/state-store-registrations.ts +20 -3
  182. package/src/lib/synced-state-location.ts +46 -6
  183. package/src/lib/test-home-guard.ts +87 -1
  184. package/src/lib/translator-budget.ts +17 -4
  185. package/src/lib/upstream-retry.ts +17 -5
  186. package/src/lib/windows-atomic-replace.ts +38 -16
  187. package/src/lib/windows-elevation.ts +6 -0
  188. package/src/lib/windows-secret-acl.ts +92 -25
  189. package/src/lib/winsw.ts +2 -3
  190. package/src/lib/workflow-budget.ts +53 -11
  191. package/src/messaging/budget.ts +45 -0
  192. package/src/messaging/discovery.ts +57 -0
  193. package/src/messaging/envelope.ts +49 -0
  194. package/src/messaging/input.ts +37 -0
  195. package/src/messaging/rpc.ts +196 -0
  196. package/src/messaging/send.ts +58 -0
  197. package/src/messaging/socket.ts +72 -0
  198. package/src/messaging/types.ts +29 -0
  199. package/src/oauth/anthropic-account-refusal.ts +90 -18
  200. package/src/oauth/anthropic-account-threshold.ts +8 -2
  201. package/src/oauth/anthropic-continuity.ts +15 -1
  202. package/src/oauth/anthropic-model-quota.ts +120 -48
  203. package/src/oauth/anthropic-model-routes.ts +11 -3
  204. package/src/oauth/anthropic-oauth-definitions.ts +98 -0
  205. package/src/oauth/anthropic-pool-config.ts +30 -0
  206. package/src/oauth/anthropic-rate-limit-policy.ts +64 -9
  207. package/src/oauth/anthropic-routing.ts +1202 -866
  208. package/src/oauth/anthropic-send-ownership.ts +41 -0
  209. package/src/oauth/anthropic.ts +4 -1
  210. package/src/oauth/chatgpt.ts +113 -41
  211. package/src/oauth/generic-account-failover.ts +3 -1
  212. package/src/oauth/health.ts +8 -6
  213. package/src/oauth/index.ts +96 -175
  214. package/src/oauth/kiro.ts +22 -9
  215. package/src/oauth/login-cli.ts +2 -0
  216. package/src/oauth/model-discovery-auth.ts +43 -0
  217. package/src/oauth/pool-kernel.ts +11 -5
  218. package/src/oauth/pool-settings-capability.ts +5 -3
  219. package/src/oauth/store-anthropic-instance.ts +87 -0
  220. package/src/oauth/store.ts +68 -10
  221. package/src/oauth/token-guardian.ts +2 -0
  222. package/src/oauth/types.ts +2 -0
  223. package/src/protocols/plan-snapshot.ts +5 -0
  224. package/src/protocols/settings.ts +20 -5
  225. package/src/providers/anthropic-instance-id.ts +47 -0
  226. package/src/providers/anthropic-instance.ts +48 -0
  227. package/src/providers/anthropic-reset-grant-ledger.ts +11 -1
  228. package/src/providers/azure-model-metadata.ts +115 -0
  229. package/src/providers/derive.ts +23 -0
  230. package/src/providers/kiro-models.ts +3 -0
  231. package/src/providers/label.ts +1 -1
  232. package/src/providers/model-rename-fields.ts +2 -0
  233. package/src/providers/quota/account-cache.ts +37 -11
  234. package/src/providers/quota/anthropic-account-quota.ts +93 -0
  235. package/src/providers/quota/anthropic-cooldown-recovery.ts +222 -111
  236. package/src/providers/quota/report-cache.ts +24 -1
  237. package/src/providers/quota/vendor-probes-key.ts +127 -17
  238. package/src/providers/quota/vendor-probes-oauth.ts +46 -14
  239. package/src/providers/quota.ts +23 -33
  240. package/src/providers/registry/entries-core.ts +35 -30
  241. package/src/providers/registry/model-ids.ts +1 -0
  242. package/src/providers/registry/model-seeds.ts +7 -4
  243. package/src/providers/registry/types.ts +3 -0
  244. package/src/providers/registry.ts +3 -3
  245. package/src/responses/reasoning-envelope.ts +29 -1
  246. package/src/responses/reasoning-replay-cache.ts +17 -1
  247. package/src/responses/spill-store.ts +20 -3
  248. package/src/responses/state/metrics.ts +95 -0
  249. package/src/responses/state/snapshot-codec.ts +6 -0
  250. package/src/responses/state/spill-failure.ts +12 -0
  251. package/src/responses/state/spill-queue.ts +13 -5
  252. package/src/responses/state/unforced-store-false.ts +55 -0
  253. package/src/responses/state.ts +68 -76
  254. package/src/responses/task-input.ts +17 -0
  255. package/src/router.ts +28 -4
  256. package/src/routing/compatibility/assemble.ts +1 -1
  257. package/src/routing/quota.ts +7 -3
  258. package/src/server/auth-cors.ts +5 -0
  259. package/src/server/background-lifecycle.ts +27 -0
  260. package/src/server/chat-completions.ts +7 -1
  261. package/src/server/chat-native.ts +64 -28
  262. package/src/server/claude-messages.ts +59 -8
  263. package/src/server/gui-static.ts +38 -4
  264. package/src/server/inbound-body-admission.ts +1 -0
  265. package/src/server/index/claude-intercept-lifecycle.ts +3 -1
  266. package/src/server/index/serve-options.ts +10 -2
  267. package/src/server/index/spend-ledger-lifecycle.ts +12 -5
  268. package/src/server/index/startup-warnings.ts +19 -0
  269. package/src/server/index.ts +5 -2
  270. package/src/server/inference/context.ts +1 -1
  271. package/src/server/lifecycle.ts +1 -1
  272. package/src/server/management/agent-settings-routes.ts +123 -38
  273. package/src/server/management/anthropic-account-threshold.ts +12 -9
  274. package/src/server/management/anthropic-pool-settings.ts +72 -0
  275. package/src/server/management/anthropic-reset-grant-routes.ts +98 -39
  276. package/src/server/management/codex-agent-role-routes.ts +4 -2
  277. package/src/server/management/codex-prompt-routes.ts +1 -0
  278. package/src/server/management/codex-role-auto-assign.ts +13 -2
  279. package/src/server/management/config-routes.ts +85 -14
  280. package/src/server/management/context.ts +2 -2
  281. package/src/server/management/decision-routes.ts +2 -1
  282. package/src/server/management/integration-routes.ts +25 -2
  283. package/src/server/management/oauth-account-routes.ts +79 -98
  284. package/src/server/management/provider-instance-ownership.ts +44 -0
  285. package/src/server/management/provider-routes.ts +21 -28
  286. package/src/server/management/route-registry.ts +3 -1
  287. package/src/server/management/routing-profile-routes.ts +4 -4
  288. package/src/server/management/shared.ts +3 -3
  289. package/src/server/management/system-restart.ts +44 -14
  290. package/src/server/management/system-routes.ts +24 -2
  291. package/src/server/management/vision-sidecar-options.ts +9 -6
  292. package/src/server/management/web-search-sidecar-options.ts +51 -2
  293. package/src/server/management-auth.ts +6 -0
  294. package/src/server/messages-native-eligibility.ts +3 -2
  295. package/src/server/messages-native-oauth.ts +91 -45
  296. package/src/server/messages-native-selector.ts +39 -0
  297. package/src/server/messages-native.ts +133 -38
  298. package/src/server/messages-response-headers.ts +14 -0
  299. package/src/server/request-log-conversation.ts +48 -5
  300. package/src/server/request-log.ts +36 -2
  301. package/src/server/responses/adapter-continuation.ts +31 -19
  302. package/src/server/responses/adapter-dispatch.ts +75 -24
  303. package/src/server/responses/antigravity-validation-refusal.ts +20 -6
  304. package/src/server/responses/codex-ws-exchange.ts +17 -2
  305. package/src/server/responses/codex-ws-pool.ts +100 -13
  306. package/src/server/responses/codex-ws-wire.ts +9 -0
  307. package/src/server/responses/combo-requested-effort.ts +3 -1
  308. package/src/server/responses/compact.ts +25 -8
  309. package/src/server/responses/compaction-recovery.ts +1 -1
  310. package/src/server/responses/core-codex-account.ts +22 -1
  311. package/src/server/responses/core-combo-native.ts +24 -4
  312. package/src/server/responses/core-combo.ts +38 -25
  313. package/src/server/responses/core-lifetime.ts +3 -36
  314. package/src/server/responses/core-normalize.ts +1 -0
  315. package/src/server/responses/core-options.ts +8 -1
  316. package/src/server/responses/core-replay.ts +78 -0
  317. package/src/server/responses/core.ts +3 -3
  318. package/src/server/responses/fetch-helpers.ts +26 -3
  319. package/src/server/responses/passthrough-delivery.ts +30 -4
  320. package/src/server/responses/passthrough-dispatch.ts +39 -9
  321. package/src/server/responses/request-prepare.ts +41 -5
  322. package/src/server/responses/request-send-budget.ts +150 -50
  323. package/src/server/responses/request-spend.ts +325 -33
  324. package/src/server/responses/request-transport.ts +132 -52
  325. package/src/server/responses/response-effects.ts +1 -0
  326. package/src/server/responses/run-turn-execution.ts +13 -3
  327. package/src/server/responses/sidecar-execution.ts +113 -22
  328. package/src/server/responses/sidecar-send-budget.ts +58 -0
  329. package/src/server/responses/ws-upstream.ts +19 -6
  330. package/src/server/responses-undeclared-tool-guard.ts +5 -1
  331. package/src/server/startup-health-cache.ts +2 -1
  332. package/src/server/workflow-refusal.ts +33 -0
  333. package/src/service/cli.ts +20 -4
  334. package/src/service/desktop-command-guard.ts +32 -0
  335. package/src/service/desktop-startup.ts +66 -40
  336. package/src/service/desktop-supervision.d.mts +26 -0
  337. package/src/service/desktop-supervision.mjs +140 -0
  338. package/src/service/managing-cli.ts +1 -0
  339. package/src/service/orchestration.ts +46 -20
  340. package/src/service/ownership-mutation-lease.d.mts +1 -1
  341. package/src/service/ownership-mutation-lease.mjs +5 -7
  342. package/src/service/repair.ts +25 -9
  343. package/src/service/state.ts +4 -3
  344. package/src/service/systemd.ts +17 -8
  345. package/src/service/windows-ops.ts +61 -22
  346. package/src/sidecar/anthropic-binding.ts +79 -0
  347. package/src/sidecar/auth.ts +76 -2
  348. package/src/storage/policy-job.ts +5 -1
  349. package/src/storage/policy-worker.ts +3 -1
  350. package/src/storage/policy.ts +9 -0
  351. package/src/types/anthropic-account-pool.ts +33 -0
  352. package/src/types/config.ts +33 -28
  353. package/src/types/provider.ts +5 -0
  354. package/src/types/request.ts +5 -0
  355. package/src/types/tools.ts +7 -0
  356. package/src/types/wire.ts +1 -0
  357. package/src/types.ts +4 -0
  358. package/src/update/index.ts +57 -9
  359. package/src/update/restart-ownership.ts +10 -3
  360. package/src/update/runtime-ownership.d.mts +6 -0
  361. package/src/update/runtime-ownership.mjs +23 -7
  362. package/src/usage/cost.ts +3 -2
  363. package/src/usage/expected-prices.ts +66 -14
  364. package/src/vision/anthropic-describe.ts +16 -3
  365. package/src/vision/index.ts +3 -0
  366. package/src/vision/plan.ts +19 -6
  367. package/src/web-search/alpha-search.ts +7 -1
  368. package/src/web-search/anthropic-executor.ts +16 -4
  369. package/src/web-search/index.ts +7 -3
  370. package/src/web-search/loop.ts +32 -7
  371. package/src/web-search/passthrough-bridge.ts +4 -1
  372. package/src/web-search/sidecar-providers.ts +4 -3
  373. package/gui/dist/assets/App-MbDVfUeC.js +0 -54
  374. package/gui/dist/assets/Tray-CzkxK82A.js +0 -1
  375. package/gui/dist/assets/index-UJt6npXk.js +0 -94
  376. package/gui/dist/assets/quota-summary-DU3sD6Z-.js +0 -1
@@ -2,12 +2,15 @@ import { realpathSync, statSync } from "node:fs";
2
2
  import { readClientConnectionState } from "../client/state";
3
3
  import { readConfigDiagnostics } from "../config/diagnostics";
4
4
  import { getConfigDir } from "../config/paths";
5
- import { currentCodexHome, resolveServiceOwnership, resolveServiceState } from "../service/state";
5
+ import { currentCodexHome } from "../service/state";
6
+ import { captureUpdateRestartServiceRecord, type UpdateRestartServiceRecord, type UpdateRestartServiceRecordDeps } from "./update-restart-service-record";
7
+ import { probeUpdateRestartSupervision, type UpdateRestartSupervisionDeps } from "./update-restart-supervision";
6
8
 
7
9
  export interface UpdateRestartHome {
8
10
  config: { path: string; dev: number; ino: number };
9
11
  codex: { path: string; dev: number; ino: number };
10
12
  revision: number;
13
+ serviceRecord: UpdateRestartServiceRecord;
11
14
  }
12
15
 
13
16
  function physicalDirectory(path: string): UpdateRestartHome["config"] {
@@ -17,17 +20,32 @@ function physicalDirectory(path: string): UpdateRestartHome["config"] {
17
20
  return { path: canonical, dev: stat.dev, ino: stat.ino };
18
21
  }
19
22
 
20
- /** Only an unclaimed standalone home is eligible for this narrow update path. */
21
- export function readUpdateRestartHome(): UpdateRestartHome {
22
- const state = resolveServiceState();
23
- const owner = resolveServiceOwnership();
24
- if (state.kind !== "none" || owner.kind !== "none") throw new Error("update_restart_owner_unverified");
25
- return { config: physicalDirectory(getConfigDir()), codex: physicalDirectory(currentCodexHome()), revision: owner.revision };
23
+ export interface UpdateRestartHomeDeps {
24
+ record?: UpdateRestartServiceRecordDeps;
25
+ supervision?: UpdateRestartSupervisionDeps;
26
26
  }
27
27
 
28
- export function assertUpdateRestartHome(expected: UpdateRestartHome): void {
29
- const current = readUpdateRestartHome();
30
- if (JSON.stringify(current) !== JSON.stringify(expected)) throw new Error("update_restart_home_changed");
28
+ /** Capture eligibility from the same bytes that supply the frozen fingerprint. */
29
+ export function readUpdateRestartHome(deps: UpdateRestartHomeDeps = {}): UpdateRestartHome {
30
+ const { serviceRecord, state, owner } = captureUpdateRestartServiceRecord(deps.record);
31
+ const platform = deps.record?.platform ?? process.platform;
32
+ if (owner.kind !== "none" || (state.kind === "state" && platform !== "darwin" && platform !== "linux")) {
33
+ throw new Error("update_restart_owner_unverified");
34
+ }
35
+ return { config: physicalDirectory(getConfigDir()), codex: physicalDirectory(currentCodexHome()), revision: owner.revision, serviceRecord };
36
+ }
37
+
38
+ export function assertUpdateRestartHome(expected: UpdateRestartHome, deadlineAt = Date.now() + 2000, deps: UpdateRestartHomeDeps = {}): void {
39
+ try {
40
+ const current = readUpdateRestartHome(deps);
41
+ if (JSON.stringify(current) !== JSON.stringify(expected)) throw new Error("changed");
42
+ } catch { throw new Error("update_restart_home_changed"); }
43
+ if (probeUpdateRestartSupervision(deadlineAt, deps.supervision) !== "inactive") throw new Error("update_restart_supervision_unverified");
44
+ // Manager commands may yield to an external writer even though this guard is synchronous.
45
+ try {
46
+ if (JSON.stringify(readUpdateRestartHome(deps)) !== JSON.stringify(expected)) throw new Error("changed");
47
+ } catch { throw new Error("update_restart_home_changed"); }
48
+ if ((deps.supervision?.now ?? Date.now)() >= deadlineAt) throw new Error("update_restart_deadline_expired");
31
49
  }
32
50
 
33
51
  /** Read-only eligibility shared by parent pre-stop and child admission. */
@@ -0,0 +1,116 @@
1
+ import { createHash } from "node:crypto";
2
+ import { closeSync, constants, fstatSync, lstatSync, openSync, readSync, realpathSync, type BigIntStats } from "node:fs";
3
+ import { basename, dirname, join } from "node:path";
4
+ import { plistPath, resolveServiceOwnership, resolveServiceState, serviceStatePaths, type ServiceStateEvidence } from "../service/state";
5
+ import { unitPath } from "../service/systemd";
6
+ import { inspectInstallStateBytes } from "../service/install-state-contract.mjs";
7
+
8
+ export interface UpdateRestartServiceRecord { schema: 1; digest: string }
9
+ export interface UpdateRestartServiceRecordDeps {
10
+ platform?: NodeJS.Platform;
11
+ paths?: () => readonly string[];
12
+ definitionPath?: () => string;
13
+ open?: (path: string, flags: number) => number;
14
+ fstat?: (fd: number) => BigIntStats;
15
+ read?: (fd: number, buffer: Buffer, offset: number, length: number, position: number) => number;
16
+ lstat?: (path: string) => BigIntStats;
17
+ }
18
+ /** Maximum bytes per state candidate or platform definition: 1 MiB, including growth during read. */
19
+ export const UPDATE_RESTART_SERVICE_RECORD_MAX_BYTES = 1024 * 1024;
20
+ const READ_CHUNK_BYTES = 64 * 1024;
21
+ const FAILURE = "update_restart_service_record_unverified";
22
+ const hash = (bytes: string | Buffer) => createHash("sha256").update(bytes).digest("hex");
23
+ const metadata = (stat: BigIntStats) => [stat.dev, stat.ino, stat.size, stat.mtimeNs, stat.ctimeNs, stat.mode, stat.uid, stat.gid].map(String);
24
+ const absent = (error: unknown) => !!error && typeof error === "object" && "code" in error && error.code === "ENOENT";
25
+
26
+ // Canonicalize directories, never the record itself: a record symlink is a refusal.
27
+ function canonicalPath(path: string): string {
28
+ const parent = dirname(path);
29
+ try { return join(realpathSync.native(parent), basename(path)); }
30
+ catch (error) {
31
+ if (!absent(error) || parent === path) throw new Error(FAILURE);
32
+ return join(canonicalPath(parent), basename(path));
33
+ }
34
+ }
35
+
36
+ function readBoundedRecord(fd: number, read: NonNullable<UpdateRestartServiceRecordDeps["read"]>): Buffer {
37
+ const chunk = Buffer.alloc(READ_CHUNK_BYTES);
38
+ const chunks: Buffer[] = [];
39
+ let total = 0;
40
+ for (;;) {
41
+ // At the limit, a one-byte read distinguishes EOF from concurrent growth.
42
+ const length = Math.min(chunk.length, UPDATE_RESTART_SERVICE_RECORD_MAX_BYTES - total + 1);
43
+ const count = read(fd, chunk, 0, length, total);
44
+ if (!Number.isInteger(count) || count < 0 || count > length) throw new Error(FAILURE);
45
+ if (count === 0) return Buffer.concat(chunks, total);
46
+ total += count;
47
+ if (total > UPDATE_RESTART_SERVICE_RECORD_MAX_BYTES) throw new Error(FAILURE);
48
+ chunks.push(Buffer.from(chunk.subarray(0, count)));
49
+ }
50
+ }
51
+
52
+ /** Read all authority/mirror candidates and the platform definition as one frozen record. */
53
+ export function captureUpdateRestartServiceRecord(deps: UpdateRestartServiceRecordDeps = {}) {
54
+ try {
55
+ const platform = deps.platform ?? process.platform;
56
+ const paths = (deps.paths ?? serviceStatePaths)();
57
+ const definition = platform === "darwin" ? (deps.definitionPath ?? plistPath)()
58
+ : platform === "linux" ? (deps.definitionPath ?? unitPath)() : undefined;
59
+ const statPath = deps.lstat ?? (path => lstatSync(path, { bigint: true }));
60
+ const captures = [...paths, ...(definition ? [definition] : [])].map((path, index) => {
61
+ const canonical = canonicalPath(path);
62
+ let before: BigIntStats;
63
+ try { before = statPath(path); }
64
+ catch (error) {
65
+ if (!absent(error)) throw error;
66
+ return { path, canonical, index, identity: null, bytes: undefined };
67
+ }
68
+ if (!before.isFile() || before.isSymbolicLink()) throw new Error(FAILURE);
69
+ const fd = (deps.open ?? openSync)(path, constants.O_RDONLY | (platform === "win32" ? 0 : constants.O_NOFOLLOW | constants.O_NONBLOCK));
70
+ try {
71
+ const statFd = deps.fstat ?? (handle => fstatSync(handle, { bigint: true }));
72
+ const opened = statFd(fd);
73
+ const identity = metadata(before);
74
+ if (!opened.isFile() || opened.dev !== before.dev || opened.ino !== before.ino
75
+ || opened.size < 0n || opened.size > BigInt(UPDATE_RESTART_SERVICE_RECORD_MAX_BYTES)
76
+ || JSON.stringify(identity) !== JSON.stringify(metadata(opened))) throw new Error(FAILURE);
77
+ const bytes = readBoundedRecord(fd, deps.read ?? readSync);
78
+ const after = statFd(fd);
79
+ if (!after.isFile() || JSON.stringify(identity) !== JSON.stringify(metadata(after))
80
+ || BigInt(bytes.length) !== after.size) throw new Error(FAILURE);
81
+ return { path, canonical, index, identity, bytes };
82
+ } finally { closeSync(fd); }
83
+ });
84
+ // Pin path-to-descriptor identity after every read; also confirm each ENOENT again.
85
+ for (const entry of captures) {
86
+ let current: BigIntStats;
87
+ try { current = statPath(entry.path); }
88
+ catch (error) { if (entry.identity === null && absent(error)) continue; throw error; }
89
+ if (!current.isFile() || JSON.stringify(metadata(current)) !== JSON.stringify(entry.identity)
90
+ || canonicalPath(entry.path) !== entry.canonical) throw new Error(FAILURE);
91
+ }
92
+ const evidence: ServiceStateEvidence[] = captures.slice(0, paths.length).map(entry => {
93
+ if (!entry.bytes) return { path: entry.canonical, kind: "absent" };
94
+ const parsed = inspectInstallStateBytes(entry.canonical, () => entry.bytes!.toString("utf8"));
95
+ if (parsed.kind !== "valid") throw new Error(FAILURE);
96
+ return parsed as ServiceStateEvidence;
97
+ });
98
+ const state = resolveServiceState(evidence);
99
+ const owner = resolveServiceOwnership(evidence);
100
+ if (state.kind === "unknown" || owner.kind === "unknown") throw new Error(FAILURE);
101
+ const serviceRecord: UpdateRestartServiceRecord = { schema: 1, digest: hash(JSON.stringify({
102
+ platform, authority: paths.length - 1,
103
+ records: captures.map(entry => [entry.canonical, entry.index, entry.identity, entry.bytes ? hash(entry.bytes) : null]),
104
+ })) };
105
+ return { serviceRecord, state, owner };
106
+ } catch { throw new Error(FAILURE); }
107
+ }
108
+
109
+ /** Drift is terminal; never replace the transaction's original digest. */
110
+ export function assertUpdateRestartServiceRecord(expected: UpdateRestartServiceRecord, deps: UpdateRestartServiceRecordDeps = {}) {
111
+ try {
112
+ const current = captureUpdateRestartServiceRecord(deps);
113
+ if (expected.schema !== 1 || current.serviceRecord.digest !== expected.digest) throw new Error(FAILURE);
114
+ return current;
115
+ } catch { throw new Error("update_restart_home_changed"); }
116
+ }
@@ -0,0 +1,96 @@
1
+ import { execFileSync } from "node:child_process";
2
+ import { statSync, type Stats } from "node:fs";
3
+ import { systemdUserBusEnvironment } from "../service/systemd";
4
+ import { assertLiveServiceManagerAllowed } from "../service/guards";
5
+ import { LABEL, TASK } from "../service/state";
6
+
7
+ export type UpdateRestartSupervision = "inactive" | "active" | "unknown";
8
+ export interface UpdateRestartSupervisorReply { status: number | null; stdout: string; stderr: string }
9
+ export interface UpdateRestartSupervisionDeps {
10
+ platform?: NodeJS.Platform;
11
+ now?: () => number;
12
+ uid?: number;
13
+ stat?: (path: string) => Pick<Stats, "isFile" | "mode">;
14
+ environment?: NodeJS.ProcessEnv;
15
+ exists?: (path: string) => boolean;
16
+ run?: (command: string, args: string[], timeoutMs: number, environment: NodeJS.ProcessEnv) => UpdateRestartSupervisorReply;
17
+ }
18
+ const PROBE_TIMEOUT_MS = 2000;
19
+ export const UPDATE_RESTART_SYSTEMD_ARGS = ["--user", "show", "-p", "LoadState", "-p", "ActiveState", "-p", "MainPID", "-p", "FragmentPath", "-p", "NeedDaemonReload", TASK];
20
+
21
+ function run(command: string, args: string[], timeout: number, env: NodeJS.ProcessEnv): UpdateRestartSupervisorReply {
22
+ assertLiveServiceManagerAllowed("update restart supervision");
23
+ try {
24
+ return { status: 0, stdout: execFileSync(command, args, { encoding: "utf8", timeout, env,
25
+ killSignal: "SIGKILL", maxBuffer: 64 * 1024, stdio: ["ignore", "pipe", "pipe"] }), stderr: "" };
26
+ } catch (error) {
27
+ const failure = error as { status?: number | null; signal?: string; code?: string; stdout?: Buffer; stderr?: Buffer };
28
+ if (failure.signal || failure.code || !Number.isInteger(failure.status)) throw new Error("update_restart_supervision_unverified");
29
+ return { status: failure.status!, stdout: failure.stdout?.toString() ?? "", stderr: failure.stderr?.toString() ?? "" };
30
+ }
31
+ }
32
+
33
+ /** Ignore PATH; usr-merged systemctl symlinks may resolve to a regular trusted-location target. */
34
+ export function resolveUpdateRestartSupervisor(deps: UpdateRestartSupervisionDeps = {}): string | null {
35
+ const platform = deps.platform ?? process.platform;
36
+ const paths = platform === "linux" ? ["/usr/bin/systemctl", "/bin/systemctl"] : platform === "darwin" ? ["/bin/launchctl"] : [];
37
+ for (const path of paths) {
38
+ try {
39
+ const stat = (deps.stat ?? statSync)(path);
40
+ if (stat.isFile() && (stat.mode & 0o002) === 0 && (stat.mode & 0o111) !== 0) return path;
41
+ } catch { /* Try the next trusted location; no PATH fallback. */ }
42
+ }
43
+ return null;
44
+ }
45
+
46
+ /** All manager evidence shares the transaction deadline, including the retained PID-bound probe. */
47
+ export function runBoundedUpdateRestartSupervisor(command: string, args: string[], deadlineAt: number, deps: UpdateRestartSupervisionDeps = {}) {
48
+ const now = deps.now ?? Date.now;
49
+ const remaining = deadlineAt - now();
50
+ if (!Number.isFinite(remaining) || remaining <= 0) throw new Error("update_restart_supervision_unverified");
51
+ const environment = (deps.platform ?? process.platform) === "linux"
52
+ ? systemdUserBusEnvironment(deps.environment ?? process.env, { uid: deps.uid, exists: deps.exists })
53
+ : { ...(deps.environment ?? process.env) };
54
+ const result = (deps.run ?? run)(command, args, Math.min(PROBE_TIMEOUT_MS, remaining), environment);
55
+ if (now() >= deadlineAt) throw new Error("update_restart_supervision_unverified");
56
+ return result;
57
+ }
58
+
59
+ /** Registration presence is independent of liveness; only positive inactivity admits. */
60
+ export function probeUpdateRestartSupervision(deadlineAt: number, deps: UpdateRestartSupervisionDeps = {}, command = resolveUpdateRestartSupervisor(deps)): UpdateRestartSupervision {
61
+ const platform = deps.platform ?? process.platform;
62
+ if (!command) return "unknown";
63
+ if (platform === "darwin") {
64
+ const uid = deps.uid ?? process.getuid?.() ?? 0;
65
+ const states = [`gui/${uid}/${LABEL}`, `user/${uid}/${LABEL}`].map(target => {
66
+ try {
67
+ const result = runBoundedUpdateRestartSupervisor(command, ["print", target], deadlineAt, deps);
68
+ return result.status === 112 || result.status === 113 ? "inactive" : result.status === 0 ? "active" : "unknown";
69
+ } catch { return "unknown"; }
70
+ });
71
+ return states.includes("unknown") ? "unknown" : states.includes("active") ? "active" : "inactive";
72
+ }
73
+ if (platform === "linux") {
74
+ try {
75
+ const result = runBoundedUpdateRestartSupervisor(command, UPDATE_RESTART_SYSTEMD_ARGS, deadlineAt, deps);
76
+ return classifyUpdateRestartSystemdSupervision(result);
77
+ } catch { return "unknown"; }
78
+ }
79
+ return "unknown";
80
+ }
81
+
82
+ /** The retained manager observation must satisfy the same positive-inactivity contract. */
83
+ export function classifyUpdateRestartSystemdSupervision(result: UpdateRestartSupervisorReply): UpdateRestartSupervision {
84
+ if (result.status !== 0) return "unknown";
85
+ const fields = new Map<string, string>();
86
+ for (const line of result.stdout.trim().split(/\r?\n/)) {
87
+ const match = /^([A-Za-z]+)=(.*)$/.exec(line);
88
+ if (!match || fields.has(match[1]!)) return "unknown";
89
+ fields.set(match[1]!, match[2]!);
90
+ }
91
+ const pid = fields.get("MainPID");
92
+ if (!pid || !/^\d+$/.test(pid) || !Number.isSafeInteger(Number(pid))) return "unknown";
93
+ if (!["loaded", "not-found"].includes(fields.get("LoadState") ?? "")) return "unknown";
94
+ if (fields.get("ActiveState") === "inactive" && pid === "0") return "inactive";
95
+ return fields.get("ActiveState") === "active" || Number(pid) > 0 ? "active" : "unknown";
96
+ }
@@ -7,21 +7,23 @@ import { isRealBunBinary } from "../lib/bun-binary-validator.mjs";
7
7
  import { isProcessAlive } from "../lib/process-control";
8
8
  import { parseStrictSemver } from "../lib/strict-semver";
9
9
  import { startArgv } from "../lib/self-launch-argv";
10
- import { diagnoseService } from "../service/diagnostics";
11
- import { inspectGuardedManagerTarget } from "../service/guarded-manager-target";
10
+ import { inspectGuardedManagerTarget, type GuardedManagerDeps } from "../service/guarded-manager-target";
12
11
  import { acquireOwnershipMutationLease, unprivilegedOwnershipMutationEnvironment } from "../service/ownership-mutation-lease.mjs";
13
12
  import { serviceStatePaths } from "../service/state";
14
13
  import { findLiveProxy, probeEndpointLiveness, probeHostname, type LiveProxy } from "../server/proxy-liveness";
15
14
  import { waitForPortAvailable } from "../server/ports";
16
15
  import { UPDATE_RESTART_CHILD_ENV, type UpdateRestartChildMarker } from "./update-restart-child";
17
- import { assertUpdateRestartConfiguration, assertUpdateRestartHome, readUpdateRestartHome, type UpdateRestartHome } from "./update-restart-home";
16
+ import { assertUpdateRestartConfiguration, assertUpdateRestartHome, readUpdateRestartHome, type UpdateRestartHome, type UpdateRestartHomeDeps } from "./update-restart-home";
18
17
  import { observeAttestedUpdateReplacement, stopAttestedUpdateTarget } from "./update-restart-transport";
19
18
  import type { UpdateRestartCandidate } from "./update-restart-candidate";
20
19
  import { INCOMPLETE_INSTALL_RECOVERY } from "./restart-failure";
21
20
  import { computeVersionSkew } from "./version-skew";
21
+ import { createSupervisionLatch, inspectDesktopSupervision } from "../service/desktop-supervision.mjs";
22
+ import { classifyUpdateRestartSystemdSupervision, probeUpdateRestartSupervision, resolveUpdateRestartSupervisor, runBoundedUpdateRestartSupervisor, UPDATE_RESTART_SYSTEMD_ARGS, type UpdateRestartSupervisionDeps } from "./update-restart-supervision";
22
23
 
23
24
  export interface UpdateRestartChild { pid?: number; exitCode: number | null; signalCode: string | null }
24
25
  export interface UpdateRestartIo {
26
+ inspectSupervision?: typeof inspectDesktopSupervision;
25
27
  now(): number;
26
28
  acquire(): { release(): void };
27
29
  home(): UpdateRestartHome;
@@ -37,7 +39,7 @@ export interface UpdateRestartIo {
37
39
  wait(ms: number): Promise<void>;
38
40
  }
39
41
  const ELIGIBILITY_REASONS = ["windows", "unsupported_platform", "foreground", "service", "shared_or_client",
40
- "package_tree_fenced", "unverifiable_ancestry", "target_changed", "configuration_changed"] as const;
42
+ "package_tree_fenced", "unverifiable_ancestry", "target_changed", "configuration_changed", "desktop"] as const;
41
43
  export type UpdateRestartEligibilityReason = (typeof ELIGIBILITY_REASONS)[number];
42
44
 
43
45
  /** Carries only a closed reason code across the eligibility/transport boundary. */
@@ -56,6 +58,7 @@ function sameRuntime(candidate: UpdateRestartCandidate, current: RuntimePortStat
56
58
 
57
59
  /** A terminal update transaction: it never enters generic restart recovery. */
58
60
  export async function runUpdateRestart(candidate: UpdateRestartCandidate, deadlineAt: number, io: UpdateRestartIo): Promise<UpdateRestartResult> {
61
+ const latch = createSupervisionLatch();
59
62
  let phase = "eligibility";
60
63
  let lease: { release(): void } | undefined;
61
64
  // Recorded here because the stop transport sanitizes anything thrown by beforeStop.
@@ -79,6 +82,13 @@ export async function runUpdateRestart(candidate: UpdateRestartCandidate, deadli
79
82
  eligibilityReason = currentRuntime?.siblingOfPort !== undefined ? "shared_or_client" : "target_changed";
80
83
  throw new Error("target");
81
84
  }
85
+ const supervision = (io.inspectSupervision ?? inspectDesktopSupervision)({ targetPid: candidate.target.pid });
86
+ const evidence = supervision.kind === "desktop" && supervision.runtimePid !== candidate.target.pid
87
+ ? { kind: "unknown" as const, reason: "unrelated-target", desktopSeen: false } : supervision;
88
+ if (latch.observe(evidence)) {
89
+ eligibilityReason = "desktop";
90
+ throw new UpdateRestartEligibilityError("desktop");
91
+ }
82
92
  try {
83
93
  if (!io.standalone(candidate.target)) throw new Error("target");
84
94
  } catch (error) {
@@ -147,6 +157,7 @@ export function describeUpdateRestartFailure(code: string, reason?: UpdateRestar
147
157
  switch (reason) {
148
158
  case "windows": return "Restart from a newer CLI is unavailable on Windows. Run `ocx status`, then restart through the owning service or desktop app.";
149
159
  case "unsupported_platform": return "Restart from a newer CLI requires macOS or Linux. Run `ocx status`, then use the owning lifecycle manager.";
160
+ case "desktop": return "OpenCodex Desktop supervises this proxy. Use the app's updater (tray → Check for Updates), or quit OpenCodex before updating and restarting from this CLI.";
150
161
  case "service": return "An installed or active service owns lifecycle control. Run `ocx status`, then use the owning installation's `ocx service restart`.";
151
162
  case "foreground": return "The proxy is attached to a parent process. Run `ocx status`; if it runs in a terminal, restart it in that terminal, otherwise use its owning app or supervisor.";
152
163
  case "shared_or_client": return "This is a shared or connected-client runtime. Run `ocx status` and restart through its owning Hub, service or desktop app.";
@@ -180,29 +191,64 @@ export function describeUpdateRestartFailure(code: string, reason?: UpdateRestar
180
191
  }
181
192
  }
182
193
 
183
- function standalone(target: UpdateRestartCandidate["target"]): boolean {
184
- try { assertUpdateRestartConfiguration(target.hostname ?? ""); }
194
+ export interface UpdateRestartStandaloneDeps {
195
+ platform?: NodeJS.Platform;
196
+ expectedHome?: UpdateRestartHome;
197
+ checkConfiguration?: typeof assertUpdateRestartConfiguration;
198
+ command?: typeof readProcessCommandLine;
199
+ parent?: (pid: number) => string;
200
+ home?: UpdateRestartHomeDeps;
201
+ supervision?: UpdateRestartSupervisionDeps;
202
+ manager?: GuardedManagerDeps;
203
+ }
204
+
205
+ export function standalone(target: UpdateRestartCandidate["target"], deadlineAt: number, deps: UpdateRestartStandaloneDeps = {}): boolean {
206
+ const platform = deps.platform ?? process.platform;
207
+ try { (deps.checkConfiguration ?? assertUpdateRestartConfiguration)(target.hostname ?? ""); }
185
208
  catch { throw new UpdateRestartEligibilityError("configuration_changed"); }
186
- if (process.platform !== "darwin" && process.platform !== "linux") {
187
- throw new UpdateRestartEligibilityError(process.platform === "win32" ? "windows" : "unsupported_platform");
209
+ if (platform !== "darwin" && platform !== "linux") {
210
+ throw new UpdateRestartEligibilityError(platform === "win32" ? "windows" : "unsupported_platform");
188
211
  }
189
212
  const host = probeHostname(target.hostname).replace(/^\[|\]$/g, "");
190
213
  if (target.role === "client") throw new UpdateRestartEligibilityError("shared_or_client");
191
214
  if (target.packageTreeFenced) throw new UpdateRestartEligibilityError("package_tree_fenced");
192
215
  if (!isIP(host) || target.source !== "runtime") throw new UpdateRestartEligibilityError("unverifiable_ancestry");
193
- const command = readProcessCommandLine(target.pid);
216
+ const command = (deps.command ?? readProcessCommandLine)(target.pid);
194
217
  if (!command || !isOcxStartCommandLine(command)) throw new UpdateRestartEligibilityError("unverifiable_ancestry");
195
- if (diagnoseService().installed) throw new UpdateRestartEligibilityError("service");
196
- const manager = inspectGuardedManagerTarget(target.pid, target.port);
218
+ let home: UpdateRestartHome;
219
+ try {
220
+ home = readUpdateRestartHome(deps.home);
221
+ if (deps.expectedHome && JSON.stringify(home) !== JSON.stringify(deps.expectedHome)) throw new Error("changed");
222
+ }
223
+ catch { throw new UpdateRestartEligibilityError("service"); }
224
+ const supervision = { ...deps.supervision, platform };
225
+ const managerCommand = resolveUpdateRestartSupervisor(supervision);
226
+ if (!managerCommand) throw new UpdateRestartEligibilityError("service");
227
+ if (probeUpdateRestartSupervision(deadlineAt, supervision, managerCommand) !== "inactive") throw new UpdateRestartEligibilityError("service");
228
+ const manager = inspectGuardedManagerTarget(target.pid, target.port, {
229
+ ...deps.manager, platform,
230
+ launchctl: args => {
231
+ const result = runBoundedUpdateRestartSupervisor(managerCommand, args, deadlineAt, supervision);
232
+ return { ...result, ok: result.status === 0 };
233
+ },
234
+ systemdShow: () => {
235
+ const result = runBoundedUpdateRestartSupervisor(managerCommand, UPDATE_RESTART_SYSTEMD_ARGS, deadlineAt, supervision);
236
+ if (classifyUpdateRestartSystemdSupervision(result) !== "inactive") throw new Error("update_restart_supervision_unverified");
237
+ return result.stdout;
238
+ },
239
+ });
197
240
  if (manager.kind !== "absent") throw new UpdateRestartEligibilityError(manager.kind === "bound" ? "service" : "unverifiable_ancestry");
198
241
  let parent: string;
199
242
  try {
200
- parent = execFileSync("ps", ["-o", "ppid=", "-p", String(target.pid)], {
243
+ parent = deps.parent ? deps.parent(target.pid) : execFileSync("ps", ["-o", "ppid=", "-p", String(target.pid)], {
201
244
  encoding: "utf8", timeout: 1000, stdio: ["ignore", "pipe", "ignore"],
202
245
  }).trim();
203
246
  } catch { throw new UpdateRestartEligibilityError("unverifiable_ancestry"); }
204
247
  if (!/^[1-9]\d*$/.test(parent)) throw new UpdateRestartEligibilityError("unverifiable_ancestry");
205
248
  if (parent !== "1") throw new UpdateRestartEligibilityError("foreground");
249
+ try {
250
+ if (JSON.stringify(readUpdateRestartHome(deps.home)) !== JSON.stringify(home)) throw new Error("changed");
251
+ } catch { throw new UpdateRestartEligibilityError("service"); }
206
252
  return true;
207
253
  }
208
254
 
@@ -213,7 +259,7 @@ export function restartFromCurrentInstallation(candidate: UpdateRestartCandidate
213
259
  return runUpdateRestart(candidate, deadlineAt, {
214
260
  now: Date.now,
215
261
  acquire: () => acquireOwnershipMutationLease(serviceStatePaths(), { waitMs: Math.max(0, Math.min(2000, deadlineAt - Date.now())) }),
216
- home: readUpdateRestartHome, checkHome: assertUpdateRestartHome, runtime: readRuntimePort, standalone,
262
+ home: readUpdateRestartHome, checkHome: home => assertUpdateRestartHome(home, deadlineAt), runtime: readRuntimePort, standalone: target => standalone(target, deadlineAt, { expectedHome: candidate.home }),
217
263
  runtimeReady: () => isRealBunBinary(executable),
218
264
  stop: async (target, deadline, beforeStop) => {
219
265
  const token = configuredAdminToken();
package/src/cli/v2.ts CHANGED
@@ -15,15 +15,14 @@ import { parseV2Command, V2_USAGE, type V2ParsedCommand } from "./v2-input";
15
15
  import { handleV2RuntimeCommand } from "./v2-runtime";
16
16
  import { localV2State, v2LocalSyncResult, type V2LocalSync } from "./v2-local-output";
17
17
  import { execFileSync } from "node:child_process";
18
- import { dirname } from "node:path";
19
- import { activeCodexConfigPath, getAgentsEnabled, getAgentsMaxDepth, getLogicalMaxThreads, getMultiAgentModeHintText, getSubagentDeveloperInstructions, hasAgentsMaxThreads, isMultiAgentV2Enabled, setMultiAgentModeHintText, transitionMultiAgentV2 } from "../codex/features";
18
+ import { getAgentsEnabled, getAgentsMaxDepth, getLogicalMaxThreads, getMultiAgentModeHintText, getSubagentDeveloperInstructions, hasAgentsMaxThreads, isMultiAgentV2Enabled, setMultiAgentModeHintText, transitionMultiAgentV2 } from "../codex/features";
20
19
 
21
20
  import { commandInvocation, type SpawnInvocation } from "../lib/win-exec";
22
21
  import { deleteConfigTopLevelKey, loadConfig, saveConfig } from "../config";
23
22
  import { resolveAndPersistCodexRuntime, type ResolveCodexRuntimeDeps } from "../codex/runtime";
24
23
 
25
24
  export interface V2CliDeps {
26
- execFile?: (file: string, args: string[], options?: SpawnInvocation["options"]) => void;
25
+ execFile?: (file: string, args: string[], options?: SpawnInvocation["options"] & { env?: NodeJS.ProcessEnv }) => void;
27
26
  isEnabled?: typeof isMultiAgentV2Enabled;
28
27
  hasMaxThreads?: typeof hasAgentsMaxThreads;
29
28
  sync?: (port?: number) => Promise<unknown>;
@@ -35,7 +34,8 @@ export interface V2CliDeps {
35
34
 
36
35
  export type CodexFeaturesInvocationDeps =
37
36
  & Parameters<typeof commandInvocation>[3]
38
- & Pick<ResolveCodexRuntimeDeps, "existsSync" | "execFileSync" | "configDir" | "readFileSync">;
37
+ & Pick<ResolveCodexRuntimeDeps, "existsSync" | "execFileSync" | "configDir" | "readFileSync">
38
+ & { requireAvailable?: boolean };
39
39
 
40
40
  /**
41
41
  * Shared invocation for `codex features enable|disable <feature>` — the single
@@ -51,14 +51,18 @@ export function codexFeaturesInvocation(
51
51
  platform: NodeJS.Platform = process.platform,
52
52
  deps: CodexFeaturesInvocationDeps = {},
53
53
  ): SpawnInvocation {
54
- const command = resolveAndPersistCodexRuntime({
54
+ const resolved = resolveAndPersistCodexRuntime({
55
55
  env: deps.env ?? process.env,
56
56
  platform,
57
57
  existsSync: deps.existsSync,
58
58
  execFileSync: deps.execFileSync,
59
59
  configDir: deps.configDir,
60
60
  readFileSync: deps.readFileSync,
61
- }).runtime.command || "codex";
61
+ });
62
+ if (deps.requireAvailable && resolved.runtime.source === "fallback" && resolved.runtime.version === null) {
63
+ throw Object.assign(new Error("spawn codex ENOENT"), { code: "ENOENT" });
64
+ }
65
+ const command = resolved.runtime.command || "codex";
62
66
  return commandInvocation(command, ["features", action, feature], platform, deps);
63
67
  }
64
68
 
@@ -70,27 +74,30 @@ export function codexFeaturesInvocation(
70
74
  */
71
75
  export function runCodexFeaturesCommand(
72
76
  action: "enable" | "disable",
73
- feature: string = "multi_agent_v2",
77
+ feature: string,
78
+ env: NodeJS.ProcessEnv,
79
+ validateBeforeSpawn: () => void,
80
+ preparedInvocation?: SpawnInvocation,
74
81
  ): void {
75
- const inv = codexFeaturesInvocation(action, feature);
82
+ const inv = preparedInvocation ?? codexFeaturesInvocation(action, feature, process.platform, { env });
83
+ validateBeforeSpawn();
76
84
  execFileSync(inv.file, inv.args,
77
85
  {
78
86
  stdio: ["ignore", "pipe", "pipe"], timeout: 15_000, windowsHide: true, encoding: "utf8",
79
- // The reader resolves $CODEX_HOME at call time (including the WSL Windows-home
80
- // detection); force the same home on the child so it never toggles a different
81
- // config than the one the postcondition re-reads.
82
- env: { ...process.env, CODEX_HOME: dirname(activeCodexConfigPath()) },
83
87
  ...inv.options,
88
+ env,
84
89
  });
85
90
  }
86
91
 
87
- function runCodexFeatures(action: "enable" | "disable", deps: V2CliDeps): void {
92
+ function runCodexFeatures(action: "enable" | "disable", deps: V2CliDeps, env: NodeJS.ProcessEnv, validateBeforeSpawn: () => void): void {
88
93
  if (deps.execFile) {
89
- const inv = (deps.featuresInvocation ?? codexFeaturesInvocation)(action);
90
- deps.execFile(inv.file, inv.args, inv.options);
94
+ const inv = deps.featuresInvocation ? deps.featuresInvocation(action)
95
+ : codexFeaturesInvocation(action, "multi_agent_v2", process.platform, { env });
96
+ validateBeforeSpawn();
97
+ deps.execFile(inv.file, inv.args, { ...inv.options, env });
91
98
  return;
92
99
  }
93
- runCodexFeaturesCommand(action);
100
+ runCodexFeaturesCommand(action, "multi_agent_v2", env, validateBeforeSpawn);
94
101
  }
95
102
 
96
103
  export function v2StatusLine(enabled: boolean): string {
@@ -182,7 +189,7 @@ export async function cmdV2(args: string[], deps: V2CliDeps = {}, findPort?: ()
182
189
  }
183
190
  if (parsed.verb === "threads") {
184
191
  const enabled = (deps.isEnabled ?? isMultiAgentV2Enabled)();
185
- const result = transitionMultiAgentV2(enabled, next => runCodexFeatures(next ? "enable" : "disable", deps), { threadLimit: parsed.value });
192
+ const result = transitionMultiAgentV2(enabled, (next, env, validate) => runCodexFeatures(next ? "enable" : "disable", deps, env, validate), { threadLimit: parsed.value });
186
193
  if (!result.ok) return emit(false, ["Unable to update the native thread limit. Read ocx v2 status before retrying."]);
187
194
  changed = result.changed;
188
195
  return emit(true, [changed ? `max_threads = ${parsed.value} (${enabled ? "v2" : "v1"}) — applies to new sessions.` : `max_threads already ${parsed.value} — nothing to do.`]);
@@ -194,7 +201,7 @@ export async function cmdV2(args: string[], deps: V2CliDeps = {}, findPort?: ()
194
201
  changed = (config.multiAgentMode ?? "default") !== parsed.value;
195
202
  if (parsed.value !== "default") {
196
203
  const target = parsed.value === "v2" && config.keepNativeChatGptOnV1 !== true;
197
- const transition = transitionMultiAgentV2(target, next => runCodexFeatures(next ? "enable" : "disable", deps));
204
+ const transition = transitionMultiAgentV2(target, (next, env, validate) => runCodexFeatures(next ? "enable" : "disable", deps, env, validate));
198
205
  if (!transition.ok) { changed = false; return emit(false, ["Native mode transition failed. Read ocx v2 status before retrying."]); }
199
206
  changed ||= transition.changed;
200
207
  }
@@ -207,7 +214,7 @@ export async function cmdV2(args: string[], deps: V2CliDeps = {}, findPort?: ()
207
214
  unchangedKeep = (config.keepNativeChatGptOnV1 === true) === parsed.value;
208
215
  changed = !unchangedKeep;
209
216
  if (parsed.value && requiresGlobalV2Disabled(config.multiAgentMode, true)) {
210
- const transition = transitionMultiAgentV2(false, next => runCodexFeatures(next ? "enable" : "disable", deps));
217
+ const transition = transitionMultiAgentV2(false, (next, env, validate) => runCodexFeatures(next ? "enable" : "disable", deps, env, validate));
211
218
  if (!transition.ok) { changed = false; return emit(false, ["Native hybrid-mode transition failed. Read ocx v2 status before retrying."]); }
212
219
  changed ||= transition.changed;
213
220
  }
@@ -222,7 +229,7 @@ export async function cmdV2(args: string[], deps: V2CliDeps = {}, findPort?: ()
222
229
  if (want && requiresGlobalV2Disabled(config.multiAgentMode, config.keepNativeChatGptOnV1 === true)) {
223
230
  return emit(false, ["v2 on: incompatible with keep-native-v1 while mode is v2 — Codex's global multi_agent_v2 overrides the native v1 catalog pin. Run 'ocx v2 keep-native-v1 off' first."]);
224
231
  }
225
- const transition = transitionMultiAgentV2(want, next => runCodexFeatures(next ? "enable" : "disable", deps));
232
+ const transition = transitionMultiAgentV2(want, (next, env, validate) => runCodexFeatures(next ? "enable" : "disable", deps, env, validate));
226
233
  if (!transition.ok) return emit(false, ["Native feature transition failed. Read ocx v2 status before retrying."]);
227
234
  changed = transition.changed;
228
235
  if (!changed) return emit(true, [`multi_agent_v2 already ${want ? "ON" : "OFF"} — nothing to do.`]);
@@ -0,0 +1,77 @@
1
+ import { readConfigDiagnostics } from "../config/diagnostics";
2
+ import { readRuntimePort } from "../config/process-state";
3
+ import { packageVersion } from "./help";
4
+ import { proxyIdentityAt } from "../server/proxy-liveness";
5
+ import { computeVersionSkew } from "./version-skew";
6
+
7
+ const LIFECYCLE = new Set(["start", "stop", "restart", "update", "service"]);
8
+ const NOTICE_DEADLINE_MS = 200;
9
+ let printed = false;
10
+
11
+ export function shouldNoticeVersionSkew(command: string | undefined, args: string[]): boolean {
12
+ const boundary = args.indexOf("--");
13
+ const prefix = boundary < 0 ? args : args.slice(0, boundary);
14
+ return LIFECYCLE.has(command ?? "")
15
+ && !prefix.some(arg => arg === "--json" || arg === "--help" || arg === "-h")
16
+ && prefix[1] !== "help";
17
+ }
18
+
19
+ export interface VersionSkewNoticeIo {
20
+ readRuntime?: typeof readRuntimePort;
21
+ readConfig?: typeof readConfigDiagnostics;
22
+ probe?: typeof proxyIdentityAt;
23
+ cliVersion?: () => string;
24
+ now?: () => number;
25
+ warn?: (message: string) => void;
26
+ }
27
+
28
+ /**
29
+ * One proxyIdentityAt (src/server/proxy-liveness.ts) call validates /healthz identity,
30
+ * expected PID and terminal-safe version within the total diagnostic budget.
31
+ */
32
+ export async function maybeNoticeVersionSkew(
33
+ command: string | undefined, args: string[], io: VersionSkewNoticeIo = {},
34
+ ): Promise<void> {
35
+ if (printed || !shouldNoticeVersionSkew(command, args)) return;
36
+ const now = io.now ?? Date.now;
37
+ const deadlineAt = now() + NOTICE_DEADLINE_MS;
38
+ let timer: ReturnType<typeof setTimeout> | undefined;
39
+ try {
40
+ const runtime = (io.readRuntime ?? readRuntimePort)();
41
+ let port: number;
42
+ let hostname: string | undefined;
43
+ let expectedPid: number | undefined;
44
+ if (runtime) {
45
+ ({ port, hostname } = runtime);
46
+ expectedPid = runtime.pid;
47
+ } else {
48
+ const diagnostics = (io.readConfig ?? readConfigDiagnostics)();
49
+ if (diagnostics.error) return;
50
+ port = diagnostics.config.port ?? 10100;
51
+ hostname = diagnostics.config.hostname;
52
+ }
53
+ const remaining = Math.floor(deadlineAt - now());
54
+ if (remaining <= 0) return;
55
+ const cliVersion = (io.cliVersion ?? packageVersion)();
56
+ if (now() >= deadlineAt) return;
57
+ const identity = await Promise.race([
58
+ (io.probe ?? proxyIdentityAt)(port, { hostname, expectedPid }, {
59
+ timeoutMs: Math.max(1, Math.floor(deadlineAt - now())),
60
+ attempts: 1, deadlineAt, nowFn: now,
61
+ }),
62
+ new Promise<null>(resolve => {
63
+ timer = setTimeout(() => resolve(null), Math.max(1, deadlineAt - now()));
64
+ }),
65
+ ]);
66
+ if (printed || !identity || now() >= deadlineAt) return;
67
+ const skew = computeVersionSkew(cliVersion, identity.version);
68
+ if (!skew.skewed) return;
69
+ printed = true;
70
+ (io.warn ?? console.error)(`ocx ${cliVersion} does not match the running proxy ${identity.version}. `
71
+ + "Check which opencodex installation you meant to use (`ocx status` shows both).");
72
+ } catch {
73
+ // Best-effort diagnostics must not fail a lifecycle command or change its exit code.
74
+ } finally {
75
+ if (timer !== undefined) clearTimeout(timer);
76
+ }
77
+ }