@bitkyc08/opencodex 2.59.0 → 2.61.0-preview.20260922

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 (374) hide show
  1. package/AGENTS_INSTALL.md +64 -0
  2. package/README.md +28 -1
  3. package/bin/ocx.mjs +382 -209
  4. package/gui/dist/assets/App-E64Rzjap.js +50 -0
  5. package/gui/dist/assets/Tray-_nfzD8k4.js +1 -0
  6. package/gui/dist/assets/index-DpdfZWMK.js +86 -0
  7. package/gui/dist/assets/index-_bpvxJu0.css +1 -0
  8. package/gui/dist/assets/usage-companion-chart-DtoK7T6h.js +1 -0
  9. package/gui/dist/favicon.png +0 -0
  10. package/gui/dist/index.html +2 -2
  11. package/gui/dist/provider-icons/stepfun-color.svg +1 -0
  12. package/package.json +8 -1
  13. package/src/adapters/anthropic-image-codec.ts +16 -2
  14. package/src/adapters/anthropic-image-normalize.ts +49 -2
  15. package/src/adapters/anthropic.ts +20 -1
  16. package/src/adapters/base.ts +23 -0
  17. package/src/adapters/coding-agent/protocol.ts +36 -6
  18. package/src/adapters/coding-agent/turn.ts +32 -4
  19. package/src/adapters/command-code.ts +52 -4
  20. package/src/adapters/cursor/catalog.ts +51 -7
  21. package/src/adapters/cursor/checkpoint-store.ts +3 -0
  22. package/src/adapters/cursor/discovery.ts +11 -8
  23. package/src/adapters/cursor/live-transport.ts +26 -9
  24. package/src/adapters/cursor/protobuf-request.ts +6 -3
  25. package/src/adapters/cursor/request-builder.ts +20 -4
  26. package/src/adapters/cursor/transport.ts +19 -0
  27. package/src/adapters/cursor.ts +14 -3
  28. package/src/adapters/declaration-carrier.ts +45 -0
  29. package/src/adapters/devin/cloud-direct/chat.ts +3 -1
  30. package/src/adapters/devin/cloud-direct/stated-reset-retry.ts +42 -5
  31. package/src/adapters/devin.ts +125 -36
  32. package/src/adapters/google-antigravity-replay.ts +1 -1
  33. package/src/adapters/google-antigravity-wire.ts +34 -7
  34. package/src/adapters/google-errors.ts +7 -1
  35. package/src/adapters/google-http.ts +49 -10
  36. package/src/adapters/google-tool-schema.ts +595 -31
  37. package/src/adapters/google-wire-compiler.ts +93 -10
  38. package/src/adapters/google-wire-shape.ts +461 -0
  39. package/src/adapters/google.ts +66 -11
  40. package/src/adapters/image.ts +4 -1
  41. package/src/adapters/input-media-guard.ts +21 -9
  42. package/src/adapters/kiro/usage.ts +3 -2
  43. package/src/adapters/kiro-tool-fallback.ts +1 -1
  44. package/src/adapters/ollama-native.ts +6 -0
  45. package/src/adapters/openai-chat/developer-role.ts +61 -0
  46. package/src/adapters/openai-chat/messages.ts +46 -27
  47. package/src/adapters/openai-chat/parallel-tool-calls.ts +32 -0
  48. package/src/adapters/openai-chat/passthrough.ts +33 -9
  49. package/src/adapters/openai-chat/reasoning-wire.ts +89 -0
  50. package/src/adapters/openai-chat-images.ts +3 -1
  51. package/src/adapters/openai-chat.ts +23 -58
  52. package/src/adapters/openai-responses/image-gen.ts +8 -6
  53. package/src/adapters/openai-responses/passthrough.ts +17 -3
  54. package/src/adapters/openai-responses/reasoning.ts +7 -0
  55. package/src/adapters/opencode-go-additional-tools.ts +12 -2
  56. package/src/adapters/registry.ts +3 -2
  57. package/src/adapters/run-turn-queue.ts +178 -29
  58. package/src/adapters/xai-web-search.ts +16 -1
  59. package/src/bridge/errors.ts +8 -2
  60. package/src/bridge/response-json.ts +9 -1
  61. package/src/bridge/sse.ts +13 -151
  62. package/src/chat/inbound.ts +141 -5
  63. package/src/claude/desktop-3p.ts +7 -1
  64. package/src/claude/desktop-first-party.ts +183 -0
  65. package/src/claude/desktop-gateway-state.ts +41 -0
  66. package/src/claude/inbound-content-options.ts +6 -0
  67. package/src/claude/inbound.ts +32 -6
  68. package/src/claude/intercept/connect-proxy.ts +179 -0
  69. package/src/claude/intercept/listener.ts +122 -0
  70. package/src/claude/intercept/local-ca.ts +298 -0
  71. package/src/claude/intercept/runtime.ts +98 -0
  72. package/src/claude/intercept/settings.ts +189 -0
  73. package/src/cli/access.ts +87 -0
  74. package/src/cli/account-auth.ts +19 -0
  75. package/src/cli/account-extended.ts +4 -4
  76. package/src/cli/capabilities.ts +31 -0
  77. package/src/cli/claude-desktop.ts +206 -16
  78. package/src/cli/codex-shim-autorestore.ts +3 -0
  79. package/src/cli/companion.ts +56 -0
  80. package/src/cli/dispatch.ts +46 -7
  81. package/src/cli/doctor.ts +28 -9
  82. package/src/cli/ensure-desired-integrations.ts +43 -5
  83. package/src/cli/help.ts +7 -9
  84. package/src/cli/hub.ts +3 -2
  85. package/src/cli/index.ts +203 -59
  86. package/src/cli/init.ts +8 -0
  87. package/src/cli/integrations.ts +7 -1
  88. package/src/cli/opencode.ts +2 -2
  89. package/src/cli/provider.ts +13 -1
  90. package/src/cli/registry.ts +41 -2
  91. package/src/cli/resolve.ts +230 -0
  92. package/src/cli/root.ts +24 -1
  93. package/src/cli/start-ownership-publication.ts +56 -0
  94. package/src/cli/status-probes.ts +2 -18
  95. package/src/cli/status.ts +62 -0
  96. package/src/cli/stop-report.ts +143 -0
  97. package/src/cli/uninstall-plan.ts +9 -0
  98. package/src/client/machine-api.ts +2 -2
  99. package/src/client/machine-listener.ts +4 -7
  100. package/src/client/runtime.ts +26 -2
  101. package/src/clients/aside-profiles.ts +4 -0
  102. package/src/clients/config-export/zcode-store.ts +157 -0
  103. package/src/clients/config-export.ts +36 -0
  104. package/src/codex/account-store.ts +65 -0
  105. package/src/codex/app-server-processes.ts +72 -40
  106. package/src/codex/auth-api/account-list.ts +19 -11
  107. package/src/codex/auth-api/login-flow.ts +6 -1
  108. package/src/codex/auth-api/pool-quota-probe.ts +30 -7
  109. package/src/codex/autostart-health.ts +28 -0
  110. package/src/codex/catalog/build-entries.ts +2 -2
  111. package/src/codex/catalog/effort.ts +3 -3
  112. package/src/codex/catalog/gather-capture.ts +21 -2
  113. package/src/codex/catalog/model-hints.ts +29 -28
  114. package/src/codex/catalog/parsing.ts +7 -0
  115. package/src/codex/catalog/provider-models.ts +43 -17
  116. package/src/codex/catalog/retained-sync.ts +24 -28
  117. package/src/codex/catalog/routed-gather.ts +19 -0
  118. package/src/codex/context-compat.ts +5 -2
  119. package/src/codex/convergence.ts +2 -2
  120. package/src/codex/desired-state.ts +4 -1
  121. package/src/codex/history-job.ts +6 -6
  122. package/src/codex/history-provider.ts +31 -166
  123. package/src/codex/history-rollout-read.ts +174 -0
  124. package/src/codex/inject/config-toml.ts +41 -6
  125. package/src/codex/inject/paginated-openai-compat.ts +90 -0
  126. package/src/codex/inject.ts +18 -15
  127. package/src/codex/injected-marker.ts +18 -0
  128. package/src/codex/internal/catalog-writer.ts +33 -1
  129. package/src/codex/main-account.ts +6 -0
  130. package/src/codex/model-cache.ts +99 -6
  131. package/src/codex/model-entitlement-admission.ts +59 -0
  132. package/src/codex/model-entitlements.ts +116 -54
  133. package/src/codex/native-main-admission.ts +83 -0
  134. package/src/codex/observed-model-denials.ts +101 -8
  135. package/src/codex/prompt-text-probe.ts +9 -6
  136. package/src/codex/routing/health-store.ts +39 -0
  137. package/src/codex/routing/selection.ts +37 -1
  138. package/src/codex/routing.ts +12 -42
  139. package/src/codex/shim-templates.ts +29 -3
  140. package/src/codex/shim.ts +1 -1
  141. package/src/codex/subagent-model-fallback.ts +22 -4
  142. package/src/combos/failover.ts +3 -0
  143. package/src/companion/settings.ts +132 -0
  144. package/src/config/admitted-identity.ts +222 -0
  145. package/src/config/atomic-write.ts +117 -5
  146. package/src/config/diagnostics.ts +22 -1
  147. package/src/config/feature-flags.ts +5 -0
  148. package/src/config/load-degrade.ts +52 -7
  149. package/src/config/process-state.ts +1 -1
  150. package/src/config/proxy-env.ts +8 -2
  151. package/src/config/schema/compaction-triggers.ts +11 -0
  152. package/src/config/schema/config-schema.ts +31 -1
  153. package/src/config/schema/leaf-validators.ts +59 -0
  154. package/src/config.ts +3 -3
  155. package/src/generated/compatibility-version.json +604 -280
  156. package/src/grok/reset-coupons.ts +38 -19
  157. package/src/images/loop.ts +6 -1
  158. package/src/integrations/aside-profile-context.ts +37 -3
  159. package/src/integrations/aside-profile-journal.ts +68 -3
  160. package/src/integrations/aside-profiles.ts +128 -3
  161. package/src/integrations/config-io.ts +44 -10
  162. package/src/integrations/merge.ts +120 -13
  163. package/src/integrations/mutation-plan.ts +921 -0
  164. package/src/integrations/registry.ts +38 -0
  165. package/src/integrations/state.ts +78 -45
  166. package/src/integrations/target.ts +208 -0
  167. package/src/integrations/writer.ts +134 -110
  168. package/src/lab/conformance/fixture-provider.ts +5 -0
  169. package/src/lab/live/transport.ts +4 -0
  170. package/src/lab/live/types.ts +5 -0
  171. package/src/lab/subject/behavior-fingerprint.ts +1 -1
  172. package/src/lib/admin-secrets.ts +9 -1
  173. package/src/lib/browser-launch-notice.ts +59 -0
  174. package/src/lib/bun-runtime.ts +6 -2
  175. package/src/lib/debug-log-buffer.ts +6 -1
  176. package/src/lib/debug.ts +63 -0
  177. package/src/lib/errors.ts +79 -0
  178. package/src/lib/http-response-semantics.ts +57 -0
  179. package/src/lib/lab-live-pinned-sender.ts +26 -12
  180. package/src/lib/open-url.ts +51 -7
  181. package/src/lib/package-tree-integrity.ts +2 -1
  182. package/src/lib/package-version.ts +8 -0
  183. package/src/lib/pinned-http.ts +142 -2
  184. package/src/lib/plain-data.ts +103 -0
  185. package/src/lib/process-control.ts +13 -5
  186. package/src/lib/provider-egress.ts +310 -0
  187. package/src/lib/provider-outbound.ts +109 -16
  188. package/src/lib/proxy-env.ts +82 -7
  189. package/src/lib/request-execution-budget.ts +72 -0
  190. package/src/lib/request-failure-attribution.ts +183 -0
  191. package/src/lib/request-failure-model.ts +236 -0
  192. package/src/lib/request-resend-gate.ts +138 -0
  193. package/src/lib/socks5-fetch.ts +136 -26
  194. package/src/lib/spend-ledger-owner.ts +364 -0
  195. package/src/lib/spend-reservation-ledger.ts +218 -27
  196. package/src/lib/standalone.ts +16 -0
  197. package/src/lib/upstream-retry.ts +167 -16
  198. package/src/lib/windows-system-proxy.ts +16 -11
  199. package/src/lib/winsw.ts +2 -2
  200. package/src/oauth/callback-server.ts +4 -3
  201. package/src/oauth/generic-account-failover.ts +1 -0
  202. package/src/oauth/health.ts +12 -1
  203. package/src/oauth/index.ts +27 -108
  204. package/src/oauth/login-cli.ts +80 -29
  205. package/src/oauth/login-flow-state.ts +127 -0
  206. package/src/providers/api-key-resolve.ts +133 -0
  207. package/src/providers/api-key-selection.ts +5 -1
  208. package/src/providers/derive.ts +34 -17
  209. package/src/providers/devin-cli-authmode-migration.ts +14 -10
  210. package/src/providers/key-failover.ts +97 -19
  211. package/src/providers/key-store.ts +34 -110
  212. package/src/providers/model-rename-fields.ts +147 -0
  213. package/src/providers/model-rename-migration.ts +179 -38
  214. package/src/providers/model-rename-startup.ts +7 -5
  215. package/src/providers/openai-virtual-models.ts +42 -2
  216. package/src/providers/quota/antigravity.ts +22 -2
  217. package/src/providers/quota/vendor-probes-key.ts +38 -23
  218. package/src/providers/reasoning-metadata.ts +43 -18
  219. package/src/providers/registry/entries-core.ts +47 -20
  220. package/src/providers/registry/entries-extended.ts +63 -4
  221. package/src/providers/registry/model-ids.ts +168 -0
  222. package/src/providers/registry/model-seeds.ts +56 -10
  223. package/src/providers/registry/types.ts +2 -0
  224. package/src/providers/resolved-model-policy-merge.ts +167 -0
  225. package/src/providers/resolved-model-policy.ts +406 -0
  226. package/src/providers/stale-vision-classification-migration.ts +137 -0
  227. package/src/providers/xai-transport.ts +12 -1
  228. package/src/reasoning-effort.ts +8 -0
  229. package/src/responses/apply-patch-envelope.ts +0 -12
  230. package/src/responses/freeform-wrapper-scan.ts +279 -0
  231. package/src/responses/function-call-compat.ts +38 -1
  232. package/src/responses/inline-document.ts +65 -0
  233. package/src/responses/input-media.ts +42 -8
  234. package/src/responses/legacy-dotted-tool-name-repair.ts +134 -0
  235. package/src/responses/muse-tool-name-alias.ts +19 -0
  236. package/src/responses/parser-content.ts +8 -2
  237. package/src/responses/parser-tools.ts +3 -0
  238. package/src/responses/parser.ts +3 -1
  239. package/src/responses/progressive-freeform-input.ts +130 -0
  240. package/src/responses/reasoning-envelope.ts +30 -0
  241. package/src/responses/schema.ts +3 -0
  242. package/src/responses/state.ts +5 -12
  243. package/src/responses/tool-name-aliases.ts +15 -1
  244. package/src/router.ts +108 -117
  245. package/src/routing/compatibility/behavior.ts +9 -0
  246. package/src/routing/compatibility/subject.ts +16 -1
  247. package/src/server/adapter-resolve.ts +9 -0
  248. package/src/server/admission-model-scope.ts +219 -0
  249. package/src/server/audio-live.ts +9 -3
  250. package/src/server/audio-upstream.ts +18 -0
  251. package/src/server/auth-cors.ts +29 -0
  252. package/src/server/chat-completions.ts +60 -4
  253. package/src/server/chat-native.ts +19 -4
  254. package/src/server/claude-messages.ts +61 -20
  255. package/src/server/effort-row.ts +11 -3
  256. package/src/server/grok-responses-control-frame.ts +160 -1
  257. package/src/server/grok-responses-snapshot-repair.ts +113 -11
  258. package/src/server/gui-freshness.ts +103 -0
  259. package/src/server/gui-static.ts +7 -9
  260. package/src/server/images.ts +59 -6
  261. package/src/server/index/claude-intercept-lifecycle.ts +49 -0
  262. package/src/server/index/serve-options.ts +142 -39
  263. package/src/server/index/spend-ledger-lifecycle.ts +92 -0
  264. package/src/server/index/startup-warnings.ts +24 -0
  265. package/src/server/index/websocket-handler.ts +6 -1
  266. package/src/server/index.ts +34 -38
  267. package/src/server/lifecycle.ts +4 -4
  268. package/src/server/live-call-bindings.ts +6 -0
  269. package/src/server/live.ts +88 -3
  270. package/src/server/management/agent-settings-routes.ts +121 -36
  271. package/src/server/management/aside-profile-routes.ts +266 -7
  272. package/src/server/management/companion-routes.ts +77 -0
  273. package/src/server/management/config-routes.ts +18 -2
  274. package/src/server/management/context.ts +3 -0
  275. package/src/server/management/integration-routes.ts +287 -5
  276. package/src/server/management/logs-usage-routes.ts +19 -0
  277. package/src/server/management/metrics-routes.ts +20 -0
  278. package/src/server/management/model-rows.ts +224 -12
  279. package/src/server/management/native-integration-routes.ts +103 -6
  280. package/src/server/management/oauth-account-routes.ts +45 -7
  281. package/src/server/management/route-registry.ts +20 -0
  282. package/src/server/management/shared.ts +28 -4
  283. package/src/server/management/system-restart.ts +7 -2
  284. package/src/server/management/system-routes.ts +2 -0
  285. package/src/server/management/usage-aggregate-cache.ts +4 -0
  286. package/src/server/management/usage-timeline-routes.ts +44 -0
  287. package/src/server/management-api.ts +10 -9
  288. package/src/server/management-auth.ts +15 -1
  289. package/src/server/proxy-liveness.ts +75 -0
  290. package/src/server/readiness.ts +29 -10
  291. package/src/server/relay-eager.ts +24 -2
  292. package/src/server/relay.ts +138 -12
  293. package/src/server/request-log-failure-attribution.ts +99 -0
  294. package/src/server/request-log.ts +169 -2
  295. package/src/server/request-metrics.ts +298 -0
  296. package/src/server/responses/adapter-continuation.ts +3 -3
  297. package/src/server/responses/adapter-dispatch.ts +11 -6
  298. package/src/server/responses/codex-ws-wire.ts +34 -8
  299. package/src/server/responses/combo-stream-preflight.ts +168 -6
  300. package/src/server/responses/compact.ts +43 -10
  301. package/src/server/responses/compaction-routing.ts +111 -0
  302. package/src/server/responses/core-codex-account.ts +8 -3
  303. package/src/server/responses/core-combo.ts +7 -7
  304. package/src/server/responses/core-normalize.ts +6 -12
  305. package/src/server/responses/core-opaque-recovery.ts +91 -0
  306. package/src/server/responses/core-options.ts +4 -0
  307. package/src/server/responses/encrypted-payload.ts +20 -2
  308. package/src/server/responses/fetch-helpers.ts +124 -8
  309. package/src/server/responses/input-admission.ts +10 -0
  310. package/src/server/responses/passthrough-delivery.ts +45 -15
  311. package/src/server/responses/passthrough-dispatch.ts +215 -44
  312. package/src/server/responses/passthrough-error.ts +27 -8
  313. package/src/server/responses/policy-fallback.ts +5 -13
  314. package/src/server/responses/request-prepare.ts +109 -18
  315. package/src/server/responses/request-send-budget.ts +17 -1
  316. package/src/server/responses/request-sidecar-auth.ts +1 -1
  317. package/src/server/responses/request-transport.ts +26 -6
  318. package/src/server/responses/reset-replay.ts +108 -0
  319. package/src/server/responses/run-turn-execution.ts +25 -3
  320. package/src/server/responses/sidecar-execution.ts +17 -2
  321. package/src/server/responses/ws-upstream.ts +14 -27
  322. package/src/server/responses-custom-tool-repair.ts +27 -54
  323. package/src/server/responses-request-tool-scope.ts +214 -0
  324. package/src/server/responses-undeclared-tool-guard.ts +35 -2
  325. package/src/server/search.ts +25 -1
  326. package/src/server/sse-payload-rewrite.ts +1 -1
  327. package/src/server/usage-ledger-retention.ts +73 -0
  328. package/src/service/cli.ts +48 -2
  329. package/src/service/health.ts +3 -2
  330. package/src/service/install-state-contract.d.mts +27 -0
  331. package/src/service/install-state-contract.mjs +34 -0
  332. package/src/service/launchd.ts +1 -1
  333. package/src/service/orchestration.ts +2 -4
  334. package/src/service/ownership-compatibility.ts +164 -0
  335. package/src/service/ownership-mutation-lease.d.mts +32 -0
  336. package/src/service/ownership-mutation-lease.mjs +211 -0
  337. package/src/service/repair.ts +45 -1
  338. package/src/service/state-lock.ts +269 -0
  339. package/src/service/state-record.d.mts +36 -0
  340. package/src/service/state-record.mjs +138 -0
  341. package/src/service/state.ts +582 -68
  342. package/src/service/windows-taskxml.ts +11 -10
  343. package/src/service.ts +7 -3
  344. package/src/tray/windows-tray.ps1 +156 -4
  345. package/src/types/config.ts +48 -3
  346. package/src/types/provider.ts +91 -0
  347. package/src/types/request.ts +30 -2
  348. package/src/types/tools.ts +33 -0
  349. package/src/types.ts +4 -0
  350. package/src/update/index.ts +207 -63
  351. package/src/update/job.ts +9 -5
  352. package/src/update/ownership-transaction.ts +47 -0
  353. package/src/update/restart-ownership.ts +54 -0
  354. package/src/update/runtime-ownership.d.mts +40 -0
  355. package/src/update/runtime-ownership.mjs +122 -0
  356. package/src/usage/attempt-delivery.ts +198 -0
  357. package/src/usage/cache-diagnostic.ts +305 -0
  358. package/src/usage/failure-fingerprint.ts +118 -0
  359. package/src/usage/failure-projection-cache.ts +174 -0
  360. package/src/usage/failure-projection.ts +174 -0
  361. package/src/usage/ledger-retention.ts +165 -0
  362. package/src/usage/log.ts +126 -79
  363. package/src/usage/request-outcome.ts +150 -0
  364. package/src/usage/retention-contract.ts +28 -0
  365. package/src/usage/summary.ts +2 -2
  366. package/src/usage/telemetry-contract.ts +237 -0
  367. package/src/usage/timeline.ts +236 -0
  368. package/src/vision/eligibility.ts +88 -9
  369. package/src/vision/plan.ts +34 -10
  370. package/src/web-search/alpha-search.ts +21 -1
  371. package/src/web-search/executor.ts +41 -2
  372. package/src/web-search/loop.ts +6 -1
  373. package/gui/dist/assets/index-C5IebErG.js +0 -136
  374. package/gui/dist/assets/index-OESInAjC.css +0 -1
