* 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";
export type * from "./settings-schema";
export * from "./settings-schema";
export interface RawSettings {
[key: string]: unknown;
}
export interface SettingsOptions {
cwd?: string;
agentDir?: string;
inMemory?: boolean;
overrides?: Partial<Record<SettingPath, unknown>>;
}
* 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;
}
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 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<string, string> {
if (!value || typeof value !== "object" || Array.isArray(value)) return {};
const result: Record<string, string> = {};
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;
}
export class Settings {
#configPath: string | null;
#cwd: string;
#agentDir: string;
#storage: AgentStorage | null = null;
#global: RawSettings = {};
#project: RawSettings = {};
#overrides: RawSettings = {};
#merged: RawSettings = {};
#modified = new Set<string>();
#saveTimer?: NodeJS.Timeout;
#savePromise?: Promise<void>;
#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);
}
this.#overrides = this.#migrateRawSettings(this.#overrides);
}
}
* 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;
}
* 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) {
const pathScopedValue = resolvePathScopedStringArray(path, value, this.#cwd);
return (pathScopedValue ?? 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();
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;
}
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", "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<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 = 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<string> {
return { ...this.get("modelRoles") };
}
* Override model roles (helper for modelRoles record).
*/
overrideModelRoles(roles: ReadOnlyDict<string>): 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);
}
async #load(): Promise<Settings> {
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;
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;
try {
await Bun.file(this.#configPath).text();
return;
} catch (err) {
if (!isEnoent(err)) return;
}
let settings: RawSettings = {};
let migrated = false;
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 {}
try {
const dbSettings = this.#storage?.getSettings();
if (dbSettings) {
settings = this.#deepMerge(settings, this.#migrateRawSettings(dbSettings as RawSettings));
migrated = true;
}
} catch {}
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 {}
}
}
#migrateRawSettings(raw: RawSettings): RawSettings {
if ("queueMode" in raw && !("steeringMode" in raw)) {
raw.steeringMode = raw.queueMode;
delete raw.queueMode;
}
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);
}
}
if (typeof raw.theme === "string") {
const oldTheme = raw.theme;
if (oldTheme === "light" || oldTheme === "dark") {
delete raw.theme;
} else {
const slot = isLightTheme(oldTheme) ? "light" : "dark";
raw.theme = { [slot]: oldTheme };
}
}
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;
}
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;
}
}
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";
}
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";
}
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;
}
}
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;
}
if (memoryBackendObj && memoryBackendObj.backend === "mnemosyne") {
memoryBackendObj.backend = "mnemopi";
}
if ("mnemosyne" in raw && !("mnemopi" in raw)) {
raw.mnemopi = raw.mnemosyne;
delete raw.mnemosyne;
}
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;
}
}
return raw;
}
#queueSave(): void {
if (!this.#persist || !this.#configPath) return;
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 () => {
const current = await this.#loadYaml(configPath);
for (const modPath of modifiedPaths) {
const segments = modPath.split(".");
const value = getByPath(this.#global, segments);
setByPath(current, segments, value);
}
this.#global = current;
await Bun.write(configPath, YAML.stringify(this.#global, null, 2));
});
} catch (error) {
logger.warn("Settings: save failed", { error: String(error) });
for (const p of modifiedPaths) {
this.#modified.add(p);
}
}
this.#rebuildMerged();
}
#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;
}
}
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);
}
},
"provider.appendOnlyContext": value => {
if (typeof value === "string") {
for (const cb of appendOnlyModeCallbacks) cb(value);
}
},
};
const appendOnlyModeCallbacks = new Set<(value: string) => void>();
* Subscribe to append-only mode setting changes.
* Returns an unsubscribe function. Multiple sessions (main + subagents)
* can register independently without overwriting each other.
*/
export function onAppendOnlyModeChanged(cb: (value: string) => void): () => void {
appendOnlyModeCallbacks.add(cb);
return () => {
appendOnlyModeCallbacks.delete(cb);
};
}
let globalInstance: Settings | null = null;
let globalInstancePromise: Promise<Settings> | 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<string | symbol, unknown>)[prop];
if (typeof value === "function") {
return value.bind(globalInstance);
}
return value;
},
});