@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
@@ -34,28 +34,121 @@ import upstreamModelsSnapshot from "../data/upstream-models.json";
34
34
  import { activeCodexModelsCachePath, catalogBackupPathFor, findNativeTemplate, isDefaultCatalogPath, legacyCatalogBackupPath, parseCatalogJson, readCatalog, readCatalogBackup, readCodexCatalogPath } from "./parsing";
35
35
  import type { RawCatalog, RawEntry } from "./parsing";
36
36
  import { codexExecInvocation, isSpawnableCodexCandidate } from "../exec-invocation";
37
- import { resolveAndPersistCodexRuntime } from "../runtime";
38
- import type { EffortClampDiagnostic } from "../runtime";
37
+ import {
38
+ parsePersistedCodexRuntime,
39
+ peekCodexRuntimeProcessCache,
40
+ resolveAndPersistCodexRuntime,
41
+ } from "../runtime";
42
+ import type {
43
+ DeepReadonly,
44
+ EffortClampDiagnostic,
45
+ ResolvedCodexRuntime,
46
+ } from "../runtime";
47
+ import type {
48
+ CatalogGatherEvidenceSession,
49
+ CatalogGatherReadableSourceRole,
50
+ } from "./filesystem-evidence";
39
51
 
40
52
  export { isSpawnableCodexCandidate, codexExecInvocation } from "../exec-invocation";
53
+ export type {
54
+ CatalogGatherEvidenceSession,
55
+ CatalogGatherReadableSourceRole,
56
+ } from "./filesystem-evidence";
41
57
 
42
58
  export const BUNDLED_CATALOG_CACHE_MS = 60_000;
43
59
 
44
- export let bundledCatalogCache: {
60
+ export type ReadonlyRawCatalog = DeepReadonly<RawCatalog>;
61
+
62
+ interface BundledCatalogMemo {
45
63
  /** Selected runtime identity; must change when doctor/sync picks a different binary. */
46
- key: string;
47
- expiresAt: number;
48
- value: RawCatalog | null;
49
- } | null = null;
64
+ readonly key: string;
65
+ readonly expiresAt: number;
66
+ readonly epoch: number;
67
+ readonly valueIdentity: string;
68
+ readonly value: ReadonlyRawCatalog | null;
69
+ }
70
+
71
+ export interface BundledCatalogCacheState {
72
+ readonly epoch: number;
73
+ readonly valueIdentity: string | null;
74
+ }
75
+
76
+ let bundledCatalogEpoch = 0;
77
+ let bundledCatalogCache: BundledCatalogMemo | null = null;
78
+
79
+ function cloneAndDeepFreeze<T>(value: T): DeepReadonly<T> {
80
+ const clone = (current: unknown): unknown => {
81
+ if (Array.isArray(current)) return current.map(clone);
82
+ if (current && typeof current === "object") {
83
+ const out: Record<string, unknown> = {};
84
+ for (const [key, child] of Object.entries(current)) out[key] = clone(child);
85
+ return out;
86
+ }
87
+ return current;
88
+ };
89
+ const freeze = (current: unknown): unknown => {
90
+ if (!current || typeof current !== "object" || Object.isFrozen(current)) return current;
91
+ for (const child of Object.values(current)) freeze(child);
92
+ return Object.freeze(current);
93
+ };
94
+ return freeze(clone(value)) as DeepReadonly<T>;
95
+ }
96
+
97
+ function bundledRuntimeKey(
98
+ runtime: Pick<ResolvedCodexRuntime, "command" | "version">,
99
+ opencodexHome: string = process.env.OPENCODEX_HOME ?? "",
100
+ ): string {
101
+ return [runtime.command, runtime.version ?? "", opencodexHome].join("\0");
102
+ }
103
+
104
+ function publishBundledCatalogCache(
105
+ key: string,
106
+ expiresAt: number,
107
+ value: RawCatalog | null,
108
+ ): void {
109
+ const epoch = ++bundledCatalogEpoch;
110
+ bundledCatalogCache = {
111
+ key,
112
+ expiresAt,
113
+ epoch,
114
+ valueIdentity: `bundled:${epoch}`,
115
+ value: value === null ? null : cloneAndDeepFreeze(value),
116
+ };
117
+ }
118
+
119
+ function clearBundledCatalogCache(): void {
120
+ bundledCatalogEpoch += 1;
121
+ bundledCatalogCache = null;
122
+ }
123
+
124
+ export function bundledCatalogCacheState(): Readonly<BundledCatalogCacheState> {
125
+ return Object.freeze({
126
+ epoch: bundledCatalogEpoch,
127
+ valueIdentity: bundledCatalogCache?.valueIdentity ?? null,
128
+ });
129
+ }
50
130
 
