@bitkyc08/opencodex 2.10.0 → 2.10.1

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 (274) hide show
  1. package/AGENTS_INSTALL.md +77 -0
  2. package/README.md +4 -10
  3. package/bin/ocx.mjs +71 -18
  4. package/gui/dist/assets/index-Cd6_PBKn.css +1 -0
  5. package/gui/dist/assets/index-ChZQsmBY.js +70 -0
  6. package/gui/dist/index.html +2 -2
  7. package/gui/dist/provider-icons/alibaba-color.svg +1 -1
  8. package/gui/dist/provider-icons/antigravity-color.svg +1 -1
  9. package/gui/dist/provider-icons/claude-color.svg +1 -1
  10. package/gui/dist/provider-icons/cline-color.svg +16 -0
  11. package/gui/dist/provider-icons/cloudflare-ai-gateway-color.svg +1 -1
  12. package/gui/dist/provider-icons/copilot-color.svg +1 -1
  13. package/gui/dist/provider-icons/cursor-color.svg +1 -1
  14. package/gui/dist/provider-icons/deepseek-color.svg +1 -1
  15. package/gui/dist/provider-icons/firepass-color.svg +1 -1
  16. package/gui/dist/provider-icons/fireworks-color.svg +1 -1
  17. package/gui/dist/provider-icons/gemini-color.svg +1 -1
  18. package/gui/dist/provider-icons/github-copilot-color.svg +1 -1
  19. package/gui/dist/provider-icons/gitlab-duo-color.svg +1 -1
  20. package/gui/dist/provider-icons/grok.svg +1 -1
  21. package/gui/dist/provider-icons/groq-color.svg +1 -1
  22. package/gui/dist/provider-icons/huggingface-color.svg +1 -1
  23. package/gui/dist/provider-icons/kimi-color.svg +1 -1
  24. package/gui/dist/provider-icons/kiro-color.svg +2 -2
  25. package/gui/dist/provider-icons/lm-studio-color.svg +1 -1
  26. package/gui/dist/provider-icons/mistral-color.svg +1 -1
  27. package/gui/dist/provider-icons/moonshot-color.svg +1 -1
  28. package/gui/dist/provider-icons/nvidia-color.svg +1 -1
  29. package/gui/dist/provider-icons/ollama-color.svg +1 -1
  30. package/gui/dist/provider-icons/openai.svg +1 -1
  31. package/gui/dist/provider-icons/opencode.svg +2 -1
  32. package/gui/dist/provider-icons/openrouter-color.svg +1 -1
  33. package/gui/dist/provider-icons/pi.svg +2 -2
  34. package/gui/dist/provider-icons/qianfan-color.svg +1 -1
  35. package/gui/dist/provider-icons/qwen-portal-color.svg +1 -1
  36. package/gui/dist/provider-icons/vercel-ai-gateway-color.svg +1 -1
  37. package/gui/dist/provider-icons/vllm-color.svg +1 -1
  38. package/gui/dist/provider-icons/xiaomi-color.svg +1 -1
  39. package/package.json +8 -4
  40. package/src/adapters/anthropic.ts +208 -14
  41. package/src/adapters/base.ts +16 -5
  42. package/src/adapters/cursor/effort-map.ts +3 -2
  43. package/src/adapters/cursor/framing.ts +39 -0
  44. package/src/adapters/cursor/live-transport.ts +105 -95
  45. package/src/adapters/cursor/native-exec.ts +32 -6
  46. package/src/adapters/cursor/protobuf-request.ts +20 -15
  47. package/src/adapters/cursor/request-builder.ts +21 -7
  48. package/src/adapters/cursor/types.ts +7 -0
  49. package/src/adapters/google-antigravity-replay.ts +237 -21
  50. package/src/adapters/google-truncation.ts +11 -0
  51. package/src/adapters/google.ts +50 -9
  52. package/src/adapters/identity.ts +39 -6
  53. package/src/adapters/kiro-errors.ts +11 -0
  54. package/src/adapters/kiro-events.ts +19 -1
  55. package/src/adapters/kiro-thinking.ts +10 -2
  56. package/src/adapters/kiro-tools.ts +10 -1
  57. package/src/adapters/kiro.ts +37 -11
  58. package/src/adapters/openai-chat.ts +284 -83
  59. package/src/adapters/openai-responses.ts +182 -24
  60. package/src/bridge.ts +177 -7
  61. package/src/chat/outbound.ts +78 -23
  62. package/src/claude/agents-inject.ts +27 -5
  63. package/src/claude/inbound.ts +11 -1
  64. package/src/claude/model-info.ts +13 -10
  65. package/src/claude/outbound.ts +17 -0
  66. package/src/cli/account-api.ts +24 -0
  67. package/src/cli/account-auth.ts +31 -6
  68. package/src/cli/account-main.ts +317 -0
  69. package/src/cli/account.ts +5 -0
  70. package/src/cli/claude.ts +2 -1
  71. package/src/cli/doctor.ts +93 -22
  72. package/src/cli/export-command.ts +26 -12
  73. package/src/cli/help.ts +8 -6
  74. package/src/cli/index.ts +56 -22
  75. package/src/cli/integrations.ts +84 -1
  76. package/src/cli/observe.ts +54 -1
  77. package/src/cli/opencode.ts +2 -1
  78. package/src/cli/provider-runtime.ts +18 -1
  79. package/src/cli/route-policy.ts +92 -0
  80. package/src/cli/runtime-api.ts +6 -3
  81. package/src/cli/star-prompt.ts +71 -15
  82. package/src/cli/status.ts +1 -1
  83. package/src/cli/v2.ts +36 -9
  84. package/src/clients/config-export.ts +687 -10
  85. package/src/codex/account-lifecycle.ts +30 -5
  86. package/src/codex/account-usability.ts +22 -2
  87. package/src/codex/admission.ts +255 -0
  88. package/src/codex/auth-api.ts +427 -140
  89. package/src/codex/auth-context.ts +155 -30
  90. package/src/codex/autostart-health.ts +8 -1
  91. package/src/codex/catalog/account-models.ts +62 -0
  92. package/src/codex/catalog/aggregation.ts +14 -1
  93. package/src/codex/catalog/bundled.ts +282 -32
  94. package/src/codex/catalog/filesystem-evidence.ts +302 -0
  95. package/src/codex/catalog/metadata.ts +51 -6
  96. package/src/codex/catalog/parsing.ts +6 -3
  97. package/src/codex/catalog/provider-fetch.ts +576 -41
  98. package/src/codex/catalog/sync.ts +505 -66
  99. package/src/codex/catalog-admission.ts +197 -0
  100. package/src/codex/catalog-write-serialization.ts +241 -0
  101. package/src/codex/catalog.ts +2 -1
  102. package/src/codex/codex-write-lock.ts +372 -0
  103. package/src/codex/convergence-types.ts +593 -0
  104. package/src/codex/convergence.ts +441 -0
  105. package/src/codex/desired-state.ts +177 -0
  106. package/src/codex/features.ts +52 -8
  107. package/src/codex/generation.ts +202 -0
  108. package/src/codex/history-job.ts +257 -0
  109. package/src/codex/history-lock.ts +241 -0
  110. package/src/codex/history-migration-guardian.ts +18 -5
  111. package/src/codex/history-provider.ts +9 -2
  112. package/src/codex/history-transition.ts +105 -0
  113. package/src/codex/history-worker.ts +176 -0
  114. package/src/codex/inject-coordination.ts +245 -0
  115. package/src/codex/inject.ts +605 -124
  116. package/src/codex/integration-record.ts +266 -0
  117. package/src/codex/internal/catalog-writer.ts +203 -0
  118. package/src/codex/internal/history-writer.ts +80 -0
  119. package/src/codex/journal.ts +10 -1
  120. package/src/codex/main-account-cache.ts +24 -0
  121. package/src/codex/management-convergence.ts +114 -0
  122. package/src/codex/native-main-admission.ts +47 -0
  123. package/src/codex/native-main-auth-temp.ts +187 -0
  124. package/src/codex/native-main-claim.ts +167 -0
  125. package/src/codex/native-main-lock-file.ts +158 -0
  126. package/src/codex/native-main-owner.ts +315 -0
  127. package/src/codex/native-profile-api.ts +247 -0
  128. package/src/codex/native-profile-manager.ts +1512 -0
  129. package/src/codex/native-profile-processes.ts +121 -0
  130. package/src/codex/native-profile-recovery.ts +99 -0
  131. package/src/codex/native-profile-stage-store.ts +387 -0
  132. package/src/codex/native-profile-startup.ts +340 -0
  133. package/src/codex/native-profile-store.ts +855 -0
  134. package/src/codex/native-profile-types.ts +120 -0
  135. package/src/codex/native-residue.ts +557 -0
  136. package/src/codex/project-config-warnings.ts +18 -4
  137. package/src/codex/prompt-journal.ts +311 -0
  138. package/src/codex/prompt-layers.ts +967 -0
  139. package/src/codex/prompt-lock.ts +143 -0
  140. package/src/codex/quota-rejection.ts +224 -0
  141. package/src/codex/quota.ts +86 -3
  142. package/src/codex/routing.ts +299 -62
  143. package/src/codex/runtime.ts +159 -38
  144. package/src/codex/shim.ts +39 -13
  145. package/src/codex/subagent-model-fallback.ts +73 -12
  146. package/src/codex/transition-state.ts +604 -0
  147. package/src/codex/upstream-host-health.ts +70 -0
  148. package/src/codex/user-identity.ts +266 -0
  149. package/src/codex/write-coordination.ts +114 -0
  150. package/src/config.ts +562 -26
  151. package/src/generated/jawcode-model-metadata.ts +2 -2
  152. package/src/grok/inject.ts +15 -4
  153. package/src/grok/inspect.ts +45 -0
  154. package/src/images/loop.ts +113 -20
  155. package/src/integrations/config-io.ts +151 -0
  156. package/src/integrations/journal.ts +315 -0
  157. package/src/integrations/merge.ts +135 -0
  158. package/src/integrations/native/ownership-preflight.ts +165 -0
  159. package/src/integrations/ownership.ts +111 -0
  160. package/src/integrations/registry.ts +101 -0
  161. package/src/integrations/serialize.ts +235 -0
  162. package/src/integrations/state.ts +290 -0
  163. package/src/integrations/store.ts +103 -0
  164. package/src/integrations/writer.ts +492 -0
  165. package/src/lib/bounded-body.ts +46 -8
  166. package/src/lib/bun-runtime.ts +110 -1
  167. package/src/lib/bun-stream-caps.ts +2 -1
  168. package/src/lib/redact.ts +407 -2
  169. package/src/lib/shadow-call.ts +24 -0
  170. package/src/lib/translator-budget.ts +10 -0
  171. package/src/lib/upstream-reachability.ts +91 -0
  172. package/src/lib/upstream-retry.ts +154 -2
  173. package/src/lib/windows-secret-acl.ts +212 -11
  174. package/src/lib/winsw.ts +9 -3
  175. package/src/oauth/index.ts +61 -3
  176. package/src/oauth/key-providers.ts +4 -0
  177. package/src/oauth/kiro.ts +50 -6
  178. package/src/oauth/store.ts +31 -6
  179. package/src/oauth/token-guardian.ts +9 -3
  180. package/src/providers/codex-capacity.ts +288 -0
  181. package/src/providers/derive.ts +33 -1
  182. package/src/providers/free-directory.ts +3 -1
  183. package/src/providers/key-failover.ts +71 -3
  184. package/src/providers/openai-sidecar.ts +64 -4
  185. package/src/providers/openai-virtual-models.ts +1 -0
  186. package/src/providers/quota.ts +334 -26
  187. package/src/providers/registry.ts +284 -16
  188. package/src/providers/xai-transport.ts +11 -4
  189. package/src/responses/compaction.ts +8 -1
  190. package/src/responses/hosted-tool-policy.ts +9 -0
  191. package/src/responses/parser.ts +44 -2
  192. package/src/responses/reasoning-envelope.ts +9 -1
  193. package/src/responses/reasoning-replay-cache.ts +105 -0
  194. package/src/responses/spill-store.ts +45 -8
  195. package/src/responses/state.ts +161 -17
  196. package/src/router.ts +302 -16
  197. package/src/routing/analytics.ts +377 -0
  198. package/src/routing/capability.ts +204 -0
  199. package/src/routing/cost.ts +77 -0
  200. package/src/routing/evaluator.ts +444 -0
  201. package/src/routing/health.ts +401 -0
  202. package/src/routing/history/cursor.ts +43 -0
  203. package/src/routing/history/indexer.ts +590 -0
  204. package/src/routing/history/schema.ts +72 -0
  205. package/src/routing/profile.ts +423 -0
  206. package/src/routing/quota.ts +124 -0
  207. package/src/routing/request-evidence.ts +45 -0
  208. package/src/routing/trace.ts +686 -0
  209. package/src/server/auth-cors.ts +46 -6
  210. package/src/server/chat-completions.ts +28 -13
  211. package/src/server/claude-messages.ts +23 -15
  212. package/src/server/gui-static.ts +39 -10
  213. package/src/server/images.ts +10 -1
  214. package/src/server/index.ts +238 -52
  215. package/src/server/lifecycle.ts +228 -9
  216. package/src/server/live.ts +14 -3
  217. package/src/server/management/agent-settings-routes.ts +64 -14
  218. package/src/server/management/combo-routes.ts +5 -5
  219. package/src/server/management/config-routes.ts +1 -1
  220. package/src/server/management/context.ts +42 -2
  221. package/src/server/management/integration-routes.ts +538 -0
  222. package/src/server/management/logs-usage-routes.ts +1 -1
  223. package/src/server/management/model-routes.ts +32 -113
  224. package/src/server/management/model-rows.ts +117 -0
  225. package/src/server/management/native-integration-routes.ts +587 -0
  226. package/src/server/management/oauth-account-routes.ts +1 -1
  227. package/src/server/management/provider-routes.ts +218 -117
  228. package/src/server/management/request-history-routes.ts +191 -0
  229. package/src/server/management/routing-analytics-routes.ts +74 -0
  230. package/src/server/management/routing-profile-routes.ts +177 -0
  231. package/src/server/management/shared.ts +2 -2
  232. package/src/server/management/sidebar-routes.ts +47 -31
  233. package/src/server/management/sync-response.ts +69 -0
  234. package/src/server/management/system-restart.ts +276 -24
  235. package/src/server/management/system-routes.ts +4 -0
  236. package/src/server/management-api.ts +84 -9
  237. package/src/server/management-auth.ts +43 -5
  238. package/src/server/relay-eager.ts +82 -42
  239. package/src/server/relay.ts +120 -6
  240. package/src/server/request-log.ts +26 -6
  241. package/src/server/responses/collaboration.ts +63 -8
  242. package/src/server/responses/compact.ts +272 -41
  243. package/src/server/responses/core.ts +730 -132
  244. package/src/server/responses/fetch-helpers.ts +15 -1
  245. package/src/server/responses-item-id-repair.ts +32 -3
  246. package/src/server/responses-json-events.ts +52 -0
  247. package/src/server/responses-snapshot-repair.ts +621 -0
  248. package/src/server/search.ts +51 -6
  249. package/src/server/sse-payload-rewrite.ts +89 -12
  250. package/src/server/startup-health-cache.ts +7 -1
  251. package/src/server/ws-bridge.ts +11 -17
  252. package/src/service-manager-probe.ts +297 -0
  253. package/src/service.ts +222 -32
  254. package/src/tray/windows-tray.ps1 +9 -0
  255. package/src/tray/windows.ts +15 -7
  256. package/src/types.ts +194 -14
  257. package/src/update/index.ts +13 -13
  258. package/src/update/job.ts +24 -21
  259. package/src/update/notify.ts +7 -3
  260. package/src/usage/cost.ts +0 -0
  261. package/src/usage/expected-prices.ts +129 -10
  262. package/src/usage/log.ts +50 -15
  263. package/src/usage/summary.ts +4 -4
  264. package/src/vision/index.ts +6 -1
  265. package/src/web-search/loop.ts +161 -34
  266. package/gui/dist/assets/index-OY43ubAq.css +0 -1
  267. package/gui/dist/assets/index-YwNnKZcL.js +0 -67
  268. package/gui/dist/provider-icons/antigravity.svg +0 -1
  269. package/gui/dist/provider-icons/claude.svg +0 -1
  270. package/gui/dist/provider-icons/copilot.svg +0 -1
  271. package/gui/dist/provider-icons/cursor.svg +0 -2
  272. package/gui/dist/provider-icons/gemini.svg +0 -1
  273. package/gui/dist/provider-icons/grok-color.svg +0 -1
  274. package/gui/dist/provider-icons/kiro.svg +0 -14
