diff --git a/packages/coding-agent/examples/sdk/10-settings.ts b/packages/coding-agent/examples/sdk/10-settings.ts index c42fab24..e91d7e2d 100644 --- a/packages/coding-agent/examples/sdk/10-settings.ts +++ b/packages/coding-agent/examples/sdk/10-settings.ts @@ -1,33 +1,38 @@ /** * Settings Configuration * - * Override settings from agentDir/settings.json. + * Override settings using SettingsManager. */ -import { createAgentSession, loadSettings, SessionManager } from "../../src/index.js"; +import { createAgentSession, loadSettings, SessionManager, SettingsManager } from "../../src/index.js"; -// Load current settings +// Load current settings (merged global + project) const settings = loadSettings(); console.log("Current settings:", JSON.stringify(settings, null, 2)); // Override specific settings +const settingsManager = SettingsManager.create(); +settingsManager.applyOverrides({ + compaction: { enabled: false }, + retry: { enabled: true, maxRetries: 5, baseDelayMs: 1000 }, +}); + const { session } = await createAgentSession({ - settings: { - // Disable auto-compaction - compaction: { enabled: false }, - - // Custom retry behavior - retry: { - enabled: true, - maxRetries: 5, - baseDelayMs: 1000, - }, - - // Terminal options - terminal: { showImages: true }, - hideThinkingBlock: true, - }, + settingsManager, sessionManager: SessionManager.inMemory(), }); console.log("Session created with custom settings"); + +// For testing without file I/O: +const inMemorySettings = SettingsManager.inMemory({ + compaction: { enabled: false }, + retry: { enabled: false }, +}); + +const { session: testSession } = await createAgentSession({ + settingsManager: inMemorySettings, + sessionManager: SessionManager.inMemory(), +}); + +console.log("Test session created with in-memory settings"); diff --git a/packages/coding-agent/examples/sdk/12-full-control.ts b/packages/coding-agent/examples/sdk/12-full-control.ts index f2efba49..4487828b 100644 --- a/packages/coding-agent/examples/sdk/12-full-control.ts +++ b/packages/coding-agent/examples/sdk/12-full-control.ts @@ -12,6 +12,7 @@ import { defaultGetApiKey, findModel, SessionManager, + SettingsManager, readTool, bashTool, type HookFactory, @@ -53,6 +54,12 @@ const statusTool: CustomAgentTool = { const { model } = findModel("anthropic", "claude-sonnet-4-20250514"); if (!model) throw new Error("Model not found"); +// In-memory settings with overrides +const settingsManager = SettingsManager.inMemory({ + compaction: { enabled: false }, + retry: { enabled: true, maxRetries: 2 }, +}); + const { session } = await createAgentSession({ cwd: process.cwd(), agentDir: "/tmp/my-agent", @@ -71,7 +78,7 @@ Available: read, bash, status. Be concise.`, contextFiles: [], slashCommands: [], sessionManager: SessionManager.inMemory(), - settings: { compaction: { enabled: false } }, + settingsManager, }); session.subscribe((event) => { diff --git a/packages/coding-agent/src/core/sdk.ts b/packages/coding-agent/src/core/sdk.ts index 0ecf4f24..60e09794 100644 --- a/packages/coding-agent/src/core/sdk.ts +++ b/packages/coding-agent/src/core/sdk.ts @@ -111,8 +111,8 @@ export interface CreateAgentSessionOptions { /** Session manager. Default: SessionManager.create(cwd) */ sessionManager?: SessionManager; - /** Settings overrides (merged with agentDir/settings.json) */ - settings?: Partial; + /** Settings manager. Default: SettingsManager.create(cwd, agentDir) */ + settingsManager?: SettingsManager; } /** Result from createAgentSession */ @@ -338,10 +338,10 @@ export function buildSystemPrompt(options: BuildSystemPromptOptions = {}): strin // Settings /** - * Load settings from agentDir/settings.json. + * Load settings from agentDir/settings.json merged with cwd/.pi/settings.json. */ -export function loadSettings(agentDir?: string): Settings { - const manager = new SettingsManager(agentDir ?? getDefaultAgentDir()); +export function loadSettings(cwd?: string, agentDir?: string): Settings { + const manager = SettingsManager.create(cwd ?? process.cwd(), agentDir ?? getDefaultAgentDir()); return { defaultProvider: manager.getDefaultProvider(), defaultModel: manager.getDefaultModel(), @@ -449,8 +449,8 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {} // Configure OAuth storage for this agentDir configureOAuthStorage(agentDir); - const settingsManager = new SettingsManager(agentDir); - const sessionManager = options.sessionManager ?? SessionManager.create(cwd); + const settingsManager = options.settingsManager ?? SettingsManager.create(cwd, agentDir); + const sessionManager = options.sessionManager ?? SessionManager.create(cwd, agentDir); // Check if session has existing data to restore const existingSession = sessionManager.loadSession(); diff --git a/packages/coding-agent/src/core/settings-manager.ts b/packages/coding-agent/src/core/settings-manager.ts index de4d0d06..e56b2779 100644 --- a/packages/coding-agent/src/core/settings-manager.ts +++ b/packages/coding-agent/src/core/settings-manager.ts @@ -1,6 +1,6 @@ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "fs"; import { dirname, join } from "path"; -import { getAgentDir } from "../config.js"; +import { CONFIG_DIR_NAME, getAgentDir } from "../config.js"; export interface CompactionSettings { enabled?: boolean; // default: true @@ -49,39 +49,118 @@ export interface Settings { terminal?: TerminalSettings; } -export class SettingsManager { - private settingsPath: string; - private settings: Settings; +/** Deep merge settings: project/overrides take precedence, nested objects merge recursively */ +function deepMergeSettings(base: Settings, overrides: Settings): Settings { + const result: Settings = { ...base }; - constructor(baseDir?: string) { - const dir = baseDir || getAgentDir(); - this.settingsPath = join(dir, "settings.json"); - this.settings = this.load(); + for (const key of Object.keys(overrides) as (keyof Settings)[]) { + const overrideValue = overrides[key]; + const baseValue = base[key]; + + if (overrideValue === undefined) { + continue; + } + + // For nested objects, merge recursively + if ( + typeof overrideValue === "object" && + overrideValue !== null && + !Array.isArray(overrideValue) && + typeof baseValue === "object" && + baseValue !== null && + !Array.isArray(baseValue) + ) { + (result as Record)[key] = { ...baseValue, ...overrideValue }; + } else { + // For primitives and arrays, override value wins + (result as Record)[key] = overrideValue; + } } - private load(): Settings { - if (!existsSync(this.settingsPath)) { + return result; +} + +export class SettingsManager { + private settingsPath: string | null; + private projectSettingsPath: string | null; + private globalSettings: Settings; + private settings: Settings; + private persist: boolean; + + private constructor( + settingsPath: string | null, + projectSettingsPath: string | null, + initialSettings: Settings, + persist: boolean, + ) { + this.settingsPath = settingsPath; + this.projectSettingsPath = projectSettingsPath; + this.persist = persist; + this.globalSettings = initialSettings; + const projectSettings = this.loadProjectSettings(); + this.settings = deepMergeSettings(this.globalSettings, projectSettings); + } + + /** Create a SettingsManager that loads from files */ + static create(cwd: string = process.cwd(), agentDir: string = getAgentDir()): SettingsManager { + const settingsPath = join(agentDir, "settings.json"); + const projectSettingsPath = join(cwd, CONFIG_DIR_NAME, "settings.json"); + const globalSettings = SettingsManager.loadFromFile(settingsPath); + return new SettingsManager(settingsPath, projectSettingsPath, globalSettings, true); + } + + /** Create an in-memory SettingsManager (no file I/O) */ + static inMemory(settings: Partial = {}): SettingsManager { + return new SettingsManager(null, null, settings, false); + } + + private static loadFromFile(path: string): Settings { + if (!existsSync(path)) { + return {}; + } + try { + const content = readFileSync(path, "utf-8"); + return JSON.parse(content); + } catch (error) { + console.error(`Warning: Could not read settings file ${path}: ${error}`); + return {}; + } + } + + private loadProjectSettings(): Settings { + if (!this.projectSettingsPath || !existsSync(this.projectSettingsPath)) { return {}; } try { - const content = readFileSync(this.settingsPath, "utf-8"); + const content = readFileSync(this.projectSettingsPath, "utf-8"); return JSON.parse(content); } catch (error) { - console.error(`Warning: Could not read settings file: ${error}`); + console.error(`Warning: Could not read project settings file: ${error}`); return {}; } } + /** Apply additional overrides on top of current settings */ + applyOverrides(overrides: Partial): void { + this.settings = deepMergeSettings(this.settings, overrides); + } + private save(): void { + if (!this.persist || !this.settingsPath) return; + try { - // Ensure directory exists const dir = dirname(this.settingsPath); if (!existsSync(dir)) { mkdirSync(dir, { recursive: true }); } - writeFileSync(this.settingsPath, JSON.stringify(this.settings, null, 2), "utf-8"); + // Save only global settings (project settings are read-only) + writeFileSync(this.settingsPath, JSON.stringify(this.globalSettings, null, 2), "utf-8"); + + // Re-merge project settings into active settings + const projectSettings = this.loadProjectSettings(); + this.settings = deepMergeSettings(this.globalSettings, projectSettings); } catch (error) { console.error(`Warning: Could not save settings file: ${error}`); } @@ -92,7 +171,7 @@ export class SettingsManager { } setLastChangelogVersion(version: string): void { - this.settings.lastChangelogVersion = version; + this.globalSettings.lastChangelogVersion = version; this.save(); } @@ -105,18 +184,18 @@ export class SettingsManager { } setDefaultProvider(provider: string): void { - this.settings.defaultProvider = provider; + this.globalSettings.defaultProvider = provider; this.save(); } setDefaultModel(modelId: string): void { - this.settings.defaultModel = modelId; + this.globalSettings.defaultModel = modelId; this.save(); } setDefaultModelAndProvider(provider: string, modelId: string): void { - this.settings.defaultProvider = provider; - this.settings.defaultModel = modelId; + this.globalSettings.defaultProvider = provider; + this.globalSettings.defaultModel = modelId; this.save(); } @@ -125,7 +204,7 @@ export class SettingsManager { } setQueueMode(mode: "all" | "one-at-a-time"): void { - this.settings.queueMode = mode; + this.globalSettings.queueMode = mode; this.save(); } @@ -134,7 +213,7 @@ export class SettingsManager { } setTheme(theme: string): void { - this.settings.theme = theme; + this.globalSettings.theme = theme; this.save(); } @@ -143,7 +222,7 @@ export class SettingsManager { } setDefaultThinkingLevel(level: "off" | "minimal" | "low" | "medium" | "high" | "xhigh"): void { - this.settings.defaultThinkingLevel = level; + this.globalSettings.defaultThinkingLevel = level; this.save(); } @@ -152,10 +231,10 @@ export class SettingsManager { } setCompactionEnabled(enabled: boolean): void { - if (!this.settings.compaction) { - this.settings.compaction = {}; + if (!this.globalSettings.compaction) { + this.globalSettings.compaction = {}; } - this.settings.compaction.enabled = enabled; + this.globalSettings.compaction.enabled = enabled; this.save(); } @@ -180,10 +259,10 @@ export class SettingsManager { } setRetryEnabled(enabled: boolean): void { - if (!this.settings.retry) { - this.settings.retry = {}; + if (!this.globalSettings.retry) { + this.globalSettings.retry = {}; } - this.settings.retry.enabled = enabled; + this.globalSettings.retry.enabled = enabled; this.save(); } @@ -200,7 +279,7 @@ export class SettingsManager { } setHideThinkingBlock(hide: boolean): void { - this.settings.hideThinkingBlock = hide; + this.globalSettings.hideThinkingBlock = hide; this.save(); } @@ -209,7 +288,7 @@ export class SettingsManager { } setShellPath(path: string | undefined): void { - this.settings.shellPath = path; + this.globalSettings.shellPath = path; this.save(); } @@ -218,7 +297,7 @@ export class SettingsManager { } setCollapseChangelog(collapse: boolean): void { - this.settings.collapseChangelog = collapse; + this.globalSettings.collapseChangelog = collapse; this.save(); } @@ -227,7 +306,7 @@ export class SettingsManager { } setHookPaths(paths: string[]): void { - this.settings.hooks = paths; + this.globalSettings.hooks = paths; this.save(); } @@ -236,7 +315,7 @@ export class SettingsManager { } setHookTimeout(timeout: number): void { - this.settings.hookTimeout = timeout; + this.globalSettings.hookTimeout = timeout; this.save(); } @@ -245,7 +324,7 @@ export class SettingsManager { } setCustomToolPaths(paths: string[]): void { - this.settings.customTools = paths; + this.globalSettings.customTools = paths; this.save(); } @@ -254,10 +333,10 @@ export class SettingsManager { } setSkillsEnabled(enabled: boolean): void { - if (!this.settings.skills) { - this.settings.skills = {}; + if (!this.globalSettings.skills) { + this.globalSettings.skills = {}; } - this.settings.skills.enabled = enabled; + this.globalSettings.skills.enabled = enabled; this.save(); } @@ -280,10 +359,10 @@ export class SettingsManager { } setShowImages(show: boolean): void { - if (!this.settings.terminal) { - this.settings.terminal = {}; + if (!this.globalSettings.terminal) { + this.globalSettings.terminal = {}; } - this.settings.terminal.showImages = show; + this.globalSettings.terminal.showImages = show; this.save(); } } diff --git a/packages/coding-agent/src/main.ts b/packages/coding-agent/src/main.ts index 5fa89a5a..1ad5616e 100644 --- a/packages/coding-agent/src/main.ts +++ b/packages/coding-agent/src/main.ts @@ -288,7 +288,7 @@ export async function main(args: string[]) { const isInteractive = !parsed.print && parsed.mode === undefined; const mode = parsed.mode || "text"; - const settingsManager = new SettingsManager(); + const settingsManager = SettingsManager.create(cwd); initTheme(settingsManager.getTheme(), isInteractive); let scopedModels: ScopedModel[] = []; diff --git a/packages/coding-agent/src/utils/shell.ts b/packages/coding-agent/src/utils/shell.ts index bde51928..d2a1f74f 100644 --- a/packages/coding-agent/src/utils/shell.ts +++ b/packages/coding-agent/src/utils/shell.ts @@ -34,7 +34,7 @@ export function getShellConfig(): { shell: string; args: string[] } { return cachedShellConfig; } - const settings = new SettingsManager(); + const settings = SettingsManager.create(); const customShellPath = settings.getShellPath(); // 1. Check user-specified shell path diff --git a/packages/coding-agent/test/agent-session-branching.test.ts b/packages/coding-agent/test/agent-session-branching.test.ts index b29299f4..d14fa483 100644 --- a/packages/coding-agent/test/agent-session-branching.test.ts +++ b/packages/coding-agent/test/agent-session-branching.test.ts @@ -57,7 +57,7 @@ describe.skipIf(!API_KEY)("AgentSession branching", () => { }); sessionManager = noSession ? SessionManager.inMemory() : SessionManager.create(tempDir); - const settingsManager = new SettingsManager(tempDir); + const settingsManager = SettingsManager.create(tempDir, tempDir); session = new AgentSession({ agent, diff --git a/packages/coding-agent/test/agent-session-compaction.test.ts b/packages/coding-agent/test/agent-session-compaction.test.ts index a2574483..7a31053b 100644 --- a/packages/coding-agent/test/agent-session-compaction.test.ts +++ b/packages/coding-agent/test/agent-session-compaction.test.ts @@ -61,7 +61,7 @@ describe.skipIf(!API_KEY)("AgentSession compaction e2e", () => { }); sessionManager = SessionManager.create(tempDir); - const settingsManager = new SettingsManager(tempDir); + const settingsManager = SettingsManager.create(tempDir, tempDir); session = new AgentSession({ agent, @@ -177,7 +177,7 @@ describe.skipIf(!API_KEY)("AgentSession compaction e2e", () => { // Create in-memory session manager const noSessionManager = SessionManager.inMemory(); - const settingsManager = new SettingsManager(tempDir); + const settingsManager = SettingsManager.create(tempDir, tempDir); const noSessionSession = new AgentSession({ agent,