1
import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";2
import { basename, dirname, join, resolve, sep } from "node:path";3
import { detectCapabilities, getTerminalColorMode, type TerminalColorMode } from "@earendil-works/pi-tui";4
import chalk from "chalk";5
import { CONFIG_DIR_NAME } from "../config.ts";6
import { loadThemeFromPath, type Theme } from "../modes/interactive/theme/theme.ts";7
import type { ResourceDiagnostic } from "./diagnostics.ts";9
export type { ResourceCollision, ResourceDiagnostic } from "./diagnostics.ts";11
import { canonicalizePath, isLocalPath, resolvePath } from "../utils/paths.ts";12
import { stripBom } from "../utils/text.ts";13
import { createEventBus, type EventBus } from "./event-bus.ts";14
import {15
clearExtensionCache,16
createExtensionRuntime,17
loadExtensionFromFactory,18
loadExtensionsCached,19
} from "./extensions/loader.ts";20
import type {21
Extension,22
ExtensionFactory,23
ExtensionRuntime,24
InlineExtension,25
LoadExtensionsResult,26
} from "./extensions/types.ts";27
import { findGitPaths } from "./footer-data-provider.ts";28
import { DefaultPackageManager, type PathMetadata, type ResolvedResource } from "./package-manager.ts";29
import type { PromptTemplate } from "./prompt-templates.ts";30
import { loadPromptTemplates } from "./prompt-templates.ts";31
import { SettingsManager } from "./settings-manager.ts";32
import type { Skill } from "./skills.ts";33
import { loadSkills } from "./skills.ts";34
import {35
BUILTIN_PATH_PREFIX,36
createSourceInfo,37
getSyntheticPathSource,38
isSyntheticPath,39
type SourceInfo,40
} from "./source-info.ts";41
import { resetTimings } from "./timings.ts";43
export interface ResourceExtensionPaths {44
skillPaths?: Array<{ path: string; metadata: PathMetadata }>;45
promptPaths?: Array<{ path: string; metadata: PathMetadata }>;46
themePaths?: Array<{ path: string; metadata: PathMetadata }>;47
}49
export interface ResourceLoaderReloadOptions {50
resolveProjectTrust?: (input: { extensionsResult: LoadExtensionsResult }) => Promise<boolean>;51
}53
const HOST_PROVIDED_EXTENSION_PACKAGES = new Set([54
"@earendil-works/pi-agent-core",55
"@earendil-works/pi-ai",56
"@earendil-works/pi-coding-agent",57
"@earendil-works/pi-tui",58
"@mariozechner/pi-agent-core",59
"@mariozechner/pi-ai",60
"@mariozechner/pi-coding-agent",61
"@mariozechner/pi-tui",62
"@sinclair/typebox",63
"typebox",64
]);66
function collectExtensionPackageWarnings(67
extensionPaths: string[],68
metadataByPath: Map<string, PathMetadata>,69
): Array<{ path: string; warning: string }> {70
const warnings: Array<{ path: string; warning: string }> = [];71
const packageRoots = new Set(72
extensionPaths73
.map((extensionPath) => metadataByPath.get(extensionPath)?.packageRoot)74
.filter((packageRoot): packageRoot is string => packageRoot !== undefined),75
);76
for (const packageRoot of packageRoots) {77
const packageJsonPath = join(packageRoot, "package.json");78
if (!existsSync(packageJsonPath)) continue;79
const manifest = JSON.parse(stripBom(readFileSync(packageJsonPath, "utf-8"))) as { dependencies?: unknown };80
if (81
typeof manifest.dependencies !== "object" ||82
manifest.dependencies === null ||83
Array.isArray(manifest.dependencies)84
) {85
continue;86
}87
const hostDependencies = Object.keys(manifest.dependencies)88
.filter((name) => HOST_PROVIDED_EXTENSION_PACKAGES.has(name))89
.sort();90
if (hostDependencies.length === 0) continue;91
warnings.push({92
path: packageJsonPath,93
warning: `Host-provided extension packages must be declared in peerDependencies with a "*" range, not dependencies: ${hostDependencies.join(", ")}. Installed copies can bypass the extension loader and create duplicate runtime modules.`,94
});95
}96
return warnings;97
}99
function mergeExtensionWarnings(100
result: LoadExtensionsResult,101
warnings: Array<{ path: string; warning: string }>,102
): void {103
result.warnings = [104
...new Map([...(result.warnings ?? []), ...warnings].map((warning) => [warning.path, warning])).values(),105
];106
}108
type BuiltinExtension = Exclude<InlineExtension, ExtensionFactory>;110
/** Built-in extensions supply the code of `builtin:<name>` extension paths (see `InlineExtension`). */111
export function isBuiltinExtension(input: InlineExtension): input is BuiltinExtension {112
return typeof input !== "function" && input.builtin === true;113
}115
/**116
* Leave out replaceable extensions (see `InlineExtension`) that share a tool, command, or flag name117
* with another extension. For example, a third-party MCP extension that registers `/mcp` replaces118
* the built-in MCP extension instead of both connecting the same servers.119
*/120
function omitReplacedExtensions(121
extensions: Extension[],122
warnings?: Array<{ path: string; warning: string }>,123
): Extension[] {124
const names = (extension: Extension) => [125
...[...extension.tools.keys()].map((name) => `tool:${name}`),126
...[...extension.commands.keys()].map((name) => `command:${name}`),127
...[...extension.flags.keys()].map((name) => `flag:${name}`),128
];129
const taken = new Map(130
extensions131
.filter((extension) => !extension.replaceable)132
.flatMap((extension) => names(extension).map((name) => [name, extension] as const)),133
);134
return extensions.filter((extension) => {135
if (!extension.replaceable) return true;136
const replacement = names(extension)137
.map((name) => ({ name, extension: taken.get(name) }))138
.find((value): value is { name: string; extension: Extension } => value.extension !== undefined);139
if (!replacement) return true;140
if (extension.path.startsWith(BUILTIN_PATH_PREFIX)) {141
const builtinName = extension.path.slice(BUILTIN_PATH_PREFIX.length);142
const [kind, rawName] = replacement.name.split(":", 2);143
const registeredName = kind === "command" ? `/${rawName}` : kind === "flag" ? `--${rawName}` : rawName;144
warnings?.push({145
path: extension.path,146
warning: `Extension ${replacement.extension.path} registers ${kind} \`${registeredName}\`, so built-in extension \`${builtinName}\` was not loaded. To use \`${builtinName}\`, run \`pi config\` and make sure it is enabled under Built-in extensions, then disable or remove the existing extension. We recommend only having one or the other loaded at a time.`,147
});148
}149
return false;150
});151
}153
export interface ResourceLoader {154
getExtensions(): LoadExtensionsResult;155
getSkills(): { skills: Skill[]; diagnostics: ResourceDiagnostic[] };156
getPrompts(): { prompts: PromptTemplate[]; diagnostics: ResourceDiagnostic[] };157
getThemes(): { themes: Theme[]; diagnostics: ResourceDiagnostic[] };158
getAgentsFiles(): { agentsFiles: Array<{ path: string; content: string }> };159
getSystemPrompt(): string | undefined;160
getSystemPromptSource(): { path: string } | undefined;161
getAppendSystemPrompt(): string[];162
getAppendSystemPromptSources(): Array<{ path: string }>;163
extendResources(paths: ResourceExtensionPaths): void;164
reload(options?: ResourceLoaderReloadOptions): Promise<void>;165
}167
function resolvePromptInput(input: string | undefined, description: string): string | undefined {168
if (!input) {169
return undefined;170
}172
if (existsSync(input)) {173
try {174
return stripBom(readFileSync(input, "utf-8"));175
} catch (error) {176
console.error(chalk.yellow(`Warning: Could not read ${description} file ${input}: ${error}`));177
return input;178
}179
}181
return input;182
}184
function loadContextFileFromDir(dir: string): { path: string; content: string } | null {185
const candidates = ["AGENTS.override.md", "AGENTS.md", "AGENTS.MD", "CLAUDE.md", "CLAUDE.MD"];186
for (const filename of candidates) {187
const filePath = join(dir, filename);188
if (existsSync(filePath)) {189
try {190
if (!statSync(filePath).isFile()) {191
continue;192
}193
return {194
path: filePath,195
content: stripBom(readFileSync(filePath, "utf-8")),196
};197
} catch (error) {198
console.error(chalk.yellow(`Warning: Could not read ${filePath}: ${error}`));199
}200
}201
}202
return null;203
}205
/**206
* The main repo's context file that a nested linked worktree's own copy shadows: both207
* occupy the same logical repository scope, so loading both applies that context twice. Returns208
* undefined when nothing is shadowed, leaving normal ancestor inheritance alone.209
*210
* Returned canonicalized (realpath), because `git worktree add` writes the `.git`211
* file's `gitdir:` target in realpath form while cwd may still be symlinked212
* (macOS `/tmp` -> `/private/tmp`).213
*/214
function findShadowedContextFile(cwd: string): string | undefined {215
const gitPaths = findGitPaths(cwd);216
if (!gitPaths) return undefined;217
const commonGitDir = canonicalizePath(gitPaths.commonGitDir);218
const worktreeRoot = canonicalizePath(gitPaths.repoDir);219
const mainRepoRoot = dirname(commonGitDir);220
// False for an ordinary repo, where the two are the same dir, and for a sibling221
// worktree (`git worktree add ../feat`), whose main repo is not an ancestor.222
if (!worktreeRoot.startsWith(`${mainRepoRoot}${sep}`)) return undefined;223
// dirname of the common git dir is the main worktree root only when that dir is224
// itself checked out from the same repo. In a bare layout (`proj/.bare` +225
// `proj/main`) it is just the directory holding `.bare`, which tracks nothing; a226
// submodule's gitdir has no `commondir`, so it lands under `.git/modules`.227
if (canonicalizePath(join(mainRepoRoot, ".git")) !== commonGitDir) return undefined;228
const worktreeContextFile = loadContextFileFromDir(worktreeRoot);229
return worktreeContextFile ? join(mainRepoRoot, basename(worktreeContextFile.path)) : undefined;230
}232
export function loadProjectContextFiles(options: {233
cwd: string;234
agentDir: string;235
}): Array<{ path: string; content: string }> {236
const resolvedCwd = resolvePath(options.cwd);237
const resolvedAgentDir = resolvePath(options.agentDir);239
const contextFiles: Array<{ path: string; content: string }> = [];240
const seenPaths = new Set<string>();242
const globalContext = loadContextFileFromDir(resolvedAgentDir);243
if (globalContext) {244
contextFiles.push(globalContext);245
seenPaths.add(globalContext.path);246
}248
const ancestorContextFiles: Array<{ path: string; content: string }> = [];250
const shadowedContextFile = findShadowedContextFile(resolvedCwd);251
let currentDir = resolvedCwd;253
while (true) {254
const contextFile = loadContextFileFromDir(currentDir);255
const isShadowed =256
shadowedContextFile !== undefined && canonicalizePath(contextFile?.path ?? "") === shadowedContextFile;257
if (contextFile && !isShadowed && !seenPaths.has(contextFile.path)) {258
ancestorContextFiles.unshift(contextFile);259
seenPaths.add(contextFile.path);260
}262
const parentDir = dirname(currentDir);263
if (parentDir === currentDir) break;264
currentDir = parentDir;265
}267
contextFiles.push(...ancestorContextFiles);269
return contextFiles;270
}272
export interface DefaultResourceLoaderOptions {273
cwd: string;274
agentDir: string;275
settingsManager?: SettingsManager;276
eventBus?: EventBus;277
additionalExtensionPaths?: string[];278
additionalSkillPaths?: string[];279
additionalPromptTemplatePaths?: string[];280
additionalThemePaths?: string[];281
extensionFactories?: InlineExtension[];282
noExtensions?: boolean;283
noSkills?: boolean;284
noPromptTemplates?: boolean;285
noThemes?: boolean;286
noContextFiles?: boolean;287
systemPrompt?: string;288
appendSystemPrompt?: string[];289
extensionsOverride?: (base: LoadExtensionsResult) => LoadExtensionsResult;290
skillsOverride?: (base: { skills: Skill[]; diagnostics: ResourceDiagnostic[] }) => {291
skills: Skill[];292
diagnostics: ResourceDiagnostic[];293
};294
promptsOverride?: (base: { prompts: PromptTemplate[]; diagnostics: ResourceDiagnostic[] }) => {295
prompts: PromptTemplate[];296
diagnostics: ResourceDiagnostic[];297
};298
themesOverride?: (base: { themes: Theme[]; diagnostics: ResourceDiagnostic[] }) => {299
themes: Theme[];300
diagnostics: ResourceDiagnostic[];301
};302
agentsFilesOverride?: (base: { agentsFiles: Array<{ path: string; content: string }> }) => {303
agentsFiles: Array<{ path: string; content: string }>;304
};305
systemPromptOverride?: (base: string | undefined) => string | undefined;306
appendSystemPromptOverride?: (base: string[]) => string[];307
}309
export class DefaultResourceLoader implements ResourceLoader {310
private cwd: string;311
private agentDir: string;312
private settingsManager: SettingsManager;313
private eventBus: EventBus;314
private packageManager: DefaultPackageManager;315
private additionalExtensionPaths: string[];316
private additionalSkillPaths: string[];317
private additionalPromptTemplatePaths: string[];318
private additionalThemePaths: string[];319
private extensionFactories: InlineExtension[];320
private builtinExtensions: Map<string, BuiltinExtension>;321
private noExtensions: boolean;322
private noSkills: boolean;323
private noPromptTemplates: boolean;324
private noThemes: boolean;325
private noContextFiles: boolean;326
private systemPromptSource?: string;327
private appendSystemPromptSource?: string[];328
private extensionsOverride?: (base: LoadExtensionsResult) => LoadExtensionsResult;329
private skillsOverride?: (base: { skills: Skill[]; diagnostics: ResourceDiagnostic[] }) => {330
skills: Skill[];331
diagnostics: ResourceDiagnostic[];332
};333
private promptsOverride?: (base: { prompts: PromptTemplate[]; diagnostics: ResourceDiagnostic[] }) => {334
prompts: PromptTemplate[];335
diagnostics: ResourceDiagnostic[];336
};337
private themesOverride?: (base: { themes: Theme[]; diagnostics: ResourceDiagnostic[] }) => {338
themes: Theme[];339
diagnostics: ResourceDiagnostic[];340
};341
private agentsFilesOverride?: (base: { agentsFiles: Array<{ path: string; content: string }> }) => {342
agentsFiles: Array<{ path: string; content: string }>;343
};344
private systemPromptOverride?: (base: string | undefined) => string | undefined;345
private appendSystemPromptOverride?: (base: string[]) => string[];347
private extensionsResult: LoadExtensionsResult;348
private skills: Skill[];349
private skillDiagnostics: ResourceDiagnostic[];350
private prompts: PromptTemplate[];351
private promptDiagnostics: ResourceDiagnostic[];352
private themes: Theme[];353
private themeDiagnostics: ResourceDiagnostic[];354
private agentsFiles: Array<{ path: string; content: string }>;355
private systemPrompt?: string;356
private systemPromptSourcePath?: string;357
private appendSystemPrompt: string[];358
private appendSystemPromptSourcePaths: string[];359
private lastSkillPaths: string[];360
private extensionSkillSourceInfos: Map<string, SourceInfo>;361
private extensionPromptSourceInfos: Map<string, SourceInfo>;362
private extensionThemeSourceInfos: Map<string, SourceInfo>;363
private resourceMetadataByPath: Map<string, PathMetadata>;364
private lastPromptPaths: string[];365
private lastThemePaths: string[];366
private loaded: boolean;368
constructor(options: DefaultResourceLoaderOptions) {369
this.cwd = resolvePath(options.cwd);370
this.agentDir = resolvePath(options.agentDir);371
this.settingsManager = options.settingsManager ?? SettingsManager.create(this.cwd, this.agentDir);372
this.eventBus = options.eventBus ?? createEventBus();373
const factories = options.extensionFactories ?? [];374
this.extensionFactories = factories.filter((input) => !isBuiltinExtension(input));375
this.builtinExtensions = new Map(factories.filter(isBuiltinExtension).map((input) => [input.name, input]));376
this.packageManager = new DefaultPackageManager({377
cwd: this.cwd,378
agentDir: this.agentDir,379
settingsManager: this.settingsManager,380
builtinExtensions: [...this.builtinExtensions.keys()],381
});382
this.additionalExtensionPaths = options.additionalExtensionPaths ?? [];383
this.additionalSkillPaths = options.additionalSkillPaths ?? [];384
this.additionalPromptTemplatePaths = options.additionalPromptTemplatePaths ?? [];385
this.additionalThemePaths = options.additionalThemePaths ?? [];386
this.noExtensions = options.noExtensions ?? false;387
this.noSkills = options.noSkills ?? false;388
this.noPromptTemplates = options.noPromptTemplates ?? false;389
this.noThemes = options.noThemes ?? false;390
this.noContextFiles = options.noContextFiles ?? false;391
this.systemPromptSource = options.systemPrompt;392
this.appendSystemPromptSource = options.appendSystemPrompt;393
this.extensionsOverride = options.extensionsOverride;394
this.skillsOverride = options.skillsOverride;395
this.promptsOverride = options.promptsOverride;396
this.themesOverride = options.themesOverride;397
this.agentsFilesOverride = options.agentsFilesOverride;398
this.systemPromptOverride = options.systemPromptOverride;399
this.appendSystemPromptOverride = options.appendSystemPromptOverride;401
this.extensionsResult = { extensions: [], errors: [], runtime: createExtensionRuntime() };402
this.skills = [];403
this.skillDiagnostics = [];404
this.prompts = [];405
this.promptDiagnostics = [];406
this.themes = [];407
this.themeDiagnostics = [];408
this.agentsFiles = [];409
this.appendSystemPrompt = [];410
this.appendSystemPromptSourcePaths = [];411
this.lastSkillPaths = [];412
this.extensionSkillSourceInfos = new Map();413
this.extensionPromptSourceInfos = new Map();414
this.extensionThemeSourceInfos = new Map();415
this.resourceMetadataByPath = new Map();416
this.lastPromptPaths = [];417
this.lastThemePaths = [];418
this.loaded = false;419
}421
getExtensions(): LoadExtensionsResult {422
return this.extensionsResult;423
}425
getSkills(): { skills: Skill[]; diagnostics: ResourceDiagnostic[] } {426
return { skills: this.skills, diagnostics: this.skillDiagnostics };427
}429
getPrompts(): { prompts: PromptTemplate[]; diagnostics: ResourceDiagnostic[] } {430
return { prompts: this.prompts, diagnostics: this.promptDiagnostics };431
}433
getThemes(): { themes: Theme[]; diagnostics: ResourceDiagnostic[] } {434
return { themes: this.themes, diagnostics: this.themeDiagnostics };435
}437
getAgentsFiles(): { agentsFiles: Array<{ path: string; content: string }> } {438
return { agentsFiles: this.agentsFiles };439
}441
getSystemPrompt(): string | undefined {442
return this.systemPrompt;443
}445
getSystemPromptSource(): { path: string } | undefined {446
return this.systemPromptSourcePath ? { path: this.systemPromptSourcePath } : undefined;447
}449
getAppendSystemPrompt(): string[] {450
return this.appendSystemPrompt;451
}453
getAppendSystemPromptSources(): Array<{ path: string }> {454
return this.appendSystemPromptSourcePaths.map((path) => ({ path }));455
}457
extendResources(paths: ResourceExtensionPaths): void {458
const skillPaths = this.normalizeExtensionPaths(paths.skillPaths ?? []);459
const promptPaths = this.normalizeExtensionPaths(paths.promptPaths ?? []);460
const themePaths = this.normalizeExtensionPaths(paths.themePaths ?? []);462
for (const entry of skillPaths) {463
this.extensionSkillSourceInfos.set(entry.path, createSourceInfo(entry.path, entry.metadata));464
}465
for (const entry of promptPaths) {466
this.extensionPromptSourceInfos.set(entry.path, createSourceInfo(entry.path, entry.metadata));467
}468
for (const entry of themePaths) {469
this.extensionThemeSourceInfos.set(entry.path, createSourceInfo(entry.path, entry.metadata));470
}472
if (skillPaths.length > 0) {473
this.lastSkillPaths = this.mergePaths(474
this.lastSkillPaths,475
skillPaths.map((entry) => entry.path),476
);477
this.updateSkillsFromPaths(this.lastSkillPaths, this.resourceMetadataByPath);478
}480
if (promptPaths.length > 0) {481
this.lastPromptPaths = this.mergePaths(482
this.lastPromptPaths,483
promptPaths.map((entry) => entry.path),484
);485
this.updatePromptsFromPaths(this.lastPromptPaths, this.resourceMetadataByPath);486
}488
if (themePaths.length > 0) {489
this.lastThemePaths = this.mergePaths(490
this.lastThemePaths,491
themePaths.map((entry) => entry.path),492
);493
this.updateThemesFromPaths(this.lastThemePaths, this.resourceMetadataByPath);494
}495
}497
async loadProjectTrustExtensions(): Promise<LoadExtensionsResult> {498
// Force untrusted project settings for the bootstrap pass. This keeps project-local499
// extensions/packages out while still loading user/global and temporary CLI extensions.500
this.settingsManager.setProjectTrusted(false);501
await this.settingsManager.reload();502
return this.loadCurrentExtensionSet({ includeInlineFactories: true });503
}505
async reload(options?: ResourceLoaderReloadOptions): Promise<void> {506
resetTimings("extensions");508
if (this.loaded) {509
clearExtensionCache();510
}512
let preTrustExtensions: LoadExtensionsResult | undefined;513
if (options?.resolveProjectTrust) {514
preTrustExtensions = await this.loadProjectTrustExtensions();515
const projectTrusted = await options.resolveProjectTrust({ extensionsResult: preTrustExtensions });516
this.settingsManager.setProjectTrusted(projectTrusted);517
}519
// reload() preserves SettingsManager.projectTrusted and reloads settings for that trust state.520
await this.settingsManager.reload();521
const resolvedPaths = await this.packageManager.resolve();522
const cliExtensionPaths = await this.packageManager.resolveExtensionSources(this.additionalExtensionPaths, {523
temporary: true,524
});525
// Kept on the instance so post-reload passes (extendResources) can still resolve package metadata.526
this.resourceMetadataByPath = new Map();527
const metadataByPath = this.resourceMetadataByPath;529
this.extensionSkillSourceInfos = new Map();530
this.extensionPromptSourceInfos = new Map();531
this.extensionThemeSourceInfos = new Map();533
// Helper to extract enabled paths and store metadata534
const getEnabledResources = (resources: ResolvedResource[]): ResolvedResource[] => {535
for (const r of resources) {536
if (!metadataByPath.has(r.path)) {537
metadataByPath.set(r.path, r.metadata);538
}539
}540
return resources.filter((r) => r.enabled);541
};543
const getEnabledPaths = (resources: ResolvedResource[]): string[] =>544
getEnabledResources(resources).map((r) => r.path);545
const enabledExtensions = getEnabledPaths(resolvedPaths.extensions);546
const enabledSkillResources = getEnabledResources(resolvedPaths.skills);547
const enabledPrompts = getEnabledPaths(resolvedPaths.prompts);548
const enabledThemes = getEnabledPaths(resolvedPaths.themes);550
const enabledSkills = enabledSkillResources.map((resource) => this.mapSkillPath(resource, metadataByPath));552
// Add CLI paths metadata553
for (const r of cliExtensionPaths.extensions) {554
if (!metadataByPath.has(r.path)) {555
metadataByPath.set(r.path, { source: "cli", scope: "temporary", origin: "top-level" });556
}557
}558
for (const r of cliExtensionPaths.skills) {559
if (!metadataByPath.has(r.path)) {560
metadataByPath.set(r.path, { source: "cli", scope: "temporary", origin: "top-level" });561
}562
}564
const cliEnabledExtensions = getEnabledPaths(cliExtensionPaths.extensions);565
const cliEnabledSkills = getEnabledPaths(cliExtensionPaths.skills);566
const cliEnabledPrompts = getEnabledPaths(cliExtensionPaths.prompts);567
const cliEnabledThemes = getEnabledPaths(cliExtensionPaths.themes);569
const extensionPaths = this.noExtensions570
? cliEnabledExtensions571
: this.mergePaths(cliEnabledExtensions, enabledExtensions);573
const packageWarnings = collectExtensionPackageWarnings(extensionPaths, metadataByPath);574
const extensionsResult = await this.loadFinalExtensionSet(extensionPaths, preTrustExtensions);575
mergeExtensionWarnings(extensionsResult, packageWarnings);576
for (const p of this.additionalExtensionPaths) {577
if (isLocalPath(p)) {578
const resolved = this.resolveResourcePath(p);579
if (!existsSync(resolved)) {580
extensionsResult.errors.push({ path: resolved, error: `Extension path does not exist: ${resolved}` });581
}582
}583
}584
this.extensionsResult = this.extensionsOverride ? this.extensionsOverride(extensionsResult) : extensionsResult;585
this.applyExtensionSourceInfo(this.extensionsResult.extensions, metadataByPath);587
const skillPaths = this.noSkills588
? this.mergePaths(cliEnabledSkills, this.additionalSkillPaths)589
: this.mergePaths([...cliEnabledSkills, ...enabledSkills], this.additionalSkillPaths);591
this.lastSkillPaths = skillPaths;592
this.updateSkillsFromPaths(skillPaths, metadataByPath);593
for (const p of this.additionalSkillPaths) {594
if (isLocalPath(p)) {595
const resolved = this.resolveResourcePath(p);596
if (!existsSync(resolved) && !this.skillDiagnostics.some((d) => d.path === resolved)) {597
this.skillDiagnostics.push({ type: "error", message: "Skill path does not exist", path: resolved });598
}599
}600
}602
const promptPaths = this.noPromptTemplates603
? this.mergePaths(cliEnabledPrompts, this.additionalPromptTemplatePaths)604
: this.mergePaths([...cliEnabledPrompts, ...enabledPrompts], this.additionalPromptTemplatePaths);606
this.lastPromptPaths = promptPaths;607
this.updatePromptsFromPaths(promptPaths, metadataByPath);608
for (const p of this.additionalPromptTemplatePaths) {609
if (isLocalPath(p)) {610
const resolved = this.resolveResourcePath(p);611
if (!existsSync(resolved) && !this.promptDiagnostics.some((d) => d.path === resolved)) {612
this.promptDiagnostics.push({613
type: "error",614
message: "Prompt template path does not exist",615
path: resolved,616
});617
}618
}619
}621
const themePaths = this.noThemes622
? this.mergePaths(cliEnabledThemes, this.additionalThemePaths)623
: this.mergePaths([...cliEnabledThemes, ...enabledThemes], this.additionalThemePaths);625
this.lastThemePaths = themePaths;626
this.updateThemesFromPaths(themePaths, metadataByPath);627
for (const p of this.additionalThemePaths) {628
const resolved = this.resolveResourcePath(p);629
if (!existsSync(resolved) && !this.themeDiagnostics.some((d) => d.path === resolved)) {630
this.themeDiagnostics.push({ type: "error", message: "Theme path does not exist", path: resolved });631
}632
}634
const agentsFiles = {635
agentsFiles: this.noContextFiles636
? []637
: loadProjectContextFiles({638
cwd: this.cwd,639
agentDir: this.agentDir,640
}),641
};642
const resolvedAgentsFiles = this.agentsFilesOverride ? this.agentsFilesOverride(agentsFiles) : agentsFiles;643
this.agentsFiles = resolvedAgentsFiles.agentsFiles;645
const systemPromptSource = this.systemPromptSource ?? this.discoverSystemPromptFile();646
const baseSystemPrompt = resolvePromptInput(systemPromptSource, "system prompt");647
this.systemPrompt = this.systemPromptOverride ? this.systemPromptOverride(baseSystemPrompt) : baseSystemPrompt;648
this.systemPromptSourcePath =649
systemPromptSource && existsSync(systemPromptSource) ? resolvePath(systemPromptSource) : undefined;651
let appendSources = this.appendSystemPromptSource;652
if (!appendSources) {653
const discoveredAppendSystemPromptFile = this.discoverAppendSystemPromptFile();654
appendSources = discoveredAppendSystemPromptFile ? [discoveredAppendSystemPromptFile] : [];655
}656
const baseAppend = appendSources657
.map((s) => resolvePromptInput(s, "append system prompt"))658
.filter((s): s is string => s !== undefined);659
this.appendSystemPrompt = this.appendSystemPromptOverride660
? this.appendSystemPromptOverride(baseAppend)661
: baseAppend;662
this.appendSystemPromptSourcePaths = appendSources663
.filter((source) => existsSync(source))664
.map((source) => resolvePath(source));665
this.loaded = true;666
}668
private async loadCurrentExtensionSet(options: { includeInlineFactories: boolean }): Promise<LoadExtensionsResult> {669
const resolvedPaths = await this.packageManager.resolve();670
const cliExtensionPaths = await this.packageManager.resolveExtensionSources(this.additionalExtensionPaths, {671
temporary: true,672
});673
const enabledExtensions = resolvedPaths.extensions.filter((r) => r.enabled).map((r) => r.path);674
const cliEnabledExtensions = cliExtensionPaths.extensions.filter((r) => r.enabled).map((r) => r.path);675
// Built-in extensions wait for the final pass: project settings can disable them, and a loaded676
// extension cannot be unloaded.677
const extensionPaths = (678
this.noExtensions ? cliEnabledExtensions : this.mergePaths(cliEnabledExtensions, enabledExtensions)679
).filter((path) => !path.startsWith(BUILTIN_PATH_PREFIX));680
const metadataByPath = new Map(681
[...resolvedPaths.extensions, ...cliExtensionPaths.extensions].map((resource) => [682
resource.path,683
resource.metadata,684
]),685
);686
const packageWarnings = collectExtensionPackageWarnings(extensionPaths, metadataByPath);687
const extensionsResult = await loadExtensionsCached(extensionPaths, this.cwd, this.eventBus);688
mergeExtensionWarnings(extensionsResult, packageWarnings);689
if (!options.includeInlineFactories) {690
return extensionsResult;691
}693
const inlineExtensions = await this.loadExtensionFactories(extensionsResult.runtime);694
extensionsResult.extensions.push(...inlineExtensions.extensions);695
extensionsResult.errors.push(...inlineExtensions.errors);696
const replacementWarnings: Array<{ path: string; warning: string }> = [];697
extensionsResult.extensions = omitReplacedExtensions(extensionsResult.extensions, replacementWarnings);698
mergeExtensionWarnings(extensionsResult, replacementWarnings);699
return extensionsResult;700
}702
private resolveExtensionLoadPath(path: string): string {703
return isSyntheticPath(path) ? path : resolvePath(path, this.cwd, { normalizeUnicodeSpaces: true });704
}706
/** Load extension paths: files from disk and `builtin:<name>` paths from the built-in extensions. */707
private async loadExtensionPaths(paths: string[], runtime?: ExtensionRuntime): Promise<LoadExtensionsResult> {708
const isBuiltinPath = (path: string) => path.startsWith(BUILTIN_PATH_PREFIX);709
const result = await loadExtensionsCached(710
paths.filter((path) => !isBuiltinPath(path)),711
this.cwd,712
this.eventBus,713
runtime,714
);715
for (const path of paths.filter(isBuiltinPath)) {716
const builtin = this.builtinExtensions.get(path.slice(BUILTIN_PATH_PREFIX.length));717
if (!builtin) {718
result.errors.push({ path, error: `Unknown built-in extension: ${path}` });719
continue;720
}721
try {722
const extension = await loadExtensionFromFactory(723
builtin.factory,724
this.cwd,725
this.eventBus,726
result.runtime,727
path,728
);729
extension.hidden = true;730
extension.replaceable = builtin.replaceable === true;731
result.extensions.push(extension);732
} catch (error) {733
result.errors.push({ path, error: error instanceof Error ? error.message : "failed to load extension" });734
}735
}736
return result;737
}739
private async loadFinalExtensionSet(740
extensionPaths: string[],741
preTrustExtensions: LoadExtensionsResult | undefined,742
): Promise<LoadExtensionsResult> {743
// Without a pre-trust pass nothing is preloaded, and inline extensions load here.744
const preloaded = preTrustExtensions?.extensions ?? [];745
const preloadedByPath = new Map(746
preloaded747
.filter((extension) => !extension.path.startsWith("<inline:"))748
.map((extension) => [extension.resolvedPath, extension]),749
);750
const failedPreloadPaths = new Set(751
(preTrustExtensions?.errors ?? []).map((error) => this.resolveExtensionLoadPath(error.path)),752
);753
const remainingPaths = extensionPaths.filter((path) => {754
const resolvedPath = this.resolveExtensionLoadPath(path);755
return !preloadedByPath.has(resolvedPath) && !failedPreloadPaths.has(resolvedPath);756
});757
const remainingExtensions = await this.loadExtensionPaths(remainingPaths, preTrustExtensions?.runtime);758
const loadedByPath = new Map(preloadedByPath);759
for (const extension of remainingExtensions.extensions) {760
loadedByPath.set(extension.resolvedPath, extension);761
}763
const inlineExtensions = preTrustExtensions764
? { extensions: preloaded.filter((extension) => extension.path.startsWith("<inline:")), errors: [] }765
: await this.loadExtensionFactories(remainingExtensions.runtime);766
const orderedExtensions = extensionPaths767
.map((path) => loadedByPath.get(this.resolveExtensionLoadPath(path)))768
.filter((extension): extension is Extension => extension !== undefined);769
orderedExtensions.push(...inlineExtensions.extensions);771
const replacementWarnings: Array<{ path: string; warning: string }> = [];772
const extensionsResult: LoadExtensionsResult = {773
extensions: omitReplacedExtensions(orderedExtensions, replacementWarnings),774
errors: [...(preTrustExtensions?.errors ?? []), ...remainingExtensions.errors, ...inlineExtensions.errors],775
warnings: [...(preTrustExtensions?.warnings ?? []), ...(remainingExtensions.warnings ?? [])],776
runtime: remainingExtensions.runtime,777
};778
mergeExtensionWarnings(extensionsResult, replacementWarnings);779
this.addExtensionConflictDiagnostics(extensionsResult);780
return extensionsResult;781
}783
private addExtensionConflictDiagnostics(extensionsResult: LoadExtensionsResult): void {784
// Detect extension conflicts (tools, commands, flags with same names from different extensions)785
// Keep all extensions loaded. Conflicts are reported as diagnostics, and precedence is handled by load order.786
const conflicts = this.detectExtensionConflicts(extensionsResult.extensions);787
for (const conflict of conflicts) {788
extensionsResult.errors.push({ path: conflict.path, error: conflict.message });789
}790
}792
private mapSkillPath(resource: ResolvedResource, metadataByPath: Map<string, PathMetadata>): string {793
if (resource.metadata.source !== "auto" && resource.metadata.origin !== "package") {794
return resource.path;795
}796
try {797
const stats = statSync(resource.path);798
if (!stats.isDirectory()) {799
return resource.path;800
}801
} catch {802
return resource.path;803
}804
const skillFile = join(resource.path, "SKILL.md");805
if (existsSync(skillFile)) {806
if (!metadataByPath.has(skillFile)) {807
metadataByPath.set(skillFile, resource.metadata);808
}809
return skillFile;810
}811
return resource.path;812
}814
private normalizeExtensionPaths(815
entries: Array<{ path: string; metadata: PathMetadata }>,816
): Array<{ path: string; metadata: PathMetadata }> {817
return entries.map((entry) => {818
const metadata = entry.metadata.baseDir819
? { ...entry.metadata, baseDir: this.resolveResourcePath(entry.metadata.baseDir) }820
: entry.metadata;821
return {822
path: this.resolveResourcePath(entry.path),823
metadata,824
};825
});826
}828
private updateSkillsFromPaths(skillPaths: string[], metadataByPath?: Map<string, PathMetadata>): void {829
let skillsResult: { skills: Skill[]; diagnostics: ResourceDiagnostic[] };830
if (this.noSkills && skillPaths.length === 0) {831
skillsResult = { skills: [], diagnostics: [] };832
} else {833
skillsResult = loadSkills({834
cwd: this.cwd,835
agentDir: this.agentDir,836
skillPaths,837
includeDefaults: false,838
});839
}840
const resolvedSkills = this.skillsOverride ? this.skillsOverride(skillsResult) : skillsResult;841
this.skills = resolvedSkills.skills.map((skill) => ({842
...skill,843
sourceInfo:844
this.findSourceInfoForPath(skill.filePath, this.extensionSkillSourceInfos, metadataByPath) ??845
skill.sourceInfo ??846
this.getDefaultSourceInfoForPath(skill.filePath),847
}));848
this.skillDiagnostics = resolvedSkills.diagnostics;849
}851
private updatePromptsFromPaths(promptPaths: string[], metadataByPath?: Map<string, PathMetadata>): void {852
let promptsResult: { prompts: PromptTemplate[]; diagnostics: ResourceDiagnostic[] };853
if (this.noPromptTemplates && promptPaths.length === 0) {854
promptsResult = { prompts: [], diagnostics: [] };855
} else {856
const loaded = loadPromptTemplates({857
cwd: this.cwd,858
agentDir: this.agentDir,859
promptPaths,860
includeDefaults: false,861
});862
const deduped = this.dedupePrompts(loaded.templates);863
promptsResult = {864
prompts: deduped.prompts,865
diagnostics: [...loaded.diagnostics, ...deduped.diagnostics],866
};867
}868
const resolvedPrompts = this.promptsOverride ? this.promptsOverride(promptsResult) : promptsResult;869
this.prompts = resolvedPrompts.prompts.map((prompt) => ({870
...prompt,871
sourceInfo:872
this.findSourceInfoForPath(prompt.filePath, this.extensionPromptSourceInfos, metadataByPath) ??873
prompt.sourceInfo ??874
this.getDefaultSourceInfoForPath(prompt.filePath),875
}));876
this.promptDiagnostics = resolvedPrompts.diagnostics;877
}879
private updateThemesFromPaths(themePaths: string[], metadataByPath?: Map<string, PathMetadata>): void {880
let themesResult: { themes: Theme[]; diagnostics: ResourceDiagnostic[] };881
if (this.noThemes && themePaths.length === 0) {882
themesResult = { themes: [], diagnostics: [] };883
} else {884
// Theme construction only needs trueColor, so skip the unrelated tmux hyperlink probe.885
const colorMode = getTerminalColorMode({886
...detectCapabilities(() => false),887
...this.settingsManager.getTerminalCapabilityOverrides(),888
});889
const loaded = this.loadThemes(themePaths, false, colorMode);890
const deduped = this.dedupeThemes(loaded.themes);891
themesResult = { themes: deduped.themes, diagnostics: [...loaded.diagnostics, ...deduped.diagnostics] };892
}893
const resolvedThemes = this.themesOverride ? this.themesOverride(themesResult) : themesResult;894
this.themes = resolvedThemes.themes.map((theme) => {895
const sourcePath = theme.sourcePath;896
theme.sourceInfo = sourcePath897
? (this.findSourceInfoForPath(sourcePath, this.extensionThemeSourceInfos, metadataByPath) ??898
theme.sourceInfo ??899
this.getDefaultSourceInfoForPath(sourcePath))900
: theme.sourceInfo;901
return theme;902
});903
this.themeDiagnostics = resolvedThemes.diagnostics;904
}906
private applyExtensionSourceInfo(extensions: Extension[], metadataByPath: Map<string, PathMetadata>): void {907
for (const extension of extensions) {908
extension.sourceInfo =909
this.findSourceInfoForPath(extension.path, undefined, metadataByPath) ??910
this.getDefaultSourceInfoForPath(extension.path);911
for (const command of extension.commands.values()) {912
command.sourceInfo = extension.sourceInfo;913
}914
for (const tool of extension.tools.values()) {915
tool.sourceInfo = extension.sourceInfo;916
}917
}918
}920
private findSourceInfoForPath(921
resourcePath: string,922
extraSourceInfos?: Map<string, SourceInfo>,923
metadataByPath?: Map<string, PathMetadata>,924
): SourceInfo | undefined {925
if (!resourcePath) {926
return undefined;927
}929
if (isSyntheticPath(resourcePath)) {930
return this.getDefaultSourceInfoForPath(resourcePath);931
}933
const normalizedResourcePath = resolve(resourcePath);934
if (extraSourceInfos) {935
for (const [sourcePath, sourceInfo] of extraSourceInfos.entries()) {936
const normalizedSourcePath = resolve(sourcePath);937
if (938
normalizedResourcePath === normalizedSourcePath ||939
normalizedResourcePath.startsWith(`${normalizedSourcePath}${sep}`)940
) {941
return { ...sourceInfo, path: resourcePath };942
}943
}944
}946
if (metadataByPath) {947
const exact = metadataByPath.get(normalizedResourcePath) ?? metadataByPath.get(resourcePath);948
if (exact) {949
return createSourceInfo(resourcePath, exact);950
}952
for (const [sourcePath, metadata] of metadataByPath.entries()) {953
const normalizedSourcePath = resolve(sourcePath);954
if (955
normalizedResourcePath === normalizedSourcePath ||956
normalizedResourcePath.startsWith(`${normalizedSourcePath}${sep}`)957
) {958
return createSourceInfo(resourcePath, metadata);959
}960
}961
}963
return undefined;964
}966
private getDefaultSourceInfoForPath(filePath: string): SourceInfo {967
const syntheticSource = getSyntheticPathSource(filePath);968
if (syntheticSource) {969
return {970
path: filePath,971
source: syntheticSource,972
scope: "temporary",973
origin: "top-level",974
};975
}977
const normalizedPath = resolve(filePath);978
const agentRoots = [979
join(this.agentDir, "skills"),980
join(this.agentDir, "prompts"),981
join(this.agentDir, "themes"),982
join(this.agentDir, "extensions"),983
];984
const projectRoots = [985
join(this.cwd, CONFIG_DIR_NAME, "skills"),986
join(this.cwd, CONFIG_DIR_NAME, "prompts"),987
join(this.cwd, CONFIG_DIR_NAME, "themes"),988
join(this.cwd, CONFIG_DIR_NAME, "extensions"),989
];991
for (const root of agentRoots) {992
if (this.isUnderPath(normalizedPath, root)) {993
return { path: filePath, source: "local", scope: "user", origin: "top-level", baseDir: root };994
}995
}997
for (const root of projectRoots) {998
if (this.isUnderPath(normalizedPath, root)) {999
return { path: filePath, source: "local", scope: "project", origin: "top-level", baseDir: root };1000
}1001
}1003
return {1004
path: filePath,1005
source: "local",1006
scope: "temporary",1007
origin: "top-level",1008
baseDir: statSync(normalizedPath).isDirectory() ? normalizedPath : resolve(normalizedPath, ".."),1009
};1010
}1012
private mergePaths(primary: string[], additional: string[]): string[] {1013
const merged: string[] = [];1014
const seen = new Set<string>();1016
for (const p of [...primary, ...additional]) {1017
const resolved = this.resolveResourcePath(p);1018
const canonicalPath = canonicalizePath(resolved);1019
if (seen.has(canonicalPath)) continue;1020
seen.add(canonicalPath);1021
merged.push(resolved);1022
}1024
return merged;1025
}1027
private resolveResourcePath(p: string): string {1028
return isSyntheticPath(p) ? p : resolvePath(p, this.cwd, { trim: true });1029
}1031
private loadThemes(1032
paths: string[],1033
includeDefaults: boolean,1034
colorMode: TerminalColorMode,1035
): {1036
themes: Theme[];1037
diagnostics: ResourceDiagnostic[];1038
} {1039
const themes: Theme[] = [];1040
const diagnostics: ResourceDiagnostic[] = [];1041
if (includeDefaults) {1042
const defaultDirs = [join(this.agentDir, "themes"), join(this.cwd, CONFIG_DIR_NAME, "themes")];1044
for (const dir of defaultDirs) {1045
this.loadThemesFromDir(dir, themes, diagnostics, colorMode);1046
}1047
}1049
for (const p of paths) {1050
const resolved = this.resolveResourcePath(p);1051
if (!existsSync(resolved)) {1052
diagnostics.push({ type: "warning", message: "theme path does not exist", path: resolved });1053
continue;1054
}1056
try {1057
const stats = statSync(resolved);1058
if (stats.isDirectory()) {1059
this.loadThemesFromDir(resolved, themes, diagnostics, colorMode);1060
} else if (stats.isFile() && resolved.endsWith(".json")) {1061
this.loadThemeFromFile(resolved, themes, diagnostics, colorMode);1062
} else {1063
diagnostics.push({ type: "warning", message: "theme path is not a json file", path: resolved });1064
}1065
} catch (error) {1066
const message = error instanceof Error ? error.message : "failed to read theme path";1067
diagnostics.push({ type: "warning", message, path: resolved });1068
}1069
}1071
return { themes, diagnostics };1072
}1074
private loadThemesFromDir(1075
dir: string,1076
themes: Theme[],1077
diagnostics: ResourceDiagnostic[],1078
colorMode: TerminalColorMode,1079
): void {1080
if (!existsSync(dir)) {1081
return;1082
}1084
try {1085
const entries = readdirSync(dir, { withFileTypes: true });1086
for (const entry of entries) {1087
let isFile = entry.isFile();1088
if (entry.isSymbolicLink()) {1089
try {1090
isFile = statSync(join(dir, entry.name)).isFile();1091
} catch {1092
continue;1093
}1094
}1095
if (!isFile) {1096
continue;1097
}1098
if (!entry.name.endsWith(".json")) {1099
continue;1100
}1101
this.loadThemeFromFile(join(dir, entry.name), themes, diagnostics, colorMode);1102
}1103
} catch (error) {1104
const message = error instanceof Error ? error.message : "failed to read theme directory";1105
diagnostics.push({ type: "warning", message, path: dir });1106
}1107
}1109
private loadThemeFromFile(1110
filePath: string,1111
themes: Theme[],1112
diagnostics: ResourceDiagnostic[],1113
colorMode: TerminalColorMode,1114
): void {1115
try {1116
themes.push(loadThemeFromPath(filePath, colorMode));1117
} catch (error) {1118
const message = error instanceof Error ? error.message : "failed to load theme";1119
diagnostics.push({ type: "warning", message, path: filePath });1120
}1121
}1123
private async loadExtensionFactories(runtime: ExtensionRuntime): Promise<{1124
extensions: Extension[];1125
errors: Array<{ path: string; error: string }>;1126
}> {1127
const extensions: Extension[] = [];1128
const errors: Array<{ path: string; error: string }> = [];1130
for (const [index, input] of this.extensionFactories.entries()) {1131
const isNamed = typeof input !== "function";1132
const factory = isNamed ? input.factory : input;1133
const extensionPath = `<inline:${isNamed ? input.name : index + 1}>`;1134
try {1135
const extension = await loadExtensionFromFactory(factory, this.cwd, this.eventBus, runtime, extensionPath);1136
extension.hidden = isNamed && input.hidden;1137
extension.replaceable = isNamed && input.replaceable === true;1138
extensions.push(extension);1139
} catch (error) {1140
const message = error instanceof Error ? error.message : "failed to load extension";1141
errors.push({ path: extensionPath, error: message });1142
}1143
}1145
return { extensions, errors };1146
}1148
private dedupePrompts(prompts: PromptTemplate[]): { prompts: PromptTemplate[]; diagnostics: ResourceDiagnostic[] } {1149
const seen = new Map<string, PromptTemplate>();1150
const diagnostics: ResourceDiagnostic[] = [];1152
for (const prompt of prompts) {1153
const existing = seen.get(prompt.name);1154
if (existing) {1155
diagnostics.push({1156
type: "collision",1157
message: `name "/${prompt.name}" collision`,1158
path: prompt.filePath,1159
collision: {1160
resourceType: "prompt",1161
name: prompt.name,1162
winnerPath: existing.filePath,1163
loserPath: prompt.filePath,1164
},1165
});1166
} else {1167
seen.set(prompt.name, prompt);1168
}1169
}1171
return { prompts: Array.from(seen.values()), diagnostics };1172
}1174
private dedupeThemes(themes: Theme[]): { themes: Theme[]; diagnostics: ResourceDiagnostic[] } {1175
const seen = new Map<string, Theme>();1176
const diagnostics: ResourceDiagnostic[] = [];1178
for (const t of themes) {1179
const name = t.name ?? "unnamed";1180
const existing = seen.get(name);1181
if (existing) {1182
diagnostics.push({1183
type: "collision",1184
message: `name "${name}" collision`,1185
path: t.sourcePath,1186
collision: {1187
resourceType: "theme",1188
name,1189
winnerPath: existing.sourcePath ?? "<builtin>",1190
loserPath: t.sourcePath ?? "<builtin>",1191
},1192
});1193
} else {1194
seen.set(name, t);1195
}1196
}1198
return { themes: Array.from(seen.values()), diagnostics };1199
}1201
private discoverSystemPromptFile(): string | undefined {1202
const projectPath = join(this.cwd, CONFIG_DIR_NAME, "SYSTEM.md");1203
if (this.settingsManager.isProjectTrusted() && existsSync(projectPath)) {1204
return projectPath;1205
}1207
const globalPath = join(this.agentDir, "SYSTEM.md");1208
if (existsSync(globalPath)) {1209
return globalPath;1210
}1212
return undefined;1213
}1215
private discoverAppendSystemPromptFile(): string | undefined {1216
const projectPath = join(this.cwd, CONFIG_DIR_NAME, "APPEND_SYSTEM.md");1217
if (this.settingsManager.isProjectTrusted() && existsSync(projectPath)) {1218
return projectPath;1219
}1221
const globalPath = join(this.agentDir, "APPEND_SYSTEM.md");1222
if (existsSync(globalPath)) {1223
return globalPath;1224
}1226
return undefined;1227
}1229
private isUnderPath(target: string, root: string): boolean {1230
const normalizedRoot = resolve(root);1231
if (target === normalizedRoot) {1232
return true;1233
}1234
const prefix = normalizedRoot.endsWith(sep) ? normalizedRoot : `${normalizedRoot}${sep}`;1235
return target.startsWith(prefix);1236
}1238
private detectExtensionConflicts(extensions: Extension[]): Array<{ path: string; message: string }> {1239
const conflicts: Array<{ path: string; message: string }> = [];1241
// Track which extension registered each tool and flag1242
const toolOwners = new Map<string, string>();1243
const flagOwners = new Map<string, string>();1245
for (const ext of extensions) {1246
// Check tools1247
for (const toolName of ext.tools.keys()) {1248
const existingOwner = toolOwners.get(toolName);1249
if (existingOwner && existingOwner !== ext.path) {1250
conflicts.push({1251
path: ext.path,1252
message: `Tool "${toolName}" conflicts with ${existingOwner}`,1253
});1254
} else {1255
toolOwners.set(toolName, ext.path);1256
}1257
}1259
// Check flags1260
for (const flagName of ext.flags.keys()) {1261
const existingOwner = flagOwners.get(flagName);1262
if (existingOwner && existingOwner !== ext.path) {1263
conflicts.push({1264
path: ext.path,1265
message: `Flag "--${flagName}" conflicts with ${existingOwner}`,1266
});1267
} else {1268
flagOwners.set(flagName, ext.path);1269
}1270
}1271
}1273
return conflicts;1274
}1275
}