@iislee/opencodex 2.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (476) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +225 -0
  3. package/assets/architecture.png +0 -0
  4. package/assets/banner.png +0 -0
  5. package/assets/claude-code-models.gif +0 -0
  6. package/assets/codex-app-picker.png +0 -0
  7. package/bin/ocx.mjs +451 -0
  8. package/bin/package-main.mjs +9 -0
  9. package/gui/dist/assets/index-DTpMHS4F.js +67 -0
  10. package/gui/dist/assets/index-ZNVDE3C7.css +1 -0
  11. package/gui/dist/favicon.png +0 -0
  12. package/gui/dist/icons.svg +24 -0
  13. package/gui/dist/index.html +25 -0
  14. package/gui/dist/logo.png +0 -0
  15. package/gui/dist/provider-icons/alibaba-color.svg +1 -0
  16. package/gui/dist/provider-icons/antigravity-color.svg +1 -0
  17. package/gui/dist/provider-icons/antigravity.svg +1 -0
  18. package/gui/dist/provider-icons/claude-color.svg +1 -0
  19. package/gui/dist/provider-icons/claude.svg +1 -0
  20. package/gui/dist/provider-icons/cloudflare-ai-gateway-color.svg +1 -0
  21. package/gui/dist/provider-icons/copilot-color.svg +1 -0
  22. package/gui/dist/provider-icons/copilot.svg +1 -0
  23. package/gui/dist/provider-icons/cursor-color.svg +2 -0
  24. package/gui/dist/provider-icons/cursor.svg +2 -0
  25. package/gui/dist/provider-icons/deepseek-color.svg +1 -0
  26. package/gui/dist/provider-icons/discord.svg +1 -0
  27. package/gui/dist/provider-icons/firepass-color.svg +1 -0
  28. package/gui/dist/provider-icons/fireworks-color.svg +1 -0
  29. package/gui/dist/provider-icons/gemini-color.svg +1 -0
  30. package/gui/dist/provider-icons/gemini.svg +1 -0
  31. package/gui/dist/provider-icons/github-copilot-color.svg +1 -0
  32. package/gui/dist/provider-icons/gitlab-duo-color.svg +1 -0
  33. package/gui/dist/provider-icons/grok-color.svg +1 -0
  34. package/gui/dist/provider-icons/grok.svg +1 -0
  35. package/gui/dist/provider-icons/groq-color.svg +1 -0
  36. package/gui/dist/provider-icons/huggingface-color.svg +1 -0
  37. package/gui/dist/provider-icons/kimi-color.svg +1 -0
  38. package/gui/dist/provider-icons/kiro-color.svg +15 -0
  39. package/gui/dist/provider-icons/kiro.svg +14 -0
  40. package/gui/dist/provider-icons/lm-studio-color.svg +1 -0
  41. package/gui/dist/provider-icons/mistral-color.svg +1 -0
  42. package/gui/dist/provider-icons/moonshot-color.svg +1 -0
  43. package/gui/dist/provider-icons/nvidia-color.svg +1 -0
  44. package/gui/dist/provider-icons/ollama-color.svg +1 -0
  45. package/gui/dist/provider-icons/openai.svg +1 -0
  46. package/gui/dist/provider-icons/opencode.svg +1 -0
  47. package/gui/dist/provider-icons/openrouter-color.svg +1 -0
  48. package/gui/dist/provider-icons/pi.svg +21 -0
  49. package/gui/dist/provider-icons/qianfan-color.svg +1 -0
  50. package/gui/dist/provider-icons/qwen-portal-color.svg +1 -0
  51. package/gui/dist/provider-icons/telegram.svg +1 -0
  52. package/gui/dist/provider-icons/vercel-ai-gateway-color.svg +1 -0
  53. package/gui/dist/provider-icons/vllm-color.svg +1 -0
  54. package/gui/dist/provider-icons/xiaomi-color.svg +1 -0
  55. package/package.json +102 -0
  56. package/src/AGENTS.md +28 -0
  57. package/src/adapters/anthropic-image-guard.ts +251 -0
  58. package/src/adapters/anthropic-image-normalize.ts +518 -0
  59. package/src/adapters/anthropic.ts +1003 -0
  60. package/src/adapters/azure.ts +36 -0
  61. package/src/adapters/base.ts +72 -0
  62. package/src/adapters/client-fingerprint.ts +59 -0
  63. package/src/adapters/cursor/arg-codec.ts +38 -0
  64. package/src/adapters/cursor/arg-normalize.ts +104 -0
  65. package/src/adapters/cursor/cursor-errors.ts +165 -0
  66. package/src/adapters/cursor/discovery.ts +276 -0
  67. package/src/adapters/cursor/effort-map.ts +127 -0
  68. package/src/adapters/cursor/exec-policy.ts +88 -0
  69. package/src/adapters/cursor/framing.ts +211 -0
  70. package/src/adapters/cursor/gen/agent_pb.ts +15274 -0
  71. package/src/adapters/cursor/kv-store.ts +52 -0
  72. package/src/adapters/cursor/live-models.ts +153 -0
  73. package/src/adapters/cursor/live-smoke-gate.ts +41 -0
  74. package/src/adapters/cursor/live-transport.ts +1214 -0
  75. package/src/adapters/cursor/mcp-config.ts +42 -0
  76. package/src/adapters/cursor/mcp-manager.ts +333 -0
  77. package/src/adapters/cursor/message-mapper.ts +49 -0
  78. package/src/adapters/cursor/native-exec-common.ts +55 -0
  79. package/src/adapters/cursor/native-exec-desktop.ts +184 -0
  80. package/src/adapters/cursor/native-exec-fs.ts +329 -0
  81. package/src/adapters/cursor/native-exec-mcp.ts +153 -0
  82. package/src/adapters/cursor/native-exec-network.ts +43 -0
  83. package/src/adapters/cursor/native-exec-shell.ts +548 -0
  84. package/src/adapters/cursor/native-exec-tools.ts +118 -0
  85. package/src/adapters/cursor/native-exec.ts +576 -0
  86. package/src/adapters/cursor/protobuf-events.ts +563 -0
  87. package/src/adapters/cursor/protobuf-request.ts +714 -0
  88. package/src/adapters/cursor/request-builder.ts +255 -0
  89. package/src/adapters/cursor/thread-continuity.ts +67 -0
  90. package/src/adapters/cursor/tool-definitions.ts +505 -0
  91. package/src/adapters/cursor/transport-retry.ts +132 -0
  92. package/src/adapters/cursor/transport.ts +57 -0
  93. package/src/adapters/cursor/types.ts +52 -0
  94. package/src/adapters/cursor.ts +196 -0
  95. package/src/adapters/google-antigravity-replay.ts +303 -0
  96. package/src/adapters/google-antigravity-wire.ts +108 -0
  97. package/src/adapters/google-errors.ts +85 -0
  98. package/src/adapters/google-http.ts +100 -0
  99. package/src/adapters/google-tool-schema.ts +173 -0
  100. package/src/adapters/google-truncation.ts +13 -0
  101. package/src/adapters/google-wire-compiler.ts +232 -0
  102. package/src/adapters/google.ts +758 -0
  103. package/src/adapters/identity.ts +44 -0
  104. package/src/adapters/image.ts +23 -0
  105. package/src/adapters/kiro-constants.ts +16 -0
  106. package/src/adapters/kiro-errors.ts +197 -0
  107. package/src/adapters/kiro-events.ts +179 -0
  108. package/src/adapters/kiro-images.ts +129 -0
  109. package/src/adapters/kiro-retry.ts +312 -0
  110. package/src/adapters/kiro-thinking.ts +96 -0
  111. package/src/adapters/kiro-tool-fallback.ts +36 -0
  112. package/src/adapters/kiro-tools.ts +215 -0
  113. package/src/adapters/kiro-truncation.ts +33 -0
  114. package/src/adapters/kiro-wire.ts +129 -0
  115. package/src/adapters/kiro.ts +1898 -0
  116. package/src/adapters/mimo-free.ts +263 -0
  117. package/src/adapters/openai-chat.ts +1005 -0
  118. package/src/adapters/openai-responses.ts +1137 -0
  119. package/src/adapters/run-turn-queue.ts +114 -0
  120. package/src/adapters/tool-catalog-nudge.ts +71 -0
  121. package/src/adapters/upstream-http-error.ts +48 -0
  122. package/src/bridge.ts +1619 -0
  123. package/src/chat/inbound.ts +295 -0
  124. package/src/chat/outbound.ts +765 -0
  125. package/src/claude/agents-inject.ts +243 -0
  126. package/src/claude/alias.ts +149 -0
  127. package/src/claude/auth-detect.ts +229 -0
  128. package/src/claude/auth-mode-migration.ts +32 -0
  129. package/src/claude/auth-mode.ts +62 -0
  130. package/src/claude/context-windows.ts +189 -0
  131. package/src/claude/desktop-3p-guard.ts +35 -0
  132. package/src/claude/desktop-3p-paths.ts +84 -0
  133. package/src/claude/desktop-3p.ts +381 -0
  134. package/src/claude/desktop-health.ts +26 -0
  135. package/src/claude/desktop-profile.ts +263 -0
  136. package/src/claude/gateway-cache.ts +70 -0
  137. package/src/claude/inbound-debug.ts +163 -0
  138. package/src/claude/inbound.ts +509 -0
  139. package/src/claude/model-info.ts +151 -0
  140. package/src/claude/outbound.ts +872 -0
  141. package/src/cli/access.ts +108 -0
  142. package/src/cli/account-api.ts +268 -0
  143. package/src/cli/account-auth.ts +223 -0
  144. package/src/cli/account-extended.ts +350 -0
  145. package/src/cli/account.ts +275 -0
  146. package/src/cli/agent-driven.ts +70 -0
  147. package/src/cli/agent.ts +184 -0
  148. package/src/cli/catalog-prewarm.ts +27 -0
  149. package/src/cli/claude-desktop.ts +188 -0
  150. package/src/cli/claude.ts +286 -0
  151. package/src/cli/codex-shim-autorestore.ts +45 -0
  152. package/src/cli/combo.ts +119 -0
  153. package/src/cli/config-command.ts +145 -0
  154. package/src/cli/debug.ts +228 -0
  155. package/src/cli/doctor.ts +930 -0
  156. package/src/cli/export-command.ts +187 -0
  157. package/src/cli/help.ts +354 -0
  158. package/src/cli/index.ts +1113 -0
  159. package/src/cli/init.ts +224 -0
  160. package/src/cli/integrations.ts +142 -0
  161. package/src/cli/interactive-confirm.ts +133 -0
  162. package/src/cli/internal-dispatch.ts +20 -0
  163. package/src/cli/models-runtime.ts +212 -0
  164. package/src/cli/models.ts +336 -0
  165. package/src/cli/observe.ts +117 -0
  166. package/src/cli/opencode.ts +586 -0
  167. package/src/cli/pi.ts +188 -0
  168. package/src/cli/provider-runtime.ts +162 -0
  169. package/src/cli/provider.ts +463 -0
  170. package/src/cli/runtime-api.ts +325 -0
  171. package/src/cli/star-prompt.ts +155 -0
  172. package/src/cli/status-oauth.ts +78 -0
  173. package/src/cli/status.ts +321 -0
  174. package/src/cli/sync-cloud.ts +283 -0
  175. package/src/cli/system-command.ts +112 -0
  176. package/src/cli/tray-proxy.ts +52 -0
  177. package/src/cli/v2.ts +173 -0
  178. package/src/cli.ts +10 -0
  179. package/src/clients/config-export.ts +377 -0
  180. package/src/clients/effective-status.ts +385 -0
  181. package/src/clients/probes/agy.ts +55 -0
  182. package/src/clients/probes/cc-switch.ts +110 -0
  183. package/src/clients/probes/claude.ts +90 -0
  184. package/src/clients/probes/codex.ts +125 -0
  185. package/src/clients/probes/grok.ts +29 -0
  186. package/src/clients/probes/opencode.ts +109 -0
  187. package/src/clients/probes/paseo.ts +55 -0
  188. package/src/clients/probes/pi.ts +55 -0
  189. package/src/cloud/onedrive-auth.ts +666 -0
  190. package/src/cloud/onedrive-graph.ts +108 -0
  191. package/src/cloud/settings.ts +75 -0
  192. package/src/cloud/sync.ts +212 -0
  193. package/src/cloud/types.ts +56 -0
  194. package/src/cloud/vault.ts +89 -0
  195. package/src/codex/account-id.ts +34 -0
  196. package/src/codex/account-label.ts +34 -0
  197. package/src/codex/account-lifecycle.ts +55 -0
  198. package/src/codex/account-namespace-match.ts +63 -0
  199. package/src/codex/account-namespaces.ts +149 -0
  200. package/src/codex/account-pause.ts +20 -0
  201. package/src/codex/account-runtime-state.ts +31 -0
  202. package/src/codex/account-store.ts +517 -0
  203. package/src/codex/account-usability.ts +20 -0
  204. package/src/codex/app-server-processes.ts +756 -0
  205. package/src/codex/auth-api.ts +1540 -0
  206. package/src/codex/auth-collision.ts +107 -0
  207. package/src/codex/auth-context.ts +352 -0
  208. package/src/codex/autostart-health.ts +149 -0
  209. package/src/codex/catalog/aggregation.ts +378 -0
  210. package/src/codex/catalog/bundled.ts +251 -0
  211. package/src/codex/catalog/effort.ts +355 -0
  212. package/src/codex/catalog/metadata.ts +180 -0
  213. package/src/codex/catalog/parsing.ts +456 -0
  214. package/src/codex/catalog/provider-fetch.ts +902 -0
  215. package/src/codex/catalog/sync.ts +620 -0
  216. package/src/codex/catalog.ts +12 -0
  217. package/src/codex/data/upstream-models.json +830 -0
  218. package/src/codex/exec-invocation.ts +22 -0
  219. package/src/codex/features.ts +969 -0
  220. package/src/codex/history-migration-guardian.ts +102 -0
  221. package/src/codex/history-provider.ts +776 -0
  222. package/src/codex/home.ts +206 -0
  223. package/src/codex/inject.ts +799 -0
  224. package/src/codex/injected-marker.ts +72 -0
  225. package/src/codex/journal.ts +163 -0
  226. package/src/codex/main-account-cache.ts +32 -0
  227. package/src/codex/main-account.ts +40 -0
  228. package/src/codex/model-cache.ts +227 -0
  229. package/src/codex/paths.ts +65 -0
  230. package/src/codex/plugins-doctor.ts +242 -0
  231. package/src/codex/pool-rotation.ts +225 -0
  232. package/src/codex/project-config-warnings.ts +411 -0
  233. package/src/codex/quota.ts +411 -0
  234. package/src/codex/refresh.ts +53 -0
  235. package/src/codex/routing.ts +1477 -0
  236. package/src/codex/runtime.ts +538 -0
  237. package/src/codex/shim.ts +1189 -0
  238. package/src/codex/subagent-defaults.ts +550 -0
  239. package/src/codex/subagent-model-fallback.ts +469 -0
  240. package/src/codex/sync.ts +130 -0
  241. package/src/codex/warmup.ts +192 -0
  242. package/src/codex/websocket-registry.ts +100 -0
  243. package/src/combos/failover.ts +140 -0
  244. package/src/combos/index.ts +41 -0
  245. package/src/combos/request.ts +62 -0
  246. package/src/combos/resolve.ts +232 -0
  247. package/src/combos/types.ts +326 -0
  248. package/src/config.ts +2356 -0
  249. package/src/generated/jawcode-model-metadata.ts +104 -0
  250. package/src/github/star-state.ts +203 -0
  251. package/src/grok/inject.ts +545 -0
  252. package/src/grok/status.ts +121 -0
  253. package/src/grok/sync.ts +103 -0
  254. package/src/grok/usage-hook/report.mjs +348 -0
  255. package/src/grok/usage-hook.ts +278 -0
  256. package/src/images/artifacts.ts +516 -0
  257. package/src/images/fulfill-video.ts +163 -0
  258. package/src/images/fulfill.ts +149 -0
  259. package/src/images/index.ts +4 -0
  260. package/src/images/loop.ts +829 -0
  261. package/src/images/plan.ts +133 -0
  262. package/src/images/synthetic-tool.ts +133 -0
  263. package/src/images/types.ts +41 -0
  264. package/src/images/xai-client.ts +141 -0
  265. package/src/images/xai-video-client.ts +163 -0
  266. package/src/index.ts +22 -0
  267. package/src/lib/abort.ts +146 -0
  268. package/src/lib/admin-secrets.ts +25 -0
  269. package/src/lib/admission.ts +83 -0
  270. package/src/lib/app-owned-memory-stores.ts +173 -0
  271. package/src/lib/app-owned-memory.ts +265 -0
  272. package/src/lib/bounded-body.ts +202 -0
  273. package/src/lib/bun-binary-validator.d.mts +3 -0
  274. package/src/lib/bun-binary-validator.mjs +18 -0
  275. package/src/lib/bun-runtime.ts +71 -0
  276. package/src/lib/bun-stream-caps.ts +126 -0
  277. package/src/lib/config-ownership.ts +360 -0
  278. package/src/lib/crash-guard.ts +344 -0
  279. package/src/lib/debug-log-buffer.ts +83 -0
  280. package/src/lib/debug-settings.ts +108 -0
  281. package/src/lib/debug.ts +31 -0
  282. package/src/lib/destination-policy.ts +316 -0
  283. package/src/lib/errors.ts +364 -0
  284. package/src/lib/eventstream-decoder.ts +253 -0
  285. package/src/lib/gcp-adc.ts +341 -0
  286. package/src/lib/injection-debug-log.ts +58 -0
  287. package/src/lib/open-url.ts +25 -0
  288. package/src/lib/pinned-http.ts +151 -0
  289. package/src/lib/privacy.ts +20 -0
  290. package/src/lib/process-control.ts +165 -0
  291. package/src/lib/provider-outbound.ts +170 -0
  292. package/src/lib/provider-url.ts +14 -0
  293. package/src/lib/proxy-env.ts +18 -0
  294. package/src/lib/redact.ts +105 -0
  295. package/src/lib/retry-after.ts +55 -0
  296. package/src/lib/service-secrets.ts +25 -0
  297. package/src/lib/shadow-call.ts +30 -0
  298. package/src/lib/sidecar-tracker.ts +52 -0
  299. package/src/lib/sse-decoder.ts +323 -0
  300. package/src/lib/state-store-registrations.ts +109 -0
  301. package/src/lib/state-store-sweeper.ts +184 -0
  302. package/src/lib/test-home-guard.ts +90 -0
  303. package/src/lib/token-estimate.ts +69 -0
  304. package/src/lib/translator-budget.ts +356 -0
  305. package/src/lib/upstream-retry.ts +239 -0
  306. package/src/lib/win-exec.ts +115 -0
  307. package/src/lib/win-paths.ts +68 -0
  308. package/src/lib/windows-elevation.ts +705 -0
  309. package/src/lib/windows-secret-acl.ts +514 -0
  310. package/src/lib/winsw.ts +375 -0
  311. package/src/oauth/anthropic-routing.ts +594 -0
  312. package/src/oauth/anthropic.ts +177 -0
  313. package/src/oauth/callback-server.ts +294 -0
  314. package/src/oauth/chatgpt.ts +150 -0
  315. package/src/oauth/cursor.ts +211 -0
  316. package/src/oauth/github-copilot.ts +428 -0
  317. package/src/oauth/google-antigravity.ts +230 -0
  318. package/src/oauth/health.ts +399 -0
  319. package/src/oauth/index.ts +1174 -0
  320. package/src/oauth/key-providers.ts +108 -0
  321. package/src/oauth/kimi.ts +213 -0
  322. package/src/oauth/kiro-credentials.ts +726 -0
  323. package/src/oauth/kiro.ts +577 -0
  324. package/src/oauth/local-token-detect.ts +121 -0
  325. package/src/oauth/log.ts +48 -0
  326. package/src/oauth/login-cli.ts +163 -0
  327. package/src/oauth/pkce.ts +15 -0
  328. package/src/oauth/store.ts +630 -0
  329. package/src/oauth/token-guardian.ts +303 -0
  330. package/src/oauth/types.ts +62 -0
  331. package/src/oauth/xai.ts +241 -0
  332. package/src/pi/extensions.ts +72 -0
  333. package/src/pi/home.ts +42 -0
  334. package/src/pi/index.ts +40 -0
  335. package/src/pi/models.ts +278 -0
  336. package/src/pi/packages.ts +219 -0
  337. package/src/pi/settings.ts +365 -0
  338. package/src/pi/status.ts +68 -0
  339. package/src/pi/sync.ts +75 -0
  340. package/src/providers/alibaba-region-backup.ts +75 -0
  341. package/src/providers/alibaba-region-migration.ts +156 -0
  342. package/src/providers/alibaba-region-startup.ts +36 -0
  343. package/src/providers/antigravity-models.ts +205 -0
  344. package/src/providers/api-keys.ts +140 -0
  345. package/src/providers/base-url-choices.ts +64 -0
  346. package/src/providers/context-cap.ts +65 -0
  347. package/src/providers/derive.ts +339 -0
  348. package/src/providers/free-directory.ts +184 -0
  349. package/src/providers/github-copilot-transport.ts +56 -0
  350. package/src/providers/key-failover.ts +203 -0
  351. package/src/providers/kiro-models.ts +67 -0
  352. package/src/providers/label.ts +19 -0
  353. package/src/providers/model-discovery.ts +356 -0
  354. package/src/providers/openai-sidecar.ts +175 -0
  355. package/src/providers/openai-tier-startup.ts +27 -0
  356. package/src/providers/openai-tiers.ts +301 -0
  357. package/src/providers/openai-virtual-models.ts +82 -0
  358. package/src/providers/openrouter-routing.ts +102 -0
  359. package/src/providers/provider-id-rewrite.ts +150 -0
  360. package/src/providers/quota.ts +1260 -0
  361. package/src/providers/registry.ts +1600 -0
  362. package/src/providers/slug-codec.ts +67 -0
  363. package/src/providers/xai-transport.ts +141 -0
  364. package/src/reasoning-effort.ts +135 -0
  365. package/src/responses/compaction.ts +117 -0
  366. package/src/responses/parser.ts +656 -0
  367. package/src/responses/reasoning-envelope.ts +52 -0
  368. package/src/responses/schema.ts +159 -0
  369. package/src/responses/spill-store.ts +394 -0
  370. package/src/responses/state.ts +895 -0
  371. package/src/responses/tool-groups.ts +19 -0
  372. package/src/router.ts +425 -0
  373. package/src/server/adapter-resolve.ts +80 -0
  374. package/src/server/auth-cors.ts +530 -0
  375. package/src/server/chat-completions.ts +368 -0
  376. package/src/server/claude-messages.ts +914 -0
  377. package/src/server/effort-policy.ts +172 -0
  378. package/src/server/gui-static.ts +123 -0
  379. package/src/server/image-retry.ts +42 -0
  380. package/src/server/images.ts +476 -0
  381. package/src/server/index.ts +1126 -0
  382. package/src/server/lifecycle.ts +227 -0
  383. package/src/server/live.ts +598 -0
  384. package/src/server/management/agent-settings-routes.ts +1169 -0
  385. package/src/server/management/api-access.ts +141 -0
  386. package/src/server/management/api-key-usage.ts +167 -0
  387. package/src/server/management/body.ts +35 -0
  388. package/src/server/management/clients-routes.ts +63 -0
  389. package/src/server/management/cloud-sync-routes.ts +266 -0
  390. package/src/server/management/combo-routes.ts +220 -0
  391. package/src/server/management/config-routes.ts +422 -0
  392. package/src/server/management/context.ts +31 -0
  393. package/src/server/management/logs-usage-routes.ts +707 -0
  394. package/src/server/management/model-routes.ts +525 -0
  395. package/src/server/management/oauth-account-routes.ts +563 -0
  396. package/src/server/management/provider-routes.ts +556 -0
  397. package/src/server/management/shared.ts +277 -0
  398. package/src/server/management/sidebar-routes.ts +90 -0
  399. package/src/server/management/system-restart.ts +179 -0
  400. package/src/server/management/system-routes.ts +117 -0
  401. package/src/server/management/usage-summary-cache.ts +86 -0
  402. package/src/server/management-api.ts +215 -0
  403. package/src/server/management-auth.ts +267 -0
  404. package/src/server/memory-watchdog.ts +156 -0
  405. package/src/server/port-reclaim.ts +307 -0
  406. package/src/server/ports.ts +116 -0
  407. package/src/server/proxy-liveness.ts +201 -0
  408. package/src/server/relay-eager.ts +313 -0
  409. package/src/server/relay.ts +1049 -0
  410. package/src/server/request-decompress.ts +132 -0
  411. package/src/server/request-log-conversation.ts +168 -0
  412. package/src/server/request-log.ts +1046 -0
  413. package/src/server/responses/collaboration.ts +354 -0
  414. package/src/server/responses/compact.ts +384 -0
  415. package/src/server/responses/core.ts +2758 -0
  416. package/src/server/responses/encrypted-payload.ts +308 -0
  417. package/src/server/responses/fetch-helpers.ts +157 -0
  418. package/src/server/responses/passthrough-error.ts +78 -0
  419. package/src/server/responses/terminal-guard.ts +230 -0
  420. package/src/server/responses/upstream-error.ts +48 -0
  421. package/src/server/responses-image-gen-repair.ts +132 -0
  422. package/src/server/responses-item-id-repair.ts +224 -0
  423. package/src/server/responses.ts +9 -0
  424. package/src/server/search.ts +136 -0
  425. package/src/server/sse-payload-rewrite.ts +175 -0
  426. package/src/server/startup-action-control.ts +308 -0
  427. package/src/server/startup-health-cache.ts +113 -0
  428. package/src/server/system-env.ts +413 -0
  429. package/src/server/windows-tcp-drop.ts +184 -0
  430. package/src/server/windows-tray-control.ts +41 -0
  431. package/src/server/ws-bridge.ts +471 -0
  432. package/src/service.ts +2554 -0
  433. package/src/stall-timeout.ts +20 -0
  434. package/src/storage/cleanup-job.ts +57 -0
  435. package/src/storage/cleanup.ts +3085 -0
  436. package/src/storage/policy-job.ts +457 -0
  437. package/src/storage/policy-scheduler.ts +40 -0
  438. package/src/storage/policy-worker.ts +59 -0
  439. package/src/storage/policy.ts +527 -0
  440. package/src/storage/restore-job.ts +299 -0
  441. package/src/storage/restore-worker.ts +58 -0
  442. package/src/storage/scanner.ts +238 -0
  443. package/src/storage/storage-mutation-coordinator.ts +139 -0
  444. package/src/storage/worker-lifecycle.ts +215 -0
  445. package/src/tray/assets/opencodex-tray-offline.ico +0 -0
  446. package/src/tray/assets/opencodex-tray-online.ico +0 -0
  447. package/src/tray/assets/opencodex-tray-warning.ico +0 -0
  448. package/src/tray/assets/opencodex-tray.png +0 -0
  449. package/src/tray/windows-tray.ps1 +290 -0
  450. package/src/tray/windows.ts +730 -0
  451. package/src/types.ts +1237 -0
  452. package/src/update/badge.ts +72 -0
  453. package/src/update/index.ts +407 -0
  454. package/src/update/job.ts +1520 -0
  455. package/src/update/notify.ts +257 -0
  456. package/src/update/npm-invocation.d.mts +23 -0
  457. package/src/update/npm-invocation.mjs +94 -0
  458. package/src/update/tray-update-plan.d.mts +18 -0
  459. package/src/update/tray-update-plan.mjs +38 -0
  460. package/src/usage/cost.ts +0 -0
  461. package/src/usage/debug.ts +97 -0
  462. package/src/usage/expected-prices.ts +164 -0
  463. package/src/usage/log.ts +658 -0
  464. package/src/usage/summary.ts +585 -0
  465. package/src/usage/totals.ts +14 -0
  466. package/src/vision/anthropic-describe.ts +185 -0
  467. package/src/vision/describe.ts +125 -0
  468. package/src/vision/index.ts +467 -0
  469. package/src/web-search/anthropic-executor.ts +189 -0
  470. package/src/web-search/executor.ts +105 -0
  471. package/src/web-search/format-result.ts +89 -0
  472. package/src/web-search/index.ts +196 -0
  473. package/src/web-search/loop.ts +664 -0
  474. package/src/web-search/parse.ts +220 -0
  475. package/src/web-search/progress-stream.ts +342 -0
  476. package/src/web-search/synthetic-tool.ts +47 -0
