Files
oh-my-pi/packages/coding-agent/src/config/settings.ts
T
can1357 003eccce52 fix: remove unused type alias and fix test compatibility with Bun 1.3.11
- Remove unused ParsedChunkTarget type alias (lint error)
- Increase setImmediate timing in history storage compat test
- Mock warmPythonEnvironment in python tool tests
- Fix tlaplus LSP test to spy on $which instead of Bun.which
2026-04-08 12:17:37 +02:00

702 lines
23 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 * as fs from "node:fs";
import * as path from "node:path";
import { setDefaultTabWidth } from "@oh-my-pi/pi-natives";
import { getAgentDbPath, getAgentDir, getProjectDir, isEnoent, logger, procmgr } 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<Record<SettingPath, unknown>>;
}
// ═══════════════════════════════════════════════════════════════════════════
// 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<string, unknown>)[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;
}
// ═══════════════════════════════════════════════════════════════════════════
// 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<string>();
/** Pending save (debounced) */
#saveTimer?: NodeJS.Timeout;
#savePromise?: 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, "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<Settings> {
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<Record<SettingPath, unknown>> = {}): 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<P extends SettingPath>(path: P): SettingValue<P> {
const segments = path.split(".");
const value = getByPath(this.#merged, segments);
if (value !== undefined) {
return value as SettingValue<P>;
}
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<P extends SettingPath>(path: P, value: SettingValue<P>): 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<P extends SettingPath>(path: P, value: SettingValue<P>): 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<void> {
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<Settings> {
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<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", "chunk", or null (use global default).
*/
getEditVariantForModel(model: string | undefined): EditMode | null {
if (!model) return null;
const variants = (this.#merged.edit as { modelVariants?: Record<string, string> })?.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 = this.get("modelRoles");
this.set("modelRoles", { ...current, [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<string> {
return this.get("modelRoles");
}
/*
* Override model roles (helper for modelRoles record).
*/
overrideModelRoles(roles: ReadOnlyDict<string>): void {
const prev = this.get("modelRoles");
for (const [role, modelId] of Object.entries(roles)) {
if (modelId) {
prev[role] = modelId;
}
}
this.override("modelRoles", prev);
}
/**
* Set disabled providers (for compatibility with discovery system).
*/
setDisabledProviders(ids: string[]): void {
this.set("disabledProviders", ids);
}
// ─────────────────────────────────────────────────────────────────────────
// Loading
// ─────────────────────────────────────────────────────────────────────────
async #load(): Promise<Settings> {
if (this.#persist) {
// Open storage
this.#storage = await AgentStorage.open(getAgentDbPath(this.#agentDir));
// Migrate from legacy formats if needed
await this.#migrateFromLegacy();
// Load global settings from config.yml
this.#global = await this.#loadYaml(this.#configPath!);
}
// Load project settings
this.#project = await this.#loadProjectSettings();
// Build merged view
this.#rebuildMerged();
this.#fireAllHooks();
return this;
}
async #loadYaml(filePath: string): Promise<RawSettings> {
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<RawSettings> {
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<void> {
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<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 };
}
}
// 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 ? "worktree" : "none";
}
delete isolationObj.enabled;
}
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<void> {
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<P extends SettingPath> = (value: SettingValue<P>, prev: SettingValue<P>) => void;
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) });
});
}
},
"display.tabWidth": value => {
if (typeof value === "number") {
setDefaultTabWidth(value);
}
},
};
// ═══════════════════════════════════════════════════════════════════════════
// Global Singleton
// ═══════════════════════════════════════════════════════════════════════════
let globalInstance: Settings | null = null;
let globalInstancePromise: Promise<Settings> | null = 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<string | symbol, unknown>)[prop];
if (typeof value === "function") {
return value.bind(globalInstance);
}
return value;
},
});
// ═══════════════════════════════════════════════════════════════════════════
// Helpers
// ═══════════════════════════════════════════════════════════════════════════