@iislee/opencodex 2.11.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 (476) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +225 -0
  3. package/assets/architecture.png +0 -0
  4. package/assets/banner.png +0 -0
  5. package/assets/claude-code-models.gif +0 -0
  6. package/assets/codex-app-picker.png +0 -0
  7. package/bin/ocx.mjs +451 -0
  8. package/bin/package-main.mjs +9 -0
  9. package/gui/dist/assets/index-DTpMHS4F.js +67 -0
  10. package/gui/dist/assets/index-ZNVDE3C7.css +1 -0
  11. package/gui/dist/favicon.png +0 -0
  12. package/gui/dist/icons.svg +24 -0
  13. package/gui/dist/index.html +25 -0
  14. package/gui/dist/logo.png +0 -0
  15. package/gui/dist/provider-icons/alibaba-color.svg +1 -0
  16. package/gui/dist/provider-icons/antigravity-color.svg +1 -0
  17. package/gui/dist/provider-icons/antigravity.svg +1 -0
  18. package/gui/dist/provider-icons/claude-color.svg +1 -0
  19. package/gui/dist/provider-icons/claude.svg +1 -0
  20. package/gui/dist/provider-icons/cloudflare-ai-gateway-color.svg +1 -0
  21. package/gui/dist/provider-icons/copilot-color.svg +1 -0
  22. package/gui/dist/provider-icons/copilot.svg +1 -0
  23. package/gui/dist/provider-icons/cursor-color.svg +2 -0
  24. package/gui/dist/provider-icons/cursor.svg +2 -0
  25. package/gui/dist/provider-icons/deepseek-color.svg +1 -0
  26. package/gui/dist/provider-icons/discord.svg +1 -0
  27. package/gui/dist/provider-icons/firepass-color.svg +1 -0
  28. package/gui/dist/provider-icons/fireworks-color.svg +1 -0
  29. package/gui/dist/provider-icons/gemini-color.svg +1 -0
  30. package/gui/dist/provider-icons/gemini.svg +1 -0
  31. package/gui/dist/provider-icons/github-copilot-color.svg +1 -0
  32. package/gui/dist/provider-icons/gitlab-duo-color.svg +1 -0
  33. package/gui/dist/provider-icons/grok-color.svg +1 -0
  34. package/gui/dist/provider-icons/grok.svg +1 -0
  35. package/gui/dist/provider-icons/groq-color.svg +1 -0
  36. package/gui/dist/provider-icons/huggingface-color.svg +1 -0
  37. package/gui/dist/provider-icons/kimi-color.svg +1 -0
  38. package/gui/dist/provider-icons/kiro-color.svg +15 -0
  39. package/gui/dist/provider-icons/kiro.svg +14 -0
  40. package/gui/dist/provider-icons/lm-studio-color.svg +1 -0
  41. package/gui/dist/provider-icons/mistral-color.svg +1 -0
  42. package/gui/dist/provider-icons/moonshot-color.svg +1 -0
  43. package/gui/dist/provider-icons/nvidia-color.svg +1 -0
  44. package/gui/dist/provider-icons/ollama-color.svg +1 -0
  45. package/gui/dist/provider-icons/openai.svg +1 -0
  46. package/gui/dist/provider-icons/opencode.svg +1 -0
  47. package/gui/dist/provider-icons/openrouter-color.svg +1 -0
  48. package/gui/dist/provider-icons/pi.svg +21 -0
  49. package/gui/dist/provider-icons/qianfan-color.svg +1 -0
  50. package/gui/dist/provider-icons/qwen-portal-color.svg +1 -0
  51. package/gui/dist/provider-icons/telegram.svg +1 -0
  52. package/gui/dist/provider-icons/vercel-ai-gateway-color.svg +1 -0
  53. package/gui/dist/provider-icons/vllm-color.svg +1 -0
  54. package/gui/dist/provider-icons/xiaomi-color.svg +1 -0
  55. package/package.json +102 -0
  56. package/src/AGENTS.md +28 -0
  57. package/src/adapters/anthropic-image-guard.ts +251 -0
  58. package/src/adapters/anthropic-image-normalize.ts +518 -0
  59. package/src/adapters/anthropic.ts +1003 -0
  60. package/src/adapters/azure.ts +36 -0
  61. package/src/adapters/base.ts +72 -0
  62. package/src/adapters/client-fingerprint.ts +59 -0
  63. package/src/adapters/cursor/arg-codec.ts +38 -0
  64. package/src/adapters/cursor/arg-normalize.ts +104 -0
  65. package/src/adapters/cursor/cursor-errors.ts +165 -0
  66. package/src/adapters/cursor/discovery.ts +276 -0
  67. package/src/adapters/cursor/effort-map.ts +127 -0
  68. package/src/adapters/cursor/exec-policy.ts +88 -0
  69. package/src/adapters/cursor/framing.ts +211 -0
  70. package/src/adapters/cursor/gen/agent_pb.ts +15274 -0
  71. package/src/adapters/cursor/kv-store.ts +52 -0
  72. package/src/adapters/cursor/live-models.ts +153 -0
  73. package/src/adapters/cursor/live-smoke-gate.ts +41 -0
  74. package/src/adapters/cursor/live-transport.ts +1214 -0
  75. package/src/adapters/cursor/mcp-config.ts +42 -0
  76. package/src/adapters/cursor/mcp-manager.ts +333 -0
  77. package/src/adapters/cursor/message-mapper.ts +49 -0
  78. package/src/adapters/cursor/native-exec-common.ts +55 -0
  79. package/src/adapters/cursor/native-exec-desktop.ts +184 -0
  80. package/src/adapters/cursor/native-exec-fs.ts +329 -0
  81. package/src/adapters/cursor/native-exec-mcp.ts +153 -0
  82. package/src/adapters/cursor/native-exec-network.ts +43 -0
  83. package/src/adapters/cursor/native-exec-shell.ts +548 -0
  84. package/src/adapters/cursor/native-exec-tools.ts +118 -0
  85. package/src/adapters/cursor/native-exec.ts +576 -0
  86. package/src/adapters/cursor/protobuf-events.ts +563 -0
  87. package/src/adapters/cursor/protobuf-request.ts +714 -0
  88. package/src/adapters/cursor/request-builder.ts +255 -0
  89. package/src/adapters/cursor/thread-continuity.ts +67 -0
  90. package/src/adapters/cursor/tool-definitions.ts +505 -0
  91. package/src/adapters/cursor/transport-retry.ts +132 -0
  92. package/src/adapters/cursor/transport.ts +57 -0
  93. package/src/adapters/cursor/types.ts +52 -0
  94. package/src/adapters/cursor.ts +196 -0
  95. package/src/adapters/google-antigravity-replay.ts +303 -0
  96. package/src/adapters/google-antigravity-wire.ts +108 -0
  97. package/src/adapters/google-errors.ts +85 -0
  98. package/src/adapters/google-http.ts +100 -0
  99. package/src/adapters/google-tool-schema.ts +173 -0
  100. package/src/adapters/google-truncation.ts +13 -0
  101. package/src/adapters/google-wire-compiler.ts +232 -0
  102. package/src/adapters/google.ts +758 -0
  103. package/src/adapters/identity.ts +44 -0
  104. package/src/adapters/image.ts +23 -0
  105. package/src/adapters/kiro-constants.ts +16 -0
  106. package/src/adapters/kiro-errors.ts +197 -0
  107. package/src/adapters/kiro-events.ts +179 -0
  108. package/src/adapters/kiro-images.ts +129 -0
  109. package/src/adapters/kiro-retry.ts +312 -0
  110. package/src/adapters/kiro-thinking.ts +96 -0
  111. package/src/adapters/kiro-tool-fallback.ts +36 -0
  112. package/src/adapters/kiro-tools.ts +215 -0
  113. package/src/adapters/kiro-truncation.ts +33 -0
  114. package/src/adapters/kiro-wire.ts +129 -0
  115. package/src/adapters/kiro.ts +1898 -0
  116. package/src/adapters/mimo-free.ts +263 -0
  117. package/src/adapters/openai-chat.ts +1005 -0
  118. package/src/adapters/openai-responses.ts +1137 -0
  119. package/src/adapters/run-turn-queue.ts +114 -0
  120. package/src/adapters/tool-catalog-nudge.ts +71 -0
  121. package/src/adapters/upstream-http-error.ts +48 -0
  122. package/src/bridge.ts +1619 -0
  123. package/src/chat/inbound.ts +295 -0
  124. package/src/chat/outbound.ts +765 -0
  125. package/src/claude/agents-inject.ts +243 -0
  126. package/src/claude/alias.ts +149 -0
  127. package/src/claude/auth-detect.ts +229 -0
  128. package/src/claude/auth-mode-migration.ts +32 -0
  129. package/src/claude/auth-mode.ts +62 -0
  130. package/src/claude/context-windows.ts +189 -0
  131. package/src/claude/desktop-3p-guard.ts +35 -0
  132. package/src/claude/desktop-3p-paths.ts +84 -0
  133. package/src/claude/desktop-3p.ts +381 -0
  134. package/src/claude/desktop-health.ts +26 -0
  135. package/src/claude/desktop-profile.ts +263 -0
  136. package/src/claude/gateway-cache.ts +70 -0
  137. package/src/claude/inbound-debug.ts +163 -0
  138. package/src/claude/inbound.ts +509 -0
  139. package/src/claude/model-info.ts +151 -0
  140. package/src/claude/outbound.ts +872 -0
  141. package/src/cli/access.ts +108 -0
  142. package/src/cli/account-api.ts +268 -0
  143. package/src/cli/account-auth.ts +223 -0
  144. package/src/cli/account-extended.ts +350 -0
  145. package/src/cli/account.ts +275 -0
  146. package/src/cli/agent-driven.ts +70 -0
  147. package/src/cli/agent.ts +184 -0
  148. package/src/cli/catalog-prewarm.ts +27 -0
  149. package/src/cli/claude-desktop.ts +188 -0
  150. package/src/cli/claude.ts +286 -0
  151. package/src/cli/codex-shim-autorestore.ts +45 -0
  152. package/src/cli/combo.ts +119 -0
  153. package/src/cli/config-command.ts +145 -0
  154. package/src/cli/debug.ts +228 -0
  155. package/src/cli/doctor.ts +930 -0
  156. package/src/cli/export-command.ts +187 -0
  157. package/src/cli/help.ts +354 -0
  158. package/src/cli/index.ts +1113 -0
  159. package/src/cli/init.ts +224 -0
  160. package/src/cli/integrations.ts +142 -0
  161. package/src/cli/interactive-confirm.ts +133 -0
  162. package/src/cli/internal-dispatch.ts +20 -0
  163. package/src/cli/models-runtime.ts +212 -0
  164. package/src/cli/models.ts +336 -0
  165. package/src/cli/observe.ts +117 -0
  166. package/src/cli/opencode.ts +586 -0
  167. package/src/cli/pi.ts +188 -0
  168. package/src/cli/provider-runtime.ts +162 -0
  169. package/src/cli/provider.ts +463 -0
  170. package/src/cli/runtime-api.ts +325 -0
  171. package/src/cli/star-prompt.ts +155 -0
  172. package/src/cli/status-oauth.ts +78 -0
  173. package/src/cli/status.ts +321 -0
  174. package/src/cli/sync-cloud.ts +283 -0
  175. package/src/cli/system-command.ts +112 -0
  176. package/src/cli/tray-proxy.ts +52 -0
  177. package/src/cli/v2.ts +173 -0
  178. package/src/cli.ts +10 -0
  179. package/src/clients/config-export.ts +377 -0
  180. package/src/clients/effective-status.ts +385 -0
  181. package/src/clients/probes/agy.ts +55 -0
  182. package/src/clients/probes/cc-switch.ts +110 -0
  183. package/src/clients/probes/claude.ts +90 -0
  184. package/src/clients/probes/codex.ts +125 -0
  185. package/src/clients/probes/grok.ts +29 -0
  186. package/src/clients/probes/opencode.ts +109 -0
  187. package/src/clients/probes/paseo.ts +55 -0
  188. package/src/clients/probes/pi.ts +55 -0
  189. package/src/cloud/onedrive-auth.ts +666 -0
  190. package/src/cloud/onedrive-graph.ts +108 -0
  191. package/src/cloud/settings.ts +75 -0
  192. package/src/cloud/sync.ts +212 -0
  193. package/src/cloud/types.ts +56 -0
  194. package/src/cloud/vault.ts +89 -0
  195. package/src/codex/account-id.ts +34 -0
  196. package/src/codex/account-label.ts +34 -0
  197. package/src/codex/account-lifecycle.ts +55 -0
  198. package/src/codex/account-namespace-match.ts +63 -0
  199. package/src/codex/account-namespaces.ts +149 -0
  200. package/src/codex/account-pause.ts +20 -0
  201. package/src/codex/account-runtime-state.ts +31 -0
  202. package/src/codex/account-store.ts +517 -0
  203. package/src/codex/account-usability.ts +20 -0
  204. package/src/codex/app-server-processes.ts +756 -0
  205. package/src/codex/auth-api.ts +1540 -0
  206. package/src/codex/auth-collision.ts +107 -0
  207. package/src/codex/auth-context.ts +352 -0
  208. package/src/codex/autostart-health.ts +149 -0
  209. package/src/codex/catalog/aggregation.ts +378 -0
  210. package/src/codex/catalog/bundled.ts +251 -0
  211. package/src/codex/catalog/effort.ts +355 -0
  212. package/src/codex/catalog/metadata.ts +180 -0
  213. package/src/codex/catalog/parsing.ts +456 -0
  214. package/src/codex/catalog/provider-fetch.ts +902 -0
  215. package/src/codex/catalog/sync.ts +620 -0
  216. package/src/codex/catalog.ts +12 -0
  217. package/src/codex/data/upstream-models.json +830 -0
  218. package/src/codex/exec-invocation.ts +22 -0
  219. package/src/codex/features.ts +969 -0
  220. package/src/codex/history-migration-guardian.ts +102 -0
  221. package/src/codex/history-provider.ts +776 -0
  222. package/src/codex/home.ts +206 -0
  223. package/src/codex/inject.ts +799 -0
  224. package/src/codex/injected-marker.ts +72 -0
  225. package/src/codex/journal.ts +163 -0
  226. package/src/codex/main-account-cache.ts +32 -0
  227. package/src/codex/main-account.ts +40 -0
  228. package/src/codex/model-cache.ts +227 -0
  229. package/src/codex/paths.ts +65 -0
  230. package/src/codex/plugins-doctor.ts +242 -0
  231. package/src/codex/pool-rotation.ts +225 -0
  232. package/src/codex/project-config-warnings.ts +411 -0
  233. package/src/codex/quota.ts +411 -0
  234. package/src/codex/refresh.ts +53 -0
  235. package/src/codex/routing.ts +1477 -0
  236. package/src/codex/runtime.ts +538 -0
  237. package/src/codex/shim.ts +1189 -0
  238. package/src/codex/subagent-defaults.ts +550 -0
  239. package/src/codex/subagent-model-fallback.ts +469 -0
  240. package/src/codex/sync.ts +130 -0
  241. package/src/codex/warmup.ts +192 -0
  242. package/src/codex/websocket-registry.ts +100 -0
  243. package/src/combos/failover.ts +140 -0
  244. package/src/combos/index.ts +41 -0
  245. package/src/combos/request.ts +62 -0
  246. package/src/combos/resolve.ts +232 -0
  247. package/src/combos/types.ts +326 -0
  248. package/src/config.ts +2356 -0
  249. package/src/generated/jawcode-model-metadata.ts +104 -0
  250. package/src/github/star-state.ts +203 -0
  251. package/src/grok/inject.ts +545 -0
  252. package/src/grok/status.ts +121 -0
  253. package/src/grok/sync.ts +103 -0
  254. package/src/grok/usage-hook/report.mjs +348 -0
  255. package/src/grok/usage-hook.ts +278 -0
  256. package/src/images/artifacts.ts +516 -0
  257. package/src/images/fulfill-video.ts +163 -0
  258. package/src/images/fulfill.ts +149 -0
  259. package/src/images/index.ts +4 -0
  260. package/src/images/loop.ts +829 -0
  261. package/src/images/plan.ts +133 -0
  262. package/src/images/synthetic-tool.ts +133 -0
  263. package/src/images/types.ts +41 -0
  264. package/src/images/xai-client.ts +141 -0
  265. package/src/images/xai-video-client.ts +163 -0
  266. package/src/index.ts +22 -0
  267. package/src/lib/abort.ts +146 -0
  268. package/src/lib/admin-secrets.ts +25 -0
  269. package/src/lib/admission.ts +83 -0
  270. package/src/lib/app-owned-memory-stores.ts +173 -0
  271. package/src/lib/app-owned-memory.ts +265 -0
  272. package/src/lib/bounded-body.ts +202 -0
  273. package/src/lib/bun-binary-validator.d.mts +3 -0
  274. package/src/lib/bun-binary-validator.mjs +18 -0
  275. package/src/lib/bun-runtime.ts +71 -0
  276. package/src/lib/bun-stream-caps.ts +126 -0
  277. package/src/lib/config-ownership.ts +360 -0
  278. package/src/lib/crash-guard.ts +344 -0
  279. package/src/lib/debug-log-buffer.ts +83 -0
  280. package/src/lib/debug-settings.ts +108 -0
  281. package/src/lib/debug.ts +31 -0
  282. package/src/lib/destination-policy.ts +316 -0
  283. package/src/lib/errors.ts +364 -0
  284. package/src/lib/eventstream-decoder.ts +253 -0
  285. package/src/lib/gcp-adc.ts +341 -0
  286. package/src/lib/injection-debug-log.ts +58 -0
  287. package/src/lib/open-url.ts +25 -0
  288. package/src/lib/pinned-http.ts +151 -0
  289. package/src/lib/privacy.ts +20 -0
  290. package/src/lib/process-control.ts +165 -0
  291. package/src/lib/provider-outbound.ts +170 -0
  292. package/src/lib/provider-url.ts +14 -0
  293. package/src/lib/proxy-env.ts +18 -0
  294. package/src/lib/redact.ts +105 -0
  295. package/src/lib/retry-after.ts +55 -0
  296. package/src/lib/service-secrets.ts +25 -0
  297. package/src/lib/shadow-call.ts +30 -0
  298. package/src/lib/sidecar-tracker.ts +52 -0
  299. package/src/lib/sse-decoder.ts +323 -0
  300. package/src/lib/state-store-registrations.ts +109 -0
  301. package/src/lib/state-store-sweeper.ts +184 -0
  302. package/src/lib/test-home-guard.ts +90 -0
  303. package/src/lib/token-estimate.ts +69 -0
  304. package/src/lib/translator-budget.ts +356 -0
  305. package/src/lib/upstream-retry.ts +239 -0
  306. package/src/lib/win-exec.ts +115 -0
  307. package/src/lib/win-paths.ts +68 -0
  308. package/src/lib/windows-elevation.ts +705 -0
  309. package/src/lib/windows-secret-acl.ts +514 -0
  310. package/src/lib/winsw.ts +375 -0
  311. package/src/oauth/anthropic-routing.ts +594 -0
  312. package/src/oauth/anthropic.ts +177 -0
  313. package/src/oauth/callback-server.ts +294 -0
  314. package/src/oauth/chatgpt.ts +150 -0
  315. package/src/oauth/cursor.ts +211 -0
  316. package/src/oauth/github-copilot.ts +428 -0
  317. package/src/oauth/google-antigravity.ts +230 -0
  318. package/src/oauth/health.ts +399 -0
  319. package/src/oauth/index.ts +1174 -0
  320. package/src/oauth/key-providers.ts +108 -0
  321. package/src/oauth/kimi.ts +213 -0
  322. package/src/oauth/kiro-credentials.ts +726 -0
  323. package/src/oauth/kiro.ts +577 -0
  324. package/src/oauth/local-token-detect.ts +121 -0
  325. package/src/oauth/log.ts +48 -0
  326. package/src/oauth/login-cli.ts +163 -0
  327. package/src/oauth/pkce.ts +15 -0
  328. package/src/oauth/store.ts +630 -0
  329. package/src/oauth/token-guardian.ts +303 -0
  330. package/src/oauth/types.ts +62 -0
  331. package/src/oauth/xai.ts +241 -0
  332. package/src/pi/extensions.ts +72 -0
  333. package/src/pi/home.ts +42 -0
  334. package/src/pi/index.ts +40 -0
  335. package/src/pi/models.ts +278 -0
  336. package/src/pi/packages.ts +219 -0
  337. package/src/pi/settings.ts +365 -0
  338. package/src/pi/status.ts +68 -0
  339. package/src/pi/sync.ts +75 -0
  340. package/src/providers/alibaba-region-backup.ts +75 -0
  341. package/src/providers/alibaba-region-migration.ts +156 -0
  342. package/src/providers/alibaba-region-startup.ts +36 -0
  343. package/src/providers/antigravity-models.ts +205 -0
  344. package/src/providers/api-keys.ts +140 -0
  345. package/src/providers/base-url-choices.ts +64 -0
  346. package/src/providers/context-cap.ts +65 -0
  347. package/src/providers/derive.ts +339 -0
  348. package/src/providers/free-directory.ts +184 -0
  349. package/src/providers/github-copilot-transport.ts +56 -0
  350. package/src/providers/key-failover.ts +203 -0
  351. package/src/providers/kiro-models.ts +67 -0
  352. package/src/providers/label.ts +19 -0
  353. package/src/providers/model-discovery.ts +356 -0
  354. package/src/providers/openai-sidecar.ts +175 -0
  355. package/src/providers/openai-tier-startup.ts +27 -0
  356. package/src/providers/openai-tiers.ts +301 -0
  357. package/src/providers/openai-virtual-models.ts +82 -0
  358. package/src/providers/openrouter-routing.ts +102 -0
  359. package/src/providers/provider-id-rewrite.ts +150 -0
  360. package/src/providers/quota.ts +1260 -0
  361. package/src/providers/registry.ts +1600 -0
  362. package/src/providers/slug-codec.ts +67 -0
  363. package/src/providers/xai-transport.ts +141 -0
  364. package/src/reasoning-effort.ts +135 -0
  365. package/src/responses/compaction.ts +117 -0
  366. package/src/responses/parser.ts +656 -0
  367. package/src/responses/reasoning-envelope.ts +52 -0
  368. package/src/responses/schema.ts +159 -0
  369. package/src/responses/spill-store.ts +394 -0
  370. package/src/responses/state.ts +895 -0
  371. package/src/responses/tool-groups.ts +19 -0
  372. package/src/router.ts +425 -0
  373. package/src/server/adapter-resolve.ts +80 -0
  374. package/src/server/auth-cors.ts +530 -0
  375. package/src/server/chat-completions.ts +368 -0
  376. package/src/server/claude-messages.ts +914 -0
  377. package/src/server/effort-policy.ts +172 -0
  378. package/src/server/gui-static.ts +123 -0
  379. package/src/server/image-retry.ts +42 -0
  380. package/src/server/images.ts +476 -0
  381. package/src/server/index.ts +1126 -0
  382. package/src/server/lifecycle.ts +227 -0
  383. package/src/server/live.ts +598 -0
  384. package/src/server/management/agent-settings-routes.ts +1169 -0
  385. package/src/server/management/api-access.ts +141 -0
  386. package/src/server/management/api-key-usage.ts +167 -0
  387. package/src/server/management/body.ts +35 -0
  388. package/src/server/management/clients-routes.ts +63 -0
  389. package/src/server/management/cloud-sync-routes.ts +266 -0
  390. package/src/server/management/combo-routes.ts +220 -0
  391. package/src/server/management/config-routes.ts +422 -0
  392. package/src/server/management/context.ts +31 -0
  393. package/src/server/management/logs-usage-routes.ts +707 -0
  394. package/src/server/management/model-routes.ts +525 -0
  395. package/src/server/management/oauth-account-routes.ts +563 -0
  396. package/src/server/management/provider-routes.ts +556 -0
  397. package/src/server/management/shared.ts +277 -0
  398. package/src/server/management/sidebar-routes.ts +90 -0
  399. package/src/server/management/system-restart.ts +179 -0
  400. package/src/server/management/system-routes.ts +117 -0
  401. package/src/server/management/usage-summary-cache.ts +86 -0
  402. package/src/server/management-api.ts +215 -0
  403. package/src/server/management-auth.ts +267 -0
  404. package/src/server/memory-watchdog.ts +156 -0
  405. package/src/server/port-reclaim.ts +307 -0
  406. package/src/server/ports.ts +116 -0
  407. package/src/server/proxy-liveness.ts +201 -0
  408. package/src/server/relay-eager.ts +313 -0
  409. package/src/server/relay.ts +1049 -0
  410. package/src/server/request-decompress.ts +132 -0
  411. package/src/server/request-log-conversation.ts +168 -0
  412. package/src/server/request-log.ts +1046 -0
  413. package/src/server/responses/collaboration.ts +354 -0
  414. package/src/server/responses/compact.ts +384 -0
  415. package/src/server/responses/core.ts +2758 -0
  416. package/src/server/responses/encrypted-payload.ts +308 -0
  417. package/src/server/responses/fetch-helpers.ts +157 -0
  418. package/src/server/responses/passthrough-error.ts +78 -0
  419. package/src/server/responses/terminal-guard.ts +230 -0
  420. package/src/server/responses/upstream-error.ts +48 -0
  421. package/src/server/responses-image-gen-repair.ts +132 -0
  422. package/src/server/responses-item-id-repair.ts +224 -0
  423. package/src/server/responses.ts +9 -0
  424. package/src/server/search.ts +136 -0
  425. package/src/server/sse-payload-rewrite.ts +175 -0
  426. package/src/server/startup-action-control.ts +308 -0
  427. package/src/server/startup-health-cache.ts +113 -0
  428. package/src/server/system-env.ts +413 -0
  429. package/src/server/windows-tcp-drop.ts +184 -0
  430. package/src/server/windows-tray-control.ts +41 -0
  431. package/src/server/ws-bridge.ts +471 -0
  432. package/src/service.ts +2554 -0
  433. package/src/stall-timeout.ts +20 -0
  434. package/src/storage/cleanup-job.ts +57 -0
  435. package/src/storage/cleanup.ts +3085 -0
  436. package/src/storage/policy-job.ts +457 -0
  437. package/src/storage/policy-scheduler.ts +40 -0
  438. package/src/storage/policy-worker.ts +59 -0
  439. package/src/storage/policy.ts +527 -0
  440. package/src/storage/restore-job.ts +299 -0
  441. package/src/storage/restore-worker.ts +58 -0
  442. package/src/storage/scanner.ts +238 -0
  443. package/src/storage/storage-mutation-coordinator.ts +139 -0
  444. package/src/storage/worker-lifecycle.ts +215 -0
  445. package/src/tray/assets/opencodex-tray-offline.ico +0 -0
  446. package/src/tray/assets/opencodex-tray-online.ico +0 -0
  447. package/src/tray/assets/opencodex-tray-warning.ico +0 -0
  448. package/src/tray/assets/opencodex-tray.png +0 -0
  449. package/src/tray/windows-tray.ps1 +290 -0
  450. package/src/tray/windows.ts +730 -0
  451. package/src/types.ts +1237 -0
  452. package/src/update/badge.ts +72 -0
  453. package/src/update/index.ts +407 -0
  454. package/src/update/job.ts +1520 -0
  455. package/src/update/notify.ts +257 -0
  456. package/src/update/npm-invocation.d.mts +23 -0
  457. package/src/update/npm-invocation.mjs +94 -0
  458. package/src/update/tray-update-plan.d.mts +18 -0
  459. package/src/update/tray-update-plan.mjs +38 -0
  460. package/src/usage/cost.ts +0 -0
  461. package/src/usage/debug.ts +97 -0
  462. package/src/usage/expected-prices.ts +164 -0
  463. package/src/usage/log.ts +658 -0
  464. package/src/usage/summary.ts +585 -0
  465. package/src/usage/totals.ts +14 -0
  466. package/src/vision/anthropic-describe.ts +185 -0
  467. package/src/vision/describe.ts +125 -0
  468. package/src/vision/index.ts +467 -0
  469. package/src/web-search/anthropic-executor.ts +189 -0
  470. package/src/web-search/executor.ts +105 -0
  471. package/src/web-search/format-result.ts +89 -0
  472. package/src/web-search/index.ts +196 -0
  473. package/src/web-search/loop.ts +664 -0
  474. package/src/web-search/parse.ts +220 -0
  475. package/src/web-search/progress-stream.ts +342 -0
  476. package/src/web-search/synthetic-tool.ts +47 -0