@@ -0,0 +1,545 @@
1
+ import { constants, copyFileSync, existsSync, readFileSync, statSync } from "node:fs";
2
+ import { homedir } from "node:os";
3
+ import { join } from "node:path";
4
+ import { atomicWriteFile } from "../config";
5
+ import { applyEol, dominantEol, isLoopbackHostname, providerBaseHost } from "../codex/inject";
6
+ import { stripGrokUsageHooks } from "./usage-hook";
7
+
8
+ export interface GrokInjectModel {
9
+ id: string;
10
+ name?: string;
11
+ contextWindow?: number;
12
+ }
13
+
14
+ export interface GrokInjectResult {
15
+ ok: boolean;
16
+ changed: boolean;
17
+ message: string;
18
+ skippedReason?: "no-grok-home" | "orphaned-marker" | "non-loopback";
19
+ }
20
+
21
+ const BEGIN_MARKER = "# >>> opencodex managed block — do not edit (removed by `ocx stop`) >>>";
22
+ const END_MARKER = "# <<< opencodex managed block <<<";
23
+ // grok 0.2.101 verified live (2026-07-23): [model_providers.<id>] inheritance parses but the
24
+ // inherited base_url is NOT applied to inference routing — the turn falls through to the default
25
+ // cli-chat-proxy and 401s. Per-model direct fields DO route. So every [model.*] block carries its
26
+ // own base_url/api_backend/api_key and no [model_providers] table is emitted at all.
27
+
28
+ interface ManagedRegion {
29
+ start: number;
30
+ end: number;
31
+ orphaned: boolean;
32
+ }
33
+
34
+ function tomlString(value: string): string {
35
+ return JSON.stringify(value);
36
+ }
37
+
38
+ function resolveGrokHome(grokHome?: string): string {
39
+ return grokHome ?? (process.env.GROK_HOME || join(homedir(), ".grok"));
40
+ }
41
+
42
+ function isDirectory(path: string): boolean {
43
+ try {
44
+ return statSync(path).isDirectory();
45
+ } catch {
46
+ return false;
47
+ }
48
+ }
49
+
50
+ function findManagedRegion(content: string): ManagedRegion | null {
51
+ const start = content.indexOf(BEGIN_MARKER);
52
+ if (start === -1) return null;
53
+ const endMarkerStart = content.indexOf(END_MARKER, start + BEGIN_MARKER.length);
54
+ if (endMarkerStart === -1) return { start, end: content.length, orphaned: true };
55
+ return { start, end: endMarkerStart + END_MARKER.length, orphaned: false };
56
+ }
57
+
58
+ /**
59
+ * A TOML key segment as it may be spelled in a table header: bare, basic string, or literal
60
+ * string. All three spellings of the same key address the SAME table, so both segments of a
61
+ * `[model.<alias>]` header must be canonicalized before comparison.
62
+ */
63
+ const KEY_SEGMENT = String.raw`(?:[A-Za-z0-9_-]+|"(?:[^"\\]|\\.)*"|'[^']*')`;
64
+ /**
65
+ * User-owned model table headers. Also matches array-of-table (`[[model.x]]`) and sub-table
66
+ * (`[model.x.sub]`) spellings. `[[model.x]]` genuinely collides with a generated `[model.x]`,
67
+ * and one collision makes grok reject the ENTIRE config layer ("duplicate key"), taking every
68
+ * unrelated user setting with it; `[model.x.sub]` does not strictly collide, but reserving it
69
+ * costs only a suffixed alias and keeps us clear of the user's namespace.
70
+ *
71
+ * Every character class here is newline-free ON PURPOSE. With `[^\]]*` the optional sub-table
72
+ * tail runs past the end of its own line, so an unclosed `[model.…` inside a multiline string
73
+ * swallows the following lines — including a real `[model.<alias>]` header, which then goes
74
+ * unreserved and produces the very duplicate-key config this scan exists to prevent.
75
+ */
76
+ const MODEL_TABLE_HEADER = new RegExp(
77
+ String.raw`^[ \t]*\[\[?[ \t]*(${KEY_SEGMENT})[ \t]*\.[ \t]*(${KEY_SEGMENT})[ \t]*(?:\.[^\]\r\n]*)?\]\]?[ \t]*(?:#.*)?$`,
78
+ "gm",
79
+ );
80
+
81
+ /**
82
+ * ANY table header, capturing its full dotted key. Used to compute table SPANS: a table
83
+ * body runs from its own header to the next header of any kind, so the orphan sweep can
84
+ * remove a whole table instead of a guessed line range (a partial removal would re-parent
85
+ * the leftover keys onto the preceding table).
86
+ */
87
+ const ANY_TABLE_HEADER = /^[ \t]*\[\[?[ \t]*([^\]\r\n]*?)[ \t]*\]\]?[ \t]*(?:#.*)?$/gm;
88
+
89
+ /** Resolve a header key segment (bare / basic / literal) to the key it actually addresses. */
90
+ function canonicalKeySegment(raw: string): string {
91
+ if (raw.startsWith('"')) return decodeTomlBasicString(raw.slice(1, -1));
92
+ if (raw.startsWith("'")) return raw.slice(1, -1); // literal strings have no escapes
93
+ return raw;
94
+ }
95
+
96
+ /**
97
+ * `[model.<alias>]` table headers the USER owns (outside our fence) — reserved for collisions.
98
+ * TOML admits equivalent header spellings for BOTH segments (`["model"."ocx-mine"]`,
99
+ * `['model'.ocx-mine]`, `[ model . ocx-mine ]`); all of them redefine the same table, so each
100
+ * form is canonicalized before it is reserved.
101
+ */
102
+ function userModelAliases(content: string, region: ManagedRegion | null): Set<string> {
103
+ const outsideManagedRegion = region
104
+ ? content.slice(0, region.start) + content.slice(region.end)
105
+ : content;
106
+ const aliases = new Set<string>();
107
+ for (const match of outsideManagedRegion.matchAll(MODEL_TABLE_HEADER)) {
108
+ if (canonicalKeySegment(match[1]!) !== "model") continue;
109
+ aliases.add(canonicalKeySegment(match[2]!));
110
+ }
111
+ return aliases;
112
+ }
113
+
114
+ /**
115
+ * The api_key literal every generated entry carries. It is the STRONG ownership signal:
116
+ * a value we mint, that a human has no reason to type by hand.
117
+ */
118
+ const OPENCODEX_API_KEY = "opencodex-loopback";
119
+
120
+ /** A plain `[model.<alias>]` table outside the fence that opencodex itself wrote. */
121
+ interface OrphanTable {
122
+ alias: string;
123
+ /** The model id this entry routes to — used to find its replacement alias. */
124
+ modelId: string | undefined;
125
+ /** Offsets into the NORMALIZED content: header start .. next header start (or EOF). */
126
+ start: number;
127
+ end: number;
128
+ }
129
+
130
+ /** `key = "value"` / `key = value` pairs at the top level of one table body. */
131
+ function tableBodyKeys(body: string): Map<string, string> {
132
+ const keys = new Map<string, string>();
133
+ for (const line of body.split("\n")) {
134
+ const match = /^[ \t]*([A-Za-z0-9_-]+)[ \t]*=[ \t]*(.*?)[ \t]*$/.exec(line);
135
+ if (!match) continue;
136
+ const raw = match[2]!;
137
+ const value = raw.startsWith('"') && raw.endsWith('"') && raw.length >= 2
138
+ ? decodeTomlBasicString(raw.slice(1, -1))
139
+ : raw;
140
+ if (!keys.has(match[1]!)) keys.set(match[1]!, value);
141
+ }
142
+ return keys;
143
+ }
144
+
145
+ /** Is this base_url pointed at the local machine? */
146
+ function isLoopbackBaseUrl(value: string | undefined): boolean {
147
+ if (!value) return false;
148
+ try {
149
+ return isLoopbackHostname(new URL(value).hostname);
150
+ } catch {
151
+ return false;
152
+ }
153
+ }
154
+
155
+ /**
156
+ * Model tables OUTSIDE the fence that opencodex itself wrote (#511).
157
+ *
158
+ * These are entries from a version that predates the markers (or predates
159
+ * `context_window`). Because they sit outside the fence, `userModelAliases` reserves
160
+ * their aliases as user-owned and the writer routes around them with a `-2` suffix — so
161
+ * every sync adds a CORRECT duplicate and never removes the stale original. Grok then
162
+ * resolves the original, finds no `context_window`, and falls back to its own 200k.
163
+ *
164
+ * Ownership is CONJUNCTIVE and deliberately strict, because a false positive deletes a
165
+ * hand-written user model:
166
+ * - a plain `[model.x]` header (never `[[model.x]]` / `[model.x.sub]` — those spellings
167
+ * mark human authorship and stay reserved);
168
+ * - `api_key` equal to our own literal;
169
+ * - a loopback `base_url`, so an entry that merely copied our key while pointing at a
170
+ * remote host is left alone.
171
+ * A loopback base_url ALONE is not enough: aiming your own model at the local proxy is a
172
+ * legitimate thing to do.
173
+ */
174
+ function findOpencodexOrphans(content: string, region: ManagedRegion | null): OrphanTable[] {
175
+ const orphans: OrphanTable[] = [];
176
+ // A pre-fence orphan's body must stop AT the fence. The managed block opens with a
177
+ // COMMENT, not a table header, so a span that runs to "the next table header" swallows
178
+ // the BEGIN marker whenever no other table separates them — and removing the orphan then
179
+ // deletes the fence opener itself, which strands the END marker and makes every later
180
+ // sync re-append a block (the #511 duplicate loop, one layer down).
181
+ // `region` is null ONLY when BEGIN_MARKER is absent (see findManagedRegion), so -1
182
+ // disables the clamp for marker-less files without a redundant scan.
183
+ const fenceStart = region ? region.start : -1;
184
+ const clampEnd = (start: number, end: number): number =>
185
+ fenceStart >= 0 && start < fenceStart ? Math.min(end, fenceStart) : end;
186
+ // Collect every table header first: a table body runs to the NEXT header, whatever it is.
187
+ const headers: Array<{ index: number; length: number; segments: string[]; array: boolean }> = [];
188
+ for (const match of content.matchAll(ANY_TABLE_HEADER)) {
189
+ headers.push({
190
+ index: match.index!,
191
+ length: match[0].length,
192
+ segments: match[1]!.split(".").map(part => canonicalKeySegment(part.trim())),
193
+ array: match[0].trimStart().startsWith("[["),
194
+ });
195
+ }
196
+ for (const [position, header] of headers.entries()) {
197
+ if (header.array || header.segments.length !== 2 || header.segments[0] !== "model") continue;
198
+ // Inside the fence the regular splice already owns it.
199
+ if (region && header.index >= region.start && header.index < region.end) continue;
200
+ const bodyEnd = clampEnd(header.index, headers[position + 1]?.index ?? content.length);
201
+ const keys = tableBodyKeys(content.slice(header.index + header.length, bodyEnd));
202
+ if (keys.get("api_key") !== OPENCODEX_API_KEY) continue;
203
+ if (!isLoopbackBaseUrl(keys.get("base_url"))) continue;
204
+ // Swallow the entry's OWN sub-tables (`[model.<alias>.extra_headers]`). Grok writes
205
+ // them when it re-serializes the file, and leaving one behind keeps the alias
206
+ // reserved by `userModelAliases` — so the sweep would remove the parent and STILL
207
+ // allocate a suffixed duplicate, which is the exact #511 loop we came to close.
208
+ let end = bodyEnd;
209
+ for (let next = position + 1; next < headers.length; next += 1) {
210
+ const child = headers[next]!;
211
+ // Only a PRE-fence parent may be cut short by the fence. Without the parent test a
212
+ // below-fence orphan would break on its first child (every index is past the fence),
213
+ // leaving the sub-table behind to keep the alias reserved — the -2 loop again.
214
+ if (fenceStart >= 0 && header.index < fenceStart && child.index >= fenceStart) break;
215
+ if (child.segments.length <= 2) break;
216
+ if (child.segments[0] !== "model" || child.segments[1] !== header.segments[1]) break;
217
+ end = clampEnd(header.index, headers[next + 1]?.index ?? content.length);
218
+ }
219
+ orphans.push({ alias: header.segments[1]!, modelId: keys.get("model"), start: header.index, end });
220
+ }
221
+ return orphans;
222
+ }
223
+
224
+ /** Remove whole tables, back to front so earlier offsets stay valid. */
225
+ function removeOrphanTables(content: string, orphans: OrphanTable[]): string {
226
+ let next = content;
227
+ for (const orphan of [...orphans].sort((a, b) => b.start - a.start)) {
228
+ next = next.slice(0, orphan.start) + next.slice(orphan.end);
229
+ }
230
+ return next;
231
+ }
232
+
233
+ /**
234
+ * Repoint `default` / `fork_secondary_model` at the alias that survived.
235
+ *
236
+ * Removing an adopted orphan that `[models] default` names would leave Grok pointing at
237
+ * a model that no longer exists — and on a real machine `default` DOES name one, so this
238
+ * is the common path rather than an edge case.
239
+ */
240
+ function rewriteAliasReferences(content: string, renames: Map<string, string>): string {
241
+ if (renames.size === 0) return content;
242
+ return content.replace(
243
+ /^([ \t]*(?:default|fork_secondary_model)[ \t]*=[ \t]*")([^"]*)(")/gm,
244
+ (whole, prefix: string, value: string, suffix: string) => {
245
+ const replacement = renames.get(value);
246
+ return replacement ? `${prefix}${replacement}${suffix}` : whole;
247
+ },
248
+ );
249
+ }
250
+
251
+ function orphanedMarkerResult(action: string): GrokInjectResult {
252
+ return {
253
+ ok: false,
254
+ changed: false,
255
+ message: `Grok config ${action} refused: found the opencodex begin marker without its end marker. `
256
+ + "The managed region boundary is ambiguous, so nothing was modified. "
257
+ + "Repair ~/.grok/config.toml manually (see config.toml.bak-opencodex) and re-run.",
258
+ skippedReason: "orphaned-marker",
259
+ };
260
+ }
261
+
262
+ function copyBackupOnce(configPath: string, backupPath: string): void {
263
+ if (existsSync(backupPath)) return;
264
+ try {
265
+ copyFileSync(configPath, backupPath, constants.COPYFILE_EXCL);
266
+ } catch (error) {
267
+ if (!(error instanceof Error && "code" in error && error.code === "EEXIST")) throw error;
268
+ }
269
+ }
270
+
271
+ function errorResult(action: string, error: unknown): GrokInjectResult {
272
+ const detail = error instanceof Error ? error.message : String(error);
273
+ return { ok: false, changed: false, message: `Could not ${action} Grok config: ${detail}` };
274
+ }
275
+
276
+ export function buildGrokManagedBlock(
277
+ port: number,
278
+ models: GrokInjectModel[],
279
+ hostname?: string,
280
+ reservedAliases?: ReadonlySet<string>,
281
+ /**
282
+ * Ids to allocate an alias for but NOT emit. Alias numbering must not depend on which
283
+ * models the user switched off, or excluding one colliding model would rename another
284
+ * model's alias out from under a grok config that already uses it.
285
+ */
286
+ excluded?: ReadonlySet<string>,
287
+ ): string {
288
+ const host = providerBaseHost(hostname);
289
+ const baseUrl = `http://${host}:${port}/v1`;
290
+ const lines = [
291
+ BEGIN_MARKER,
292
+ ];
293
+ const aliasCounts = new Map<string, number>();
294
+ const taken = new Set(reservedAliases ?? []);
295
+
296
+ for (const model of models) {
297
+ const baseAlias = `ocx-${model.id.replace(/[^A-Za-z0-9_-]/g, "-")}`;
298
+ let count = (aliasCounts.get(baseAlias) ?? 0) + 1;
299
+ let alias = count === 1 ? baseAlias : `${baseAlias}-${count}`;
300
+ // User-owned [model.<alias>] tables outside the fence are reserved: emitting a
301
+ // duplicate table header would make the whole TOML invalid for grok.
302
+ while (taken.has(alias)) {
303
+ count += 1;
304
+ alias = `${baseAlias}-${count}`;
305
+ }
306
+ aliasCounts.set(baseAlias, count);
307
+ taken.add(alias);
308
+ // Slot consumed, table not written: this is what keeps every other alias stable
309
+ // across selection changes.
310
+ if (excluded?.has(model.id)) continue;
311
+ const isFirst = lines.length === 1;
312
+ lines.push(
313
+ ...(isFirst ? [] : [""]),
314
+ `[model.${alias}]`,
315
+ `model = ${tomlString(model.id)}`,
316
+ `base_url = ${tomlString(baseUrl)}`,
317
+ 'api_backend = "chat_completions"',
318
+ 'api_key = "opencodex-loopback"',
319
+ `name = ${tomlString(model.name ?? `OCX ${model.id}`)}`,
320
+ // Best-effort attribution tag for the usage dashboard. Upstream Grok sends
321
+ // extra_headers verbatim on inference calls (11-custom-models.md). This is NOT a
322
+ // security boundary — any loopback client could send the same header.
323
+ 'extra_headers = { "x-opencodex-grok" = "1" }',
324
+ );
325
+ if (Number.isFinite(model.contextWindow) && (model.contextWindow ?? 0) > 0) {
326
+ lines.push(`context_window = ${model.contextWindow}`);
327
+ }
328
+ }
329
+
330
+ lines.push(END_MARKER);
331
+ return lines.join("\n");
332
+ }
333
+
334
+ export function injectGrokConfig(
335
+ port: number,
336
+ models: GrokInjectModel[],
337
+ opts: { grokHome?: string; hostname?: string; excluded?: ReadonlySet<string> } = {},
338
+ ): GrokInjectResult {
339
+ const grokHome = resolveGrokHome(opts.grokHome);
340
+ if (!isDirectory(grokHome)) {
341
+ return {
342
+ ok: true,
343
+ changed: false,
344
+ message: `Grok home not found at ${grokHome}; config injection skipped.`,
345
+ skippedReason: "no-grok-home",
346
+ };
347
+ }
348
+
349
+ // Non-loopback binds require the real admission token (src/server/auth-cors.ts), and there is
350
+ // no safe way for a REGENERATED block to carry it: a literal token would write the user's
351
+ // secret into their own file and overwrite it on every start/ensure/restart, while omitting
352
+ // api_key in favour of env_key opens grok's credential fallthrough — with no `model_provider`
353
+ // to fail closed, an unresolved env_key makes grok send its xAI session bearer to our
354
+ // plaintext LAN endpoint (upstream config.rs resolve_credentials). So we do not auto-register
355
+ // at all here; the user configures models manually, outside our fence, where nothing we do
356
+ // can clobber their credential.
357
+ if (!isLoopbackHostname(opts.hostname)) {
358
+ const removed = stripGrokConfig({ ...(opts.grokHome !== undefined ? { grokHome: opts.grokHome } : {}) });
359
+ const cleanup = removed.changed
360
+ ? " Removed the previously generated block, which pointed at a loopback address."
361
+ : "";
362
+ return {
363
+ ok: true, // a deliberate policy skip, not a failure — it must never block startup
364
+ changed: removed.changed,
365
+ skippedReason: "non-loopback",
366
+ message: `Grok auto-registration skipped: opencodex is bound to the non-loopback host `
367
+ + `"${opts.hostname}", where requests need your admission token. A managed block would `
368
+ + `either store that secret in ~/.grok/config.toml or overwrite it on the next start, so `
369
+ + `add the models yourself OUTSIDE the opencodex markers (see the Grok Build guide).${cleanup}`,
370
+ };
371
+ }
372
+
373
+ const configPath = join(grokHome, "config.toml");
374
+ const backupPath = join(grokHome, "config.toml.bak-opencodex");
375
+ try {
376
+ const configExisted = existsSync(configPath);
377
+ const rawContent = configExisted ? readFileSync(configPath, "utf8") : "";
378
+ const eol = dominantEol(rawContent);
379
+ const originalContent = applyEol(rawContent, "\n");
380
+ const originalRegion = findManagedRegion(originalContent);
381
+ // Ambiguous fence: refuse before the sweep, or "outside the region" could mean the
382
+ // entire file.
383
+ if (originalRegion?.orphaned) return orphanedMarkerResult("injection");
384
+
385
+ // Adopt our own pre-fence entries (#511) BEFORE reserving user aliases, so the stale
386
+ // duplicate is replaced instead of routed around forever. Runs inside the normalized
387
+ // window so the user's dominant EOL is still restored below.
388
+ const orphans = findOpencodexOrphans(originalContent, originalRegion);
389
+ const content = removeOrphanTables(originalContent, orphans);
390
+ // Removing bytes above the fence MOVES it: recompute rather than adjust arithmetic,
391
+ // so the splice below cannot cut the file in the wrong place.
392
+ const region = orphans.length > 0 ? findManagedRegion(content) : originalRegion;
393
+
394
+ const block = buildGrokManagedBlock(port, models, opts.hostname, userModelAliases(content, region), opts.excluded);
395
+ let nextContent: string;
396
+ if (region) {
397
+ nextContent = content.slice(0, region.start) + block + content.slice(region.end);
398
+ } else if (content.length === 0) {
399
+ nextContent = `${block}\n`;
400
+ } else {
401
+ // Exactly ONE separator newline, always. The old rule ("\n\n" when the file lacked a
402
+ // trailing newline) made two different originals — "X" and "X\n" — produce byte-identical
403
+ // files, so strip could not restore both. One newline keeps injection injective: the
404
+ // user's own terminator is preserved verbatim and strip can undo exactly what we added.
405
+ nextContent = `${content}\n${block}\n`;
406
+ }
407
+
408
+ // Repoint `default` / `fork_secondary_model` at whichever alias survived. A removed
409
+ // model with no replacement keeps its reference untouched — a stale name in a working
410
+ // file beats a dangling one.
411
+ if (orphans.length > 0) {
412
+ const survivors = new Map<string, string>();
413
+ for (const match of nextContent.matchAll(MODEL_TABLE_HEADER)) {
414
+ if (canonicalKeySegment(match[1]!) !== "model") continue;
415
+ const alias = canonicalKeySegment(match[2]!);
416
+ const body = nextContent.slice(match.index! + match[0].length);
417
+ const modelId = tableBodyKeys(body.slice(0, body.search(/^[ \t]*\[/m) + 1 || body.length)).get("model");
418
+ if (modelId !== undefined && !survivors.has(modelId)) survivors.set(modelId, alias);
419
+ }
420
+ const renames = new Map<string, string>();
421
+ for (const orphan of orphans) {
422
+ const replacement = orphan.modelId === undefined ? undefined : survivors.get(orphan.modelId);
423
+ if (replacement && replacement !== orphan.alias) renames.set(orphan.alias, replacement);
424
+ }
425
+ nextContent = rewriteAliasReferences(nextContent, renames);
426
+ }
427
+
428
+ const output = applyEol(nextContent, eol);
429
+ if (output === rawContent) {
430
+ return { ok: true, changed: false, message: "Grok config already contains the current opencodex managed block." };
431
+ }
432
+ // Back up before a first-time fence write AND before any sweep, since adopting an orphan
433
+ // deletes a table the user has in their file. Previously the adjacent-orphan layout got a
434
+ // backup only as a side effect of the fence being destroyed (which made `region` falsy);
435
+ // preserving the fence must not silently drop that safety net.
436
+ if (configExisted && (!region || orphans.length > 0)) copyBackupOnce(configPath, backupPath);
437
+ atomicWriteFile(configPath, output);
438
+ return {
439
+ ok: true,
440
+ changed: true,
441
+ message: region
442
+ ? "Updated the opencodex managed block in Grok config."
443
+ : "Added the opencodex managed block to Grok config.",
444
+ };
445
+ } catch (error) {
446
+ return errorResult("inject", error);
447
+ }
448
+ }
449
+
450
+ export function stripGrokConfig(opts: { grokHome?: string } = {}): GrokInjectResult {
451
+ // Fence teardown (model block in config.toml).
452
+ const fence = stripGrokConfigFence(opts);
453
+ // Usage-hook teardown (hooks/opencodex-usage*). Independent of the fence so a missing
454
+ // config.toml still cleans managed hooks; best-effort so a hook failure never blocks stop.
455
+ let hooks: { ok: boolean; changed: boolean; message: string };
456
+ try {
457
+ hooks = stripGrokUsageHooks(opts);
458
+ } catch (error) {
459
+ hooks = {
460
+ ok: false,
461
+ changed: false,
462
+ message: `Grok usage hook cleanup failed: ${error instanceof Error ? error.message : String(error)}`,
463
+ };
464
+ }
465
+ if (!hooks.changed && hooks.ok) return fence;
466
+ const parts = [fence.message, hooks.message].filter(Boolean);
467
+ return {
468
+ ok: fence.ok && hooks.ok,
469
+ changed: fence.changed || hooks.changed,
470
+ message: parts.join(" "),
471
+ ...(fence.skippedReason ? { skippedReason: fence.skippedReason } : {}),
472
+ };
473
+ }
474
+
475
+ function stripGrokConfigFence(opts: { grokHome?: string } = {}): GrokInjectResult {
476
+ const grokHome = resolveGrokHome(opts.grokHome);
477
+ if (!isDirectory(grokHome)) {
478
+ return {
479
+ ok: true,
480
+ changed: false,
481
+ message: `Grok home not found at ${grokHome}; no managed config to remove.`,
482
+ skippedReason: "no-grok-home",
483
+ };
484
+ }
485
+
486
+ const configPath = join(grokHome, "config.toml");
487
+ if (!existsSync(configPath)) {
488
+ return { ok: true, changed: false, message: "Grok config not found; no managed block to remove." };
489
+ }
490
+
491
+ try {
492
+ const rawContent = readFileSync(configPath, "utf8");
493
+ const eol = dominantEol(rawContent);
494
+ const content = applyEol(rawContent, "\n");
495
+ const region = findManagedRegion(content);
496
+ if (!region) {
497
+ return { ok: true, changed: false, message: "No opencodex managed block found in Grok config." };
498
+ }
499
+ if (region.orphaned) return orphanedMarkerResult("cleanup");
500
+
501
+ let removalEnd = region.end;
502
+ if (content.startsWith("\n", removalEnd)) removalEnd += 1;
503
+ let prefix = content.slice(0, region.start);
504
+ const restOfFile = content.slice(removalEnd);
505
+ // Undo the single separator newline injection added. Two cases, mirroring inject:
506
+ // "X\n" -> "X\n" + "\n" + block => prefix ends "\n\n", drop one.
507
+ // "X" -> "X" + "\n" + block => prefix ends "\n" at EOF, drop it.
508
+ // A block the user has appended content after is left alone: we never shrink their bytes.
509
+ if (prefix.endsWith("\n\n")) prefix = prefix.slice(0, -1);
510
+ else if (restOfFile.length === 0 && prefix.endsWith("\n")) prefix = prefix.slice(0, -1);
511
+ const stripped = prefix + restOfFile;
512
+ atomicWriteFile(configPath, applyEol(stripped, eol));
513
+
514
+ return {
515
+ ok: true,
516
+ changed: true,
517
+ message: "Removed the opencodex managed block from Grok config.",
518
+ };
519
+ } catch (error) {
520
+ return errorResult("strip", error);
521
+ }
522
+ }
523
+ /** Decode a TOML basic-string body: JSON-compatible escapes plus TOML's \uXXXX / \UXXXXXXXX. */
524
+ function decodeTomlBasicString(body: string): string {
525
+ return body.replace(
526
+ /\\(u[0-9A-Fa-f]{4}|U[0-9A-Fa-f]{8}|.)/g,
527
+ (whole, esc: string) => {
528
+ if (esc[0] === "u") return String.fromCharCode(parseInt(esc.slice(1), 16));
529
+ if (esc[0] === "U") {
530
+ const code = parseInt(esc.slice(1), 16);
531
+ return code <= 0x10ffff ? String.fromCodePoint(code) : whole;
532
+ }
533
+ switch (esc) {
534
+ case "b": return "\b";
535
+ case "t": return "\t";
536
+ case "n": return "\n";
537
+ case "f": return "\f";
538
+ case "r": return "\r";
539
+ case '"': return '"';
540
+ case "\\": return "\\";
541
+ default: return whole; // invalid escape — keep raw, reservation stays conservative
542
+ }
543
+ },
544
+ );
545
+ }
@@ -0,0 +1,121 @@
1
+ /**
2
+ * Read-only view of the Grok Build managed block.
3
+ *
4
+ * This never writes. `injectGrokConfig` owns every mutation of `~/.grok/config.toml`, behind
5
+ * guards (non-loopback refusal, byte-for-byte preservation of user content, alias reservation)
6
+ * that a web-reachable writer would widen the blast radius of. The dashboard only needs to
7
+ * answer "is Grok wired up, and with what?", which a reader does at a fraction of the risk.
8
+ *
9
+ * It parses only the fenced region we ourselves emit, and only the specific fields
10
+ * `buildGrokManagedBlock` writes — user content outside the fence is never read or echoed,
11
+ * since it can legitimately contain real credentials.
12
+ */
13
+ import { readFileSync } from "node:fs";
14
+ import { homedir } from "node:os";
15
+ import { join } from "node:path";
16
+
17
+ const BEGIN_MARKER = "# >>> opencodex managed block — do not edit (removed by `ocx stop`) >>>";
18
+ const END_MARKER = "# <<< opencodex managed block <<<";
19
+
20
+ export interface GrokStatusModel {
21
+ /** Alias of the emitted `[model.<alias>]` table. */
22
+ alias: string;
23
+ /** The model id opencodex routes for. */
24
+ id: string;
25
+ contextWindow?: number;
26
+ }
27
+
28
+ export interface GrokStatus {
29
+ configPath: string;
30
+ /** Whether the managed fence is present in that file. */
31
+ present: boolean;
32
+ /** Endpoint the fence points at, parsed from the first entry. */
33
+ baseUrl: string | null;
34
+ models: GrokStatusModel[];
35
+ }
36
+
37
+ /** Mirrors `resolveGrokHome` in ./inject so both agree on the authoritative file. */
38
+ export function grokConfigPath(grokHome?: string): string {
39
+ const home = grokHome ?? (process.env.GROK_HOME || join(homedir(), ".grok"));
40
+ return join(home, "config.toml");
41
+ }
42
+
43
+ function tomlStringValue(line: string): string | undefined {
44
+ const match = /^[A-Za-z_]+\s*=\s*"((?:[^"\\]|\\.)*)"\s*$/.exec(line.trim());
45
+ if (!match) return undefined;
46
+ return match[1]!.replace(/\\(["\\])/g, "$1");
47
+ }
48
+
49
+ export function readGrokStatus(opts: { grokHome?: string } = {}): GrokStatus {
50
+ const configPath = grokConfigPath(opts.grokHome);
51
+ let content: string;
52
+ try {
53
+ content = readFileSync(configPath, "utf8");
54
+ } catch {
55
+ // No Grok install, or no config yet. Absent is a state, not an error.
56
+ return { configPath, present: false, baseUrl: null, models: [] };
57
+ }
58
+
59
+ const begin = content.indexOf(BEGIN_MARKER);
60
+ const end = content.indexOf(END_MARKER, begin + 1);
61
+ if (begin < 0 || end < 0) return { configPath, present: false, baseUrl: null, models: [] };
62
+
63
+ const region = content.slice(begin + BEGIN_MARKER.length, end);
64
+ const models: GrokStatusModel[] = [];
65
+ let baseUrl: string | null = null;
66
+ let current: GrokStatusModel | null = null;
67
+
68
+ for (const rawLine of region.split("\n")) {
69
+ const line = rawLine.trim();
70
+ const header = /^\[model\.([^\]]+)\]$/.exec(line);
71
+ if (header) {
72
+ current = { alias: header[1]!, id: "" };
73
+ models.push(current);
74
+ continue;
75
+ }
76
+ if (!current) continue;
77
+ if (line.startsWith("model =")) {
78
+ current.id = tomlStringValue(line) ?? "";
79
+ } else if (line.startsWith("base_url =")) {
80
+ baseUrl ??= tomlStringValue(line) ?? null;
81
+ } else if (line.startsWith("context_window =")) {
82
+ const value = Number(line.slice(line.indexOf("=") + 1).trim());
83
+ if (Number.isFinite(value) && value > 0) current.contextWindow = value;
84
+ }
85
+ }
86
+
87
+ return { configPath, present: true, baseUrl, models: models.filter(model => model.id) };
88
+ }
89
+
90
+ /**
91
+ * Does the fence point somewhere the running proxy is NOT?
92
+ *
93
+ * Grok resolves its model at request time and retries a refused connection up to 15
94
+ * times, and those retries are invisible to us: nothing reaches the proxy, so nothing
95
+ * lands in our log. The user sees a correct context window (the stale entry carries it)
96
+ * and an endless "Retrying (attempt N/15)". That is a silent failure unless someone
97
+ * compares the fence's port against the port we actually bound — which is exactly what
98
+ * `ocx status` is for.
99
+ *
100
+ * Returns null when there is nothing to say: no fence, an unparsable endpoint, or a
101
+ * fence that already agrees with the live listener.
102
+ */
103
+ export function grokFenceEndpointDrift(
104
+ status: Pick<GrokStatus, "present" | "baseUrl">,
105
+ livePort: number | undefined,
106
+ ): { fencePort: number; livePort: number } | null {
107
+ if (!status.present || !status.baseUrl) return null;
108
+ if (typeof livePort !== "number" || !Number.isFinite(livePort) || livePort <= 0) return null;
109
+ let fencePort: number;
110
+ try {
111
+ const url = new URL(status.baseUrl);
112
+ // An explicit port is the only thing we write, so an empty one means a shape we did
113
+ // not produce; stay quiet rather than guess a default.
114
+ if (!url.port) return null;
115
+ fencePort = Number(url.port);
116
+ } catch {
117
+ return null;
118
+ }
119
+ if (!Number.isFinite(fencePort) || fencePort === livePort) return null;
120
+ return { fencePort, livePort };
121
+ }