51
131
  /** Test-only: clear the bundled-catalog cache (owned here; sync.ts calls this instead of assigning the import). */
52
132
  export function resetBundledCatalogCacheForTests(): void {
53
- bundledCatalogCache = null;
133
+ clearBundledCatalogCache();
54
134
  }
55
135
 
56
136
  /** Drop the process-local bundled catalog memo (e.g. after runtime selection changes). */
57
137
  export function invalidateBundledCatalogCache(): void {
58
- bundledCatalogCache = null;
138
+ clearBundledCatalogCache();
139
+ }
140
+
141
+ /** Test-only owner mutation seam; input is cloned and frozen before publication. */
142
+ export function setBundledCatalogCacheForTests(
143
+ runtime: Pick<ResolvedCodexRuntime, "command" | "version">,
144
+ value: RawCatalog | null,
145
+ options: Readonly<{ expiresAt?: number; opencodexHome?: string }> = {},
146
+ ): void {
147
+ publishBundledCatalogCache(
148
+ bundledRuntimeKey(runtime, options.opencodexHome),
149
+ options.expiresAt ?? Date.now() + BUNDLED_CATALOG_CACHE_MS,
150
+ value,
151
+ );
59
152
  }
60
153
 
61
154
  export type ExecFile = (
@@ -143,7 +236,7 @@ export function runCodexDebugModels(
143
236
  });
144
237
  }
145
238
 
146
- export function loadBundledCodexCatalog(deps: BundledCatalogDeps = {}): RawCatalog | null {
239
+ export function loadBundledCodexCatalog(deps: BundledCatalogDeps = {}): ReadonlyRawCatalog | null {
147
240
  const useCache = !deps.commandCandidates && !deps.execFileSync && !deps.configDir && !deps.env;
148
241
  const execFile = deps.execFileSync ?? (execFileSync as unknown as ExecFile);
149
242
  // Prefer the single resolved runtime so sync/clamp never probe a different binary
@@ -168,11 +261,7 @@ export function loadBundledCodexCatalog(deps: BundledCatalogDeps = {}): RawCatal
168
261
  discoverAlternatives: deps.discoverAlternatives ?? false,
169
262
  });
170
263
  if (useCache) {
171
- cacheKey = [
172
- resolved.runtime.command,
173
- resolved.runtime.version ?? "",
174
- process.env.OPENCODEX_HOME ?? "",
175
- ].join("\0");
264
+ cacheKey = bundledRuntimeKey(resolved.runtime);
176
265
  }
177
266
  return [resolved.runtime.command];
178
267
  })();
@@ -183,33 +272,193 @@ export function loadBundledCodexCatalog(deps: BundledCatalogDeps = {}): RawCatal
183
272
  && bundledCatalogCache.key === cacheKey
184
273
  && bundledCatalogCache.expiresAt > Date.now()
185
274
  ) {
186
- return bundledCatalogCache.value;
275
+ return bundledCatalogCache.value === null
276
+ ? null
277
+ : cloneAndDeepFreeze(bundledCatalogCache.value);
187
278
  }
