返回源码地图

packages/coding-agent/src/core/resource-loader.ts

v1.0.0 · a13d35a742c6 · 08:资源发现、reload、信任主干;非所有 package 解析分支审计

完整原文供逐行核对;页面收录不代表每行都经过人工语义审核。MIT 许可见 许可证。

1import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
2import { basename, dirname, join, resolve, sep } from "node:path";
3import { detectCapabilities, getTerminalColorMode, type TerminalColorMode } from "@earendil-works/pi-tui";
4import chalk from "chalk";
5import { CONFIG_DIR_NAME } from "../config.ts";
6import { loadThemeFromPath, type Theme } from "../modes/interactive/theme/theme.ts";
7import type { ResourceDiagnostic } from "./diagnostics.ts";
8
9export type { ResourceCollision, ResourceDiagnostic } from "./diagnostics.ts";
10
11import { canonicalizePath, isLocalPath, resolvePath } from "../utils/paths.ts";
12import { stripBom } from "../utils/text.ts";
13import { createEventBus, type EventBus } from "./event-bus.ts";
14import {
15 clearExtensionCache,
16 createExtensionRuntime,
17 loadExtensionFromFactory,
18 loadExtensionsCached,
19} from "./extensions/loader.ts";
20import type {
21 Extension,
22 ExtensionFactory,
23 ExtensionRuntime,
24 InlineExtension,
25 LoadExtensionsResult,
26} from "./extensions/types.ts";
27import { findGitPaths } from "./footer-data-provider.ts";
28import { DefaultPackageManager, type PathMetadata, type ResolvedResource } from "./package-manager.ts";
29import type { PromptTemplate } from "./prompt-templates.ts";
30import { loadPromptTemplates } from "./prompt-templates.ts";
31import { SettingsManager } from "./settings-manager.ts";
32import type { Skill } from "./skills.ts";
33import { loadSkills } from "./skills.ts";
34import {
35 BUILTIN_PATH_PREFIX,
36 createSourceInfo,
37 getSyntheticPathSource,
38 isSyntheticPath,
39 type SourceInfo,
40} from "./source-info.ts";
41import { resetTimings } from "./timings.ts";
42
43export interface ResourceExtensionPaths {
44 skillPaths?: Array<{ path: string; metadata: PathMetadata }>;
45 promptPaths?: Array<{ path: string; metadata: PathMetadata }>;
46 themePaths?: Array<{ path: string; metadata: PathMetadata }>;
47}
48
49export interface ResourceLoaderReloadOptions {
50 resolveProjectTrust?: (input: { extensionsResult: LoadExtensionsResult }) => Promise<boolean>;
51}
52
53const 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]);
65
66function 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 extensionPaths
73 .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}
98
99function 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}
107
108type BuiltinExtension = Exclude<InlineExtension, ExtensionFactory>;
109
110/** Built-in extensions supply the code of `builtin:<name>` extension paths (see `InlineExtension`). */
111export function isBuiltinExtension(input: InlineExtension): input is BuiltinExtension {
112 return typeof input !== "function" && input.builtin === true;
113}
114
115/**
116 * Leave out replaceable extensions (see `InlineExtension`) that share a tool, command, or flag name
117 * with another extension. For example, a third-party MCP extension that registers `/mcp` replaces
118 * the built-in MCP extension instead of both connecting the same servers.
119 */
120function 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 extensions
131 .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}
152
153export 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}
166
167function resolvePromptInput(input: string | undefined, description: string): string | undefined {
168 if (!input) {
169 return undefined;
170 }
171
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 }
180
181 return input;
182}
183
184function 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}
204
205/**
206 * The main repo's context file that a nested linked worktree's own copy shadows: both
207 * occupy the same logical repository scope, so loading both applies that context twice. Returns
208 * 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 symlinked
212 * (macOS `/tmp` -> `/private/tmp`).
213 */
214function 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 sibling
221 // 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 is
224 // 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; a
226 // 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}
231
232export 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);
238
239 const contextFiles: Array<{ path: string; content: string }> = [];
240 const seenPaths = new Set<string>();
241
242 const globalContext = loadContextFileFromDir(resolvedAgentDir);
243 if (globalContext) {
244 contextFiles.push(globalContext);
245 seenPaths.add(globalContext.path);
246 }
247
248 const ancestorContextFiles: Array<{ path: string; content: string }> = [];
249
250 const shadowedContextFile = findShadowedContextFile(resolvedCwd);
251 let currentDir = resolvedCwd;
252
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 }
261
262 const parentDir = dirname(currentDir);
263 if (parentDir === currentDir) break;
264 currentDir = parentDir;
265 }
266
267 contextFiles.push(...ancestorContextFiles);
268
269 return contextFiles;
270}
271
272export 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}
308
309export 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[];
346
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;
367
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;
400
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 }
420
421 getExtensions(): LoadExtensionsResult {
422 return this.extensionsResult;
423 }
424
425 getSkills(): { skills: Skill[]; diagnostics: ResourceDiagnostic[] } {
426 return { skills: this.skills, diagnostics: this.skillDiagnostics };
427 }
428
429 getPrompts(): { prompts: PromptTemplate[]; diagnostics: ResourceDiagnostic[] } {
430 return { prompts: this.prompts, diagnostics: this.promptDiagnostics };
431 }
432
433 getThemes(): { themes: Theme[]; diagnostics: ResourceDiagnostic[] } {
434 return { themes: this.themes, diagnostics: this.themeDiagnostics };
435 }
436
437 getAgentsFiles(): { agentsFiles: Array<{ path: string; content: string }> } {
438 return { agentsFiles: this.agentsFiles };
439 }
440
441 getSystemPrompt(): string | undefined {
442 return this.systemPrompt;
443 }
444
445 getSystemPromptSource(): { path: string } | undefined {
446 return this.systemPromptSourcePath ? { path: this.systemPromptSourcePath } : undefined;
447 }
448
449 getAppendSystemPrompt(): string[] {
450 return this.appendSystemPrompt;
451 }
452
453 getAppendSystemPromptSources(): Array<{ path: string }> {
454 return this.appendSystemPromptSourcePaths.map((path) => ({ path }));
455 }
456
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 ?? []);
461
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 }
471
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 }
479
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 }
487
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 }
496
497 async loadProjectTrustExtensions(): Promise<LoadExtensionsResult> {
498 // Force untrusted project settings for the bootstrap pass. This keeps project-local
499 // 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 }
504
505 async reload(options?: ResourceLoaderReloadOptions): Promise<void> {
506 resetTimings("extensions");
507
508 if (this.loaded) {
509 clearExtensionCache();
510 }
511
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 }
518
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;
528
529 this.extensionSkillSourceInfos = new Map();
530 this.extensionPromptSourceInfos = new Map();
531 this.extensionThemeSourceInfos = new Map();
532
533 // Helper to extract enabled paths and store metadata
534 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 };
542
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);
549
550 const enabledSkills = enabledSkillResources.map((resource) => this.mapSkillPath(resource, metadataByPath));
551
552 // Add CLI paths metadata
553 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 }
563
564 const cliEnabledExtensions = getEnabledPaths(cliExtensionPaths.extensions);
565 const cliEnabledSkills = getEnabledPaths(cliExtensionPaths.skills);
566 const cliEnabledPrompts = getEnabledPaths(cliExtensionPaths.prompts);
567 const cliEnabledThemes = getEnabledPaths(cliExtensionPaths.themes);
568
569 const extensionPaths = this.noExtensions
570 ? cliEnabledExtensions
571 : this.mergePaths(cliEnabledExtensions, enabledExtensions);
572
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);
586
587 const skillPaths = this.noSkills
588 ? this.mergePaths(cliEnabledSkills, this.additionalSkillPaths)
589 : this.mergePaths([...cliEnabledSkills, ...enabledSkills], this.additionalSkillPaths);
590
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 }
601
602 const promptPaths = this.noPromptTemplates
603 ? this.mergePaths(cliEnabledPrompts, this.additionalPromptTemplatePaths)
604 : this.mergePaths([...cliEnabledPrompts, ...enabledPrompts], this.additionalPromptTemplatePaths);
605
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 }
620
621 const themePaths = this.noThemes
622 ? this.mergePaths(cliEnabledThemes, this.additionalThemePaths)
623 : this.mergePaths([...cliEnabledThemes, ...enabledThemes], this.additionalThemePaths);
624
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 }
633
634 const agentsFiles = {
635 agentsFiles: this.noContextFiles
636 ? []
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;
644
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;
650
651 let appendSources = this.appendSystemPromptSource;
652 if (!appendSources) {
653 const discoveredAppendSystemPromptFile = this.discoverAppendSystemPromptFile();
654 appendSources = discoveredAppendSystemPromptFile ? [discoveredAppendSystemPromptFile] : [];
655 }
656 const baseAppend = appendSources
657 .map((s) => resolvePromptInput(s, "append system prompt"))
658 .filter((s): s is string => s !== undefined);
659 this.appendSystemPrompt = this.appendSystemPromptOverride
660 ? this.appendSystemPromptOverride(baseAppend)
661 : baseAppend;
662 this.appendSystemPromptSourcePaths = appendSources
663 .filter((source) => existsSync(source))
664 .map((source) => resolvePath(source));
665 this.loaded = true;
666 }
667
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 loaded
676 // 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 }
692
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 }
701
702 private resolveExtensionLoadPath(path: string): string {
703 return isSyntheticPath(path) ? path : resolvePath(path, this.cwd, { normalizeUnicodeSpaces: true });
704 }
705
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 }
738
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 preloaded
747 .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 }
762
763 const inlineExtensions = preTrustExtensions
764 ? { extensions: preloaded.filter((extension) => extension.path.startsWith("<inline:")), errors: [] }
765 : await this.loadExtensionFactories(remainingExtensions.runtime);
766 const orderedExtensions = extensionPaths
767 .map((path) => loadedByPath.get(this.resolveExtensionLoadPath(path)))
768 .filter((extension): extension is Extension => extension !== undefined);
769 orderedExtensions.push(...inlineExtensions.extensions);
770
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 }
782
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 }
791
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 }
813
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.baseDir
819 ? { ...entry.metadata, baseDir: this.resolveResourcePath(entry.metadata.baseDir) }
820 : entry.metadata;
821 return {
822 path: this.resolveResourcePath(entry.path),
823 metadata,
824 };
825 });
826 }
827
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 }
850
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 }
878
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 = sourcePath
897 ? (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 }
905
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 }
919
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 }
928
929 if (isSyntheticPath(resourcePath)) {
930 return this.getDefaultSourceInfoForPath(resourcePath);
931 }
932
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 }
945
946 if (metadataByPath) {
947 const exact = metadataByPath.get(normalizedResourcePath) ?? metadataByPath.get(resourcePath);
948 if (exact) {
949 return createSourceInfo(resourcePath, exact);
950 }
951
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 }
962
963 return undefined;
964 }
965
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 }
976
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 ];
990
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 }
996
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 }
1002
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 }
1011
1012 private mergePaths(primary: string[], additional: string[]): string[] {
1013 const merged: string[] = [];
1014 const seen = new Set<string>();
1015
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 }
1023
1024 return merged;
1025 }
1026
1027 private resolveResourcePath(p: string): string {
1028 return isSyntheticPath(p) ? p : resolvePath(p, this.cwd, { trim: true });
1029 }
1030
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")];
1043
1044 for (const dir of defaultDirs) {
1045 this.loadThemesFromDir(dir, themes, diagnostics, colorMode);
1046 }
1047 }
1048
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 }
1055
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 }
1070
1071 return { themes, diagnostics };
1072 }
1073
1074 private loadThemesFromDir(
1075 dir: string,
1076 themes: Theme[],
1077 diagnostics: ResourceDiagnostic[],
1078 colorMode: TerminalColorMode,
1079 ): void {
1080 if (!existsSync(dir)) {
1081 return;
1082 }
1083
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 }
1108
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 }
1122
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 }> = [];
1129
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 }
1144
1145 return { extensions, errors };
1146 }
1147
1148 private dedupePrompts(prompts: PromptTemplate[]): { prompts: PromptTemplate[]; diagnostics: ResourceDiagnostic[] } {
1149 const seen = new Map<string, PromptTemplate>();
1150 const diagnostics: ResourceDiagnostic[] = [];
1151
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 }
1170
1171 return { prompts: Array.from(seen.values()), diagnostics };
1172 }
1173
1174 private dedupeThemes(themes: Theme[]): { themes: Theme[]; diagnostics: ResourceDiagnostic[] } {
1175 const seen = new Map<string, Theme>();
1176 const diagnostics: ResourceDiagnostic[] = [];
1177
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 }
1197
1198 return { themes: Array.from(seen.values()), diagnostics };
1199 }
1200
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 }
1206
1207 const globalPath = join(this.agentDir, "SYSTEM.md");
1208 if (existsSync(globalPath)) {
1209 return globalPath;
1210 }
1211
1212 return undefined;
1213 }
1214
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 }
1220
1221 const globalPath = join(this.agentDir, "APPEND_SYSTEM.md");
1222 if (existsSync(globalPath)) {
1223 return globalPath;
1224 }
1225
1226 return undefined;
1227 }
1228
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 }
1237
1238 private detectExtensionConflicts(extensions: Extension[]): Array<{ path: string; message: string }> {
1239 const conflicts: Array<{ path: string; message: string }> = [];
1240
1241 // Track which extension registered each tool and flag
1242 const toolOwners = new Map<string, string>();
1243 const flagOwners = new Map<string, string>();
1244
1245 for (const ext of extensions) {
1246 // Check tools
1247 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 }
1258
1259 // Check flags
1260 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 }
1272
1273 return conflicts;
1274 }
1275}