/** * 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 * as fs from "node:fs"; import * as os from "node:os"; import * as path from "node:path"; import { getAgentDbPath, getAgentDir, getProjectDir, isEnoent, logger, procmgr, setDefaultTabWidth, } from "@oh-my-pi/pi-utils"; import { YAML } from "bun"; import { type Settings as SettingsCapabilityItem, settingsCapability } from "../capability/settings"; import type { ModelRole } from "../config/model-registry"; import { loadCapability } from "../discovery"; import { isLightTheme, setAutoThemeMapping, setColorBlindMode, setSymbolPreset } from "../modes/theme/theme"; import { AgentStorage } from "../session/agent-storage"; import { type EditMode, normalizeEditMode } from "../utils/edit-mode"; import { withFileLock } from "./file-lock"; 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; } export interface SettingsOptions { /** Current working directory for project settings discovery */ cwd?: string; /** Agent directory for config.yml storage */ agentDir?: string; /** Don't persist to disk (for tests) */ inMemory?: boolean; /** Initial overrides */ overrides?: Partial>; } // ═══════════════════════════════════════════════════════════════════════════ // Path Utilities // ═══════════════════════════════════════════════════════════════════════════ /** * Get a nested value from an object by path segments. */ function getByPath(obj: RawSettings, segments: 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)[segment]; } return current; } /** * 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; } const PATH_SCOPED_ARRAY_SETTINGS = new Set(["enabledModels", "disabledProviders"]); type PathScopedStringArrayEntry = { path?: unknown; paths?: unknown; pathPrefix?: unknown; pathPrefixes?: unknown; values?: unknown; items?: unknown; models?: unknown; providers?: unknown; }; function normalizePathPrefix(prefix: string): string { const expanded = prefix === "~" ? os.homedir() : prefix.startsWith("~/") ? path.join(os.homedir(), prefix.slice(2)) : prefix; return path.resolve(expanded); } 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 shallowStringRecord(value: unknown): Record { if (!value || typeof value !== "object" || Array.isArray(value)) return {}; const result: Record = {}; for (const [key, item] of Object.entries(value)) { if (typeof item === "string") { result[key] = item; } } return result; } 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; /** Global settings from config.yml */ #global: RawSettings = {}; /** Project settings from .claude/settings.yml etc */ #project: RawSettings = {}; /** Runtime overrides (not persisted) */ #overrides: RawSettings = {}; /** Merged view (global + project + overrides) */ #merged: RawSettings = {}; /** Paths modified during this session (for partial save) */ #modified = new Set(); /** Pending save (debounced) */ #saveTimer?: NodeJS.Timeout; #savePromise?: Promise; /** 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, "config.yml"); this.#persist = !options.inMemory; if (options.overrides) { for (const [key, value] of Object.entries(options.overrides)) { setByPath(this.#overrides, key.split("."), value); } } } // ───────────────────────────────────────────────────────────────────────── // Factory Methods // ───────────────────────────────────────────────────────────────────────── /** * Initialize the global singleton. * Call once at startup before accessing `settings`. */ static init(options: SettingsOptions = {}): Promise { if (globalInstancePromise) return globalInstancePromise; const instance = new Settings(options); const promise = instance.#load(); globalInstancePromise = promise; return promise.then( instance => { globalInstance = instance; globalInstancePromise = Promise.resolve(instance); return instance; }, error => { globalInstance = null; throw error; }, ); } /** * Create an isolated instance for testing. * Does not affect the global singleton. */ static isolated(overrides: Partial> = {}): Settings { const instance = new Settings({ inMemory: true, overrides }); 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

(path: P): SettingValue

{ const segments = path.split("."); const value = getByPath(this.#merged, segments); if (value !== undefined) { const pathScopedValue = resolvePathScopedStringArray(path, value, this.#cwd); return (pathScopedValue ?? value) as SettingValue

; } return getDefault(path); } /** * Set a setting value (sync). * Updates global settings and queues a background save. * Triggers hooks for settings that have side effects. */ set

