Files
oh-my-pi/packages/coding-agent/src/config/settings.ts
T
can1357 6d2bae2c41 feat: implemented long-context pricing and configuration support
- Added long-context pricing tiers and billing policies for subscription Codex models in the catalog.
- Introduced the `extendedContext` configuration setting to control premium long-context windows.
- Implemented runtime policy refresh and model re-binding when context settings change.
- Added comprehensive unit tests for pricing tiers, context capping, and policy toggling behavior.
2026-08-20 02:47:32 +02:00

2606 lines
96 KiB
TypeScript

/**
* Settings singleton with sync get/set and background persistence.
*
* Usage:
* import { settings } from "./settings";
*
* const enabled = settings.get("compaction.enabled"); // sync read
* settings.set("theme.dark", "titanium"); // sync write, saves in background
*
* For tests:
* const isolated = Settings.isolated({ "compaction.enabled": false });
*/
import { randomUUID } from "node:crypto";
import * as fs from "node:fs";
import * as os from "node:os";
import * as path from "node:path";
import { configureCredentialRedaction } from "@oh-my-pi/pi-ai/providers/transform-messages";
import { configureProviderMaxInFlightRequests } from "@oh-my-pi/pi-ai/stream";
import {
getAgentDbPath,
getAgentDir,
getLastChangelogVersionPath,
getProjectDir,
hasFsCode,
isEnoent,
logger,
MAIN_CONFIG_FILENAMES,
procmgr,
setWorktreesDir,
toError,
} from "@oh-my-pi/pi-utils";
import { withFileLock } from "@oh-my-pi/pi-utils/file-lock";
import { JSONC, YAML } from "bun";
import { invalidate as invalidateCapabilityFsCache } from "../capability/fs";
import { type Settings as SettingsCapabilityItem, settingsCapability } from "../capability/settings";
import type { ModelRole } from "../config/model-roles";
import { loadCapability } from "../discovery";
import { isLightTheme, setAutoThemeMapping, setColorBlindMode, setSymbolPreset } from "../modes/theme/theme";
import { AgentStorage } from "../session/agent-storage";
import { AUTO_IMAGE_PROVIDER_ORDER, isImageProviderId } from "../tools/image-providers";
import { type EditMode, normalizeEditMode } from "../utils/edit-mode";
import { INSPECT_IMAGE_MODES } from "../utils/inspect-image-mode";
import { isSearchProviderId, SEARCH_PROVIDER_ORDER } from "../web/search/types";
import {
type BashInterceptorRule,
type GroupPrefix,
type GroupTypeMap,
getDefault,
SETTINGS_SCHEMA,
type SettingPath,
type SettingValue,
} from "./settings-schema";
// Re-export types that callers need
export type * from "./settings-schema";
export * from "./settings-schema";
// ═══════════════════════════════════════════════════════════════════════════
// Types
// ═══════════════════════════════════════════════════════════════════════════
/** Raw settings object as stored in YAML */
export interface RawSettings {
[key: string]: unknown;
}
type YamlLoadResult =
| { kind: "missing" }
| { kind: "loaded"; settings: RawSettings }
| { kind: "invalid"; error: unknown; backupPath?: string }
| { kind: "unreadable"; error: unknown };
type MainYamlReadResult = {
settings: RawSettings | null;
configPath: string | null;
};
type ProjectSettingsReadResult = {
settings: RawSettings;
fileSettings: RawSettings;
shellPathSource: string | undefined;
};
type ConfigOverlayReadResult = {
settings: RawSettings;
shellPathSource: string | undefined;
};
export interface SettingsOptions {
/** Current working directory for project settings discovery */
cwd?: string;
/** Agent directory for config.yml/config.yaml storage */
agentDir?: string;
/** Don't persist to disk (for tests) */
inMemory?: boolean;
/** Read config sources without opening storage or writing migrations */
readOnly?: boolean;
/** Initial overrides */
overrides?: Partial<Record<SettingPath, unknown>>;
/** Extra config.yml-style overlays loaded after global/project settings */
configFiles?: string[];
}
// ═══════════════════════════════════════════════════════════════════════════
// Path Utilities
// ═══════════════════════════════════════════════════════════════════════════
/**
* Get a nested value from an object by path segments.
*/
function getByPath(obj: RawSettings, segments: readonly string[]): unknown {
let current: unknown = obj;
for (const segment of segments) {
if (current === null || current === undefined || typeof current !== "object") {
return undefined;
}
current = (current as Record<string, unknown>)[segment];
}
return current;
}
const SETTING_PATH_SEGMENTS: Record<SettingPath, readonly string[]> = Object.fromEntries(
(Object.keys(SETTINGS_SCHEMA) as SettingPath[]).map(settingPath => [settingPath, settingPath.split(".")]),
) as unknown as Record<SettingPath, readonly string[]>;
/**
* Set a nested value in an object by path segments.
* Creates intermediate objects as needed.
*/
function setByPath(obj: RawSettings, segments: string[], value: unknown): void {
let current = obj;
for (let i = 0; i < segments.length - 1; i++) {
const segment = segments[i];
if (!(segment in current) || typeof current[segment] !== "object" || current[segment] === null) {
current[segment] = {};
}
current = current[segment] as RawSettings;
}
current[segments[segments.length - 1]] = value;
}
export function normalizeProviderMaxInFlightRequests(value: unknown): Record<string, number> {
if (!value || typeof value !== "object" || Array.isArray(value)) return {};
const normalized: Record<string, number> = {};
for (const [provider, rawLimit] of Object.entries(value)) {
if (typeof rawLimit !== "number" || !Number.isFinite(rawLimit) || rawLimit <= 0) continue;
normalized[provider] = Math.max(1, Math.floor(rawLimit));
}
return normalized;
}
export function validateProviderMaxInFlightRequests(value: unknown): Record<string, number> {
if (!value || typeof value !== "object" || Array.isArray(value)) return {};
const invalidProviders: string[] = [];
const normalized: Record<string, number> = {};
for (const [provider, rawLimit] of Object.entries(value)) {
if (typeof rawLimit !== "number" || !Number.isFinite(rawLimit) || rawLimit <= 0) {
invalidProviders.push(provider);
continue;
}
normalized[provider] = Math.max(1, Math.floor(rawLimit));
}
if (invalidProviders.length > 0) {
throw new Error(`Provider request limits must be positive numbers: ${invalidProviders.join(", ")}`);
}
return normalized;
}
const PATH_SCOPED_ARRAY_SETTINGS = new Set<SettingPath>(["enabledModels", "disabledProviders"]);
type PathScopedStringArrayEntry = {
path?: unknown;
paths?: unknown;
pathPrefix?: unknown;
pathPrefixes?: unknown;
values?: unknown;
items?: unknown;
models?: unknown;
providers?: unknown;
};
function expandTilde(p: string): string {
return p === "~" ? os.homedir() : p.startsWith("~/") ? path.join(os.homedir(), p.slice(2)) : p;
}
function normalizePathPrefix(prefix: string): string {
return path.resolve(expandTilde(prefix));
}
function pathMatchesPrefix(cwd: string, prefix: string): boolean {
const relative = path.relative(normalizePathPrefix(prefix), path.resolve(cwd));
return relative === "" || (!!relative && !relative.startsWith("..") && !path.isAbsolute(relative));
}
function stringArrayFromUnknown(value: unknown): string[] {
if (typeof value === "string") return [value];
if (Array.isArray(value)) return value.filter((item): item is string => typeof item === "string");
return [];
}
function isRecord(value: unknown): value is Record<string, unknown> {
return !!value && typeof value === "object" && !Array.isArray(value);
}
/**
* Migrate a v17 leaf rename that used to nest under a boolean parent path
* (`dev.autoqa.consent` → `dev.autoqaConsent`, `todo.reminders.max` →
* `todo.remindersMax`). Pre-rename configs left the leaf beneath the parent,
* so the parent path resolved to an object and truthy checks like
* `isAutoQaEnabled` treated a consent-only container as "enabled".
*
* Handles nested (`{ parent: { leaf } }`) and quoted-dotted (`"parent.leaf"`)
* legacy sources. An explicit new key always wins; a separately configured
* boolean parent is preserved; an irrecoverable object-valued parent (only ever
* a container for the old leaf) is dropped so the schema default applies.
*/
function migrateNestedLeafRename(
raw: RawSettings,
root: string,
parent: string,
oldLeaf: string,
newLeaf: string,
isLeafValue: (value: unknown) => boolean,
): void {
const rootObj = isRecord(raw[root]) ? (raw[root] as Record<string, unknown>) : undefined;
const nestedParent = rootObj?.[parent];
const flatParent = raw[`${root}.${parent}`];
const oldParentPath = `${root}.${parent}`;
const candidates = [
rootObj?.[newLeaf],
raw[`${root}.${newLeaf}`],
isRecord(nestedParent) ? nestedParent[oldLeaf] : undefined,
raw[`${oldParentPath}.${oldLeaf}`],
];
const resolvedLeaf = candidates.find(isLeafValue);
const recoveredParent =
typeof nestedParent === "boolean" ? nestedParent : typeof flatParent === "boolean" ? flatParent : undefined;
const ensureRoot = (): Record<string, unknown> => {
const current = raw[root];
if (isRecord(current)) return current;
const created: Record<string, unknown> = {};
raw[root] = created;
return created;
};
if (resolvedLeaf !== undefined) {
const target = ensureRoot();
if (!isLeafValue(target[newLeaf])) {
target[newLeaf] = resolvedLeaf;
}
}
// Strip legacy leaf sources (nested + flat dotted).
delete raw[`${oldParentPath}.${oldLeaf}`];
delete raw[`${root}.${newLeaf}`];
if (isRecord(raw[root]) && isRecord((raw[root] as Record<string, unknown>)[parent])) {
const parentObj = (raw[root] as Record<string, unknown>)[parent] as Record<string, unknown>;
delete parentObj[oldLeaf];
if (Object.keys(parentObj).length === 0) {
delete (raw[root] as Record<string, unknown>)[parent];
}
}
// The parent path must be a boolean or absent — never a leftover object.
if (recoveredParent !== undefined) {
const target = ensureRoot();
if (typeof target[parent] !== "boolean") {
target[parent] = recoveredParent;
}
} else if (isRecord(raw[root]) && isRecord((raw[root] as Record<string, unknown>)[parent])) {
delete (raw[root] as Record<string, unknown>)[parent];
}
delete raw[oldParentPath];
if (isRecord(raw[root]) && Object.keys(raw[root] as Record<string, unknown>).length === 0) {
delete raw[root];
}
}
function modelRoleValueFromUnknown(value: unknown): string | undefined {
if (typeof value === "string") return value;
if (!Array.isArray(value)) return undefined;
const entries = stringArrayFromUnknown(value);
return entries.length === value.length ? entries.join(",") : undefined;
}
type EditVariantEntry = {
patternLower: string;
mode: EditMode;
};
function resolvePathScopedStringArray(settingPath: SettingPath, value: unknown, cwd: string): string[] | undefined {
if (!PATH_SCOPED_ARRAY_SETTINGS.has(settingPath) || !Array.isArray(value)) return undefined;
const resolved: string[] = [];
for (const entry of value) {
if (typeof entry === "string") {
resolved.push(entry);
continue;
}
if (!entry || typeof entry !== "object" || Array.isArray(entry)) continue;
const scoped = entry as PathScopedStringArrayEntry;
const prefixes = [
...stringArrayFromUnknown(scoped.path),
...stringArrayFromUnknown(scoped.paths),
...stringArrayFromUnknown(scoped.pathPrefix),
...stringArrayFromUnknown(scoped.pathPrefixes),
];
if (prefixes.length === 0 || !prefixes.some(prefix => pathMatchesPrefix(cwd, prefix))) continue;
const values =
settingPath === "enabledModels"
? [
...stringArrayFromUnknown(scoped.values),
...stringArrayFromUnknown(scoped.items),
...stringArrayFromUnknown(scoped.models),
]
: [
...stringArrayFromUnknown(scoped.values),
...stringArrayFromUnknown(scoped.items),
...stringArrayFromUnknown(scoped.providers),
];
resolved.push(...values);
}
return resolved;
}
// ═══════════════════════════════════════════════════════════════════════════
// Settings Class
// ═══════════════════════════════════════════════════════════════════════════
export class Settings {
#configPath: string | null;
#cwd: string;
#agentDir: string;
#storage: AgentStorage | null = null;
#configFiles: string[] = [];
/** Global settings from config.yml/config.yaml */
#global: RawSettings = {};
/** Project settings from .claude/settings.yml etc */
#project: RawSettings = {};
/** Last successfully loaded native .omp/config.yml contents. */
#projectFileSettings: RawSettings = {};
/** Logical config paths whose malformed targets were moved aside. */
#quarantinedYamlTargets = new Map<string, string>();
/** Extra config.yml-style overlays passed by CLI */
#configOverlay: RawSettings = {};
/** Project settings file that most recently supplied shellPath. */
#projectShellPathSource: string | undefined;
/** Explicit config overlay that most recently supplied shellPath. */
#overlayShellPathSource: string | undefined;
/** Runtime overrides (not persisted) */
#overrides: RawSettings = {};
/** Merged view (global + project + overrides) */
#merged: RawSettings = {};
/** Cached resolved values from the merged view, including defaults/path scoping */
#resolvedCache = new Map<SettingPath, unknown>();
#editVariantCache: readonly EditVariantEntry[] | undefined;
/** Paths modified during this session (for partial save) */
#modified = new Set<string>();
/** Individual project model roles modified during this session */
#modifiedProjectModelRoles = new Set<string>();
/** Individual global model roles modified during this session (for partial save) */
#modifiedGlobalModelRoles = new Set<string>();
/** Changes whenever a live API mutates a persisted layer. */
#persistedMutationGeneration = 0;
/**
* Original process-wide model-role overrides captured before a project edit
* temporarily replaced them via `#updateRuntimeModelRoleOverride`. Restored
* on `reloadForCwd` / `cloneForCwd` so destination projects never inherit the
* source-project value. Maps role → original override value (`undefined`
* when the role had no runtime override).
*/
#savedRuntimeModelRoleOverrides = new Map<string, string | undefined>();
/** Legacy `lastChangelogVersion` captured from config.yml during migration (now a marker file). */
#legacyLastChangelogVersion?: string;
/** Pending save (debounced) */
#saveTimer?: NodeJS.Timeout;
#savePromise?: Promise<void>;
#projectSaveTimer?: NodeJS.Timeout;
#projectSavePromise?: Promise<void>;
/** Coalesces concurrent persisted-layer refreshes into one atomic reload. */
#reloadFromDiskPromise?: Promise<void>;
/** Whether to persist changes */
#persist: boolean;
private constructor(options: SettingsOptions = {}) {
this.#cwd = path.normalize(options.cwd ?? getProjectDir());
this.#agentDir = path.normalize(options.agentDir ?? getAgentDir());
this.#configPath = options.inMemory ? null : path.join(this.#agentDir, MAIN_CONFIG_FILENAMES[0]);
const configFiles = process.env.PI_CONFIG_FILES?.split(path.delimiter).filter(Boolean) ?? [];
if (options.configFiles) configFiles.push(...options.configFiles);
this.#configFiles = configFiles.map(file => path.resolve(this.#cwd, expandTilde(file)));
this.#persist = !options.inMemory && options.readOnly !== true;
liveSettingsInstances.add(new WeakRef(this));
if (options.overrides) {
for (const [key, value] of Object.entries(options.overrides)) {
setByPath(this.#overrides, key.split("."), value);
}
this.#overrides = this.#migrateRawSettings(this.#overrides);
}
}
// ─────────────────────────────────────────────────────────────────────────
// Factory Methods
// ─────────────────────────────────────────────────────────────────────────
/**
* Initialize the global singleton.
* Call once at startup before accessing `settings`.
*/
static init(options: SettingsOptions = {}): Promise<Settings> {
if (globalInstancePromise) return globalInstancePromise;
const instance = new Settings(options);
const promise = instance.#load();
globalInstancePromise = promise;
return promise.then(
instance => {
globalInstance = instance;
clearBoundSettingsMethods();
globalInstancePromise = Promise.resolve(instance);
return instance;
},
error => {
globalInstance = null;
globalInstancePromise = null;
clearBoundSettingsMethods();
throw error;
},
);
}
/**
* Load effective settings from config.yml and project providers without
* opening agent.db, migrating legacy settings, or writing marker files.
*/
static loadReadOnly(options: SettingsOptions = {}): Promise<Settings> {
const instance = new Settings({ ...options, readOnly: true });
return instance.#loadReadOnly();
}
/**
* Load a persisted settings instance without touching the global singleton.
*/
static loadIsolated(options: SettingsOptions = {}): Promise<Settings> {
const instance = new Settings(options);
return instance.#load();
}
/**
* Create an in-memory settings instance without affecting the global singleton.
* A supplied storage handle remains shared for runtime data while setting overrides stay non-persistent.
*/
static isolated(
overrides: Partial<Record<SettingPath, unknown>> = {},
options: { storage?: AgentStorage | null } = {},
): Settings {
const instance = new Settings({ inMemory: true, overrides });
instance.#storage = options.storage ?? null;
instance.#rebuildMerged();
return instance;
}
/**
* Get the global singleton.
* Throws if not initialized.
*/
static get instance(): Settings {
if (!globalInstance) {
throw new Error("Settings not initialized. Call Settings.init() first.");
}
return globalInstance;
}
// ─────────────────────────────────────────────────────────────────────────
// Core API
// ─────────────────────────────────────────────────────────────────────────
/**
* Get a setting value (sync).
* Returns the merged value from global + project + overrides, or the default.
*/
get<P extends SettingPath>(path: P): SettingValue<P> {
if (this.#resolvedCache.has(path)) {
return this.#resolvedCache.get(path) as SettingValue<P>;
}
const value = getByPath(this.#merged, SETTING_PATH_SEGMENTS[path]);
const resolved =
value !== undefined ? (resolvePathScopedStringArray(path, value, this.#cwd) ?? value) : getDefault(path);
this.#resolvedCache.set(path, resolved);
return resolved as SettingValue<P>;
}
/**
* Whether `path` has an explicitly configured value (global config, project
* config, or runtime override) rather than falling back to the schema default.
*/
isConfigured(path: SettingPath): boolean {
return getByPath(this.#merged, SETTING_PATH_SEGMENTS[path]) !== undefined;
}
/**
* Set a setting value (sync).
* Updates global settings and queues a background save.
* Triggers hooks for settings that have side effects.
*/
set<P extends SettingPath>(path: P, value: SettingValue<P>): void {
const prev = this.get(path);
const segments = path.split(".");
setByPath(this.#global, segments, value);
this.#persistedMutationGeneration++;
this.#modified.add(path);
this.#rebuildMerged();
const next = this.get(path);
this.#queueSave();
// Trigger hook if exists
const hook = SETTING_HOOKS[path];
if (hook) {
hook(next, prev);
}
this.#fireEffectiveSettingChanged(path, next, prev);
}
/**
* Apply runtime overrides (not persisted).
*/
override<P extends SettingPath>(path: P, value: SettingValue<P>): void {
if (path === "modelRoles") {
this.#savedRuntimeModelRoleOverrides.clear();
}
const prev = this.get(path);
const segments = path.split(".");
setByPath(this.#overrides, segments, value);
this.#rebuildMerged();
this.#fireEffectiveSettingChanged(path, this.get(path), prev);
}
/**
* Clear a runtime override.
*/
clearOverride(path: SettingPath): void {
if (path === "modelRoles") {
this.#savedRuntimeModelRoleOverrides.clear();
}
const prev = this.get(path);
const segments = path.split(".");
let current = this.#overrides;
for (let i = 0; i < segments.length - 1; i++) {
const segment = segments[i];
if (!(segment in current)) return;
current = current[segment] as RawSettings;
}
delete current[segments[segments.length - 1]];
this.#rebuildMerged();
this.#fireEffectiveSettingChanged(path, this.get(path), prev);
}
#fireEffectiveSettingChanged(path: SettingPath, value: unknown, prev: unknown): void {
if (Object.is(value, prev)) return;
if (path === "statusLine.sessionAccent") {
statusLineSessionAccentSignal.fire();
}
if (path === "modelRoles") {
modelRolesSignal.fire();
}
}
/** Set once this instance is discarded; background saves become no-ops. */
#savesCancelled = false;
/**
* Drop pending debounced saves and refuse any further background writes.
* Used when an instance is being discarded (test teardown): an armed timer
* or a chained in-flight save on a dropped instance would otherwise fire
* later and race the successor's file locks.
*/
cancelPendingSaves(): void {
this.#savesCancelled = true;
clearTimeout(this.#saveTimer);
this.#saveTimer = undefined;
clearTimeout(this.#projectSaveTimer);
this.#projectSaveTimer = undefined;
}
/**
* Flush any pending saves to disk.
* Call before exit to ensure all changes are persisted.
*/
async flush(): Promise<void> {
if (this.#saveTimer) {
clearTimeout(this.#saveTimer);
this.#saveTimer = undefined;
}
if (this.#projectSaveTimer) {
clearTimeout(this.#projectSaveTimer);
this.#projectSaveTimer = undefined;
}
if (this.#savePromise) {
await this.#savePromise;
}
if (this.#projectSavePromise) {
await this.#projectSavePromise;
}
if (this.#modified.size > 0 || this.#modifiedGlobalModelRoles.size > 0) {
await this.#saveNow();
}
if (this.#modifiedProjectModelRoles.size > 0) {
await this.#saveProjectNow();
}
}
async cloneForCwd(cwd: string): Promise<Settings> {
const cloned = new Settings({
cwd,
agentDir: this.#agentDir,
inMemory: !this.#persist,
});
cloned.#storage = this.#storage;
cloned.#configPath = this.#configPath;
cloned.#global = structuredClone(this.#global);
cloned.#project = this.#persist ? await cloned.#loadProjectSettings() : structuredClone(this.#project);
if (!this.#persist) cloned.#projectShellPathSource = this.#projectShellPathSource;
cloned.#configFiles = [...this.#configFiles];
cloned.#configOverlay = structuredClone(this.#configOverlay);
cloned.#overlayShellPathSource = this.#overlayShellPathSource;
cloned.#overrides = this.#buildOriginalOverrides();
cloned.#rebuildMerged();
cloned.#fireAllHooks();
return cloned;
}
/**
* Re-read the current global, project, and explicit overlay layers from disk
* without replacing this instance or discarding runtime overrides.
*
* All sources are loaded before any live layer is replaced, so readers never
* observe a partially refreshed configuration. Concurrent callers share the
* same reload.
*/
async reloadFromDisk(): Promise<void> {
if (!this.#persist) return;
if (this.#reloadFromDiskPromise) return this.#reloadFromDiskPromise;
const reload = this.#reloadPersistedLayers();
this.#reloadFromDiskPromise = reload;
try {
await reload;
} finally {
if (this.#reloadFromDiskPromise === reload) {
this.#reloadFromDiskPromise = undefined;
}
}
}
async #reloadPersistedLayers(): Promise<void> {
for (;;) {
await this.flush();
const mutationGeneration = this.#persistedMutationGeneration;
const previousSignaledValues = {
modelRoles: this.get("modelRoles"),
sessionAccent: this.get("statusLine.sessionAccent"),
};
const previousHookValues = new Map<SettingPath, unknown>();
for (const key of Object.keys(SETTING_HOOKS) as SettingPath[]) {
previousHookValues.set(key, this.get(key));
}
const [globalResult, projectResult, overlayResult] = await Promise.allSettled([
this.#readExistingMainYaml(false),
this.#readProjectSettings(false),
this.#readConfigOverlays(false),
]);
if (mutationGeneration !== this.#persistedMutationGeneration) continue;
if (globalResult.status === "rejected") throw globalResult.reason;
if (projectResult.status === "rejected") throw projectResult.reason;
if (overlayResult.status === "rejected") throw overlayResult.reason;
this.#configPath = globalResult.value.configPath;
this.#global = globalResult.value.settings ?? {};
this.#project = projectResult.value.settings;
this.#projectFileSettings = projectResult.value.fileSettings;
this.#projectShellPathSource = projectResult.value.shellPathSource;
this.#configOverlay = overlayResult.value.settings;
this.#overlayShellPathSource = overlayResult.value.shellPathSource;
this.#rebuildMerged();
const nextModelRoles = this.get("modelRoles");
if (!Bun.deepEquals(nextModelRoles, previousSignaledValues.modelRoles)) {
this.#fireEffectiveSettingChanged("modelRoles", nextModelRoles, previousSignaledValues.modelRoles);
}
const nextSessionAccent = this.get("statusLine.sessionAccent");
if (!Bun.deepEquals(nextSessionAccent, previousSignaledValues.sessionAccent)) {
this.#fireEffectiveSettingChanged(
"statusLine.sessionAccent",
nextSessionAccent,
previousSignaledValues.sessionAccent,
);
}
for (const [key, previous] of previousHookValues) {
const next = this.get(key);
if (!Bun.deepEquals(next, previous)) {
SETTING_HOOKS[key]?.(next, previous);
}
}
return;
}
}
/**
* Re-scope this instance to a new working directory *in place*: reload the
* project layer (`.claude/settings.yml` etc.) from `cwd`, re-resolve
* path-scoped settings against it, and re-fire side-effect hooks (theme,
* symbols, tab width, …). Global settings and runtime overrides are preserved.
*
* Unlike {@link cloneForCwd}, this mutates the live instance, so every holder
* (the `settings` proxy, the active session, controllers) observes the new
* project scope without swapping references — used when the process changes
* directory mid-run (`/move`, cross-project resume). No-op when `cwd` is
* already the current scope.
*/
async reloadForCwd(cwd: string): Promise<void> {
const normalized = path.normalize(cwd);
if (normalized === this.#cwd) return;
await this.flush();
this.#restoreRuntimeModelRoleOverrides();
const prevModelRoles = this.get("modelRoles");
this.#cwd = normalized;
if (this.#persist) {
this.#project = await this.#loadProjectSettings();
}
this.#rebuildMerged();
this.#fireEffectiveSettingChanged("modelRoles", this.get("modelRoles"), prevModelRoles);
this.#fireAllHooks();
}
// ─────────────────────────────────────────────────────────────────────────
// Accessors
// ─────────────────────────────────────────────────────────────────────────
getStorage(): AgentStorage | null {
return this.#storage;
}
getCwd(): string {
return this.#cwd;
}
getAgentDir(): string {
return this.#agentDir;
}
getPlansDirectory(): string {
return path.join(this.#agentDir, "plans");
}
/**
* Get shell configuration based on settings.
*/
getShellConfig() {
const shell = this.get("shellPath");
let configSource = this.#configPath ?? path.join(this.#agentDir, MAIN_CONFIG_FILENAMES[0]);
if (Object.hasOwn(this.#project, "shellPath")) {
configSource = this.#projectShellPathSource ?? "the active project configuration";
}
if (Object.hasOwn(this.#configOverlay, "shellPath")) {
configSource = this.#overlayShellPathSource ?? "the active config overlay";
}
if (Object.hasOwn(this.#overrides, "shellPath")) {
configSource = "the runtime settings override";
}
return procmgr.getShellConfig(shell, { configSource });
}
/**
* Get all settings in a group with full type safety.
*/
getGroup<G extends GroupPrefix>(prefix: G): GroupTypeMap[G] {
const result: Record<string, unknown> = {};
for (const key of Object.keys(SETTINGS_SCHEMA) as SettingPath[]) {
if (key.startsWith(`${prefix}.`)) {
const suffix = key.slice(prefix.length + 1);
result[suffix] = this.get(key);
}
}
return result as unknown as GroupTypeMap[G];
}
/**
* Get the edit variant for a specific model.
* Returns "patch", "replace", "hashline", "apply_patch", or null (use global default).
*/
getEditVariantForModel(model: string | undefined): EditMode | null {
if (!model) return null;
const variants = this.#getEditVariantEntries();
if (variants.length === 0) return null;
const modelLower = model.toLowerCase();
for (let i = 0; i < variants.length; i++) {
const variant = variants[i];
if (modelLower.includes(variant.patternLower)) {
return variant.mode;
}
}
return null;
}
#getEditVariantEntries(): readonly EditVariantEntry[] {
if (this.#editVariantCache !== undefined) return this.#editVariantCache;
const value = getByPath(this.#merged, ["edit", "modelVariants"]);
if (!isRecord(value)) {
this.#editVariantCache = [];
return this.#editVariantCache;
}
const variants: EditVariantEntry[] = [];
for (const pattern in value) {
if (!Object.hasOwn(value, pattern)) continue;
const rawMode = value[pattern];
if (typeof rawMode !== "string") continue;
const mode = normalizeEditMode(rawMode);
if (mode) {
variants.push({ patternLower: pattern.toLowerCase(), mode });
}
}
this.#editVariantCache = variants;
return variants;
}
/**
* Get bash interceptor rules (typed accessor for complex array config).
*/
getBashInterceptorRules(): BashInterceptorRule[] {
return this.get("bashInterceptor.patterns");
}
#modelRolesFromLayer(layer: RawSettings): Record<string, string> {
const value = getByPath(layer, ["modelRoles"]);
if (!isRecord(value)) return {};
const roles: Record<string, string> = {};
for (const role in value) {
if (!Object.hasOwn(value, role)) continue;
const modelId = modelRoleValueFromUnknown(value[role]);
if (modelId !== undefined) {
roles[role] = modelId;
}
}
return roles;
}
#modelRoleLayerOwns(layer: RawSettings, role: ModelRole | string): boolean {
const value = getByPath(layer, ["modelRoles"]);
if (!isRecord(value)) return false;
return Object.hasOwn(value, role);
}
/**
* Set the full `modelRoles` map on the runtime override layer without
* routing through the public {@link override} method. Internal callers
* (project edits, global fallback updates) use this so they can control
* capture invalidation independently of the whole-map replacement
* semantics that `override("modelRoles", …)` carries.
*/
#setRuntimeModelRoleOverrides(next: Record<string, string>): void {
const prev = this.get("modelRoles");
setByPath(this.#overrides, ["modelRoles"], next);
this.#rebuildMerged();
this.#fireEffectiveSettingChanged("modelRoles", this.get("modelRoles"), prev);
}
#updateRuntimeModelRoleOverride(role: ModelRole | string, modelId: string | undefined): void {
const runtimeOverrides = getByPath(this.#overrides, ["modelRoles"]);
if (!isRecord(runtimeOverrides) || !Object.hasOwn(runtimeOverrides, role)) return;
const nextRuntimeOverride = this.#modelRolesFromLayer(this.#overrides);
if (modelId === undefined) {
delete nextRuntimeOverride[role];
} else {
nextRuntimeOverride[role] = modelId;
}
this.#setRuntimeModelRoleOverrides(nextRuntimeOverride);
}
/**
* Capture the original process-wide override for `role` the first time a
* project edit temporarily replaces it, so the original can be restored on
* cwd changes. Subsequent edits in the same cwd must not overwrite the
* first captured value.
*/
#captureRuntimeModelRoleOverride(role: ModelRole | string): void {
if (this.#savedRuntimeModelRoleOverrides.has(role)) return;
const runtimeOverrides = getByPath(this.#overrides, ["modelRoles"]);
if (!isRecord(runtimeOverrides) || !Object.hasOwn(runtimeOverrides, role)) return;
this.#savedRuntimeModelRoleOverrides.set(role, this.#modelRolesFromLayer(this.#overrides)[role]);
}
/**
* Restore original process-wide model-role overrides that were temporarily
* replaced by project edits, mutating `#overrides` in place without
* rebuilding. All remaining captures are valid because superseding
* operations (late `overrideModelRoles`, global-mode `setModelRole`,
* whole-map `override`/`clearOverride`) invalidate the affected captures
* at the point of supersession. Caller is responsible for `#rebuildMerged()`.
*/
#restoreRuntimeModelRoleOverrides(): void {
if (this.#savedRuntimeModelRoleOverrides.size === 0) return;
const runtimeRoles = getByPath(this.#overrides, ["modelRoles"]);
if (!isRecord(runtimeRoles)) {
this.#savedRuntimeModelRoleOverrides.clear();
return;
}
for (const [role, originalValue] of this.#savedRuntimeModelRoleOverrides) {
if (originalValue === undefined) {
delete runtimeRoles[role];
} else {
runtimeRoles[role] = originalValue;
}
}
this.#savedRuntimeModelRoleOverrides.clear();
}
/**
* Produce a deep copy of `#overrides` with original process-wide model-role
* overrides restored, for use by {@link cloneForCwd}. All remaining
* captures are valid (see {@link #restoreRuntimeModelRoleOverrides}).
* Does not mutate the current instance's `#overrides`.
*/
#buildOriginalOverrides(): RawSettings {
if (this.#savedRuntimeModelRoleOverrides.size === 0) {
return structuredClone(this.#overrides);
}
const overrides = structuredClone(this.#overrides);
const runtimeRoles = getByPath(overrides, ["modelRoles"]);
if (!isRecord(runtimeRoles)) return overrides;
for (const [role, originalValue] of this.#savedRuntimeModelRoleOverrides) {
if (originalValue === undefined) {
delete runtimeRoles[role];
} else {
runtimeRoles[role] = originalValue;
}
}
return overrides;
}
#setProjectModelRoleValue(role: ModelRole | string, modelId: string | null): void {
const prev = this.get("modelRoles");
const projectRoles = getByPath(this.#project, ["modelRoles"]);
const current: Record<string, unknown> = isRecord(projectRoles) ? { ...projectRoles } : {};
current[role] = modelId;
setByPath(this.#project, ["modelRoles"], current);
this.#modifiedProjectModelRoles.add(role);
this.#persistedMutationGeneration++;
this.#rebuildMerged();
this.#fireEffectiveSettingChanged("modelRoles", this.get("modelRoles"), prev);
this.#queueProjectSave();
}
/**
* Set a model role (helper for modelRoles record). Passing `undefined`
* clears the role from the persisted record and any runtime override.
*
* In project storage mode, when a project edit has temporarily replaced
* the process-wide runtime override for `role` and that override is still
* active (the runtime slot currently matches the project value), the
* global-layer write must not rewrite that runtime slot — otherwise the
* global fallback would immediately shadow the still-configured project
* role. The global layer is still persisted; only the runtime override is
* left untouched. The guard is precise so that a later clear, a late
* `overrideModelRoles`, or a storage-mode transition does not leave a
* stale skip in place.
*/
setModelRole(role: ModelRole | string, modelId: string | undefined): void {
const prev = this.get("modelRoles");
const current = this.#modelRolesFromLayer(this.#global);
if (modelId === undefined) {
delete current[role];
} else {
current[role] = modelId;
}
// Persist per-role rather than marking the whole `modelRoles` path
// modified: #saveNow merges only the changed role into the re-read
// file, so a concurrent external edit to a sibling role is not
// clobbered by this process's stale in-memory snapshot.
setByPath(this.#global, ["modelRoles"], current);
this.#modifiedGlobalModelRoles.add(role);
this.#persistedMutationGeneration++;
this.#rebuildMerged();
this.#queueSave();
this.#fireEffectiveSettingChanged("modelRoles", this.get("modelRoles"), prev);
if (this.isProjectModelRoleRuntimeOverrideActive(role)) {
return;
}
this.#savedRuntimeModelRoleOverrides.delete(role);
this.#updateRuntimeModelRoleOverride(role, modelId);
}
/**
* Whether `role`'s runtime override slot currently holds the temporary
* project-scoped value installed by a prior `setProjectModelRole`. Returns
* `false` when storage is not project-mode, no capture exists, or the
* project role was cleared. With explicit provenance invalidation, a
* surviving capture implies no external supersession occurred.
*/
isProjectModelRoleRuntimeOverrideActive(role: ModelRole | string): boolean {
if (this.get("modelRoleStorage") !== "project") return false;
if (!this.#savedRuntimeModelRoleOverrides.has(role)) return false;
return !!this.getProjectModelRole(role);
}
/**
* Set a model role in the current project's settings layer.
*/
setProjectModelRole(role: ModelRole | string, modelId: string): void {
this.#setProjectModelRoleValue(role, modelId);
this.#captureRuntimeModelRoleOverride(role);
this.#updateRuntimeModelRoleOverride(role, modelId);
}
/**
* Clear a model role from the current project's settings layer.
*/
clearProjectModelRole(role: ModelRole | string): void {
this.#setProjectModelRoleValue(role, null);
this.#captureRuntimeModelRoleOverride(role);
this.#updateRuntimeModelRoleOverride(role, undefined);
}
/**
* Get a model role (helper for modelRoles record).
*/
getModelRole(role: ModelRole | string): string | undefined {
const roles: unknown = this.get("modelRoles");
if (!isRecord(roles)) return undefined;
return modelRoleValueFromUnknown(roles[role]);
}
/**
* Get a model role from only the global settings layer.
*/
getGlobalModelRole(role: ModelRole | string): string | undefined {
const modelId = this.#modelRolesFromLayer(this.#global)[role];
return modelId || undefined;
}
/**
* Get a model role from only the current project settings layer.
*/
getProjectModelRole(role: ModelRole | string): string | undefined {
const modelId = this.#modelRolesFromLayer(this.#project)[role];
return modelId || undefined;
}
/**
* Report which layer actually supplies the effective model role across
* full merge precedence (runtime override → config overlay → project →
* global → default). Unlike {@link getModelRoleSource}, this accounts
* for runtime and config-overlay layers and detects ownership by key
* presence rather than normalized value, so a `null` tombstone in the
* overlay or runtime layer correctly blocks lower layers. The project
* layer is checked through {@link #projectSettingsForMerge} because a
* project null is a cleared value (falls back to global), not a
* tombstone.
*/
getModelRoleProvenance(role: ModelRole | string): "runtime" | "overlay" | "project" | "global" | "default" {
if (this.#modelRoleLayerOwns(this.#overrides, role)) return "runtime";
if (this.#modelRoleLayerOwns(this.#configOverlay, role)) return "overlay";
if (this.#modelRoleLayerOwns(this.#projectSettingsForMerge(), role)) return "project";
if (this.#modelRoleLayerOwns(this.#global, role)) return "global";
return "default";
}
/**
* Get the persisted layer supplying a model role (project/global/default only).
*/
getModelRoleSource(role: ModelRole | string): "project" | "global" | "default" {
if (this.getProjectModelRole(role)) return "project";
if (this.getGlobalModelRole(role)) return "global";
return "default";
}
/**
* Get all model roles (helper for modelRoles record).
*/
getModelRoles(): ReadOnlyDict<string> {
const roles: unknown = this.get("modelRoles");
if (!isRecord(roles)) return {};
const normalized: Record<string, string> = {};
for (const role in roles) {
if (!Object.hasOwn(roles, role)) continue;
const modelId = modelRoleValueFromUnknown(roles[role]);
if (modelId !== undefined) {
normalized[role] = modelId;
}
}
return normalized;
}
/*
* Override model roles (helper for modelRoles record).
*/
overrideModelRoles(roles: ReadOnlyDict<string>): void {
const next = this.#modelRolesFromLayer(this.#overrides);
for (const [role, modelId] of Object.entries(roles)) {
if (modelId) {
next[role] = modelId;
this.#savedRuntimeModelRoleOverrides.delete(role);
}
}
this.#setRuntimeModelRoleOverrides(next);
}
/**
* Set disabled providers (for compatibility with discovery system).
*/
setDisabledProviders(ids: string[]): void {
this.set("disabledProviders", ids);
}
// ─────────────────────────────────────────────────────────────────────────
// Loading
// ─────────────────────────────────────────────────────────────────────────
async #load(): Promise<Settings> {
// Project settings discovery is independent of the persist chain, while
// the persist steps themselves remain sequential. Wait for both branches
// to settle so simultaneous failures produce one catchable error without
// abandoning the other rejection.
const [globalResult, projectResult] = await Promise.allSettled([
this.#persist ? this.#loadGlobalSettings() : Promise.resolve(),
this.#loadProjectSettings(),
]);
if (globalResult.status === "rejected") throw globalResult.reason;
if (projectResult.status === "rejected") throw projectResult.reason;
this.#project = projectResult.value;
this.#configOverlay = await this.#loadConfigOverlays();
// Build merged view (global → project → overrides; project wins over global)
this.#rebuildMerged();
this.#fireAllHooks();
return this;
}
async #loadGlobalSettings(): Promise<void> {
this.#storage = await AgentStorage.open(getAgentDbPath(this.#agentDir));
const existingConfig = await this.#loadExistingMainYaml();
if (existingConfig) {
this.#global = existingConfig;
} else {
await this.#migrateFromLegacy();
this.#global = await this.#loadYaml(this.#configPath!);
}
await this.#seedLastChangelogVersionMarker();
}
async #loadReadOnly(): Promise<Settings> {
const [globalResult, projectResult] = await Promise.allSettled([
this.#loadExistingMainYaml(),
this.#loadProjectSettings(),
]);
if (globalResult.status === "rejected") throw globalResult.reason;
if (projectResult.status === "rejected") throw projectResult.reason;
if (globalResult.value) {
this.#global = globalResult.value;
}
this.#project = projectResult.value;
this.#configOverlay = await this.#loadConfigOverlays();
this.#rebuildMerged();
return this;
}
async #loadYaml(filePath: string): Promise<RawSettings> {
const loaded = await this.#loadYamlIfPresentForStartup(filePath);
return loaded ?? {};
}
async #loadYamlIfPresent(filePath: string, captureLegacyChangelogVersion = true): Promise<YamlLoadResult> {
let content: string;
try {
content = await fs.promises.readFile(filePath, "utf8");
} catch (error) {
if (isEnoent(error)) return { kind: "missing" };
return { kind: "unreadable", error };
}
let parsed: unknown;
try {
parsed = YAML.parse(content);
} catch (error) {
return { kind: "invalid", error };
}
if (parsed === null || parsed === undefined) {
return { kind: "loaded", settings: {} };
}
if (typeof parsed !== "object" || Array.isArray(parsed)) {
return {
kind: "invalid",
error: new Error("Settings YAML must contain a mapping at the document root"),
};
}
return {
kind: "loaded",
settings: this.#migrateRawSettings(parsed as RawSettings, captureLegacyChangelogVersion),
};
}
async #resolveYamlWritePath(filePath: string): Promise<string> {
const quarantinedTarget = this.#quarantinedYamlTargets.get(filePath);
if (quarantinedTarget) return quarantinedTarget;
try {
return await fs.promises.realpath(filePath);
} catch (error) {
if (!isEnoent(error)) throw error;
}
// realpath fails for a dangling symlink. Resolve its immediate target so
// recreating a quarantined config repairs the target without replacing
// the user-managed link.
try {
const stat = await fs.promises.lstat(filePath);
if (stat.isSymbolicLink()) {
const target = await fs.promises.readlink(filePath);
return path.resolve(path.dirname(filePath), target);
}
} catch (error) {
if (!isEnoent(error)) throw error;
}
return path.resolve(filePath);
}
async #withYamlWriteLock<T>(filePath: string, fn: (writePath: string) => Promise<T>): Promise<T> {
const writePath = await this.#resolveYamlWritePath(filePath);
return await withFileLock(writePath, async () => fn(writePath));
}
async #loadYamlIfPresentForStartup(filePath: string): Promise<RawSettings | null> {
const result = await this.#loadYamlIfPresent(filePath);
if (result.kind !== "invalid" || !this.#persist) {
return this.#unwrapYamlLoadResult(filePath, result);
}
return await this.#withYamlWriteLock(filePath, async writePath =>
this.#loadYamlIfPresentForWriteLocked(filePath, writePath, true),
);
}
/**
* Read a YAML settings file while its write lock is held. Invalid files are
* moved aside before reporting failure, so a later write can never truncate
* the only copy of the user's configuration.
*/
async #loadYamlIfPresentForWriteLocked(
filePath: string,
writePath: string,
rejectMissing = false,
): Promise<RawSettings | null> {
let result = await this.#loadYamlIfPresent(writePath);
if (result.kind === "missing" && rejectMissing) {
throw new Error(
`Settings config was invalid before locking and is now missing: ${filePath}; another process may have moved it aside`,
);
}
if (result.kind === "invalid") {
result = await this.#quarantineInvalidYamlLocked(writePath, result);
this.#quarantinedYamlTargets.set(filePath, writePath);
}
return this.#unwrapYamlLoadResult(filePath, result);
}
async #quarantineInvalidYamlLocked(
filePath: string,
result: Extract<YamlLoadResult, { kind: "invalid" }>,
): Promise<Extract<YamlLoadResult, { kind: "invalid" }>> {
const backupPath = `${filePath}.broken-${Date.now()}-${process.pid}-${randomUUID()}`;
try {
await fs.promises.rename(filePath, backupPath);
} catch (error) {
throw new Error(
`Settings config is invalid and could not be moved aside: ${filePath}; refusing to overwrite it: ${String(error)}`,
);
}
logger.warn("Settings: moved invalid config aside", {
path: filePath,
backupPath,
error: String(result.error),
});
return { ...result, backupPath };
}
#unwrapYamlLoadResult(filePath: string, result: YamlLoadResult): RawSettings | null {
switch (result.kind) {
case "missing":
return null;
case "loaded":
return result.settings;
case "invalid":
throw new Error(
`Settings config is invalid: ${filePath}${result.backupPath ? ` (moved to ${result.backupPath})` : ""}: ${String(result.error)}`,
);
case "unreadable":
throw new Error(`Failed to read settings config ${filePath}: ${String(result.error)}`);
}
}
async #readExistingMainYaml(quarantineInvalid: boolean): Promise<MainYamlReadResult> {
if (!this.#configPath) return { settings: null, configPath: null };
for (const filename of MAIN_CONFIG_FILENAMES) {
const configPath = path.join(this.#agentDir, filename);
const loaded = quarantineInvalid
? await this.#loadYamlIfPresentForStartup(configPath)
: this.#unwrapYamlLoadResult(configPath, await this.#loadYamlIfPresent(configPath, false));
if (loaded) return { settings: loaded, configPath };
}
return {
settings: null,
configPath: path.join(this.#agentDir, MAIN_CONFIG_FILENAMES[0]),
};
}
async #loadExistingMainYaml(): Promise<RawSettings | null> {
const result = await this.#readExistingMainYaml(true);
this.#configPath = result.configPath;
return result.settings;
}
async #readProjectSettings(quarantineInvalid: boolean): Promise<ProjectSettingsReadResult> {
let shellPathSource: string | undefined;
let merged: RawSettings = {};
try {
const result = await loadCapability(settingsCapability.id, { cwd: this.#cwd });
for (const item of result.items as SettingsCapabilityItem[]) {
if (item.level === "project") {
merged = this.#deepMerge(merged, item.data as RawSettings);
if (Object.hasOwn(item.data, "shellPath")) shellPathSource = item.path;
}
}
} catch {
shellPathSource = undefined;
// Capability discovery is best-effort; the native project config below
// remains authoritative for its model-role layer and must not be hidden.
}
const projectConfigPath = path.join(this.#cwd, ".omp", "config.yml");
const nativeProject = quarantineInvalid
? await this.#loadYaml(projectConfigPath)
: (this.#unwrapYamlLoadResult(projectConfigPath, await this.#loadYamlIfPresent(projectConfigPath, false)) ??
{});
const nativeModelRoles = getByPath(nativeProject, ["modelRoles"]);
if (nativeModelRoles !== undefined) {
merged = this.#deepMerge(merged, { modelRoles: nativeModelRoles });
}
return {
settings: this.#migrateRawSettings(merged, quarantineInvalid),
fileSettings: structuredClone(nativeProject),
shellPathSource,
};
}
async #loadProjectSettings(): Promise<RawSettings> {
const result = await this.#readProjectSettings(true);
this.#projectFileSettings = result.fileSettings;
this.#projectShellPathSource = result.shellPathSource;
return result.settings;
}
async #readConfigOverlays(captureLegacyChangelogVersion = true): Promise<ConfigOverlayReadResult> {
let shellPathSource: string | undefined;
let settings: RawSettings = {};
for (const filePath of this.#configFiles) {
const overlay = await this.#loadOverlayYaml(filePath, captureLegacyChangelogVersion);
settings = this.#deepMerge(settings, overlay);
if (Object.hasOwn(overlay, "shellPath")) shellPathSource = filePath;
}
return { settings, shellPathSource };
}
async #loadConfigOverlays(): Promise<RawSettings> {
const result = await this.#readConfigOverlays();
this.#overlayShellPathSource = result.shellPathSource;
return result.settings;
}
/**
* Strict loader for explicit `--config` overlays: unlike `#loadYaml`,
* missing or malformed files are hard errors so a typo'd path cannot
* silently fall back to the persistent settings.
*/
async #loadOverlayYaml(filePath: string, captureLegacyChangelogVersion = true): Promise<RawSettings> {
let content: string;
try {
content = await Bun.file(filePath).text();
} catch (error) {
throw new Error(
isEnoent(error)
? `Config overlay not found: ${filePath}`
: `Failed to read config overlay ${filePath}: ${String(error)}`,
);
}
let parsed: unknown;
try {
parsed = YAML.parse(content);
} catch (error) {
throw new Error(`Failed to parse config overlay ${filePath}: ${String(error)}`);
}
if (parsed === null || parsed === undefined) return {};
if (typeof parsed !== "object" || Array.isArray(parsed)) {
throw new Error(`Config overlay must be a YAML mapping: ${filePath}`);
}
return this.#migrateRawSettings(parsed as RawSettings, captureLegacyChangelogVersion);
}
async #migrateFromLegacy(): Promise<void> {
if (!this.#configPath) return;
let settings: RawSettings = {};
let migrated = false;
// 1. Migrate from settings.json
const settingsJsonPath = path.join(this.#agentDir, "settings.json");
try {
const parsed: unknown = JSONC.parse(await Bun.file(settingsJsonPath).text());
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
settings = this.#deepMerge(settings, this.#migrateRawSettings(parsed as RawSettings));
migrated = true;
try {
fs.renameSync(settingsJsonPath, `${settingsJsonPath}.bak`);
} catch {}
}
} catch {}
// 2. Migrate from agent.db
try {
const dbSettings = this.#storage?.getSettings();
if (dbSettings) {
settings = this.#deepMerge(settings, this.#migrateRawSettings(dbSettings as RawSettings));
migrated = true;
}
} catch {}
// 3. Write merged settings
if (migrated && Object.keys(settings).length > 0) {
try {
await this.#writeYamlAtomically(this.#configPath, settings);
logger.debug("Settings: migrated to config.yml", { path: this.#configPath });
} catch {}
}
}
/** Apply schema migrations to raw settings */
#migrateRawSettings(raw: RawSettings, captureLegacyChangelogVersion = true): RawSettings {
// queueMode -> steeringMode
if ("queueMode" in raw && !("steeringMode" in raw)) {
raw.steeringMode = raw.queueMode;
delete raw.queueMode;
}
// lastChangelogVersion moved out of config.yml into the
// <agentDir>/last-changelog-version marker file so version bumps no
// longer dirty user-tracked configs. Capture for marker seeding (see
// #seedLastChangelogVersionMarker), then strip the key — the next
// config save drops it from disk.
if (captureLegacyChangelogVersion && typeof raw.lastChangelogVersion === "string") {
this.#legacyLastChangelogVersion ??= raw.lastChangelogVersion;
}
delete raw.lastChangelogVersion;
// collapseChangelog (boolean) -> startup.changelogMode (enum). Preserve
// every explicit legacy choice while giving new installs the schema's
// "summary" default: true -> summary, false -> expanded. A separately
// configured new mode always wins.
const startupObj = isRecord(raw.startup) ? (raw.startup as Record<string, unknown>) : undefined;
const legacyCollapseChangelog = typeof raw.collapseChangelog === "boolean" ? raw.collapseChangelog : undefined;
const flatChangelogMode = raw["startup.changelogMode"];
const normalizedFlatChangelogMode =
flatChangelogMode === "summary" || flatChangelogMode === "expanded" || flatChangelogMode === "hidden"
? flatChangelogMode
: undefined;
if (legacyCollapseChangelog !== undefined || normalizedFlatChangelogMode !== undefined) {
if (!startupObj) {
raw.startup = {};
}
const target = raw.startup as Record<string, unknown>;
if (target.changelogMode === undefined) {
target.changelogMode =
normalizedFlatChangelogMode ??
(legacyCollapseChangelog !== undefined ? (legacyCollapseChangelog ? "summary" : "expanded") : undefined);
}
}
delete raw.collapseChangelog;
delete raw["startup.changelogMode"];
// ask.timeout: ms -> seconds (if value > 1000, it's old ms format)
if (raw.ask && typeof (raw.ask as Record<string, unknown>).timeout === "number") {
const oldValue = (raw.ask as Record<string, unknown>).timeout as number;
if (oldValue > 1000) {
(raw.ask as Record<string, unknown>).timeout = Math.round(oldValue / 1000);
}
}
// Migrate old flat "theme" string to nested theme.dark/theme.light
if (typeof raw.theme === "string") {
const oldTheme = raw.theme;
if (oldTheme === "light" || oldTheme === "dark") {
// Built-in defaults — just remove, let new defaults apply
delete raw.theme;
} else {
// Custom theme — detect luminance to place in correct slot
const slot = isLightTheme(oldTheme) ? "light" : "dark";
raw.theme = { [slot]: oldTheme };
}
}
// inspect_image.enabled (boolean) -> inspect_image.mode (enum). Explicit
// user choices are preserved: true -> "on", false -> "off". Configs with
// no legacy key get the new "auto" default, which hides the tool for
// models with native image input. Handles nested and quoted-dotted
// ("inspect_image.enabled") sources; the target is always the nested
// form, which is the only shape the resolver reads.
const inspectImageObj = isRecord(raw.inspect_image) ? (raw.inspect_image as Record<string, unknown>) : undefined;
const legacyEnabled =
typeof inspectImageObj?.enabled === "boolean"
? inspectImageObj.enabled
: typeof raw["inspect_image.enabled"] === "boolean"
? (raw["inspect_image.enabled"] as boolean)
: undefined;
if (legacyEnabled !== undefined) {
if (!inspectImageObj) {
raw.inspect_image = {};
}
const target = raw.inspect_image as Record<string, unknown>;
const flatMode = raw["inspect_image.mode"];
if (target.mode === undefined) {
// A quoted-dotted explicit mode wins over the legacy boolean but
// must be normalized into the nested form the resolver reads.
target.mode =
typeof flatMode === "string" && (INSPECT_IMAGE_MODES as readonly string[]).includes(flatMode)
? flatMode
: legacyEnabled
? "on"
: "off";
}
delete target.enabled;
delete raw["inspect_image.enabled"];
delete raw["inspect_image.mode"];
}
// task.isolation.enabled (boolean) -> task.isolation.mode (enum)
const taskObj = raw.task as Record<string, unknown> | undefined;
const isolationObj = taskObj?.isolation as Record<string, unknown> | undefined;
if (isolationObj && "enabled" in isolationObj) {
if (typeof isolationObj.enabled === "boolean") {
isolationObj.mode = isolationObj.enabled ? "auto" : "none";
}
delete isolationObj.enabled;
}
// task.simple: removed — the task tool no longer accepts a per-call
// schema (workflows drive structured output via eval agent()) and the
// batch/context shape is gated by task.batch instead.
if (taskObj && "simple" in taskObj) {
delete taskObj.simple;
}
// task.eager / todo.eager: boolean -> enum (default | preferred | always).
// `true` reproduced the previous "on" behavior, which is now `always`.
if (taskObj && typeof taskObj.eager === "boolean") {
taskObj.eager = taskObj.eager ? "always" : "default";
}
const todoObj = raw.todo as Record<string, unknown> | undefined;
if (todoObj && typeof todoObj.eager === "boolean") {
todoObj.eager = todoObj.eager ? "always" : "default";
}
// task.isolation.mode: legacy values from before the pi-iso PAL refactor.
// `worktree` was git worktree → now lives under `rcopy`. `fuse-overlay`
// and `fuse-projfs` are now the platform-named `overlayfs` / `projfs`
// kinds; the PAL falls back internally when the chosen one isn't
// available, so we don't need the old TS-side platform guards.
if (isolationObj && typeof isolationObj.mode === "string") {
const legacy: Record<string, string> = {
worktree: "rcopy",
"fuse-overlay": "overlayfs",
"fuse-projfs": "projfs",
};
const mapped = legacy[isolationObj.mode as string];
if (mapped !== undefined) {
isolationObj.mode = mapped;
}
}
// edit.mode: removed "atom" and "vim" variants map back to "hashline"
const editObj = raw.edit as Record<string, unknown> | undefined;
if (editObj) {
if (editObj.mode === "atom" || editObj.mode === "vim") {
editObj.mode = "hashline";
}
const modelVariants = editObj.modelVariants as Record<string, unknown> | undefined;
if (modelVariants && typeof modelVariants === "object" && !Array.isArray(modelVariants)) {
for (const [pattern, variant] of Object.entries(modelVariants)) {
if (variant === "atom" || variant === "vim") {
modelVariants[pattern] = "hashline";
}
}
}
}
if (raw["edit.mode"] === "atom" || raw["edit.mode"] === "vim") {
raw["edit.mode"] = "hashline";
}
// compaction.strategy: removed local-model shake-summary mode; plain shake
// keeps the same mechanical artifact-backed reduction without background CPU.
const compactionObj = raw.compaction as Record<string, unknown> | undefined;
if (compactionObj?.strategy === "shake-summary") {
compactionObj.strategy = "shake";
}
if (raw["compaction.strategy"] === "shake-summary") {
raw["compaction.strategy"] = "shake";
}
// snapcompact.systemPrompt: boolean -> scoped enum.
const snapcompactObj = raw.snapcompact as Record<string, unknown> | undefined;
if (snapcompactObj && typeof snapcompactObj.systemPrompt === "boolean") {
snapcompactObj.systemPrompt = snapcompactObj.systemPrompt ? "all" : "none";
}
if (typeof raw["snapcompact.systemPrompt"] === "boolean") {
raw["snapcompact.systemPrompt"] = raw["snapcompact.systemPrompt"] ? "all" : "none";
}
// inlineToolDescriptors: boolean -> enum (auto | on | off). The old
// `true`/`false` mapped directly onto inline-on/inline-off, so preserve
// the user's explicit choice; new installs get the `auto` default that
// turns it on only for Gemini models.
if (typeof raw.inlineToolDescriptors === "boolean") {
raw.inlineToolDescriptors = raw.inlineToolDescriptors ? "on" : "off";
}
// statusLine: rename "plan_mode" segment to "mode"
const statusLineObj = raw.statusLine as Record<string, unknown> | undefined;
if (statusLineObj) {
for (const key of ["leftSegments", "rightSegments"] as const) {
const segments = statusLineObj[key];
if (Array.isArray(segments)) {
statusLineObj[key] = segments.map(seg => (seg === "plan_mode" ? "mode" : seg));
}
}
const segmentOptions = statusLineObj.segmentOptions as Record<string, unknown> | undefined;
if (segmentOptions && "plan_mode" in segmentOptions && !("mode" in segmentOptions)) {
segmentOptions.mode = segmentOptions.plan_mode;
delete segmentOptions.plan_mode;
}
}
// providers.parallelFetch (boolean) replaced by the providers.fetch reader
// priority enum. The new default ("auto") supersedes both old values —
// Parallel is now a deep fallback in the auto chain rather than the first
// choice — so drop the legacy key (flat and nested) and let the enum
// default apply.
const providersObj = raw.providers as Record<string, unknown> | undefined;
if (providersObj && "parallelFetch" in providersObj) {
delete providersObj.parallelFetch;
}
delete raw["providers.parallelFetch"];
// codexResets.autoRedeem: boolean -> tri-state enum.
// Existing explicit false keeps the old "do not run" behavior; missing
// config now falls through to the new "unset" default, which asks before
// the first eligible spend.
const codexResetsObj = raw.codexResets as Record<string, unknown> | undefined;
if (codexResetsObj && typeof codexResetsObj.autoRedeem === "boolean") {
codexResetsObj.autoRedeem = codexResetsObj.autoRedeem ? "yes" : "no";
}
if (typeof raw["codexResets.autoRedeem"] === "boolean") {
raw["codexResets.autoRedeem"] = raw["codexResets.autoRedeem"] ? "yes" : "no";
}
// Map legacy `memories.enabled` boolean to the explicit `memory.backend`
// enum if the latter hasn't been set yet. Idempotent: subsequent
// migrations are no-ops once memory.backend is materialised.
const memoryBackendObj = raw.memory as Record<string, unknown> | undefined;
const memoryBackendSet = memoryBackendObj && typeof memoryBackendObj.backend === "string";
const memoriesObj = raw.memories as Record<string, unknown> | undefined;
if (!memoryBackendSet && memoriesObj && typeof memoriesObj.enabled === "boolean") {
const next = memoriesObj.enabled ? "local" : "off";
const memoryRoot = (memoryBackendObj ?? {}) as Record<string, unknown>;
memoryRoot.backend = next;
raw.memory = memoryRoot;
}
// Rename the legacy local `mnemosyne` memory backend to `mnemopi`.
// - `memory.backend: "mnemosyne"` now selects the renamed backend.
// - the top-level `mnemosyne` settings object becomes `mnemopi`.
// Idempotent: skips the object move once `mnemopi` is materialised.
if (memoryBackendObj && memoryBackendObj.backend === "mnemosyne") {
memoryBackendObj.backend = "mnemopi";
}
if ("mnemosyne" in raw && !("mnemopi" in raw)) {
raw.mnemopi = raw.mnemosyne;
delete raw.mnemosyne;
}
// hindsight: dynamicBankId/agentName -> scoping enum + bankId
// - dynamicBankId=true → scoping="per-project" (closest semantic match;
// the legacy `agent::project::channel::user` tuple was per-project in
// practice — the channel/user env vars were rarely set).
// - hindsight.agentName was only used as the agent slot in the legacy
// dynamic tuple; if the user customised it we surface it as the new
// bankId base when no explicit bankId is set.
const hindsightObj = raw.hindsight as Record<string, unknown> | undefined;
if (hindsightObj) {
if ("dynamicBankId" in hindsightObj) {
if (!("scoping" in hindsightObj) && hindsightObj.dynamicBankId === true) {
hindsightObj.scoping = "per-project";
}
delete hindsightObj.dynamicBankId;
}
if ("agentName" in hindsightObj) {
const agentName = hindsightObj.agentName;
if (
!("bankId" in hindsightObj) &&
typeof agentName === "string" &&
agentName.trim().length > 0 &&
agentName !== "omp"
) {
hindsightObj.bankId = agentName;
}
delete hindsightObj.agentName;
}
}
// power.preventIdleSleep / power.preventSystemSleep / power.declareUserActive
// / power.preventDisplaySleep (four booleans) → power.sleepPrevention enum.
// The enum is cumulative: each level adds the flags of all lower levels.
// Migration picks the highest level whose condition is met, scanning from
// most to least aggressive so a single enum value captures the old state.
if (
!("sleepPrevention" in ((raw.power as Record<string, unknown>) ?? {})) &&
raw["power.sleepPrevention"] === undefined
) {
const powerObj = raw.power as Record<string, unknown> | undefined;
const getFlag = (key: string): boolean | undefined => {
const nested = powerObj?.[key];
const flat = raw[`power.${key}`];
const value = nested ?? flat;
return typeof value === "boolean" ? value : undefined;
};
const idle = getFlag("preventIdleSleep");
const system = getFlag("preventSystemSleep");
const user = getFlag("declareUserActive");
const display = getFlag("preventDisplaySleep");
const anySet = idle !== undefined || system !== undefined || user !== undefined || display !== undefined;
if (anySet) {
const mode = system || user ? "system" : display ? "display" : idle !== false ? "idle" : "off";
const powerRoot = (powerObj ?? {}) as Record<string, unknown>;
powerRoot.sleepPrevention = mode;
raw.power = powerRoot;
}
// Clean up old keys (nested + flat)
if (powerObj) {
delete powerObj.preventIdleSleep;
delete powerObj.preventSystemSleep;
delete powerObj.declareUserActive;
delete powerObj.preventDisplaySleep;
}
delete raw["power.preventIdleSleep"];
delete raw["power.preventSystemSleep"];
delete raw["power.declareUserActive"];
delete raw["power.preventDisplaySleep"];
}
// Migration for renamed settings grep.* and glob.* from search.* and find.*:
// 1. Nested settings: find -> glob, search -> grep (per-property merge to avoid clobbering)
const ensureRawObject = (key: "glob" | "grep"): Record<string, unknown> => {
const current = raw[key];
if (isRecord(current)) {
return current;
}
const created: Record<string, unknown> = {};
raw[key] = created;
return created;
};
if ("find" in raw) {
const findObj = raw.find;
if (isRecord(findObj)) {
const globObj = ensureRawObject("glob");
const findKeys: Array<"enabled"> = ["enabled"];
for (const key of findKeys) {
if (key in findObj && !(key in globObj)) {
globObj[key] = findObj[key];
}
}
}
delete raw.find;
}
if ("search" in raw) {
const searchObj = raw.search;
if (isRecord(searchObj)) {
const grepObj = ensureRawObject("grep");
const searchKeys: Array<"enabled" | "contextBefore" | "contextAfter"> = [
"enabled",
"contextBefore",
"contextAfter",
];
for (const key of searchKeys) {
if (key in searchObj && !(key in grepObj)) {
grepObj[key] = searchObj[key];
}
}
}
delete raw.search;
}
// 2. Flat settings keys: map them to the proper nested target so get/set resolves them correctly
if ("find.enabled" in raw) {
const globObj = ensureRawObject("glob");
if (!("enabled" in globObj)) {
globObj.enabled = raw["find.enabled"];
}
delete raw["find.enabled"];
}
if ("search.enabled" in raw) {
const grepObj = ensureRawObject("grep");
if (!("enabled" in grepObj)) {
grepObj.enabled = raw["search.enabled"];
}
delete raw["search.enabled"];
}
if ("search.contextBefore" in raw) {
const grepObj = ensureRawObject("grep");
if (!("contextBefore" in grepObj)) {
grepObj.contextBefore = raw["search.contextBefore"];
}
delete raw["search.contextBefore"];
}
if ("search.contextAfter" in raw) {
const grepObj = ensureRawObject("grep");
if (!("contextAfter" in grepObj)) {
grepObj.contextAfter = raw["search.contextAfter"];
}
delete raw["search.contextAfter"];
}
// Also clean up any empty nested objects we might have created or left behind
if (raw.glob && typeof raw.glob === "object" && Object.keys(raw.glob).length === 0) {
delete raw.glob;
}
if (raw.grep && typeof raw.grep === "object" && Object.keys(raw.grep).length === 0) {
delete raw.grep;
}
// readHashLines: removed. Hashline anchors are now driven solely by
// edit.mode === "hashline"; the separate read toggle only ever produced
// the incoherent "hashline edits without addressable anchors" state.
delete raw.readHashLines;
// serviceTier (single enum with scoped openai-only/claude-only sentinels)
// → per-family tier.openai/tier.anthropic/tier.google; serviceTierSubagent
// → tier.subagent; serviceTierAdvisor → tier.advisor. `fastModeScope` is
// dropped — per-family scoping is now expressed by the three tier settings.
const tierObj = isRecord(raw.tier) ? raw.tier : {};
let tierTouched = false;
const setTier = (family: string, value: unknown): void => {
if (value !== undefined && !(family in tierObj)) {
tierObj[family] = value;
tierTouched = true;
}
};
if (typeof raw.serviceTier === "string") {
switch (raw.serviceTier) {
case "priority":
setTier("openai", "priority");
setTier("anthropic", "priority");
setTier("google", "priority");
break;
case "openai-only":
setTier("openai", "priority");
break;
case "claude-only":
setTier("anthropic", "priority");
break;
case "auto":
case "default":
case "flex":
case "scale":
setTier("openai", raw.serviceTier);
break;
}
delete raw.serviceTier;
}
const mapInheritTier = (value: unknown): unknown =>
value === "openai-only" || value === "claude-only" ? "priority" : value;
if ("serviceTierSubagent" in raw) {
setTier("subagent", mapInheritTier(raw.serviceTierSubagent));
delete raw.serviceTierSubagent;
}
if ("serviceTierAdvisor" in raw) {
setTier("advisor", mapInheritTier(raw.serviceTierAdvisor));
delete raw.serviceTierAdvisor;
}
if (tierTouched) raw.tier = tierObj;
delete raw.fastModeScope;
// advisor.subagents (blanket advisor on every spawned subagent) → per-agent
// task.agentAdvisor, migrated to the bundled generic `task` agent. An
// explicit boolean maps to "on"/"off" IN THE SAME LAYER — migration runs
// per file, so a project-level `false` must keep overriding a global
// `true` after both layers migrate.
{
const advisorObj = isRecord(raw.advisor) ? raw.advisor : undefined;
const legacySubagents =
advisorObj && "subagents" in advisorObj ? advisorObj.subagents : raw["advisor.subagents"];
if (typeof legacySubagents === "boolean") {
const taskObj = isRecord(raw.task) ? raw.task : {};
const agentAdvisor = isRecord(taskObj.agentAdvisor) ? taskObj.agentAdvisor : {};
if (!("task" in agentAdvisor)) agentAdvisor.task = legacySubagents ? "on" : "off";
taskObj.agentAdvisor = agentAdvisor;
raw.task = taskObj;
}
if (advisorObj) delete advisorObj.subagents;
delete raw["advisor.subagents"];
}
// v17 renames that used to nest under a boolean parent path:
// dev.autoqa.consent -> dev.autoqaConsent
// todo.reminders.max -> todo.remindersMax
migrateNestedLeafRename(
raw,
"dev",
"autoqa",
"consent",
"autoqaConsent",
value => value === "unset" || value === "granted" || value === "denied",
);
migrateNestedLeafRename(
raw,
"todo",
"reminders",
"max",
"remindersMax",
value => typeof value === "number" && Number.isFinite(value),
);
// BM25 tool discovery removal: tools.discoveryMode / tools.essentialOverride /
// mcp.discoveryMode / mcp.discoveryDefaultServers are gone with no
// replacement (`tools.xdev` stays at its own default). Dead keys are
// deleted so they stop lingering in config.yml.
const toolsObj = raw.tools as Record<string, unknown> | undefined;
if (toolsObj) {
delete toolsObj.discoveryMode;
delete toolsObj.essentialOverride;
}
delete raw["tools.discoveryMode"];
delete raw["tools.essentialOverride"];
const mcpObj = raw.mcp as Record<string, unknown> | undefined;
if (mcpObj) {
delete mcpObj.discoveryMode;
delete mcpObj.discoveryDefaultServers;
}
delete raw["mcp.discoveryMode"];
delete raw["mcp.discoveryDefaultServers"];
// providers.webSearch / providers.image (single preferred provider) →
// providers.webSearchOrder / providers.imageOrder (priority lists). A
// concrete legacy choice becomes the head of the new list with every
// remaining provider appended in its built-in order, so the old
// preference stays #1 and the fallback chain is written out explicitly.
// "auto" (or an unknown id) just drops the key — the default chain.
const providerPrefsObj = raw.providers as Record<string, unknown> | undefined;
const migrateProviderPreference = (
legacyKey: string,
orderKey: string,
expand: (value: string) => string[] | undefined,
): void => {
const flatLegacyKey = `providers.${legacyKey}`;
const legacy = providerPrefsObj?.[legacyKey] ?? raw[flatLegacyKey];
if (legacy === undefined) return;
const existingOrder = providerPrefsObj?.[orderKey] ?? raw[`providers.${orderKey}`];
const orderAlreadySet = Array.isArray(existingOrder) && existingOrder.length > 0;
if (!orderAlreadySet && typeof legacy === "string") {
const expanded = expand(legacy);
if (expanded) {
const root = providerPrefsObj ?? {};
root[orderKey] = expanded;
raw.providers = root;
}
}
if (providerPrefsObj) delete providerPrefsObj[legacyKey];
delete raw[flatLegacyKey];
};
migrateProviderPreference("webSearch", "webSearchOrder", value =>
value !== "auto" && isSearchProviderId(value)
? [value, ...SEARCH_PROVIDER_ORDER.filter(id => id !== value)]
: undefined,
);
migrateProviderPreference("image", "imageOrder", value =>
value !== "auto" && isImageProviderId(value)
? [value, ...AUTO_IMAGE_PROVIDER_ORDER.filter(id => id !== value)]
: undefined,
);
// Consolidate the retired Exa suite toggles onto the sole remaining
// provider switch. The old runtime required both `enabled` and
// `enableSearch`, so preserve that AND semantics when both are present.
// Researcher and Websets were removed with the standalone Exa tools.
const exaObj = isRecord(raw.exa) ? raw.exa : undefined;
const exaEnabledValues = [
exaObj?.enabled,
raw["exa.enabled"],
exaObj?.enableSearch,
raw["exa.enableSearch"],
].filter((value): value is boolean => typeof value === "boolean");
const hasFlatExaSetting =
"exa.enabled" in raw ||
"exa.enableSearch" in raw ||
"exa.enableResearcher" in raw ||
"exa.enableWebsets" in raw;
if (exaObj || hasFlatExaSetting) {
const exaRoot = exaObj ?? {};
if (exaEnabledValues.length > 0) {
exaRoot.enabled = exaEnabledValues.every(Boolean);
}
delete exaRoot.enableSearch;
delete exaRoot.enableResearcher;
delete exaRoot.enableWebsets;
if (Object.keys(exaRoot).length > 0) {
raw.exa = exaRoot;
} else {
delete raw.exa;
}
delete raw["exa.enabled"];
delete raw["exa.enableSearch"];
delete raw["exa.enableResearcher"];
delete raw["exa.enableWebsets"];
}
// computer.backend and model-specific controller routing were removed
// when the computer tool moved to one native desktop implementation.
const computerObj = isRecord(raw.computer) ? raw.computer : undefined;
if (computerObj && "backend" in computerObj) {
delete computerObj.backend;
if (Object.keys(computerObj).length === 0) {
delete raw.computer;
}
}
delete raw["computer.backend"];
return raw;
}
/**
* One-time migration: seed the last-changelog-version marker file from the
* legacy config.yml key. An existing marker always wins — it is the newer
* source of truth.
*/
async #seedLastChangelogVersionMarker(): Promise<void> {
const legacy = this.#legacyLastChangelogVersion;
if (!legacy) return;
const markerPath = getLastChangelogVersionPath(this.#agentDir);
try {
if ((await Bun.file(markerPath).text()).trim()) return;
} catch (error) {
if (!isEnoent(error)) return;
}
try {
await Bun.write(markerPath, legacy);
} catch (error) {
logger.warn("Settings: failed to seed last-changelog-version marker", { error: String(error) });
}
}
// ─────────────────────────────────────────────────────────────────────────
// Saving
// ─────────────────────────────────────────────────────────────────────────
async #writeYamlAtomically(filePath: string, settings: RawSettings): Promise<void> {
const tempPath = `${filePath}.${process.pid}.${randomUUID()}.tmp`;
let removeTemp = false;
try {
const handle = await fs.promises.open(tempPath, "wx", 0o600);
removeTemp = true;
try {
await handle.writeFile(YAML.stringify(settings, null, 2), "utf8");
await handle.sync();
} finally {
await handle.close();
}
try {
await fs.promises.rename(tempPath, filePath);
} catch (error) {
if (!hasFsCode(error, "EPERM")) throw error;
await this.#replaceYamlAfterEperm(tempPath, filePath, error);
}
removeTemp = false;
} finally {
if (removeTemp) {
await fs.promises.rm(tempPath, { force: true }).catch(() => {});
}
}
}
async #replaceYamlAfterEperm(tempPath: string, filePath: string, renameError: unknown): Promise<void> {
const backupPath = `${filePath}.${process.pid}.${randomUUID()}.bak`;
try {
await fs.promises.rename(filePath, backupPath);
} catch (error) {
if (isEnoent(error)) {
await fs.promises.rename(tempPath, filePath);
return;
}
throw renameError;
}
try {
await fs.promises.rename(tempPath, filePath);
} catch (replaceError) {
try {
await fs.promises.rename(backupPath, filePath);
} catch (rollbackError) {
throw new Error(
`Failed to replace settings file after EPERM (original: ${toError(renameError).message}; retry: ${
toError(replaceError).message
}; rollback: ${toError(rollbackError).message})`,
{ cause: toError(renameError) },
);
}
throw replaceError;
}
try {
await fs.promises.rm(backupPath);
} catch (error) {
if (!isEnoent(error)) {
logger.warn("Settings: failed to remove atomic-write backup", {
path: filePath,
backupPath,
error: toError(error).message,
});
}
}
}
#queueSave(): void {
if (!this.#persist || !this.#configPath) return;
// Debounce: wait 100ms for more changes
clearTimeout(this.#saveTimer);
this.#saveTimer = setTimeout(() => {
this.#saveTimer = undefined;
const previousSave = this.#savePromise;
const savePromise = previousSave ? previousSave.then(() => this.#saveNow()) : this.#saveNow();
this.#savePromise = savePromise;
savePromise
.catch(err => {
logger.warn("Settings: background save failed", { error: String(err) });
})
.finally(() => {
if (this.#savePromise === savePromise) {
this.#savePromise = undefined;
}
});
}, 100);
}
async #saveNow(): Promise<void> {
if (this.#savesCancelled || !this.#persist || !this.#configPath) return;
if (this.#modified.size === 0 && this.#modifiedGlobalModelRoles.size === 0) return;
const configPath = this.#configPath;
const modifiedPaths = [...this.#modified];
const modifiedModelRoles = [...this.#modifiedGlobalModelRoles];
const globalRolesAtStart = this.#modelRolesFromLayer(this.#global);
this.#modified.clear();
this.#modifiedGlobalModelRoles.clear();
try {
await this.#withYamlWriteLock(configPath, async writePath => {
// Re-read to preserve external changes. If this instance moved a
// malformed file aside, recover from its last in-memory state
// rather than recreating the config from only the pending path.
const loaded = await this.#loadYamlIfPresentForWriteLocked(configPath, writePath);
const current =
loaded ?? (this.#quarantinedYamlTargets.has(configPath) ? structuredClone(this.#global) : {});
// Apply only our modified whole-value paths
for (const modPath of modifiedPaths) {
const segments = modPath.split(".");
const value = getByPath(this.#global, segments);
setByPath(current, segments, value);
}
// Merge only the model roles captured by this save. Then retain
// any role changed while the async read/lock was pending before
// replacing #global, so the follow-up save still sees its value.
const latestGlobalRoles = this.#modelRolesFromLayer(this.#global);
const rolesToPreserve = new Set(this.#modifiedGlobalModelRoles);
for (const role in globalRolesAtStart) {
if (globalRolesAtStart[role] !== latestGlobalRoles[role]) {
rolesToPreserve.add(role);
}
}
for (const role in latestGlobalRoles) {
if (globalRolesAtStart[role] !== latestGlobalRoles[role]) {
rolesToPreserve.add(role);
}
}
if (modifiedModelRoles.length > 0 || rolesToPreserve.size > 0) {
const currentRoles = getByPath(current, ["modelRoles"]);
const mergedRoles: Record<string, unknown> = isRecord(currentRoles) ? { ...currentRoles } : {};
for (const role of modifiedModelRoles) {
if (Object.hasOwn(globalRolesAtStart, role)) {
mergedRoles[role] = globalRolesAtStart[role];
} else {
delete mergedRoles[role];
}
}
for (const role of rolesToPreserve) {
if (Object.hasOwn(latestGlobalRoles, role)) {
mergedRoles[role] = latestGlobalRoles[role];
} else {
delete mergedRoles[role];
}
}
setByPath(current, ["modelRoles"], mergedRoles);
}
// Update our global with any external changes we preserved
this.#global = current;
await this.#writeYamlAtomically(writePath, this.#global);
this.#quarantinedYamlTargets.delete(configPath);
// These pending roles were included in this write. Remove each
// only if no newer local change arrived while the write was in flight.
const globalRolesAfterWrite = this.#modelRolesFromLayer(this.#global);
for (const role of rolesToPreserve) {
if (latestGlobalRoles[role] === globalRolesAfterWrite[role]) {
this.#modifiedGlobalModelRoles.delete(role);
}
}
});
} catch (error) {
logger.warn("Settings: save failed", { error: String(error) });
// Re-add failed paths for retry
for (const p of modifiedPaths) {
this.#modified.add(p);
}
for (const role of modifiedModelRoles) {
this.#modifiedGlobalModelRoles.add(role);
}
this.#rebuildMerged();
throw error;
}
this.#rebuildMerged();
}
#queueProjectSave(): void {
if (!this.#persist) return;
clearTimeout(this.#projectSaveTimer);
this.#projectSaveTimer = setTimeout(() => {
this.#projectSaveTimer = undefined;
const savePromise = this.#saveProjectNow();
this.#projectSavePromise = savePromise;
savePromise
.catch(err => {
logger.warn("Settings: background project save failed", { error: String(err) });
})
.finally(() => {
if (this.#projectSavePromise === savePromise) {
this.#projectSavePromise = undefined;
}
});
}, 100);
}
async #saveProjectNow(): Promise<void> {
if (this.#savesCancelled || !this.#persist || this.#modifiedProjectModelRoles.size === 0) return;
const projectConfigPath = path.join(this.#cwd, ".omp", "config.yml");
const modifiedModelRoles = [...this.#modifiedProjectModelRoles];
this.#modifiedProjectModelRoles.clear();
try {
await fs.promises.mkdir(path.dirname(projectConfigPath), { recursive: true });
await this.#withYamlWriteLock(projectConfigPath, async writePath => {
const loaded = await this.#loadYamlIfPresentForWriteLocked(projectConfigPath, writePath);
const projectSettings =
loaded ??
(this.#quarantinedYamlTargets.has(projectConfigPath) ? structuredClone(this.#projectFileSettings) : {});
const projectRoles = getByPath(this.#project, ["modelRoles"]);
for (const role of modifiedModelRoles) {
const value = isRecord(projectRoles) ? projectRoles[role] : undefined;
setByPath(projectSettings, ["modelRoles", role], value);
}
await this.#writeYamlAtomically(writePath, projectSettings);
this.#projectFileSettings = structuredClone(projectSettings);
this.#quarantinedYamlTargets.delete(projectConfigPath);
});
invalidateCapabilityFsCache(projectConfigPath);
} catch (error) {
for (const role of modifiedModelRoles) {
this.#modifiedProjectModelRoles.add(role);
}
throw error;
}
this.#rebuildMerged();
}
// ─────────────────────────────────────────────────────────────────────────
// Utilities
// ─────────────────────────────────────────────────────────────────────────
#projectSettingsForMerge(): RawSettings {
const projectRoles = getByPath(this.#project, ["modelRoles"]);
if (!isRecord(projectRoles)) return this.#project;
let filteredRoles: Record<string, unknown> | undefined;
for (const role in projectRoles) {
if (!Object.hasOwn(projectRoles, role) || modelRoleValueFromUnknown(projectRoles[role]) !== undefined)
continue;
filteredRoles ??= { ...projectRoles };
delete filteredRoles[role];
}
return filteredRoles ? { ...this.#project, modelRoles: filteredRoles } : this.#project;
}
#rebuildMerged(): void {
this.#merged = this.#deepMerge(this.#deepMerge({}, this.#global), this.#projectSettingsForMerge());
this.#merged = this.#deepMerge(this.#merged, this.#configOverlay);
this.#merged = this.#deepMerge(this.#merged, this.#overrides);
this.#resolvedCache.clear();
this.#editVariantCache = undefined;
}
#fireAllHooks(): void {
for (const key of Object.keys(SETTING_HOOKS) as SettingPath[]) {
const hook = SETTING_HOOKS[key];
if (hook) {
const value = this.get(key);
hook(value, value);
}
}
}
#deepMerge(base: RawSettings, overrides: RawSettings): RawSettings {
const result = { ...base };
for (const key of Object.keys(overrides)) {
const override = overrides[key];
const baseVal = base[key];
if (override === undefined) continue;
if (
typeof override === "object" &&
override !== null &&
!Array.isArray(override) &&
typeof baseVal === "object" &&
baseVal !== null &&
!Array.isArray(baseVal)
) {
result[key] = this.#deepMerge(baseVal as RawSettings, override as RawSettings);
} else {
result[key] = override;
}
}
return result;
}
}
// ═══════════════════════════════════════════════════════════════════════════
// Setting Hooks
// ═══════════════════════════════════════════════════════════════════════════
type SettingHook<P extends SettingPath> = (value: SettingValue<P>, prev: SettingValue<P>) => void;
/**
* Minimal change-notification primitive backing the exported `on*Changed`
* subscriptions. Holds a listener set, hands out unsubscribe closures, and
* isolates errors so a single throwing listener can't abort the rest or bubble
* out of `Settings.set()`.
*
* @typeParam A - argument tuple forwarded to each listener on `fire`.
*/
class SettingSignal<A extends unknown[] = []> {
#listeners = new Set<(...args: A) => void>();
constructor(private readonly label: string) {}
/** Subscribe `cb`; returns an unsubscribe function. */
on(cb: (...args: A) => void): () => void {
this.#listeners.add(cb);
return () => {
this.#listeners.delete(cb);
};
}
/**
* Invoke every listener with `args`. Iterates a snapshot so a listener may
* (un)subscribe mid-fire without re-entrancy — the Hindsight backend
* re-registers the fresh state's listener on every rebuild — and wraps each
* call so a throwing listener is logged and skipped instead of aborting the
* rest.
*/
fire(...args: A): void {
for (const cb of [...this.#listeners]) {
try {
cb(...args);
} catch (err) {
logger.warn(`Settings: ${this.label} hook failed`, { error: String(err) });
}
}
}
}
const SETTING_HOOKS: Partial<Record<SettingPath, SettingHook<any>>> = {
"theme.dark": value => {
if (typeof value === "string") {
setAutoThemeMapping("dark", value);
}
},
"theme.light": value => {
if (typeof value === "string") {
setAutoThemeMapping("light", value);
}
},
symbolPreset: value => {
if (typeof value === "string" && (value === "unicode" || value === "nerd" || value === "ascii")) {
setSymbolPreset(value).catch(err => {
logger.warn("Settings: symbolPreset hook failed", { preset: value, error: String(err) });
});
}
},
colorBlindMode: value => {
if (typeof value === "boolean") {
setColorBlindMode(value).catch(err => {
logger.warn("Settings: colorBlindMode hook failed", { enabled: value, error: String(err) });
});
}
},
"provider.appendOnlyContext": value => {
if (typeof value === "string") {
appendOnlyModeSignal.fire(value);
}
},
"providers.maxInFlightRequests": value => {
configureProviderMaxInFlightRequests(validateProviderMaxInFlightRequests(value));
},
"secrets.enabled": value => {
configureCredentialRedaction(value === true);
},
"hindsight.bankId": () => hindsightScopeSignal.fire(),
"hindsight.bankIdPrefix": () => hindsightScopeSignal.fire(),
"hindsight.scoping": () => hindsightScopeSignal.fire(),
extendedContext: () => extendedContextSignal.fire(),
"worktree.base": value => {
const dir = typeof value === "string" && value.trim() ? value : undefined;
// Always call so an unset/empty value clears a previously-applied override.
// setWorktreesDir expands `~`, rejects relative paths, and returns the
// applied absolute path (or undefined when cleared/rejected).
if (dir && !setWorktreesDir(dir)) {
logger.warn("Settings: worktree.base must be an absolute or ~-relative path; ignoring", { value: dir });
} else if (!dir) {
setWorktreesDir(undefined);
}
},
};
/** Fires when `provider.appendOnlyContext` changes at runtime. */
const appendOnlyModeSignal = new SettingSignal<[value: string]>("provider.appendOnlyContext");
/**
* Subscribe to append-only mode setting changes.
* Returns an unsubscribe function. Multiple sessions (main + subagents)
* can register independently without overwriting each other.
*/
export const onAppendOnlyModeChanged = (cb: (value: string) => void) => appendOnlyModeSignal.on(cb);
/** Fires when any model role changes at runtime. */
const modelRolesSignal = new SettingSignal("modelRoles");
/** Subscribe to model role changes. Returns an unsubscribe function. */
export const onModelRolesChanged: (cb: () => void) => () => void = modelRolesSignal.on.bind(modelRolesSignal);
/** Fires when `extendedContext` changes at runtime. */
const extendedContextSignal = new SettingSignal("extendedContext");
/**
* Subscribe to extended-context setting changes. Sessions re-derive their
* model's effective context window (the registry clamps premium long-context
* models to the standard-pricing threshold while the setting is off).
* Returns an unsubscribe function.
*/
export const onExtendedContextChanged = (cb: () => void) => extendedContextSignal.on(cb);
/** Fires when `statusLine.sessionAccent` changes at runtime. */
const statusLineSessionAccentSignal = new SettingSignal("statusLine.sessionAccent");
/**
* Subscribe to session-accent setting changes.
* Returns an unsubscribe function. Callers should re-read settings in the callback.
*/
export const onStatusLineSessionAccentChanged = (cb: () => void) => statusLineSessionAccentSignal.on(cb);
/** Fires when any `hindsight.bankId` / `bankIdPrefix` / `scoping` value changes. */
const hindsightScopeSignal = new SettingSignal("hindsight scope");
/**
* Subscribe to changes in the Hindsight bank-scoping settings. Lets the
* Hindsight backend rebuild the active `HindsightSessionState` when the
* operator switches `hindsight.bankId`, `hindsight.bankIdPrefix`, or
* `hindsight.scoping` mid-session so subsequent retain/recall calls land in
* the new bank instead of the one selected at session start.
*
* Returns an unsubscribe function. The callback receives no arguments — the
* caller is expected to re-read the relevant settings via `Settings.get`.
*/
export const onHindsightScopeChanged = (cb: () => void) => hindsightScopeSignal.on(cb);
// ═══════════════════════════════════════════════════════════════════════════
// Global Singleton
// ═══════════════════════════════════════════════════════════════════════════
/**
* Weak registry of every constructed instance so `resetSettingsForTest` can
* disarm stray background saves on isolated instances too. WeakRefs never
* retain instances; the set is cleared on every test reset.
*/
const liveSettingsInstances = new Set<WeakRef<Settings>>();
let globalInstance: Settings | null = null;
let globalInstancePromise: Promise<Settings> | null = null;
let boundSettingsInstance: Settings | null = null;
let boundSettingsMethods = new Map<PropertyKey, unknown>();
function clearBoundSettingsMethods(): void {
boundSettingsInstance = null;
boundSettingsMethods = new Map<PropertyKey, unknown>();
}
export function isSettingsInitialized(): boolean {
return globalInstance !== null;
}
/**
* Reset the global singleton for testing.
* @internal
*/
export function resetSettingsForTest(): void {
// Disarm every constructed instance's debounced saves — including isolated
// (non-singleton) instances: an armed timer or chained in-flight save on a
// dropped instance fires mid-way through the NEXT test and races its file
// locks/spies (cross-file pollution).
for (const ref of liveSettingsInstances) {
ref.deref()?.cancelPendingSaves();
}
liveSettingsInstances.clear();
globalInstance = null;
globalInstancePromise = null;
clearBoundSettingsMethods();
configureProviderMaxInFlightRequests(undefined);
configureCredentialRedaction(false);
}
/**
* The global settings singleton.
* Must call `Settings.init()` before using.
*/
export const settings = new Proxy({} as Settings, {
get(_target, prop) {
if (!globalInstance) {
throw new Error("Settings not initialized. Call Settings.init() first.");
}
if (boundSettingsInstance !== globalInstance) {
clearBoundSettingsMethods();
boundSettingsInstance = globalInstance;
}
const value = (globalInstance as unknown as Record<PropertyKey, unknown>)[prop];
if (typeof value === "function") {
const cached = boundSettingsMethods.get(prop);
if (cached) return cached;
const bound = value.bind(globalInstance);
boundSettingsMethods.set(prop, bound);
return bound;
}
return value;
},
});
// ═══════════════════════════════════════════════════════════════════════════
// Helpers
// ═══════════════════════════════════════════════════════════════════════════