@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.
- package/README.md +3 -3
- package/bin/ocx.mjs +99 -70
- package/gui/dist/assets/index-Be1sDpWw.css +1 -0
- package/gui/dist/assets/index-D4OKgUV4.js +114 -0
- package/gui/dist/index.html +2 -2
- package/package.json +3 -1
- package/src/adapters/anthropic.ts +104 -15
- package/src/adapters/base.ts +29 -1
- package/src/adapters/command-code.ts +42 -6
- package/src/adapters/cursor/call-id.ts +44 -0
- package/src/adapters/cursor/catalog.ts +541 -0
- package/src/adapters/cursor/checkpoint-store.ts +15 -10
- package/src/adapters/cursor/cursor-errors.ts +15 -0
- package/src/adapters/cursor/discovery.ts +69 -18
- package/src/adapters/cursor/effort-map.ts +79 -1
- package/src/adapters/cursor/envelope-echo.ts +290 -0
- package/src/adapters/cursor/live-models.ts +7 -2
- package/src/adapters/cursor/live-transport.ts +14 -0
- package/src/adapters/cursor/message-mapper.ts +4 -1
- package/src/adapters/cursor/native-exec-desktop.ts +23 -0
- package/src/adapters/cursor/native-exec-fs.ts +2 -2
- package/src/adapters/cursor/native-exec-network.ts +1 -1
- package/src/adapters/cursor/native-exec-shell.ts +5 -2
- package/src/adapters/cursor/native-exec.ts +20 -0
- package/src/adapters/cursor/protobuf-request.ts +68 -14
- package/src/adapters/cursor/request-builder.ts +40 -10
- package/src/adapters/cursor/tool-definitions.ts +14 -4
- package/src/adapters/cursor/tool-result-normalize.ts +28 -5
- package/src/adapters/cursor/types.ts +20 -0
- package/src/adapters/cursor.ts +209 -38
- package/src/adapters/exec-tool-result-normalize.ts +99 -0
- package/src/adapters/google-antigravity-replay.ts +173 -25
- package/src/adapters/google-antigravity-wire.ts +5 -0
- package/src/adapters/google-errors.ts +44 -12
- package/src/adapters/google.ts +136 -24
- package/src/adapters/image.ts +1 -1
- package/src/adapters/kiro-constants.ts +27 -0
- package/src/adapters/kiro-tools.ts +55 -16
- package/src/adapters/kiro.ts +182 -23
- package/src/adapters/openai-chat.ts +300 -242
- package/src/adapters/openai-responses.ts +353 -27
- package/src/adapters/run-turn-queue.ts +36 -1
- package/src/adapters/tool-catalog-nudge.ts +4 -3
- package/src/adapters/xai-tool-schema.ts +436 -0
- package/src/adapters/xai-web-search.ts +17 -16
- package/src/bridge.ts +90 -37
- package/src/chat/inbound.ts +14 -1
- package/src/chat/outbound.ts +15 -7
- package/src/claude/agents-inject.ts +8 -1
- package/src/claude/context-windows.ts +16 -9
- package/src/claude/outbound.ts +24 -11
- package/src/cli/access.ts +46 -3
- package/src/cli/account-api.ts +116 -19
- package/src/cli/account-extended.ts +262 -29
- package/src/cli/account-main.ts +12 -12
- package/src/cli/account.ts +69 -15
- package/src/cli/agent.ts +8 -1
- package/src/cli/alias.ts +66 -0
- package/src/cli/capabilities-command.ts +94 -0
- package/src/cli/capabilities.ts +496 -0
- package/src/cli/claude-desktop.ts +31 -11
- package/src/cli/claude.ts +26 -1
- package/src/cli/dispatch.ts +256 -28
- package/src/cli/doctor.ts +126 -14
- package/src/cli/help.ts +17 -6
- package/src/cli/index.ts +39 -9
- package/src/cli/init.ts +1 -0
- package/src/cli/inspect.ts +230 -0
- package/src/cli/models-runtime.ts +95 -0
- package/src/cli/models.ts +26 -10
- package/src/cli/observe.ts +31 -8
- package/src/cli/provider-runtime.ts +16 -2
- package/src/cli/provider.ts +2 -1
- package/src/cli/registry.ts +49 -10
- package/src/cli/runtime-api.ts +51 -7
- package/src/cli/status.ts +41 -1
- package/src/cli/storage.ts +234 -0
- package/src/cli/system-command.ts +16 -0
- package/src/cli/system-restart-client.ts +1 -1
- package/src/cli/usage-report.ts +184 -0
- package/src/cli/v2.ts +34 -10
- package/src/cli/version-skew.ts +46 -0
- package/src/clients/config-export.ts +85 -3
- package/src/clients/effective-status.ts +30 -2
- package/src/clients/probes/cline.ts +81 -0
- package/src/codex/account-label.ts +21 -0
- package/src/codex/account-pause.ts +2 -1
- package/src/codex/account-priority.ts +3 -2
- package/src/codex/app-server-processes.ts +83 -7
- package/src/codex/auth-api.ts +50 -10
- package/src/codex/auth-context.ts +21 -18
- package/src/codex/autostart-health.ts +16 -0
- package/src/codex/catalog/aggregation.ts +18 -0
- package/src/codex/catalog/effort.ts +18 -3
- package/src/codex/catalog/metadata.ts +27 -1
- package/src/codex/catalog/native-models.ts +5 -2
- package/src/codex/catalog/parsing.ts +72 -27
- package/src/codex/catalog/provider-fetch.ts +187 -31
- package/src/codex/catalog/sync.ts +131 -5
- package/src/codex/catalog.ts +1 -1
- package/src/codex/codex-write-lock.ts +3 -1
- package/src/codex/convergence-types.ts +1 -1
- package/src/codex/convergence.ts +27 -2
- package/src/codex/desired-state.ts +2 -2
- package/src/codex/desktop-app-restart.ts +355 -0
- package/src/codex/history-job.ts +32 -3
- package/src/codex/history-manifest.ts +112 -0
- package/src/codex/history-migration-guardian.ts +5 -5
- package/src/codex/history-provider.ts +825 -247
- package/src/codex/history-worker.ts +8 -5
- package/src/codex/inject-coordination.ts +83 -0
- package/src/codex/inject.ts +63 -22
- package/src/codex/injected-marker.ts +1 -1
- package/src/codex/internal/history-writer.ts +4 -3
- package/src/codex/log-guard/inspect.ts +22 -4
- package/src/codex/model-entitlements.ts +9 -2
- package/src/codex/native-profile-startup.ts +157 -27
- package/src/codex/native-residue.ts +26 -33
- package/src/codex/prompt-layers.ts +371 -25
- package/src/codex/prompt-text-probe.ts +238 -0
- package/src/codex/quota.ts +91 -10
- package/src/codex/routing.ts +9 -0
- package/src/codex/shim.ts +56 -3
- package/src/codex/subagent-model-fallback.ts +104 -25
- package/src/codex/transition-state.ts +116 -8
- package/src/combos/failover.ts +27 -0
- package/src/combos/types.ts +10 -0
- package/src/compatibility/index.ts +26 -0
- package/src/compatibility/manifest.ts +253 -0
- package/src/compatibility/openai-responses.ts +121 -0
- package/src/config/atomic-write.ts +219 -0
- package/src/config/paths.ts +40 -0
- package/src/config/process-state.ts +309 -0
- package/src/config/provider-validation.ts +177 -0
- package/src/config/rebase-provenance.ts +68 -0
- package/src/config.ts +206 -824
- package/src/generated/compatibility-version.json +373 -213
- package/src/images/loop.ts +5 -4
- package/src/images/plan.ts +5 -4
- package/src/integrations/ownership-policy.ts +160 -0
- package/src/integrations/ownership.ts +46 -2
- package/src/integrations/registry.ts +7 -0
- package/src/integrations/state.ts +79 -7
- package/src/integrations/writer.ts +26 -2
- package/src/lab/conformance/fixtures/protocol-v1-cases.json +1 -1
- package/src/lab/fabric/producer-child.ts +1 -1
- package/src/lib/admin-secrets.ts +24 -0
- package/src/lib/bounded-body.ts +14 -2
- package/src/lib/config-ownership.ts +20 -0
- package/src/lib/errors.ts +36 -3
- package/src/lib/package-tree-integrity.ts +101 -0
- package/src/lib/process-control.ts +2 -1
- package/src/lib/service-secrets.ts +15 -0
- package/src/lib/tool-argument-integers.ts +56 -5
- package/src/oauth/callback-server.ts +22 -2
- package/src/oauth/cline.ts +203 -0
- package/src/oauth/generic-account-failover.ts +231 -0
- package/src/oauth/health.ts +1 -1
- package/src/oauth/index.ts +65 -6
- package/src/oauth/key-providers.ts +10 -1
- package/src/oauth/kimi.ts +9 -1
- package/src/oauth/kiro.ts +45 -0
- package/src/oauth/local-token-detect.ts +2 -0
- package/src/oauth/open-browser-choice.ts +26 -0
- package/src/oauth/store.ts +14 -5
- package/src/oauth/types.ts +15 -0
- package/src/providers/auto-compact-budget.ts +65 -0
- package/src/providers/command-code-efforts.ts +38 -6
- package/src/providers/context-cap.ts +4 -3
- package/src/providers/default-aliases.ts +65 -0
- package/src/providers/derive.ts +28 -0
- package/src/providers/fastwire.ts +7 -1
- package/src/providers/label.ts +34 -1
- package/src/providers/model-presets.ts +119 -0
- package/src/providers/new-model-policy.ts +146 -0
- package/src/providers/provider-id-rewrite.ts +2 -1
- package/src/providers/quota.ts +65 -7
- package/src/providers/registry.ts +184 -69
- package/src/providers/slug-codec.ts +52 -0
- package/src/providers/xai-transport.ts +21 -0
- package/src/reasoning-effort.ts +19 -2
- package/src/responses/apply-patch-envelope.ts +63 -0
- package/src/responses/code-mode-helper-compat.ts +50 -0
- package/src/responses/custom-tool-compat.ts +157 -39
- package/src/responses/namespace-tool-compat.ts +84 -4
- package/src/responses/parser.ts +7 -2
- package/src/responses/reasoning-replay-cache.ts +81 -3
- package/src/responses/schema.ts +5 -1
- package/src/responses/state.ts +94 -4
- package/src/responses/thought-signature-replay.ts +17 -0
- package/src/responses/turn-termination.ts +107 -0
- package/src/router.ts +40 -2
- package/src/routing/analytics.ts +1 -0
- package/src/routing/quota.ts +10 -0
- package/src/server/auth-cors.ts +27 -7
- package/src/server/chat-completions.ts +24 -16
- package/src/server/chat-native-sse.ts +3 -3
- package/src/server/chat-native.ts +30 -11
- package/src/server/claude-messages.ts +1 -1
- package/src/server/index.ts +179 -32
- package/src/server/lifecycle.ts +52 -1
- package/src/server/local-management-read-client.ts +1 -1
- package/src/server/local-provider-reload-client.ts +1 -1
- package/src/server/management/agent-settings-routes.ts +45 -23
- package/src/server/management/codex-prompt-routes.ts +570 -0
- package/src/server/management/combo-routes.ts +2 -1
- package/src/server/management/config-routes.ts +59 -12
- package/src/server/management/context.ts +10 -1
- package/src/server/management/logs-usage-routes.ts +30 -24
- package/src/server/management/model-routes.ts +274 -4
- package/src/server/management/model-rows.ts +4 -0
- package/src/server/management/native-integration-routes.ts +2 -1
- package/src/server/management/oauth-account-routes.ts +10 -4
- package/src/server/management/provider-capability-config.ts +1 -1
- package/src/server/management/provider-routes.ts +81 -11
- package/src/server/management/route-registry.ts +332 -0
- package/src/server/management/routing-profile-routes.ts +2 -2
- package/src/server/management/system-restart.ts +1 -1
- package/src/server/management-api.ts +18 -16
- package/src/server/port-reclaim.ts +1 -1
- package/src/server/proxy-liveness.ts +29 -5
- package/src/server/relay-eager.ts +147 -20
- package/src/server/relay.ts +251 -19
- package/src/server/request-log-conversation.ts +51 -0
- package/src/server/request-log.ts +70 -16
- package/src/server/responses/codex-auth-error.ts +55 -0
- package/src/server/responses/collaboration.ts +37 -3
- package/src/server/responses/combo-stream-preflight.ts +178 -0
- package/src/server/responses/compact.ts +6 -21
- package/src/server/responses/core.ts +810 -208
- package/src/server/responses/empty-completion-guard.ts +63 -6
- package/src/server/responses/fetch-helpers.ts +17 -102
- package/src/server/responses/input-admission.ts +3 -1
- package/src/server/responses/passthrough-error.ts +33 -9
- package/src/server/responses/responses-field-backfill.ts +105 -13
- package/src/server/responses/ws-upstream.ts +75 -2
- package/src/server/responses-custom-tool-repair.ts +87 -6
- package/src/server/responses-terminal-repair.ts +25 -4
- package/src/server/responses-undeclared-tool-guard.ts +241 -18
- package/src/server/sse-frame-buffer.ts +31 -4
- package/src/server/ws-bridge.ts +14 -2
- package/src/service.ts +42 -4
- package/src/storage/policy-job.ts +14 -4
- package/src/storage/policy.ts +88 -23
- package/src/types/config.ts +66 -0
- package/src/types/provider.ts +69 -0
- package/src/types/request.ts +17 -4
- package/src/types/tools.ts +116 -11
- package/src/types.ts +3 -1
- package/src/update/index.ts +5 -4
- package/src/update/job.ts +3 -1
- package/src/update/transactional-install.mjs +8 -1
- package/src/usage/expected-prices.ts +43 -24
- package/src/usage/log.ts +63 -12
- package/src/usage/summary.ts +211 -8
- package/src/vision/describe.ts +18 -13
- package/src/web-search/exa-executor.ts +40 -9
- package/src/web-search/executor.ts +10 -3
- package/src/web-search/loop.ts +5 -4
- package/gui/dist/assets/index-BF38heuV.js +0 -104
- 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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
695
|
-
|
|
696
|
-
|
|
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 (
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
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
|
-
|
|
706
|
-
|
|
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
|
-
|
|
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) {
|