- Replaced four individual power boolean settings with a single `power.sleepPrevention` enum for improved configuration management. - Implemented automatic migration logic in `Settings.init` to map legacy macOS power booleans to the new enum levels. - Updated `AgentSession` to utilize the new `sleepPrevention` enum for macOS power assertion lifecycle management. - Changed default value of `display.cacheMissMarker` to `false` to suppress cache-miss markers. - Added comprehensive unit tests in `settings-manager.test.ts` to verify backward-compatible migration behavior.
1244 lines
45 KiB
TypeScript
1244 lines
45 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 os from "node:os";
|
|
import * as path from "node:path";
|
|
import {
|
|
getAgentDbPath,
|
|
getAgentDir,
|
|
getLastChangelogVersionPath,
|
|
getProjectDir,
|
|
isEnoent,
|
|
logger,
|
|
procmgr,
|
|
} from "@oh-my-pi/pi-utils";
|
|
import { JSONC, YAML } from "bun";
|
|
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 { 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>>;
|
|
/** 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;
|
|
}
|
|
|
|
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 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;
|
|
}
|
|
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
// Settings Class
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
|
|
export class Settings {
|
|
#configPath: string | null;
|
|
#cwd: string;
|
|
#agentDir: string;
|
|
#storage: AgentStorage | null = null;
|
|
|
|
#configFiles: string[] = [];
|
|
/** Global settings from config.yml */
|
|
#global: RawSettings = {};
|
|
/** Project settings from .claude/settings.yml etc */
|
|
#project: RawSettings = {};
|
|
/** Extra config.yml-style overlays passed by CLI */
|
|
#configOverlay: RawSettings = {};
|
|
/** 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>();
|
|
|
|
/** Paths modified during this session (for partial save) */
|
|
#modified = new Set<string>();
|
|
|
|
/** Legacy `lastChangelogVersion` captured from config.yml during migration (now a marker file). */
|
|
#legacyLastChangelogVersion?: 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.#configFiles = options.configFiles?.map(file => path.resolve(this.#cwd, expandTilde(file))) ?? [];
|
|
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);
|
|
}
|
|
}
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────
|
|
// 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;
|
|
},
|
|
);
|
|
}
|
|
|
|
/**
|
|
* 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> {
|
|
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.#modified.add(path);
|
|
this.#rebuildMerged();
|
|
const next = this.get(path);
|
|
this.#queueSave();
|
|
|
|
// Trigger hook if exists
|
|
const hook = SETTING_HOOKS[path];
|
|
if (hook) {
|
|
hook(value, prev);
|
|
}
|
|
this.#fireEffectiveSettingChanged(path, next, prev);
|
|
}
|
|
|
|
/**
|
|
* Apply runtime overrides (not persisted).
|
|
*/
|
|
override<P extends SettingPath>(path: P, value: SettingValue<P>): void {
|
|
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 {
|
|
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();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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.#configFiles = [...this.#configFiles];
|
|
cloned.#configOverlay = structuredClone(this.#configOverlay);
|
|
cloned.#overrides = structuredClone(this.#overrides);
|
|
cloned.#rebuildMerged();
|
|
cloned.#fireAllHooks();
|
|
return cloned;
|
|
}
|
|
|
|
/**
|
|
* 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;
|
|
this.#cwd = normalized;
|
|
if (this.#persist) {
|
|
this.#project = await this.#loadProjectSettings();
|
|
}
|
|
this.#rebuildMerged();
|
|
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");
|
|
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);
|
|
}
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────
|
|
// Loading
|
|
// ─────────────────────────────────────────────────────────────────────────
|
|
|
|
async #load(): Promise<Settings> {
|
|
// 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!);
|
|
await this.#seedLastChangelogVersionMarker();
|
|
}
|
|
|
|
this.#project = await projectPromise;
|
|
this.#configOverlay = await this.#loadConfigOverlays();
|
|
|
|
// Build merged view (global → project → overrides; project wins over global)
|
|
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 #loadConfigOverlays(): Promise<RawSettings> {
|
|
let merged: RawSettings = {};
|
|
for (const filePath of this.#configFiles) {
|
|
merged = this.#deepMerge(merged, await this.#loadOverlayYaml(filePath));
|
|
}
|
|
return merged;
|
|
}
|
|
|
|
/**
|
|
* 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): 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);
|
|
}
|
|
|
|
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: 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 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;
|
|
}
|
|
|
|
// 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 (typeof raw.lastChangelogVersion === "string") {
|
|
this.#legacyLastChangelogVersion ??= raw.lastChangelogVersion;
|
|
}
|
|
delete raw.lastChangelogVersion;
|
|
|
|
// 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 ? "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";
|
|
}
|
|
|
|
// 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"];
|
|
}
|
|
|
|
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
|
|
// ─────────────────────────────────────────────────────────────────────────
|
|
|
|
#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.#configOverlay);
|
|
this.#merged = this.#deepMerge(this.#merged, this.#overrides);
|
|
this.#resolvedCache.clear();
|
|
}
|
|
|
|
#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);
|
|
}
|
|
},
|
|
"hindsight.bankId": () => hindsightScopeSignal.fire(),
|
|
"hindsight.bankIdPrefix": () => hindsightScopeSignal.fire(),
|
|
"hindsight.scoping": () => hindsightScopeSignal.fire(),
|
|
};
|
|
/** 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 `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
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
|
|
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 {
|
|
globalInstance = null;
|
|
globalInstancePromise = null;
|
|
clearBoundSettingsMethods();
|
|
}
|
|
|
|
/**
|
|
* 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
|
|
// ═══════════════════════════════════════════════════════════════════════════
|