@iislee/opencodex 2.31.0 → 2.35.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (261) hide show
  1. package/README.md +3 -3
  2. package/bin/ocx.mjs +99 -70
  3. package/gui/dist/assets/index-Be1sDpWw.css +1 -0
  4. package/gui/dist/assets/index-D4OKgUV4.js +114 -0
  5. package/gui/dist/index.html +2 -2
  6. package/package.json +3 -1
  7. package/src/adapters/anthropic.ts +104 -15
  8. package/src/adapters/base.ts +29 -1
  9. package/src/adapters/command-code.ts +42 -6
  10. package/src/adapters/cursor/call-id.ts +44 -0
  11. package/src/adapters/cursor/catalog.ts +541 -0
  12. package/src/adapters/cursor/checkpoint-store.ts +15 -10
  13. package/src/adapters/cursor/cursor-errors.ts +15 -0
  14. package/src/adapters/cursor/discovery.ts +69 -18
  15. package/src/adapters/cursor/effort-map.ts +79 -1
  16. package/src/adapters/cursor/envelope-echo.ts +290 -0
  17. package/src/adapters/cursor/live-models.ts +7 -2
  18. package/src/adapters/cursor/live-transport.ts +14 -0
  19. package/src/adapters/cursor/message-mapper.ts +4 -1
  20. package/src/adapters/cursor/native-exec-desktop.ts +23 -0
  21. package/src/adapters/cursor/native-exec-fs.ts +2 -2
  22. package/src/adapters/cursor/native-exec-network.ts +1 -1
  23. package/src/adapters/cursor/native-exec-shell.ts +5 -2
  24. package/src/adapters/cursor/native-exec.ts +20 -0
  25. package/src/adapters/cursor/protobuf-request.ts +68 -14
  26. package/src/adapters/cursor/request-builder.ts +40 -10
  27. package/src/adapters/cursor/tool-definitions.ts +14 -4
  28. package/src/adapters/cursor/tool-result-normalize.ts +28 -5
  29. package/src/adapters/cursor/types.ts +20 -0
  30. package/src/adapters/cursor.ts +209 -38
  31. package/src/adapters/exec-tool-result-normalize.ts +99 -0
  32. package/src/adapters/google-antigravity-replay.ts +173 -25
  33. package/src/adapters/google-antigravity-wire.ts +5 -0
  34. package/src/adapters/google-errors.ts +44 -12
  35. package/src/adapters/google.ts +136 -24
  36. package/src/adapters/image.ts +1 -1
  37. package/src/adapters/kiro-constants.ts +27 -0
  38. package/src/adapters/kiro-tools.ts +55 -16
  39. package/src/adapters/kiro.ts +182 -23
  40. package/src/adapters/openai-chat.ts +300 -242
  41. package/src/adapters/openai-responses.ts +353 -27
  42. package/src/adapters/run-turn-queue.ts +36 -1
  43. package/src/adapters/tool-catalog-nudge.ts +4 -3
  44. package/src/adapters/xai-tool-schema.ts +436 -0
  45. package/src/adapters/xai-web-search.ts +17 -16
  46. package/src/bridge.ts +90 -37
  47. package/src/chat/inbound.ts +14 -1
  48. package/src/chat/outbound.ts +15 -7
  49. package/src/claude/agents-inject.ts +8 -1
  50. package/src/claude/context-windows.ts +16 -9
  51. package/src/claude/outbound.ts +24 -11
  52. package/src/cli/access.ts +46 -3
  53. package/src/cli/account-api.ts +116 -19
  54. package/src/cli/account-extended.ts +262 -29
  55. package/src/cli/account-main.ts +12 -12
  56. package/src/cli/account.ts +69 -15
  57. package/src/cli/agent.ts +8 -1
  58. package/src/cli/alias.ts +66 -0
  59. package/src/cli/capabilities-command.ts +94 -0
  60. package/src/cli/capabilities.ts +496 -0
  61. package/src/cli/claude-desktop.ts +31 -11
  62. package/src/cli/claude.ts +26 -1
  63. package/src/cli/dispatch.ts +256 -28
  64. package/src/cli/doctor.ts +126 -14
  65. package/src/cli/help.ts +17 -6
  66. package/src/cli/index.ts +39 -9
  67. package/src/cli/init.ts +1 -0
  68. package/src/cli/inspect.ts +230 -0
  69. package/src/cli/models-runtime.ts +95 -0
  70. package/src/cli/models.ts +26 -10
  71. package/src/cli/observe.ts +31 -8
  72. package/src/cli/provider-runtime.ts +16 -2
  73. package/src/cli/provider.ts +2 -1
  74. package/src/cli/registry.ts +49 -10
  75. package/src/cli/runtime-api.ts +51 -7
  76. package/src/cli/status.ts +41 -1
  77. package/src/cli/storage.ts +234 -0
  78. package/src/cli/system-command.ts +16 -0
  79. package/src/cli/system-restart-client.ts +1 -1
  80. package/src/cli/usage-report.ts +184 -0
  81. package/src/cli/v2.ts +34 -10
  82. package/src/cli/version-skew.ts +46 -0
  83. package/src/clients/config-export.ts +85 -3
  84. package/src/clients/effective-status.ts +30 -2
  85. package/src/clients/probes/cline.ts +81 -0
  86. package/src/codex/account-label.ts +21 -0
  87. package/src/codex/account-pause.ts +2 -1
  88. package/src/codex/account-priority.ts +3 -2
  89. package/src/codex/app-server-processes.ts +83 -7
  90. package/src/codex/auth-api.ts +50 -10
  91. package/src/codex/auth-context.ts +21 -18
  92. package/src/codex/autostart-health.ts +16 -0
  93. package/src/codex/catalog/aggregation.ts +18 -0
  94. package/src/codex/catalog/effort.ts +18 -3
  95. package/src/codex/catalog/metadata.ts +27 -1
  96. package/src/codex/catalog/native-models.ts +5 -2
  97. package/src/codex/catalog/parsing.ts +72 -27
  98. package/src/codex/catalog/provider-fetch.ts +187 -31
  99. package/src/codex/catalog/sync.ts +131 -5
  100. package/src/codex/catalog.ts +1 -1
  101. package/src/codex/codex-write-lock.ts +3 -1
  102. package/src/codex/convergence-types.ts +1 -1
  103. package/src/codex/convergence.ts +27 -2
  104. package/src/codex/desired-state.ts +2 -2
  105. package/src/codex/desktop-app-restart.ts +355 -0
  106. package/src/codex/history-job.ts +32 -3
  107. package/src/codex/history-manifest.ts +112 -0
  108. package/src/codex/history-migration-guardian.ts +5 -5
  109. package/src/codex/history-provider.ts +825 -247
  110. package/src/codex/history-worker.ts +8 -5
  111. package/src/codex/inject-coordination.ts +83 -0
  112. package/src/codex/inject.ts +63 -22
  113. package/src/codex/injected-marker.ts +1 -1
  114. package/src/codex/internal/history-writer.ts +4 -3
  115. package/src/codex/log-guard/inspect.ts +22 -4
  116. package/src/codex/model-entitlements.ts +9 -2
  117. package/src/codex/native-profile-startup.ts +157 -27
  118. package/src/codex/native-residue.ts +26 -33
  119. package/src/codex/prompt-layers.ts +371 -25
  120. package/src/codex/prompt-text-probe.ts +238 -0
  121. package/src/codex/quota.ts +91 -10
  122. package/src/codex/routing.ts +9 -0
  123. package/src/codex/shim.ts +56 -3
  124. package/src/codex/subagent-model-fallback.ts +104 -25
  125. package/src/codex/transition-state.ts +116 -8
  126. package/src/combos/failover.ts +27 -0
  127. package/src/combos/types.ts +10 -0
  128. package/src/compatibility/index.ts +26 -0
  129. package/src/compatibility/manifest.ts +253 -0
  130. package/src/compatibility/openai-responses.ts +121 -0
  131. package/src/config/atomic-write.ts +219 -0
  132. package/src/config/paths.ts +40 -0
  133. package/src/config/process-state.ts +309 -0
  134. package/src/config/provider-validation.ts +177 -0
  135. package/src/config/rebase-provenance.ts +68 -0
  136. package/src/config.ts +206 -824
  137. package/src/generated/compatibility-version.json +373 -213
  138. package/src/images/loop.ts +5 -4
  139. package/src/images/plan.ts +5 -4
  140. package/src/integrations/ownership-policy.ts +160 -0
  141. package/src/integrations/ownership.ts +46 -2
  142. package/src/integrations/registry.ts +7 -0
  143. package/src/integrations/state.ts +79 -7
  144. package/src/integrations/writer.ts +26 -2
  145. package/src/lab/conformance/fixtures/protocol-v1-cases.json +1 -1
  146. package/src/lab/fabric/producer-child.ts +1 -1
  147. package/src/lib/admin-secrets.ts +24 -0
  148. package/src/lib/bounded-body.ts +14 -2
  149. package/src/lib/config-ownership.ts +20 -0
  150. package/src/lib/errors.ts +36 -3
  151. package/src/lib/package-tree-integrity.ts +101 -0
  152. package/src/lib/process-control.ts +2 -1
  153. package/src/lib/service-secrets.ts +15 -0
  154. package/src/lib/tool-argument-integers.ts +56 -5
  155. package/src/oauth/callback-server.ts +22 -2
  156. package/src/oauth/cline.ts +203 -0
  157. package/src/oauth/generic-account-failover.ts +231 -0
  158. package/src/oauth/health.ts +1 -1
  159. package/src/oauth/index.ts +65 -6
  160. package/src/oauth/key-providers.ts +10 -1
  161. package/src/oauth/kimi.ts +9 -1
  162. package/src/oauth/kiro.ts +45 -0
  163. package/src/oauth/local-token-detect.ts +2 -0
  164. package/src/oauth/open-browser-choice.ts +26 -0
  165. package/src/oauth/store.ts +14 -5
  166. package/src/oauth/types.ts +15 -0
  167. package/src/providers/auto-compact-budget.ts +65 -0
  168. package/src/providers/command-code-efforts.ts +38 -6
  169. package/src/providers/context-cap.ts +4 -3
  170. package/src/providers/default-aliases.ts +65 -0
  171. package/src/providers/derive.ts +28 -0
  172. package/src/providers/fastwire.ts +7 -1
  173. package/src/providers/label.ts +34 -1
  174. package/src/providers/model-presets.ts +119 -0
  175. package/src/providers/new-model-policy.ts +146 -0
  176. package/src/providers/provider-id-rewrite.ts +2 -1
  177. package/src/providers/quota.ts +65 -7
  178. package/src/providers/registry.ts +184 -69
  179. package/src/providers/slug-codec.ts +52 -0
  180. package/src/providers/xai-transport.ts +21 -0
  181. package/src/reasoning-effort.ts +19 -2
  182. package/src/responses/apply-patch-envelope.ts +63 -0
  183. package/src/responses/code-mode-helper-compat.ts +50 -0
  184. package/src/responses/custom-tool-compat.ts +157 -39
  185. package/src/responses/namespace-tool-compat.ts +84 -4
  186. package/src/responses/parser.ts +7 -2
  187. package/src/responses/reasoning-replay-cache.ts +81 -3
  188. package/src/responses/schema.ts +5 -1
  189. package/src/responses/state.ts +94 -4
  190. package/src/responses/thought-signature-replay.ts +17 -0
  191. package/src/responses/turn-termination.ts +107 -0
  192. package/src/router.ts +40 -2
  193. package/src/routing/analytics.ts +1 -0
  194. package/src/routing/quota.ts +10 -0
  195. package/src/server/auth-cors.ts +27 -7
  196. package/src/server/chat-completions.ts +24 -16
  197. package/src/server/chat-native-sse.ts +3 -3
  198. package/src/server/chat-native.ts +30 -11
  199. package/src/server/claude-messages.ts +1 -1
  200. package/src/server/index.ts +179 -32
  201. package/src/server/lifecycle.ts +52 -1
  202. package/src/server/local-management-read-client.ts +1 -1
  203. package/src/server/local-provider-reload-client.ts +1 -1
  204. package/src/server/management/agent-settings-routes.ts +45 -23
  205. package/src/server/management/codex-prompt-routes.ts +570 -0
  206. package/src/server/management/combo-routes.ts +2 -1
  207. package/src/server/management/config-routes.ts +59 -12
  208. package/src/server/management/context.ts +10 -1
  209. package/src/server/management/logs-usage-routes.ts +30 -24
  210. package/src/server/management/model-routes.ts +274 -4
  211. package/src/server/management/model-rows.ts +4 -0
  212. package/src/server/management/native-integration-routes.ts +2 -1
  213. package/src/server/management/oauth-account-routes.ts +10 -4
  214. package/src/server/management/provider-capability-config.ts +1 -1
  215. package/src/server/management/provider-routes.ts +81 -11
  216. package/src/server/management/route-registry.ts +332 -0
  217. package/src/server/management/routing-profile-routes.ts +2 -2
  218. package/src/server/management/system-restart.ts +1 -1
  219. package/src/server/management-api.ts +18 -16
  220. package/src/server/port-reclaim.ts +1 -1
  221. package/src/server/proxy-liveness.ts +29 -5
  222. package/src/server/relay-eager.ts +147 -20
  223. package/src/server/relay.ts +251 -19
  224. package/src/server/request-log-conversation.ts +51 -0
  225. package/src/server/request-log.ts +70 -16
  226. package/src/server/responses/codex-auth-error.ts +55 -0
  227. package/src/server/responses/collaboration.ts +37 -3
  228. package/src/server/responses/combo-stream-preflight.ts +178 -0
  229. package/src/server/responses/compact.ts +6 -21
  230. package/src/server/responses/core.ts +810 -208
  231. package/src/server/responses/empty-completion-guard.ts +63 -6
  232. package/src/server/responses/fetch-helpers.ts +17 -102
  233. package/src/server/responses/input-admission.ts +3 -1
  234. package/src/server/responses/passthrough-error.ts +33 -9
  235. package/src/server/responses/responses-field-backfill.ts +105 -13
  236. package/src/server/responses/ws-upstream.ts +75 -2
  237. package/src/server/responses-custom-tool-repair.ts +87 -6
  238. package/src/server/responses-terminal-repair.ts +25 -4
  239. package/src/server/responses-undeclared-tool-guard.ts +241 -18
  240. package/src/server/sse-frame-buffer.ts +31 -4
  241. package/src/server/ws-bridge.ts +14 -2
  242. package/src/service.ts +42 -4
  243. package/src/storage/policy-job.ts +14 -4
  244. package/src/storage/policy.ts +88 -23
  245. package/src/types/config.ts +66 -0
  246. package/src/types/provider.ts +69 -0
  247. package/src/types/request.ts +17 -4
  248. package/src/types/tools.ts +116 -11
  249. package/src/types.ts +3 -1
  250. package/src/update/index.ts +5 -4
  251. package/src/update/job.ts +3 -1
  252. package/src/update/transactional-install.mjs +8 -1
  253. package/src/usage/expected-prices.ts +43 -24
  254. package/src/usage/log.ts +63 -12
  255. package/src/usage/summary.ts +211 -8
  256. package/src/vision/describe.ts +18 -13
  257. package/src/web-search/exa-executor.ts +40 -9
  258. package/src/web-search/executor.ts +10 -3
  259. package/src/web-search/loop.ts +5 -4
  260. package/gui/dist/assets/index-BF38heuV.js +0 -104
  261. package/gui/dist/assets/index-DMiI18Kv.css +0 -1
