@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
package/src/service.ts ADDED
@@ -0,0 +1,2554 @@
1
+ /**
2
+ * `ocx service` — run the proxy as a background service that auto-starts on login and
3
+ * auto-restarts on crash. macOS → launchd; Windows → Task Scheduler; Linux → systemd user unit.
4
+ * The service sets OCX_SERVICE=1 so the proxy's shutdown handler does NOT restore native
5
+ * Codex on a service-managed restart (the restarted instance re-injects); explicit stop/uninstall
6
+ * restore it via the command.
7
+ */
8
+ import { execFileSync, execSync, spawnSync } from "node:child_process";
9
+ import { findLiveProxy, proxyIdentityAt, SERVICE_STOP_LIVENESS } from "./server/proxy-liveness";
10
+ import { chmodSync, existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from "node:fs";
11
+ import { homedir } from "node:os";
12
+ import { dirname, join, resolve } from "node:path";
13
+ import { expandUserPath, getConfigDir, readPid, removePid, removeRuntimePort, verifyPidIdentity } from "./config";
14
+ import { loadConfig } from "./config";
15
+ import { restoreNativeCodex } from "./codex/inject";
16
+ import { stripGrokConfig } from "./grok/inject";
17
+ import { isWslRuntime } from "./codex/home";
18
+ import { durableBunPath, durableBunRuntime } from "./lib/bun-runtime";
19
+ import { isProcessAlive, stopProxy } from "./lib/process-control";
20
+ import { serviceApiTokenFilePath } from "./lib/service-secrets";
21
+ import { randomUUID } from "node:crypto";
22
+ import {
23
+ ELEVATION_REQUEST_TIMEOUT_MS,
24
+ OCX_ELEVATED_PROTOCOL_FAILED,
25
+ raceWithTimeout,
26
+ resolveTrustedWindowsSchtasksExe,
27
+ startElevatedSchtasksCreateAndRun,
28
+ runWindowsElevated,
29
+ toWindowsSchtasksError,
30
+ WindowsElevationError,
31
+ type ElevatedSchedulerOutcome,
32
+ type ElevatedSchtasksCreateAndRunExecution,
33
+ type ElevatedSchtasksCreateAndRunResult,
34
+ } from "./lib/windows-elevation";
35
+ import { defaultWinswEntry, installWinswService, startWinswService, stopWinswService, statusWinswRaw, uninstallWinswService, winswStatusSummary, winswXmlPath, WINSW_SERVICE_ID, WINSW_SHA256, WINSW_VERSION } from "./lib/winsw";
36
+ import { hardenSecretDir, hardenSecretPath } from "./lib/windows-secret-acl";
37
+ import { windowsEnvIndirectBatchPathList, windowsEnvIndirectBatchValue } from "./lib/win-paths";
38
+ import { recordOwnedConfigPath } from "./lib/config-ownership";
39
+ import { maybeShowStarPrompt } from "./cli/star-prompt";
40
+
41
+ const LABEL = "com.opencodex.proxy";
42
+ const TASK = "opencodex-proxy";
43
+
44
+ export type ServiceBackend = "scheduler" | "native";
45
+
46
+ function cliEntry(): { bun: string; cli: string } {
47
+ // Bake the bundled Bun (npm global prefix, survives `ocx update`) rather than
48
+ // a transient system Bun, so launchd/systemd/schtasks keep resolving even if a
49
+ // standalone Bun is later removed. The CLI entry lives at src/cli/index.ts.
50
+ return { bun: durableBunPath(), cli: join(import.meta.dir, "cli", "index.ts") };
51
+ }
52
+
53
+ function plistPath(): string {
54
+ return join(homedir(), "Library", "LaunchAgents", `${LABEL}.plist`);
55
+ }
56
+
57
+ function logPath(): string {
58
+ return join(getConfigDir(), "service.log");
59
+ }
60
+
61
+ export function serviceLogPath(): string {
62
+ return logPath();
63
+ }
64
+
65
+ function windowsServiceScriptPath(): string {
66
+ return join(getConfigDir(), "opencodex-service.cmd");
67
+ }
68
+
69
+ function windowsLauncherVbsPath(): string {
70
+ return join(getConfigDir(), "opencodex-service-launcher.vbs");
71
+ }
72
+
73
+ function windowsTaskXmlPath(): string {
74
+ return join(getConfigDir(), "opencodex-service-task.xml");
75
+ }
76
+
77
+ function serviceStatePath(): string {
78
+ return join(getConfigDir(), "service-state.json");
79
+ }
80
+
81
+ function defaultOpenCodexHome(): string {
82
+ return resolve(join(homedir(), ".opencodex"));
83
+ }
84
+
85
+ function serviceStatePaths(): string[] {
86
+ const paths = [serviceStatePath()];
87
+ const defaultPath = join(defaultOpenCodexHome(), "service-state.json");
88
+ if (normalizePathForCompare(defaultPath) !== normalizePathForCompare(paths[0])) paths.push(defaultPath);
89
+ return paths;
90
+ }
91
+
92
+ function currentCodexHome(): string {
93
+ const raw = process.env.CODEX_HOME?.trim();
94
+ return raw ? resolve(expandUserPath(raw)) : join(homedir(), ".codex");
95
+ }
96
+
97
+ function currentOpenCodexHome(): string {
98
+ // getConfigDir() already resolves OPENCODEX_HOME with ~ expansion; keep the
99
+ // install-state comparison on the same normalization or `~/...` values falsely
100
+ // fail the environment-match check depending on cwd.
101
+ return getConfigDir();
102
+ }
103
+
104
+ function normalizePathForCompare(path: string): string {
105
+ const resolved = resolve(path);
106
+ return process.platform === "win32" ? resolved.toLowerCase() : resolved;
107
+ }
108
+
109
+ export interface ServiceInstallState {
110
+ version: 1 | 2;
111
+ codexHome: string;
112
+ opencodexHome: string;
113
+ /** Baked at install; lets status flag paths gone stale after npm prefix/nvm moves. */
114
+ bunPath?: string;
115
+ cliPath?: string;
116
+ /** v2: which Windows backend was chosen at install; absent (v1/legacy) means scheduler. */
117
+ backend?: ServiceBackend;
118
+ winswVersion?: string;
119
+ winswSha256?: string;
120
+ }
121
+
122
+ export function parseServiceInstallState(value: unknown): ServiceInstallState | null {
123
+ if (!value || typeof value !== "object" || Array.isArray(value)) return null;
124
+ const state = value as Record<string, unknown>;
125
+ if (state.version !== 1 && state.version !== 2) return null;
126
+ if (typeof state.codexHome !== "string" || state.codexHome.length === 0) return null;
127
+ if (typeof state.opencodexHome !== "string" || state.opencodexHome.length === 0) return null;
128
+ for (const key of ["bunPath", "cliPath", "winswVersion", "winswSha256"] as const) {
129
+ if (state[key] !== undefined && (typeof state[key] !== "string" || state[key].length === 0)) return null;
130
+ }
131
+ if (state.version === 1) {
132
+ if (state.backend !== undefined) return null;
133
+ } else if (state.backend !== "scheduler" && state.backend !== "native") {
134
+ return null;
135
+ }
136
+ return state as unknown as ServiceInstallState;
137
+ }
138
+
139
+ function writeServiceInstallState(backend: ServiceBackend = "scheduler"): void {
140
+ const { bun, cli } = cliEntry();
141
+ const state: ServiceInstallState = {
142
+ version: 2,
143
+ codexHome: currentCodexHome(),
144
+ opencodexHome: currentOpenCodexHome(),
145
+ bunPath: bun,
146
+ cliPath: cli,
147
+ backend,
148
+ ...(backend === "native" ? { winswVersion: WINSW_VERSION, winswSha256: WINSW_SHA256 } : {}),
149
+ };
150
+ for (const path of serviceStatePaths()) {
151
+ const dir = dirname(path);
152
+ recordOwnedConfigPath(getConfigDir(), path);
153
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true, mode: 0o700 });
154
+ writeFileSync(path, JSON.stringify(state, null, 2) + "\n", { encoding: "utf8", mode: 0o600 });
155
+ try { chmodSync(path, 0o600); } catch { /* best-effort */ }
156
+ if (process.platform === "win32") hardenSecretPath(path, { required: true });
157
+ }
158
+ }
159
+
160
+ function readServiceInstallState(): ServiceInstallState | null {
161
+ for (const path of serviceStatePaths()) {
162
+ try {
163
+ const parsed = parseServiceInstallState(JSON.parse(readFileSync(path, "utf8")));
164
+ if (parsed) return parsed;
165
+ } catch {
166
+ /* try the next known state path */
167
+ }
168
+ }
169
+ return null;
170
+ }
171
+
172
+ /** Single accessor for update/reinstall code — v1/legacy state maps to scheduler. */
173
+ export function readServiceBackend(): ServiceBackend {
174
+ return readServiceInstallState()?.backend === "native" ? "native" : "scheduler";
175
+ }
176
+
177
+ /** The `ocx` argv that reinstalls the currently-chosen service backend (update paths). */
178
+ export function serviceReinstallArgs(): string[] {
179
+ return readServiceBackend() === "native" ? ["service", "install", "--native"] : ["service", "install"];
180
+ }
181
+
182
+ /**
183
+ * The service was installed under a different CODEX_HOME/OPENCODEX_HOME, so this process may not
184
+ * touch it. Distinct from "stop failed": the manager was never even contacted, which means the
185
+ * installed service is still live and shared state (native Codex config, the Grok fence) must be
186
+ * left alone — tearing it down would strip config out from under a running service.
187
+ */
188
+ export class ServiceOwnershipError extends Error {
189
+ readonly code = "service-ownership-mismatch" as const;
190
+ }
191
+
192
+ export function isServiceOwnershipError(err: unknown): err is ServiceOwnershipError {
193
+ return err instanceof ServiceOwnershipError;
194
+ }
195
+
196
+ /**
197
+ * True when no installed service exists, or the installed one belongs to THIS
198
+ * CODEX_HOME/OPENCODEX_HOME. Callers use it to decide whether they may tear down shared state
199
+ * (native Codex config, the Grok fence) that a foreign service would still be relying on.
200
+ */
201
+ export function serviceEnvironmentOwnedHere(): boolean {
202
+ try {
203
+ assertServiceEnvironmentMatchesInstall();
204
+ return true;
205
+ } catch (err) {
206
+ if (isServiceOwnershipError(err)) return false;
207
+ return true; // unrelated failure: fall back to the previous behavior rather than wedging
208
+ }
209
+ }
210
+
211
+ export function assertServiceEnvironmentMatchesInstall(): void {
212
+ const state = readServiceInstallState();
213
+ if (!state) return;
214
+ const expected = normalizePathForCompare(state.codexHome);
215
+ const actual = normalizePathForCompare(currentCodexHome());
216
+ if (expected !== actual) {
217
+ throw new ServiceOwnershipError(
218
+ `Service was installed with CODEX_HOME=${state.codexHome}, but current CODEX_HOME=${currentCodexHome()}. ` +
219
+ "Run the service command from the same Codex home so native Codex restore updates the correct config.",
220
+ );
221
+ }
222
+ const expectedOpenCodexHome = normalizePathForCompare(state.opencodexHome);
223
+ const actualOpenCodexHome = normalizePathForCompare(currentOpenCodexHome());
224
+ if (expectedOpenCodexHome !== actualOpenCodexHome) {
225
+ throw new ServiceOwnershipError(
226
+ `Service was installed with OPENCODEX_HOME=${state.opencodexHome}, but current OPENCODEX_HOME=${currentOpenCodexHome()}. ` +
227
+ "Run the service command from the same OpenCodex home so service state and secrets match.",
228
+ );
229
+ }
230
+ }
231
+
232
+ function plistString(value: string): string {
233
+ return value
234
+ .replace(/&/g, "&amp;")
235
+ .replace(/</g, "&lt;")
236
+ .replace(/>/g, "&gt;")
237
+ .replace(/"/g, "&quot;")
238
+ .replace(/'/g, "&apos;");
239
+ }
240
+
241
+ function isLoopbackHostname(hostname: string | undefined): boolean {
242
+ const normalized = (hostname ?? "127.0.0.1").trim().toLowerCase();
243
+ return normalized === "" || normalized === "localhost" || normalized === "127.0.0.1" || normalized === "::1" || normalized === "[::1]";
244
+ }
245
+
246
+ export function assertServiceAuthEnvironment(): void {
247
+ const config = loadConfig();
248
+ if (isLoopbackHostname(config.hostname)) return;
249
+ if (process.env.OPENCODEX_API_AUTH_TOKEN?.trim()) return;
250
+ throw new Error(
251
+ "OPENCODEX_API_AUTH_TOKEN is required before installing a service for non-loopback hostname. " +
252
+ "Set it in the same shell, then rerun `ocx service install`.",
253
+ );
254
+ }
255
+
256
+ function writeServiceApiTokenFile(): string | null {
257
+ const token = process.env.OPENCODEX_API_AUTH_TOKEN?.trim();
258
+ if (!token) return null;
259
+ const path = serviceApiTokenFilePath();
260
+ const dir = getConfigDir();
261
+ recordOwnedConfigPath(dir, path);
262
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true, mode: 0o700 });
263
+ if (process.platform === "win32") hardenSecretDir(dir, { required: true });
264
+ writeFileSync(path, `${token}\n`, { encoding: "utf8", mode: 0o600 });
265
+ try { chmodSync(path, 0o600); } catch { /* best-effort */ }
266
+ if (process.platform === "win32") hardenSecretPath(path, { required: true });
267
+ return path;
268
+ }
269
+
270
+ export function buildPlist(): string {
271
+ const { bun, cli } = cliEntry();
272
+ const log = logPath();
273
+ const path = process.env.PATH ?? "/usr/local/bin:/usr/bin:/bin";
274
+ const codexHome = process.env.CODEX_HOME?.trim();
275
+ const opencodexHome = process.env.OPENCODEX_HOME?.trim();
276
+ const envLines = [
277
+ ` <key>OCX_SERVICE</key><string>1</string>`,
278
+ ` <key>PATH</key><string>${plistString(path)}</string>`,
279
+ codexHome ? ` <key>CODEX_HOME</key><string>${plistString(codexHome)}</string>` : null,
280
+ opencodexHome ? ` <key>OPENCODEX_HOME</key><string>${plistString(opencodexHome)}</string>` : null,
281
+ ].filter((line): line is string => Boolean(line)).join("\n");
282
+ const command = buildServiceShellCommand(bun, cli);
283
+ return `<?xml version="1.0" encoding="UTF-8"?>
284
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
285
+ <plist version="1.0">
286
+ <dict>
287
+ <key>Label</key><string>${LABEL}</string>
288
+ <key>ProgramArguments</key>
289
+ <array>
290
+ <string>/bin/sh</string>
291
+ <string>-lc</string>
292
+ <string>${plistString(command)}</string>
293
+ </array>
294
+ <key>RunAtLoad</key><true/>
295
+ <key>KeepAlive</key><true/>
296
+ <key>EnvironmentVariables</key>
297
+ <dict>
298
+ ${envLines}
299
+ </dict>
300
+ <key>StandardOutPath</key><string>${plistString(log)}</string>
301
+ <key>StandardErrorPath</key><string>${plistString(log)}</string>
302
+ </dict>
303
+ </plist>
304
+ `;
305
+ }
306
+
307
+ function shellQuote(value: string): string {
308
+ return `'${value.replace(/'/g, "'\\''")}'`;
309
+ }
310
+
311
+ /**
312
+ * Listen port baked into service wrappers / WinSW XML.
313
+ * Priority: explicit override → OCX_BAKE_PORT (update restart) → config.port → 10100.
314
+ * `config.port === 0` means ephemeral for interactive start; services need a stable pin,
315
+ * so treat 0 / invalid like unset (default 10100) instead of baking `--port 0`.
316
+ */
317
+ export function resolveServiceListenPort(override?: number): number {
318
+ if (typeof override === "number" && Number.isFinite(override) && override > 0 && override <= 65535) {
319
+ return Math.trunc(override);
320
+ }
321
+ const baked = process.env.OCX_BAKE_PORT?.trim();
322
+ if (baked && /^\d+$/.test(baked)) {
323
+ const n = Number(baked);
324
+ if (n > 0 && n <= 65535) return n;
325
+ }
326
+ const configured = loadConfig().port;
327
+ if (typeof configured === "number" && configured > 0 && configured <= 65535) return configured;
328
+ return 10100;
329
+ }
330
+
331
+ function buildServiceShellCommand(bun: string, cli: string, port = resolveServiceListenPort()): string {
332
+ const tokenFile = serviceApiTokenFilePath();
333
+ return `if [ -f ${shellQuote(tokenFile)} ]; then OPENCODEX_API_AUTH_TOKEN="$(cat ${shellQuote(tokenFile)})"; export OPENCODEX_API_AUTH_TOKEN; fi; exec ${shellQuote(bun)} ${shellQuote(cli)} start --port ${port}`;
334
+ }
335
+
336
+ /**
337
+ * The `--port <n>` actually baked into the installed launchd plist, or null when it
338
+ * cannot be read. macOS only — named for launchd rather than "service" so no caller
339
+ * assumes it covers systemd or the Windows wrapper.
340
+ *
341
+ * `start` needs this because it does NOT rewrite the plist: an install made under
342
+ * OCX_BAKE_PORT, or any later config.port edit, would otherwise leave launchd serving
343
+ * one port while the confirmation probes another, failing a healthy service.
344
+ *
345
+ * Anchored on the closing tag and matched LAST: the command also carries the Bun and
346
+ * CLI paths, and a path containing the literal `start --port 9999` must not shadow
347
+ * the real argument. buildPlist emits the command as the final ProgramArguments
348
+ * string, and buildServiceShellCommand puts the port at the very end of it.
349
+ */
350
+ export function launchdListenPort(deps: { readPlist?: () => string } = {}): number | null {
351
+ try {
352
+ const text = (deps.readPlist ?? (() => readFileSync(plistPath(), "utf8")))();
353
+ const last = [...text.matchAll(/start --port (\d{1,5})\s*<\/string>/g)].at(-1);
354
+ if (!last) return null;
355
+ const n = Number(last[1]);
356
+ return n > 0 && n <= 65535 ? n : null;
357
+ } catch {
358
+ return null;
359
+ }
360
+ }
361
+
362
+ /** The `--port <n>` baked into the installed systemd user unit. Linux only. */
363
+ export function systemdListenPort(deps: { readUnit?: () => string } = {}): number | null {
364
+ try {
365
+ const text = (deps.readUnit ?? (() => readFileSync(unitPath(), "utf8")))();
366
+ const last = [...text.matchAll(/start --port (\d{1,5})(?:\s|"|$)/gm)].at(-1);
367
+ if (!last) return null;
368
+ const n = Number(last[1]);
369
+ return n > 0 && n <= 65535 ? n : null;
370
+ } catch {
371
+ return null;
372
+ }
373
+ }
374
+
375
+ /**
376
+ * Shared tail parser for the baked `--port <n>`.
377
+ *
378
+ * Terminators cover all three artifact shapes: whitespace (batch wrapper, systemd
379
+ * unit), `"` (systemd's quoted ExecStart), `<` (WinSW's `</arguments>`), and `&` (an
380
+ * XML-escaped quote). Matched LAST because every artifact carries the Bun and CLI
381
+ * paths ahead of the argument, and a path containing the literal must not shadow it.
382
+ */
383
+ function parseBakedListenPort(read: () => string): number | null {
384
+ try {
385
+ const last = [...read().matchAll(/start --port (\d{1,5})(?:\s|"|&|<|$)/gm)].at(-1);
386
+ if (!last) return null;
387
+ const n = Number(last[1]);
388
+ return n > 0 && n <= 65535 ? n : null;
389
+ } catch {
390
+ return null;
391
+ }
392
+ }
393
+
394
+ /** The `--port <n>` baked into the Task Scheduler wrapper. Windows scheduler backend. */
395
+ export function windowsListenPort(deps: { readScript?: () => string } = {}): number | null {
396
+ return parseBakedListenPort(deps.readScript ?? (() => readFileSync(windowsServiceScriptPath(), "utf8")));
397
+ }
398
+
399
+ /**
400
+ * The `--port <n>` baked into the WinSW XML's `<arguments>`. Windows native backend.
401
+ *
402
+ * Separate from {@link windowsListenPort} rather than one function branching on
403
+ * `readServiceBackend()`: the recorded backend can disagree with what is actually on
404
+ * disk (the `stale` / `backendStateMismatch` cases `deriveWindowsServiceDiagnostic`
405
+ * exists to catch), and a reader that trusted it would then read the wrong file.
406
+ * Each returns null when its own artifact is absent, so the chain needs no branch.
407
+ */
408
+ export function winswListenPort(deps: { readXml?: () => string } = {}): number | null {
409
+ return parseBakedListenPort(deps.readXml ?? (() => readFileSync(winswXmlPath(), "utf8")));
410
+ }
411
+
412
+ /**
413
+ * The listen port of the INSTALLED service artifact, falling back to the configured
414
+ * one. Each reader returns null off its own platform, so the chain needs no platform
415
+ * branch — and on Windows both return null, preserving today's behavior.
416
+ */
417
+ export function installedServiceListenPort(): number {
418
+ return launchdListenPort()
419
+ ?? systemdListenPort()
420
+ ?? windowsListenPort()
421
+ ?? winswListenPort()
422
+ ?? resolveServiceListenPort();
423
+ }
424
+
425
+ export const SERVICE_INSTALL_HEALTH_MS = 20_000;
426
+
427
+ /**
428
+ * Whether a proxy actually answers on the port this install/start just produced.
429
+ *
430
+ * Registration is not service: `launchctl list` reports a job that never bound, and
431
+ * `systemctl is-active` reports a process that bound nothing. Probing is the only
432
+ * thing that answers the question the user is actually asking.
433
+ *
434
+ * Probes the BAKED target rather than resolving one. `findLiveProxy` resolves through
435
+ * pidfile -> runtime-port -> config.port, and a service reinstall has just invalidated
436
+ * the first two while `resolveServiceListenPort` (OCX_BAKE_PORT precedence, config.port
437
+ * === 0 normalization) can disagree with the third.
438
+ *
439
+ * Soft: returns the outcome, never throws; the caller chooses between a checkmark and
440
+ * an actionable warning.
441
+ */
442
+ export async function confirmServiceServing(
443
+ deps: {
444
+ port?: number;
445
+ hostname?: string;
446
+ probe?: (port: number, hostname: string) => Promise<boolean>;
447
+ sleep?: (ms: number) => Promise<void>;
448
+ now?: () => number;
449
+ timeoutMs?: number;
450
+ } = {},
451
+ ): Promise<{ ok: true; port: number } | { ok: false; port: number }> {
452
+ const port = deps.port ?? installedServiceListenPort();
453
+ const hostname = deps.hostname ?? loadConfig().hostname ?? "127.0.0.1";
454
+ const now = deps.now ?? Date.now;
455
+ const sleep = deps.sleep ?? ((ms: number) => new Promise<void>(r => setTimeout(r, ms)));
456
+ const probe = deps.probe ?? (async (p, h) => !!(await proxyIdentityAt(p, { hostname: h })));
457
+ const deadline = now() + (deps.timeoutMs ?? SERVICE_INSTALL_HEALTH_MS);
458
+ for (;;) {
459
+ if (await probe(port, hostname)) return { ok: true, port };
460
+ if (now() >= deadline) return { ok: false, port };
461
+ await sleep(500);
462
+ }
463
+ }
464
+
465
+ /**
466
+ * Print the outcome of `install` / `start` / `repair` in terms of what the user cares
467
+ * about — is it serving? — instead of whether the manager accepted the registration.
468
+ *
469
+ * Sets `process.exitCode = 1` when nothing answers. That is deliberate: the GUI update
470
+ * worker reads the child's exit status, so a registered-but-silent service now makes it
471
+ * fall back to a direct proxy start rather than reporting a successful update over a
472
+ * dead port.
473
+ */
474
+ async function reportServiceServing(
475
+ verb: "installed" | "started" | "repaired",
476
+ deps: Parameters<typeof confirmServiceServing>[0] = {},
477
+ ): Promise<void> {
478
+ const serving = await confirmServiceServing(deps);
479
+ if (serving.ok) {
480
+ console.log(`✅ opencodex service ${verb} and serving on port ${serving.port}.`);
481
+ return;
482
+ }
483
+ console.error(
484
+ `⚠️ Service ${verb}, but no proxy answered on port ${serving.port} within `
485
+ + `${Math.trunc(SERVICE_INSTALL_HEALTH_MS / 1000)}s.\n`
486
+ + ` The manager registered the job; that is not the same as serving.\n`
487
+ + ` Log: ${serviceLogPath()}\n`
488
+ + ` Meanwhile: ocx start (serves in the foreground)`,
489
+ );
490
+ process.exitCode = 1;
491
+ }
492
+
493
+ /**
494
+ * The reinstall command for the CURRENTLY INSTALLED backend.
495
+ *
496
+ * Plain `ocx service install` on a native/WinSW install runs installWindows's
497
+ * transactional backend switch, which tears down WinSW and replaces it with the Task
498
+ * Scheduler backend. Advising it in a repair hint would silently change the user's
499
+ * backend, so the hint has to carry `--native` when that is what is installed.
500
+ */
501
+ function serviceRepairCommand(): string {
502
+ return process.platform === "win32" && readServiceBackend() === "native"
503
+ ? "ocx service install --native"
504
+ : "ocx service install";
505
+ }
506
+
507
+ function systemdQuote(value: string): string {
508
+ return `"${value
509
+ .replace(/\\/g, "\\\\")
510
+ .replace(/"/g, "\\\"")
511
+ .replace(/%/g, "%%")
512
+ .replace(/\n/g, "\\n")}"`;
513
+ }
514
+
515
+ function systemdEnvironmentAssignment(name: string, value: string | undefined): string | null {
516
+ if (!value) return null;
517
+ return `Environment=${systemdQuote(`${name}=${value}`)}`;
518
+ }
519
+
520
+ function systemdOutputTarget(value: string): string {
521
+ // StandardOutput/StandardError use output specifiers such as append:/path.
522
+ // Quoting the full specifier makes systemd reject it as an invalid output target.
523
+ return value.replace(/%/g, "%%").replace(/\n/g, "\\n");
524
+ }
525
+
526
+ function sh(cmd: string): string {
527
+ return execSync(cmd, { encoding: "utf8", stdio: ["pipe", "pipe", "pipe"] }).trim();
528
+ }
529
+
530
+ /**
531
+ * Run `launchctl` and report BOTH streams regardless of exit status.
532
+ *
533
+ * `launchctl load` writes "Load failed: <n>: <reason>" to stderr and exits 0 for
534
+ * every already-bootstrapped job. `sh()` above is execSync, which throws only on a
535
+ * non-zero exit, so install and start both reported success for a load that did
536
+ * nothing — leaving launchd running the PREVIOUS plist while a freshly written one
537
+ * sat unused on disk. That is the 2026-08-02 report: `ocx service` prints a
538
+ * checkmark, `launchctl list` shows the job, and the port answers nothing.
539
+ *
540
+ * spawnSync, NOT execFileSync: execFileSync discards stderr when the child exits 0,
541
+ * which is precisely this case — a runner built on it returns an empty stderr and
542
+ * the guard below can never fire. Measured on macOS 27.0.
543
+ */
544
+ export function runLaunchctl(
545
+ args: string[],
546
+ deps: { run?: typeof spawnSync } = {},
547
+ ): { ok: boolean; stdout: string; stderr: string } {
548
+ const run = deps.run ?? spawnSync;
549
+ const result = run("/bin/launchctl", args, { encoding: "utf8", windowsHide: true });
550
+ // `error` is set when the spawn itself failed (ENOENT off macOS) and `status` is
551
+ // null for a signalled child; neither may be reported as success.
552
+ if (result.error) return { ok: false, stdout: "", stderr: String(result.error.message ?? "") };
553
+ return {
554
+ ok: result.status === 0,
555
+ stdout: String(result.stdout ?? "").trim(),
556
+ stderr: String(result.stderr ?? "").trim(),
557
+ };
558
+ }
559
+
560
+ /**
561
+ * Whether launchctl output indicates the operation did not take. Needed because
562
+ * `ok` alone is insufficient for the legacy `load`/`unload` subcommands, which
563
+ * report failure on stderr while exiting 0. `bootstrap` exits 5, so for that path
564
+ * this is belt-and-braces rather than the only signal.
565
+ */
566
+ export function launchctlLoadFailed(stderr: string): boolean {
567
+ return /\b(?:Load|Bootstrap) failed\b/i.test(stderr);
568
+ }
569
+
570
+ /** launchd domain target for the current user's GUI session. */
571
+ function launchdGuiDomain(): string {
572
+ return `gui/${process.getuid?.() ?? 0}`;
573
+ }
574
+
575
+ /**
576
+ * Whether launchd is running the job from the CURRENT plist. `launchctl list` only
577
+ * proves domain membership — a job bootstrapped from an older plist stays listed
578
+ * forever. `launchctl print` exposes the live `arguments`, which is the only way to
579
+ * catch a load that silently no-op'd.
580
+ */
581
+ export function launchdJobMatchesPlist(
582
+ expectedCommand: string,
583
+ deps: { run?: typeof runLaunchctl } = {},
584
+ ): { loaded: boolean; matchesPlist: boolean } {
585
+ const run = deps.run ?? runLaunchctl;
586
+ const printed = run(["print", `${launchdGuiDomain()}/${LABEL}`]);
587
+ if (!printed.ok) return { loaded: false, matchesPlist: false };
588
+ // `print` writes the arguments block to stdout for a live job. Search both streams
589
+ // anyway so a future launchctl that moves diagnostics between them cannot turn this
590
+ // into a false negative — a false "stale" verdict would send users to `bootout` for
591
+ // nothing.
592
+ const printedText = `${printed.stdout}\n${printed.stderr}`;
593
+ return { loaded: true, matchesPlist: printedText.includes(expectedCommand) };
594
+ }
595
+
596
+ /**
597
+ * Decode schtasks stdout. `/query /xml` emits UTF-16LE (often with BOM) because the
598
+ * registered task document is UTF-16; reading that as UTF-8 makes every health check
599
+ * fail ("registration present but unhealthy") and rolls back a successful elevated create.
600
+ */
601
+ export function decodeSchtasksOutput(buffer: Buffer): string {
602
+ if (buffer.length === 0) return "";
603
+ const bomUtf16Le = buffer.length >= 2 && buffer[0] === 0xff && buffer[1] === 0xfe;
604
+ const bomUtf16Be = buffer.length >= 2 && buffer[0] === 0xfe && buffer[1] === 0xff;
605
+ const looksUtf16Le = buffer.length >= 4
606
+ && buffer[1] === 0x00
607
+ && buffer[3] === 0x00
608
+ && buffer[0] !== 0x00;
609
+ if (bomUtf16Le || looksUtf16Le) {
610
+ return buffer.toString("utf16le").replace(/^\uFEFF/, "").trim();
611
+ }
612
+ if (bomUtf16Be) {
613
+ // Swap pairs then decode as utf16le.
614
+ const swapped = Buffer.alloc(buffer.length - 2);
615
+ for (let i = 2; i + 1 < buffer.length; i += 2) {
616
+ swapped[i - 2] = buffer[i + 1]!;
617
+ swapped[i - 1] = buffer[i]!;
618
+ }
619
+ return swapped.toString("utf16le").trim();
620
+ }
621
+ return buffer.toString("utf8").replace(/^\uFEFF/, "").trim();
622
+ }
623
+
624
+ function runFile(file: string, args: string[]): string {
625
+ const buffer = execFileSync(file, args, {
626
+ encoding: "buffer",
627
+ stdio: ["ignore", "pipe", "pipe"],
628
+ windowsHide: true,
629
+ }) as Buffer;
630
+ return decodeSchtasksOutput(buffer);
631
+ }
632
+
633
+ function windowsSchtasks(): string {
634
+ return resolveTrustedWindowsSchtasksExe();
635
+ }
636
+
637
+ function windowsWscript(): string {
638
+ const candidate = join(process.env.SystemRoot ?? "C:\\Windows", "System32", "wscript.exe");
639
+ return existsSync(candidate) ? candidate : "wscript.exe";
640
+ }
641
+
642
+ let querySchtasksForTests: ((args: string[]) => string) | null = null;
643
+
644
+ function querySchtasks(args: string[]): string {
645
+ if (querySchtasksForTests) return querySchtasksForTests(args);
646
+ return runFile(windowsSchtasks(), args);
647
+ }
648
+
649
+ /** Test-only seam for Task Scheduler query used by presence probes. */
650
+ export function setQuerySchtasksForTests(next: ((args: string[]) => string) | null): void {
651
+ querySchtasksForTests = next;
652
+ }
653
+
654
+ function schtasks(args: string[]): string {
655
+ try {
656
+ return querySchtasks(args);
657
+ } catch (error) {
658
+ throw toWindowsSchtasksError(error, args);
659
+ }
660
+ }
661
+
662
+ /** Tri-state Task Scheduler presence: never treat a failed query as proven absence. */
663
+ export type WindowsSchedulerTaskProbe =
664
+ | { status: "present" }
665
+ | { status: "absent" }
666
+ | { status: "unknown"; detail: string };
667
+
668
+ export type WindowsSchedulerProxyProbe =
669
+ | { status: "running"; port: number }
670
+ | { status: "not-running" }
671
+ | { status: "unknown" };
672
+
673
+ /**
674
+ * Render Task Scheduler status without exposing localized `schtasks` table output.
675
+ * The task probe answers installation state; the identity-checked health probe answers
676
+ * runtime state. Keep probe details out of this user-facing line because they can contain
677
+ * incorrectly decoded, locale-specific command output.
678
+ */
679
+ export function formatWindowsSchedulerServiceStatus(
680
+ task: WindowsSchedulerTaskProbe,
681
+ proxy: WindowsSchedulerProxyProbe,
682
+ ): string {
683
+ if (task.status === "present") {
684
+ if (proxy.status === "running") {
685
+ return `✅ service installed (Task Scheduler); OpenCodex proxy running on port ${proxy.port}.`;
686
+ }
687
+ if (proxy.status === "not-running") {
688
+ return "⚠️ service installed (Task Scheduler); OpenCodex proxy not running.";
689
+ }
690
+ return "⚠️ service installed (Task Scheduler); OpenCodex proxy status unknown.";
691
+ }
692
+ if (task.status === "absent") {
693
+ if (proxy.status === "running") {
694
+ return `❌ service not installed (Task Scheduler); OpenCodex proxy is running independently on port ${proxy.port}.`;
695
+ }
696
+ if (proxy.status === "unknown") {
697
+ return "❌ service not installed (Task Scheduler); OpenCodex proxy status unknown.";
698
+ }
699
+ return "❌ service not installed (Task Scheduler).";
700
+ }
701
+ if (proxy.status === "running") {
702
+ return `⚠️ Task Scheduler registration unknown; OpenCodex proxy running on port ${proxy.port}.`;
703
+ }
704
+ if (proxy.status === "not-running") {
705
+ return "⚠️ service status unknown (Task Scheduler query failed); OpenCodex proxy not running.";
706
+ }
707
+ return "⚠️ service status unknown (Task Scheduler and proxy checks failed).";
708
+ }
709
+
710
+ export async function inspectWindowsSchedulerServiceStatus(io: {
711
+ probeTask?: () => WindowsSchedulerTaskProbe;
712
+ findProxy?: () => Promise<{ port: number } | null>;
713
+ } = {}): Promise<string> {
714
+ let task: WindowsSchedulerTaskProbe;
715
+ try {
716
+ task = (io.probeTask ?? probeWindowsSchedulerTask)();
717
+ } catch (error) {
718
+ task = { status: "unknown", detail: schtasksErrorDetail(error) };
719
+ }
720
+
721
+ let proxy: WindowsSchedulerProxyProbe;
722
+ try {
723
+ const live = await (io.findProxy ?? findLiveProxy)();
724
+ proxy = live ? { status: "running", port: live.port } : { status: "not-running" };
725
+ } catch {
726
+ proxy = { status: "unknown" };
727
+ }
728
+
729
+ return formatWindowsSchedulerServiceStatus(task, proxy);
730
+ }
731
+
732
+ function schtasksErrorDetail(error: unknown): string {
733
+ return error instanceof Error ? error.message : String(error);
734
+ }
735
+
736
+ /** True when a schtasks CSV listing line refers to the given task name. */
737
+ export function windowsSchedulerCsvIncludesTask(csv: string, taskName: string): boolean {
738
+ const needle = taskName.toLowerCase();
739
+ for (const line of csv.split(/\r?\n/)) {
740
+ const lower = line.toLowerCase();
741
+ if (!lower.includes(needle)) continue;
742
+ // Prefer exact CSV field matches ("\TaskName" / "TaskName") before a substring hit.
743
+ if (
744
+ lower.includes(`"\\${needle}"`)
745
+ || lower.includes(`"${needle}"`)
746
+ || new RegExp(`(^|[,\\\\])${needle.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}([,"]|$)`).test(lower)
747
+ ) {
748
+ return true;
749
+ }
750
+ }
751
+ return false;
752
+ }
753
+
754
+ /**
755
+ * Probe whether the OpenCodex Task Scheduler task exists.
756
+ * Query failures fall back to a CSV listing before concluding absence; if both
757
+ * fail, returns `unknown` so callers can fail closed instead of releasing locks.
758
+ */
759
+ export function probeWindowsSchedulerTask(taskName = TASK): WindowsSchedulerTaskProbe {
760
+ if (process.platform !== "win32") return { status: "absent" };
761
+
762
+ let queryFailure: string | null = null;
763
+ try {
764
+ const out = querySchtasks(["/query", "/tn", taskName]);
765
+ if (out.includes(taskName)) return { status: "present" };
766
+ } catch (error) {
767
+ queryFailure = schtasksErrorDetail(error);
768
+ }
769
+
770
+ try {
771
+ const csv = querySchtasks(["/query", "/fo", "CSV"]);
772
+ if (windowsSchedulerCsvIncludesTask(csv, taskName)) return { status: "present" };
773
+ return { status: "absent" };
774
+ } catch (error) {
775
+ const listDetail = schtasksErrorDetail(error);
776
+ const detail = queryFailure
777
+ ? `Specific query failed (${queryFailure}); CSV listing also failed (${listDetail}).`
778
+ : `Task query did not confirm presence and CSV listing failed (${listDetail}).`;
779
+ return { status: "unknown", detail };
780
+ }
781
+ }
782
+
783
+ /** True when the Task Scheduler registration for the default proxy task is proven present. */
784
+ export function windowsSchedulerTaskInstalled(taskName = TASK): boolean {
785
+ return probeWindowsSchedulerTask(taskName).status === "present";
786
+ }
787
+
788
+ export interface WindowsSchedulerInstallVerification {
789
+ taskInstalled: boolean;
790
+ registrationHealthy: boolean;
791
+ assetsHealthy: boolean;
792
+ nativeServiceAbsent: boolean;
793
+ /** True when SCM probe failed; not a proven WinSW presence. */
794
+ nativeStatusUnknown: boolean;
795
+ conflict: boolean;
796
+ ok: boolean;
797
+ detail: string;
798
+ }
799
+
800
+ /** Pure postcondition evaluation for an elevated scheduler install. */
801
+ export function evaluateWindowsSchedulerInstallVerification(inputs: {
802
+ taskInstalled: boolean;
803
+ xml: string;
804
+ assetsExist: boolean;
805
+ nativeStatus: "started" | "stopped" | "nonexistent" | "unknown";
806
+ wscript?: string;
807
+ launcher?: string;
808
+ }): WindowsSchedulerInstallVerification {
809
+ const registrationHealthy = inputs.xml.length > 0
810
+ && windowsTaskRegistrationHealthy(inputs.xml, inputs.wscript, inputs.launcher);
811
+ const assetsHealthy = inputs.assetsExist;
812
+ const nativeServiceAbsent = inputs.nativeStatus === "nonexistent";
813
+ const nativeStatusUnknown = inputs.nativeStatus === "unknown";
814
+ // Only treat proven WinSW presence as a backend conflict — never "unknown".
815
+ const conflict = inputs.taskInstalled
816
+ && (inputs.nativeStatus === "started" || inputs.nativeStatus === "stopped");
817
+ const ok = inputs.taskInstalled && registrationHealthy && assetsHealthy && nativeServiceAbsent && !conflict;
818
+ const detail = !inputs.taskInstalled
819
+ ? "Task Scheduler task is not installed."
820
+ : conflict
821
+ ? `CONFLICT: Task Scheduler and native WinSW (${WINSW_SERVICE_ID}) are both present.`
822
+ : !assetsHealthy
823
+ ? "Required scheduler service assets are missing."
824
+ : !registrationHealthy
825
+ ? (inputs.xml.trim()
826
+ ? "Task Scheduler registration is present but unhealthy."
827
+ : "Task Scheduler task is present but its XML could not be read.")
828
+ : nativeStatusUnknown
829
+ ? "The Task Scheduler task was created, but OpenCodex could not verify that the native WinSW service is absent."
830
+ : "ok";
831
+ return {
832
+ taskInstalled: inputs.taskInstalled,
833
+ registrationHealthy,
834
+ assetsHealthy,
835
+ nativeServiceAbsent,
836
+ nativeStatusUnknown,
837
+ conflict,
838
+ ok,
839
+ detail,
840
+ };
841
+ }
842
+
843
+ /** Conflict-free postcondition check for an elevated scheduler install. */
844
+ export function verifyWindowsSchedulerInstall(taskName = TASK): WindowsSchedulerInstallVerification {
845
+ const taskInstalled = windowsSchedulerTaskInstalled(taskName);
846
+ let xml = "";
847
+ if (taskInstalled) {
848
+ try { xml = querySchtasks(["/query", "/tn", taskName, "/xml"]); } catch { xml = ""; }
849
+ }
850
+ // After elevated create, non-elevated `/query /xml` can fail or return empty while the
851
+ // task is still listed. Fall back to the on-disk document we registered.
852
+ if (taskInstalled && !xml.trim()) {
853
+ const diskPath = windowsTaskXmlPath();
854
+ if (existsSync(diskPath)) {
855
+ try { xml = decodeSchtasksOutput(readFileSync(diskPath)); } catch { /* keep empty */ }
856
+ }
857
+ }
858
+ return evaluateWindowsSchedulerInstallVerification({
859
+ taskInstalled,
860
+ xml,
861
+ assetsExist: [windowsServiceScriptPath(), windowsLauncherVbsPath(), windowsTaskXmlPath()].every(existsSync),
862
+ nativeStatus: statusWinswRaw(),
863
+ });
864
+ }
865
+
866
+ async function elevateSchtasks(args: string[]): Promise<void> {
867
+ const exitCode = await runWindowsElevated(windowsSchtasks(), args);
868
+ if (exitCode !== 0) {
869
+ throw new Error(`Background service install failed with exit code ${exitCode}.`);
870
+ }
871
+ }
872
+
873
+ async function rollbackElevatedSchedulerTask(taskName = TASK): Promise<string | null> {
874
+ try {
875
+ await elevateSchtasks(["/delete", "/tn", taskName, "/f"]);
876
+ } catch (error) {
877
+ return error instanceof Error ? error.message : String(error);
878
+ }
879
+ const probe = resolveWindowsSchedulerTaskProbe(taskName);
880
+ if (probe.status === "absent") return null;
881
+ if (probe.status === "unknown") {
882
+ return `Task Scheduler task ${taskName} presence could not be verified after rollback: ${probe.detail}`;
883
+ }
884
+ return `Task Scheduler task ${taskName} is still present after rollback.`;
885
+ }
886
+
887
+ type ElevateCreateAndRunStart = (
888
+ schtasksPath: string,
889
+ createArgs: string[],
890
+ runArgs: string[],
891
+ deleteArgs: string[],
892
+ ) => ElevatedSchtasksCreateAndRunExecution;
893
+
894
+ type FinalizeHooks = {
895
+ startElevateCreateAndRun?: ElevateCreateAndRunStart;
896
+ /** Legacy sync hook used by older tests — wraps a resolved result as an execution. */
897
+ elevateCreateAndRun?: (
898
+ schtasksPath: string,
899
+ createArgs: string[],
900
+ runArgs: string[],
901
+ deleteArgs: string[],
902
+ ) => Promise<ElevatedSchtasksCreateAndRunResult>;
903
+ verify?: () => WindowsSchedulerInstallVerification;
904
+ writeInstallState?: () => void;
905
+ /** Preferred tri-state probe for security-sensitive reconciliation. */
906
+ probeTask?: () => WindowsSchedulerTaskProbe;
907
+ /** Legacy boolean hook; mapped to present/absent when probeTask is unset. */
908
+ taskInstalled?: () => boolean;
909
+ /** Defense-in-depth: late reconciliation must still own this attempt. */
910
+ stillOwnsAttempt?: (attemptId: string) => boolean;
911
+ requestTimeoutMs?: number;
912
+ };
913
+
914
+ let finalizeHooks: FinalizeHooks | null = null;
915
+
916
+ function resolveWindowsSchedulerTaskProbe(taskName = TASK): WindowsSchedulerTaskProbe {
917
+ if (finalizeHooks?.probeTask) return finalizeHooks.probeTask();
918
+ if (finalizeHooks?.taskInstalled) {
919
+ return finalizeHooks.taskInstalled() ? { status: "present" } : { status: "absent" };
920
+ }
921
+ return probeWindowsSchedulerTask(taskName);
922
+ }
923
+
924
+ /** Test-only hooks for elevated create+run finalization. */
925
+ export function setFinalizeWindowsSchedulerHooksForTests(hooks: FinalizeHooks | null): void {
926
+ finalizeHooks = hooks;
927
+ }
928
+
929
+ function throwPartialInstall(parts: string[]): never {
930
+ throw new Error(parts.filter(Boolean).join(" "));
931
+ }
932
+
933
+ /**
934
+ * Reconcile an unrecognized elevated exit when we cannot trust the phase code.
935
+ * Never invent a create-vs-run classification; inspect actual task state first.
936
+ * An unverifiable probe must fail closed (partial / blocked), never release.
937
+ */
938
+ async function reconcileUnknownElevatedOutcome(exitCode: number): Promise<void> {
939
+ const probe = resolveWindowsSchedulerTaskProbe();
940
+ const parts = [
941
+ "The elevated Task Scheduler operation returned an unknown result.",
942
+ `Exit code: ${exitCode}.`,
943
+ "OpenCodex could not prove whether task creation completed, so installation state was not written.",
944
+ ];
945
+ if (probe.status === "unknown") {
946
+ parts.push(`Task Scheduler presence could not be verified: ${probe.detail}`);
947
+ parts.push("A partial Task Scheduler backend may remain.");
948
+ throwPartialInstall(parts);
949
+ }
950
+ if (probe.status === "absent") {
951
+ parts.push("No OpenCodex Task Scheduler task was found after the elevated operation.");
952
+ throwPartialInstall(parts);
953
+ }
954
+ parts.push("A Task Scheduler task is present; attempting cleanup.");
955
+ const rollbackError = await rollbackElevatedSchedulerTask();
956
+ if (rollbackError) {
957
+ parts.push(`Cleanup also failed: ${rollbackError}`);
958
+ parts.push(`Remove the task manually with 'schtasks /delete /tn ${TASK} /f' if it remains.`);
959
+ } else {
960
+ parts.push("The elevated Task Scheduler task was removed.");
961
+ }
962
+ throwPartialInstall(parts);
963
+ }
964
+
965
+ type ApplyElevatedOptions = {
966
+ attemptId: string;
967
+ writeOnSuccess: boolean;
968
+ stillOwnsAttempt?: (attemptId: string) => boolean;
969
+ };
970
+
971
+ function attemptStillOwned(options: ApplyElevatedOptions): boolean {
972
+ const check = options.stillOwnsAttempt ?? finalizeHooks?.stillOwnsAttempt;
973
+ return !check || check(options.attemptId);
974
+ }
975
+
976
+ async function applyElevatedSchedulerResult(
977
+ result: ElevatedSchtasksCreateAndRunResult,
978
+ options: ApplyElevatedOptions,
979
+ ): Promise<void> {
980
+ if (!attemptStillOwned(options)) {
981
+ return;
982
+ }
983
+ const outcome: ElevatedSchedulerOutcome = result.outcome;
984
+
985
+ if (outcome === "create-failed") {
986
+ throw new Error("Elevated schtasks /create failed. The Task Scheduler task was not registered.");
987
+ }
988
+ if (outcome === "run-failed-rolled-back") {
989
+ throw new Error(
990
+ "Elevated schtasks /run failed after the task was registered. The elevated process rolled the task back. Installation state was not written.",
991
+ );
992
+ }
993
+ if (outcome === "run-failed-rollback-failed") {
994
+ throwPartialInstall([
995
+ "Elevated schtasks /run failed after the task was registered, and elevated rollback also failed.",
996
+ "A partial Task Scheduler backend may remain.",
997
+ `Remove the task manually with 'schtasks /delete /tn ${TASK} /f' if present.`,
998
+ "Installation state was not written.",
999
+ ]);
1000
+ }
1001
+ if (outcome !== "success") {
1002
+ await reconcileUnknownElevatedOutcome(result.exitCode);
1003
+ }
1004
+
1005
+ const verification = (finalizeHooks?.verify ?? verifyWindowsSchedulerInstall)();
1006
+ if (!verification.ok) {
1007
+ // Preserve a healthy elevated task when WinSW absence cannot be proven (unknown SCM status).
1008
+ // Unknown is not a confirmed dual-backend conflict; install state is still withheld.
1009
+ const preserveElevatedTask = verification.taskInstalled
1010
+ && verification.registrationHealthy
1011
+ && verification.assetsHealthy
1012
+ && !verification.conflict
1013
+ && verification.nativeStatusUnknown;
1014
+ if (preserveElevatedTask) {
1015
+ throwPartialInstall([
1016
+ "Elevated Task Scheduler registration did not produce a conflict-free install.",
1017
+ verification.detail,
1018
+ "The elevated Task Scheduler task was left in place because native WinSW status could not be verified.",
1019
+ "Installation state was not written.",
1020
+ ]);
1021
+ }
1022
+ const rollbackError = await rollbackElevatedSchedulerTask();
1023
+ const parts = [
1024
+ "Elevated Task Scheduler registration did not produce a conflict-free install.",
1025
+ verification.detail,
1026
+ ];
1027
+ if (rollbackError) {
1028
+ parts.push(`Rollback also failed: ${rollbackError}`);
1029
+ parts.push(`Remove the task manually with 'schtasks /delete /tn ${TASK} /f' and the native service with 'sc delete ${WINSW_SERVICE_ID}' if present.`);
1030
+ } else {
1031
+ parts.push("The elevated Task Scheduler task was rolled back.");
1032
+ }
1033
+ parts.push("Installation state was not written.");
1034
+ throwPartialInstall(parts);
1035
+ }
1036
+ if (options.writeOnSuccess) {
1037
+ if (!attemptStillOwned(options)) {
1038
+ return;
1039
+ }
1040
+ (finalizeHooks?.writeInstallState ?? (() => writeServiceInstallState("scheduler")))();
1041
+ }
1042
+ }
1043
+
1044
+ /** Outcome of late reconciliation after a request-level elevation timeout. */
1045
+ export type ElevatedReconciliationOutcome =
1046
+ | "released"
1047
+ | "blocked-partial";
1048
+
1049
+ export type FinalizeWindowsSchedulerResult =
1050
+ | { kind: "done" }
1051
+ | {
1052
+ kind: "indeterminate";
1053
+ attemptId: string;
1054
+ /** Settles after the elevated transaction finishes and late reconciliation runs. */
1055
+ reconciliation: Promise<ElevatedReconciliationOutcome>;
1056
+ };
1057
+
1058
+ export type FinalizeWindowsSchedulerOptions = {
1059
+ attemptId?: string;
1060
+ stillOwnsAttempt?: (attemptId: string) => boolean;
1061
+ requestTimeoutMs?: number;
1062
+ };
1063
+
1064
+ function startElevateExecution(
1065
+ schtasksPath: string,
1066
+ createArgs: string[],
1067
+ runArgs: string[],
1068
+ deleteArgs: string[],
1069
+ ): ElevatedSchtasksCreateAndRunExecution {
1070
+ if (finalizeHooks?.startElevateCreateAndRun) {
1071
+ return finalizeHooks.startElevateCreateAndRun(schtasksPath, createArgs, runArgs, deleteArgs);
1072
+ }
1073
+ if (finalizeHooks?.elevateCreateAndRun) {
1074
+ const completion = finalizeHooks.elevateCreateAndRun(schtasksPath, createArgs, runArgs, deleteArgs);
1075
+ return { completion, launcherPid: null };
1076
+ }
1077
+ return startElevatedSchtasksCreateAndRun(schtasksPath, createArgs, runArgs, deleteArgs);
1078
+ }
1079
+
1080
+ function isPartialInstallError(error: unknown): boolean {
1081
+ if (!(error instanceof Error)) return false;
1082
+ return /partial Task Scheduler/i.test(error.message)
1083
+ || /Cleanup also failed/i.test(error.message)
1084
+ || /left in place because native WinSW status could not be verified/i.test(error.message)
1085
+ || /Task Scheduler presence could not be verified/i.test(error.message);
1086
+ }
1087
+
1088
+ /**
1089
+ * Re-register the scheduler task with elevation after a non-elevated install wrote assets.
1090
+ *
1091
+ * Request timeout does not kill the elevated launcher. On timeout this returns
1092
+ * `indeterminate` and keeps reconciling the eventual protocol result.
1093
+ */
1094
+ export async function finalizeWindowsSchedulerServiceRegistration(
1095
+ script = windowsServiceScriptPath(),
1096
+ options?: FinalizeWindowsSchedulerOptions,
1097
+ ): Promise<FinalizeWindowsSchedulerResult> {
1098
+ if (process.platform !== "win32") {
1099
+ throw new Error("Windows scheduler registration is only supported on Windows.");
1100
+ }
1101
+ const attemptId = options?.attemptId ?? randomUUID();
1102
+ const stillOwnsAttempt = options?.stillOwnsAttempt ?? finalizeHooks?.stillOwnsAttempt;
1103
+ const createArgs = buildWindowsSchtasksCreateArgs(script);
1104
+ const runArgs = ["/run", "/tn", TASK];
1105
+ const deleteArgs = ["/delete", "/tn", TASK, "/f"];
1106
+ const started = startElevateExecution(windowsSchtasks(), createArgs, runArgs, deleteArgs);
1107
+ const timeoutMs = options?.requestTimeoutMs
1108
+ ?? finalizeHooks?.requestTimeoutMs
1109
+ ?? ELEVATION_REQUEST_TIMEOUT_MS;
1110
+ const applyOpts: ApplyElevatedOptions = { attemptId, writeOnSuccess: true, stillOwnsAttempt };
1111
+
1112
+ let raced: { status: "completed"; value: ElevatedSchtasksCreateAndRunResult } | { status: "timed-out" };
1113
+ try {
1114
+ raced = await raceWithTimeout(started.completion, timeoutMs);
1115
+ } catch (error) {
1116
+ // Cancellation / launch failure / signal before or instead of a protocol result.
1117
+ // Signal after Start-Process may leave an elevated child; reconcile conservatively.
1118
+ if (error instanceof WindowsElevationError && error.reason === "terminated") {
1119
+ try {
1120
+ await reconcileUnknownElevatedOutcome(OCX_ELEVATED_PROTOCOL_FAILED);
1121
+ } catch (reconcileError) {
1122
+ // Prefer the reconciliation detail (partial install / cleanup guidance) over the
1123
+ // generic signal message so callers can block retries when a task remains.
1124
+ throw reconcileError;
1125
+ }
1126
+ }
1127
+ throw error;
1128
+ }
1129
+
1130
+ if (raced.status === "completed") {
1131
+ await applyElevatedSchedulerResult(raced.value, applyOpts);
1132
+ return { kind: "done" };
1133
+ }
1134
+
1135
+ const reconciliation = (async (): Promise<ElevatedReconciliationOutcome> => {
1136
+ try {
1137
+ const result = await started.completion;
1138
+ await applyElevatedSchedulerResult(result, applyOpts);
1139
+ return "released";
1140
+ } catch (error) {
1141
+ if (error instanceof WindowsElevationError && error.reason === "cancelled") {
1142
+ return "released";
1143
+ }
1144
+ if (error instanceof WindowsElevationError && error.reason === "launch-failed") {
1145
+ return "released";
1146
+ }
1147
+ if (error instanceof WindowsElevationError && error.reason === "terminated") {
1148
+ try {
1149
+ await reconcileUnknownElevatedOutcome(OCX_ELEVATED_PROTOCOL_FAILED);
1150
+ return "released";
1151
+ } catch (reconcileError) {
1152
+ return isPartialInstallError(reconcileError) ? "blocked-partial" : "released";
1153
+ }
1154
+ }
1155
+ // applyElevatedSchedulerResult failures are expected (create/run/conflict); swallow for background.
1156
+ if (isPartialInstallError(error)) {
1157
+ return "blocked-partial";
1158
+ }
1159
+ return "released";
1160
+ }
1161
+ })();
1162
+
1163
+ return { kind: "indeterminate", attemptId, reconciliation };
1164
+ }
1165
+
1166
+ /**
1167
+ * Pure post-restart / pre-install advisory check. Does not mutate state.
1168
+ * A process-local indeterminate lock cannot survive restart — callers must inspect reality.
1169
+ */
1170
+ export function evaluateSchedulerInstallRestartReconciliation(inputs: {
1171
+ taskInstalled: boolean;
1172
+ registrationHealthy: boolean;
1173
+ assetsHealthy: boolean;
1174
+ nativeStatus: "started" | "stopped" | "nonexistent" | "unknown";
1175
+ installStateBackend: "scheduler" | "native" | null;
1176
+ }): {
1177
+ status: "healthy" | "orphan-task" | "stale-install-state" | "conflict" | "unhealthy" | "unverified";
1178
+ detail: string;
1179
+ } {
1180
+ const conflict = inputs.taskInstalled
1181
+ && (inputs.nativeStatus === "started" || inputs.nativeStatus === "stopped");
1182
+ if (conflict) {
1183
+ return {
1184
+ status: "conflict",
1185
+ detail: `CONFLICT: Task Scheduler and native WinSW (${WINSW_SERVICE_ID}) are both present.`,
1186
+ };
1187
+ }
1188
+ if (inputs.taskInstalled && inputs.nativeStatus === "unknown") {
1189
+ return {
1190
+ status: "unverified",
1191
+ detail: "The Task Scheduler task exists, but native WinSW status could not be verified.",
1192
+ };
1193
+ }
1194
+ if (inputs.taskInstalled && (!inputs.registrationHealthy || !inputs.assetsHealthy)) {
1195
+ return {
1196
+ status: "unhealthy",
1197
+ detail: !inputs.assetsHealthy
1198
+ ? "Required scheduler service assets are missing."
1199
+ : "Task Scheduler registration is present but unhealthy.",
1200
+ };
1201
+ }
1202
+ if (inputs.taskInstalled && inputs.installStateBackend !== "scheduler") {
1203
+ return {
1204
+ status: "orphan-task",
1205
+ detail: "A Task Scheduler task is present without matching scheduler install state.",
1206
+ };
1207
+ }
1208
+ if (!inputs.taskInstalled && inputs.installStateBackend === "scheduler") {
1209
+ return {
1210
+ status: "stale-install-state",
1211
+ detail: "Scheduler install state is present but the Task Scheduler task is absent.",
1212
+ };
1213
+ }
1214
+ return { status: "healthy", detail: "ok" };
1215
+ }
1216
+
1217
+ function windowsBatchValue(value: string): string {
1218
+ return value
1219
+ .replace(/%/g, "%%")
1220
+ .replace(/\^/g, "^^")
1221
+ .replace(/"/g, "")
1222
+ .replace(/[\r\n]/g, "");
1223
+ }
1224
+
1225
+ type WindowsBatchValueKind = "raw" | "path" | "pathList";
1226
+
1227
+ function windowsBatchSet(name: string, value: string | undefined, kind: WindowsBatchValueKind = "raw"): string | null {
1228
+ if (!value) return null;
1229
+ const rendered =
1230
+ kind === "path" ? windowsEnvIndirectBatchValue(value, windowsBatchValue)
1231
+ : kind === "pathList" ? windowsEnvIndirectBatchPathList(value, windowsBatchValue)
1232
+ : windowsBatchValue(value);
1233
+ return `set "${name}=${rendered}"`;
1234
+ }
1235
+
1236
+ function taskXmlString(value: string): string {
1237
+ return value
1238
+ .replace(/&/g, "&amp;")
1239
+ .replace(/</g, "&lt;")
1240
+ .replace(/>/g, "&gt;")
1241
+ .replace(/"/g, "&quot;")
1242
+ .replace(/'/g, "&apos;");
1243
+ }
1244
+
1245
+ /**
1246
+ * RunLevel check. Schema default is LeastPrivilege (omitted on export). Elevated
1247
+ * `schtasks /create` often rewrites the registered task to HighestAvailable even when
1248
+ * the source XML asked for LeastPrivilege — still InteractiveToken / same user.
1249
+ * Keep accepting HighestAvailable here: rejecting it would false-fail healthy elevated
1250
+ * installs, and windowsTaskRegistrationHealthy tests encode that contract.
1251
+ */
1252
+ function taskXmlRunLevelAcceptable(principal: string): boolean {
1253
+ if (taskXmlHasPrefixedTag(principal, "RunLevel")) return false;
1254
+ const count = taskXmlElementCount(principal, "RunLevel");
1255
+ if (count === 0) return true;
1256
+ if (count > 1) return false;
1257
+ const value = new RegExp(`<RunLevel(?:\\s[^>]*?)?>\\s*([^<]*?)\\s*<\\/RunLevel>`, "i").exec(principal)?.[1]?.trim().toLowerCase();
1258
+ return value === "leastprivilege" || value === "highestavailable";
1259
+ }
1260
+
1261
+ export function buildWindowsServiceScript(entry = cliEntry(), port = resolveServiceListenPort()): string {
1262
+ const { bun, cli } = entry;
1263
+ const bunRuntime = durableBunRuntime();
1264
+ const path = process.env.PATH ?? "";
1265
+ const lines = [
1266
+ "@echo off",
1267
+ "setlocal",
1268
+ // The wrapper console is hidden by the wscript launcher (window style 0), so switching
1269
+ // it to UTF-8 is safe (no leak into user shells) and lets cmd parse UTF-8 remnants.
1270
+ "chcp 65001 >nul",
1271
+ windowsBatchSet("OCX_SERVICE", "1"),
1272
+ windowsBatchSet("PATH", path, "pathList"),
1273
+ windowsBatchSet("CODEX_HOME", process.env.CODEX_HOME?.trim(), "path"),
1274
+ windowsBatchSet("OPENCODEX_HOME", process.env.OPENCODEX_HOME?.trim(), "path"),
1275
+ windowsBatchSet("OCX_API_TOKEN_FILE", serviceApiTokenFilePath(), "path"),
1276
+ windowsBatchSet("OCX_SERVICE_LOG", serviceLogPath(), "path"),
1277
+ windowsBatchSet("OCX_BUN", bun, "path"),
1278
+ windowsBatchSet("OCX_CLI", cli, "path"),
1279
+ 'if exist "%OCX_API_TOKEN_FILE%" (',
1280
+ ' set /p OPENCODEX_API_AUTH_TOKEN=<"%OCX_API_TOKEN_FILE%"',
1281
+ ")",
1282
+ ":loop",
1283
+ '>>"%OCX_SERVICE_LOG%" echo [%DATE% %TIME%] opencodex service wrapper start',
1284
+ '>>"%OCX_SERVICE_LOG%" echo bun="%OCX_BUN%"',
1285
+ `>>"%OCX_SERVICE_LOG%" echo bun_source="${bunRuntime.source}"`,
1286
+ '>>"%OCX_SERVICE_LOG%" echo cli="%OCX_CLI%"',
1287
+ '>>"%OCX_SERVICE_LOG%" echo opencodex_home="%OPENCODEX_HOME%"',
1288
+ '>>"%OCX_SERVICE_LOG%" echo codex_home="%CODEX_HOME%"',
1289
+ '>>"%OCX_SERVICE_LOG%" echo token_file="%OCX_API_TOKEN_FILE%"',
1290
+ `"%OCX_BUN%" "%OCX_CLI%" start --port ${port} >>"%OCX_SERVICE_LOG%" 2>&1`,
1291
+ "if %ERRORLEVEL% NEQ 0 (",
1292
+ ' >>"%OCX_SERVICE_LOG%" echo [%DATE% %TIME%] child exited with code %ERRORLEVEL%; restarting in 5s',
1293
+ // `timeout` needs console stdin and dies with "Input redirection is not supported"
1294
+ // under Task Scheduler, turning the 5s cooldown into a hot restart loop; ping doesn't.
1295
+ " ping -n 6 127.0.0.1 >nul",
1296
+ " goto loop",
1297
+ ")",
1298
+ "endlocal",
1299
+ ].filter((line): line is string => Boolean(line));
1300
+ return `${lines.join("\r\n")}\r\n`;
1301
+ }
1302
+
1303
+ export function buildWindowsSchtasksCreateArgs(script = windowsServiceScriptPath()): string[] {
1304
+ const xml = script === windowsServiceScriptPath() ? windowsTaskXmlPath() : `${script}.xml`;
1305
+ return ["/create", "/tn", TASK, "/xml", xml, "/f"];
1306
+ }
1307
+
1308
+ /**
1309
+ * VBS launcher that starts the batch wrapper with a hidden window (style 0).
1310
+ * bWaitOnReturn=True keeps wscript.exe resident for the wrapper's lifetime so the
1311
+ * scheduled task stays "running": MultipleInstancesPolicy=IgnoreNew keeps preventing
1312
+ * duplicates and `schtasks /end` still has a live task instance to stop. Without the
1313
+ * launcher, the console batch action shows a closable cmd window in the interactive
1314
+ * session (issue #165). VBS string literals escape `"` as `""`.
1315
+ */
1316
+ export function buildWindowsLauncherVbs(script = windowsServiceScriptPath()): string {
1317
+ const escaped = script.replace(/"/g, '""');
1318
+ const lines = [
1319
+ "' OpenCodex service launcher — runs the batch wrapper with a hidden window.",
1320
+ "' Generated by `ocx service install`; do not edit.",
1321
+ 'Set shell = CreateObject("WScript.Shell")',
1322
+ // WshShell.Run(command, windowStyle 0 = hidden, bWaitOnReturn True = stay resident).
1323
+ `shell.Run """${escaped}""", 0, True`,
1324
+ ];
1325
+ return `${lines.join("\r\n")}\r\n`;
1326
+ }
1327
+
1328
+ export function buildWindowsTaskXml(script = windowsServiceScriptPath(), launcher = windowsLauncherVbsPath()): string {
1329
+ const escapedWscript = taskXmlString(windowsWscript());
1330
+ // Escape the launcher path independently for the <Arguments> element; quoting it
1331
+ // keeps spaces intact, and /b (batch mode) suppresses script error popups.
1332
+ const escapedLauncherArgs = taskXmlString(`/b /nologo "${launcher}"`);
1333
+ return `<?xml version="1.0" encoding="UTF-16"?>
1334
+ <Task version="1.4" xmlns="http://schemas.microsoft.com/windows/2004/02/mit/task">
1335
+ <RegistrationInfo>
1336
+ <Description>OpenCodex proxy service wrapper</Description>
1337
+ </RegistrationInfo>
1338
+ <Triggers>
1339
+ <LogonTrigger>
1340
+ <Enabled>true</Enabled>
1341
+ </LogonTrigger>
1342
+ </Triggers>
1343
+ <Principals>
1344
+ <Principal id="Author">
1345
+ <LogonType>InteractiveToken</LogonType>
1346
+ <RunLevel>LeastPrivilege</RunLevel>
1347
+ </Principal>
1348
+ </Principals>
1349
+ <Settings>
1350
+ <MultipleInstancesPolicy>IgnoreNew</MultipleInstancesPolicy>
1351
+ <DisallowStartIfOnBatteries>false</DisallowStartIfOnBatteries>
1352
+ <StopIfGoingOnBatteries>false</StopIfGoingOnBatteries>
1353
+ <AllowHardTerminate>true</AllowHardTerminate>
1354
+ <StartWhenAvailable>true</StartWhenAvailable>
1355
+ <RunOnlyIfNetworkAvailable>false</RunOnlyIfNetworkAvailable>
1356
+ <AllowStartOnDemand>true</AllowStartOnDemand>
1357
+ <Enabled>true</Enabled>
1358
+ <Hidden>false</Hidden>
1359
+ <ExecutionTimeLimit>PT0S</ExecutionTimeLimit>
1360
+ <Priority>7</Priority>
1361
+ <RestartOnFailure>
1362
+ <Interval>PT1M</Interval>
1363
+ <Count>3</Count>
1364
+ </RestartOnFailure>
1365
+ </Settings>
1366
+ <Actions Context="Author">
1367
+ <Exec>
1368
+ <Command>${escapedWscript}</Command>
1369
+ <Arguments>${escapedLauncherArgs}</Arguments>
1370
+ </Exec>
1371
+ </Actions>
1372
+ </Task>
1373
+ `;
1374
+ }
1375
+
1376
+ function taskXmlSection(xml: string, tag: string): string {
1377
+ return new RegExp(`<${tag}(?:\\s[^>]*)?>([\\s\\S]*?)<\\/${tag}>`, "i").exec(xml)?.[1] ?? "";
1378
+ }
1379
+
1380
+ /** Drop comments and CDATA so a commented-out decoy cannot satisfy any check. */
1381
+ function taskXmlWithoutCommentsAndCdata(xml: string): string {
1382
+ return xml.replace(/<!--[\s\S]*?-->/g, "").replace(/<!\[CDATA\[[\s\S]*?\]\]>/g, "");
1383
+ }
1384
+
1385
+ /**
1386
+ * Count occurrences of an unprefixed tag, including the self-closing form. The
1387
+ * element boundary matters: `<EnabledExtra>` must not count as `Enabled`.
1388
+ */
1389
+ function taskXmlElementCount(xml: string, tag: string): number {
1390
+ return xml.match(new RegExp(`<${tag}(?:\\s[^>]*?)?\\s*\\/?>`, "gi"))?.length ?? 0;
1391
+ }
1392
+
1393
+ /**
1394
+ * True when a namespace-prefixed form of the tag appears. A prefixed element bound
1395
+ * to the task namespace carries a real value, but this module parses by regex and
1396
+ * cannot resolve prefixes — so it fails closed instead of reading the element as
1397
+ * absent (which would silently apply the schema default).
1398
+ */
1399
+ function taskXmlHasPrefixedTag(xml: string, tag: string): boolean {
1400
+ return new RegExp(`<[A-Za-z_][\\w.-]*:${tag}(?:[\\s/>])`, "i").test(xml);
1401
+ }
1402
+
1403
+ /**
1404
+ * Compare an element that Task Scheduler may omit when exporting a registered task.
1405
+ * Absence means the documented schema default (#432); a present element must still
1406
+ * match exactly, so a malformed or explicitly unsafe value never reads as healthy.
1407
+ */
1408
+ /**
1409
+ * Decode XML's five predefined entities, exactly once.
1410
+ *
1411
+ * Task Scheduler re-encodes element text when it exports a task, so a needle we
1412
+ * escaped ourselves can never match its output (#608). Compare decoded values
1413
+ * instead of encoded ones.
1414
+ *
1415
+ * The single pass is the point: decoding twice would turn `&amp;quot;` into `"`,
1416
+ * letting a doubly-encoded value impersonate the expected launcher path.
1417
+ */
1418
+ function taskXmlDecodeEntities(value: string): string {
1419
+ return value.replace(/&(amp|lt|gt|quot|apos);/g, (_, name: string) => (
1420
+ name === "amp" ? "&"
1421
+ : name === "lt" ? "<"
1422
+ : name === "gt" ? ">"
1423
+ : name === "quot" ? "\""
1424
+ : "'"
1425
+ ));
1426
+ }
1427
+
1428
+ /**
1429
+ * Exactly one unprefixed `<tag>` whose DECODED text equals `expected`.
1430
+ *
1431
+ * Unlike taskXmlOptionalValueEquals(), an absent element is NOT a pass: these
1432
+ * elements name what actually gets executed, so a missing <Command>/<Arguments>
1433
+ * must fail the health check rather than inherit a schema default.
1434
+ */
1435
+ function taskXmlDecodedValueEquals(xml: string, tag: string, expected: string): boolean {
1436
+ // Same reasoning as the optional helper: `<t:Arguments>` must not read as absent.
1437
+ if (taskXmlHasPrefixedTag(xml, tag)) return false;
1438
+ if (taskXmlElementCount(xml, tag) !== 1) return false;
1439
+ // `[^<]*` refuses nested markup, so a decoy inside a child element cannot match.
1440
+ const value = new RegExp(`<${tag}(?:\\s[^>]*?)?>([^<]*)<\\/${tag}>`, "i").exec(xml)?.[1];
1441
+ if (value === undefined) return false;
1442
+ return taskXmlDecodeEntities(value).trim().toLowerCase() === expected.trim().toLowerCase();
1443
+ }
1444
+
1445
+ function taskXmlOptionalValueEquals(xml: string, tag: string, expected: string): boolean {
1446
+ // Check the prefixed form first: treating `<t:Enabled>false</t:Enabled>` as an
1447
+ // omission would turn an explicitly disabled task into a healthy one.
1448
+ if (taskXmlHasPrefixedTag(xml, tag)) return false;
1449
+ const count = taskXmlElementCount(xml, tag);
1450
+ if (count === 0) return true;
1451
+ if (count > 1) return false;
1452
+ const value = new RegExp(`<${tag}(?:\\s[^>]*?)?>\\s*([^<]*?)\\s*<\\/${tag}>`, "i").exec(xml)?.[1];
1453
+ return value?.trim().toLowerCase() === expected.toLowerCase();
1454
+ }
1455
+
1456
+ /** Validate the security/lifecycle-critical fields of the registered scheduler task. */
1457
+ export function windowsTaskRegistrationHealthy(
1458
+ xml: string,
1459
+ wscript = windowsWscript(),
1460
+ launcher = windowsLauncherVbsPath(),
1461
+ ): boolean {
1462
+ const scrubbed = taskXmlWithoutCommentsAndCdata(xml);
1463
+ // taskXmlSection() takes the FIRST match and the schema allows arbitrary XML under
1464
+ // Task/Data, so a Data block placed before the real sections could shadow them.
1465
+ // We never emit Data, so its presence alone disqualifies the registration. Both
1466
+ // forms are rejected because taskXmlElementCount() ignores prefixed tags.
1467
+ if (taskXmlElementCount(scrubbed, "Data") > 0 || taskXmlHasPrefixedTag(scrubbed, "Data")) return false;
1468
+ const triggers = taskXmlSection(scrubbed, "Triggers");
1469
+ const trigger = taskXmlSection(triggers, "LogonTrigger");
1470
+ const principal = taskXmlSection(scrubbed, "Principal");
1471
+ const settings = taskXmlSection(scrubbed, "Settings");
1472
+ const action = taskXmlSection(scrubbed, "Exec");
1473
+ // A self-closing <LogonTrigger /> leaves an empty section, so look for the element
1474
+ // itself — scoped to <Triggers> so a decoy elsewhere cannot satisfy it.
1475
+ return taskXmlElementCount(triggers, "LogonTrigger") > 0
1476
+ && taskXmlOptionalValueEquals(trigger, "Enabled", "true")
1477
+ && /<LogonType>\s*InteractiveToken\s*<\/LogonType>/i.test(principal)
1478
+ && taskXmlRunLevelAcceptable(principal)
1479
+ && taskXmlOptionalValueEquals(settings, "Enabled", "true")
1480
+ && /<MultipleInstancesPolicy>\s*IgnoreNew\s*<\/MultipleInstancesPolicy>/i.test(settings)
1481
+ && /<ExecutionTimeLimit>\s*PT0S\s*<\/ExecutionTimeLimit>/i.test(settings)
1482
+ // Compare decoded VALUES, not encodings: Task Scheduler canonicalizes the
1483
+ // quotes we wrote as `&quot;` back to literal `"` on export, so an escaped
1484
+ // needle never matched and a healthy task read as permanently stale (#608).
1485
+ // Case-insensitive: elevated `schtasks /create` may rewrite System32 casing.
1486
+ && taskXmlDecodedValueEquals(action, "Command", wscript)
1487
+ && taskXmlDecodedValueEquals(action, "Arguments", `/b /nologo "${launcher}"`);
1488
+ }
1489
+
1490
+ export interface WindowsSchedulerXmlState {
1491
+ installed: boolean;
1492
+ enabled: boolean;
1493
+ registrationHealthy: boolean;
1494
+ }
1495
+
1496
+ /**
1497
+ * Single source of truth for reading a registered task's XML. Both the status
1498
+ * diagnostic and its tests go through here, so a partial fix cannot leave one
1499
+ * caller on an older, stricter reading of the same document (#432).
1500
+ */
1501
+ export function readWindowsSchedulerXmlState(
1502
+ xml: string,
1503
+ wscript?: string,
1504
+ launcher?: string,
1505
+ ): WindowsSchedulerXmlState {
1506
+ const installed = xml.length > 0;
1507
+ if (!installed) return { installed: false, enabled: false, registrationHealthy: false };
1508
+ const scrubbed = taskXmlWithoutCommentsAndCdata(xml);
1509
+ const hasData = taskXmlElementCount(scrubbed, "Data") > 0 || taskXmlHasPrefixedTag(scrubbed, "Data");
1510
+ const settings = hasData ? "" : taskXmlSection(scrubbed, "Settings");
1511
+ return {
1512
+ installed: true,
1513
+ enabled: !hasData && taskXmlOptionalValueEquals(settings, "Enabled", "true"),
1514
+ registrationHealthy: windowsTaskRegistrationHealthy(xml, wscript, launcher),
1515
+ };
1516
+ }
1517
+
1518
+ // ── macOS (launchd) ──
1519
+ function installLaunchd(): void {
1520
+ const dir = join(homedir(), "Library", "LaunchAgents");
1521
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
1522
+ recordOwnedConfigPath(getConfigDir(), serviceStatePath());
1523
+ if (!existsSync(getConfigDir())) mkdirSync(getConfigDir(), { recursive: true });
1524
+ writeServiceApiTokenFile();
1525
+ const p = plistPath();
1526
+ writeFileSync(p, buildPlist(), "utf8");
1527
+ // Best-effort: an absent job is fine here, and a failed unload is caught by the
1528
+ // load verification below with a better message than a raw unload error.
1529
+ runLaunchctl(["unload", p]);
1530
+ const loaded = runLaunchctl(["load", "-w", p]);
1531
+ if (!loaded.ok || launchctlLoadFailed(loaded.stderr)) {
1532
+ // Do NOT write install state for a load that did not take: state describing an
1533
+ // unused plist is what made this failure invisible.
1534
+ throw new Error(
1535
+ `launchctl could not load ${p}: ${loaded.stderr || "load reported failure"}\n`
1536
+ + "A previous job may still be bootstrapped. Try:\n"
1537
+ + ` launchctl bootout ${launchdGuiDomain()}/${LABEL}\n`
1538
+ + "then re-run 'ocx service install'.",
1539
+ );
1540
+ }
1541
+ writeServiceInstallState();
1542
+ }
1543
+ /**
1544
+ * Deps are named for the layer they replace, not for the process API: `launchctl`
1545
+ * returns a {@link runLaunchctl} result and `matches` a {@link launchdJobMatchesPlist}
1546
+ * result. Only `runLaunchctl` itself takes a spawnSync mock.
1547
+ *
1548
+ * Exported for the branch tests. Every parameter is optional, so this stays
1549
+ * assignable to `ServiceOps.start` (`() => void`) and `platformOps` wires the same
1550
+ * function the tests exercise.
1551
+ */
1552
+ export function startLaunchd(deps: {
1553
+ launchctl?: typeof runLaunchctl;
1554
+ matches?: typeof launchdJobMatchesPlist;
1555
+ } = {}): void {
1556
+ const run = deps.launchctl ?? runLaunchctl;
1557
+ const p = plistPath();
1558
+ const loaded = run(["load", "-w", p]);
1559
+ if (loaded.ok && !launchctlLoadFailed(loaded.stderr)) return;
1560
+ // `Load failed` on start is AMBIGUOUS in a way it is not on install: the job may
1561
+ // already be bootstrapped from THIS plist, which is a no-op rather than an error.
1562
+ // `install` can assume a stale job (it just rewrote the plist); `start` cannot, and
1563
+ // throwing here would break `ocx service start` on every healthy service.
1564
+ const entry = cliEntry();
1565
+ const live = (deps.matches ?? launchdJobMatchesPlist)(
1566
+ buildServiceShellCommand(entry.bun, entry.cli),
1567
+ );
1568
+ if (live.loaded && live.matchesPlist) {
1569
+ console.log("ℹ️ service was already loaded from the current plist; nothing to do.");
1570
+ return;
1571
+ }
1572
+ throw new Error(
1573
+ `launchctl could not load ${p}: ${loaded.stderr || "load reported failure"}\n`
1574
+ + (live.loaded
1575
+ ? `launchd is running an OLDER plist. Fix:\n launchctl bootout ${launchdGuiDomain()}/${LABEL}\n ocx service install`
1576
+ : "The job is not loaded. Run 'ocx service install' to re-register it."),
1577
+ );
1578
+ }
1579
+ function stopLaunchd(): void { try { sh(`launchctl unload "${plistPath()}"`); } catch { /* not loaded */ } }
1580
+ function statusLaunchd(): string { try { return sh(`launchctl list | grep ${LABEL} || true`); } catch { return ""; } }
1581
+ function uninstallLaunchd(): void {
1582
+ const p = plistPath();
1583
+ try { sh(`launchctl unload "${p}" 2>/dev/null`); } catch { /* not loaded */ }
1584
+ if (existsSync(p)) unlinkSync(p);
1585
+ }
1586
+
1587
+ // ── Windows (Task Scheduler) ──
1588
+ /**
1589
+ * In-place service-asset write that tolerates the transient EBUSY/EPERM/EACCES Windows
1590
+ * throws while the just-ended task's cmd.exe (or an AV scanner) still holds the file.
1591
+ */
1592
+ function writeServiceAssetWithRetry(path: string, content: string, encoding: "utf8" | "utf16le"): void {
1593
+ for (let attempt = 0; ; attempt++) {
1594
+ try {
1595
+ writeFileSync(path, content, encoding);
1596
+ return;
1597
+ } catch (err) {
1598
+ const code = (err as NodeJS.ErrnoException).code;
1599
+ if (attempt >= 2 || (code !== "EBUSY" && code !== "EPERM" && code !== "EACCES")) throw err;
1600
+ Bun.sleepSync(150);
1601
+ }
1602
+ }
1603
+ }
1604
+
1605
+ /**
1606
+ * Rewrite on-disk scheduler assets (script/VBS/XML) without re-registering the task.
1607
+ * Used by fresh install (before schtasks /create) and by repair (no elevation).
1608
+ */
1609
+ function writeWindowsSchedulerAssets(): void {
1610
+ if (!existsSync(getConfigDir())) mkdirSync(getConfigDir(), { recursive: true });
1611
+ writeServiceApiTokenFile();
1612
+ const script = windowsServiceScriptPath();
1613
+ writeServiceAssetWithRetry(script, buildWindowsServiceScript(), "utf8");
1614
+ // UTF-16LE + BOM: a BOM-less UTF-8 VBS mis-decodes non-ASCII (e.g. Korean) profile
1615
+ // paths on some WSH/codepage combinations — same contract as the task XML below.
1616
+ writeServiceAssetWithRetry(windowsLauncherVbsPath(), `\uFEFF${buildWindowsLauncherVbs(script)}`, "utf16le");
1617
+ writeServiceAssetWithRetry(windowsTaskXmlPath(), `\uFEFF${buildWindowsTaskXml(script)}`, "utf16le");
1618
+ }
1619
+
1620
+ function installWindows(): void {
1621
+ recordOwnedConfigPath(getConfigDir(), serviceStatePath());
1622
+ // Transactional backend switch: installing the scheduler backend removes a native
1623
+ // service first — two live managers would both respawn the proxy (conflict).
1624
+ if (statusWinswRaw() !== "nonexistent") {
1625
+ console.log("🔁 Removing the native (WinSW) service before installing the Task Scheduler backend...");
1626
+ try {
1627
+ uninstallWinswService();
1628
+ } catch (err) {
1629
+ throw new Error(`Cannot remove the native service before switching to Task Scheduler: ${err instanceof Error ? err.message : String(err)}. Remove it manually with 'sc delete ${WINSW_SERVICE_ID}' or retry.`);
1630
+ }
1631
+ if (statusWinswRaw() !== "nonexistent") {
1632
+ throw new Error(`Native service registration could not be re-verified after the removal attempt — aborting switch. Check 'sc.exe query ${WINSW_SERVICE_ID}' and remove it manually if present.`);
1633
+ }
1634
+ }
1635
+ // End a running task BEFORE rewriting the assets it is executing — cmd.exe reading the
1636
+ // script mid-rewrite runs a torn batch file, and its open handle can fail the write.
1637
+ try { stopWindows(); } catch { /* not running */ }
1638
+ writeWindowsSchedulerAssets();
1639
+ schtasks(buildWindowsSchtasksCreateArgs(windowsServiceScriptPath()));
1640
+ schtasks(["/run", "/tn", TASK]);
1641
+ writeServiceInstallState("scheduler");
1642
+ }
1643
+
1644
+ export interface RepairServiceDeps {
1645
+ diagnose?: () => ServiceDiagnostic;
1646
+ assertEnv?: () => void;
1647
+ assertAuth?: () => void;
1648
+ writeSchedulerAssets?: () => void;
1649
+ stopScheduler?: () => void;
1650
+ startScheduler?: () => void;
1651
+ writeSchedulerState?: () => void;
1652
+ writeNativeState?: () => void;
1653
+ repairNative?: () => void | Promise<void>;
1654
+ repairLaunchd?: () => void;
1655
+ repairSystemd?: () => void;
1656
+ /** Test seam — defaults to process.platform so Linux CI cannot hit real installSystemd. */
1657
+ platform?: NodeJS.Platform;
1658
+ }
1659
+
1660
+ /**
1661
+ * Repair an already-installed background service without Task Scheduler re-registration.
1662
+ *
1663
+ * Windows scheduler: rewrite assets + stop/start — no `schtasks /create`, no UAC.
1664
+ * Windows native: WinSW asset rewrite + restart (skips `install /p` when present).
1665
+ * macOS/Linux: re-run the user-level install/reload path.
1666
+ */
1667
+ export async function repairService(deps: RepairServiceDeps = {}): Promise<void> {
1668
+ const diagnose = deps.diagnose ?? diagnoseService;
1669
+ const platform = deps.platform ?? process.platform;
1670
+ const diag = diagnose();
1671
+ if (!diag.supported) {
1672
+ throw new Error(`Background service is unsupported (${diag.summary}).`);
1673
+ }
1674
+ if (diag.conflict) {
1675
+ throw new Error(
1676
+ "Cannot repair while Task Scheduler and native WinSW are both present. "
1677
+ + "Run 'ocx service uninstall' then reinstall one backend with 'ocx service install'.",
1678
+ );
1679
+ }
1680
+ if (!diag.installed) {
1681
+ throw new Error("Background service is not installed. Run 'ocx service install' first.");
1682
+ }
1683
+
1684
+ (deps.assertEnv ?? assertServiceEnvironmentMatchesInstall)();
1685
+ (deps.assertAuth ?? assertServiceAuthEnvironment)();
1686
+
1687
+ if (platform === "win32") {
1688
+ if (diag.backend === "native") {
1689
+ await (deps.repairNative ?? (() => installWinswService(defaultWinswEntry(import.meta.dir))))();
1690
+ (deps.writeNativeState ?? (() => writeServiceInstallState("native")))();
1691
+ return;
1692
+ }
1693
+ try { (deps.stopScheduler ?? stopWindows)(); } catch { /* not running */ }
1694
+ (deps.writeSchedulerAssets ?? writeWindowsSchedulerAssets)();
1695
+ (deps.startScheduler ?? startWindows)();
1696
+ (deps.writeSchedulerState ?? (() => writeServiceInstallState("scheduler")))();
1697
+ return;
1698
+ }
1699
+ if (platform === "darwin") {
1700
+ (deps.repairLaunchd ?? installLaunchd)();
1701
+ return;
1702
+ }
1703
+ if (platform === "linux") {
1704
+ (deps.repairSystemd ?? installSystemd)();
1705
+ return;
1706
+ }
1707
+ throw new Error(`Background service repair is unsupported on ${platform}.`);
1708
+ }
1709
+
1710
+ /**
1711
+ * Opt-in native backend (`ocx service install --native`). Transactional: removes the
1712
+ * scheduler backend first; on failure the machine is left with NO service (explicitly
1713
+ * reported) — never a silent fallback to the scheduler.
1714
+ */
1715
+ /** Refuse WinSW when the interactive user is a Microsoft account (SCM cannot authenticate it). */
1716
+ export function assertWindowsNativeServiceAccountSupported(): void {
1717
+ if (process.platform !== "win32") return;
1718
+ const source = readWindowsPrincipalSource();
1719
+ if (source?.toLowerCase() === "microsoftaccount") {
1720
+ throw new Error(
1721
+ "The native (WinSW) service backend cannot run under a Microsoft-account Windows login. "
1722
+ + "Keep the Task Scheduler backend (`ocx service install`) or sign in with a local/domain account before `ocx service install --native`.",
1723
+ );
1724
+ }
1725
+ }
1726
+
1727
+ function readWindowsPrincipalSource(): string | null {
1728
+ if (process.platform !== "win32") return null;
1729
+ const ps = join(process.env.SystemRoot ?? "C:\\Windows", "System32", "WindowsPowerShell", "v1.0", "powershell.exe");
1730
+ if (!existsSync(ps)) return null;
1731
+ try {
1732
+ const out = execFileSync(ps, [
1733
+ "-NoLogo",
1734
+ "-NoProfile",
1735
+ "-NonInteractive",
1736
+ "-Command",
1737
+ "(Get-LocalUser -Name $env:USERNAME -ErrorAction SilentlyContinue).PrincipalSource",
1738
+ ], { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], windowsHide: true }).trim();
1739
+ return out || null;
1740
+ } catch {
1741
+ return null;
1742
+ }
1743
+ }
1744
+
1745
+ async function installWindowsNative(): Promise<void> {
1746
+ assertWindowsNativeServiceAccountSupported();
1747
+ recordOwnedConfigPath(getConfigDir(), serviceStatePath());
1748
+ if (!existsSync(getConfigDir())) mkdirSync(getConfigDir(), { recursive: true });
1749
+ writeServiceApiTokenFile();
1750
+ let hadScheduler = false;
1751
+ try {
1752
+ hadScheduler = schtasks(["/query", "/tn", TASK]).includes(TASK);
1753
+ } catch { /* task absent */ }
1754
+ if (hadScheduler) {
1755
+ console.log("🔁 Removing the Task Scheduler backend before installing the native (WinSW) service...");
1756
+ try { stopWindows(); } catch { /* not running */ }
1757
+ try {
1758
+ uninstallWindows();
1759
+ } catch (err) {
1760
+ throw new Error(`Cannot remove the Task Scheduler backend before switching to native: ${err instanceof Error ? err.message : String(err)}`);
1761
+ }
1762
+ // Verify removal — schtasks /delete can silently fail if UAC or policy blocks it.
1763
+ try {
1764
+ if (schtasks(["/query", "/tn", TASK]).includes(TASK)) {
1765
+ throw new Error("Task Scheduler backend still present after removal — aborting switch.");
1766
+ }
1767
+ } catch (e) {
1768
+ if (e instanceof Error && e.message.includes("still present")) throw e;
1769
+ /* query failure = task absent, which is what we want */
1770
+ }
1771
+ }
1772
+ try {
1773
+ await installWinswService(defaultWinswEntry(import.meta.dir));
1774
+ } catch (err) {
1775
+ if (hadScheduler) console.error("⚠️ Native install failed AFTER removing the Task Scheduler backend — no service is installed now. Run `ocx service install` to restore the scheduler backend, or retry `--native`.");
1776
+ throw err;
1777
+ }
1778
+ writeServiceInstallState("native");
1779
+ }
1780
+ function startWindows(): void { schtasks(["/run", "/tn", TASK]); }
1781
+
1782
+ export function isWindowsSchedulerEndBenign(error: unknown): boolean {
1783
+ const detail = schtasksErrorDetail(error).toLowerCase();
1784
+ return detail.includes("no running instance")
1785
+ || detail.includes("not currently running")
1786
+ || detail.includes("0x41330");
1787
+ }
1788
+
1789
+ /**
1790
+ * End the scheduler task. "Already stopped" is success; other `/end` failures are
1791
+ * swallowed so callers can still run tracked-proxy + live-proxy cleanup.
1792
+ *
1793
+ * Do not key a restart-window wait on `/end` failure: the #764 case is an `/end`
1794
+ * that *succeeds* while the wrapper survives and respawns. That verification lives
1795
+ * on the stop-verification path (poll across the restart window), not here.
1796
+ */
1797
+ export function stopWindows(): void {
1798
+ try {
1799
+ schtasks(["/end", "/tn", TASK]);
1800
+ } catch (error) {
1801
+ if (isWindowsSchedulerEndBenign(error)) return;
1802
+ }
1803
+ }
1804
+ function statusWindows(): string { try { return schtasks(["/query", "/tn", TASK]); } catch { return ""; } }
1805
+ function statusWindowsXml(): string { try { return schtasks(["/query", "/tn", TASK, "/xml"]); } catch { return ""; } }
1806
+ function uninstallWindows(): void {
1807
+ const probe = probeWindowsSchedulerTask(TASK);
1808
+ if (probe.status === "present") {
1809
+ try {
1810
+ schtasks(["/delete", "/tn", TASK, "/f"]);
1811
+ } catch (error) {
1812
+ throw new Error(`Failed to delete Task Scheduler task ${TASK}: ${error instanceof Error ? error.message : String(error)}`);
1813
+ }
1814
+ const afterDelete = probeWindowsSchedulerTask(TASK);
1815
+ if (afterDelete.status === "present") {
1816
+ throw new Error(`Task Scheduler task ${TASK} is still present after delete — refusing to remove service assets. Retry from an elevated shell.`);
1817
+ }
1818
+ if (afterDelete.status === "unknown") {
1819
+ throw new Error(`Task Scheduler task ${TASK} presence could not be verified after delete — refusing to remove service assets.`);
1820
+ }
1821
+ } else if (probe.status === "unknown") {
1822
+ throw new Error(`Task Scheduler task ${TASK} presence could not be verified — refusing to remove service assets.`);
1823
+ }
1824
+ if (existsSync(windowsServiceScriptPath())) unlinkSync(windowsServiceScriptPath());
1825
+ if (existsSync(windowsLauncherVbsPath())) unlinkSync(windowsLauncherVbsPath());
1826
+ if (existsSync(windowsTaskXmlPath())) unlinkSync(windowsTaskXmlPath());
1827
+ }
1828
+
1829
+ /**
1830
+ * Warn when the paths baked into installed service assets no longer exist (npm prefix
1831
+ * moved, nvm switch, reinstall) — the service manager would restart-loop on a dead path
1832
+ * while `schtasks`/`launchctl` still report "installed".
1833
+ */
1834
+ export function bakedServicePathsDiagnostic(): string | null {
1835
+ const state = readServiceInstallState();
1836
+ if (!state?.bunPath || !state?.cliPath) return null;
1837
+ const missing = [state.bunPath, state.cliPath].filter(path => !existsSync(path));
1838
+ if (missing.length === 0) return null;
1839
+ return `STALE baked paths (missing: ${missing.join(", ")}) — run 'ocx service install' to re-bake`;
1840
+ }
1841
+
1842
+ function serviceDiagnosticsSummary(): string {
1843
+ const stale = bakedServicePathsDiagnostic();
1844
+ return stale ? `${stale}; logs: ${serviceLogPath()}` : `logs: ${serviceLogPath()}`;
1845
+ }
1846
+
1847
+ // ── Linux (systemd user unit) ──
1848
+ function unitDir(): string {
1849
+ return join(homedir(), ".config", "systemd", "user");
1850
+ }
1851
+
1852
+ function unitPath(): string {
1853
+ return join(unitDir(), `${TASK}.service`);
1854
+ }
1855
+
1856
+ export function buildUnit(): string {
1857
+ const { bun, cli } = cliEntry();
1858
+ const log = logPath();
1859
+ const path = process.env.PATH ?? "/usr/local/bin:/usr/bin:/bin";
1860
+ const codexHome = systemdEnvironmentAssignment("CODEX_HOME", process.env.CODEX_HOME?.trim());
1861
+ const opencodexHome = systemdEnvironmentAssignment("OPENCODEX_HOME", process.env.OPENCODEX_HOME?.trim());
1862
+ const envLines = [
1863
+ systemdEnvironmentAssignment("OCX_SERVICE", "1"),
1864
+ systemdEnvironmentAssignment("PATH", path),
1865
+ codexHome,
1866
+ opencodexHome,
1867
+ ].filter((line): line is string => Boolean(line)).join("\n");
1868
+ return `[Unit]
1869
+ Description=OpenCodex Proxy Server
1870
+ After=network-online.target
1871
+ Wants=network-online.target
1872
+
1873
+ [Service]
1874
+ Type=simple
1875
+ ExecStart=${systemdQuote("/bin/sh")} -lc ${systemdQuote(buildServiceShellCommand(bun, cli))}
1876
+ Restart=on-failure
1877
+ RestartSec=5
1878
+ ${envLines}
1879
+ StandardOutput=${systemdOutputTarget(`append:${log}`)}
1880
+ StandardError=${systemdOutputTarget(`append:${log}`)}
1881
+
1882
+ [Install]
1883
+ WantedBy=default.target
1884
+ `;
1885
+ }
1886
+
1887
+ /** The per-user runtime dir systemd creates (holds the user-bus socket), or null. */
1888
+ function userRuntimeDir(): string | null {
1889
+ const fromEnv = process.env.XDG_RUNTIME_DIR;
1890
+ if (fromEnv && existsSync(fromEnv)) return fromEnv;
1891
+ if (typeof process.getuid === "function") {
1892
+ const candidate = `/run/user/${process.getuid()}`;
1893
+ if (existsSync(candidate)) return candidate;
1894
+ }
1895
+ return null;
1896
+ }
1897
+
1898
+ /**
1899
+ * SSH sessions frequently start without `XDG_RUNTIME_DIR`/`DBUS_SESSION_BUS_ADDRESS`, so
1900
+ * `systemctl --user` can't find the user bus even when systemd is running. Point `XDG_RUNTIME_DIR`
1901
+ * at the per-user runtime dir when it exists so the `--user` probe and install commands reach the
1902
+ * bus. No-op when already set or when no runtime dir exists (e.g. genuinely non-systemd hosts).
1903
+ */
1904
+ function ensureUserBusEnv(): void {
1905
+ if (process.env.XDG_RUNTIME_DIR) return;
1906
+ const dir = userRuntimeDir();
1907
+ if (dir) process.env.XDG_RUNTIME_DIR = dir;
1908
+ }
1909
+
1910
+ function isSystemd(): boolean {
1911
+ try { execSync("systemctl --version", { stdio: "pipe" }); } catch { return false; }
1912
+ ensureUserBusEnv();
1913
+ // Prefer the user-bus probe; but an SSH session without a user D-Bus fails it even when systemd
1914
+ // is present (F9). Fall back to the per-user runtime dir existing — a strong signal the user
1915
+ // systemd instance is available — so a first-time `ocx service install` isn't wrongly refused.
1916
+ try { execSync("systemctl --user show-environment", { stdio: "pipe" }); return true; } catch { /* no user bus in this session */ }
1917
+ return userRuntimeDir() !== null;
1918
+ }
1919
+
1920
+ function installSystemd(): void {
1921
+ ensureUserBusEnv(); // reach the user bus over a bare SSH session (F9)
1922
+ const dir = unitDir();
1923
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
1924
+ recordOwnedConfigPath(getConfigDir(), serviceStatePath());
1925
+ if (!existsSync(getConfigDir())) mkdirSync(getConfigDir(), { recursive: true });
1926
+ writeServiceApiTokenFile();
1927
+ writeFileSync(unitPath(), buildUnit(), "utf8");
1928
+ sh("systemctl --user daemon-reload");
1929
+ sh(`systemctl --user enable ${TASK}`);
1930
+ sh(`systemctl --user restart ${TASK}`);
1931
+ writeServiceInstallState();
1932
+ }
1933
+ /**
1934
+ * Whether systemd's in-memory unit differs from the file on disk.
1935
+ *
1936
+ * The systemd analogue of launchd's stale-plist case: writing
1937
+ * `~/.config/systemd/user/<unit>` does not change the definition systemd has loaded
1938
+ * until `daemon-reload`, so a plain `systemctl start` would run the PREVIOUS
1939
+ * ExecStart. `NeedDaemonReload` is a per-unit property emitted as a bare
1940
+ * `NeedDaemonReload=yes|no` line; pass the unit name or `show` reports the manager's
1941
+ * own property instead, which answers a different question.
1942
+ *
1943
+ * Fail-open: if the query cannot run (no user bus, unit absent) we must not block a
1944
+ * start that would otherwise work.
1945
+ */
1946
+ export function systemdNeedsDaemonReload(deps: { show?: () => string } = {}): boolean {
1947
+ try {
1948
+ const out = (deps.show ?? (() => sh(`systemctl --user show -p NeedDaemonReload ${TASK}`)))();
1949
+ return /NeedDaemonReload\s*=\s*yes/i.test(out);
1950
+ } catch {
1951
+ return false;
1952
+ }
1953
+ }
1954
+
1955
+ function startSystemd(): void {
1956
+ ensureUserBusEnv();
1957
+ if (!existsSync(unitPath())) {
1958
+ console.error(`opencodex service is not installed: ${unitPath()}`);
1959
+ console.error("Run `ocx service install` first to create and enable the systemd user unit.");
1960
+ process.exit(1);
1961
+ }
1962
+ // The unit on disk may be newer than what systemd loaded; starting now would run
1963
+ // the previous definition.
1964
+ //
1965
+ // `start` alone is not enough after a reload: it is a no-op on an already-active
1966
+ // unit, so the stale process would keep running the old ExecStart. NeedDaemonReload
1967
+ // compares disk against loaded, never loaded against running, so the only way to
1968
+ // make the running process match the file is to restart it.
1969
+ if (systemdNeedsDaemonReload()) {
1970
+ console.log("ℹ️ unit file changed on disk; reloading systemd and restarting the service.");
1971
+ sh("systemctl --user daemon-reload");
1972
+ sh(`systemctl --user restart ${TASK}`);
1973
+ return;
1974
+ }
1975
+ sh(`systemctl --user start ${TASK}`);
1976
+ }
1977
+ function stopSystemd(): void { try { sh(`systemctl --user stop ${TASK}`); } catch { /* not running */ } }
1978
+ function statusSystemd(): string { try { return sh(`systemctl --user status ${TASK}`); } catch { return ""; } }
1979
+ function uninstallSystemd(): void {
1980
+ try { sh(`systemctl --user disable --now ${TASK}`); } catch { /* absent */ }
1981
+ if (existsSync(unitPath())) unlinkSync(unitPath());
1982
+ try { sh("systemctl --user daemon-reload"); } catch { /* best-effort */ }
1983
+ }
1984
+
1985
+ type ServiceOps = {
1986
+ install: () => void | Promise<void>; start: () => void; stop: () => void;
1987
+ status: () => string; uninstall: () => void;
1988
+ };
1989
+
1990
+ function platformOps(backend: ServiceBackend = "scheduler"): ServiceOps | null {
1991
+ if (process.platform === "darwin")
1992
+ return { install: installLaunchd, start: startLaunchd, stop: stopLaunchd, status: statusLaunchd, uninstall: uninstallLaunchd };
1993
+ if (process.platform === "win32") {
1994
+ if (backend === "native")
1995
+ return { install: installWindowsNative, start: startWinswService, stop: stopWinswService, status: winswStatusSummary, uninstall: uninstallWinswService };
1996
+ return { install: installWindows, start: startWindows, stop: stopWindows, status: statusWindows, uninstall: uninstallWindows };
1997
+ }
1998
+ if (process.platform === "linux") {
1999
+ if (existsSync("/.dockerenv")) {
2000
+ console.error("Docker detected. Run 'ocx start' directly instead of using the service manager.");
2001
+ process.exit(1);
2002
+ }
2003
+ if (!isSystemd() && !existsSync(unitPath())) {
2004
+ console.error("systemd not found. Run 'ocx start' under your process supervisor.");
2005
+ if (isWslRuntime()) {
2006
+ console.error("WSL detected: enable systemd by adding [boot] systemd=true to /etc/wsl.conf, then run 'wsl --shutdown' from Windows and reopen the distro (WSL 0.67.6+).");
2007
+ }
2008
+ process.exit(1);
2009
+ }
2010
+ return { install: installSystemd, start: startSystemd, stop: stopSystemd, status: statusSystemd, uninstall: uninstallSystemd };
2011
+ }
2012
+ return null;
2013
+ }
2014
+
2015
+ type TrackedProxyCleanupResult = "none" | "stale" | "stopped";
2016
+
2017
+ function verifiedKillTarget(pid: number | null | undefined): number | null {
2018
+ if (typeof pid !== "number" || !Number.isSafeInteger(pid) || pid <= 0) return null;
2019
+ const verified = verifyPidIdentity(pid);
2020
+ return verified === pid ? verified : null;
2021
+ }
2022
+
2023
+ /**
2024
+ * Whether a proxy is still answering after the service manager claimed to stop it.
2025
+ *
2026
+ * `ops.stop()` reports the outcome of the STOP COMMAND, not of the process. A Windows scheduler
2027
+ * task whose wrapper survives `schtasks /end` respawns its child a few seconds later, so a stop
2028
+ * that returned success can still leave a live proxy — and `ocx service stop` then restored
2029
+ * native Codex on top of a running one (#764). The tracked-pid cleanup does not catch it either:
2030
+ * the respawned child writes a different pid, or none this process knows about.
2031
+ *
2032
+ * Probed rather than assumed, and bounded. The respawn risk is specific to a supervisor that can
2033
+ * restart its child — the Windows scheduler wrapper — so only that case pays the restart window.
2034
+ * Everywhere else a single probe answers the question, because nothing is going to bring the
2035
+ * proxy back after `launchctl unload` or `systemctl stop`. Making every platform wait 7s on a
2036
+ * stop that already succeeded would trade one bug for a worse everyday one.
2037
+ */
2038
+ export async function proxyStillLiveAfterStop(deps: {
2039
+ findProxy?: () => Promise<{ port: number } | null>;
2040
+ sleep?: (ms: number) => Promise<void>;
2041
+ now?: () => number;
2042
+ /** Whether the stopped supervisor can respawn its child; only then is polling worth the wait. */
2043
+ canRespawn?: boolean;
2044
+ } = {}): Promise<{ port: number } | null> {
2045
+ const sleep = deps.sleep ?? ((ms: number) => new Promise<void>(r => setTimeout(r, ms)));
2046
+ const now = deps.now ?? Date.now;
2047
+ const canRespawn = deps.canRespawn ?? process.platform === "win32";
2048
+ const deadline = now() + (canRespawn ? 7000 : 0);
2049
+ // Single-shot (non-respawn) still needs one full SERVICE_STOP_LIVENESS budget; respawn
2050
+ // polling shares the outer deadline so multi-candidate discovery cannot overrun it.
2051
+ const findProxy = deps.findProxy ?? (() => {
2052
+ const probeDeadline = canRespawn
2053
+ ? deadline
2054
+ : now() + (SERVICE_STOP_LIVENESS.timeoutMs! * SERVICE_STOP_LIVENESS.attempts! + 250);
2055
+ return findLiveProxy({ ...SERVICE_STOP_LIVENESS, deadlineAt: probeDeadline, nowFn: now });
2056
+ });
2057
+ for (;;) {
2058
+ try {
2059
+ const live = await findProxy();
2060
+ if (live) return live;
2061
+ } catch {
2062
+ // A probe failure is not proof the proxy is gone; keep polling until the deadline.
2063
+ }
2064
+ if (now() >= deadline) return null;
2065
+ await sleep(1000);
2066
+ }
2067
+ }
2068
+
2069
+ async function stopTrackedProxyIfRunning(): Promise<TrackedProxyCleanupResult> {
2070
+ let stopped = false;
2071
+ const pid = readPid();
2072
+ const trackedKillPid = verifiedKillTarget(pid);
2073
+ if (trackedKillPid !== null && isProcessAlive(trackedKillPid)) {
2074
+ await stopProxy(trackedKillPid);
2075
+ removePid(trackedKillPid);
2076
+ removeRuntimePort(trackedKillPid);
2077
+ stopped = true;
2078
+ } else if (pid) {
2079
+ removePid(pid);
2080
+ removeRuntimePort(pid);
2081
+ }
2082
+ // Orphan recovery: the pid file can be missing/stale while the service wrapper keeps
2083
+ // a live proxy running — mirror `ocx stop`'s identity-checked findLiveProxy fallback.
2084
+ // Cap multi-candidate discovery so stop cleanup cannot hang for three full retry budgets.
2085
+ const live = await findLiveProxy({
2086
+ ...SERVICE_STOP_LIVENESS,
2087
+ deadlineAt: Date.now() + 7000,
2088
+ });
2089
+ const liveKillPid = verifiedKillTarget(live?.pid);
2090
+ if (liveKillPid !== null) {
2091
+ await stopProxy(liveKillPid);
2092
+ removePid(liveKillPid);
2093
+ removeRuntimePort(liveKillPid);
2094
+ stopped = true;
2095
+ }
2096
+ if (stopped) return "stopped";
2097
+ if (pid) return "stale";
2098
+ return "none";
2099
+ }
2100
+
2101
+ async function stopTrackedProxyForServiceCommand(): Promise<TrackedProxyCleanupResult> {
2102
+ try {
2103
+ return await stopTrackedProxyIfRunning();
2104
+ } catch (err) {
2105
+ console.error(`⚠️ Failed to stop proxy: ${err instanceof Error ? err.message : String(err)}`);
2106
+ return "none";
2107
+ }
2108
+ }
2109
+
2110
+ /**
2111
+ * If a service is installed, stop it so the process manager doesn't respawn after `ocx stop`.
2112
+ * Returns true if a service was found and stopped.
2113
+ */
2114
+ export function stopServiceIfInstalled(): boolean {
2115
+ assertServiceEnvironmentMatchesInstall();
2116
+ if (process.platform === "darwin") {
2117
+ if (existsSync(plistPath())) {
2118
+ try { stopLaunchd(); return true; } catch { return false; }
2119
+ }
2120
+ } else if (process.platform === "win32") {
2121
+ // Query BOTH backends regardless of state: a failed switch or stale state can leave
2122
+ // two managers installed, and either one would respawn the proxy after `ocx stop`.
2123
+ let stopped = false;
2124
+ try {
2125
+ const q = schtasks(["/query", "/tn", TASK]);
2126
+ if (q.includes(TASK)) { stopWindows(); stopped = true; }
2127
+ } catch { /* task not found */ }
2128
+ if (statusWinswRaw() !== "nonexistent") {
2129
+ try { stopWinswService(); stopped = true; } catch { /* best-effort */ }
2130
+ }
2131
+ if (stopped) return true;
2132
+ } else if (process.platform === "linux" && isSystemd() && existsSync(unitPath())) {
2133
+ try { stopSystemd(); return true; } catch { return false; }
2134
+ }
2135
+ return false;
2136
+ }
2137
+
2138
+ /** Delete install-state files; stale state would make `ocx update` "reinstall" a service that no longer exists. */
2139
+ function removeServiceInstallState(): void {
2140
+ for (const path of serviceStatePaths()) {
2141
+ try { if (existsSync(path)) unlinkSync(path); } catch { /* best-effort */ }
2142
+ }
2143
+ }
2144
+
2145
+ /**
2146
+ * Best-effort service removal for full uninstall. Unlike `ocx service uninstall`, this is quiet
2147
+ * when no service exists and never exits the process just because the platform has no service
2148
+ * manager.
2149
+ */
2150
+ export function uninstallServiceIfInstalled(): boolean {
2151
+ assertServiceEnvironmentMatchesInstall();
2152
+ if (process.platform === "darwin") {
2153
+ if (existsSync(plistPath())) {
2154
+ try { uninstallLaunchd(); removeServiceInstallState(); return true; } catch { return false; }
2155
+ }
2156
+ } else if (process.platform === "win32") {
2157
+ let removed = false;
2158
+ try {
2159
+ const q = schtasks(["/query", "/tn", TASK]);
2160
+ if (q.includes(TASK)) { uninstallWindows(); removed = true; }
2161
+ } catch { /* task not found */ }
2162
+ if (statusWinswRaw() !== "nonexistent") {
2163
+ try {
2164
+ uninstallWinswService();
2165
+ removed = true;
2166
+ } catch (err) {
2167
+ console.warn(`⚠️ Failed to remove native service: ${err instanceof Error ? err.message : String(err)}. Check 'sc.exe query ${WINSW_SERVICE_ID}'.`);
2168
+ }
2169
+ }
2170
+ if (removed) { removeServiceInstallState(); return true; }
2171
+ } else if (process.platform === "linux" && existsSync(unitPath())) {
2172
+ try { uninstallSystemd(); removeServiceInstallState(); return true; } catch {
2173
+ try { unlinkSync(unitPath()); removeServiceInstallState(); return true; } catch { return false; }
2174
+ }
2175
+ }
2176
+ return false;
2177
+ }
2178
+
2179
+ /** True if a background service (launchd/systemd/Task Scheduler) is installed. */
2180
+ export function isServiceInstalled(): boolean {
2181
+ return diagnoseService().installed;
2182
+ }
2183
+
2184
+ /**
2185
+ * True when an installed background service can actually supervise the proxy.
2186
+ * Presence alone is not enough: stale/missing assets, conflicts, and disabled
2187
+ * registrations report `installed` but will not bring the proxy back after exit.
2188
+ */
2189
+ export function isServiceViable(): boolean {
2190
+ return diagnoseService().viable;
2191
+ }
2192
+
2193
+ export interface ServiceDiagnostic {
2194
+ supported: boolean;
2195
+ installed: boolean;
2196
+ enabled: boolean;
2197
+ running: boolean;
2198
+ viable: boolean;
2199
+ startable: boolean;
2200
+ stale: boolean;
2201
+ conflict: boolean;
2202
+ backend: ServiceBackend | "launchd" | "systemd" | null;
2203
+ summary: string;
2204
+ }
2205
+
2206
+ /** Windows tray may restart a healthy-but-stopped native service; stale/conflicting installs remain blocked. */
2207
+ export function serviceStartableFromTray(service: ServiceDiagnostic): boolean {
2208
+ return service.startable && !service.stale && !service.conflict;
2209
+ }
2210
+
2211
+ export interface WindowsServiceDiagnosticInputs {
2212
+ /**
2213
+ * Raw `schtasks /query /xml` output; empty when no task is registered. Passed as
2214
+ * XML rather than pre-computed booleans so every caller reads the document through
2215
+ * readWindowsSchedulerXmlState() — a second, stricter reading elsewhere would
2216
+ * silently reintroduce the stale-status false positive (#432).
2217
+ */
2218
+ schedulerXml: string;
2219
+ /** Whether the on-disk service assets exist. A filesystem concern, not an XML one. */
2220
+ schedulerAssetsPresent: boolean;
2221
+ nativeStatus: "started" | "stopped" | "nonexistent" | "unknown";
2222
+ recordedBackend: ServiceBackend | null;
2223
+ staleBakedPaths: boolean;
2224
+ nativeRepairAssetsOnly: boolean;
2225
+ diagnostics: string;
2226
+ }
2227
+
2228
+ export function deriveWindowsServiceDiagnostic(inputs: WindowsServiceDiagnosticInputs): ServiceDiagnostic {
2229
+ const schedulerState = readWindowsSchedulerXmlState(inputs.schedulerXml);
2230
+ const schedulerInstalled = schedulerState.installed;
2231
+ const schedulerEnabled = schedulerState.enabled;
2232
+ const schedulerAssetsHealthy = inputs.schedulerAssetsPresent && schedulerState.registrationHealthy;
2233
+ const nativeInstalled = inputs.nativeStatus !== "nonexistent";
2234
+ const conflict = schedulerInstalled && nativeInstalled;
2235
+ const backendStateMismatch = schedulerInstalled
2236
+ ? inputs.recordedBackend !== "scheduler"
2237
+ : nativeInstalled && inputs.recordedBackend !== "native";
2238
+ const stale = inputs.staleBakedPaths
2239
+ || (schedulerInstalled && !schedulerAssetsHealthy)
2240
+ || backendStateMismatch
2241
+ || (inputs.nativeStatus === "nonexistent" && inputs.nativeRepairAssetsOnly);
2242
+ const backend = schedulerInstalled ? "scheduler" : nativeInstalled ? "native" : null;
2243
+ const enabled = schedulerInstalled ? schedulerEnabled : inputs.nativeStatus === "started";
2244
+ const running = nativeInstalled ? inputs.nativeStatus === "started" : schedulerInstalled && schedulerEnabled;
2245
+ const viable = !conflict && !stale
2246
+ && (schedulerInstalled ? schedulerEnabled && schedulerAssetsHealthy : inputs.nativeStatus === "started");
2247
+ const startable = !conflict && !stale
2248
+ && (schedulerInstalled
2249
+ ? schedulerEnabled && schedulerAssetsHealthy
2250
+ : inputs.nativeStatus === "started" || inputs.nativeStatus === "stopped");
2251
+ const detail = conflict
2252
+ ? "CONFLICT: Task Scheduler and native WinSW are both present — run 'ocx service uninstall' then reinstall one"
2253
+ : stale
2254
+ ? "stale or missing service assets — run 'ocx service install' to repair"
2255
+ : schedulerInstalled
2256
+ ? schedulerEnabled ? "Task Scheduler enabled" : "Task Scheduler disabled"
2257
+ : nativeInstalled
2258
+ ? `native (WinSW ${WINSW_VERSION}): ${inputs.nativeStatus}`
2259
+ : "not installed";
2260
+ const summary = backend ? `installed, ${detail} (${inputs.diagnostics})` : `not installed (${inputs.diagnostics})`;
2261
+ return {
2262
+ supported: true,
2263
+ installed: schedulerInstalled || nativeInstalled,
2264
+ enabled,
2265
+ running,
2266
+ viable,
2267
+ startable,
2268
+ stale,
2269
+ conflict,
2270
+ backend,
2271
+ summary,
2272
+ };
2273
+ }
2274
+
2275
+ /**
2276
+ * Fail-closed restart diagnostic. Presence alone is never enough: conflicting
2277
+ * managers, stale baked paths, disabled registrations, and unknown/stopped
2278
+ * native managers cannot claim that Codex will reconnect after a reboot.
2279
+ */
2280
+ export function diagnoseService(): ServiceDiagnostic {
2281
+ const diagnostics = serviceDiagnosticsSummary();
2282
+ if (process.platform === "darwin") {
2283
+ const installed = existsSync(plistPath());
2284
+ const running = installed && Boolean(statusLaunchd());
2285
+ const stale = installed && bakedServicePathsDiagnostic() !== null;
2286
+ const viable = installed && running && !stale;
2287
+ const summary = !installed ? `not installed (${diagnostics})`
2288
+ : stale ? `installed, but stale (launchd; ${diagnostics})`
2289
+ : running ? `installed and loaded (launchd; ${diagnostics})`
2290
+ : `installed, not loaded (launchd; ${diagnostics})`;
2291
+ return { supported: true, installed, enabled: running, running, viable, startable: installed && !stale, stale, conflict: false, backend: "launchd", summary };
2292
+ }
2293
+ if (process.platform === "win32") {
2294
+ const schedulerXml = statusWindowsXml();
2295
+ const schedulerAssetsPresent = [windowsServiceScriptPath(), windowsLauncherVbsPath(), windowsTaskXmlPath()]
2296
+ .every(existsSync);
2297
+ const nativeStatus = statusWinswRaw();
2298
+ const installState = readServiceInstallState();
2299
+ const recordedBackend: ServiceBackend | null = !installState
2300
+ ? null
2301
+ : installState.backend === "native" ? "native" : "scheduler";
2302
+ return deriveWindowsServiceDiagnostic({
2303
+ schedulerXml,
2304
+ schedulerAssetsPresent,
2305
+ nativeStatus,
2306
+ recordedBackend,
2307
+ staleBakedPaths: bakedServicePathsDiagnostic() !== null,
2308
+ nativeRepairAssetsOnly: Boolean(winswStatusSummary()),
2309
+ diagnostics,
2310
+ });
2311
+ }
2312
+ if (process.platform === "linux") {
2313
+ if (existsSync("/.dockerenv")) return { supported: false, installed: false, enabled: false, running: false, viable: false, startable: false, stale: false, conflict: false, backend: null, summary: "unsupported in Docker" };
2314
+ if (!isSystemd()) return { supported: false, installed: false, enabled: false, running: false, viable: false, startable: false, stale: false, conflict: false, backend: null, summary: "unsupported: systemd not found" };
2315
+ const installed = existsSync(unitPath());
2316
+ const enabled = installed && (() => { try { return sh(`systemctl --user is-enabled ${TASK}`) === "enabled"; } catch { return false; } })();
2317
+ const running = installed && (() => { try { return sh(`systemctl --user is-active ${TASK}`) === "active"; } catch { return false; } })();
2318
+ const stale = installed && bakedServicePathsDiagnostic() !== null;
2319
+ const viable = installed && enabled && running && !stale;
2320
+ const summary = !installed ? `not installed (${diagnostics})`
2321
+ : stale ? `installed, but stale (systemd user; ${diagnostics})`
2322
+ : viable ? `installed, enabled and running (systemd user; ${diagnostics})`
2323
+ : `installed, but ${!enabled ? "disabled" : "not running"} (systemd user; ${diagnostics})`;
2324
+ return { supported: true, installed, enabled, running, viable, startable: installed && !stale, stale, conflict: false, backend: "systemd", summary };
2325
+ }
2326
+ return { supported: false, installed: false, enabled: false, running: false, viable: false, startable: false, stale: false, conflict: false, backend: null, summary: `unsupported on ${process.platform}` };
2327
+ }
2328
+
2329
+ export function serviceStatusSummary(): string {
2330
+ return diagnoseService().summary;
2331
+ }
2332
+
2333
+ /**
2334
+ * Status a human can act on: registration state, whether a proxy actually answers,
2335
+ * and — when it does not — whether launchd is running the plist we have on disk.
2336
+ *
2337
+ * `launchctl list` membership cannot distinguish "serving", "bootstrapped from an
2338
+ * older plist", and "loaded but never bound"; the reported failure was the middle
2339
+ * one presented as the first.
2340
+ *
2341
+ * Resolves the port through `confirmServiceServing`, i.e. the same
2342
+ * `installedServiceListenPort()` path install/start/repair use, so those surfaces can
2343
+ * never disagree about one service. The budget is short (2 probes) because this is a
2344
+ * status read, not a post-install wait.
2345
+ */
2346
+ export async function serviceStatusReport(
2347
+ deps: {
2348
+ diagnose?: () => ServiceDiagnostic;
2349
+ serving?: () => Promise<{ ok: boolean; port: number }>;
2350
+ matchesPlist?: () => { loaded: boolean; matchesPlist: boolean };
2351
+ } = {},
2352
+ ): Promise<string> {
2353
+ const diag = (deps.diagnose ?? diagnoseService)();
2354
+ if (!diag.installed) return `❌ ${diag.summary}`;
2355
+
2356
+ const serving = await (deps.serving ?? (() => confirmServiceServing({ timeoutMs: 1_500 })))();
2357
+ if (serving.ok) return `✅ ${diag.summary}\n Serving on port ${serving.port}.`;
2358
+
2359
+ // The dep is consulted FIRST; the platform check only guards the default. Wrapping
2360
+ // the whole expression in a darwin check would discard an injected seam on
2361
+ // Linux/Windows and make the stale-plist case untestable there.
2362
+ const stalePlist = deps.matchesPlist?.() ?? (process.platform === "darwin"
2363
+ ? (() => {
2364
+ const entry = cliEntry();
2365
+ // Pass the INSTALLED port explicitly: the default third argument is
2366
+ // resolveServiceListenPort(), which reads OCX_BAKE_PORT/config.port, so after
2367
+ // a config edit the expected string would never match and every run would
2368
+ // print a false "OLDER plist".
2369
+ return launchdJobMatchesPlist(
2370
+ buildServiceShellCommand(entry.bun, entry.cli, installedServiceListenPort()),
2371
+ );
2372
+ })()
2373
+ : null);
2374
+ const staleLine = stalePlist && stalePlist.loaded && !stalePlist.matchesPlist
2375
+ ? " launchd is running an OLDER plist than the one on disk.\n"
2376
+ + ` Fix: launchctl bootout gui/$(id -u)/${LABEL} && ocx service install\n`
2377
+ : "";
2378
+
2379
+ return `⚠️ ${diag.summary}\n`
2380
+ + ` Registered, but no proxy is answering on port ${serving.port}.\n`
2381
+ + staleLine
2382
+ + ` Log: ${serviceLogPath()}\n`
2383
+ + ` Repair: ${serviceRepairCommand()}\n`
2384
+ + " Meanwhile: ocx start (serves in the foreground)";
2385
+ }
2386
+
2387
+ export function normalizeServiceSubcommand(sub?: string): string {
2388
+ return sub ?? "install";
2389
+ }
2390
+
2391
+ export interface ParsedServiceArgs {
2392
+ sub: string;
2393
+ backend: ServiceBackend | null;
2394
+ invalid: string[];
2395
+ }
2396
+
2397
+ /**
2398
+ * `ocx service [sub] [--native|--scheduler]`. The first non-flag token is the
2399
+ * subcommand; backend flags are only meaningful for `install` (validated by the caller).
2400
+ */
2401
+ export function parseServiceArgs(args: string[]): ParsedServiceArgs {
2402
+ let sub: string | undefined;
2403
+ let backend: ServiceBackend | null = null;
2404
+ const invalid: string[] = [];
2405
+ for (const arg of args) {
2406
+ if (arg === "--native") {
2407
+ if (backend === "scheduler") { invalid.push("--native (conflicts with --scheduler)"); continue; }
2408
+ backend = "native";
2409
+ }
2410
+ else if (arg === "--scheduler") {
2411
+ if (backend === "native") { invalid.push("--scheduler (conflicts with --native)"); continue; }
2412
+ backend = "scheduler";
2413
+ }
2414
+ else if (arg.startsWith("--")) invalid.push(arg);
2415
+ else if (sub === undefined) sub = arg;
2416
+ else invalid.push(arg);
2417
+ }
2418
+ return { sub: normalizeServiceSubcommand(sub), backend, invalid };
2419
+ }
2420
+
2421
+ export async function serviceCommand(...args: (string | undefined)[]): Promise<void> {
2422
+ const parsed = parseServiceArgs(args.filter((a): a is string => Boolean(a)));
2423
+ const command = parsed.sub;
2424
+ if (parsed.invalid.length > 0) {
2425
+ console.error(`Unknown service option: ${parsed.invalid.join(" ")}`);
2426
+ process.exit(1);
2427
+ }
2428
+ if (parsed.backend && command !== "install") {
2429
+ console.error("--native/--scheduler apply to `ocx service install` only; other subcommands use the installed backend.");
2430
+ process.exit(1);
2431
+ }
2432
+ if (parsed.backend === "native" && process.platform !== "win32") {
2433
+ console.error("--native (WinSW) is Windows-only.");
2434
+ process.exit(1);
2435
+ }
2436
+ if (command === "repair") {
2437
+ assertServiceEnvironmentMatchesInstall();
2438
+ assertServiceAuthEnvironment();
2439
+ await repairService();
2440
+ // All three platforms: a repair that reports success while nothing serves is the
2441
+ // defect class this unit exists to close. Windows bakes its port into the
2442
+ // scheduler wrapper or the WinSW XML, both of which installedServiceListenPort()
2443
+ // now reads.
2444
+ await reportServiceServing("repaired");
2445
+ return;
2446
+ }
2447
+ // Non-install subcommands follow the backend recorded at install time (state v2).
2448
+ const backend: ServiceBackend = parsed.backend ?? (process.platform === "win32" ? readServiceBackend() : "scheduler");
2449
+ const ops = platformOps(backend);
2450
+ if (!ops) {
2451
+ console.error("ocx service supports macOS (launchd), Windows (Task Scheduler), and Linux (systemd).");
2452
+ process.exit(1);
2453
+ }
2454
+ switch (command) {
2455
+ case "install":
2456
+ assertServiceEnvironmentMatchesInstall();
2457
+ assertServiceAuthEnvironment();
2458
+ await ops.install();
2459
+ // The wrapper was written moments ago in this process, so the configured port
2460
+ // and the baked one cannot have diverged yet — unlike `start`, which reads the
2461
+ // installed artifact instead.
2462
+ await reportServiceServing("installed", { port: resolveServiceListenPort() });
2463
+ if (process.platform === "linux") console.log(" For auto-start on boot: loginctl enable-linger $USER");
2464
+ // Service users never reach the `ocx start` prompt: the proxy they run is the
2465
+ // supervised child, which always carries OCX_SERVICE=1. This command, though, is
2466
+ // hand-typed in a real terminal, so it is the one interactive moment they get.
2467
+ // Same one-time marker and same guards (TTY, gh auth, agent deferral) apply.
2468
+ await maybeShowStarPrompt();
2469
+ break;
2470
+ case "start":
2471
+ ops.start();
2472
+ await reportServiceServing("started");
2473
+ break;
2474
+ case "stop": {
2475
+ assertServiceEnvironmentMatchesInstall();
2476
+ // Only stop what is actually installed. The unguarded call ran a real `launchctl unload`
2477
+ // (and its Windows/Linux twins) even with nothing installed.
2478
+ if (ops.status() !== null || isServiceInstalled()) {
2479
+ ops.stop();
2480
+ }
2481
+ await stopTrackedProxyForServiceCommand();
2482
+ {
2483
+ // Verify rather than trust the stop command: a surviving wrapper respawns its child
2484
+ // seconds later, and restoring native Codex on top of a live proxy is the failure #764
2485
+ // reports as "stop reports success without stopping the proxy".
2486
+ const survivor = await proxyStillLiveAfterStop();
2487
+ if (survivor) {
2488
+ console.error(
2489
+ `❌ service stop did not take effect: a proxy is still listening on port ${survivor.port}.`
2490
+ + "\nNative Codex was NOT restored, because doing so while the proxy is running leaves"
2491
+ + " both pointing at each other. Check for a second service backend (`ocx service status`)"
2492
+ + " or a manually started proxy, then re-run `ocx service stop`.",
2493
+ );
2494
+ process.exitCode = 1;
2495
+ break;
2496
+ }
2497
+ const restore = restoreNativeCodex();
2498
+ if (restore.success) console.log("✅ service stopped + native Codex restored.");
2499
+ else console.error(`⚠️ service stopped, but native Codex restore FAILED: ${restore.message}\nRun \`ocx restore\` (or check $CODEX_HOME/config.toml) before using native Codex.`);
2500
+ // The Grok fence is the other managed config this command owns. Leaving it behind
2501
+ // pointed grok at a dead endpoint while native Codex was already restored.
2502
+ const grok = stripGrokConfig();
2503
+ if (grok.changed) console.log(`↩️ ${grok.message}`);
2504
+ else if (!grok.ok) console.error(`⚠️ ${grok.message}`);
2505
+ }
2506
+ break;
2507
+ }
2508
+ case "status": {
2509
+ if (process.platform === "win32" && backend === "scheduler") {
2510
+ console.log(await inspectWindowsSchedulerServiceStatus());
2511
+ } else {
2512
+ // Replaces raw `ops.status()` output, which on darwin is a `launchctl list`
2513
+ // line: registration reported as if it were service. serviceStatusReport
2514
+ // subsumes the not-installed case and adds the serving / stale-plist split.
2515
+ console.log(await serviceStatusReport());
2516
+ }
2517
+ console.log(`Diagnostics: ${serviceDiagnosticsSummary()}`);
2518
+ break;
2519
+ }
2520
+ case "uninstall":
2521
+ case "remove":
2522
+ assertServiceEnvironmentMatchesInstall();
2523
+ try { ops.stop(); } catch (err) {
2524
+ console.warn(`⚠️ Service stop failed: ${err instanceof Error ? err.message : String(err)}`);
2525
+ }
2526
+ await stopTrackedProxyForServiceCommand();
2527
+ try {
2528
+ ops.uninstall();
2529
+ } catch (err) {
2530
+ console.error(`❌ Service uninstall failed: ${err instanceof Error ? err.message : String(err)}`);
2531
+ console.error("The service may still be installed. Check with 'ocx service status' or remove manually.");
2532
+ process.exit(1);
2533
+ }
2534
+ {
2535
+ const restore = restoreNativeCodex();
2536
+ if (!restore.success) {
2537
+ console.error(`⚠️ native Codex restore FAILED: ${restore.message}\nRun \`ocx restore\` before using native Codex.`);
2538
+ }
2539
+ const grok = stripGrokConfig();
2540
+ if (grok.changed) console.log(`↩️ ${grok.message}`);
2541
+ else if (!grok.ok) console.error(`⚠️ ${grok.message}`);
2542
+ }
2543
+ removeServiceInstallState();
2544
+ try { if (existsSync(serviceApiTokenFilePath())) unlinkSync(serviceApiTokenFilePath()); } catch { /* best-effort */ }
2545
+ console.log("✅ service uninstalled.");
2546
+ break;
2547
+ default:
2548
+ console.error("Usage: ocx service [install|repair|start|stop|status|uninstall|remove] [--native|--scheduler]");
2549
+ console.error(" With no subcommand, installs/updates and starts the background service.");
2550
+ console.error(" repair: refresh assets and restart an already-installed service (no admin re-prompt).");
2551
+ console.error(" --native (Windows only): register a real SCM service via WinSW instead of Task Scheduler.");
2552
+ process.exit(1);
2553
+ }
2554
+ }