(path: P, value: SettingValue

): void { const prev = this.get(path); const segments = path.split("."); setByPath(this.#global, segments, value); this.#modified.add(path); this.#rebuildMerged(); this.#queueSave(); // Trigger hook if exists const hook = SETTING_HOOKS[path]; if (hook) { hook(value, prev); } } /** * Apply runtime overrides (not persisted). */ override

(path: P, value: SettingValue

): void { const segments = path.split("."); setByPath(this.#overrides, segments, value); this.#rebuildMerged(); } /** * Clear a runtime override. */ clearOverride(path: SettingPath): void { 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(); } /** * Flush any pending saves to disk. * Call before exit to ensure all changes are persisted. */ async flush(): Promise { if (this.#saveTimer) { clearTimeout(this.#saveTimer); this.#saveTimer = undefined; } if (this.#savePromise) { await this.#savePromise; } if (this.#modified.size > 0) { await this.#saveNow(); } } async cloneForCwd(cwd: string): Promise { const cloned = new Settings({ cwd, agentDir: this.#agentDir, inMemory: !this.#persist, }); cloned.#storage = this.#storage; cloned.#global = structuredClone(this.#global); cloned.#project = this.#persist ? await cloned.#loadProjectSettings() : structuredClone(this.#project); cloned.#overrides = structuredClone(this.#overrides); cloned.#rebuildMerged(); cloned.#fireAllHooks(); return cloned; } // ───────────────────────────────────────────────────────────────────────── // 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"); return procmgr.getShellConfig(shell); } /** * Get all settings in a group with full type safety. */ getGroup(prefix: G): GroupTypeMap[G] { const result: Record = {}; 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", "vim", "apply_patch", or null (use global default). */ getEditVariantForModel(model: string | undefined): EditMode | null { if (!model) return null; const variants = (this.#merged.edit as { modelVariants?: Record })?.modelVariants; if (!variants) return null; for (const pattern in variants) { if (model.includes(pattern)) { const value = normalizeEditMode(variants[pattern]); if (value) { return value; } } } return null; } /** * Get bash interceptor rules (typed accessor for complex array config). */ getBashInterceptorRules(): BashInterceptorRule[] { return this.get("bashInterceptor.patterns"); } /** * Set a model role (helper for modelRoles record). */ setModelRole(role: ModelRole | string, modelId: string): void { const current = shallowStringRecord(getByPath(this.#global, ["modelRoles"])); const runtimeOverrides = getByPath(this.#overrides, ["modelRoles"]); const updateRuntimeOverride = !!runtimeOverrides && typeof runtimeOverrides === "object" && !Array.isArray(runtimeOverrides) && Object.hasOwn(runtimeOverrides, role); this.set("modelRoles", { ...current, [role]: modelId }); if (updateRuntimeOverride) { this.override("modelRoles", { ...shallowStringRecord(runtimeOverrides), [role]: modelId }); } } /** * Get a model role (helper for modelRoles record). */ getModelRole(role: ModelRole | string): string | undefined { const roles = this.get("modelRoles"); return roles[role]; } /** * Get all model roles (helper for modelRoles record). */ getModelRoles(): ReadOnlyDict { return { ...this.get("modelRoles") }; } /* * Override model roles (helper for modelRoles record). */ overrideModelRoles(roles: ReadOnlyDict): void { const next = shallowStringRecord(getByPath(this.#overrides, ["modelRoles"])); for (const [role, modelId] of Object.entries(roles)) { if (modelId) { next[role] = modelId; } } this.override("modelRoles", next); } /** * Set disabled providers (for compatibility with discovery system). */ setDisabledProviders(ids: string[]): void { this.set("disabledProviders", ids); } // ───────────────────────────────────────────────────────────────────────── // Loading // ───────────────────────────────────────────────────────────────────────── async #load(): Promise { // Project settings load (loadCapability scans cwd) is independent of the // persist chain (storage open → legacy migration → global config.yml read), // so kick it off first and await after the persist chain completes. The // persist steps remain sequential: migration may write config.yml, which // #loadYaml then reads; migration's db fallback needs #storage opened. const projectPromise = this.#loadProjectSettings(); if (this.#persist) { this.#storage = await AgentStorage.open(getAgentDbPath(this.#agentDir)); await this.#migrateFromLegacy(); this.#global = await this.#loadYaml(this.#configPath!); } this.#project = await projectPromise; // Build merged view (global → project → overrides; project wins over global) this.#rebuildMerged(); this.#fireAllHooks(); return this; } async #loadYaml(filePath: string): Promise { try { const content = await Bun.file(filePath).text(); const parsed = YAML.parse(content); if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { return {}; } return this.#migrateRawSettings(parsed as RawSettings); } catch (error) { if (isEnoent(error)) return {}; logger.warn("Settings: failed to load", { path: filePath, error: String(error) }); return {}; } } async #loadProjectSettings(): Promise { try { const result = await loadCapability(settingsCapability.id, { cwd: this.#cwd }); let merged: RawSettings = {}; for (const item of result.items as SettingsCapabilityItem[]) { if (item.level === "project") { merged = this.#deepMerge(merged, item.data as RawSettings); } } return this.#migrateRawSettings(merged); } catch { return {}; } } async #migrateFromLegacy(): Promise { if (!this.#configPath) return; // Check if config.yml already exists try { await Bun.file(this.#configPath).text(); return; // Already exists, no migration needed } catch (err) { if (!isEnoent(err)) return; } let settings: RawSettings = {}; let migrated = false; // 1. Migrate from settings.json const settingsJsonPath = path.join(this.#agentDir, "settings.json"); try { const parsed = JSON.parse(await Bun.file(settingsJsonPath).text()); if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) { settings = this.#deepMerge(settings, this.#migrateRawSettings(parsed)); 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 Bun.write(this.#configPath, YAML.stringify(settings, null, 2)); logger.debug("Settings: migrated to config.yml", { path: this.#configPath }); } catch {} } } /** Apply schema migrations to raw settings */ #migrateRawSettings(raw: RawSettings): RawSettings { // queueMode -> steeringMode if ("queueMode" in raw && !("steeringMode" in raw)) { raw.steeringMode = raw.queueMode; delete raw.queueMode; } // ask.timeout: ms -> seconds (if value > 1000, it's old ms format) if (raw.ask && typeof (raw.ask as Record).timeout === "number") { const oldValue = (raw.ask as Record).timeout as number; if (oldValue > 1000) { (raw.ask as Record).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 }; } } // task.isolation.enabled (boolean) -> task.isolation.mode (enum) const taskObj = raw.task as Record | undefined; const isolationObj = taskObj?.isolation as Record | undefined; if (isolationObj && "enabled" in isolationObj) { if (typeof isolationObj.enabled === "boolean") { isolationObj.mode = isolationObj.enabled ? "auto" : "none"; } delete isolationObj.enabled; } // 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 = { 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" variant is now "hashline" const editObj = raw.edit as Record | undefined; if (editObj) { if (editObj.mode === "atom") { editObj.mode = "hashline"; } const modelVariants = editObj.modelVariants as Record | undefined; if (modelVariants && typeof modelVariants === "object" && !Array.isArray(modelVariants)) { for (const [pattern, variant] of Object.entries(modelVariants)) { if (variant === "atom") { modelVariants[pattern] = "hashline"; } } } } if (raw["edit.mode"] === "atom") { raw["edit.mode"] = "hashline"; } // statusLine: rename "plan_mode" segment to "mode" const statusLineObj = raw.statusLine as Record | 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 | undefined; if (segmentOptions && "plan_mode" in segmentOptions && !("mode" in segmentOptions)) { segmentOptions.mode = segmentOptions.plan_mode; delete segmentOptions.plan_mode; } } // 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 | undefined; const memoryBackendSet = memoryBackendObj && typeof memoryBackendObj.backend === "string"; const memoriesObj = raw.memories as Record | undefined; if (!memoryBackendSet && memoriesObj && typeof memoriesObj.enabled === "boolean") { const next = memoriesObj.enabled ? "local" : "off"; const memoryRoot = (memoryBackendObj ?? {}) as Record; memoryRoot.backend = next; raw.memory = memoryRoot; } // 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 | 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; } } return raw; } // ───────────────────────────────────────────────────────────────────────── // Saving // ───────────────────────────────────────────────────────────────────────── #queueSave(): void { if (!this.#persist || !this.#configPath) return; // Debounce: wait 100ms for more changes if (this.#saveTimer) { clearTimeout(this.#saveTimer); } this.#saveTimer = setTimeout(() => { this.#saveTimer = undefined; this.#saveNow().catch(err => { logger.warn("Settings: background save failed", { error: String(err) }); }); }, 100); } async #saveNow(): Promise { if (!this.#persist || !this.#configPath || this.#modified.size === 0) return; const configPath = this.#configPath; const modifiedPaths = [...this.#modified]; this.#modified.clear(); try { await withFileLock(configPath, async () => { // Re-read to preserve external changes const current = await this.#loadYaml(configPath); // Apply only our modified paths for (const modPath of modifiedPaths) { const segments = modPath.split("."); const value = getByPath(this.#global, segments); setByPath(current, segments, value); } // Update our global with any external changes we preserved this.#global = current; await Bun.write(configPath, YAML.stringify(this.#global, null, 2)); }); } 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); } } this.#rebuildMerged(); } // ───────────────────────────────────────────────────────────────────────── // Utilities // ───────────────────────────────────────────────────────────────────────── #rebuildMerged(): void { this.#merged = this.#deepMerge(this.#deepMerge({}, this.#global), this.#project); this.#merged = this.#deepMerge(this.#merged, this.#overrides); } #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

= (value: SettingValue

, prev: SettingValue

) => void; const SETTING_HOOKS: Partial>> = { "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) }); }); } }, "display.tabWidth": value => { if (typeof value === "number") { setDefaultTabWidth(value); } }, }; // ═══════════════════════════════════════════════════════════════════════════ // Global Singleton // ═══════════════════════════════════════════════════════════════════════════ let globalInstance: Settings | null = null; let globalInstancePromise: Promise | null = null; export function isSettingsInitialized(): boolean { return globalInstance !== null; } /** * Reset the global singleton for testing. * @internal */ export function resetSettingsForTest(): void { globalInstance = null; globalInstancePromise = null; } /** * 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."); } const value = (globalInstance as unknown as Record)[prop]; if (typeof value === "function") { return value.bind(globalInstance); } return value; }, }); // ═══════════════════════════════════════════════════════════════════════════ // Helpers // ═══════════════════════════════════════════════════════════════════════════