@@ -26,7 +26,7 @@
26
26
  * CODEX_HOME is resolved at CALL time (the `features.ts:58-67` pattern) so tests
27
27
  * can point fixtures via env or an explicit path.
28
28
  */
29
- import { existsSync, readFileSync, realpathSync } from "node:fs";
29
+ import { existsSync, readFileSync, readdirSync, realpathSync } from "node:fs";
30
30
  import { dirname, join, resolve } from "node:path";
31
31
  import { createHash, randomBytes } from "node:crypto";
32
32
  import { expandUserPath } from "../config";
@@ -100,6 +100,24 @@ export const LAYER_INVENTORY: readonly LayerDescriptor[] = Object.freeze([
100
100
  { id: "tools", class: "feature-gated", key: "features.deferred_tool_world_state", default: false, order: 12 },
101
101
  { id: "skills", class: "config-toggle", key: "skills.include_instructions", default: true, order: 13 },
102
102
  { id: "multi-agent-mode", class: "feature-gated", key: "features.multi_agent_v2.enabled", default: false, order: 14 },
103
+ /**
104
+ * Commit and pull-request attribution, contributed by `ext/git-attribution` rather
105
+ * than by a world_state.rs section — which is why it is absent from the order list
106
+ * above and carries `order: null`: it registers through
107
+ * `extensions.context_contributors()` (`core/src/session/world_state.rs:64-66`),
108
+ * whose position is registration-order dependent.
109
+ *
110
+ * `runtime-conditional`, NOT feature-gated. `ext/git-attribution/src/lib.rs:33-80`
111
+ * resolves enablement from the AUTH SERVER via `resolve_attribution_policy`, caches
112
+ * it on the thread store, and falls back to disabled when the lookup fails.
113
+ * `features/src/lib.rs:277` records the old config flag as removed, so there is no
114
+ * key for this GUI to write and nothing in [features] to point a user at.
115
+ *
116
+ * Both states emit text: enabled sends the `Co-authored-by: Codex` trailer plus the
117
+ * `Generated with Codex.` PR marker, disabled sends an explicit countermand. So the
118
+ * row's condition line must name the policy rather than claiming "always on".
119
+ */
120
+ { id: "git-attribution", class: "runtime-conditional", key: null, default: null, order: null },
103
121
  ] as const);