@@ -0,0 +1,441 @@
1
+ import { join } from "node:path";
2
+
3
+ import { getConfigDir, websocketsEnabled, withExpectedConfigGenerationSync } from "../config";
4
+ import { COMBO_NAMESPACE } from "../combos";
5
+ import { isCanonicalOpenAiForwardProvider } from "../providers/openai-tiers";
6
+ import { getAuthStorePath } from "../oauth/store";
7
+ import type { OcxConfig } from "../types";
8
+ import { captureCatalogAdmissionSnapshot } from "./catalog-admission";
9
+ import {
10
+ type CatalogSourceForGather,
11
+ bundledCatalogCacheState,
12
+ resolveCatalogSourceForGather,
13
+ } from "./catalog/bundled";
14
+ import {
15
+ acceptCatalogGatherSourcePath,
16
+ captureAndSealCatalogHomeSelection,
17
+ captureCatalogGatherTargetIdentity,
18
+ createCatalogGatherEvidenceSession,
19
+ readCatalogGatherSource,
20
+ sealCatalogGatherEvidenceSession,
21
+ type CatalogFilesystemEvidenceSession,
22
+ } from "./catalog/filesystem-evidence";
23
+ import {
24
+ CatalogGatherBusyError,
25
+ createCatalogGatherAuthorityIdentity,
26
+ filterCatalogVisibleModels,
27
+ gatherRoutedModelsForCatalogGather,
28
+ type CatalogGatherProviderAuthOutcome,
29
+ } from "./catalog/provider-fetch";
30
+ import {
31
+ catalogBackupPathFor,
32
+ catalogHasRoutedEntries,
33
+ findNativeTemplate,
34
+ legacyCatalogBackupPath,
35
+ parseCatalogJson,
36
+ type RawCatalog,
37
+ } from "./catalog/parsing";
38
+ import {
39
+ buildCatalogEntries,
40
+ orderForSubagents,
41
+ } from "./catalog/sync";
42
+ import { exactComboCatalogSlugs } from "./catalog/aggregation";
43
+ import { disabledNativeSlugs } from "./catalog/metadata";
44
+ import { codexRuntimeStatePath, peekCodexRuntimeProcessCache } from "./runtime";
45
+ import { withCatalogWriteSerialization } from "./catalog-write-serialization";
46
+ import {
47
+ publishHashedCodexCatalogBackup,
48
+ publishLegacyCodexCatalogBackup,
49
+ replaceActiveCodexCatalog,
50
+ replaceCodexModelsCache,
51
+ type PreparedCatalogFileWrite,
52
+ } from "./internal/catalog-writer";
53
+ import type {
54
+ CatalogAdmissionSnapshot,
55
+ CatalogDisposition,
56
+ CatalogGatherAuthorityIdentity,
57
+ CatalogNotice,
58
+ CatalogProviderDiscoveryPolicySnapshot,
59
+ CatalogProcessLocalEvidence,
60
+ CatalogSourceEvidence,
61
+ CatalogSourceRole,
62
+ ConvergeRequest,
63
+ } from "./convergence-types";
64
+
65
+ export interface CatalogWriteReceipt {
66
+ readonly keyedBackup: "written" | "preserved" | "not-requested";
67
+ readonly legacyBackup: "written" | "preserved" | "not-requested";
68
+ readonly catalog: "written" | "not-written";
69
+ readonly cache: "written" | "not-written";
70
+ }
71
+
72
+ export type CodexCatalogCommitResult =
73
+ | { readonly kind: "committed"; readonly changed: boolean; readonly writes: CatalogWriteReceipt }
74
+ | { readonly kind: "stale"; readonly reason: "generation" | "home-selection" | "source-observation" | "process-local" | "target-identity" | "candidate-consumed" }
75
+ | { readonly kind: "refused"; readonly reason: "source-unreadable" | "source-ambiguous" | "target-unsafe" }
76
+ | { readonly kind: "failed"; readonly surface: "disk"; readonly writes: CatalogWriteReceipt };
77
+
78
+ declare const catalogCandidateBrand: unique symbol;
79
+ export interface CodexCatalogCandidate { readonly [catalogCandidateBrand]: true }
80
+
81
+ export type CodexCatalogGatherResult =
82
+ | { readonly kind: "candidate"; readonly candidate: CodexCatalogCandidate }
83
+ | { readonly kind: "disposition"; readonly disposition: CatalogDisposition };
84
+
85
+ type CommitAttempt = CodexCatalogCommitResult | { readonly kind: "busy" };
86
+
87
+ interface CandidateState {
88
+ consumed: boolean;
89
+ readonly generation: CatalogAdmissionSnapshot["generation"];
90
+ readonly authority: CatalogGatherAuthorityIdentity;
91
+ readonly sourceEvidence: CatalogSourceEvidence;
92
+ readonly processLocal: CatalogProcessLocalEvidence;
93
+ readonly home: string;
94
+ readonly targets: CatalogAdmissionSnapshot["targets"];
95
+ readonly catalog: PreparedCatalogFileWrite;
96
+ readonly cache: PreparedCatalogFileWrite;
97
+ readonly keyedBackup?: PreparedCatalogFileWrite;
98
+ readonly legacyBackup?: PreparedCatalogFileWrite;
99
+ readonly changed: boolean;
100
+ readonly notices: readonly CatalogNotice[];
101
+ }
102
+
103
+ const candidateStates = new WeakMap<object, CandidateState>();
104
+ function same(left: unknown, right: unknown): boolean {
105
+ return JSON.stringify(left) === JSON.stringify(right);
106
+ }
107
+
108
+ function targetPath(identity: string): string {
109
+ const parsed = JSON.parse(identity) as { path?: unknown };
110
+ if (typeof parsed.path !== "string") throw new TypeError("Catalog target identity has no path.");
111
+ return parsed.path;
112
+ }
113
+
114
+ function catalogFrom(bytes: Uint8Array | null): RawCatalog | null {
115
+ return bytes === null ? null : parseCatalogJson(Buffer.from(bytes).toString("utf8"));
116
+ }
117
+
118
+ function catalogBytes(catalog: RawCatalog): string {
119
+ return `${JSON.stringify(catalog, null, 2)}\n`;
120
+ }
121
+
122
+ interface ReadonlyRawCatalogLike {
123
+ readonly models?: readonly Readonly<Record<string, unknown>>[];
124
+ }
125
+
126
+ function hasRoutedEntries(catalog: ReadonlyRawCatalogLike): boolean {
127
+ return (catalog.models ?? []).some(entry => typeof entry.slug === "string" && entry.slug.includes("/"));
128
+ }
129
+
130
+ function processEvidence(source: CatalogSourceForGather): CatalogProcessLocalEvidence {
131
+ return Object.freeze({
132
+ runtime: Object.freeze({ ...source.processLocal.runtime }),
133
+ bundledCatalog: Object.freeze({ ...source.processLocal.bundledCatalog }),
134
+ });
135
+ }
136
+
137
+ function bindGatherPaths(
138
+ session: CatalogFilesystemEvidenceSession,
139
+ snapshot: CatalogAdmissionSnapshot,
140
+ ): Readonly<{ catalog: string; cache: string; keyedBackup: string; legacyBackup?: string }> {
141
+ const catalog = targetPath(snapshot.targets.catalog);
142
+ const cache = targetPath(snapshot.targets.cache);
143
+ const keyedBackup = targetPath(snapshot.targets.catalogBackups[0]!);
144
+ const legacyBackup = snapshot.targets.catalogBackups[1]
145
+ ? targetPath(snapshot.targets.catalogBackups[1]) : undefined;
146
+ const configPath = snapshot.sourceEvidence.required["catalog-target-selection"].logicalPath;
147
+
148
+ acceptCatalogGatherSourcePath(session, "catalog-target-selection", configPath);
149
+ readCatalogGatherSource(session, "catalog-target-selection");
150
+ acceptCatalogGatherSourcePath(session, "active-catalog-merge", catalog);
151
+ acceptCatalogGatherSourcePath(session, "hashed-backup-fallback", keyedBackup);
152
+ acceptCatalogGatherSourcePath(session, "legacy-backup-fallback", legacyBackup ?? legacyCatalogBackupPath());
153
+ acceptCatalogGatherSourcePath(session, "models-cache-fallback", cache);
154
+ acceptCatalogGatherSourcePath(session, "runtime-selection", codexRuntimeStatePath(getConfigDir()));
155
+ acceptCatalogGatherSourcePath(session, "provider-auth-selection", getAuthStorePath());
156
+ acceptCatalogGatherSourcePath(session, "native-catalog-selection", catalog);
157
+ return { catalog, cache, keyedBackup, ...(legacyBackup ? { legacyBackup } : {}) };
158
+ }
159
+
160
+ function prepareCatalog(
161
+ config: Readonly<OcxConfig>,
162
+ source: Extract<CatalogSourceForGather, { kind: "available" }>,
163
+ active: RawCatalog | null,
164
+ routedModels: Awaited<ReturnType<typeof gatherRoutedModelsForCatalogGather>>,
165
+ ): RawCatalog {
166
+ const catalog = JSON.parse(JSON.stringify(source.catalog)) as RawCatalog;
167
+ const template = findNativeTemplate(catalog);
168
+ const enabled = filterCatalogVisibleModels(routedModels, config);
169
+ const featured = config.subagentModels ?? [];
170
+ const ordered = orderForSubagents(enabled, featured);
171
+ const multiAgentMode = config.multiAgentMode === "v1" || config.multiAgentMode === "v2"
172
+ ? config.multiAgentMode : "default";
173
+ const exactComboSlugs = exactComboCatalogSlugs(config);
174
+ const hasPhysicalComboProvider = Object.hasOwn(config.providers, COMBO_NAMESPACE);
175
+ const enabledProviders = Object.entries(config.providers).filter(([, provider]) => provider.disabled !== true);
176
+ const includeNativeOpenAi = enabledProviders.length === 0 || enabledProviders.some(([name, provider]) => (
177
+ name === "openai" && isCanonicalOpenAiForwardProvider(provider)
178
+ ));
179
+ const disabledNative = disabledNativeSlugs(config);
180
+ const nativeSlugs = includeNativeOpenAi
181
+ ? [...new Set((active?.models ?? catalog.models ?? []).flatMap(entry => (
182
+ typeof entry.slug === "string" && !entry.slug.includes("/") && !disabledNative.has(entry.slug)
183
+ ? [entry.slug] : []
184
+ )))]
185
+ : [];
186
+ const entries = buildCatalogEntries(
187
+ template ? JSON.parse(JSON.stringify(template)) : null,
188
+ nativeSlugs, ordered, featured, websocketsEnabled(config), multiAgentMode, exactComboSlugs,
189
+ );
190
+ if (entries.length === nativeSlugs.length) {
191
+ const configuredProviders = new Set(enabledProviders.map(([name]) => name));
192
+ const preserved = (active?.models ?? []).filter(entry => {
193
+ if (typeof entry.slug !== "string" || !entry.slug.includes("/")) return false;
194
+ const provider = entry.slug.slice(0, entry.slug.indexOf("/"));
195
+ const description = typeof entry.description === "string" ? entry.description : "";
196
+ return configuredProviders.has(provider) || !description.startsWith("Routed via opencodex → ");
197
+ });
198
+ entries.push(...preserved);
199
+ }
200
+ if (!hasPhysicalComboProvider) {
201
+ const exact = exactComboSlugs;
202
+ catalog.models = entries.filter(entry => (
203
+ typeof entry.slug !== "string" || !exact.has(entry.slug)
204
+ || (Array.isArray(entry.input_modalities) && entry.input_modalities.length > 0)
205
+ ));
206
+ } else {
207
+ catalog.models = entries;
208
+ }
209
+ return catalog;
210
+ }
211
+
212
+ export async function gatherCodexCatalogCandidate(
213
+ snapshot: CatalogAdmissionSnapshot,
214
+ ): Promise<CodexCatalogGatherResult> {
215
+ let providerGatherStarted = false;
216
+ try {
217
+ const session = createCatalogGatherEvidenceSession();
218
+ const home = captureAndSealCatalogHomeSelection(session);
219
+ if (!same(home, snapshot.sourceEvidence.homeSelection)) {
220
+ return { kind: "disposition", disposition: { status: "skipped", reason: "stale", retryable: true } };
221
+ }
222
+ const paths = bindGatherPaths(session, snapshot);
223
+ const source = resolveCatalogSourceForGather(session);
224
+ if (source.kind === "catalog-unavailable") {
225
+ return { kind: "disposition", disposition: { status: "skipped", reason: "catalog-unavailable", retryable: false } };
226
+ }
227
+
228
+ const activeBytes = readCatalogGatherSource(session, "active-catalog-merge");
229
+ const cacheBytes = readCatalogGatherSource(session, "models-cache-fallback");
230
+ const keyedBackupBytes = readCatalogGatherSource(session, "hashed-backup-fallback");
231
+ const legacyBackupBytes = paths.legacyBackup
232
+ ? readCatalogGatherSource(session, "legacy-backup-fallback") : null;
233
+ if (Object.keys(snapshot.config.combos ?? {}).length > 0) {
234
+ readCatalogGatherSource(session, "native-catalog-selection");
235
+ }
236
+ const authOutcomes: CatalogGatherProviderAuthOutcome[] = [];
237
+ const discoveryPolicies: CatalogProviderDiscoveryPolicySnapshot[] = [];
238
+ providerGatherStarted = true;
239
+ const routedModels = await gatherRoutedModelsForCatalogGather(snapshot.config, session, {
240
+ providerAuthOutcomes: authOutcomes,
241
+ discoveryPolicySnapshots: discoveryPolicies,
242
+ });
243
+ const processLocal = processEvidence(source);
244
+ const sourceEvidence = sealCatalogGatherEvidenceSession(session);
245
+ if (!same(sourceEvidence.required, snapshot.sourceEvidence.required)) {
246
+ return { kind: "disposition", disposition: { status: "skipped", reason: "stale", retryable: true } };
247
+ }
248
+
249
+ const current = captureCatalogAdmissionSnapshot(snapshot.config);
250
+ if (!same(current.configIdentity, snapshot.configIdentity)
251
+ || !same(current.targets, snapshot.targets)
252
+ || !same(current.sourceEvidence.homeSelection, snapshot.sourceEvidence.homeSelection)
253
+ || !same(current.sourceEvidence.required, snapshot.sourceEvidence.required)) {
254
+ return { kind: "disposition", disposition: { status: "skipped", reason: "stale", retryable: true } };
255
+ }
256
+
257
+ const active = catalogFrom(activeBytes);
258
+ const preparedCatalog = prepareCatalog(snapshot.config, source, active, routedModels);
259
+ const preparedCatalogBytes = catalogBytes(preparedCatalog);
260
+ const preparedCacheBytes = `${JSON.stringify({
261
+ fetched_at: "2000-01-01T00:00:00Z",
262
+ client_version: "0.0.0",
263
+ models: preparedCatalog.models ?? [],
264
+ }, null, 2)}\n`;
265
+ const pristineBytes = active && !catalogHasRoutedEntries(active)
266
+ ? Buffer.from(activeBytes!).toString("utf8")
267
+ : !hasRoutedEntries(source.catalog) ? `${JSON.stringify(source.catalog, null, 2)}\n` : null;
268
+ const notices = new Set<CatalogNotice>();
269
+ if (source.source !== "bundled-catalog-template") notices.add("fallback");
270
+ if (authOutcomes.some(outcome => outcome.state !== "available")) notices.add("provider-auth");
271
+ const candidate = {} as CodexCatalogCandidate;
272
+ candidateStates.set(candidate, {
273
+ consumed: false,
274
+ generation: snapshot.generation,
275
+ authority: createCatalogGatherAuthorityIdentity(
276
+ snapshot,
277
+ sourceEvidence,
278
+ processLocal,
279
+ discoveryPolicies,
280
+ ),
281
+ sourceEvidence,
282
+ processLocal,
283
+ home: home.canonicalCodexHome,
284
+ targets: snapshot.targets,
285
+ catalog: { path: paths.catalog, content: preparedCatalogBytes },
286
+ cache: { path: paths.cache, content: preparedCacheBytes },
287
+ ...(pristineBytes ? { keyedBackup: { path: paths.keyedBackup, content: pristineBytes } } : {}),
288
+ ...(pristineBytes && paths.legacyBackup
289
+ ? { legacyBackup: { path: paths.legacyBackup, content: pristineBytes } } : {}),
290
+ changed: Buffer.from(activeBytes ?? []).toString("utf8") !== preparedCatalogBytes
291
+ || Buffer.from(cacheBytes ?? []).toString("utf8") !== preparedCacheBytes,
292
+ notices: Object.freeze([...notices]),
293
+ });
294
+ return { kind: "candidate", candidate };
295
+ } catch (error) {
296
+ if (error instanceof CatalogGatherBusyError) {
297
+ return { kind: "disposition", disposition: { status: "skipped", reason: "busy", retryable: true } };
298
+ }
299
+ if (!providerGatherStarted) {
300
+ return { kind: "disposition", disposition: { status: "skipped", reason: "refused", retryable: false } };
301
+ }
302
+ return {
303
+ kind: "disposition",
304
+ disposition: { status: "failed", reason: "provider-network", phase: "gather", retryable: true, partialWrite: false },
305
+ };
306
+ }
307
+ }
308
+
309
+ function revalidateCandidate(state: CandidateState): CodexCatalogCommitResult | null {
310
+ let session: CatalogFilesystemEvidenceSession;
311
+ let validatingTargets = false;
312
+ try {
313
+ session = createCatalogGatherEvidenceSession();
314
+ const home = captureAndSealCatalogHomeSelection(session);
315
+ if (!same(home, state.sourceEvidence.homeSelection)) return { kind: "stale", reason: "home-selection" };
316
+ validatingTargets = true;
317
+ const currentTargets = {
318
+ catalog: captureCatalogGatherTargetIdentity(session, state.catalog.path),
319
+ cache: captureCatalogGatherTargetIdentity(session, state.cache.path),
320
+ catalogBackups: state.targets.catalogBackups.map(identity => (
321
+ captureCatalogGatherTargetIdentity(session, targetPath(identity))
322
+ )),
323
+ };
324
+ if (!same(currentTargets, state.targets)) return { kind: "stale", reason: "target-identity" };
325
+ validatingTargets = false;
326
+ for (const observation of [state.sourceEvidence.required["catalog-target-selection"]]) {
327
+ acceptCatalogGatherSourcePath(session, observation.role, observation.logicalPath);
328
+ readCatalogGatherSource(session, observation.role);
329
+ }
330
+ for (const [role, observations] of Object.entries(state.sourceEvidence.conditional)) {
331
+ for (const observation of observations) {
332
+ acceptCatalogGatherSourcePath(session, role as CatalogSourceRole, observation.logicalPath);
333
+ readCatalogGatherSource(session, role as CatalogSourceRole);
334
+ }
335
+ }
336
+ if (!same(sealCatalogGatherEvidenceSession(session), state.sourceEvidence)) {
337
+ return { kind: "stale", reason: "source-observation" };
338
+ }
339
+ } catch {
340
+ return { kind: "refused", reason: validatingTargets ? "target-unsafe" : "source-unreadable" };
341
+ }
342
+
343
+ if (state.processLocal.runtime.state === "used") {
344
+ const current = peekCodexRuntimeProcessCache();
345
+ if (current.kind !== "available" || current.epoch !== state.processLocal.runtime.epoch
346
+ || current.valueIdentity !== state.processLocal.runtime.valueIdentity) {
347
+ return { kind: "stale", reason: "process-local" };
348
+ }
349
+ }
350
+ if (state.processLocal.bundledCatalog.state === "used") {
351
+ const current = bundledCatalogCacheState();
352
+ if (current.epoch !== state.processLocal.bundledCatalog.epoch
353
+ || current.valueIdentity !== state.processLocal.bundledCatalog.valueIdentity) {
354
+ return { kind: "stale", reason: "process-local" };
355
+ }
356
+ }
357
+ return null;
358
+ }
359
+
360
+ function fixedCommit(state: CandidateState, permit: Parameters<typeof replaceActiveCodexCatalog>[0]): CodexCatalogCommitResult {
361
+ let writes: CatalogWriteReceipt = {
362
+ keyedBackup: "not-requested",
363
+ legacyBackup: "not-requested",
364
+ catalog: "not-written",
365
+ cache: "not-written",
366
+ };
367
+ try {
368
+ if (state.keyedBackup) {
369
+ writes = { ...writes, keyedBackup: publishHashedCodexCatalogBackup(permit, state.home, state.keyedBackup) };
370
+ }
371
+ if (state.legacyBackup) {
372
+ writes = { ...writes, legacyBackup: publishLegacyCodexCatalogBackup(permit, state.home, state.legacyBackup) };
373
+ }
374
+ replaceActiveCodexCatalog(permit, state.home, state.catalog);
375
+ writes = { ...writes, catalog: "written" };
376
+ replaceCodexModelsCache(permit, state.home, state.cache);
377
+ writes = { ...writes, cache: "written" };
378
+ return { kind: "committed", changed: state.changed, writes };
379
+ } catch {
380
+ return { kind: "failed", surface: "disk", writes };
381
+ }
382
+ }
383
+
384
+ export async function commitCodexCatalogCandidate(
385
+ candidate: CodexCatalogCandidate,
386
+ deadlineMs: number,
387
+ ): Promise<CommitAttempt> {
388
+ const state = candidateStates.get(candidate as object);
389
+ if (!state) return { kind: "refused", reason: "source-ambiguous" };
390
+ if (state.consumed) return { kind: "stale", reason: "candidate-consumed" };
391
+ const deadline = Date.now() + Math.max(0, deadlineMs);
392
+ while (true) {
393
+ const acquired = withCatalogWriteSerialization(state.home, permit => {
394
+ state.consumed = true;
395
+ const guarded = withExpectedConfigGenerationSync(state.generation, () => {
396
+ const invalid = revalidateCandidate(state);
397
+ return invalid ?? fixedCommit(state, permit);
398
+ });
399
+ if (guarded.kind === "conflict") return { kind: "stale", reason: "generation" } as const;
400
+ if (guarded.kind === "unavailable") return { kind: "busy" } as const;
401
+ return guarded.value;
402
+ });
403
+ if (acquired.kind === "completed") return acquired.value;
404
+ if (acquired.reason !== "busy" || Date.now() >= deadline) return { kind: "busy" };
405
+ await Bun.sleep(Math.min(10, Math.max(1, deadline - Date.now())));
406
+ }
407
+ }
408
+
409
+ function projectCommit(result: CommitAttempt, notices: readonly CatalogNotice[]): CatalogDisposition {
410
+ if (result.kind === "busy") return { status: "skipped", reason: "busy", retryable: true };
411
+ if (result.kind === "committed") {
412
+ return { status: "committed", changed: result.changed, degraded: notices.length > 0, notices };
413
+ }
414
+ if (result.kind === "stale") return { status: "skipped", reason: "stale", retryable: true };
415
+ if (result.kind === "refused") return { status: "skipped", reason: "refused", retryable: false };
416
+ const partialWrite = result.writes.keyedBackup === "written"
417
+ || result.writes.legacyBackup === "written" || result.writes.catalog === "written";
418
+ return { status: "failed", reason: "disk", phase: "commit", retryable: false, partialWrite };
419
+ }
420
+
421
+ export async function convergeCodexCatalog(
422
+ snapshot: CatalogAdmissionSnapshot,
423
+ request: ConvergeRequest,
424
+ lifecycle: Readonly<{ onCommitBegin?: () => void }> = {},
425
+ ): Promise<Readonly<{ changed: boolean; catalogRefresh: CatalogDisposition }>> {
426
+ if (request.scope !== "catalog" || request.action !== "converge") {
427
+ return {
428
+ changed: false,
429
+ catalogRefresh: { status: "failed", reason: "disk", phase: "gather", retryable: false, partialWrite: false },
430
+ };
431
+ }
432
+ const gathered = await gatherCodexCatalogCandidate(snapshot);
433
+ if (gathered.kind === "disposition") return { changed: false, catalogRefresh: gathered.disposition };
434
+ const state = candidateStates.get(gathered.candidate as object)!;
435
+ lifecycle.onCommitBegin?.();
436
+ const committed = await commitCodexCatalogCandidate(gathered.candidate, request.deadlineMs);
437
+ return {
438
+ changed: committed.kind === "committed" ? committed.changed : false,
439
+ catalogRefresh: projectCommit(committed, state.notices),
440
+ };
441
+ }
@@ -0,0 +1,177 @@
1
+ /**
2
+ * Durable desired state for the native Codex integration.
3
+ *
4
+ * The switch itself was never the hard part — `ocx restore` already returns Codex
5
+ * to its native path without stopping the proxy. What was missing is that the
6
+ * decision did not survive: `ocx start` force-synced unconditionally, so an OFF
7
+ * lasted exactly until the next start. That is the defect this module closes, and
8
+ * it is the same one Grok's shipped toggle still has.
9
+ *
10
+ * ABSENT MEANS ON. A user who never touched a switch, a config written by an
11
+ * older binary, and an explicit `true` are the same state, and none of them may
12
+ * be read as "the user turned this off". Only an explicit `false` is OFF.
13
+ *
14
+ * This module does NOT own linearization. The plan that predates
15
+ * `src/codex/user-identity.ts` proposed a second per-home lock at
16
+ * `tmpdir()/opencodex-native-locks/sha256(home).sqlite`; that keys on the home
17
+ * alone, carries no proof of effective user, and would collide or split
18
+ * depending on the temp root. Convergence takes the write lock; this module only
19
+ * records intent through the config coordinator.
20
+ *
21
+ * Design record: devlog/_plan/260803_codex_desktop_toggle/030_desired_state.md.
22
+ */
23
+ import { loadConfig, mutatePersistedConfig } from "../config";
24
+ import type { OcxClientIntegrationsConfig, OcxConfig } from "../types";
25
+
26
+ /** Clients whose durable intent this module owns. */
27
+ export type DurableIntentClientId = keyof OcxClientIntegrationsConfig;
28
+
29
+ /** Injectable for tests; production passes the real sync. */
30
+ export type CodexStartupSync = (port: number) => Promise<unknown>;
31
+
32
+ export type CodexDesiredStateResult =
33
+ | { readonly ok: true; readonly status: "committed" | "unchanged"; readonly enabled: boolean }
34
+ | {
35
+ readonly ok: false;
36
+ readonly reason: "missing" | "invalid" | "conflict";
37
+ readonly retryable: boolean;
38
+ readonly message: string;
39
+ };
40
+
41
+ /**
42
+ * Is native Codex integration wanted?
43
+ *
44
+ * Takes the config rather than reading it, so a caller that already holds an
45
+ * admitted snapshot cannot accidentally answer from a fresher one — the whole
46
+ * point of admission is that one decision uses one set of bytes.
47
+ */
48
+ export function integrationEnabled(
49
+ config: Pick<OcxConfig, "clientIntegrations">,
50
+ client: DurableIntentClientId,
51
+ ): boolean {
52
+ return config.clientIntegrations?.[client] !== false;
53
+ }
54
+
55
+ export function codexIntegrationEnabled(config: Pick<OcxConfig, "clientIntegrations">): boolean {
56
+ return integrationEnabled(config, "codex");
57
+ }
58
+
59
+ /**
60
+ * Grok's toggle SHIPPED without this, which is the bug: it strips the fence in
61
+ * `~/.grok/config.toml` and records nothing, so the next `ocx start` calls
62
+ * `syncGrokConfig` unconditionally and writes the fence straight back.
63
+ */
64
+ export function grokIntegrationEnabled(config: Pick<OcxConfig, "clientIntegrations">): boolean {
65
+ return integrationEnabled(config, "grok");
66
+ }
67
+
68
+ /** The same question when no snapshot is in hand. Reads the persisted config. */
69
+ export function codexIntegrationEnabledNow(): boolean {
70
+ return codexIntegrationEnabled(loadConfig());
71
+ }
72
+
73
+ /**
74
+ * Persist the desired state, touching only that one field.
75
+ *
76
+ * Field-scoped on purpose: the callback runs on a freshly rebased snapshot inside
77
+ * the config coordinator, so a concurrent provider or model edit is preserved
78
+ * instead of being clobbered by a whole-config write built from a stale read.
79
+ */
80
+ export function setIntegrationEnabled(
81
+ client: DurableIntentClientId,
82
+ enabled: boolean,
83
+ ): CodexDesiredStateResult {
84
+ const outcome = mutatePersistedConfig(config => {
85
+ const current = integrationEnabled(config, client);
86
+ if (current === enabled) return { changed: false, value: enabled };
87
+ const integrations = { ...(config.clientIntegrations ?? {}) };
88
+ if (enabled) {
89
+ // ON is the absence, not a stored `true`: writing `true` would make an
90
+ // untouched config and a re-enabled one differ in bytes for no reason, and
91
+ // every later reader has to treat them identically anyway.
92
+ delete integrations[client];
93
+ } else {
94
+ integrations[client] = false;
95
+ }
96
+ // Drop the key entirely once nothing is left in it, so enabling twice does
97
+ // not leave `"clientIntegrations": {}` behind in the user's file.
98
+ if (Object.keys(integrations).length === 0) delete config.clientIntegrations;
99
+ else config.clientIntegrations = integrations;
100
+ return { changed: true, value: enabled };
101
+ });
102
+
103
+ if (outcome.status !== "unavailable") {
104
+ return { ok: true, status: outcome.status, enabled };
105
+ }
106
+ // `conflict` is the only retryable one: it means a competing writer won the
107
+ // rebase, so the same call can succeed. A missing or malformed config will fail
108
+ // identically forever, and telling a caller to retry that is how a UI ends up
109
+ // spinning on a problem only the user can fix.
110
+ const retryable = outcome.reason === "conflict";
111
+ return {
112
+ ok: false,
113
+ reason: outcome.reason,
114
+ retryable,
115
+ message: outcome.reason === "conflict"
116
+ ? "Another process changed the config while this switch was being written."
117
+ : outcome.reason === "missing"
118
+ ? "No config file exists to record the switch in."
119
+ : "The config file is malformed; refusing to overwrite it.",
120
+ };
121
+ }
122
+
123
+ export function setCodexIntegrationEnabled(enabled: boolean): CodexDesiredStateResult {
124
+ return setIntegrationEnabled("codex", enabled);
125
+ }
126
+
127
+ export function setGrokIntegrationEnabled(enabled: boolean): CodexDesiredStateResult {
128
+ return setIntegrationEnabled("grok", enabled);
129
+ }
130
+
131
+ /**
132
+ * The startup gate, as a function rather than an `if` buried in `handleStart`.
133
+ *
134
+ * `ocx start` used to call `syncModelsToCodex(port).catch(() => {})`
135
+ * unconditionally, which is exactly why turning Codex off lasted until the next
136
+ * start: the restore worked, and then start put the routing straight back. It
137
+ * lived inline in a 600-line startup function that opens sockets and installs
138
+ * services, so nothing could test it — and an untestable gate is how the
139
+ * unconditional version survived this long.
140
+ *
141
+ * The `.catch` is deliberate and stays: a provider fetch failing at startup must
142
+ * not stop the proxy from coming up. Swallowing a failure to APPLY is tolerable.
143
+ * Swallowing the user's decision was not.
144
+ *
145
+ * Returns whether the sync ran, so a caller — or a test — can tell "skipped
146
+ * because the user turned it off" from "ran and quietly failed".
147
+ */
148
+ export async function syncCodexOnStartIfEnabled(
149
+ port: number,
150
+ config: Pick<OcxConfig, "clientIntegrations">,
151
+ sync: CodexStartupSync = defaultStartupSync,
152
+ ): Promise<boolean> {
153
+ if (!codexIntegrationEnabled(config)) return false;
154
+ await sync(port).catch(() => {});
155
+ return true;
156
+ }
157
+
158
+ async function defaultStartupSync(port: number): Promise<unknown> {
159
+ const { syncModelsToCodex } = await import("./sync");
160
+ return syncModelsToCodex(port);
161
+ }
162
+
163
+ /**
164
+ * The Grok startup gate.
165
+ *
166
+ * Grok's toggle shipped and then `ocx start` called `syncGrokConfig`
167
+ * unconditionally, so switching Grok off lasted exactly one restart — the fence
168
+ * came out of `~/.grok/config.toml` and the next start wrote it straight back.
169
+ * Same defect as Codex had, in a different file.
170
+ *
171
+ * The caller keeps its own try/catch, because a Grok failure must never block
172
+ * startup and its diagnostic is worth printing. This only answers whether to
173
+ * attempt the sync at all.
174
+ */
175
+ export function shouldSyncGrokOnStart(config: Pick<OcxConfig, "clientIntegrations">): boolean {
176
+ return grokIntegrationEnabled(config);
177
+ }