feat(coding-agent): prioritize project resources over global

This commit is contained in:
Mario Zechner 2026-02-24 23:50:55 +01:00
parent 380236a003
commit f0379384fe
8 changed files with 271 additions and 63 deletions

View file

@ -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

View file

@ -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);

View file

@ -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<string, RegisteredTool>();
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<string, ExtensionFlag>();
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<string, string>();
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);
}
}

View file

@ -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[] {

View file

@ -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;

View file

@ -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,
);
}

View file

@ -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) {

View file

@ -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<string, string> };
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"]);