104
122
 
105
123
  /**
@@ -129,6 +147,7 @@ export function isToggleId(value: string): value is ToggleId {
129
147
  export interface Paths {
130
148
  configPath?: string;
131
149
  storePath?: string;
150
+ baseVariantDir?: string;
132
151
  }
133
152
 
134
153
  function activeCodexHome(): string {
@@ -150,6 +169,18 @@ export function activeStorePath(opts?: Paths): string {
150
169
  return opts?.storePath ?? join(activeCodexHome(), "opencodex-prompt.json");
151
170
  }
152
171
 
172
+ /**
173
+ * Where authored base-prompt variants live, one markdown file per variant.
174
+ *
175
+ * A directory of real files rather than another JSON store, because
176
+ * `model_instructions_file` points Codex at a path it reads directly. Embedding the
177
+ * bodies in `opencodex-prompt.json` would mean materialising a temp file at selection
178
+ * time, which is a second write path for no gain.
179
+ */
180
+ export function activeBaseVariantDir(opts?: Paths): string {
181
+ return opts?.baseVariantDir ?? join(activeCodexHome(), "opencodex-prompt-base");
182
+ }
183
+
153
184
  function journalPathFor(storePath: string): string {
154
185
  return `${storePath.replace(/\.json$/, "")}.journal`;
155
186
  }