@@ -0,0 +1,1520 @@
1
+ import { spawn, spawnSync, type ChildProcess } from "node:child_process";
2
+ import { existsSync, mkdirSync, openSync, readFileSync, writeSync } from "node:fs";
3
+ import { dirname, join, sep } from "node:path";
4
+ import { fileURLToPath } from "node:url";
5
+ import {
6
+ atomicWriteFile,
7
+ getConfigDir,
8
+ loadConfig,
9
+ readPid,
10
+ readRuntimePort,
11
+ removePid,
12
+ removeRuntimePort,
13
+ verifyPidIdentity,
14
+ } from "../config";
15
+ import { isProcessAlive, killProxy } from "../lib/process-control";
16
+ import {
17
+ buildWindowsElevatedArgumentList,
18
+ resolveTrustedWindowsPowerShellExe,
19
+ } from "../lib/windows-elevation";
20
+ import { stopWinswService } from "../lib/winsw";
21
+ import { listListenPids, reclaimListenPort, scanListenPids, type ListenPidScan } from "../server/port-reclaim";
22
+ import { dropWindowsTcpRowsForLocalPort } from "../server/windows-tcp-drop";
23
+ import { isOpencodexHealthz, probeHostname, proxyIdentityAt, type HealthzIdentity } from "../server/proxy-liveness";
24
+ import { isServiceInstalled, isServiceViable, readServiceBackend, stopWindows } from "../service";
25
+ import {
26
+ type Channel,
27
+ type Installer,
28
+ PKG,
29
+ checkUpdatePackageIntegrity,
30
+ currentVersion,
31
+ defaultUpdateTag,
32
+ detectInstall,
33
+ latestVersion,
34
+ updateCommand,
35
+ updateCommandStr,
36
+ } from "./index";
37
+ import { isNewer } from "./notify";
38
+ import { isRealBunBinary } from "../lib/bun-binary-validator.mjs";
39
+ import { handoffWindowsTrayForUpdate, planWindowsTrayUpdate } from "./tray-update-plan.mjs";
40
+
41
+ const RELEASE_NOTES_URL = "https://github.com/lidge-jun/opencodex/releases/latest";
42
+ const UPDATE_JOB_FILENAME = "update-job.json";
43
+ const UPDATE_TIMEOUT_MS = 180_000;
44
+ const RESTART_TIMEOUT_MS = 60_000;
45
+ const RESTART_HEALTH_TIMEOUT_MS = 30_000;
46
+ const RESTART_STABILITY_WINDOW_MS = 15_000;
47
+ /** Legacy active records did not persist a worker PID, so age is their only safe recovery signal. */
48
+ export const UPDATE_JOB_LEGACY_STALE_MS = 10 * 60_000;
49
+ /** How long update restart waits for the captured port to become bindable after stop. */
50
+ export const RESTART_PORT_RECLAIM_MS = 30_000;
51
+
52
+ export type UpdateJobStatus = "running" | "restarting" | "succeeded" | "failed";
53
+
54
+ export interface UpdateCheckResult {
55
+ currentVersion: string;
56
+ latestVersion: string | null;
57
+ channel: Channel;
58
+ installer: Installer;
59
+ updateAvailable: boolean;
60
+ canUpdate: boolean;
61
+ command: string;
62
+ releaseNotesUrl: string;
63
+ reason?: string;
64
+ }
65
+
66
+ export interface UpdateJobState {
67
+ id: string;
68
+ status: UpdateJobStatus;
69
+ startedAt: string;
70
+ updatedAt: string;
71
+ currentVersion: string;
72
+ latestVersion: string | null;
73
+ channel: Channel;
74
+ installer: Installer;
75
+ restart: boolean;
76
+ command: string;
77
+ releaseNotesUrl: string;
78
+ log: string[];
79
+ pid?: number;
80
+ error?: string;
81
+ exitCode?: number | null;
82
+ signal?: string | null;
83
+ restarted?: boolean;
84
+ }
85
+
86
+ export class UpdateJobError extends Error {
87
+ constructor(message: string, readonly status = 400, readonly code = "update_error") {
88
+ super(message);
89
+ }
90
+ }
91
+
92
+ export interface UpdateCheckDeps {
93
+ currentVersion: () => string;
94
+ detectInstall: () => Installer;
95
+ latestVersion: (tag: Channel) => string | null;
96
+ }
97
+
98
+ interface UpdateWorkerProcess {
99
+ pid?: number;
100
+ unref(): void;
101
+ once(event: "error", listener: (error: Error) => void): unknown;
102
+ }
103
+
104
+ export interface StartUpdateJobDeps {
105
+ checkForUpdateFn: (channel: Channel) => UpdateCheckResult;
106
+ spawnWorkerFn: (jobId: string, channel: Channel, restart: boolean) => UpdateWorkerProcess;
107
+ isProcessAliveFn: (pid: number) => boolean;
108
+ nowMs: () => number;
109
+ }
110
+
111
+ const defaultCheckDeps: UpdateCheckDeps = {
112
+ currentVersion,
113
+ detectInstall,
114
+ latestVersion,
115
+ };
116
+
117
+ function nodeBin(): string {
118
+ return process.platform === "win32" ? "node.exe" : "node";
119
+ }
120
+
121
+ /**
122
+ * Strict bind script: exit 0 only after listen+close. Any listen error (including
123
+ * Windows ghost-TCB failures under Bun) is busy — matches published `ocx start`
124
+ * probes that treat every listen error as unavailable.
125
+ */
126
+ function strictBindProbeScript(port: number, hostname: string): string {
127
+ return [
128
+ "const net=require('net');",
129
+ "const s=net.createServer();",
130
+ "s.once('error',()=>process.exit(2));",
131
+ `s.listen(${Math.trunc(port)},${JSON.stringify(hostname)},()=>s.close(()=>process.exit(0)));`,
132
+ "setTimeout(()=>process.exit(3),2500);",
133
+ ].join("");
134
+ }
135
+
136
+ function spawnBindProbe(bin: string, script: string): boolean {
137
+ try {
138
+ const r = spawnSync(bin, ["-e", script], {
139
+ windowsHide: true,
140
+ timeout: 4000,
141
+ stdio: "ignore",
142
+ });
143
+ return r.status === 0;
144
+ } catch {
145
+ return false;
146
+ }
147
+ }
148
+
149
+ /**
150
+ * Live global package Bun — not the npm rename tree the update worker may still
151
+ * be executing from (`@scope/.opencodex-*`). Reject the tiny postinstall
152
+ * stub so probes fall back to the worker runtime instead of failing forever.
153
+ */
154
+ function livePackageBunPath(): string | null {
155
+ const launcher = packageLauncherPath();
156
+ const root = join(dirname(launcher), "..");
157
+ for (const name of ["bun.exe", "bun"]) {
158
+ const candidate = join(root, "node_modules", "bun", "bin", name);
159
+ if (isRealBunBinary(candidate)) return candidate;
160
+ }
161
+ return null;
162
+ }
163
+
164
+ /**
165
+ * Port is free for post-update `ocx start` only when the runtime that will
166
+ * actually execute the start can bind. Prefer live package Bun; fall back to the
167
+ * worker runtime. Do not require a separate `node` binary (Bun-only installs).
168
+ */
169
+ async function strictRuntimePortAvailable(port: number, hostname = "127.0.0.1"): Promise<boolean> {
170
+ const script = strictBindProbeScript(port, hostname);
171
+ const bun = livePackageBunPath();
172
+ if (bun) return spawnBindProbe(bun, script);
173
+ return spawnBindProbe(process.execPath, script);
174
+ }
175
+
176
+ /**
177
+ * Wait until netstat reports no LISTEN owners on `port` AND the start runtime
178
+ * can bind. Dead PIDs still appear as holders while the ghost TCB lives;
179
+ * SetTcpEntry is a no-op without elevation (rc 317), so wait them out.
180
+ */
181
+ async function waitForGhostListenClear(
182
+ port: number,
183
+ hostname: string,
184
+ listPids: (port: number) => number[],
185
+ timeoutMs: number,
186
+ sleep: (ms: number) => Promise<void>,
187
+ aliveFn: (pid: number) => boolean = isProcessAlive,
188
+ ): Promise<{ ok: boolean; accessDenied: boolean }> {
189
+ const deadline = Date.now() + timeoutMs;
190
+ let accessDenied = false;
191
+ while (Date.now() < deadline) {
192
+ const holders = listPids(port).filter(pid => pid !== process.pid);
193
+ const liveHolders = holders.filter(pid => aliveFn(pid));
194
+ // Never SetTcpEntry while a live process still owns the port (foreign or ocx).
195
+ if (process.platform === "win32" && liveHolders.length === 0) {
196
+ try {
197
+ const drop = dropWindowsTcpRowsForLocalPort(port);
198
+ if (drop.accessDenied > 0) accessDenied = true;
199
+ } catch { /* best-effort */ }
200
+ }
201
+ if (holders.length === 0 && await strictRuntimePortAvailable(port, hostname)) {
202
+ return { ok: true, accessDenied };
203
+ }
204
+ await sleep(500);
205
+ }
206
+ return { ok: false, accessDenied };
207
+ }
208
+
209
+ function packageLauncherPath(): string {
210
+ // This module lives at src/update/job.ts — the launcher is <pkg-root>/bin/ocx.mjs.
211
+ // After `npm install -g`, import.meta.url can still point at npm's renamed temp
212
+ // tree (`@scope/.opencodex-*`). Prefer the live package path when that happens.
213
+ const fromMeta = join(dirname(fileURLToPath(import.meta.url)), "..", "..", "bin", "ocx.mjs");
214
+ if (!/[\\/]\.opencodex-/i.test(fromMeta) && existsSync(fromMeta)) return fromMeta;
215
+ // Support both the upstream scope and this fork's publish scope.
216
+ for (const scope of ["iislee", "bitkyc08"]) {
217
+ const live = fromMeta.replace(
218
+ new RegExp(`[\\\\/]@${scope}[\\\\/]\\.opencodex-[^\\\\/]+`, "i"),
219
+ `${sep}@${scope}${sep}opencodex`,
220
+ );
221
+ if (live !== fromMeta && existsSync(live)) return live;
222
+ }
223
+ return fromMeta;
224
+ }
225
+
226
+ function formatCommand(bin: string, args: string[]): string {
227
+ return `${bin} ${args.join(" ")}`;
228
+ }
229
+
230
+ function manualSourceCommand(): string {
231
+ return "git pull && bun install && bun run build:gui";
232
+ }
233
+
234
+ export function normalizeUpdateChannel(raw: string | null | undefined, current = currentVersion()): Channel {
235
+ return raw === "latest" || raw === "preview" ? raw : defaultUpdateTag(current);
236
+ }
237
+
238
+ export function updateJobPath(): string {
239
+ return join(getConfigDir(), UPDATE_JOB_FILENAME);
240
+ }
241
+
242
+ function ensureJobDir(): void {
243
+ const dir = getConfigDir();
244
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true, mode: 0o700 });
245
+ }
246
+
247
+ function writeJob(job: UpdateJobState): void {
248
+ ensureJobDir();
249
+ atomicWriteFile(updateJobPath(), `${JSON.stringify(job, null, 2)}\n`);
250
+ }
251
+
252
+ export function readUpdateJob(jobId?: string | null): UpdateJobState | null {
253
+ try {
254
+ const parsed = JSON.parse(readFileSync(updateJobPath(), "utf8")) as UpdateJobState;
255
+ if (jobId && parsed.id !== jobId) return null;
256
+ if (!parsed || typeof parsed.id !== "string" || typeof parsed.status !== "string") return null;
257
+ return parsed;
258
+ } catch {
259
+ return null;
260
+ }
261
+ }
262
+
263
+ function updateJob(job: UpdateJobState, patch: Partial<UpdateJobState>, logLine?: string): UpdateJobState {
264
+ const current = readUpdateJob(job.id) ?? job;
265
+ const next = {
266
+ ...current,
267
+ ...patch,
268
+ updatedAt: new Date().toISOString(),
269
+ log: logLine ? [...current.log, logLine] : current.log,
270
+ };
271
+ writeJob(next);
272
+ return next;
273
+ }
274
+
275
+ export function updateExecutionCommand(
276
+ installer: Installer,
277
+ channel: Channel,
278
+ launcher = packageLauncherPath(),
279
+ resolvedVersion?: string | null,
280
+ ): { bin: string; args: string[]; display: string } {
281
+ if (installer === "npm") {
282
+ const bin = nodeBin();
283
+ const args = [launcher, "update", "--tag", channel];
284
+ // The Node launcher self-update re-resolves the tag at its own time — a residual
285
+ // divergence window this path cannot close (documented, not claimed immutable).
286
+ return { bin, args, display: formatCommand(bin, args) };
287
+ }
288
+ if (installer === "bun") {
289
+ const command = updateCommand(installer, channel, resolvedVersion);
290
+ const bin = process.platform === "win32" ? process.execPath : command.bin;
291
+ const { args } = command;
292
+ return { bin, args, display: updateCommandStr(installer, channel, resolvedVersion) };
293
+ }
294
+ return { bin: "sh", args: ["-lc", manualSourceCommand()], display: manualSourceCommand() };
295
+ }
296
+
297
+ export function restartCommand(
298
+ serviceInstalled: boolean,
299
+ installer: Installer,
300
+ launcher = packageLauncherPath(),
301
+ port?: number,
302
+ serviceArgs?: string[],
303
+ ): { mode: "service" | "proxy"; bin: string; args: string[]; display: string } {
304
+ const mode = serviceInstalled ? "service" : "proxy";
305
+ const pinPort = !serviceInstalled && typeof port === "number" && Number.isFinite(port) && port > 0;
306
+ const startArgs = pinPort
307
+ ? [launcher, "start", "--port", String(Math.trunc(port))]
308
+ : [launcher, "start"];
309
+ const svcArgs = serviceInstalled ? [launcher, ...(serviceArgs ?? ["service", "install"])] : startArgs;
310
+ if (installer === "npm") {
311
+ const bin = nodeBin();
312
+ const args = svcArgs;
313
+ return { mode, bin, args, display: formatCommand(bin, args) };
314
+ }
315
+ // bun/source installs: restart via the current runtime executable + package launcher (both real
316
+ // .exe files), NOT the `ocx.cmd` shim. Spawning a `.cmd` shell-less throws EINVAL on Windows
317
+ // Node/Bun ≥18.20/20.12 (CVE-2024-27980 hardening) — the same class the npm path (nodeBin) avoids.
318
+ const bin = process.execPath;
319
+ const args = svcArgs;
320
+ return { mode, bin, args, display: formatCommand(bin, args) };
321
+ }
322
+
323
+ export function checkForUpdate(
324
+ requestedChannel?: Channel,
325
+ deps: UpdateCheckDeps = defaultCheckDeps,
326
+ ): UpdateCheckResult {
327
+ const current = deps.currentVersion();
328
+ const installer = deps.detectInstall();
329
+ const channel = requestedChannel ?? normalizeUpdateChannel(null, current);
330
+ // Always resolve the registry version so the dashboard can show local vs remote,
331
+ // even for source checkouts (which stay manual-only for installation).
332
+ const latest = deps.latestVersion(channel);
333
+ const updateAvailable = !!latest && isNewer(latest, current, channel);
334
+ let reason: string | undefined;
335
+ let command = installer === "source" ? manualSourceCommand() : updateExecutionCommand(installer, channel).display;
336
+
337
+ if (installer === "source") {
338
+ reason = "source_checkout";
339
+ command = manualSourceCommand();
340
+ } else if (!latest) {
341
+ reason = "latest_unavailable";
342
+ } else if (!updateAvailable) {
343
+ reason = "already_latest";
344
+ }
345
+
346
+ return {
347
+ currentVersion: current,
348
+ latestVersion: latest,
349
+ channel,
350
+ installer,
351
+ updateAvailable,
352
+ canUpdate: installer !== "source" && updateAvailable,
353
+ command,
354
+ releaseNotesUrl: RELEASE_NOTES_URL,
355
+ ...(reason ? { reason } : {}),
356
+ };
357
+ }
358
+
359
+ function newJobId(): string {
360
+ return `${Date.now()}-${Math.random().toString(36).slice(2, 10)}`;
361
+ }
362
+
363
+ /**
364
+ * [Decision Log]
365
+ * - Purpose: recover dashboard updates after a detached worker dies without unlocking concurrent live updates.
366
+ * - Existing constraints: legacy records have no PID, while a healthy update may legitimately run for minutes.
367
+ * - Alternatives considered: clear every active record by age, or require operators to delete the file manually.
368
+ * - Chosen approach: trust PID liveness first and use a conservative age limit only for legacy no-PID records.
369
+ * - Why: age-only recovery can start two installers, while never recovering leaves the dashboard permanently blocked.
370
+ * - Impact: live PID records remain locked regardless of age; dead PIDs recover immediately; legacy records recover after ten minutes.
371
+ */
372
+ export function staleActiveUpdateJobReason(
373
+ job: Pick<UpdateJobState, "status" | "pid" | "updatedAt">,
374
+ now = Date.now(),
375
+ isAlive: (pid: number) => boolean = isProcessAlive,
376
+ ): string | null {
377
+ if (job.status !== "running" && job.status !== "restarting") return null;
378
+ if (typeof job.pid === "number" && Number.isSafeInteger(job.pid) && job.pid > 0) {
379
+ return isAlive(job.pid) ? null : `update worker PID ${job.pid} is no longer running`;
380
+ }
381
+ const updatedAt = Date.parse(job.updatedAt);
382
+ if (Number.isFinite(updatedAt) && now - updatedAt >= UPDATE_JOB_LEGACY_STALE_MS) {
383
+ return "legacy active update record has no worker PID and exceeded the stale window";
384
+ }
385
+ return null;
386
+ }
387
+
388
+ /**
389
+ * Spawn the GUI update worker without inheriting the proxy's LISTEN socket.
390
+ *
391
+ * On Windows, `spawn(..., { detached: true, stdio: "ignore" })` still inherits
392
+ * inheritable handles — including Bun.serve's LISTEN socket. After stop-first
393
+ * update kills the proxy PID, netstat keeps showing that dead PID as LISTENING
394
+ * until every inheriting child exits. The update worker was that child, so the
395
+ * port stayed busy for the whole job. Launch via PowerShell Start-Process so
396
+ * the worker is a fresh process tree with no inherited LISTEN handle.
397
+ */
398
+ export function spawnGuiUpdateWorker(
399
+ jobId: string,
400
+ channel: Channel,
401
+ restart: boolean,
402
+ ): UpdateWorkerProcess {
403
+ const script = process.argv[1];
404
+ const args = [
405
+ script,
406
+ "__gui-update-worker",
407
+ jobId,
408
+ channel,
409
+ restart ? "restart" : "no-restart",
410
+ ];
411
+ if (process.platform !== "win32") {
412
+ return spawn(process.execPath, args, {
413
+ detached: true,
414
+ stdio: "ignore",
415
+ windowsHide: true,
416
+ env: { ...process.env, OCX_SERVICE: "1" },
417
+ });
418
+ }
419
+
420
+ // Single -ArgumentList string with CommandLineToArgvW quoting so paths with
421
+ // spaces survive Start-Process's space-join (array elements lose outer quotes).
422
+ const psQuote = (value: string): string => `'${value.replace(/'/g, "''")}'`;
423
+ const argumentList = buildWindowsElevatedArgumentList(args);
424
+ const ps = [
425
+ `$env:OCX_SERVICE = '1'`,
426
+ `$p = Start-Process -FilePath ${psQuote(process.execPath)} -ArgumentList ${psQuote(argumentList)} -WindowStyle Hidden -PassThru`,
427
+ `if (-not $p) { exit 1 }`,
428
+ `Write-Output $p.Id`,
429
+ ].join("; ");
430
+ const launched = spawnSync(
431
+ resolveTrustedWindowsPowerShellExe(),
432
+ ["-NoProfile", "-NoLogo", "-NonInteractive", "-WindowStyle", "Hidden", "-Command", ps],
433
+ { encoding: "utf8", windowsHide: true, timeout: 15_000 },
434
+ );
435
+ const pid = Number(String(launched.stdout ?? "").trim().split(/\r?\n/).pop());
436
+ if (launched.status !== 0 || !Number.isSafeInteger(pid) || pid <= 0) {
437
+ const detail = String(launched.stderr ?? launched.stdout ?? "").trim() || `status ${launched.status}`;
438
+ throw new Error(`Windows update worker Start-Process failed: ${detail}`);
439
+ }
440
+ return {
441
+ pid,
442
+ unref() { /* Start-Process already detached */ },
443
+ once() { /* startup errors are not wired across Start-Process */ },
444
+ };
445
+ }
446
+
447
+ const defaultStartUpdateJobDeps: StartUpdateJobDeps = {
448
+ checkForUpdateFn: channel => checkForUpdate(channel),
449
+ spawnWorkerFn: spawnGuiUpdateWorker,
450
+ isProcessAliveFn: isProcessAlive,
451
+ nowMs: Date.now,
452
+ };
453
+
454
+ export function startUpdateJob(
455
+ channel: Channel,
456
+ restart: boolean,
457
+ deps: Partial<StartUpdateJobDeps> = {},
458
+ ): UpdateJobState {
459
+ const resolvedDeps = { ...defaultStartUpdateJobDeps, ...deps };
460
+ const running = readUpdateJob();
461
+ if (running?.status === "running" || running?.status === "restarting") {
462
+ const staleReason = staleActiveUpdateJobReason(
463
+ running,
464
+ resolvedDeps.nowMs(),
465
+ resolvedDeps.isProcessAliveFn,
466
+ );
467
+ if (!staleReason) {
468
+ throw new UpdateJobError("An update job is already running", 409, "update_already_running");
469
+ }
470
+ updateJob(
471
+ running,
472
+ { status: "failed", error: `Recovered stale update job: ${staleReason}.`, exitCode: null },
473
+ `Recovered stale update job: ${staleReason}.`,
474
+ );
475
+ }
476
+
477
+ const check = resolvedDeps.checkForUpdateFn(channel);
478
+ if (!check.canUpdate) {
479
+ throw new UpdateJobError(check.reason ?? "No update is available", 409, check.reason ?? "update_unavailable");
480
+ }
481
+
482
+ const id = newJobId();
483
+ const now = new Date(resolvedDeps.nowMs()).toISOString();
484
+ const job: UpdateJobState = {
485
+ id,
486
+ status: "running",
487
+ startedAt: now,
488
+ updatedAt: now,
489
+ currentVersion: check.currentVersion,
490
+ latestVersion: check.latestVersion,
491
+ channel: check.channel,
492
+ installer: check.installer,
493
+ restart,
494
+ command: check.command,
495
+ releaseNotesUrl: check.releaseNotesUrl,
496
+ log: [`Update job queued for ${check.currentVersion} -> ${check.latestVersion}.`],
497
+ };
498
+ writeJob(job);
499
+
500
+ let child: UpdateWorkerProcess;
501
+ try {
502
+ child = resolvedDeps.spawnWorkerFn(id, channel, restart);
503
+ } catch (error) {
504
+ const message = error instanceof Error ? error.message : String(error);
505
+ updateJob(job, { status: "failed", error: `Could not start update worker: ${message}` }, "Update worker failed to start.");
506
+ throw new UpdateJobError("Could not start update worker", 500, "update_worker_start_failed");
507
+ }
508
+ if (typeof child.pid !== "number" || !Number.isSafeInteger(child.pid) || child.pid <= 0) {
509
+ updateJob(job, { status: "failed", error: "Could not start update worker: no worker PID was returned." }, "Update worker failed to start.");
510
+ throw new UpdateJobError("Could not start update worker", 500, "update_worker_start_failed");
511
+ }
512
+ const startedJob = updateJob(job, { pid: child.pid }, `Update worker started as PID ${child.pid}.`);
513
+ child.once("error", error => {
514
+ const current = readUpdateJob(id);
515
+ if (!current || current.pid !== child.pid || (current.status !== "running" && current.status !== "restarting")) return;
516
+ updateJob(
517
+ current,
518
+ { status: "failed", error: `Update worker failed to start: ${error.message}` },
519
+ "Update worker emitted a startup error.",
520
+ );
521
+ });
522
+ child.unref();
523
+ return startedJob;
524
+ }
525
+
526
+ function runLoggedCommand(job: UpdateJobState, bin: string, args: string[], timeout: number): { status: number | null; signal: NodeJS.Signals | null } {
527
+ job = updateJob(job, {}, `$ ${formatCommand(bin, args)}`);
528
+ const result = spawnSync(bin, args, {
529
+ encoding: "utf8",
530
+ timeout,
531
+ windowsHide: true,
532
+ });
533
+ const stdout = typeof result.stdout === "string" ? result.stdout.trim() : "";
534
+ const stderr = typeof result.stderr === "string" ? result.stderr.trim() : "";
535
+ if (stdout) job = updateJob(job, {}, stdout.slice(-4000));
536
+ if (stderr) updateJob(job, {}, stderr.slice(-4000));
537
+ return { status: result.status, signal: result.signal };
538
+ }
539
+
540
+ /**
541
+ * Tear down anything that would make `ocx start` exit 1 with "already running"
542
+ * (service wrapper respawn, stale pidfile + live /healthz) before a pinned spawn.
543
+ */
544
+ function preparePortForPinnedStart(
545
+ job: UpdateJobState,
546
+ port: number,
547
+ listPids: (port: number) => number[],
548
+ aliveFn: (pid: number) => boolean,
549
+ verifyOcx: (pid: number) => number | null = verifyPidIdentity,
550
+ ): void {
551
+ stopWindowsServiceWrappersBestEffort();
552
+ const pid = readPid();
553
+ if (pid) {
554
+ updateJob(job, {}, `Clearing pre-start proxy PID ${pid} before pinned start.`);
555
+ try { killProxy(pid); } catch { /* best-effort */ }
556
+ removePid(pid);
557
+ } else {
558
+ removePid();
559
+ }
560
+ removeRuntimePort();
561
+ for (const holder of listPids(port)) {
562
+ if (holder === process.pid || !aliveFn(holder)) continue;
563
+ if (verifyOcx(holder) !== holder) {
564
+ updateJob(
565
+ job,
566
+ {},
567
+ `Leaving foreign listen holder PID ${holder} on port ${port}; refusing collateral kill.`,
568
+ );
569
+ continue;
570
+ }
571
+ updateJob(job, {}, `Stopping live ocx listen holder PID ${holder} on port ${port} before pinned start.`);
572
+ try { killProxy(holder); } catch { /* best-effort */ }
573
+ }
574
+ // Match reclaimListenPort: never SetTcpEntry while a live holder remains.
575
+ const liveRemain = listPids(port).filter(pid => pid !== process.pid && aliveFn(pid));
576
+ if (process.platform === "win32" && liveRemain.length === 0) {
577
+ try { dropWindowsTcpRowsForLocalPort(port); } catch { /* best-effort */ }
578
+ }
579
+ }
580
+
581
+ function spawnDetachedStart(
582
+ job: UpdateJobState,
583
+ installer: Installer,
584
+ port?: number,
585
+ ): ChildProcess {
586
+ const cmd = restartCommand(false, installer, packageLauncherPath(), port);
587
+ const env = { ...process.env };
588
+ delete env.OCX_SERVICE;
589
+ updateJob(job, {}, `$ ${cmd.display}`);
590
+ let stdio: "ignore" | [ "ignore", number, number ] = "ignore";
591
+ let logFd: number | undefined;
592
+ try {
593
+ const logPath = join(getConfigDir(), "update-pinned-start.log");
594
+ mkdirSync(getConfigDir(), { recursive: true });
595
+ logFd = openSync(logPath, "a");
596
+ writeSync(logFd, `\n--- ${new Date().toISOString()} ---\n$ ${cmd.display}\n`);
597
+ stdio = ["ignore", logFd, logFd];
598
+ } catch { /* fall back to ignored stdio */ }
599
+ const child = spawn(cmd.bin, cmd.args, {
600
+ detached: true,
601
+ stdio,
602
+ windowsHide: true,
603
+ env,
604
+ });
605
+ child.once("error", err => {
606
+ try {
607
+ updateJob(job, {}, `Pinned start spawn error: ${err instanceof Error ? err.message : String(err)}`);
608
+ } catch { /* best-effort */ }
609
+ });
610
+ // Foreground `ocx start` keeps the listen process; EADDRINUSE/ghost races exit quickly
611
+ // with stdio ignored — surface that so the job log explains a silent miss.
612
+ child.once("exit", (code, signal) => {
613
+ if (code === 0 && !signal) return;
614
+ try {
615
+ updateJob(
616
+ job,
617
+ {},
618
+ `Pinned start exited early (code=${code ?? "null"} signal=${signal ?? "null"}).`,
619
+ );
620
+ } catch { /* best-effort */ }
621
+ });
622
+ child.unref();
623
+ return child;
624
+ }
625
+
626
+ /** Identity snapshot used to prove an npm self-update actually replaced the pre-update process. */
627
+ export interface RestartProxyIdentity {
628
+ pid: number | null;
629
+ version?: string;
630
+ }
631
+
632
+ /** Test seam: the wait/spawn pair is injectable so the restart path is verifiable. */
633
+ export interface RestartIo {
634
+ waitForPort?: typeof reclaimListenPort;
635
+ spawnStart?: (job: UpdateJobState, installer: Installer, port?: number) => void;
636
+ serviceInstalledFn?: () => boolean;
637
+ /**
638
+ * After a service reinstall exits 0, only trust the service path when this is true.
639
+ * Defaults to {@link isServiceViable} — installed-but-stale assets must fall through
640
+ * to a direct proxy start so dashboard updates never leave /healthz dead.
641
+ */
642
+ serviceViableFn?: () => boolean;
643
+ probeProxy?: (port: number, hostname?: string) => Promise<boolean>;
644
+ /** Richer /healthz read for update-correlated restart evidence (pid + version). */
645
+ probeProxyIdentity?: (port: number, hostname?: string) => Promise<RestartProxyIdentity | null>;
646
+ /** Override the /healthz appearance window (default {@link RESTART_HEALTH_TIMEOUT_MS}). */
647
+ healthTimeoutMs?: number;
648
+ /**
649
+ * Override the window for deciding whether a service-managed restart actually
650
+ * served (default {@link SERVICE_RECOVERY_HEALTH_MS}). Distinct from
651
+ * {@link healthTimeoutMs}, which is the FINAL /healthz appearance window consumed
652
+ * by awaitRestartedProxyHealthy: this one only chooses whether to ALSO attempt a
653
+ * direct start, so coupling them would let a test tightening one silently retune
654
+ * the other.
655
+ */
656
+ serviceHealthTimeoutMs?: number;
657
+ sleepMs?: (ms: number) => Promise<void>;
658
+ now?: () => number;
659
+ /** Service-mode install/reinstall command (defaults to spawnSync via runLoggedCommand). */
660
+ runService?: (
661
+ job: UpdateJobState,
662
+ bin: string,
663
+ args: string[],
664
+ ) => { status: number | null; signal?: NodeJS.Signals | null };
665
+ /** Override the explicit restart path (used by finishGuiUpdateRestart tests). */
666
+ restartAfterUpdateFn?: (
667
+ job: UpdateJobState,
668
+ captured?: { port: number; hostname: string; oldPid?: number },
669
+ io?: RestartIo,
670
+ ) => Promise<void>;
671
+ /**
672
+ * PIDs currently LISTENing on the captured port. Used to widen the post-update
673
+ * kill allowlist beyond the pre-update PID (Windows often leaves a respawned
674
+ * ocx child that would otherwise be treated as a protected listener).
675
+ */
676
+ listListenPidsFn?: (port: number) => number[];
677
+ /**
678
+ * Full listen-PID scan (ok/fail). When omitted, {@link scanListenPids} is used
679
+ * so a probe failure is not mistaken for "no listeners".
680
+ */
681
+ scanListenPidsFn?: (port: number) => ListenPidScan;
682
+ /** Identity check for listeners discovered via {@link listListenPidsFn}. */
683
+ verifyOcxFn?: (pid: number) => number | null;
684
+ /** Liveness check when deciding whether a reclaim timeout still has live holders. */
685
+ isAliveFn?: (pid: number) => boolean;
686
+ }
687
+
688
+ /**
689
+ * Health window for deciding whether a service-managed restart served, before
690
+ * falling back to a direct start. Deliberately shorter than the final verdict
691
+ * window: being wrong here costs one extra start attempt; being wrong the other way
692
+ * leaves the user with no proxy at all.
693
+ *
694
+ * It runs AFTER the child's own 20s install probe (SERVICE_INSTALL_HEALTH_MS on
695
+ * macOS/Linux), so a reinstall that exits 0 but never serves spends up to 45s before
696
+ * the fallback — inside RESTART_TIMEOUT_MS of 60s. That is why this is 25s, not more.
697
+ */
698
+ export const SERVICE_RECOVERY_HEALTH_MS = 25_000;
699
+
700
+ /**
701
+ * Whether the reinstalled service actually produced a listener on the captured target.
702
+ *
703
+ * Not a duplicate of the child's own check: since WP2 the child asserts the port on
704
+ * macOS/Linux, but Windows still reports success from registration alone, a flapping
705
+ * supervisor can satisfy a single probe, and the child may be a CLI older than that
706
+ * change. `isServiceViable()` cannot see any of those — it reads registration state.
707
+ */
708
+ async function serviceRestartServed(
709
+ job: UpdateJobState,
710
+ port: number,
711
+ hostname: string,
712
+ io: RestartIo = {},
713
+ ): Promise<boolean> {
714
+ const probe = io.probeProxy ?? (async (p: number, h?: string) => (
715
+ !!(await proxyIdentityAt(p, { hostname: h }))
716
+ ));
717
+ const sleep = io.sleepMs ?? ((ms: number) => new Promise<void>(r => setTimeout(r, ms)));
718
+ const now = io.now ?? (() => Date.now());
719
+ const deadline = now() + (io.serviceHealthTimeoutMs ?? SERVICE_RECOVERY_HEALTH_MS);
720
+ for (;;) {
721
+ if (await probe(port, hostname)) {
722
+ updateJob(job, {}, `Service-managed proxy answered on ${hostname}:${port}.`);
723
+ return true;
724
+ }
725
+ if (now() >= deadline) return false;
726
+ await sleep(500);
727
+ }
728
+ }
729
+
730
+ async function restartAfterUpdate(
731
+ job: UpdateJobState,
732
+ captured?: { port: number; hostname: string; oldPid?: number },
733
+ io: RestartIo = {},
734
+ ): Promise<void> {
735
+ const serviceInstalled = (io.serviceInstalledFn ?? isServiceInstalled)();
736
+ const config = loadConfig();
737
+ // The stop-first update flow has already cleared pid/runtime state by the time we run,
738
+ // so the pre-update capture (taken before the update command) is the authoritative
739
+ // port to wait on; config is only the cold-start fallback.
740
+ const port = captured?.port ?? config.port ?? 10100;
741
+ const hostname = captured?.hostname ?? config.hostname ?? "127.0.0.1";
742
+ const oldPid = typeof captured?.oldPid === "number" && captured.oldPid > 0
743
+ ? captured.oldPid
744
+ : undefined;
745
+ let svcArgs: string[] | undefined;
746
+ if (serviceInstalled) {
747
+ try {
748
+ const { serviceReinstallArgs } = await import("../service");
749
+ svcArgs = serviceReinstallArgs();
750
+ } catch { /* fallback to default service install */ }
751
+ }
752
+ const cmd = restartCommand(serviceInstalled, job.installer, packageLauncherPath(), port, svcArgs);
753
+ const waitFn = io.waitForPort ?? reclaimListenPort;
754
+ const listPids = io.listListenPidsFn ?? listListenPids;
755
+ const verifyOcx = io.verifyOcxFn ?? verifyPidIdentity;
756
+ const aliveFn = io.isAliveFn ?? isProcessAlive;
757
+ // Pre-update PID plus any ocx still LISTENing on the captured port. After a
758
+ // stop-first npm self-update Windows often leaves a respawned bun/node child
759
+ // that is not the captured PID; treating it as protected blocks reclaim and
760
+ // the direct-start fallback never binds.
761
+ const reclaimKillAllowlist = (): number[] => {
762
+ const allow = new Set<number>();
763
+ if (oldPid != null) allow.add(oldPid);
764
+ for (const pid of listPids(port)) {
765
+ if (pid === process.pid) continue;
766
+ if (verifyOcx(pid) === pid) allow.add(pid);
767
+ }
768
+ return [...allow];
769
+ };
770
+ const reclaimOptsFor = (onlyKillPids: number[]) => ({
771
+ timeoutMs: RESTART_PORT_RECLAIM_MS,
772
+ intervalMs: 100,
773
+ scanIntervalMs: 500,
774
+ killOcxHolders: true,
775
+ // Windows scheduler wrappers can mint a *new* bun PID during the wait; keep
776
+ // killing every ocx listener on this port, not only the pre-wait snapshot.
777
+ // npm rename trees under `@scope/.opencodex-*` are classified as ocx by
778
+ // isOcxStartCommandLine — never kill unknown foreign claimants on this port.
779
+ killAllOcxOnPort: true,
780
+ onlyKillPids,
781
+ });
782
+
783
+ if (serviceInstalled) {
784
+ // schtasks /end often leaves the hidden cmd/wscript wrapper alive; its :loop
785
+ // respawns `ocx start` a few seconds later and races port reclaim. End the
786
+ // task again and best-effort kill those wrappers before we touch the socket.
787
+ stopWindowsServiceWrappersBestEffort();
788
+ // Stop-first update already unloaded the service; reclaim the socket, then
789
+ // reinstall wrappers that bake `--port`.
790
+ const preServiceAllow = reclaimKillAllowlist();
791
+ const freed = await waitFn(port, hostname, reclaimOptsFor(preServiceAllow));
792
+ let skipServiceInstall = false;
793
+ // Windows GUI update worker sets OCX_SERVICE=1 and is never elevated.
794
+ // `schtasks /create` will UAC-fail and can race the subsequent direct start.
795
+ // Keep systemd/launchd reinstall on non-Windows supervisors.
796
+ if (process.platform === "win32" && process.env.OCX_SERVICE === "1") {
797
+ updateJob(job, {}, "Skipping service reinstall from the non-elevated update worker; falling back to a direct proxy start.");
798
+ skipServiceInstall = true;
799
+ }
800
+ if (!freed && !skipServiceInstall) {
801
+ updateJob(
802
+ job,
803
+ {},
804
+ `Port ${port} still busy after ${Math.trunc(RESTART_PORT_RECLAIM_MS / 1000)}s; refusing to hop — reinstall may fail until the port is free.`
805
+ + ` ${formatPortHolders(port, listPids, verifyOcx, preServiceAllow)}`,
806
+ );
807
+ const liveAfter = listPids(port).filter(pid => pid !== process.pid && aliveFn(pid));
808
+ if (liveAfter.length === 0) {
809
+ // Non-elevated `service install` will UAC-fail anyway; skip straight to
810
+ // the direct-start fallthrough instead of burning another minute on it.
811
+ updateJob(job, {}, "Skipping service reinstall after reclaim timeout with no live holders; falling back to a direct proxy start.");
812
+ skipServiceInstall = true;
813
+ }
814
+ }
815
+ if (!skipServiceInstall) {
816
+ const prevBake = process.env.OCX_BAKE_PORT;
817
+ process.env.OCX_BAKE_PORT = String(Math.trunc(port));
818
+ let serviceOk = false;
819
+ try {
820
+ const run = io.runService ?? ((j, bin, args) => runLoggedCommand(j, bin, args, RESTART_TIMEOUT_MS));
821
+ const result = run(job, cmd.bin, cmd.args);
822
+ serviceOk = result.status === 0;
823
+ if (!serviceOk) {
824
+ // On Windows, `schtasks /create` requires an elevated token. The update worker
825
+ // inherits the (non-admin) proxy's privileges, so a service-managed install
826
+ // updated from the GUI or a normal terminal fails here with access denied.
827
+ // Falling back to a direct proxy start keeps the update from leaving the proxy
828
+ // stopped; the stale service manager can be refreshed later with an admin
829
+ // `ocx service install`.
830
+ //
831
+ // That advice is Windows-only, and on macOS/Linux it now actively misleads:
832
+ // `ocx service install` gained a non-zero exit for a service that registers
833
+ // but does not serve, so this branch fires there for a reason elevation
834
+ // cannot fix. Point at the command that prints the real reason instead.
835
+ updateJob(
836
+ job,
837
+ {},
838
+ `Service reinstall failed (exit ${result.status ?? "?"}); falling back to a direct proxy start.`
839
+ + (process.platform === "win32"
840
+ ? " Run 'ocx service install' as administrator to refresh the background service manager."
841
+ : " Run 'ocx service install' by hand to see the reason, then 'ocx service status'."),
842
+ );
843
+ }
844
+ } finally {
845
+ if (prevBake === undefined) delete process.env.OCX_BAKE_PORT;
846
+ else process.env.OCX_BAKE_PORT = prevBake;
847
+ }
848
+ if (serviceOk) {
849
+ // Exit 0 is not enough, and neither is `viable`. Registration state cannot
850
+ // distinguish a serving supervisor from one that registered and bound nothing:
851
+ // `launchctl list` reports both, and `schtasks` reports a task whose child
852
+ // exited immediately. Since WP2 the child asserts the port itself on
853
+ // macOS/Linux, but Windows still reports success from registration alone, a
854
+ // flapping supervisor can satisfy one probe, and the child may be an older CLI.
855
+ // Ask the port before skipping the fallback this branch exists to protect.
856
+ const viable = (io.serviceViableFn ?? isServiceViable)();
857
+ if (viable) {
858
+ if (await serviceRestartServed(job, port, hostname, io)) return;
859
+ updateJob(
860
+ job,
861
+ {},
862
+ `Service reinstall exited 0 and reported viable, but nothing answered on ${hostname}:${port} `
863
+ + `within ${Math.trunc((io.serviceHealthTimeoutMs ?? SERVICE_RECOVERY_HEALTH_MS) / 1000)}s; `
864
+ + "falling back to a direct proxy start.",
865
+ );
866
+ } else {
867
+ updateJob(
868
+ job,
869
+ {},
870
+ "Service reinstall exited 0 but the background service is not viable (stale or missing assets, disabled, or conflicting); falling back to a direct proxy start.",
871
+ );
872
+ }
873
+ }
874
+ }
875
+ // Fall through to the direct proxy start below so the update never leaves the
876
+ // proxy stopped when the service reinstall could not run or did not leave a
877
+ // viable supervisor.
878
+ }
879
+
880
+ const pid = readPid();
881
+ if (pid) {
882
+ updateJob(job, {}, `Stopping current proxy PID ${pid}.`);
883
+ try {
884
+ killProxy(pid);
885
+ } catch {
886
+ // A PID that resists taskkill must not abort recovery: reclaim + pinned start
887
+ // below are the path that repairs stuck Windows listeners.
888
+ }
889
+ }
890
+ if (serviceInstalled) stopWindowsServiceWrappersBestEffort();
891
+ // Reclaim the captured port before the pinned start. Spawning `--port` while the old
892
+ // socket is still busy is how Windows updates used to fail health checks (or hop).
893
+ // killAllOcxOnPort covers wrapper-respawned bun PIDs minted during the wait.
894
+ const directAllow = reclaimKillAllowlist();
895
+ const freed = await waitFn(port, hostname, reclaimOptsFor(directAllow));
896
+ if (!freed) {
897
+ const liveHolders = listPids(port).filter(pid => pid !== process.pid && aliveFn(pid));
898
+ updateJob(
899
+ job,
900
+ {},
901
+ `Port ${port} still busy after ${Math.trunc(RESTART_PORT_RECLAIM_MS / 1000)}s (reclaim could not free the socket).`
902
+ + ` ${formatPortHolders(port, listPids, verifyOcx, directAllow)}`,
903
+ );
904
+ if (liveHolders.length > 0) {
905
+ updateJob(job, {}, `Live holder(s) remain on port ${port}; not starting on another port. Retry 'ocx start --port ${port}'.`);
906
+ return;
907
+ }
908
+ // Dead PIDs can still own LISTEN rows. SetTcpEntry needs elevation (rc 317 on a
909
+ // normal update worker), so poll until netstat is empty and the start runtime can bind.
910
+ updateJob(
911
+ job,
912
+ {},
913
+ `No live holders on port ${port}; waiting for ghost LISTEN rows to clear before pinned start.`,
914
+ );
915
+ // Injected spawnStart is the unit-test seam — skip the long OS wait.
916
+ if (!io.spawnStart) {
917
+ const sleep = io.sleepMs ?? ((ms: number) => new Promise<void>(r => setTimeout(r, ms)));
918
+ const cleared = await waitForGhostListenClear(port, hostname, listPids, 90_000, sleep);
919
+ if (cleared.accessDenied) {
920
+ updateJob(job, {}, "SetTcpEntry is non-elevated (access denied); relying on OS ghost-LISTEN expiry.");
921
+ }
922
+ if (!cleared.ok) {
923
+ updateJob(
924
+ job,
925
+ {},
926
+ `Ghost LISTEN rows on port ${port} did not clear in time. `
927
+ + `${formatPortHolders(port, listPids, verifyOcx, directAllow)} `
928
+ + `Retry 'ocx start --port ${port}'.`,
929
+ );
930
+ return;
931
+ }
932
+ }
933
+ }
934
+ // Injected spawnStart keeps unit tests deterministic (one call). Production path
935
+ // retries on missing /healthz after prepare + ghost-LISTEN clear.
936
+ if (io.spawnStart) {
937
+ io.spawnStart(job, job.installer, port);
938
+ return;
939
+ }
940
+ const sleep = io.sleepMs ?? ((ms: number) => new Promise<void>(r => setTimeout(r, ms)));
941
+ const probe = io.probeProxy ?? (async (p: number, host?: string) => (
942
+ !!(await proxyIdentityAt(p, { hostname: host }))
943
+ ));
944
+ const probeIdentity = io.probeProxyIdentity ?? defaultProbeProxyIdentity;
945
+ const expectedVersion = typeof job.latestVersion === "string" && job.latestVersion.length > 0
946
+ ? job.latestVersion
947
+ : null;
948
+ // Service wrappers can respawn a listener during reclaim; if it already reports the
949
+ // update target version, do not spawn a second start that exits "already running".
950
+ {
951
+ const identity = await probeIdentity(port, hostname);
952
+ if (identity && expectedVersion && identity.version === expectedVersion) {
953
+ updateJob(
954
+ job,
955
+ {},
956
+ `Proxy already healthy on ${hostname}:${port} at ${expectedVersion}; skipping pinned start.`,
957
+ );
958
+ return;
959
+ }
960
+ }
961
+ const attempts = 3;
962
+ // Longer than published hard-pin reclaim (30s) so a slow start can still report healthy.
963
+ const perAttemptHealthMs = 70_000;
964
+ let lastChild: ChildProcess | null = null;
965
+ for (let attempt = 1; attempt <= attempts; attempt++) {
966
+ if (attempt > 1) {
967
+ updateJob(
968
+ job,
969
+ {},
970
+ `Pinned start attempt ${attempt - 1} did not become healthy on port ${port}; `
971
+ + `retrying (${attempt}/${attempts}).`,
972
+ );
973
+ if (lastChild?.pid && aliveFn(lastChild.pid)) {
974
+ try { killProxy(lastChild.pid); } catch { /* best-effort */ }
975
+ }
976
+ lastChild = null;
977
+ }
978
+ preparePortForPinnedStart(job, port, listPids, aliveFn, verifyOcx);
979
+ const ready = await waitForGhostListenClear(
980
+ port,
981
+ hostname,
982
+ listPids,
983
+ attempt === 1 ? (freed ? 15_000 : 5_000) : 30_000,
984
+ sleep,
985
+ );
986
+ if (!ready.ok) {
987
+ updateJob(
988
+ job,
989
+ {},
990
+ `Port ${port} not bindable before pinned start attempt ${attempt}; `
991
+ + `${formatPortHolders(port, listPids, verifyOcx, directAllow)}`,
992
+ );
993
+ continue;
994
+ }
995
+ lastChild = spawnDetachedStart(job, job.installer, port);
996
+ const healthDeadline = Date.now() + perAttemptHealthMs;
997
+ while (Date.now() < healthDeadline) {
998
+ if (await probe(port, hostname)) return;
999
+ await sleep(500);
1000
+ }
1001
+ }
1002
+ // Exhausted retries: do not leave a hung pinned-start child owning the port.
1003
+ if (lastChild?.pid && aliveFn(lastChild.pid)) {
1004
+ try { killProxy(lastChild.pid); } catch { /* best-effort */ }
1005
+ }
1006
+ }
1007
+
1008
+ /** Compact listen-holder summary for update-job logs when reclaim fails. */
1009
+ function formatPortHolders(
1010
+ port: number,
1011
+ listPids: (port: number) => number[],
1012
+ verifyOcx: (pid: number) => number | null,
1013
+ allow: number[],
1014
+ ): string {
1015
+ const allowSet = new Set(allow);
1016
+ const holders = listPids(port).map(pid => {
1017
+ const tags = [
1018
+ verifyOcx(pid) === pid ? "ocx" : "foreign",
1019
+ allowSet.has(pid) ? "allow" : "deny",
1020
+ isProcessAlive(pid) ? "live" : "dead",
1021
+ ];
1022
+ return `${pid}(${tags.join(",")})`;
1023
+ });
1024
+ return `holders=[${holders.join(", ") || "none"}] allow=[${allow.join(", ") || "none"}]`;
1025
+ }
1026
+
1027
+ /** Stop the installed Windows backend and best-effort kill surviving :loop wrappers. */
1028
+ function stopWindowsServiceWrappersBestEffort(): void {
1029
+ if (process.platform !== "win32") return;
1030
+ try {
1031
+ if (readServiceBackend() === "native") {
1032
+ stopWinswService();
1033
+ return;
1034
+ }
1035
+ stopWindows();
1036
+ } catch { /* already stopped */ }
1037
+ killWindowsServiceWrapperProcesses();
1038
+ }
1039
+
1040
+ /**
1041
+ * Best-effort termination of surviving Windows scheduler launcher/wrapper processes.
1042
+ * `schtasks /end` ends the task instance but often leaves wscript/cmd running the
1043
+ * `:loop` batch, which brings the proxy back during post-update reclaim.
1044
+ */
1045
+ function killWindowsServiceWrapperProcesses(): void {
1046
+ if (process.platform !== "win32") return;
1047
+ try {
1048
+ const ps = [
1049
+ "$pats = @('opencodex-service.cmd','opencodex-service-launcher.vbs');",
1050
+ "Get-CimInstance Win32_Process | Where-Object {",
1051
+ " if ($_.ProcessId -eq $PID) { return $false };",
1052
+ " $c = $_.CommandLine; if (-not $c) { return $false };",
1053
+ " foreach ($p in $pats) { if ($c -like ('*' + $p + '*')) { return $true } };",
1054
+ " $false",
1055
+ "} | ForEach-Object { Stop-Process -Id $_.ProcessId -Force -ErrorAction SilentlyContinue }",
1056
+ ].join(" ");
1057
+ spawnSync(resolveTrustedWindowsPowerShellExe(), [
1058
+ "-NoProfile", "-NoLogo", "-NonInteractive", "-WindowStyle", "Hidden",
1059
+ "-Command", ps,
1060
+ ], { stdio: "ignore", timeout: 5000, windowsHide: true });
1061
+ } catch {
1062
+ /* best-effort */
1063
+ }
1064
+ }
1065
+
1066
+ /** Exposed for tests: drives the non-service restart path with injected io. */
1067
+ export function restartAfterUpdateForTests(
1068
+ job: UpdateJobState,
1069
+ captured: { port: number; hostname: string; oldPid?: number },
1070
+ io: RestartIo,
1071
+ ): Promise<void> {
1072
+ return restartAfterUpdate(job, captured, io);
1073
+ }
1074
+
1075
+ function restartFailureHint(port: number): string {
1076
+ return `Update installed, but the restarted proxy did not stay healthy on port ${port}. `
1077
+ + `Try 'ocx start --port ${port}'. `
1078
+ + "If the update log shows bun postinstall or EPERM warnings, "
1079
+ + "reinstall with 'npm install -g --allow-scripts=bun @iislee/opencodex'.";
1080
+ }
1081
+
1082
+ type AwaitHealthyResult =
1083
+ | { ok: true }
1084
+ | { ok: false; reason: "timeout" | "flapped" };
1085
+
1086
+ /**
1087
+ * Wait for an identity-checked /healthz on the captured listen target, then require a short
1088
+ * stability window. Soft: never marks the job failed (callers decide whether to fail or retry).
1089
+ */
1090
+ async function awaitRestartedProxyHealthy(
1091
+ job: UpdateJobState,
1092
+ captured: { port: number; hostname: string },
1093
+ io: RestartIo = {},
1094
+ ): Promise<AwaitHealthyResult> {
1095
+ // Fresh post-update starts are busy with catalog sync / OAuth; a single 750ms
1096
+ // /healthz miss must not fail the job. Use a longer probe and tolerate brief blips.
1097
+ const probe = io.probeProxy ?? (async (port: number, hostname?: string) => (
1098
+ !!(await proxyIdentityAt(port, { hostname }, { timeoutMs: 2_000, attempts: 3 }))
1099
+ ));
1100
+ const sleep = io.sleepMs ?? (async (ms: number) => {
1101
+ await new Promise(resolve => setTimeout(resolve, ms));
1102
+ });
1103
+ const now = io.now ?? (() => Date.now());
1104
+ const port = captured.port;
1105
+ const hostname = captured.hostname;
1106
+ const startDeadline = now() + (io.healthTimeoutMs ?? RESTART_HEALTH_TIMEOUT_MS);
1107
+ /** Consecutive failed probes before the stability window counts as a flap. */
1108
+ const stabilityMissLimit = 3;
1109
+
1110
+ while (true) {
1111
+ // Always make one identity-aware probe at or after the boundary. A replacement
1112
+ // becoming healthy on the final tick must not be mistaken for a timeout.
1113
+ const finalProbe = now() >= startDeadline;
1114
+ if (await probe(port, hostname)) {
1115
+ updateJob(job, {}, `Proxy reported healthy on ${hostname}:${port}; confirming it stays up...`);
1116
+ const stableUntil = now() + RESTART_STABILITY_WINDOW_MS;
1117
+ let misses = 0;
1118
+ while (now() < stableUntil) {
1119
+ if (await probe(port, hostname)) {
1120
+ misses = 0;
1121
+ } else {
1122
+ misses += 1;
1123
+ if (misses >= stabilityMissLimit) {
1124
+ updateJob(job, {}, `Proxy became unhealthy on ${hostname}:${port} during the stability window.`);
1125
+ return { ok: false, reason: "flapped" };
1126
+ }
1127
+ }
1128
+ await sleep(500);
1129
+ }
1130
+ updateJob(job, {}, `Proxy stayed healthy for ${Math.trunc(RESTART_STABILITY_WINDOW_MS / 1000)}s after restart.`);
1131
+ return { ok: true };
1132
+ }
1133
+ if (finalProbe) break;
1134
+ await sleep(Math.min(250, Math.max(0, startDeadline - now())));
1135
+ }
1136
+
1137
+ return { ok: false, reason: "timeout" };
1138
+ }
1139
+
1140
+ /**
1141
+ * Confirm that the detached/service restart really came back and stayed up. The GUI worker
1142
+ * used to mark success immediately after spawning the new process, which hid Windows cases
1143
+ * where npm left the bundled Bun runtime half-updated and the restarted proxy died seconds
1144
+ * later. A healthy /healthz must appear, then remain healthy for one short stability window.
1145
+ */
1146
+ async function confirmRestartedProxy(
1147
+ job: UpdateJobState,
1148
+ captured: { port: number; hostname: string },
1149
+ io: RestartIo = {},
1150
+ ): Promise<boolean> {
1151
+ /* [Decision Log]
1152
+ - 목적과 의도: GUI update job이 detached restart 요청만 보고 성공 처리하지 않도록, 실제 프록시 복귀 여부를 확인한다.
1153
+ - 기존 구현 및 제약 조건: update-job.json은 spawn/service reinstall 직후 `succeeded`로 끝났고, Windows npm/Bun 교체 실패처럼 몇 초 후 죽는 재시작을 잡지 못했다.
1154
+ - 검토한 주요 대안: (1) 포트 점유만 확인 — 외부 프로세스/죽기 직전 프로세스를 성공으로 오인할 수 있다. (2) 무기한 /healthz 폴링 — UX가 느려지고 worker 종료 시점이 불명확하다. (3) 짧은 healthy 등장 + 안정성 창 확인 — 실제 복귀를 확인하면서도 대기 시간을 제한할 수 있다.
1155
+ - 선택한 방식: identity-aware /healthz probe가 일정 시간 안에 나타나고, 추가 안정성 창 동안 유지되는지 확인한다.
1156
+ - 다른 대안 대신 이 방식을 선택한 이유: GUI는 "업데이트가 설치됐지만 재시작은 실패"를 분리해 알려줘야 하며, 이 방식이 가장 적은 오탐으로 그 경계를 만든다.
1157
+ - 장점, 단점 및 영향: 장점은 silent restart failure가 update-job 상태로 드러난다는 점이다. 단점은 설정상 성공 판정 창이 30초 도착 + 15초 안정성으로 늘어나고 경계 probe 지연이 추가될 수 있다는 점이며, 대신 실제 복귀를 더 정확히 반영한다.
1158
+ */
1159
+ const result = await awaitRestartedProxyHealthy(job, captured, io);
1160
+ if (result.ok) return true;
1161
+ const port = captured.port;
1162
+ const hostname = captured.hostname;
1163
+ const error = result.reason === "flapped"
1164
+ ? `proxy restart became unhealthy on ${hostname}:${port}`
1165
+ : `proxy restart never became healthy on ${hostname}:${port}`;
1166
+ updateJob(job, {
1167
+ status: "failed",
1168
+ restarted: false,
1169
+ error,
1170
+ }, restartFailureHint(port));
1171
+ return false;
1172
+ }
1173
+
1174
+ export function confirmRestartAfterUpdateForTests(
1175
+ job: UpdateJobState,
1176
+ captured: { port: number; hostname: string },
1177
+ io: RestartIo,
1178
+ ): Promise<boolean> {
1179
+ return confirmRestartedProxy(job, captured, io);
1180
+ }
1181
+
1182
+ async function defaultProbeProxyIdentity(
1183
+ port: number,
1184
+ hostname?: string,
1185
+ ): Promise<RestartProxyIdentity | null> {
1186
+ try {
1187
+ const res = await fetch(`http://${probeHostname(hostname)}:${port}/healthz`, {
1188
+ signal: AbortSignal.timeout(750),
1189
+ });
1190
+ if (!res.ok) return null;
1191
+ const body = (await res.json().catch(() => null)) as HealthzIdentity | null;
1192
+ if (!isOpencodexHealthz(body)) return null;
1193
+ return {
1194
+ pid: typeof body?.pid === "number" ? body.pid : null,
1195
+ ...(typeof body?.version === "string" ? { version: body.version } : {}),
1196
+ };
1197
+ } catch {
1198
+ return null;
1199
+ }
1200
+ }
1201
+
1202
+ /**
1203
+ * Health alone is not enough to skip the GUI worker restart: a surviving pre-update
1204
+ * process is still identity-healthy. Require update-correlated evidence — a new PID
1205
+ * when the pre-update PID was captured, and/or /healthz reporting the job's target
1206
+ * version when PID evidence is unavailable.
1207
+ */
1208
+ export function npmSelfUpdateRestartEvidence(
1209
+ job: Pick<UpdateJobState, "latestVersion">,
1210
+ captured: { oldPid?: number },
1211
+ identity: RestartProxyIdentity | null,
1212
+ ): { ok: true; detail: string } | { ok: false; reason: string } {
1213
+ if (!identity) return { ok: false, reason: "could not read proxy identity" };
1214
+
1215
+ const oldPid = typeof captured.oldPid === "number" && captured.oldPid > 0
1216
+ ? captured.oldPid
1217
+ : undefined;
1218
+ const livePid = typeof identity.pid === "number" && identity.pid > 0 ? identity.pid : null;
1219
+ const expected = typeof job.latestVersion === "string" && job.latestVersion.length > 0
1220
+ ? job.latestVersion
1221
+ : null;
1222
+ const versionMatches = expected !== null && identity.version === expected;
1223
+
1224
+ if (oldPid !== undefined) {
1225
+ if (livePid === oldPid) {
1226
+ return { ok: false, reason: "still the pre-update PID" };
1227
+ }
1228
+ if (livePid !== null) {
1229
+ if (expected !== null && identity.version && identity.version !== expected) {
1230
+ return { ok: false, reason: `new pid but version ${identity.version} !== expected ${expected}` };
1231
+ }
1232
+ return { ok: true, detail: `pid changed ${oldPid}→${livePid}` };
1233
+ }
1234
+ // Pre-update PID known but healthz omitted pid — only accept matching target version.
1235
+ if (versionMatches) return { ok: true, detail: `version ${identity.version}` };
1236
+ return { ok: false, reason: "no PID in healthz and version did not match the update target" };
1237
+ }
1238
+
1239
+ if (versionMatches) return { ok: true, detail: `version ${identity.version}` };
1240
+ if (expected !== null && identity.version && identity.version !== expected) {
1241
+ return { ok: false, reason: `version ${identity.version} !== expected ${expected}` };
1242
+ }
1243
+ return { ok: false, reason: "no pre-update PID capture and no expected-version match" };
1244
+ }
1245
+
1246
+ /**
1247
+ * Post-install restart for the GUI worker.
1248
+ *
1249
+ * npm installs run `node ocx.mjs update`, which already stops the proxy and reinstalls /
1250
+ * starts the service (or falls back to a direct start). A second `service install` here
1251
+ * calls `stopWindows()` on that healthy listener, then often fails elevation from the
1252
+ * non-interactive worker — leaving the captured port (default 10100) dead until a manual
1253
+ * restart. Prefer confirming the npm self-update's own restart first; only re-run restart
1254
+ * when that probe fails. Bun/source installs still always take the explicit restart path.
1255
+ *
1256
+ * Probe-first applies only to service-managed npm installs: without a service, `ocx.mjs`
1257
+ * only prints `ocx start` and never brings the proxy back, so waiting would always burn
1258
+ * the full health timeout. Skipping also requires update-correlated evidence (PID change
1259
+ * and/or target version) so a surviving pre-update process cannot look like success.
1260
+ * After an explicit npm restart the same evidence is required again — health alone is
1261
+ * not enough when a no-op restart or failed port reclaim leaves the old proxy up.
1262
+ *
1263
+ * Browser-dashboard update recovery must not require a viable Background Service: when
1264
+ * no service is installed (or reinstall leaves a non-viable/stale manager), the explicit
1265
+ * path always falls through to a direct `ocx start --port` so /healthz can recover.
1266
+ */
1267
+ export async function finishGuiUpdateRestart(
1268
+ job: UpdateJobState,
1269
+ captured: { port: number; hostname: string; oldPid?: number },
1270
+ installer: Installer,
1271
+ io: RestartIo = {},
1272
+ ): Promise<boolean> {
1273
+ if (installer === "npm") {
1274
+ const serviceInstalled = (io.serviceInstalledFn ?? isServiceInstalled)();
1275
+ if (serviceInstalled) {
1276
+ // Stop-first npm update leaves a dead PID's LISTEN row. Polling /healthz for the
1277
+ // full 30s against that zombie keeps ESTABLISHED TCBs alive and blocks bind.
1278
+ // If nothing live owns the port, skip straight to explicit restart. A failed
1279
+ // listener scan must not look like "no listeners" — fall back to /healthz.
1280
+ const aliveFn = io.isAliveFn ?? isProcessAlive;
1281
+ const scan: ListenPidScan = io.scanListenPidsFn
1282
+ ? io.scanListenPidsFn(captured.port)
1283
+ : io.listListenPidsFn
1284
+ // Test seam: injected list is always a successful scan.
1285
+ ? { ok: true, pids: io.listListenPidsFn(captured.port) }
1286
+ : scanListenPids(captured.port);
1287
+ const liveListeners = scan.ok
1288
+ ? scan.pids.filter(pid => pid !== process.pid && aliveFn(pid))
1289
+ : null;
1290
+ if (liveListeners !== null && liveListeners.length === 0) {
1291
+ updateJob(job, {}, "npm self-update did not leave a live listener; performing explicit restart...");
1292
+ } else {
1293
+ if (!scan.ok) {
1294
+ updateJob(
1295
+ job,
1296
+ {},
1297
+ "Listener scan inconclusive after npm self-update; probing /healthz before deciding on explicit restart...",
1298
+ );
1299
+ }
1300
+ const already = await awaitRestartedProxyHealthy(job, captured, io);
1301
+ if (already.ok) {
1302
+ const identity = await (io.probeProxyIdentity ?? defaultProbeProxyIdentity)(
1303
+ captured.port,
1304
+ captured.hostname,
1305
+ );
1306
+ const evidence = npmSelfUpdateRestartEvidence(job, captured, identity);
1307
+ if (evidence.ok) {
1308
+ updateJob(
1309
+ job,
1310
+ {},
1311
+ `Proxy already healthy on ${captured.hostname}:${captured.port} after npm self-update (${evidence.detail}); skipping redundant restart.`,
1312
+ );
1313
+ return true;
1314
+ }
1315
+ updateJob(
1316
+ job,
1317
+ {},
1318
+ `npm self-update left a healthy proxy but ${evidence.reason}; performing explicit restart...`,
1319
+ );
1320
+ } else {
1321
+ updateJob(job, {}, "npm self-update did not leave a healthy proxy; performing explicit restart...");
1322
+ }
1323
+ }
1324
+ }
1325
+ }
1326
+ const restartFn = io.restartAfterUpdateFn ?? restartAfterUpdate;
1327
+ await restartFn(job, captured, io);
1328
+ if (installer !== "npm") {
1329
+ // Bun/source: health alone remains enough unless a richer identity probe is supplied.
1330
+ if (!io.probeProxyIdentity) return confirmRestartedProxy(job, captured, io);
1331
+ }
1332
+ return confirmNpmExplicitRestart(job, captured, io);
1333
+ }
1334
+
1335
+ /**
1336
+ * After an explicit npm (or identity-aware) restart, require update-correlated
1337
+ * evidence — not merely a healthy OpenCodex listener. A no-op restart or a
1338
+ * failed port reclaim can leave the pre-update process on the captured port;
1339
+ * `confirmRestartedProxy` alone would treat that as success.
1340
+ */
1341
+ async function confirmNpmExplicitRestart(
1342
+ job: UpdateJobState,
1343
+ captured: { port: number; hostname: string; oldPid?: number },
1344
+ io: RestartIo = {},
1345
+ ): Promise<boolean> {
1346
+ const healthy = await awaitRestartedProxyHealthy(job, captured, io);
1347
+ if (!healthy.ok) {
1348
+ const port = captured.port;
1349
+ const hostname = captured.hostname;
1350
+ const error = healthy.reason === "flapped"
1351
+ ? `proxy restart became unhealthy on ${hostname}:${port}`
1352
+ : `proxy restart never became healthy on ${hostname}:${port}`;
1353
+ updateJob(job, {
1354
+ status: "failed",
1355
+ restarted: false,
1356
+ error,
1357
+ }, restartFailureHint(port));
1358
+ return false;
1359
+ }
1360
+
1361
+ const identity = await (io.probeProxyIdentity ?? defaultProbeProxyIdentity)(
1362
+ captured.port,
1363
+ captured.hostname,
1364
+ );
1365
+ const evidence = npmSelfUpdateRestartEvidence(job, captured, identity);
1366
+ if (!evidence.ok) {
1367
+ updateJob(job, {
1368
+ status: "failed",
1369
+ restarted: false,
1370
+ error: `proxy restart did not show update-correlated identity (${evidence.reason})`,
1371
+ }, restartFailureHint(captured.port));
1372
+ return false;
1373
+ }
1374
+
1375
+ updateJob(
1376
+ job,
1377
+ {},
1378
+ `Proxy restart confirmed on ${captured.hostname}:${captured.port} (${evidence.detail}).`,
1379
+ );
1380
+ return true;
1381
+ }
1382
+
1383
+ export async function runGuiUpdateWorker(jobId: string, channel: Channel, restart: boolean): Promise<void> {
1384
+ let job = readUpdateJob(jobId);
1385
+ const check = checkForUpdate(channel);
1386
+ const now = new Date().toISOString();
1387
+ // Capture the live listen target BEFORE the update command runs: the stop-first update
1388
+ // flow clears pid/runtime state, so this is the last moment the real port is knowable.
1389
+ // Only trust runtime-port.json when its pid matches the live pidfile process.
1390
+ const rt = readRuntimePort();
1391
+ const livePid = readPid();
1392
+ const preUpdateConfig = loadConfig();
1393
+ const runtimeTrusted = !!(rt && livePid && rt.pid === livePid);
1394
+ const configPort = typeof preUpdateConfig.port === "number" && preUpdateConfig.port > 0
1395
+ ? preUpdateConfig.port
1396
+ : 10100;
1397
+ const captured = {
1398
+ port: runtimeTrusted ? rt.port : configPort,
1399
+ hostname: (runtimeTrusted ? rt.hostname : undefined) ?? preUpdateConfig.hostname ?? "127.0.0.1",
1400
+ ...(runtimeTrusted && livePid ? { oldPid: livePid } : {}),
1401
+ };
1402
+ let trayWasInstalled = false;
1403
+ let trayWasRunning = false;
1404
+ if (!job) {
1405
+ job = {
1406
+ id: jobId,
1407
+ status: "running",
1408
+ startedAt: now,
1409
+ updatedAt: now,
1410
+ currentVersion: check.currentVersion,
1411
+ latestVersion: check.latestVersion,
1412
+ channel: check.channel,
1413
+ installer: check.installer,
1414
+ restart,
1415
+ command: check.command,
1416
+ releaseNotesUrl: check.releaseNotesUrl,
1417
+ log: [],
1418
+ };
1419
+ writeJob(job);
1420
+ }
1421
+
1422
+ try {
1423
+ if (!check.canUpdate) {
1424
+ throw new Error(check.reason ?? "No update is available");
1425
+ }
1426
+
1427
+ // Pre-flight integrity metadata check (same lanes as the CLI): anomalous registry
1428
+ // metadata for a resolved version fails the job BEFORE anything is spawned or the
1429
+ // proxy is stopped; transient registry failure degrades to a logged skip.
1430
+ const integrity = checkUpdatePackageIntegrity(check.latestVersion);
1431
+ if (integrity.ok === false) {
1432
+ updateJob(job, { status: "failed", error: integrity.reason });
1433
+ return;
1434
+ }
1435
+ const integrityLine = integrity.ok === "skipped"
1436
+ ? `Integrity pre-flight skipped: ${integrity.reason}. Proceeding best-effort.`
1437
+ : `Verified ${PKG}@${check.latestVersion} integrity metadata ${integrity.integrity.slice(0, 24)}…`;
1438
+
1439
+ const cmd = updateExecutionCommand(check.installer, channel, undefined, check.latestVersion);
1440
+ job = updateJob(job, {
1441
+ currentVersion: check.currentVersion,
1442
+ latestVersion: check.latestVersion,
1443
+ installer: check.installer,
1444
+ command: cmd.display,
1445
+ }, integrityLine);
1446
+
1447
+ if (process.platform === "win32") {
1448
+ try {
1449
+ const { getWindowsTrayStatus, startWindowsTray, stopWindowsTray } = await import("../tray/windows");
1450
+ const tray = getWindowsTrayStatus();
1451
+ const trayPlan = handoffWindowsTrayForUpdate(tray, {
1452
+ stop: () => {
1453
+ const stopped = stopWindowsTray();
1454
+ return { exitStatus: 0, running: stopped.running };
1455
+ },
1456
+ start: () => startWindowsTray(),
1457
+ });
1458
+ trayWasInstalled = trayPlan.refreshAfterReplacement;
1459
+ trayWasRunning = trayPlan.restoreOnFailure;
1460
+ } catch (error) {
1461
+ updateJob(job, {
1462
+ status: "failed",
1463
+ error: `Could not stop the Windows tray; aborting before package replacement: ${error instanceof Error ? error.message : String(error)}`,
1464
+ });
1465
+ return;
1466
+ }
1467
+ }
1468
+
1469
+ /* [Decision Log]
1470
+ - 목적: GUI 요청 처리 프로세스가 자신이 실행 중인 패키지를 직접 덮어쓰지 않도록 업데이트를 별도 worker에서 수행한다.
1471
+ - 대안 분석: (1) 서버에서 runUpdate 직접 호출: process.exit/stdio/실행 파일 교체 위험. (2) GUI에서 CLI 명령 안내만 제공: 자동 업데이트 UX 부족. (3) 숨은 worker가 Node launcher/Bun 전역 명령을 실행: 상태 추적과 안전한 재시작이 가능.
1472
+ - 선택 근거: 현재 CLI의 npm self-update 우회를 재사용하면서도 GUI 서버 요청 생명주기와 설치 작업을 분리할 수 있어 가장 안정적이다.
1473
+ */
1474
+ const result = runLoggedCommand(job, cmd.bin, cmd.args, UPDATE_TIMEOUT_MS);
1475
+ if (result.status !== 0) {
1476
+ if (trayWasRunning) {
1477
+ try {
1478
+ const { startWindowsTray } = await import("../tray/windows");
1479
+ startWindowsTray();
1480
+ } catch { /* retain the primary update failure */ }
1481
+ }
1482
+ updateJob(job, {
1483
+ status: "failed",
1484
+ exitCode: result.status,
1485
+ signal: result.signal,
1486
+ error: `update command failed (${result.status ?? "?"})`,
1487
+ });
1488
+ return;
1489
+ }
1490
+
1491
+ if (trayWasInstalled) {
1492
+ const trayArgs = [process.argv[1], ...planWindowsTrayUpdate({ installed: trayWasInstalled, running: trayWasRunning }).installArgs];
1493
+ const tray = runLoggedCommand(job, process.execPath, trayArgs, 20_000);
1494
+ if (tray.status !== 0) {
1495
+ updateJob(job, {}, "Windows tray refresh failed; run 'ocx tray install'.");
1496
+ if (trayWasRunning) runLoggedCommand(job, process.execPath, [process.argv[1], "tray", "start"], 15_000);
1497
+ }
1498
+ }
1499
+
1500
+ if (restart) {
1501
+ job = updateJob(job, { status: "restarting" }, "Update installed. Restarting proxy...");
1502
+ if (!(await finishGuiUpdateRestart(job, captured, check.installer))) return;
1503
+ updateJob(job, { status: "succeeded", restarted: true }, "Restart requested and proxy is healthy.");
1504
+ return;
1505
+ }
1506
+
1507
+ updateJob(job, { status: "succeeded", restarted: false }, "Update installed. Restart the proxy to use the new version.");
1508
+ } catch (err) {
1509
+ if (trayWasRunning) {
1510
+ try {
1511
+ const { startWindowsTray } = await import("../tray/windows");
1512
+ startWindowsTray();
1513
+ } catch { /* retain the primary worker failure */ }
1514
+ }
1515
+ updateJob(job, {
1516
+ status: "failed",
1517
+ error: err instanceof Error ? err.message : String(err),
1518
+ });
1519
+ }
1520
+ }