188
279
  for (const command of unique(candidates)) {
189
280
  try {
190
281
  const catalog = parseCatalogJson(runCodexDebugModels(command, execFile, deps));
191
282
  if (catalog && findNativeTemplate(catalog)) {
192
283
  if (useCache && cacheKey) {
193
- bundledCatalogCache = {
194
- key: cacheKey,
195
- expiresAt: Date.now() + BUNDLED_CATALOG_CACHE_MS,
196
- value: catalog,
197
- };
284
+ publishBundledCatalogCache(
285
+ cacheKey,
286
+ Date.now() + BUNDLED_CATALOG_CACHE_MS,
287
+ catalog,
288
+ );
289
+ return cloneAndDeepFreeze(bundledCatalogCache!.value!);
198
290
  }
199
- return catalog;
291
+ return cloneAndDeepFreeze(catalog);
200
292
  }
201
293
  } catch { /* try next candidate */ }
202
294
  }
203
295
  if (useCache && cacheKey) {
204
- bundledCatalogCache = {
205
- key: cacheKey,
206
- expiresAt: Date.now() + BUNDLED_CATALOG_CACHE_MS,
207
- value: null,
208
- };
296
+ publishBundledCatalogCache(
297
+ cacheKey,
298
+ Date.now() + BUNDLED_CATALOG_CACHE_MS,
299
+ null,
300
+ );
209
301
  }
210
302
  return null;
211
303
  }
212
304
 