@@ -411,6 +442,32 @@ export type Drift =
411
442
  | "owned-malformed"
412
443
  | null;
413
444
 
445
+ /** One authored base-prompt variant. `default` is never represented here. */
446
+ export interface BaseVariant {
447
+ id: string;
448
+ title: string;
449
+ body: string;
450
+ bytes: number;
451
+ }
452
+
453
+ /**
454
+ * Which base prompt is in force. THREE values, not two.
455
+ *
456
+ * - `default` — `model_instructions_file` is absent, so Codex uses its own base prompt.
457
+ * This is the absence of a key, not a body we store: there is nothing to edit and
458
+ * nothing to delete, which is what makes the default structurally immutable rather
459
+ * than merely guarded.
460
+ * - a variant id — the key points inside our own variant directory.
461
+ * - `external` — the key is set and points somewhere else.
462
+ *
463
+ * The third value is load-bearing and an audit forced it. Collapsing it into `default`
464
+ * would have shown a user "Codex's own base prompt" while their base prompt was in fact
465
+ * replaced by a file they had set by hand. The panel already ships a notice for that
466
+ * state in ten locales; this keeps reporting it instead of overwriting a key we do not
467
+ * own.
468
+ */
469
+ export type BaseSelection = { kind: "default" } | { kind: "variant"; id: string } | { kind: "external"; path: string };
470
+
414
471
  export interface PromptLayerSnapshot {
415
472
  configPath: string;
416
473
  storePath: string;
@@ -422,6 +479,8 @@ export interface PromptLayerSnapshot {
422
479
  toggles: ToggleState[];
423
480
  custom: CustomLayer[];
424
481
  modelInstructionsFile: string | null;
482
+ baseVariants: BaseVariant[];
483
+ baseSelection: BaseSelection;
425
484
  revision: string;
426
485
  }
427
486
 
@@ -453,6 +512,78 @@ function readModelInstructionsFile(configBytes: string | null): string | null {
453
512
  return null;
454
513
  }
455
514
 
515
+ /** Variant ids are ours to generate, so they stay in one narrow shape. */
516
+ const BASE_VARIANT_ID = /^[a-z0-9]{6}$/;
517
+
518
+ /**
519
+ * The variant files on disk, newest-id-last so the picker order is stable.
520
+ *
521
+ * `default.md` is SKIPPED rather than read: `default` names the absence of a key, so a
522
+ * file claiming that id would appear as a fourth variant whose selection could never be
523
+ * expressed. Anything not matching our own id shape is skipped for the same reason - we
524
+ * only report what we could also write.
525
+ */
526
+ export function readBaseVariants(opts?: Paths): BaseVariant[] {
527
+ const dir = activeBaseVariantDir(opts);
528
+ let names: string[];
529
+ try {
530
+ names = readdirSync(dir);
531
+ } catch {
532
+ // Absent directory is an ordinary first run, not an error.
533
+ return [];
534
+ }
535
+ const out: BaseVariant[] = [];
536
+ for (const name of names.sort()) {
537
+ if (!name.endsWith(".md")) continue;
538
+ const id = name.slice(0, -3);
539
+ if (!BASE_VARIANT_ID.test(id)) continue;
540
+ const body = readFileOrNull(join(dir, name));
541
+ if (body === null) continue;
542
+ // First line is the title when it is a markdown heading; the rest is the prompt.
543
+ // Storing the title inside the file keeps one artifact per variant instead of a
544
+ // sidecar index that can disagree with it.
545
+ const nl = body.indexOf("\n");
546
+ const firstLine = nl === -1 ? body : body.slice(0, nl);
547
+ const titled = firstLine.startsWith("# ");
548
+ out.push({
549
+ id,
550
+ title: titled ? firstLine.slice(2).trim() : id,
551
+ body: titled ? body.slice(nl === -1 ? body.length : nl + 1) : body,
552
+ bytes: Buffer.byteLength(body, "utf8"),
553
+ });
554
+ }
555
+ return out;
556
+ }
557
+
558
+ /**
559
+ * Resolve which base prompt is in force, given the config bytes and the variants.
560
+ *
561
+ * Comparison is by RESOLVED path: `~/.codex/opencodex-prompt-base/abc123.md` and an
562
+ * absolute spelling of the same file are the same selection, and treating them as
563
+ * different would report `external` for a variant we wrote ourselves.
564
+ */
565
+ export function resolveBaseSelection(
566
+ configBytes: string | null,
567
+ variants: readonly BaseVariant[],
568
+ opts?: Paths,
569
+ ): BaseSelection {
570
+ const raw = readModelInstructionsFile(configBytes);
571
+ if (raw === null) return { kind: "default" };
572
+ const dir = activeBaseVariantDir(opts);
573
+ let resolved: string;
574
+ try {
575
+ resolved = resolve(expandUserPath(raw));
576
+ } catch {
577
+ return { kind: "external", path: raw };
578
+ }
579
+ for (const variant of variants) {
580
+ if (resolved === resolve(join(dir, `${variant.id}.md`))) {
581
+ return { kind: "variant", id: variant.id };
582
+ }
583
+ }
584
+ return { kind: "external", path: raw };
585
+ }
586
+
456
587
  /**
457
588
  * Pure. Never writes, never locks, never recovers — a GET must not modify a
458
589
  * user's configuration, so drift is REPORTED here and resolved elsewhere.
@@ -472,6 +603,7 @@ export function readPromptLayers(opts?: Paths): PromptLayerSnapshot {
472
603
  const projection = ownership.state === "owned"
473
604
  ? decodeBasicString(ownership.literal)
474
605
  : null;
606
+ const baseVariants = readBaseVariants(opts);
475
607
 
476
608
  let drift: Drift = null;
477
609
  if (existsSync(`${storePath.replace(/\.json$/, "")}.journal`)) {
@@ -496,6 +628,8 @@ export function readPromptLayers(opts?: Paths): PromptLayerSnapshot {
496
628
  toggles: TOGGLE_IDS.map(id => readToggle(configBytes, id)),
497
629
  custom: layers ?? [],
498
630
  modelInstructionsFile: readModelInstructionsFile(configBytes),
631
+ baseVariants,
632
+ baseSelection: resolveBaseSelection(configBytes, baseVariants, opts),
499
633
  revision: computeRevision(configBytes, storeBytes),
500
634
  };
501
635
  }
@@ -512,6 +646,10 @@ export type WriteError =
512
646
  | "store_unreadable"
513
647
  | "invalid_characters"
514
648
  | "write_superseded"
649
+ // The filesystem refused a rename that passed every precondition: a directory on
650
+ // the store path, a mode change, a full disk. Distinct from write_superseded,
651
+ // which means another writer won a race — here nobody won and nothing landed.
652
+ | "write_failed"
515
653
  | "recovery_required"
516
654
  | "locked";
517
655
 
@@ -531,6 +669,24 @@ function splitLines(content: string): string[] {
531
669
  return content.replace(/\r\n/g, "\n").split("\n");
532
670
  }
533
671
 
672
+ /**
673
+ * A leading UTF-8 BOM, split off so line editing never steps over it.
674
+ *
675
+ * Codex reads config.toml with Rust `toml_edit`, which accepts a BOM at byte 0 and
676
+ * nowhere else. Inserting the generated block at line index 0 pushed the BOM down
677
+ * to byte 58, the write reported success because our own byte comparison matched
678
+ * what we intended to write, and the next parse failed with
679
+ * "Expected a key but found (0xEF)" — a config file the user could no longer load,
680
+ * produced by a write that told them it worked.
681
+ *
682
+ * Editors on Windows write this byte routinely, so the file is not exotic.
683
+ */
684
+ function splitBom(content: string): { bom: string; body: string } {
685
+ return content.startsWith("\ufeff")
686
+ ? { bom: "\ufeff", body: content.slice(1) }
687
+ : { bom: "", body: content };
688
+ }
689
+
534
690
  function joinLines(lines: string[], eol: "\r\n" | "\n"): string {
535
691
  const text = lines.join("\n");
536
692
  return eol === "\n" ? text : text.replace(/\n/g, "\r\n");
@@ -544,7 +700,8 @@ function firstTableIndex(lines: string[]): number {
544
700
  /** Set a root-scope boolean, inserting above the first table when absent. */
545
701
  function setRootBool(content: string, key: string, value: boolean): string {
546
702
  const eol = dominantEol(content);
547
- const lines = splitLines(content);
703
+ const { bom, body } = splitBom(content);
704
+ const lines = splitLines(body);
548
705
  const limit = firstTableIndex(lines);
549
706
  const escaped = key.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
550
707
  const pattern = new RegExp(`^(\\s*${escaped}\\s*=\\s*)(?:true|false)(\\s*(?:#.*)?)$`);
@@ -552,23 +709,49 @@ function setRootBool(content: string, key: string, value: boolean): string {
552
709
  const m = pattern.exec(lines[i]!);
553
710
  if (m) {
554
711
  lines[i] = `${m[1]}${value}${m[2]}`;
555
- return joinLines(lines, eol);
712
+ return bom + joinLines(lines, eol);
556
713
  }
557
714
  }
558
715
  lines.splice(limit, 0, `${key} = ${value}`);
559
- return joinLines(lines, eol);
716
+ return bom + joinLines(lines, eol);
717
+ }
718
+
719
+ /**
720
+ * Set or REMOVE a root-scope basic string. `null` removes the key.
721
+ *
722
+ * Removal is what selecting the default variant does, and it has to be a real deletion
723
+ * rather than an empty string: `model_instructions_file = ""` is a path Codex would try
724
+ * to read, not an absent setting.
725
+ */
726
+ function setRootString(content: string, key: string, value: string | null): string {
727
+ const eol = dominantEol(content);
728
+ const { bom, body } = splitBom(content);
729
+ const lines = splitLines(body);
730
+ const limit = firstTableIndex(lines);
731
+ const escaped = key.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
732
+ const pattern = new RegExp(`^\\s*${escaped}\\s*=\\s*"[^"]*"\\s*(?:#.*)?$`);
733
+ for (let i = 0; i < limit; i += 1) {
734
+ if (!pattern.test(lines[i]!)) continue;
735
+ if (value === null) lines.splice(i, 1);
736
+ else lines[i] = `${key} = ${encodeBasicString(value)}`;
737
+ return bom + joinLines(lines, eol);
738
+ }
739
+ if (value === null) return bom + joinLines(lines, eol);
740
+ lines.splice(limit, 0, `${key} = ${encodeBasicString(value)}`);
741
+ return bom + joinLines(lines, eol);
560
742
  }
561
743
 
562
744
  /** Set a boolean inside `[table]`, appending the table when absent. */
563
745
  function setTableBool(content: string, table: string, key: string, value: boolean): string {
564
746
  const eol = dominantEol(content);
565
- const lines = splitLines(content);
747
+ const { bom, body } = splitBom(content);
748
+ const lines = splitLines(body);
566
749
  const escaped = table.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
567
750
  const start = lines.findIndex(l => new RegExp(`^\\s*\\[${escaped}\\]\\s*(?:#.*)?$`).test(l));
568
751
  if (start === -1) {
569
752
  const tail = lines.length > 0 && lines[lines.length - 1] === "" ? lines.length - 1 : lines.length;
570
753
  lines.splice(tail, 0, `[${table}]`, `${key} = ${value}`);
571
- return joinLines(lines, eol);
754
+ return bom + joinLines(lines, eol);
572
755
  }
573
756
  const keyEscaped = key.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
574
757
  const pattern = new RegExp(`^(\\s*${keyEscaped}\\s*=\\s*)(?:true|false)(\\s*(?:#.*)?)$`);
@@ -578,11 +761,11 @@ function setTableBool(content: string, table: string, key: string, value: boolea
578
761
  const m = pattern.exec(lines[i]!);
579
762
  if (m) {
580
763
  lines[i] = `${m[1]}${value}${m[2]}`;
581
- return joinLines(lines, eol);
764
+ return bom + joinLines(lines, eol);
582
765
  }
583
766
  }
584
767
  lines.splice(end, 0, `${key} = ${value}`);
585
- return joinLines(lines, eol);
768
+ return bom + joinLines(lines, eol);
586
769
  }
587
770
 
588
771
  /**
@@ -593,7 +776,11 @@ function setTableBool(content: string, table: string, key: string, value: boolea
593
776
  function setProjection(content: string | null, projection: string | null): string {
594
777
  const base = content ?? "";
595
778
  const eol = dominantEol(base);
596
- const lines = splitLines(base);
779
+ // The BOM is held aside for the whole edit. This is the function that produced
780
+ // the corruption: the insert below is at index 0, which put the marker line
781
+ // ahead of a byte that is only legal at byte 0.
782
+ const { bom, body } = splitBom(base);
783
+ const lines = splitLines(body);
597
784
  const limit = firstTableIndex(lines);
598
785
 
599
786
  let markerAt = -1;
@@ -607,12 +794,12 @@ function setProjection(content: string | null, projection: string | null): strin
607
794
  if (markerAt !== -1) {
608
795
  if (projection === null) lines.splice(markerAt, 2);
609
796
  else lines[markerAt + 1] = `${DEV_INSTRUCTIONS_KEY} = ${encodeBasicString(projection)}`;
610
- return joinLines(lines, eol);
797
+ return bom + joinLines(lines, eol);
611
798
  }
612
799
 
613
- if (projection === null) return joinLines(lines, eol);
800
+ if (projection === null) return bom + joinLines(lines, eol);
614
801
  lines.splice(0, 0, OCX_SECTION_MARKER, `${DEV_INSTRUCTIONS_KEY} = ${encodeBasicString(projection)}`);
615
- return joinLines(lines, eol);
802
+ return bom + joinLines(lines, eol);
616
803
  }
617
804
 
618
805
  function serializeStore(layers: readonly CustomLayer[]): string {
@@ -663,7 +850,7 @@ function commit(
663
850
  return { ok: false, error: "stale_revision" };
664
851
  }
665
852
 
666
- const snapshot = readPromptLayers({ configPath, storePath });
853
+ const snapshot = readPromptLayers({ ...opts, configPath, storePath });
667
854
  const built = build(snapshot, configBytes, storeBytes);
668
855
  if ("error" in built) return { ok: false, error: built.error, detail: built.detail };
669
856
 
@@ -691,19 +878,39 @@ function commit(
691
878
 
692
879
  // 4/5. each target re-verifies ITS OWN bytes immediately before its rename,
693
880
  // so a third party writing between step 2 and here is not overwritten.
694
- if (configChanged) {
695
- if (hashBytes(readFileOrNull(configPath)) !== record.preConfig) {
696
- return rollback(record, journalPath, "stale_revision");
881
+ //
882
+ // Wrapped, because a THROW here used to escape the transaction entirely.
883
+ // Only `config` readability is pre-checked, so an unwritable STORE — a
884
+ // directory sitting on its path, a permission change, a full disk — raised
885
+ // out of `durableWrite` after the config had already been renamed into
886
+ // place. The caller saw an exception, the config carried a projection whose
887
+ // store did not exist, and the journal stayed behind claiming an
888
+ // uncommitted intent. Every later write then failed recovery_required.
889
+ //
890
+ // Rolling back on the way out restores the pre-state we recorded and drops
891
+ // the journal, so a failed write leaves the pair exactly as it was found.
892
+ try {
893
+ if (configChanged) {
894
+ if (hashBytes(readFileOrNull(configPath)) !== record.preConfig) {
895
+ return rollback(record, journalPath, "stale_revision");
896
+ }
897
+ if (nextConfig === null) durableDelete(configPath);
898
+ else durableWrite(configPath, nextConfig);
697
899
  }
698
- if (nextConfig === null) durableDelete(configPath);
699
- else durableWrite(configPath, nextConfig);
700
- }
701
- if (storeChanged) {
702
- if (hashBytes(readFileOrNull(storePath)) !== record.preStore) {
703
- return rollback(record, journalPath, "stale_revision");
900
+ if (storeChanged) {
901
+ if (hashBytes(readFileOrNull(storePath)) !== record.preStore) {
902
+ return rollback(record, journalPath, "stale_revision");
903
+ }
904
+ if (nextStore === null) durableDelete(storePath);
905
+ else durableWrite(storePath, nextStore);
704
906
  }
705
- if (nextStore === null) durableDelete(storePath);
706
- else durableWrite(storePath, nextStore);
907
+ } catch (error) {
908
+ // `rollback` is byte-hash driven and refuses to touch a file it does not
909
+ // recognise, so it is safe to run against a partially applied pair. If it
910
+ // cannot account for what it finds it returns recovery_required, which is the
911
+ // honest answer — better than a silent half-write either way.
912
+ const undone = rollback(record, journalPath, "write_failed");
913
+ return { ...undone, detail: error instanceof Error ? error.message : String(error) } as WriteResult;
707
914
  }
708
915
 
709
916
  // 6. verify COMPLETE bytes, not just our two lines: another writer could
@@ -716,7 +923,10 @@ function commit(
716
923
  if (!stillHeld(handle)) return { ok: false, error: "write_superseded" };
717
924
 
718
925
  durableDelete(journalPath); // this deletion is the commit
719
- return { ok: true, changed: true, snapshot: readPromptLayers({ configPath, storePath }) };
926
+ // The FULL opts, not just the two paths this transaction owns: rebuilding the
927
+ // snapshot from a narrowed object dropped the injected variant directory, so every
928
+ // successful write reported an empty variant list back to its caller.
929
+ return { ok: true, changed: true, snapshot: readPromptLayers({ ...opts, configPath, storePath }) };
720
930
  } finally {
721
931
  release(handle);
722
932
  }
@@ -756,6 +966,142 @@ export function setToggle(id: string, enabled: boolean, revision: string, opts?:
756
966
  }));
757
967
  }
758
968
 
969
+ /**
970
+ * Point `model_instructions_file` at a variant, or remove it for the default.
971
+ *
972
+ * Refusals, each for a reason the GUI cannot be trusted to enforce alone:
973
+ * - an unknown variant id, because the key would name a file Codex cannot read;
974
+ * - the `external` state, because retargeting a key somebody else set silently
975
+ * discards their base prompt. Adopting it is a separate, explicit act.
976
+ */
977
+ export function selectBaseVariant(selection: BaseSelection, revision: string, opts?: Paths): WriteResult {
978
+ if (selection.kind === "external") return { ok: false, error: "unknown_layer", detail: "cannot select the external state" };
979
+ const dir = activeBaseVariantDir(opts);
980
+ return commit(opts, revision, (snapshot, configBytes, storeBytes) => {
981
+ if (snapshot.baseSelection.kind === "external") {
982
+ return { error: "developer_instructions_not_owned", detail: snapshot.baseSelection.path };
983
+ }
984
+ if (selection.kind === "variant") {
985
+ const variant = snapshot.baseVariants.find(v => v.id === selection.id);
986
+ if (!variant) return { error: "unknown_layer", detail: selection.id };
987
+ }
988
+ const next = selection.kind === "default"
989
+ ? null
990
+ : resolve(join(dir, `${selection.id}.md`));
991
+ return {
992
+ nextConfig: setRootString(configBytes ?? "", "model_instructions_file", next),
993
+ nextStore: storeBytes,
994
+ };
995
+ });
996
+ }
997
+
998
+ /** How many authored variants a user may keep. Two plus the default is the ask. */
999
+ export const MAX_BASE_VARIANTS = 2;
1000
+
1001
+ /**
1002
+ * Write or delete one authored variant body.
1003
+ *
1004
+ * Ordering is deliberate and was learned from a defect in this same module: the FILE is
1005
+ * written and verified before `config.toml` is ever pointed at it. Pointing first would
1006
+ * leave the key naming a file that may not exist, which is a worse failure than a written
1007
+ * file nothing references yet.
1008
+ *
1009
+ * Deleting the variant that is currently SELECTED also clears the key in the same
1010
+ * transaction, so the config can never outlive the file it names.
1011
+ */
1012
+ export function writeBaseVariant(
1013
+ input: { id: string | null; title: string; body: string } | { id: string; delete: true },
1014
+ revision: string,
1015
+ opts?: Paths,
1016
+ ): WriteResult {
1017
+ const dir = activeBaseVariantDir(opts);
1018
+ const deleting = "delete" in input;
1019
+ if (!deleting) {
1020
+ const normalized = normalizeBody(input.body);
1021
+ const invalid = findInvalidCharacter(normalized);
1022
+ if (invalid !== null) {
1023
+ return { ok: false, error: "invalid_characters", detail: `at code point ${invalid.position}` };
1024
+ }
1025
+ }
1026
+ const existing = readBaseVariants(opts);
1027
+ const targetId = deleting
1028
+ ? input.id
1029
+ : input.id ?? newBaseVariantId(existing);
1030
+ if (!BASE_VARIANT_ID.test(targetId)) return { ok: false, error: "unknown_layer", detail: targetId };
1031
+ if (deleting && !existing.some(v => v.id === targetId)) {
1032
+ return { ok: false, error: "unknown_layer", detail: targetId };
1033
+ }
1034
+ if (!deleting && input.id === null && existing.length >= MAX_BASE_VARIANTS) {
1035
+ return { ok: false, error: "unknown_layer", detail: `at most ${MAX_BASE_VARIANTS} variants` };
1036
+ }
1037
+ const path = join(dir, `${targetId}.md`);
1038
+ const before = readFileOrNull(path);
1039
+ const next = deleting
1040
+ ? null
1041
+ : `# ${input.title.replace(/[\r\n]+/g, " ").trim() || targetId}\n${normalizeBody(input.body)}`;
1042
+
1043
+ // Whether this id is the live selection has to be decided while the file still
1044
+ // EXISTS. Deleting first made `resolveBaseSelection` fall through to `external` - the
1045
+ // path no longer matched a known variant - so the config half saw a state it refuses
1046
+ // to touch and left the key pointing at a file that was already gone.
1047
+ const selectedBefore = resolveBaseSelection(readFileOrNull(activeConfigPath(opts)), existing, opts);
1048
+ const clearingKey = deleting
1049
+ && selectedBefore.kind === "variant"
1050
+ && selectedBefore.id === targetId;
1051
+
1052
+ // On a CREATE or EDIT the file goes first: pointing config.toml at a file that does
1053
+ // not exist yet is worse than writing a file nothing references. On a DELETE the
1054
+ // order is reversed for the same reason read the other way - the key must stop
1055
+ // naming the file before the file disappears.
1056
+ if (!deleting) {
1057
+ ensureDir(path);
1058
+ try {
1059
+ durableWrite(path, next!);
1060
+ } catch (error) {
1061
+ return { ok: false, error: "write_failed", detail: error instanceof Error ? error.message : String(error) };
1062
+ }
1063
+ }
1064
+
1065
+ const result = commit(opts, revision, (_snapshot, configBytes, storeBytes) => ({
1066
+ nextConfig: clearingKey
1067
+ ? setRootString(configBytes ?? "", "model_instructions_file", null)
1068
+ : configBytes,
1069
+ nextStore: storeBytes,
1070
+ }));
1071
+
1072
+ if (!result.ok) {
1073
+ // Undo the file half rather than leaving a variant the caller was told was not
1074
+ // written. A delete has not touched the file yet, so there is nothing to undo.
1075
+ if (!deleting) {
1076
+ try {
1077
+ if (before === null) durableDelete(path);
1078
+ else durableWrite(path, before);
1079
+ } catch { /* the returned error already tells the caller to look */ }
1080
+ }
1081
+ return result;
1082
+ }
1083
+
1084
+ if (deleting) {
1085
+ try {
1086
+ durableDelete(path);
1087
+ } catch (error) {
1088
+ // The key is already clear, so the prompt is correct; the stale file is inert.
1089
+ return { ok: false, error: "write_failed", detail: error instanceof Error ? error.message : String(error) };
1090
+ }
1091
+ // Re-read so the caller sees the variant actually gone.
1092
+ return { ok: true, changed: true, snapshot: readPromptLayers(opts) };
1093
+ }
1094
+ return result;
1095
+ }
1096
+
1097
+ function newBaseVariantId(existing: readonly BaseVariant[]): string {
1098
+ const taken = new Set(existing.map(v => v.id));
1099
+ for (;;) {
1100
+ const id = randomBytes(4).toString("hex").slice(0, 6);
1101
+ if (!taken.has(id)) return id;
1102
+ }
1103
+ }
1104
+
759
1105
  /** Replace the whole custom-layer list; order is composition order. */
760
1106
  export function writeCustomLayers(layers: readonly CustomLayer[], revision: string, opts?: Paths): WriteResult {
761
1107
  for (const layer of layers) {