@@ -0,0 +1,132 @@
1
+ import { chmodSync, existsSync, mkdirSync, readFileSync, renameSync, statSync, writeFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { getConfigDir } from "../config/paths";
4
+ import {
5
+ TIMELINE_HOURS,
6
+ isTimelineModelId,
7
+ type TimelineAggregation,
8
+ type TimelineGrouping,
9
+ type TimelineMetric,
10
+ } from "../usage/timeline";
11
+
12
+ export interface CompanionSettings {
13
+ menuBarMetric: "requests" | "tokens" | "cost" | "quota" | "none";
14
+ menuBarTemplate: string | null;
15
+ showToday: boolean;
16
+ showChart: boolean;
17
+ showModels: boolean;
18
+ showCost: boolean;
19
+ showAccounts: boolean;
20
+ chartHours: typeof TIMELINE_HOURS[number];
21
+ bucketMinutes: number;
22
+ chartStyle: "line" | "stackedBar";
23
+ tokenMetric: TimelineMetric;
24
+ aggregation: TimelineAggregation;
25
+ chartGrouping: TimelineGrouping;
26
+ models: string[] | null;
27
+ hiddenProviders: string[];
28
+ }
29
+
30
+ export const DEFAULT_COMPANION_SETTINGS: CompanionSettings = {
31
+ menuBarMetric: "tokens",
32
+ menuBarTemplate: null,
33
+ showToday: true,
34
+ showChart: true,
35
+ showModels: true,
36
+ showCost: true,
37
+ showAccounts: true,
38
+ chartHours: 24,
39
+ bucketMinutes: 60,
40
+ chartStyle: "line",
41
+ tokenMetric: "total",
42
+ aggregation: "sum",
43
+ chartGrouping: "model",
44
+ models: null,
45
+ hiddenProviders: [],
46
+ };
47
+
48
+ const TEMPLATE_FIELDS = new Set(["requests", "totalTokens", "inputTokens", "outputTokens", "costUsd", "quotaPercent"]);
49
+ const MENU_BAR_METRICS = new Set(["requests", "tokens", "cost", "quota", "none"]);
50
+ const CHART_STYLES = new Set(["line", "stackedBar"]);
51
+ const TIMELINE_METRICS = new Set(["total", "input", "output", "cached"]);
52
+ const AGGREGATIONS = new Set(["sum", "average", "max"]);
53
+ const GROUPINGS = new Set(["model", "modelAccount"]);
54
+ const SETTINGS_KEYS = Object.keys(DEFAULT_COMPANION_SETTINGS) as (keyof CompanionSettings)[];
55
+
56
+ export function companionSettingsPath(): string {
57
+ return join(getConfigDir(), "companion.json");
58
+ }
59
+
60
+ function invalid(message: string): { error: string } {
61
+ return { error: message };
62
+ }
63
+
64
+ function validModels(value: unknown, key: string): value is string[] | null {
65
+ return value === null
66
+ || (Array.isArray(value)
67
+ && value.length <= 100
68
+ && value.every(isTimelineModelId));
69
+ }
70
+
71
+ function validateValue(key: keyof CompanionSettings, value: unknown): string | null {
72
+ if (key === "menuBarMetric") return typeof value === "string" && MENU_BAR_METRICS.has(value) ? null : "menuBarMetric is invalid";
73
+ if (key === "menuBarTemplate") {
74
+ if (value === null) return null;
75
+ if (typeof value !== "string" || value.length > 200) return "menuBarTemplate must be null or at most 200 characters";
76
+ for (const match of value.matchAll(/\{([^{}]+)\}/g)) {
77
+ if (!TEMPLATE_FIELDS.has(match[1]!)) return `menuBarTemplate contains unknown placeholder: ${match[1]}`;
78
+ }
79
+ return null;
80
+ }
81
+ if (["showToday", "showChart", "showModels", "showCost", "showAccounts"].includes(key)) {
82
+ return typeof value === "boolean" ? null : `${key} must be a boolean`;
83
+ }
84
+ if (key === "chartHours") return TIMELINE_HOURS.includes(value as typeof TIMELINE_HOURS[number]) ? null : "chartHours is invalid";
85
+ if (key === "bucketMinutes") return typeof value === "number" && Number.isInteger(value) && value >= 1 && value <= 1440 ? null : "bucketMinutes must be an integer from 1 through 1440";
86
+ if (key === "chartStyle") return typeof value === "string" && CHART_STYLES.has(value) ? null : "chartStyle is invalid";
87
+ if (key === "tokenMetric") return typeof value === "string" && TIMELINE_METRICS.has(value) ? null : "tokenMetric is invalid";
88
+ if (key === "aggregation") return typeof value === "string" && AGGREGATIONS.has(value) ? null : "aggregation is invalid";
89
+ if (key === "chartGrouping") return typeof value === "string" && GROUPINGS.has(value) ? null : "chartGrouping is invalid";
90
+ if (key === "models") return validModels(value, key) ? null : "models must be null or at most 100 provider/model identifiers";
91
+ if (key === "hiddenProviders") return Array.isArray(value) && value.length <= 100 && value.every(item => typeof item === "string" && item.length > 0 && !/\s/.test(item))
92
+ ? null : "hiddenProviders must contain at most 100 provider names";
93
+ return `${key} is unsupported`;
94
+ }
95
+
96
+ export function applyCompanionSettingsPatch(
97
+ current: CompanionSettings,
98
+ patch: unknown,
99
+ ): CompanionSettings | { error: string } {
100
+ if (!patch || typeof patch !== "object" || Array.isArray(patch)) return invalid("settings must be an object");
101
+ const values = patch as Record<string, unknown>;
102
+ for (const key of Object.keys(values)) {
103
+ if (!SETTINGS_KEYS.includes(key as keyof CompanionSettings)) return invalid(`unknown settings key: ${key}`);
104
+ const error = validateValue(key as keyof CompanionSettings, values[key]);
105
+ if (error) return invalid(error);
106
+ }
107
+ return { ...current, ...values } as CompanionSettings;
108
+ }
109
+
110
+ export function loadCompanionSettings(): { settings: CompanionSettings; updatedAt: number | null; corrupt?: true } {
111
+ const path = companionSettingsPath();
112
+ if (!existsSync(path)) return { settings: { ...DEFAULT_COMPANION_SETTINGS }, updatedAt: null };
113
+ try {
114
+ const parsed = JSON.parse(readFileSync(path, "utf8")) as unknown;
115
+ const settings = applyCompanionSettingsPatch(DEFAULT_COMPANION_SETTINGS, parsed);
116
+ if ("error" in settings) return { settings: { ...DEFAULT_COMPANION_SETTINGS }, updatedAt: null, corrupt: true };
117
+ return { settings, updatedAt: statSync(path).mtimeMs };
118
+ } catch {
119
+ return { settings: { ...DEFAULT_COMPANION_SETTINGS }, updatedAt: null, corrupt: true };
120
+ }
121
+ }
122
+
123
+ export function saveCompanionSettings(settings: CompanionSettings): void {
124
+ const path = companionSettingsPath();
125
+ const dir = getConfigDir();
126
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true, mode: 0o700 });
127
+ const temp = `${path}.${process.pid}.${Date.now()}.tmp`;
128
+ writeFileSync(temp, `${JSON.stringify(settings, null, 2)}\n`, { mode: 0o600 });
129
+ chmodSync(temp, 0o600);
130
+ renameSync(temp, path);
131
+ chmodSync(path, 0o600);
132
+ }
@@ -0,0 +1,222 @@
1
+ import { readConfigAdmissionSnapshot } from "./diagnostics";
2
+ import { getConfigPath } from "./paths";
3
+ import { canonicalPlainData, copyPlainData, isPlainObject, ownDataKeys } from "../lib/plain-data";
4
+ import type { OcxConfig } from "../types";
5
+
6
+ /**
7
+ * A configuration a roster may be built from, detached from the object the caller holds.
8
+ *
9
+ * The detachment is the point. A gather suspends, and while it is suspended a management route can
10
+ * edit the resident configuration in place; gathering from one state and projecting from another
11
+ * would produce rows that belong to neither. Every authoritative pass uses this copy from
12
+ * beginning to end, so what the resident object does in the meantime becomes a question about
13
+ * whether the result may be retained rather than a question about what the result is.
14
+ *
15
+ * What this deliberately does NOT do is require the resident configuration to equal the file on
16
+ * disk. The proxy routes by the configuration it is holding, so that is the configuration a
17
+ * preview and the mutation it authorizes must both describe. A file the operator has edited and
18
+ * the process has not adopted is a supported state rather than a fault: live reconciliation
19
+ * (src/config/live-reconcile.ts) merges persisted state while deliberately retaining live changes
20
+ * and the active listener binding, and can persist a binding the resident object does not have.
21
+ * Demanding equality would make preview permanently unavailable on exactly those configurations
22
+ * while proving nothing about the rows, which come from the resident object either way.
23
+ */
24
+ export interface ExportConfigAdmission {
25
+ readonly config: OcxConfig;
26
+ }
27
+
28
+ /**
29
+ * What was true when an admission was captured, kept here rather than on the admission object.
30
+ *
31
+ * None of it is data a consumer has any business reading: the file term is a digest of the
32
+ * operator's configuration file and the canonical form contains their credentials. Holding it in a
33
+ * module WeakMap means an admission can be passed around, and even serialized by a careless
34
+ * caller, without carrying any of it.
35
+ */
36
+ interface AdmissionEvidence {
37
+ readonly path: string;
38
+ readonly file: string;
39
+ readonly data: string;
40
+ readonly executors: ReadonlyMap<string, unknown>;
41
+ }
42
+
43
+ const evidence = new WeakMap<ExportConfigAdmission, AdmissionEvidence>();
44
+
45
+ /**
46
+ * Detach the configuration a roster is about to be built from, and record what it was.
47
+ *
48
+ * Two things are recorded because two things can move independently. The resident configuration is
49
+ * what the rows are derived from, so its complete structure is captured: structurally rather than
50
+ * as a list of the fields that seemed to matter, because such a list is only as complete as
51
+ * whoever last thought about it and this one had already missed export-affecting configuration.
52
+ * The configuration file is recorded beside it, so a roster does not outlive an operator editing
53
+ * the configuration under a process that has not adopted it yet.
54
+ *
55
+ * Null when the file cannot be read, when it is there but the loader would have had to salvage it,
56
+ * and when the configuration object cannot be copied as plain data. Every caller fails closed on
57
+ * it: refusing a preview costs an ordinary load, and serving one that describes a configuration
58
+ * nobody has costs a file the operator did not ask for.
59
+ */
60
+ export function captureExportConfigAdmission(live: OcxConfig): ExportConfigAdmission | null {
61
+ const path = getConfigPath();
62
+ const file = admittedFileTerm();
63
+ if (file === null) return null;
64
+ const resident = detachConfig(live);
65
+ if (resident === null) return null;
66
+ const config = withExecutors(resident);
67
+ if (config === null) return null;
68
+ const admission: ExportConfigAdmission = { config };
69
+ evidence.set(admission, { path, file, data: canonicalPlainData(resident.data), executors: resident.executors });
70
+ return admission;
71
+ }
72
+
73
+ /**
74
+ * Whether an admission still describes the configuration in hand and the file it was taken beside.
75
+ *
76
+ * Three things are checked because three things can move: the file can be rewritten, the resident
77
+ * object can be edited in place, and a consumer of the detached copy can mutate what it was given.
78
+ * The last matters as much as the others, because a pass that edited its own input and then
79
+ * published would be retaining a roster under a state that no longer describes even that input.
80
+ *
81
+ * Passive: it reads the configuration file and nothing else. No credential is resolved, no
82
+ * provider is contacted, no path is hardened and nothing is written.
83
+ */
84
+ export function isExportConfigAdmissionCurrent(admission: ExportConfigAdmission, live: OcxConfig): boolean {
85
+ const captured = evidence.get(admission);
86
+ if (captured === undefined) return false;
87
+ // A different configuration home is a different question, not a stale answer to this one.
88
+ if (getConfigPath() !== captured.path) return false;
89
+ const file = admittedFileTerm();
90
+ if (file === null || file !== captured.file) return false;
91
+ const resident = detachConfig(live);
92
+ if (resident === null || canonicalPlainData(resident.data) !== captured.data) return false;
93
+ if (!sameExecutors(resident.executors, captured.executors)) return false;
94
+ const working = detachConfig(admission.config);
95
+ return working !== null
96
+ && canonicalPlainData(working.data) === captured.data
97
+ && sameExecutors(working.executors, captured.executors);
98
+ }
99
+
100
+ /**
101
+ * A plain-data copy of a configuration for a consumer that must not observe later edits, or null.
102
+ *
103
+ * The integration writer is the case this exists for. It freezes every other resolution seam
104
+ * before its first await and then held the configuration by reference, so a plan checked under one
105
+ * configuration could be written from another: the check and the document it authorized were
106
+ * reading the same object at two different moments. One copy taken before the await gives both of
107
+ * them the same configuration.
108
+ *
109
+ * Null rather than the caller's object when the copy cannot be made. Handing back the reference
110
+ * would have been a copy in name only, and the caller would have gone on to describe it as the
111
+ * configuration it checked.
112
+ */
113
+ export function detachedConfigSnapshot(config: OcxConfig): OcxConfig | null {
114
+ const detached = detachConfig(config);
115
+ return detached === null ? null : withExecutors(detached);
116
+ }
117
+
118
+ /**
119
+ * The configuration file as an opaque term: its exact bytes, or the distinguished absence of one.
120
+ *
121
+ * This is a byte observation and nothing more. It says the operator's configuration file has not
122
+ * been rewritten since a roster was built; it is not a claim about whether the configuration the
123
+ * process is holding agrees with that file.
124
+ *
125
+ * Null for a file that cannot be read, because then a later read cannot tell whether it changed.
126
+ * Null too for one that is there and does not load cleanly, which is the existing contract for a
127
+ * derived roster rather than an inference about the resident configuration. Before this, a digest
128
+ * was accepted ahead of any look at what the parse produced.
129
+ *
130
+ * Absence is a configuration rather than the lack of one. No file means defaults, which is an
131
+ * ordinary fresh install and the ordinary state in CI.
132
+ */
133
+ function admittedFileTerm(): string | null {
134
+ const snapshot = readConfigAdmissionSnapshot();
135
+ const { source, error } = snapshot.diagnostics;
136
+ if (snapshot.kind === "read") return source === "file" && error === null ? snapshot.contentSha256 : null;
137
+ return source === "default" && error === null ? "absent" : null;
138
+ }
139
+
140
+ interface DetachedConfig {
141
+ readonly data: Record<string, unknown>;
142
+ readonly executors: ReadonlyMap<string, unknown>;
143
+ }
144
+
145
+ /**
146
+ * A configuration as plain data, with the transport executors kept out of it.
147
+ *
148
+ * The copier refuses everything JSON could not have produced, so the one thing that needs handling
149
+ * here is a provider's fetch executor: a caller owns it and the gather uses it instead of the
150
+ * global transport. It is held by reference for the detached copy and compared by reference
151
+ * afterwards, so replacing it invalidates the binding while it is never serialized.
152
+ */
153
+ function detachConfig(live: OcxConfig): DetachedConfig | null {
154
+ const executors = new Map<string, unknown>();
155
+ const root = live as unknown;
156
+ if (!isPlainObject(root)) return null;
157
+ const data: Record<string, unknown> = {};
158
+ for (const key of ownDataKeys(root)) {
159
+ if (key === null) return null;
160
+ const value = root[key];
161
+ if (value === undefined) continue;
162
+ if (key !== "providers") {
163
+ const copied = copyPlainData(value);
164
+ if (!copied.ok) return null;
165
+ data[key] = copied.value;
166
+ continue;
167
+ }
168
+ if (!isPlainObject(value)) return null;
169
+ const providers: Record<string, unknown> = {};
170
+ for (const name of ownDataKeys(value)) {
171
+ if (name === null) return null;
172
+ const provider = value[name];
173
+ if (provider === undefined) continue;
174
+ if (!isPlainObject(provider)) return null;
175
+ const copiedProvider: Record<string, unknown> = {};
176
+ for (const field of ownDataKeys(provider)) {
177
+ if (field === null) return null;
178
+ const fieldValue = provider[field];
179
+ if (fieldValue === undefined) continue;
180
+ // The executor exception applies to an executor. A provider entry keeps unknown
181
+ // configuration keys, so a fetch value that is not a function is something an operator
182
+ // wrote into the file, and it is copied and compared as the data it is. Discovery reads it
183
+ // the same way: the outbound transport takes its built-in path unless the value is
184
+ // callable.
185
+ if (field === "fetch" && typeof fieldValue === "function") {
186
+ executors.set(name, fieldValue);
187
+ continue;
188
+ }
189
+ const copied = copyPlainData(fieldValue);
190
+ if (!copied.ok) return null;
191
+ copiedProvider[field] = copied.value;
192
+ }
193
+ providers[name] = copiedProvider;
194
+ }
195
+ data[key] = providers;
196
+ }
197
+ return { data, executors };
198
+ }
199
+
200
+ /** The copy a consumer runs against, with the executors put back by reference. */
201
+ function withExecutors(detached: DetachedConfig): OcxConfig | null {
202
+ // A second copy, so what is compared later is never the object handed to a consumer.
203
+ const copied = copyPlainData(detached.data);
204
+ if (!copied.ok) return null;
205
+ const config = copied.value;
206
+ const providers = config.providers;
207
+ if (isPlainObject(providers)) {
208
+ for (const [name, executor] of detached.executors) {
209
+ const provider = providers[name];
210
+ if (isPlainObject(provider)) provider.fetch = executor;
211
+ }
212
+ }
213
+ return config as unknown as OcxConfig;
214
+ }
215
+
216
+ function sameExecutors(left: ReadonlyMap<string, unknown>, right: ReadonlyMap<string, unknown>): boolean {
217
+ if (left.size !== right.size) return false;
218
+ for (const [name, executor] of left) {
219
+ if (!right.has(name) || right.get(name) !== executor) return false;
220
+ }
221
+ return true;
222
+ }
@@ -2,6 +2,7 @@ import {
2
2
  chmodSync,
3
3
  closeSync,
4
4
  fchmodSync,
5
+ fsyncSync,
5
6
  fstatSync,
6
7
  lstatSync,
7
8
  openSync,
@@ -10,7 +11,7 @@ import {
10
11
  unlinkSync,
11
12
  writeFileSync,
12
13
  } from "node:fs";
13
- import { dirname } from "node:path";
14
+ import { basename, dirname, join } from "node:path";
14
15
  import { recordOwnedConfigPath } from "../lib/config-ownership";
15
16
  import { assertNotRealHomeUnderTest } from "../lib/test-home-guard";
16
17
  import {
@@ -162,6 +163,27 @@ function carryHardenAcrossContentWrite(path: string): void {
162
163
  reattributeHardenedSecretPath(path);
163
164
  }
164
165
 
166
+ /**
167
+ * Commit the directory entry a rename just wrote.
168
+ *
169
+ * Best effort by platform, not by importance: Windows has no directory descriptor to sync and
170
+ * some filesystems refuse the open, and failing a replacement that already happened would be
171
+ * worse than reporting it. The throw that matters is the temp's own `fsync`, which runs before
172
+ * the rename and stops it.
173
+ */
174
+ function syncParentDirectory(target: string): void {
175
+ if (process.platform === "win32") return;
176
+ let descriptor: number | undefined;
177
+ try {
178
+ descriptor = openSync(dirname(target), "r");
179
+ fsyncSync(descriptor);
180
+ } catch {
181
+ /* the rename already landed; a directory that cannot be synced is not a reason to undo it */
182
+ } finally {
183
+ if (descriptor !== undefined) { try { closeSync(descriptor); } catch { /* already closed */ } }
184
+ }
185
+ }
186
+
165
187
  function writePrivateTempFile(
166
188
  path: string,
167
189
  content: string,
@@ -191,6 +213,40 @@ function writePrivateTempFile(
191
213
  carryHardenAcrossContentWrite(path);
192
214
  }
193
215
 
216
+ /**
217
+ * The same private temp, filled by a writer that streams into the descriptor.
218
+ *
219
+ * For content that must not be held in memory as one string. The identity assertions, the
220
+ * ownership handshake and the hardening are the same; the difference is that the bytes arrive in
221
+ * bounded chunks and the descriptor is flushed before it closes.
222
+ *
223
+ * The `fsync` is not optional here and its failure is not swallowed. A replacement whose
224
+ * REPLACEMENT is not on disk can lose the rows it was supposed to retain, so the throw is what
225
+ * stops the rename from happening at all.
226
+ */
227
+ function writePrivateTempFileWith(
228
+ path: string,
229
+ write: (descriptor: number) => void,
230
+ timeoutMemoKey: string,
231
+ onCreated: () => void,
232
+ ): void {
233
+ const descriptor = openSync(path, "wx", 0o600);
234
+ onCreated();
235
+ try {
236
+ if (windowsHardeningApplies()) {
237
+ hardenSecretPath(path, { required: true, timeoutMemoKey });
238
+ }
239
+ if (process.platform !== "win32") fchmodSync(descriptor, 0o600);
240
+ assertPrivateTempDescriptor(path, descriptor);
241
+ write(descriptor);
242
+ assertPrivateTempDescriptor(path, descriptor);
243
+ fsyncSync(descriptor);
244
+ } finally {
245
+ closeSync(descriptor);
246
+ }
247
+ carryHardenAcrossContentWrite(path);
248
+ }
249
+
194
250
  async function writePrivateTempFileAsync(
195
251
  path: string,
196
252
  content: string,
@@ -215,14 +271,14 @@ async function writePrivateTempFileAsync(
215
271
  carryHardenAcrossContentWrite(path);
216
272
  }
217
273
 
218
- export function atomicWriteFile(
274
+ function atomicWriteFileToTarget(
219
275
  path: string,
220
- content: string,
276
+ content: string | ((descriptor: number) => void),
277
+ target: string,
221
278
  io?: AtomicWriteIO,
222
279
  hooks: AtomicWriteHooks = {},
223
280
  ): void {
224
281
  recordOwnedConfigPath(getConfigDir(), path);
225
- const target = resolveWriteTarget(path);
226
282
  assertResolvedTargetAllowed(path, target);
227
283
  const tmp = `${target}.ocx.${process.pid}.${nextAtomicTempSequence()}.tmp`;
228
284
  let hardened = false;
@@ -246,13 +302,26 @@ export function atomicWriteFile(
246
302
  };
247
303
  try {
248
304
  if (io) ownsTemp = true;
249
- effective.write(tmp, content);
305
+ // A streaming writer bypasses the string form of `write` and nothing else. Every later
306
+ // step -- harden, the pre-rename hooks, the rename and the whole residual-cleanup path,
307
+ // which still scrubs through `effective.write(tmp, "")` -- is shared with the string form.
308
+ if (typeof content === "function") writePrivateTempFileWith(tmp, content, path, () => { ownsTemp = true; });
309
+ else effective.write(tmp, content);
250
310
  hooks.afterTempWrite?.(tmp, target);
251
311
  effective.harden(tmp);
252
312
  hardened = true;
253
313
  hooks.beforeRename?.(tmp, target);
254
314
  hooks.validateBeforeRename?.(target);
255
315
  effective.rename(tmp, target);
316
+ // The rename is only as durable as the directory entry recording it. Fsyncing the temp's
317
+ // CONTENT and then losing the entry in a power cut leaves the old file in place, or the
318
+ // directory in an indeterminate state, while the caller was told the replacement landed.
319
+ //
320
+ // Only the streaming form does this. It is the one that makes a durability claim -- a
321
+ // replacement is not an append, and losing it can lose the rows it was meant to keep -- and
322
+ // adding a directory sync to the string form would charge every config write for a promise
323
+ // its callers have never been given.
324
+ if (typeof content === "function") syncParentDirectory(target);
256
325
  forgetEphemeralSecretPath(tmp);
257
326
  } catch (cause) {
258
327
  if (!ownsTemp) throw cause;
@@ -287,6 +356,49 @@ export function atomicWriteFile(
287
356
  }
288
357
  }
289
358
 
359
+ export function atomicWriteFile(
360
+ path: string,
361
+ content: string,
362
+ io?: AtomicWriteIO,
363
+ hooks: AtomicWriteHooks = {},
364
+ ): void {
365
+ atomicWriteFileToTarget(path, content, resolveWriteTarget(path), io, hooks);
366
+ }
367
+
368
+ /**
369
+ * Atomically replace a file with bytes produced straight into the temporary descriptor.
370
+ *
371
+ * Same publication contract as {@link atomicWriteFile}: an exclusively created private temp, the
372
+ * identity assertions around the write, `hooks.validateBeforeRename` immediately before the
373
+ * rename, the platform-aware replace, and the residual cleanup on any failure. A custom
374
+ * {@link AtomicWriteIO} is not accepted, because the point of this form is that the default
375
+ * writer owns the descriptor.
376
+ */
377
+ export function atomicWriteFileStreamed(
378
+ path: string,
379
+ write: (descriptor: number) => void,
380
+ hooks: AtomicWriteHooks = {},
381
+ ): void {
382
+ atomicWriteFileToTarget(path, write, resolveWriteTarget(path), undefined, hooks);
383
+ }
384
+
385
+ /**
386
+ * Atomically replace the named directory entry without resolving a symlink at
387
+ * that entry. This is for files in directories writable by another process:
388
+ * a raced symlink is replaced, never followed to a more privileged target.
389
+ */
390
+ export function atomicWriteFileNoFollow(
391
+ path: string,
392
+ content: string,
393
+ io?: AtomicWriteIO,
394
+ hooks: AtomicWriteHooks = {},
395
+ ): void {
396
+ // Only the final entry is no-follow: the parent still resolves, because an
397
+ // OS alias above the configured root (a home junction, /tmp) is legitimate
398
+ // and Windows cannot exclusive-create a temp through a junction.
399
+ atomicWriteFileToTarget(path, content, join(resolveWriteTarget(dirname(path)), basename(path)), io, hooks);
400
+ }
401
+
290
402
  export interface AtomicWriteAsyncIO {
291
403
  write: (path: string, content: string) => void | Promise<void>;
292
404
  harden: (path: string) => void | Promise<void>;
@@ -60,6 +60,7 @@ import {
60
60
  remoteGuiConfigSchema,
61
61
  runtimeRoleSchema,
62
62
  spendSchema,
63
+ compactionRoutingSchema,
63
64
  } from "./schema/leaf-validators";
64
65
 
65
66
  export type ConfigDiagnostics = {
@@ -561,7 +562,26 @@ function managementIngressConfigError(value: unknown): string | null {
561
562
  return null;
562
563
  }
563
564
 
565
+ /** Load degrades malformed metrics export config to off; live writes reject the same shape. */
566
+ export function metricsExportConfigError(value: unknown): string | null {
567
+ const raw = rawConfigRecord(value);
568
+ if (!raw || !Object.hasOwn(raw, "metricsExport") || raw.metricsExport === undefined) return null;
569
+ const metricsExport = rawConfigRecord(raw.metricsExport);
570
+ if (!metricsExport) return "schema_invalid: metricsExport: must be an object or omitted";
571
+ if (Object.keys(metricsExport).some(key => key !== "enabled")) {
572
+ return "schema_invalid: metricsExport: contains an unsupported field";
573
+ }
574
+ if (metricsExport.enabled !== undefined && typeof metricsExport.enabled !== "boolean") {
575
+ return "schema_invalid: metricsExport.enabled: must be a boolean";
576
+ }
577
+ return null;
578
+ }
579
+
564
580
  export function validateConfigCandidate(value: unknown): { ok: true; config: OcxConfig } | { ok: false; error: string } {
581
+ const compactionRouting = rawConfigRecord(value)?.compactionRouting;
582
+ if (compactionRouting !== undefined && !compactionRoutingSchema.safeParse(compactionRouting).success) {
583
+ return { ok: false, error: "schema_invalid: compactionRouting: requires a nonblank model, an optional valid reasoningEffort, and optional non-repeating triggers drawn from \"manual\" and \"auto\"" };
584
+ }
565
585
  const boundaryError = configReasoningPinsConfigError(value)
566
586
  ?? blankHostnameError(value)
567
587
  ?? claudeSubagentEffortError(value)
@@ -586,7 +606,8 @@ export function validateConfigCandidate(value: unknown): { ok: true; config: Ocx
586
606
  ?? clientConnectionConfigError(value)
587
607
  ?? clientRolePairError(value)
588
608
  ?? loopbackListenerPortError(value)
589
- ?? managementIngressConfigError(value);
609
+ ?? managementIngressConfigError(value)
610
+ ?? metricsExportConfigError(value);
590
611
  if (boundaryError) return { ok: false, error: boundaryError };
591
612
  const result = configSchema.safeParse(value);
592
613
  if (result.success) {
@@ -12,6 +12,11 @@ export function ultraFastTierEnabled(config: Pick<OcxConfig, "ultraFastTier">):
12
12
  return config.ultraFastTier === true;
13
13
  }
14
14
 
15
+ /** Default-off aggregate request metrics; activation is fixed for one server process lifetime. */
16
+ export function metricsExportEnabled(config: Pick<OcxConfig, "metricsExport">): boolean {
17
+ return config.metricsExport?.enabled === true;
18
+ }
19
+
15
20
  /**
16
21
  * Default cadence for the opt-in catalog auto-refresh (issue #3630): one converge pass
17
22
  * per hour. Each pass spends a live /models call against every enabled provider, and
@@ -32,6 +32,7 @@ import {
32
32
  quotaResetNotifySchema,
33
33
  remoteGuiConfigSchema,
34
34
  retryOn429PolicySchema,
35
+ retryOnResetPolicySchema,
35
36
  runtimeRoleSchema,
36
37
  spendSchema,
37
38
  } from "./schema/leaf-validators";
@@ -101,6 +102,24 @@ export function warnDegradedStreamMode(rawParsed: unknown, validated: OcxConfig)
101
102
  }
102
103
  }
103
104
 
105
+ export function warnDegradedCompactionRouting(rawParsed: unknown, validated: OcxConfig): void {
106
+ if (!rawParsed || typeof rawParsed !== "object") return;
107
+ const raw = (rawParsed as Record<string, unknown>).compactionRouting;
108
+ if (raw !== undefined && validated.compactionRouting === undefined) {
109
+ console.warn("⚠️ config.json compactionRouting is invalid (expected { model, reasoningEffort?, triggers? } with a nonblank model, a declared effort, and triggers drawn without repetition from \"manual\" and \"auto\") — compaction keeps the conversation model");
110
+ }
111
+ }
112
+
113
+ /**
114
+ * Top-level opt-in blocks whose hand-edited form degrades to "off" instead of failing the whole
115
+ * schema. Grouped behind one entry point because `src/config.ts` sits at its file-size cap, and
116
+ * the ratchet only ever moves down: a per-block call there costs a line the file does not have.
117
+ */
118
+ export function warnDegradedTopLevelOptIns(rawParsed: unknown, validated: OcxConfig): void {
119
+ warnDegradedStreamMode(rawParsed, validated);
120
+ warnDegradedCompactionRouting(rawParsed, validated);
121
+ }
122
+
104
123
  /**
105
124
  * Load-time degradation for `retryOn429` (loadConfig only): one hand-edited invalid optional
106
125
  * field (e.g. `attempts: 0` or a string) must not trip the whole provider schema and hide every
@@ -177,18 +196,44 @@ export function sanitizeRetryOn429ForLoad(parsed: unknown): void {
177
196
  * redacted (a malformed write can place a secret in a property name).
178
197
  */
179
198
  export function retryOn429PolicyConfigError(policy: unknown): string | null {
199
+ return strictPolicyConfigError("retryOn429", retryOn429PolicySchema, policy);
200
+ }
201
+
202
+ /**
203
+ * Management write-boundary validation for `retryOnReset`, with the same fail-closed contract
204
+ * as `retryOn429PolicyConfigError`: the load-time schema degrades a malformed block to
205
+ * "absent", so this is the one place a bad value is refused instead of silently dropped.
206
+ */
207
+ export function retryOnResetPolicyConfigError(policy: unknown): string | null {
208
+ return strictPolicyConfigError("retryOnReset", retryOnResetPolicySchema, policy);
209
+ }
210
+
211
+ /**
212
+ * The shared body of both. Written once because the two differ only in the field name they
213
+ * report, and a second hand-copied formatter is a second place for the redaction to be
214
+ * forgotten.
215
+ */
216
+ function strictPolicyConfigError(
217
+ field: string,
218
+ schema: {
219
+ safeParse: (value: unknown) => { success: true } | {
220
+ success: false;
221
+ error: { issues: Array<{ code: string; message: string; path: PropertyKey[]; keys?: string[] }> };
222
+ };
223
+ },
224
+ policy: unknown,
225
+ ): string | null {
180
226
  if (policy === undefined) return null;
181
- const result = retryOn429PolicySchema.safeParse(policy);
227
+ const result = schema.safeParse(policy);
182
228
  if (result.success) return null;
183
229
  const first = result.error.issues[0];
184
- if (!first) return "retryOn429 is invalid";
185
- if (first.code === "unrecognized_keys") {
230
+ if (!first) return `${field} is invalid`;
231
+ if (first.code === "unrecognized_keys" && first.keys) {
186
232
  const names = first.keys.map(key => JSON.stringify(redactSecretString(key))).join(", ");
187
- return `retryOn429 has unrecognized field${first.keys.length > 1 ? "s" : ""}: ${names}`;
233
+ return `${field} has unrecognized field${first.keys.length > 1 ? "s" : ""}: ${names}`;
188
234
  }
189
- if (first.path.length === 0) return `retryOn429 is invalid (${first.message})`;
190
- const field = String(first.path[first.path.length - 1]);
191
- return `retryOn429.${field} is invalid (${first.message})`;
235
+ if (first.path.length === 0) return `${field} is invalid (${first.message})`;
236
+ return `${field}.${String(first.path[first.path.length - 1])} is invalid (${first.message})`;
192
237
  }
193
238
 
194
239
  export function sanitizeCapabilityDeclarationsForLoad(parsed: unknown): void {