305
+ export type CatalogGatherProcessLocalObservation =
306
+ | Readonly<{ state: "unused" }>
307
+ | Readonly<{ state: "used"; epoch: number; valueIdentity: string }>;
308
+
309
+ export type CodexRuntimeForCatalogGather =
310
+ | Readonly<{
311
+ kind: "available";
312
+ origin: "process-cache" | "persisted";
313
+ runtime: DeepReadonly<ResolvedCodexRuntime>;
314
+ processLocal: CatalogGatherProcessLocalObservation;
315
+ }>
316
+ | Readonly<{
317
+ kind: "runtime-unavailable";
318
+ processLocal: Readonly<{ state: "unused" }>;
319
+ }>;
320
+
321
+ export type CatalogSourceForGather =
322
+ | Readonly<{
323
+ kind: "available";
324
+ source:
325
+ | "bundled-catalog-template"
326
+ | "active-catalog-merge"
327
+ | "hashed-backup-fallback"
328
+ | "legacy-backup-fallback"
329
+ | "models-cache-fallback";
330
+ catalog: ReadonlyRawCatalog;
331
+ processLocal: Readonly<{
332
+ runtime: CatalogGatherProcessLocalObservation;
333
+ bundledCatalog: CatalogGatherProcessLocalObservation;
334
+ }>;
335
+ }>
336
+ | Readonly<{
337
+ kind: "catalog-unavailable";
338
+ processLocal: Readonly<{
339
+ runtime: Readonly<{ state: "unused" }>;
340
+ bundledCatalog: Readonly<{ state: "unused" }>;
341
+ }>;
342
+ }>;
343
+
344
+ const UNUSED_PROCESS_LOCAL = Object.freeze({ state: "unused" as const });
345
+
346
+ function sameRuntimeIdentity(
347
+ left: Pick<ResolvedCodexRuntime, "command" | "version">,
348
+ right: Pick<ResolvedCodexRuntime, "command" | "version">,
349
+ ): boolean {
350
+ return left.command === right.command && (left.version ?? null) === (right.version ?? null);
351
+ }
352
+
353
+ /**
354
+ * Observe an already-resolved runtime only. The evidence owner supplies the
355
+ * exact persisted bytes (or observed absence); this path never probes or writes.
356
+ */
357
+ export function peekCodexRuntimeForCatalogGather(
358
+ evidenceSession: CatalogGatherEvidenceSession,
359
+ ): CodexRuntimeForCatalogGather {
360
+ const persistedBytes = evidenceSession.readSource("runtime-selection");
361
+ const persisted = persistedBytes === null
362
+ ? null
363
+ : parsePersistedCodexRuntime(persistedBytes);
364
+ const processMemo = peekCodexRuntimeProcessCache();
365
+
366
+ if (processMemo.kind === "available") {
367
+ const runtime = processMemo.value.runtime;
368
+ if (persistedBytes === null || (persisted && sameRuntimeIdentity(runtime, {
369
+ command: persisted.command,
370
+ version: persisted.selectedVersion ?? null,
371
+ }))) {
372
+ return cloneAndDeepFreeze({
373
+ kind: "available" as const,
374
+ origin: "process-cache" as const,
375
+ runtime,
376
+ processLocal: {
377
+ state: "used" as const,
378
+ epoch: processMemo.epoch,
379
+ valueIdentity: processMemo.valueIdentity,
380
+ },
381
+ });
382
+ }
383
+ }
384
+
385
+ if (persisted) {
386
+ return cloneAndDeepFreeze({
387
+ kind: "available" as const,
388
+ origin: "persisted" as const,
389
+ runtime: {
390
+ command: persisted.command,
391
+ version: persisted.selectedVersion ?? null,
392
+ source: persisted.source,
393
+ },
394
+ processLocal: UNUSED_PROCESS_LOCAL,
395
+ });
396
+ }
397
+
398
+ return Object.freeze({
399
+ kind: "runtime-unavailable" as const,
400
+ processLocal: UNUSED_PROCESS_LOCAL,
401
+ });
402
+ }
403
+
404
+ /**
405
+ * Resolve only already-observed catalog sources. A cold process cache falls
406
+ * through to evidence-owned persisted sources and never becomes probe authority.
407
+ */
408
+ export function resolveCatalogSourceForGather(
409
+ evidenceSession: CatalogGatherEvidenceSession,
410
+ ): CatalogSourceForGather {
411
+ const bundledMemo = bundledCatalogCache;
412
+ if (bundledMemo?.value && bundledMemo.expiresAt > Date.now()) {
413
+ const runtime = peekCodexRuntimeForCatalogGather(evidenceSession);
414
+ if (runtime.kind === "available" && bundledMemo.key === bundledRuntimeKey(runtime.runtime)) {
415
+ return cloneAndDeepFreeze({
416
+ kind: "available" as const,
417
+ source: "bundled-catalog-template" as const,
418
+ catalog: bundledMemo.value,
419
+ processLocal: {
420
+ runtime: runtime.processLocal,
421
+ bundledCatalog: {
422
+ state: "used" as const,
423
+ epoch: bundledMemo.epoch,
424
+ valueIdentity: bundledMemo.valueIdentity,
425
+ },
426
+ },
427
+ });
428
+ }
429
+ }
430
+
431
+ const roles = [
432
+ "active-catalog-merge",
433
+ "hashed-backup-fallback",
434
+ "legacy-backup-fallback",
435
+ "models-cache-fallback",
436
+ ] as const;
437
+ for (const role of roles) {
438
+ const bytes = evidenceSession.readSource(role);
439
+ if (bytes === null) continue;
440
+ const catalog = parseCatalogJson(Buffer.from(bytes).toString("utf8"));
441
+ if (!catalog || !findNativeTemplate(catalog)) continue;
442
+ return cloneAndDeepFreeze({
443
+ kind: "available" as const,
444
+ source: role,
445
+ catalog,
446
+ processLocal: {
447
+ runtime: UNUSED_PROCESS_LOCAL,
448
+ bundledCatalog: UNUSED_PROCESS_LOCAL,
449
+ },
450
+ });
451
+ }
452
+
453
+ return Object.freeze({
454
+ kind: "catalog-unavailable" as const,
455
+ processLocal: Object.freeze({
456
+ runtime: UNUSED_PROCESS_LOCAL,
457
+ bundledCatalog: UNUSED_PROCESS_LOCAL,
458
+ }),
459
+ });
460
+ }
461
+
213
462
  export function materializeBundledCodexCatalog(path: string, deps: BundledCatalogDeps = {}): RawCatalog | null {
214
463
  const catalog = loadBundledCodexCatalog(deps);
215
464
  if (!catalog) return null;
@@ -219,7 +468,7 @@ export function materializeBundledCodexCatalog(path: string, deps: BundledCatalo
219
468
  } catch {
220
469
  return null;
221
470
  }
222
- return catalog;
471
+ return JSON.parse(JSON.stringify(catalog)) as RawCatalog;
223
472
  }
