UNPKG

@earendil-works/pi-coding-agent

Version:

Coding agent CLI with read, bash, edit, write tools and session management

1,025 lines • 48 kB
import { existsSync, readdirSync, readFileSync, statSync } from "node:fs"; import { basename, dirname, join, resolve, sep } from "node:path"; import { detectCapabilities, getTerminalColorMode } from "@earendil-works/pi-tui"; import chalk from "chalk"; import { APP_NAME, CONFIG_DIR_NAME } from "../config.js"; import { loadThemeFromPath } from "../modes/interactive/theme/theme.js"; import { canonicalizePath, isLocalPath, isPathWithin, pathsEqual, resolvePath } from "../utils/paths.js"; import { stripBom } from "../utils/text.js"; import { createEventBus } from "./event-bus.js"; import { clearExtensionCache, createExtensionRuntime, loadExtensionFromFactory, loadExtensionsCached, } from "./extensions/loader.js"; import { findGitPaths } from "./footer-data-provider.js"; import { DefaultPackageManager, } from "./package-manager.js"; import { loadPromptTemplates } from "./prompt-templates.js"; import { SettingsManager } from "./settings-manager.js"; import { loadSkills } from "./skills.js"; import { BUILTIN_PATH_PREFIX, createSourceInfo, getResourcePathKey, getSyntheticPathSource, isSyntheticPath, } from "./source-info.js"; import { resetTimings } from "./timings.js"; const HOST_PROVIDED_EXTENSION_PACKAGES = new Set([ "@earendil-works/pi-agent-core", "@earendil-works/pi-ai", "@earendil-works/pi-coding-agent", "@earendil-works/pi-tui", "@mariozechner/pi-agent-core", "@mariozechner/pi-ai", "@mariozechner/pi-coding-agent", "@mariozechner/pi-tui", "@sinclair/typebox", "typebox", ]); function collectExtensionPackageWarnings(extensionPaths, metadataByPath) { const warnings = []; const packageRoots = new Set(extensionPaths .map((extensionPath) => metadataByPath.get(extensionPath)?.packageRoot) .filter((packageRoot) => packageRoot !== undefined)); for (const packageRoot of packageRoots) { const packageJsonPath = join(packageRoot, "package.json"); if (!existsSync(packageJsonPath)) continue; const manifest = JSON.parse(stripBom(readFileSync(packageJsonPath, "utf-8"))); if (typeof manifest.dependencies !== "object" || manifest.dependencies === null || Array.isArray(manifest.dependencies)) { continue; } const hostDependencies = Object.keys(manifest.dependencies) .filter((name) => HOST_PROVIDED_EXTENSION_PACKAGES.has(name)) .sort(); if (hostDependencies.length === 0) continue; warnings.push({ path: packageJsonPath, 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.`, }); } return warnings; } /** Describe a package that was skipped because it could not be installed, including how to drop it. */ function describePackageInstallFailure(failure) { const hint = failure.scope === "temporary" ? "Check the source and try again." : `Run "${APP_NAME} remove ${failure.source}${failure.scope === "project" ? " --local" : ""}" to remove it from settings.json.`; return `failed to install (${failure.message}). ${hint}`; } /** One-line startup warning for a package that could not be installed. */ export function formatPackageInstallWarning(failure) { return `Extension package "${failure.source}": ${describePackageInstallFailure(failure)}`; } function collectPackageInstallWarnings(...failures) { return failures.flatMap((entries) => (entries ?? []).map((failure) => ({ path: failure.source, warning: describePackageInstallFailure(failure) }))); } function mergeExtensionWarnings(result, warnings) { result.warnings = [ ...new Map([...(result.warnings ?? []), ...warnings].map((warning) => [warning.path, warning])).values(), ]; } /** Built-in extensions supply the code of `builtin:<name>` extension paths (see `InlineExtension`). */ export function isBuiltinExtension(input) { return typeof input !== "function" && input.builtin === true; } /** * Leave out replaceable extensions (see `InlineExtension`) that share a tool, command, or flag name * with another extension. For example, a third-party MCP extension that registers `/mcp` replaces * the built-in MCP extension instead of both connecting the same servers. */ function omitReplacedExtensions(extensions, warnings) { const names = (extension) => [ ...[...extension.tools.keys()].map((name) => `tool:${name}`), ...[...extension.commands.keys()].map((name) => `command:${name}`), ...[...extension.flags.keys()].map((name) => `flag:${name}`), ]; const taken = new Map(extensions .filter((extension) => !extension.replaceable) .flatMap((extension) => names(extension).map((name) => [name, extension]))); return extensions.filter((extension) => { if (!extension.replaceable) return true; const replacement = names(extension) .map((name) => ({ name, extension: taken.get(name) })) .find((value) => value.extension !== undefined); if (!replacement) return true; if (extension.path.startsWith(BUILTIN_PATH_PREFIX)) { const builtinName = extension.path.slice(BUILTIN_PATH_PREFIX.length); const [kind, rawName] = replacement.name.split(":", 2); const registeredName = kind === "command" ? `/${rawName}` : kind === "flag" ? `--${rawName}` : rawName; warnings?.push({ path: extension.path, 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.`, }); } return false; }); } function resolvePromptInput(input, description) { if (!input) { return undefined; } if (existsSync(input)) { try { return stripBom(readFileSync(input, "utf-8")); } catch (error) { console.error(chalk.yellow(`Warning: Could not read ${description} file ${input}: ${error}`)); return input; } } return input; } function loadContextFileFromDir(dir) { const candidates = ["AGENTS.override.md", "AGENTS.md", "AGENTS.MD", "CLAUDE.md", "CLAUDE.MD"]; for (const filename of candidates) { const filePath = join(dir, filename); if (existsSync(filePath)) { try { if (!statSync(filePath).isFile()) { continue; } return { path: filePath, content: stripBom(readFileSync(filePath, "utf-8")), }; } catch (error) { console.error(chalk.yellow(`Warning: Could not read ${filePath}: ${error}`)); } } } return null; } /** * The main repo's context file that a nested linked worktree's own copy shadows: both * occupy the same logical repository scope, so loading both applies that context twice. Returns * undefined when nothing is shadowed, leaving normal ancestor inheritance alone. * * Returned canonicalized (realpath), because `git worktree add` writes the `.git` * file's `gitdir:` target in realpath form while cwd may still be symlinked * (macOS `/tmp` -> `/private/tmp`). */ function findShadowedContextFile(cwd) { const gitPaths = findGitPaths(cwd); if (!gitPaths) return undefined; const commonGitDir = canonicalizePath(gitPaths.commonGitDir); const worktreeRoot = canonicalizePath(gitPaths.repoDir); const mainRepoRoot = dirname(commonGitDir); // False for an ordinary repo, where the two are the same dir, and for a sibling // worktree (`git worktree add ../feat`), whose main repo is not an ancestor. if (!isPathWithin(mainRepoRoot, worktreeRoot) || pathsEqual(worktreeRoot, mainRepoRoot)) return undefined; // dirname of the common git dir is the main worktree root only when that dir is // itself checked out from the same repo. In a bare layout (`proj/.bare` + // `proj/main`) it is just the directory holding `.bare`, which tracks nothing; a // submodule's gitdir has no `commondir`, so it lands under `.git/modules`. if (!pathsEqual(canonicalizePath(join(mainRepoRoot, ".git")), commonGitDir)) return undefined; const worktreeContextFile = loadContextFileFromDir(worktreeRoot); return worktreeContextFile ? join(mainRepoRoot, basename(worktreeContextFile.path)) : undefined; } export function loadProjectContextFiles(options) { const resolvedCwd = resolvePath(options.cwd); const resolvedAgentDir = resolvePath(options.agentDir); const contextFiles = []; const seenPaths = new Set(); const globalContext = loadContextFileFromDir(resolvedAgentDir); if (globalContext) { contextFiles.push(globalContext); seenPaths.add(globalContext.path); } const ancestorContextFiles = []; const shadowedContextFile = findShadowedContextFile(resolvedCwd); let currentDir = resolvedCwd; while (true) { const contextFile = loadContextFileFromDir(currentDir); // Compare the directory's real path, not the file's: a worktree AGENTS.md that // symlinks to the main repo's file resolves to the shadowed path itself. const isShadowed = shadowedContextFile !== undefined && contextFile !== null && pathsEqual(join(canonicalizePath(currentDir), basename(contextFile.path)), shadowedContextFile); if (contextFile && !isShadowed && !seenPaths.has(contextFile.path)) { ancestorContextFiles.unshift(contextFile); seenPaths.add(contextFile.path); } const parentDir = dirname(currentDir); if (parentDir === currentDir) break; currentDir = parentDir; } contextFiles.push(...ancestorContextFiles); return contextFiles; } export class DefaultResourceLoader { cwd; agentDir; settingsManager; eventBus; packageManager; additionalExtensionPaths; additionalSkillPaths; additionalPromptTemplatePaths; additionalThemePaths; extensionFactories; builtinExtensions; noExtensions; disabledBuiltinExtensions; noSkills; noPromptTemplates; noThemes; noContextFiles; systemPromptSource; appendSystemPromptSource; extensionsOverride; skillsOverride; promptsOverride; themesOverride; agentsFilesOverride; systemPromptOverride; appendSystemPromptOverride; extensionsResult; skills; skillDiagnostics; prompts; promptDiagnostics; themes; themeDiagnostics; agentsFiles; systemPrompt; systemPromptSourcePath; appendSystemPrompt; appendSystemPromptSourcePaths; lastSkillPaths; extensionSkillSourceInfos; extensionPromptSourceInfos; extensionThemeSourceInfos; resourceMetadataByPath; lastPromptPaths; lastThemePaths; loaded; constructor(options) { this.cwd = resolvePath(options.cwd); this.agentDir = resolvePath(options.agentDir); this.settingsManager = options.settingsManager ?? SettingsManager.create(this.cwd, this.agentDir); this.eventBus = options.eventBus ?? createEventBus(); const factories = options.extensionFactories ?? []; this.extensionFactories = factories.filter((input) => !isBuiltinExtension(input)); this.builtinExtensions = new Map(factories.filter(isBuiltinExtension).map((input) => [input.name, input])); this.packageManager = new DefaultPackageManager({ cwd: this.cwd, agentDir: this.agentDir, settingsManager: this.settingsManager, builtinExtensions: [...this.builtinExtensions.keys()], }); this.additionalExtensionPaths = options.additionalExtensionPaths ?? []; this.additionalSkillPaths = options.additionalSkillPaths ?? []; this.additionalPromptTemplatePaths = options.additionalPromptTemplatePaths ?? []; this.additionalThemePaths = options.additionalThemePaths ?? []; this.noExtensions = options.noExtensions ?? false; this.disabledBuiltinExtensions = new Set(options.disabledBuiltinExtensions); this.noSkills = options.noSkills ?? false; this.noPromptTemplates = options.noPromptTemplates ?? false; this.noThemes = options.noThemes ?? false; this.noContextFiles = options.noContextFiles ?? false; this.systemPromptSource = options.systemPrompt; this.appendSystemPromptSource = options.appendSystemPrompt; this.extensionsOverride = options.extensionsOverride; this.skillsOverride = options.skillsOverride; this.promptsOverride = options.promptsOverride; this.themesOverride = options.themesOverride; this.agentsFilesOverride = options.agentsFilesOverride; this.systemPromptOverride = options.systemPromptOverride; this.appendSystemPromptOverride = options.appendSystemPromptOverride; this.extensionsResult = { extensions: [], errors: [], runtime: createExtensionRuntime() }; this.skills = []; this.skillDiagnostics = []; this.prompts = []; this.promptDiagnostics = []; this.themes = []; this.themeDiagnostics = []; this.agentsFiles = []; this.appendSystemPrompt = []; this.appendSystemPromptSourcePaths = []; this.lastSkillPaths = []; this.extensionSkillSourceInfos = new Map(); this.extensionPromptSourceInfos = new Map(); this.extensionThemeSourceInfos = new Map(); this.resourceMetadataByPath = new Map(); this.lastPromptPaths = []; this.lastThemePaths = []; this.loaded = false; } getExtensions() { return this.extensionsResult; } getSkills() { return { skills: this.skills, diagnostics: this.skillDiagnostics }; } getPrompts() { return { prompts: this.prompts, diagnostics: this.promptDiagnostics }; } getThemes() { return { themes: this.themes, diagnostics: this.themeDiagnostics }; } getAgentsFiles() { return { agentsFiles: this.agentsFiles }; } getSystemPrompt() { return this.systemPrompt; } getSystemPromptSource() { return this.systemPromptSourcePath ? { path: this.systemPromptSourcePath } : undefined; } getAppendSystemPrompt() { return this.appendSystemPrompt; } getAppendSystemPromptSources() { return this.appendSystemPromptSourcePaths.map((path) => ({ path })); } extendResources(paths) { const skillPaths = this.normalizeExtensionPaths(paths.skillPaths ?? []); const promptPaths = this.normalizeExtensionPaths(paths.promptPaths ?? []); const themePaths = this.normalizeExtensionPaths(paths.themePaths ?? []); for (const entry of skillPaths) { this.extensionSkillSourceInfos.set(entry.path, createSourceInfo(entry.path, entry.metadata)); } for (const entry of promptPaths) { this.extensionPromptSourceInfos.set(entry.path, createSourceInfo(entry.path, entry.metadata)); } for (const entry of themePaths) { this.extensionThemeSourceInfos.set(entry.path, createSourceInfo(entry.path, entry.metadata)); } if (skillPaths.length > 0) { this.lastSkillPaths = this.mergePaths(this.lastSkillPaths, skillPaths.map((entry) => entry.path)); this.updateSkillsFromPaths(this.lastSkillPaths, this.resourceMetadataByPath); } if (promptPaths.length > 0) { this.lastPromptPaths = this.mergePaths(this.lastPromptPaths, promptPaths.map((entry) => entry.path)); this.updatePromptsFromPaths(this.lastPromptPaths, this.resourceMetadataByPath); } if (themePaths.length > 0) { this.lastThemePaths = this.mergePaths(this.lastThemePaths, themePaths.map((entry) => entry.path)); this.updateThemesFromPaths(this.lastThemePaths, this.resourceMetadataByPath); } } async loadProjectTrustExtensions() { // Force untrusted project settings for the bootstrap pass. This keeps project-local // extensions/packages out while still loading user/global and temporary CLI extensions. this.settingsManager.setProjectTrusted(false); await this.settingsManager.reload(); return this.loadCurrentExtensionSet({ includeInlineFactories: true }); } async reload(options) { resetTimings("extensions"); if (this.loaded) { clearExtensionCache(); } let preTrustExtensions; if (options?.resolveProjectTrust) { preTrustExtensions = await this.loadProjectTrustExtensions(); const projectTrusted = await options.resolveProjectTrust({ extensionsResult: preTrustExtensions }); this.settingsManager.setProjectTrusted(projectTrusted); } // reload() preserves SettingsManager.projectTrusted and reloads settings for that trust state. await this.settingsManager.reload(); const resolvedPaths = await this.packageManager.resolve(); const cliExtensionPaths = await this.packageManager.resolveExtensionSources(this.additionalExtensionPaths, { temporary: true, }); const installWarnings = collectPackageInstallWarnings(resolvedPaths.installFailures, cliExtensionPaths.installFailures); // Kept on the instance so post-reload passes (extendResources) can still resolve package metadata. this.resourceMetadataByPath = new Map(); const metadataByPath = this.resourceMetadataByPath; this.extensionSkillSourceInfos = new Map(); this.extensionPromptSourceInfos = new Map(); this.extensionThemeSourceInfos = new Map(); // Helper to extract enabled paths and store metadata const getEnabledResources = (resources) => { for (const r of resources) { if (!metadataByPath.has(r.path)) { metadataByPath.set(r.path, r.metadata); } } return resources.filter((r) => r.enabled); }; const getEnabledPaths = (resources) => getEnabledResources(resources).map((r) => r.path); const enabledExtensions = getEnabledPaths(resolvedPaths.extensions); const enabledSkillResources = getEnabledResources(resolvedPaths.skills); const enabledPrompts = getEnabledPaths(resolvedPaths.prompts); const enabledThemes = getEnabledPaths(resolvedPaths.themes); const enabledSkills = enabledSkillResources.map((resource) => this.mapSkillPath(resource, metadataByPath)); // Add CLI paths metadata for (const r of cliExtensionPaths.extensions) { if (!metadataByPath.has(r.path)) { metadataByPath.set(r.path, { source: "cli", scope: "temporary", origin: "top-level" }); } } for (const r of cliExtensionPaths.skills) { if (!metadataByPath.has(r.path)) { metadataByPath.set(r.path, { source: "cli", scope: "temporary", origin: "top-level" }); } } const cliEnabledExtensions = getEnabledPaths(cliExtensionPaths.extensions); const cliEnabledSkills = getEnabledPaths(cliExtensionPaths.skills); const cliEnabledPrompts = getEnabledPaths(cliExtensionPaths.prompts); const cliEnabledThemes = getEnabledPaths(cliExtensionPaths.themes); const extensionPaths = (this.noExtensions ? cliEnabledExtensions : this.mergePaths(cliEnabledExtensions, enabledExtensions)).filter((path) => !path.startsWith(BUILTIN_PATH_PREFIX) || !this.disabledBuiltinExtensions.has(path.slice(BUILTIN_PATH_PREFIX.length))); const packageWarnings = collectExtensionPackageWarnings(extensionPaths, metadataByPath); const extensionsResult = await this.loadFinalExtensionSet(extensionPaths, preTrustExtensions); mergeExtensionWarnings(extensionsResult, [...installWarnings, ...packageWarnings]); for (const p of this.additionalExtensionPaths) { if (isLocalPath(p)) { const resolved = this.resolveResourcePath(p); if (!existsSync(resolved)) { extensionsResult.errors.push({ path: resolved, error: `Extension path does not exist: ${resolved}` }); } } } this.extensionsResult = this.extensionsOverride ? this.extensionsOverride(extensionsResult) : extensionsResult; this.applyExtensionSourceInfo(this.extensionsResult.extensions, metadataByPath); const skillPaths = this.noSkills ? this.mergePaths(cliEnabledSkills, this.additionalSkillPaths) : this.mergePaths([...cliEnabledSkills, ...enabledSkills], this.additionalSkillPaths); this.lastSkillPaths = skillPaths; this.updateSkillsFromPaths(skillPaths, metadataByPath); for (const p of this.additionalSkillPaths) { if (isLocalPath(p)) { const resolved = this.resolveResourcePath(p); if (!existsSync(resolved) && !this.skillDiagnostics.some((d) => d.path === resolved)) { this.skillDiagnostics.push({ type: "error", message: "Skill path does not exist", path: resolved }); } } } const promptPaths = this.noPromptTemplates ? this.mergePaths(cliEnabledPrompts, this.additionalPromptTemplatePaths) : this.mergePaths([...cliEnabledPrompts, ...enabledPrompts], this.additionalPromptTemplatePaths); this.lastPromptPaths = promptPaths; this.updatePromptsFromPaths(promptPaths, metadataByPath); for (const p of this.additionalPromptTemplatePaths) { if (isLocalPath(p)) { const resolved = this.resolveResourcePath(p); if (!existsSync(resolved) && !this.promptDiagnostics.some((d) => d.path === resolved)) { this.promptDiagnostics.push({ type: "error", message: "Prompt template path does not exist", path: resolved, }); } } } const themePaths = this.noThemes ? this.mergePaths(cliEnabledThemes, this.additionalThemePaths) : this.mergePaths([...cliEnabledThemes, ...enabledThemes], this.additionalThemePaths); this.lastThemePaths = themePaths; this.updateThemesFromPaths(themePaths, metadataByPath); for (const p of this.additionalThemePaths) { const resolved = this.resolveResourcePath(p); if (!existsSync(resolved) && !this.themeDiagnostics.some((d) => d.path === resolved)) { this.themeDiagnostics.push({ type: "error", message: "Theme path does not exist", path: resolved }); } } const agentsFiles = { agentsFiles: this.noContextFiles ? [] : loadProjectContextFiles({ cwd: this.cwd, agentDir: this.agentDir, }), }; const resolvedAgentsFiles = this.agentsFilesOverride ? this.agentsFilesOverride(agentsFiles) : agentsFiles; this.agentsFiles = resolvedAgentsFiles.agentsFiles; const systemPromptSource = this.systemPromptSource ?? this.discoverSystemPromptFile(); const baseSystemPrompt = resolvePromptInput(systemPromptSource, "system prompt"); this.systemPrompt = this.systemPromptOverride ? this.systemPromptOverride(baseSystemPrompt) : baseSystemPrompt; this.systemPromptSourcePath = systemPromptSource && existsSync(systemPromptSource) ? resolvePath(systemPromptSource) : undefined; let appendSources = this.appendSystemPromptSource; if (!appendSources) { const discoveredAppendSystemPromptFile = this.discoverAppendSystemPromptFile(); appendSources = discoveredAppendSystemPromptFile ? [discoveredAppendSystemPromptFile] : []; } const baseAppend = appendSources .map((s) => resolvePromptInput(s, "append system prompt")) .filter((s) => s !== undefined); this.appendSystemPrompt = this.appendSystemPromptOverride ? this.appendSystemPromptOverride(baseAppend) : baseAppend; this.appendSystemPromptSourcePaths = appendSources .filter((source) => existsSync(source)) .map((source) => resolvePath(source)); this.loaded = true; } async loadCurrentExtensionSet(options) { const resolvedPaths = await this.packageManager.resolve(); const cliExtensionPaths = await this.packageManager.resolveExtensionSources(this.additionalExtensionPaths, { temporary: true, }); const enabledExtensions = resolvedPaths.extensions.filter((r) => r.enabled).map((r) => r.path); const cliEnabledExtensions = cliExtensionPaths.extensions.filter((r) => r.enabled).map((r) => r.path); // Built-in extensions wait for the final pass: project settings can disable them, and a loaded // extension cannot be unloaded. const extensionPaths = (this.noExtensions ? cliEnabledExtensions : this.mergePaths(cliEnabledExtensions, enabledExtensions)).filter((path) => !path.startsWith(BUILTIN_PATH_PREFIX)); const metadataByPath = new Map([...resolvedPaths.extensions, ...cliExtensionPaths.extensions].map((resource) => [ resource.path, resource.metadata, ])); const packageWarnings = collectExtensionPackageWarnings(extensionPaths, metadataByPath); const installWarnings = collectPackageInstallWarnings(resolvedPaths.installFailures, cliExtensionPaths.installFailures); const extensionsResult = await loadExtensionsCached(extensionPaths, this.cwd, this.eventBus); mergeExtensionWarnings(extensionsResult, [...installWarnings, ...packageWarnings]); if (!options.includeInlineFactories) { return extensionsResult; } const inlineExtensions = await this.loadExtensionFactories(extensionsResult.runtime); extensionsResult.extensions.push(...inlineExtensions.extensions); extensionsResult.errors.push(...inlineExtensions.errors); const replacementWarnings = []; extensionsResult.extensions = omitReplacedExtensions(extensionsResult.extensions, replacementWarnings); mergeExtensionWarnings(extensionsResult, replacementWarnings); return extensionsResult; } resolveExtensionLoadPath(path) { return isSyntheticPath(path) ? path : resolvePath(path, this.cwd, { normalizeUnicodeSpaces: true }); } getExtensionPathKey(path) { return getResourcePathKey(this.resolveExtensionLoadPath(path)); } /** Load extension paths: files from disk and `builtin:<name>` paths from the built-in extensions. */ async loadExtensionPaths(paths, runtime) { const isBuiltinPath = (path) => path.startsWith(BUILTIN_PATH_PREFIX); const result = await loadExtensionsCached(paths.filter((path) => !isBuiltinPath(path)), this.cwd, this.eventBus, runtime); for (const path of paths.filter(isBuiltinPath)) { const builtin = this.builtinExtensions.get(path.slice(BUILTIN_PATH_PREFIX.length)); if (!builtin) { result.errors.push({ path, error: `Unknown built-in extension: ${path}` }); continue; } try { const extension = await loadExtensionFromFactory(builtin.factory, this.cwd, this.eventBus, result.runtime, path); extension.hidden = true; extension.replaceable = builtin.replaceable === true; result.extensions.push(extension); } catch (error) { result.errors.push({ path, error: error instanceof Error ? error.message : "failed to load extension" }); } } return result; } async loadFinalExtensionSet(extensionPaths, preTrustExtensions) { // Without a pre-trust pass nothing is preloaded, and inline extensions load here. const preloaded = preTrustExtensions?.extensions ?? []; const preloadedByPath = new Map(preloaded .filter((extension) => !extension.path.startsWith("<inline:")) .map((extension) => [getResourcePathKey(extension.resolvedPath), extension])); const failedPreloadPaths = new Set((preTrustExtensions?.errors ?? []).map((error) => this.getExtensionPathKey(error.path))); const remainingPaths = extensionPaths.filter((path) => { const pathKey = this.getExtensionPathKey(path); return !preloadedByPath.has(pathKey) && !failedPreloadPaths.has(pathKey); }); const remainingExtensions = await this.loadExtensionPaths(remainingPaths, preTrustExtensions?.runtime); const loadedByPath = new Map(preloadedByPath); for (const extension of remainingExtensions.extensions) { loadedByPath.set(getResourcePathKey(extension.resolvedPath), extension); } const inlineExtensions = preTrustExtensions ? { extensions: preloaded.filter((extension) => extension.path.startsWith("<inline:")), errors: [] } : await this.loadExtensionFactories(remainingExtensions.runtime); const orderedExtensions = extensionPaths .map((path) => loadedByPath.get(this.getExtensionPathKey(path))) .filter((extension) => extension !== undefined); orderedExtensions.push(...inlineExtensions.extensions); const replacementWarnings = []; const extensionsResult = { extensions: omitReplacedExtensions(orderedExtensions, replacementWarnings), errors: [...(preTrustExtensions?.errors ?? []), ...remainingExtensions.errors, ...inlineExtensions.errors], warnings: [...(preTrustExtensions?.warnings ?? []), ...(remainingExtensions.warnings ?? [])], runtime: remainingExtensions.runtime, }; mergeExtensionWarnings(extensionsResult, replacementWarnings); this.addExtensionConflictDiagnostics(extensionsResult); return extensionsResult; } addExtensionConflictDiagnostics(extensionsResult) { // Detect extension conflicts (tools, commands, flags with same names from different extensions) // Keep all extensions loaded. Conflicts are reported as diagnostics, and precedence is handled by load order. const conflicts = this.detectExtensionConflicts(extensionsResult.extensions); for (const conflict of conflicts) { extensionsResult.errors.push({ path: conflict.path, error: conflict.message }); } } mapSkillPath(resource, metadataByPath) { if (resource.metadata.source !== "auto" && resource.metadata.origin !== "package") { return resource.path; } try { const stats = statSync(resource.path); if (!stats.isDirectory()) { return resource.path; } } catch { return resource.path; } const skillFile = join(resource.path, "SKILL.md"); if (existsSync(skillFile)) { if (!metadataByPath.has(skillFile)) { metadataByPath.set(skillFile, resource.metadata); } return skillFile; } return resource.path; } normalizeExtensionPaths(entries) { return entries.map((entry) => { const metadata = entry.metadata.baseDir ? { ...entry.metadata, baseDir: this.resolveResourcePath(entry.metadata.baseDir) } : entry.metadata; return { path: this.resolveResourcePath(entry.path), metadata, }; }); } updateSkillsFromPaths(skillPaths, metadataByPath) { let skillsResult; if (this.noSkills && skillPaths.length === 0) { skillsResult = { skills: [], diagnostics: [] }; } else { skillsResult = loadSkills({ cwd: this.cwd, agentDir: this.agentDir, skillPaths, includeDefaults: false, }); } const resolvedSkills = this.skillsOverride ? this.skillsOverride(skillsResult) : skillsResult; this.skills = resolvedSkills.skills.map((skill) => ({ ...skill, sourceInfo: this.findSourceInfoForPath(skill.filePath, this.extensionSkillSourceInfos, metadataByPath) ?? skill.sourceInfo ?? this.getDefaultSourceInfoForPath(skill.filePath), })); this.skillDiagnostics = resolvedSkills.diagnostics; } updatePromptsFromPaths(promptPaths, metadataByPath) { let promptsResult; if (this.noPromptTemplates && promptPaths.length === 0) { promptsResult = { prompts: [], diagnostics: [] }; } else { const loaded = loadPromptTemplates({ cwd: this.cwd, agentDir: this.agentDir, promptPaths, includeDefaults: false, }); const deduped = this.dedupePrompts(loaded.templates); promptsResult = { prompts: deduped.prompts, diagnostics: [...loaded.diagnostics, ...deduped.diagnostics], }; } const resolvedPrompts = this.promptsOverride ? this.promptsOverride(promptsResult) : promptsResult; this.prompts = resolvedPrompts.prompts.map((prompt) => ({ ...prompt, sourceInfo: this.findSourceInfoForPath(prompt.filePath, this.extensionPromptSourceInfos, metadataByPath) ?? prompt.sourceInfo ?? this.getDefaultSourceInfoForPath(prompt.filePath), })); this.promptDiagnostics = resolvedPrompts.diagnostics; } updateThemesFromPaths(themePaths, metadataByPath) { let themesResult; if (this.noThemes && themePaths.length === 0) { themesResult = { themes: [], diagnostics: [] }; } else { // Theme construction only needs trueColor, so skip the unrelated tmux hyperlink probe. const colorMode = getTerminalColorMode({ ...detectCapabilities(() => false), ...this.settingsManager.getTerminalCapabilityOverrides(), }); const loaded = this.loadThemes(themePaths, false, colorMode); const deduped = this.dedupeThemes(loaded.themes); themesResult = { themes: deduped.themes, diagnostics: [...loaded.diagnostics, ...deduped.diagnostics] }; } const resolvedThemes = this.themesOverride ? this.themesOverride(themesResult) : themesResult; this.themes = resolvedThemes.themes.map((theme) => { const sourcePath = theme.sourcePath; theme.sourceInfo = sourcePath ? (this.findSourceInfoForPath(sourcePath, this.extensionThemeSourceInfos, metadataByPath) ?? theme.sourceInfo ?? this.getDefaultSourceInfoForPath(sourcePath)) : theme.sourceInfo; return theme; }); this.themeDiagnostics = resolvedThemes.diagnostics; } applyExtensionSourceInfo(extensions, metadataByPath) { for (const extension of extensions) { extension.sourceInfo = this.findSourceInfoForPath(extension.path, undefined, metadataByPath) ?? this.getDefaultSourceInfoForPath(extension.path); for (const command of extension.commands.values()) { command.sourceInfo = extension.sourceInfo; } for (const tool of extension.tools.values()) { tool.sourceInfo = extension.sourceInfo; } } } findSourceInfoForPath(resourcePath, extraSourceInfos, metadataByPath) { if (!resourcePath) { return undefined; } if (isSyntheticPath(resourcePath)) { return this.getDefaultSourceInfoForPath(resourcePath); } const normalizedResourcePath = resolve(resourcePath); if (extraSourceInfos) { for (const [sourcePath, sourceInfo] of extraSourceInfos.entries()) { if (isPathWithin(sourcePath, normalizedResourcePath)) { return { ...sourceInfo, path: resourcePath }; } } } if (metadataByPath) { const exact = metadataByPath.get(normalizedResourcePath) ?? metadataByPath.get(resourcePath); if (exact) { return createSourceInfo(resourcePath, exact); } for (const [sourcePath, metadata] of metadataByPath.entries()) { if (isPathWithin(sourcePath, normalizedResourcePath)) { return createSourceInfo(resourcePath, metadata); } } } return undefined; } getDefaultSourceInfoForPath(filePath) { const syntheticSource = getSyntheticPathSource(filePath); if (syntheticSource) { return { path: filePath, source: syntheticSource, scope: "temporary", origin: "top-level", }; } const normalizedPath = resolve(filePath); const agentRoots = [ join(this.agentDir, "skills"), join(this.agentDir, "prompts"), join(this.agentDir, "themes"), join(this.agentDir, "extensions"), ]; const projectRoots = [ join(this.cwd, CONFIG_DIR_NAME, "skills"), join(this.cwd, CONFIG_DIR_NAME, "prompts"), join(this.cwd, CONFIG_DIR_NAME, "themes"), join(this.cwd, CONFIG_DIR_NAME, "extensions"), ]; for (const root of agentRoots) { if (this.isUnderPath(normalizedPath, root)) { return { path: filePath, source: "local", scope: "user", origin: "top-level", baseDir: root }; } } for (const root of projectRoots) { if (this.isUnderPath(normalizedPath, root)) { return { path: filePath, source: "local", scope: "project", origin: "top-level", baseDir: root }; } } return { path: filePath, source: "local", scope: "temporary", origin: "top-level", baseDir: statSync(normalizedPath).isDirectory() ? normalizedPath : resolve(normalizedPath, ".."), }; } mergePaths(primary, additional) { const merged = []; const seen = new Set(); for (const p of [...primary, ...additional]) { const resolved = this.resolveResourcePath(p); const pathKey = getResourcePathKey(resolved); if (seen.has(pathKey)) continue; seen.add(pathKey); merged.push(resolved); } return merged; } resolveResourcePath(p) { return isSyntheticPath(p) ? p : resolvePath(p, this.cwd, { trim: true }); } loadThemes(paths, includeDefaults, colorMode) { const themes = []; const diagnostics = []; if (includeDefaults) { const defaultDirs = [join(this.agentDir, "themes"), join(this.cwd, CONFIG_DIR_NAME, "themes")]; for (const dir of defaultDirs) { this.loadThemesFromDir(dir, themes, diagnostics, colorMode); } } for (const p of paths) { const resolved = this.resolveResourcePath(p); if (!existsSync(resolved)) { diagnostics.push({ type: "warning", message: "theme path does not exist", path: resolved }); continue; } try { const stats = statSync(resolved); if (stats.isDirectory()) { this.loadThemesFromDir(resolved, themes, diagnostics, colorMode); } else if (stats.isFile() && resolved.endsWith(".json")) { this.loadThemeFromFile(resolved, themes, diagnostics, colorMode); } else { diagnostics.push({ type: "warning", message: "theme path is not a json file", path: resolved }); } } catch (error) { const message = error instanceof Error ? error.message : "failed to read theme path"; diagnostics.push({ type: "warning", message, path: resolved }); } } return { themes, diagnostics }; } loadThemesFromDir(dir, themes, diagnostics, colorMode) { if (!existsSync(dir)) { return; } try { const entries = readdirSync(dir, { withFileTypes: true }); for (const entry of entries) { let isFile = entry.isFile(); if (entry.isSymbolicLink()) { try { isFile = statSync(join(dir, entry.name)).isFile(); } catch { continue; } } if (!isFile) { continue; } if (!entry.name.endsWith(".json")) { continue; } this.loadThemeFromFile(join(dir, entry.name), themes, diagnostics, colorMode); } } catch (error) { const message = error instanceof Error ? error.message : "failed to read theme directory"; diagnostics.push({ type: "warning", message, path: dir }); } } loadThemeFromFile(filePath, themes, diagnostics, colorMode) { try { themes.push(loadThemeFromPath(filePath, colorMode)); } catch (error) { const message = error instanceof Error ? error.message : "failed to load theme"; diagnostics.push({ type: "warning", message, path: filePath }); } } async loadExtensionFactories(runtime) { const extensions = []; const errors = []; for (const [index, input] of this.extensionFactories.entries()) { const isNamed = typeof input !== "function"; const factory = isNamed ? input.factory : input; const extensionPath = `<inline:${isNamed ? input.name : index + 1}>`; try { const extension = await loadExtensionFromFactory(factory, this.cwd, this.eventBus, runtime, extensionPath); extension.hidden = isNamed && input.hidden; extension.replaceable = isNamed && input.replaceable === true; extensions.push(extension); } catch (error) { const message = error instanceof Error ? error.message : "failed to load extension"; errors.push({ path: extensionPath, error: message }); } } return { extensions, errors }; } dedupePrompts(prompts) { const seen = new Map(); const diagnostics = []; for (const prompt of prompts) { const existing = seen.get(prompt.name); if (existing) { diagnostics.push({ type: "collision", message: `name "/${prompt.name}" collision`, path: prompt.filePath, collision: { resourceType: "prompt", name: prompt.name, winnerPath: existing.filePath, loserPath: prompt.filePath, }, }); } else { seen.set(prompt.name, prompt); } } return { prompts: Array.from(seen.values()), diagnostics }; } dedupeThemes(themes) { const seen = new Map(); const diagnostics = []; for (const t of themes) { const name = t.name ?? "unnamed"; const existing = seen.get(name); if (existing) { diagnostics.push({ type: "collision", message: `name "${name}" collision`, path: t.sourcePath, collision: { resourceType: "theme", name, winnerPath: existing.sourcePath ?? "<builtin>", loserPath: t.sourcePath ?? "<builtin>", }, }); } else { seen.set(name, t); } } return { themes: Array.from(seen.values()), diagnostics }; } discoverSystemPromptFile() { const projectPath = join(this.cwd, CONFIG_DIR_NAME, "SYSTEM.md"); if (this.settingsManager.isProjectTrusted() && existsSync(projectPath)) { return projectPath; } const globalPath = join(this.agentDir, "SYSTEM.md"); if (existsSync(globalPath)) { return globalPath; } return undefined; } discoverAppendSystemPromptFile() { const projectPath = join(this.cwd, CONFIG_DIR_NAME, "APPEND_SYSTEM.md"); if (this.settingsManager.isProjectTrusted() && existsSync(projectPath)) { return projectPath; } const globalPath = join(this.agentDir, "APPEND_SYSTEM.md"); if (existsSync(globalPath)) { return globalPath; } return undefined; } isUnderPath(target, root) { const normalizedRoot = resolve(root); if (target === normalizedRoot) { return true; } const prefix = normalizedRoot.endsWith(sep) ? normalizedRoot : `${normalizedRoot}${sep}`; return target.startsWith(prefix); } detectExtensionConflicts(extensions) { const conflicts = []; // Track which extension registered each tool and flag const toolOwners = new Map(); const flagOwners = new Map(); for (const ext of extensions) { // Check tools for (const toolName of ext.tools.keys()) { const existingOwner = toolOwners.get(toolName); if (existingOwner && existingOwner !== ext.path) { conflicts.push({ path: ext.path, message: `Tool "${toolName}" conflicts with ${existingOwner}`, }); } else { toolOwners.set(toolName, ext.path); } } // Check flags for (const flagName of ext.flags.keys()) { const existingOwner = flagOwners.get(flagName); if (existingOwner && existingOwner !== ext.path) { conflicts.push({ path: ext.path, message: `Flag "--${flagName}" conflicts with ${existingOwner}`, }); } else { flagOwners.set(flagName, ext.path); } } } return conflicts; } } //# sourceMappingURL=resource-loader.js.map