From f0379384fe41bc937df66a41fbd7e86898798b83 Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Tue, 24 Feb 2026 23:50:55 +0100 Subject: [PATCH] feat(coding-agent): prioritize project resources over global --- packages/coding-agent/CHANGELOG.md | 5 + .../src/core/extensions/loader.ts | 12 +- .../src/core/extensions/runner.ts | 26 +++- .../coding-agent/src/core/package-manager.ts | 88 ++++++------ .../coding-agent/src/core/resource-loader.ts | 9 +- .../src/modes/interactive/interactive-mode.ts | 2 +- .../test/extensions-runner.test.ts | 66 +++++++++ .../coding-agent/test/resource-loader.test.ts | 126 +++++++++++++++++- 8 files changed, 271 insertions(+), 63 deletions(-) diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 5836e467..3fc75ab6 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -2,6 +2,11 @@ ## [Unreleased] +### Breaking Changes + +- Resource precedence for extensions, skills, prompts, themes, and slash-command name collisions is now project-first (`cwd/.pi`) before user-global (`~/.pi/agent`). If you relied on global resources overriding project resources with the same names, rename or reorder your resources. +- Extension registration conflicts no longer unload the entire later extension. All extensions stay loaded, and conflicting command/tool/flag names are resolved by first registration in load order. + ## [0.54.2] - 2026-02-23 ### Fixed diff --git a/packages/coding-agent/src/core/extensions/loader.ts b/packages/coding-agent/src/core/extensions/loader.ts index 12f7c82c..cec13e61 100644 --- a/packages/coding-agent/src/core/extensions/loader.ts +++ b/packages/coding-agent/src/core/extensions/loader.ts @@ -173,7 +173,7 @@ function createExtensionAPI( options: { description?: string; type: "boolean" | "string"; default?: boolean | string }, ): void { extension.flags.set(name, { name, extensionPath: extension.path, ...options }); - if (options.default !== undefined) { + if (options.default !== undefined && !runtime.flagValues.has(name)) { runtime.flagValues.set(name, options.default); } }, @@ -486,14 +486,14 @@ export async function discoverAndLoadExtensions( } }; - // 1. Global extensions: agentDir/extensions/ - const globalExtDir = path.join(agentDir, "extensions"); - addPaths(discoverExtensionsInDir(globalExtDir)); - - // 2. Project-local extensions: cwd/.pi/extensions/ + // 1. Project-local extensions: cwd/.pi/extensions/ const localExtDir = path.join(cwd, ".pi", "extensions"); addPaths(discoverExtensionsInDir(localExtDir)); + // 2. Global extensions: agentDir/extensions/ + const globalExtDir = path.join(agentDir, "extensions"); + addPaths(discoverExtensionsInDir(globalExtDir)); + // 3. Explicitly configured paths for (const p of configuredPaths) { const resolved = resolvePath(p, cwd); diff --git a/packages/coding-agent/src/core/extensions/runner.ts b/packages/coding-agent/src/core/extensions/runner.ts index 06ee38bc..d71032d4 100644 --- a/packages/coding-agent/src/core/extensions/runner.ts +++ b/packages/coding-agent/src/core/extensions/runner.ts @@ -301,15 +301,17 @@ export class ExtensionRunner { return this.extensions.map((e) => e.path); } - /** Get all registered tools from all extensions. */ + /** Get all registered tools from all extensions (first registration per name wins). */ getAllRegisteredTools(): RegisteredTool[] { - const tools: RegisteredTool[] = []; + const toolsByName = new Map(); for (const ext of this.extensions) { for (const tool of ext.tools.values()) { - tools.push(tool); + if (!toolsByName.has(tool.definition.name)) { + toolsByName.set(tool.definition.name, tool); + } } } - return tools; + return Array.from(toolsByName.values()); } /** Get a tool definition by name. Returns undefined if not found. */ @@ -327,7 +329,9 @@ export class ExtensionRunner { const allFlags = new Map(); for (const ext of this.extensions) { for (const [name, flag] of ext.flags) { - allFlags.set(name, flag); + if (!allFlags.has(name)) { + allFlags.set(name, flag); + } } } return allFlags; @@ -425,6 +429,7 @@ export class ExtensionRunner { this.commandDiagnostics = []; const commands: RegisteredCommand[] = []; + const commandOwners = new Map(); for (const ext of this.extensions) { for (const command of ext.commands.values()) { if (reserved?.has(command.name)) { @@ -436,6 +441,17 @@ export class ExtensionRunner { continue; } + const existingOwner = commandOwners.get(command.name); + if (existingOwner) { + const message = `Extension command '${command.name}' from ${ext.path} conflicts with ${existingOwner}. Skipping.`; + this.commandDiagnostics.push({ type: "warning", message, path: ext.path }); + if (!this.hasUI()) { + console.warn(message); + } + continue; + } + + commandOwners.set(command.name, ext.path); commands.push(command); } } diff --git a/packages/coding-agent/src/core/package-manager.ts b/packages/coding-agent/src/core/package-manager.ts index cc47cd32..5ac0973f 100644 --- a/packages/coding-agent/src/core/package-manager.ts +++ b/packages/coding-agent/src/core/package-manager.ts @@ -722,14 +722,14 @@ export class DefaultPackageManager implements PackageManager { const globalSettings = this.settingsManager.getGlobalSettings(); const projectSettings = this.settingsManager.getProjectSettings(); - // Collect all packages with scope + // Collect all packages with scope (project first so cwd resources win collisions) const allPackages: Array<{ pkg: PackageSource; scope: SourceScope }> = []; - for (const pkg of globalSettings.packages ?? []) { - allPackages.push({ pkg, scope: "user" }); - } for (const pkg of projectSettings.packages ?? []) { allPackages.push({ pkg, scope: "project" }); } + for (const pkg of globalSettings.packages ?? []) { + allPackages.push({ pkg, scope: "user" }); + } // Dedupe: project scope wins over global for same package identity const packageSources = this.dedupePackages(allPackages); @@ -742,17 +742,6 @@ export class DefaultPackageManager implements PackageManager { const target = this.getTargetMap(accumulator, resourceType); const globalEntries = (globalSettings[resourceType] ?? []) as string[]; const projectEntries = (projectSettings[resourceType] ?? []) as string[]; - this.resolveLocalEntries( - globalEntries, - resourceType, - target, - { - source: "local", - scope: "user", - origin: "top-level", - }, - globalBaseDir, - ); this.resolveLocalEntries( projectEntries, resourceType, @@ -764,6 +753,17 @@ export class DefaultPackageManager implements PackageManager { }, projectBaseDir, ); + this.resolveLocalEntries( + globalEntries, + resourceType, + target, + { + source: "local", + scope: "user", + origin: "top-level", + }, + globalBaseDir, + ); } this.addAutoDiscoveredResources(accumulator, globalSettings, projectSettings, globalBaseDir, projectBaseDir); @@ -1600,35 +1600,6 @@ export class DefaultPackageManager implements PackageManager { } }; - addResources( - "extensions", - collectAutoExtensionEntries(userDirs.extensions), - userMetadata, - userOverrides.extensions, - globalBaseDir, - ); - addResources( - "skills", - [...collectAutoSkillEntries(userDirs.skills), ...collectAutoSkillEntries(userAgentsSkillsDir)], - userMetadata, - userOverrides.skills, - globalBaseDir, - ); - addResources( - "prompts", - collectAutoPromptEntries(userDirs.prompts), - userMetadata, - userOverrides.prompts, - globalBaseDir, - ); - addResources( - "themes", - collectAutoThemeEntries(userDirs.themes), - userMetadata, - userOverrides.themes, - globalBaseDir, - ); - addResources( "extensions", collectAutoExtensionEntries(projectDirs.extensions), @@ -1660,6 +1631,35 @@ export class DefaultPackageManager implements PackageManager { projectOverrides.themes, projectBaseDir, ); + + addResources( + "extensions", + collectAutoExtensionEntries(userDirs.extensions), + userMetadata, + userOverrides.extensions, + globalBaseDir, + ); + addResources( + "skills", + [...collectAutoSkillEntries(userDirs.skills), ...collectAutoSkillEntries(userAgentsSkillsDir)], + userMetadata, + userOverrides.skills, + globalBaseDir, + ); + addResources( + "prompts", + collectAutoPromptEntries(userDirs.prompts), + userMetadata, + userOverrides.prompts, + globalBaseDir, + ); + addResources( + "themes", + collectAutoThemeEntries(userDirs.themes), + userMetadata, + userOverrides.themes, + globalBaseDir, + ); } private collectFilesFromPaths(paths: string[], resourceType: ResourceType): string[] { diff --git a/packages/coding-agent/src/core/resource-loader.ts b/packages/coding-agent/src/core/resource-loader.ts index ff7925a9..817039fb 100644 --- a/packages/coding-agent/src/core/resource-loader.ts +++ b/packages/coding-agent/src/core/resource-loader.ts @@ -384,13 +384,10 @@ export class DefaultResourceLoader implements ResourceLoader { extensionsResult.errors.push(...inlineExtensions.errors); // Detect extension conflicts (tools, commands, flags with same names from different extensions) + // Keep all extensions loaded. Conflicts are reported as diagnostics, and precedence is handled by load order. const conflicts = this.detectExtensionConflicts(extensionsResult.extensions); - if (conflicts.length > 0) { - const conflictingPaths = new Set(conflicts.map((c) => c.path)); - extensionsResult.extensions = extensionsResult.extensions.filter((ext) => !conflictingPaths.has(ext.path)); - for (const conflict of conflicts) { - extensionsResult.errors.push({ path: conflict.path, error: conflict.message }); - } + for (const conflict of conflicts) { + extensionsResult.errors.push({ path: conflict.path, error: conflict.message }); } this.extensionsResult = this.extensionsOverride ? this.extensionsOverride(extensionsResult) : extensionsResult; diff --git a/packages/coding-agent/src/modes/interactive/interactive-mode.ts b/packages/coding-agent/src/modes/interactive/interactive-mode.ts index 3ff3feac..05e192e1 100644 --- a/packages/coding-agent/src/modes/interactive/interactive-mode.ts +++ b/packages/coding-agent/src/modes/interactive/interactive-mode.ts @@ -729,7 +729,7 @@ export class InteractiveMode { } } - return [groups.user, groups.project, groups.path].filter( + return [groups.project, groups.user, groups.path].filter( (group) => group.paths.length > 0 || group.packages.size > 0, ); } diff --git a/packages/coding-agent/test/extensions-runner.test.ts b/packages/coding-agent/test/extensions-runner.test.ts index 4538698f..411a7251 100644 --- a/packages/coding-agent/test/extensions-runner.test.ts +++ b/packages/coding-agent/test/extensions-runner.test.ts @@ -234,6 +234,42 @@ describe("ExtensionRunner", () => { expect(tools.length).toBe(2); expect(tools.map((t) => t.definition.name).sort()).toEqual(["tool_a", "tool_b"]); }); + + it("keeps first tool when two extensions register the same name", async () => { + const first = ` + import { Type } from "@sinclair/typebox"; + export default function(pi) { + pi.registerTool({ + name: "shared", + label: "shared", + description: "first", + parameters: Type.Object({}), + execute: async () => ({ content: [{ type: "text", text: "ok" }], details: {} }), + }); + } + `; + const second = ` + import { Type } from "@sinclair/typebox"; + export default function(pi) { + pi.registerTool({ + name: "shared", + label: "shared", + description: "second", + parameters: Type.Object({}), + execute: async () => ({ content: [{ type: "text", text: "ok" }], details: {} }), + }); + } + `; + fs.writeFileSync(path.join(extensionsDir, "a-first.ts"), first); + fs.writeFileSync(path.join(extensionsDir, "b-second.ts"), second); + + const result = await discoverAndLoadExtensions([], tempDir, tempDir); + const runner = new ExtensionRunner(result.extensions, result.runtime, tempDir, sessionManager, modelRegistry); + const tools = runner.getAllRegisteredTools(); + + expect(tools).toHaveLength(1); + expect(tools[0]?.definition.description).toBe("first"); + }); }); describe("command collection", () => { @@ -377,6 +413,36 @@ describe("ExtensionRunner", () => { expect(flags.has("my-flag")).toBe(true); }); + it("keeps first flag when two extensions register the same name", async () => { + const first = ` + export default function(pi) { + pi.registerFlag("shared-flag", { + description: "first", + type: "boolean", + default: true, + }); + } + `; + const second = ` + export default function(pi) { + pi.registerFlag("shared-flag", { + description: "second", + type: "boolean", + default: false, + }); + } + `; + fs.writeFileSync(path.join(extensionsDir, "a-first.ts"), first); + fs.writeFileSync(path.join(extensionsDir, "b-second.ts"), second); + + const result = await discoverAndLoadExtensions([], tempDir, tempDir); + const runner = new ExtensionRunner(result.extensions, result.runtime, tempDir, sessionManager, modelRegistry); + const flags = runner.getFlags(); + + expect(flags.get("shared-flag")?.description).toBe("first"); + expect(result.runtime.flagValues.get("shared-flag")).toBe(true); + }); + it("can set flag values", async () => { const extCode = ` export default function(pi) { diff --git a/packages/coding-agent/test/resource-loader.test.ts b/packages/coding-agent/test/resource-loader.test.ts index f4109e8a..d8141259 100644 --- a/packages/coding-agent/test/resource-loader.test.ts +++ b/packages/coding-agent/test/resource-loader.test.ts @@ -1,8 +1,12 @@ -import { mkdirSync, rmSync, writeFileSync } from "node:fs"; +import { mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { AuthStorage } from "../src/core/auth-storage.js"; +import { ExtensionRunner } from "../src/core/extensions/runner.js"; +import { ModelRegistry } from "../src/core/model-registry.js"; import { DefaultResourceLoader } from "../src/core/resource-loader.js"; +import { SessionManager } from "../src/core/session-manager.js"; import { SettingsManager } from "../src/core/settings-manager.js"; import type { Skill } from "../src/core/skills.js"; @@ -91,6 +95,126 @@ Prompt content.`, expect(prompts.some((p) => p.name === "test-prompt")).toBe(true); }); + it("should prefer project resources over user on name collisions", async () => { + const userPromptsDir = join(agentDir, "prompts"); + const projectPromptsDir = join(cwd, ".pi", "prompts"); + mkdirSync(userPromptsDir, { recursive: true }); + mkdirSync(projectPromptsDir, { recursive: true }); + const userPromptPath = join(userPromptsDir, "commit.md"); + const projectPromptPath = join(projectPromptsDir, "commit.md"); + writeFileSync(userPromptPath, "User prompt"); + writeFileSync(projectPromptPath, "Project prompt"); + + const userSkillDir = join(agentDir, "skills", "collision-skill"); + const projectSkillDir = join(cwd, ".pi", "skills", "collision-skill"); + mkdirSync(userSkillDir, { recursive: true }); + mkdirSync(projectSkillDir, { recursive: true }); + const userSkillPath = join(userSkillDir, "SKILL.md"); + const projectSkillPath = join(projectSkillDir, "SKILL.md"); + writeFileSync( + userSkillPath, + `--- +name: collision-skill +description: user +--- +User skill`, + ); + writeFileSync( + projectSkillPath, + `--- +name: collision-skill +description: project +--- +Project skill`, + ); + + const baseTheme = JSON.parse( + readFileSync(join(process.cwd(), "src", "modes", "interactive", "theme", "dark.json"), "utf-8"), + ) as { name: string; vars?: Record }; + baseTheme.name = "collision-theme"; + const userThemePath = join(agentDir, "themes", "collision.json"); + const projectThemePath = join(cwd, ".pi", "themes", "collision.json"); + mkdirSync(join(agentDir, "themes"), { recursive: true }); + mkdirSync(join(cwd, ".pi", "themes"), { recursive: true }); + writeFileSync(userThemePath, JSON.stringify(baseTheme, null, 2)); + if (baseTheme.vars) { + baseTheme.vars.accent = "#ff00ff"; + } + writeFileSync(projectThemePath, JSON.stringify(baseTheme, null, 2)); + + const loader = new DefaultResourceLoader({ cwd, agentDir }); + await loader.reload(); + + const prompt = loader.getPrompts().prompts.find((p) => p.name === "commit"); + expect(prompt?.filePath).toBe(projectPromptPath); + + const skill = loader.getSkills().skills.find((s) => s.name === "collision-skill"); + expect(skill?.filePath).toBe(projectSkillPath); + + const theme = loader.getThemes().themes.find((t) => t.name === "collision-theme"); + expect(theme?.sourcePath).toBe(projectThemePath); + }); + + it("should keep both extensions loaded when command names collide", async () => { + const userExtDir = join(agentDir, "extensions"); + const projectExtDir = join(cwd, ".pi", "extensions"); + mkdirSync(userExtDir, { recursive: true }); + mkdirSync(projectExtDir, { recursive: true }); + + writeFileSync( + join(projectExtDir, "project.ts"), + `export default function(pi) { + pi.registerCommand("deploy", { + description: "project deploy", + handler: async () => {}, + }); + pi.registerCommand("project-only", { + description: "project only", + handler: async () => {}, + }); +}`, + ); + + writeFileSync( + join(userExtDir, "user.ts"), + `export default function(pi) { + pi.registerCommand("deploy", { + description: "user deploy", + handler: async () => {}, + }); + pi.registerCommand("user-only", { + description: "user only", + handler: async () => {}, + }); +}`, + ); + + const loader = new DefaultResourceLoader({ cwd, agentDir }); + await loader.reload(); + + const extensionsResult = loader.getExtensions(); + expect(extensionsResult.extensions).toHaveLength(2); + expect(extensionsResult.errors.some((e) => e.error.includes('Command "/deploy" conflicts'))).toBe(true); + + const sessionManager = SessionManager.inMemory(); + const authStorage = AuthStorage.create(join(tempDir, "auth.json")); + const modelRegistry = new ModelRegistry(authStorage); + const runner = new ExtensionRunner( + extensionsResult.extensions, + extensionsResult.runtime, + cwd, + sessionManager, + modelRegistry, + ); + + expect(runner.getCommand("deploy")?.description).toBe("project deploy"); + expect(runner.getCommand("project-only")?.description).toBe("project only"); + expect(runner.getCommand("user-only")?.description).toBe("user only"); + + const commandNames = runner.getRegisteredCommands().map((c) => c.name); + expect(commandNames.filter((name) => name === "deploy")).toHaveLength(1); + }); + it("should honor overrides for auto-discovered resources", async () => { const settingsManager = SettingsManager.inMemory(); settingsManager.setExtensionPaths(["-extensions/disabled.ts"]);