224
473
 
225
474
  export function loadCatalogForSync(path: string): RawCatalog | null {
@@ -236,16 +485,17 @@ export function loadCatalogForSync(path: string): RawCatalog | null {
236
485
 
237
486
  export function readCurrentCatalogOrCache(): RawCatalog | null {
238
487
  const path = readCodexCatalogPath();
239
- return (isDefaultCatalogPath(path) ? loadBundledCodexCatalog() : null)
240
- ?? readCatalog(path)
241
- ?? readCatalog(activeCodexModelsCachePath());
488
+ const bundled = isDefaultCatalogPath(path) ? loadBundledCodexCatalog() : null;
489
+ if (bundled) return JSON.parse(JSON.stringify(bundled)) as RawCatalog;
490
+ return readCatalog(path) ?? readCatalog(activeCodexModelsCachePath());
242
491
  }
243
492
 
244
493
  export function loadCatalogTemplate(): RawEntry | null {
245
494
  const catalogPath = readCodexCatalogPath();
495
+ const bundled = loadBundledCodexCatalog();
246
496
  const native = findNativeTemplate(readCatalog(catalogPath))
247
497
  ?? findNativeTemplate(readCatalogBackup(catalogPath))
248
498
  ?? findNativeTemplate(readCatalog(activeCodexModelsCachePath()))
249
- ?? findNativeTemplate(loadBundledCodexCatalog());
499
+ ?? findNativeTemplate(bundled ? JSON.parse(JSON.stringify(bundled)) as RawCatalog : null);
250
500
  return native ? JSON.parse(JSON.stringify(native)) : null;
251
501
  }
@@ -0,0 +1,302 @@
1
+ import { createHash } from "node:crypto";
2
+ import { readFileSync, realpathSync, statSync } from "node:fs";
3
+ import { basename, dirname, resolve } from "node:path";
4
+
5
+ import { expandUserPath } from "../../config";
6
+ import type {
7
+ CatalogConditionalSourceObservations,
8
+ CatalogConditionalSourceRole,
9
+ CatalogFilesystemIdentity,
10
+ CatalogHomeSelectionObservation,
11
+ CatalogRequiredSourceObservations,
12
+ CatalogRequiredSourceRole,
13
+ CatalogSourceEvidence,
14
+ CatalogSourceObservation,
15
+ CatalogSourceRole,
16
+ } from "../convergence-types";
17
+ import { defaultCodexHome } from "../home";
18
+
19
+ export type CatalogGatherReadableSourceRole =
20
+ | "active-catalog-merge"
21
+ | "hashed-backup-fallback"
22
+ | "legacy-backup-fallback"
23
+ | "models-cache-fallback"
24
+ | "runtime-selection";
25
+
26
+ /** The minimal observe-only source interface consumed by bundled catalog selection. */
27
+ export interface CatalogGatherEvidenceSession {
28
+ readSource(role: CatalogGatherReadableSourceRole): Uint8Array | null;
29
+ }
30
+
31
+ /** The exact observe-only auth input consumed by provider discovery. */
32
+ export interface CatalogGatherProviderAuthEvidence {
33
+ readonly authStoreBuffer: Uint8Array | null;
34
+ }
35
+
36
+ export type CatalogFilesystemEvidenceSession = CatalogGatherEvidenceSession
37
+ & CatalogGatherProviderAuthEvidence;
38
+
39
+ const CONDITIONAL_SOURCE_ROLES = [
40
+ "bundled-catalog-template",
41
+ "active-catalog-merge",
42
+ "hashed-backup-fallback",
43
+ "legacy-backup-fallback",
44
+ "models-cache-fallback",
45
+ "native-catalog-selection",
46
+ "runtime-selection",
47
+ "provider-auth-selection",
48
+ ] as const satisfies readonly CatalogConditionalSourceRole[];
49
+
50
+ interface MutableSessionState {
51
+ homeSelection: CatalogHomeSelectionObservation | null;
52
+ readonly sourcePaths: Map<CatalogSourceRole, string>;
53
+ requiredTargetSelection: CatalogSourceObservation<CatalogRequiredSourceRole> | null;
54
+ readonly conditional: Record<CatalogConditionalSourceRole, CatalogSourceObservation[]>;
55
+ sealed: boolean;
56
+ }
57
+
58
+ const sessionStates = new WeakMap<object, MutableSessionState>();
59
+
60
+ function filesystemIdentity(
61
+ entry: Readonly<{ dev: bigint; ino: bigint }>,
62
+ ): CatalogFilesystemIdentity {
63
+ return Object.freeze({ volume: String(entry.dev), fileId: String(entry.ino) });
64
+ }
65
+
66
+ function sessionState(session: CatalogFilesystemEvidenceSession): MutableSessionState {
67
+ const state = sessionStates.get(session);
68
+ if (!state) {
69
+ throw new TypeError("Catalog filesystem evidence session was not created by its owner.");
70
+ }
71
+ return state;
72
+ }
73
+
74
+ function assertOpen(state: MutableSessionState): void {
75
+ if (state.sealed) throw new Error("Catalog filesystem evidence session is already sealed.");
76
+ }
77
+
78
+ function observedHomeSelection(): CatalogHomeSelectionObservation {
79
+ const environmentSelector = process.env.CODEX_HOME?.trim();
80
+ const selector = environmentSelector
81
+ ? { kind: "environment" as const, raw: environmentSelector }
82
+ : { kind: "default" as const, raw: defaultCodexHome() };
83
+ const selectedPath = selector.kind === "environment"
84
+ ? resolve(expandUserPath(selector.raw))
85
+ : resolve(selector.raw);
86
+ const canonicalCodexHome = realpathSync.native(selectedPath);
87
+ const root = statSync(canonicalCodexHome, { bigint: true });
88
+ if (!root.isDirectory()) {
89
+ throw new Error(`Codex home is not a directory: ${selectedPath}`);
90
+ }
91
+ return Object.freeze({
92
+ selector: Object.freeze(selector),
93
+ canonicalCodexHome,
94
+ rootIdentity: filesystemIdentity(root),
95
+ });
96
+ }
97
+
98
+ /** Capture and freeze the selector/root authority before any derived path is admitted. */
99
+ export function captureAndSealCatalogHomeSelection(
100
+ session: CatalogFilesystemEvidenceSession,
101
+ ): CatalogHomeSelectionObservation {
102
+ const state = sessionState(session);
103
+ assertOpen(state);
104
+ if (state.homeSelection) return state.homeSelection;
105
+ state.homeSelection = observedHomeSelection();
106
+ return state.homeSelection;
107
+ }
108
+
109
+ /** Bind the next consultation for a closed role without exposing the evidence arrays. */
110
+ export function acceptCatalogGatherSourcePath(
111
+ session: CatalogFilesystemEvidenceSession,
112
+ role: CatalogSourceRole,
113
+ logicalPath: string,
114
+ ): void {
115
+ const state = sessionState(session);
116
+ assertOpen(state);
117
+ if (!state.homeSelection) {
118
+ throw new Error("Catalog home selection must be sealed before accepting a derived path.");
119
+ }
120
+ state.sourcePaths.set(role, resolve(logicalPath));
121
+ }
122
+
123
+ function isMissing(error: unknown): boolean {
124
+ return Boolean(error && typeof error === "object" && "code" in error && error.code === "ENOENT");
125
+ }
126
+
127
+ function observeSource<R extends CatalogSourceRole>(
128
+ role: R,
129
+ logicalPath: string,
130
+ ): Readonly<{ bytes: Uint8Array | null; observation: CatalogSourceObservation<R> }> {
131
+ const canonicalParent = realpathSync.native(dirname(logicalPath));
132
+ const parent = statSync(canonicalParent, { bigint: true });
133
+ if (!parent.isDirectory()) {
134
+ throw new Error(`Catalog source parent is not a directory: ${dirname(logicalPath)}`);
135
+ }
136
+ const parentIdentity = Object.freeze({
137
+ canonicalPath: canonicalParent,
138
+ ...filesystemIdentity(parent),
139
+ });
140
+
141
+ let bytes: Uint8Array;
142
+ try {
143
+ bytes = Uint8Array.from(readFileSync(logicalPath));
144
+ } catch (error) {
145
+ if (!isMissing(error)) throw error;
146
+ return Object.freeze({
147
+ bytes: null,
148
+ observation: Object.freeze({
149
+ state: "absent" as const,
150
+ role,
151
+ logicalPath,
152
+ canonicalPath: resolve(canonicalParent, basename(logicalPath)),
153
+ parentIdentity,
154
+ fileIdentity: null,
155
+ }),
156
+ });
157
+ }
158
+
159
+ const canonicalPath = realpathSync.native(logicalPath);
160
+ const file = statSync(canonicalPath, { bigint: true });
161
+ if (!file.isFile()) throw new Error(`Catalog source is not a regular file: ${logicalPath}`);
162
+ return Object.freeze({
163
+ bytes,
164
+ observation: Object.freeze({
165
+ state: "present" as const,
166
+ role,
167
+ logicalPath,
168
+ canonicalPath,
169
+ parentIdentity,
170
+ fileIdentity: filesystemIdentity(file),
171
+ sha256: createHash("sha256").update(bytes).digest("hex"),
172
+ }),
173
+ });
174
+ }
175
+
176
+ /** Read once, record PRESENT/ABSENT first, then return the exact hashed buffer. */
177
+ export function readCatalogGatherSource<R extends CatalogSourceRole>(
178
+ session: CatalogFilesystemEvidenceSession,
179
+ role: R,
180
+ ): Uint8Array | null {
181
+ const state = sessionState(session);
182
+ assertOpen(state);
183
+ const logicalPath = state.sourcePaths.get(role);
184
+ if (!logicalPath) throw new Error(`No catalog source path was accepted for role ${role}.`);
185
+ if (role === "catalog-target-selection" && state.requiredTargetSelection) {
186
+ throw new Error(`Required catalog source role ${role} was already observed.`);
187
+ }
188
+
189
+ const observed = observeSource(role, logicalPath);
190
+ if (role === "catalog-target-selection") {
191
+ state.requiredTargetSelection = observed.observation as CatalogSourceObservation<"catalog-target-selection">;
192
+ } else {
193
+ state.conditional[role as CatalogConditionalSourceRole].push(observed.observation);
194
+ }
195
+ return observed.bytes;
196
+ }
197
+
198
+ function optionalFileIdentity(path: string): Readonly<{ device: string; inode: string }> | null {
199
+ try {
200
+ const entry = statSync(path, { bigint: true });
201
+ return { device: String(entry.dev), inode: String(entry.ino) };
202
+ } catch (error) {
203
+ if (isMissing(error)) return null;
204
+ throw error;
205
+ }
206
+ }
207
+
208
+ /** Probe a candidate write target without allowing filesystem mutation. */
209
+ export function captureCatalogGatherTargetIdentity(
210
+ session: CatalogFilesystemEvidenceSession,
211
+ path: string,
212
+ ): string {
213
+ const state = sessionState(session);
214
+ assertOpen(state);
215
+ if (!state.homeSelection) {
216
+ throw new Error("Catalog home selection must be sealed before accepting a derived path.");
217
+ }
218
+ const textualPath = resolve(path);
219
+ const canonicalParent = realpathSync.native(dirname(textualPath));
220
+ const parent = statSync(canonicalParent, { bigint: true });
221
+ return JSON.stringify({
222
+ path: textualPath,
223
+ canonicalParent,
224
+ parentIdentity: { device: String(parent.dev), inode: String(parent.ino) },
225
+ fileIdentity: optionalFileIdentity(textualPath),
226
+ });
227
+ }
228
+
229
+ function cloneObservation<R extends CatalogSourceRole>(
230
+ observation: CatalogSourceObservation<R>,
231
+ ): CatalogSourceObservation<R> {
232
+ if (observation.state === "absent") {
233
+ return Object.freeze({
234
+ ...observation,
235
+ parentIdentity: Object.freeze({ ...observation.parentIdentity }),
236
+ fileIdentity: null,
237
+ });
238
+ }
239
+ return Object.freeze({
240
+ ...observation,
241
+ parentIdentity: Object.freeze({ ...observation.parentIdentity }),
242
+ fileIdentity: Object.freeze({ ...observation.fileIdentity }),
243
+ });
244
+ }
245
+
246
+ /** Seal one complete owner-created session into detached, recursively frozen evidence. */
247
+ export function sealCatalogGatherEvidenceSession(
248
+ session: CatalogFilesystemEvidenceSession,
249
+ ): CatalogSourceEvidence {
250
+ const state = sessionState(session);
251
+ assertOpen(state);
252
+ if (!state.homeSelection) throw new Error("Catalog home selection is required before sealing.");
253
+ const targetSelection = state.requiredTargetSelection;
254
+ if (!targetSelection) {
255
+ throw new Error("Required catalog source role catalog-target-selection was not observed.");
256
+ }
257
+ for (const role of CONDITIONAL_SOURCE_ROLES) {
258
+ if (!Object.hasOwn(state.conditional, role)) {
259
+ throw new Error(`Conditional catalog source role ${role} is missing.`);
260
+ }
261
+ }
262
+
263
+ const conditional = Object.fromEntries(CONDITIONAL_SOURCE_ROLES.map(role => [
264
+ role,
265
+ Object.freeze(state.conditional[role].map(observation => cloneObservation(observation))),
266
+ ])) as unknown as CatalogConditionalSourceObservations;
267
+ const required = Object.freeze({
268
+ "catalog-target-selection": cloneObservation(targetSelection),
269
+ }) as CatalogRequiredSourceObservations;
270
+ const evidence = Object.freeze({
271
+ homeSelection: Object.freeze({
272
+ ...state.homeSelection,
273
+ selector: Object.freeze({ ...state.homeSelection.selector }),
274
+ rootIdentity: Object.freeze({ ...state.homeSelection.rootIdentity }),
275
+ }),
276
+ required,
277
+ conditional: Object.freeze(conditional),
278
+ });
279
+ state.sealed = true;
280
+ return evidence;
281
+ }
282
+
283
+ /** Create an opaque session; all mutable evidence state remains module-private. */
284
+ export function createCatalogGatherEvidenceSession(): CatalogFilesystemEvidenceSession {
285
+ let session: CatalogFilesystemEvidenceSession;
286
+ session = Object.freeze({
287
+ readSource: (role: CatalogGatherReadableSourceRole) => readCatalogGatherSource(session, role),
288
+ get authStoreBuffer(): Uint8Array | null {
289
+ return readCatalogGatherSource(session, "provider-auth-selection");
290
+ },
291
+ });
292
+ sessionStates.set(session, {
293
+ homeSelection: null,
294
+ sourcePaths: new Map(),
295
+ requiredTargetSelection: null,
296
+ conditional: Object.fromEntries(
297
+ CONDITIONAL_SOURCE_ROLES.map(role => [role, [] as CatalogSourceObservation[]]),
298
+ ) as unknown as MutableSessionState["conditional"],
299
+ sealed: false,
300
+ });
301
+ return session;
302
+ }