From aea9f8439dd8a93a38a7e9cfff101037cf2f68e5 Mon Sep 17 00:00:00 2001 From: Austin Mudd Date: Tue, 6 Jan 2026 14:35:43 -0800 Subject: [PATCH 1/2] feat(extensions): support async extension factory functions Extensions can now use async initialization, enabling: - Dynamic imports (e.g., loading tools from external packages) - Async setup (config fetching, service connections) - Lazy-loaded dependencies Changes: - ExtensionFactory type now returns void | Promise - loadExtensionFromFactory is now async, returns Promise - All factory(api) calls are now awaited Backwards compatible: sync extensions continue to work unchanged. --- packages/coding-agent/src/core/extensions/loader.ts | 10 +++++----- packages/coding-agent/src/core/extensions/types.ts | 4 ++-- packages/coding-agent/src/core/sdk.ts | 2 +- 3 files changed, 8 insertions(+), 8 deletions(-) diff --git a/packages/coding-agent/src/core/extensions/loader.ts b/packages/coding-agent/src/core/extensions/loader.ts index afc23069..03042a87 100644 --- a/packages/coding-agent/src/core/extensions/loader.ts +++ b/packages/coding-agent/src/core/extensions/loader.ts @@ -313,7 +313,7 @@ async function loadExtensionWithBun( setFlagValue, } = createExtensionAPI(handlers, tools, cwd, extensionPath, eventBus, sharedUI); - factory(api); + await factory(api); return { extension: { @@ -401,7 +401,7 @@ async function loadExtension( setFlagValue, } = createExtensionAPI(handlers, tools, cwd, extensionPath, eventBus, sharedUI); - factory(api); + await factory(api); return { extension: { @@ -436,13 +436,13 @@ async function loadExtension( /** * Create a LoadedExtension from an inline factory function. */ -export function loadExtensionFromFactory( +export async function loadExtensionFromFactory( factory: ExtensionFactory, cwd: string, eventBus: EventBus, sharedUI: { ui: ExtensionUIContext; hasUI: boolean }, name = "", -): LoadedExtension { +): Promise { const handlers = new Map(); const tools = new Map(); const { @@ -464,7 +464,7 @@ export function loadExtensionFromFactory( setFlagValue, } = createExtensionAPI(handlers, tools, cwd, name, eventBus, sharedUI); - factory(api); + await factory(api); return { path: name, diff --git a/packages/coding-agent/src/core/extensions/types.ts b/packages/coding-agent/src/core/extensions/types.ts index ad7b8540..96e095d2 100644 --- a/packages/coding-agent/src/core/extensions/types.ts +++ b/packages/coding-agent/src/core/extensions/types.ts @@ -641,8 +641,8 @@ export interface ExtensionAPI { events: EventBus; } -/** Extension factory function type. */ -export type ExtensionFactory = (pi: ExtensionAPI) => void; +/** Extension factory function type. Supports both sync and async initialization. */ +export type ExtensionFactory = (pi: ExtensionAPI) => void | Promise; // ============================================================================ // Loaded Extension Types diff --git a/packages/coding-agent/src/core/sdk.ts b/packages/coding-agent/src/core/sdk.ts index 6440415a..34b456ad 100644 --- a/packages/coding-agent/src/core/sdk.ts +++ b/packages/coding-agent/src/core/sdk.ts @@ -488,7 +488,7 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {} }; for (let i = 0; i < options.extensions.length; i++) { const factory = options.extensions[i]; - const loaded = loadExtensionFromFactory(factory, cwd, eventBus, uiHolder, ``); + const loaded = await loadExtensionFromFactory(factory, cwd, eventBus, uiHolder, ``); extensionsResult.extensions.push(loaded); } // Extend setUIContext to update inline extensions too From e0dbdc56d603ed66bcba5f9c587999d38b0dde45 Mon Sep 17 00:00:00 2001 From: ferologics Date: Wed, 7 Jan 2026 11:31:14 +0100 Subject: [PATCH 2/2] fix: preserve externally-added settings when saving When a user edits settings.json while pi is running (e.g., adding enabledModels), those settings would be lost when pi saved other changes (e.g., changing thinking level via Shift+Tab). The fix re-reads the file before saving and merges the current file contents with in-memory changes, so external edits are preserved. Adds test coverage for SettingsManager. --- .../coding-agent/src/core/settings-manager.ts | 8 +- .../test/settings-manager.test.ts | 108 ++++++++++++++++++ 2 files changed, 115 insertions(+), 1 deletion(-) create mode 100644 packages/coding-agent/test/settings-manager.test.ts diff --git a/packages/coding-agent/src/core/settings-manager.ts b/packages/coding-agent/src/core/settings-manager.ts index e9f29b5d..aae42ac5 100644 --- a/packages/coding-agent/src/core/settings-manager.ts +++ b/packages/coding-agent/src/core/settings-manager.ts @@ -178,7 +178,13 @@ export class SettingsManager { mkdirSync(dir, { recursive: true }); } - // Save only global settings (project settings are read-only) + // Re-read current file to preserve any settings added externally while running + const currentFileSettings = SettingsManager.loadFromFile(this.settingsPath); + // Merge: file settings as base, globalSettings (in-memory changes) as overrides + const mergedSettings = deepMergeSettings(currentFileSettings, this.globalSettings); + this.globalSettings = mergedSettings; + + // Save merged settings (project settings are read-only) writeFileSync(this.settingsPath, JSON.stringify(this.globalSettings, null, 2), "utf-8"); } catch (error) { console.error(`Warning: Could not save settings file: ${error}`); diff --git a/packages/coding-agent/test/settings-manager.test.ts b/packages/coding-agent/test/settings-manager.test.ts new file mode 100644 index 00000000..ad7fb0f3 --- /dev/null +++ b/packages/coding-agent/test/settings-manager.test.ts @@ -0,0 +1,108 @@ +import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "fs"; +import { join } from "path"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { SettingsManager } from "../src/core/settings-manager.js"; + +describe("SettingsManager", () => { + const testDir = join(process.cwd(), "test-settings-tmp"); + const agentDir = join(testDir, "agent"); + const projectDir = join(testDir, "project"); + + beforeEach(() => { + // Clean up and create fresh directories + if (existsSync(testDir)) { + rmSync(testDir, { recursive: true }); + } + mkdirSync(agentDir, { recursive: true }); + mkdirSync(join(projectDir, ".pi"), { recursive: true }); + }); + + afterEach(() => { + if (existsSync(testDir)) { + rmSync(testDir, { recursive: true }); + } + }); + + describe("preserves externally added settings", () => { + it("should preserve enabledModels when changing thinking level", () => { + // Create initial settings file + const settingsPath = join(agentDir, "settings.json"); + writeFileSync( + settingsPath, + JSON.stringify({ + theme: "dark", + defaultModel: "claude-sonnet", + }), + ); + + // Create SettingsManager (simulates pi starting up) + const manager = SettingsManager.create(projectDir, agentDir); + + // Simulate user editing settings.json externally to add enabledModels + const currentSettings = JSON.parse(readFileSync(settingsPath, "utf-8")); + currentSettings.enabledModels = ["claude-opus-4-5", "gpt-5.2-codex"]; + writeFileSync(settingsPath, JSON.stringify(currentSettings, null, 2)); + + // User changes thinking level via Shift+Tab + manager.setDefaultThinkingLevel("high"); + + // Verify enabledModels is preserved + const savedSettings = JSON.parse(readFileSync(settingsPath, "utf-8")); + expect(savedSettings.enabledModels).toEqual(["claude-opus-4-5", "gpt-5.2-codex"]); + expect(savedSettings.defaultThinkingLevel).toBe("high"); + expect(savedSettings.theme).toBe("dark"); + expect(savedSettings.defaultModel).toBe("claude-sonnet"); + }); + + it("should preserve custom settings when changing theme", () => { + const settingsPath = join(agentDir, "settings.json"); + writeFileSync( + settingsPath, + JSON.stringify({ + defaultModel: "claude-sonnet", + }), + ); + + const manager = SettingsManager.create(projectDir, agentDir); + + // User adds custom settings externally + const currentSettings = JSON.parse(readFileSync(settingsPath, "utf-8")); + currentSettings.shellPath = "/bin/zsh"; + currentSettings.extensions = ["/path/to/extension.ts"]; + writeFileSync(settingsPath, JSON.stringify(currentSettings, null, 2)); + + // User changes theme + manager.setTheme("light"); + + // Verify all settings preserved + const savedSettings = JSON.parse(readFileSync(settingsPath, "utf-8")); + expect(savedSettings.shellPath).toBe("/bin/zsh"); + expect(savedSettings.extensions).toEqual(["/path/to/extension.ts"]); + expect(savedSettings.theme).toBe("light"); + }); + + it("should let in-memory changes override file changes for same key", () => { + const settingsPath = join(agentDir, "settings.json"); + writeFileSync( + settingsPath, + JSON.stringify({ + theme: "dark", + }), + ); + + const manager = SettingsManager.create(projectDir, agentDir); + + // User externally sets thinking level to "low" + const currentSettings = JSON.parse(readFileSync(settingsPath, "utf-8")); + currentSettings.defaultThinkingLevel = "low"; + writeFileSync(settingsPath, JSON.stringify(currentSettings, null, 2)); + + // But then changes it via UI to "high" + manager.setDefaultThinkingLevel("high"); + + // In-memory change should win + const savedSettings = JSON.parse(readFileSync(settingsPath, "utf-8")); + expect(savedSettings.defaultThinkingLevel).toBe("high"); + }); + }); +});