diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index ce79dbf2b..b17094815 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -12,11 +12,14 @@ - Added the `provider_stream_event` extension event for observing parsed provider events before normalization, with an opt-in `/debug-provider` example viewer ([#9784](https://github.com/earendil-works/pi/issues/9784)). - Added a show/hide toggle (`H`) in HTML exports for custom messages marked `display: false`. Messages remain hidden by default and can also be revealed from the sidebar ([#8896](https://github.com/earendil-works/pi/issues/8896)). - Added inherited Claude Sonnet 5.5 support for Anthropic with adaptive thinking and a 1M context window. +- Added a Built-in section in `pi config` to disable the built-in `mcp`, `llama.cpp`, `codemode`, and `tool-search` extensions globally or per project, stored as `-builtin:` in the `extensions` setting. SDK inline extensions opt in with `builtin: true`. ### Changed - Switched the build from the TypeScript native preview to TypeScript 7.0 with an ES2024 target, and replaced `tsx` with Node's built-in type stripping for running from source ([#9965](https://github.com/earendil-works/pi/issues/9965)). - Removed the `[Themes]` section from the startup banner. Custom themes remain available in `/settings`, and theme conflicts are still reported. +- Built-in extensions and tools are named `builtin:` (for example `builtin:mcp` and `builtin:read`) in errors, diagnostics, RPC source info, and bug reports, instead of `` and ``. Their slash commands no longer carry a `[t]` autocomplete tag. +- `--no-extensions` also disables the built-in extensions, including the llama.cpp provider. Load one explicitly with `-e builtin:`, for example `pi -ne -e builtin:mcp`. ### Fixed diff --git a/packages/coding-agent/docs/cli.md b/packages/coding-agent/docs/cli.md index b45ae962a..cd419aaa0 100644 --- a/packages/coding-agent/docs/cli.md +++ b/packages/coding-agent/docs/cli.md @@ -170,9 +170,9 @@ pi --extension ./review.ts See [Configuration](configuration.md) for conventional directories and project trust, [Settings](settings.md#resources) for configured paths, and [Pi Packages](packages.md) for package sources. - `-e`, `--extension `
- Loads an extension file or directory and is repeatable. + Loads an extension file or directory, or a built-in extension such as `builtin:mcp`, and is repeatable. - `-ne`, `--no-extensions`
- Disables discovered and configured extensions. Explicit `-e` paths still load. + Disables discovered, configured, and built-in extensions. Explicit `-e` paths still load, so `pi -ne -e builtin:mcp` keeps only the built-in MCP support. - `--skill `
Loads a skill file or directory and is repeatable. - `-ns`, `--no-skills`
diff --git a/packages/coding-agent/docs/llama-cpp.md b/packages/coding-agent/docs/llama-cpp.md index 307032dbf..4d58ea53d 100644 --- a/packages/coding-agent/docs/llama-cpp.md +++ b/packages/coding-agent/docs/llama-cpp.md @@ -110,3 +110,5 @@ curl http://127.0.0.1:8080/models - **Model missing from `/model` with `--no-models-autoload`:** Load it with `/llama` first. - **Load fails or uses too much memory:** Lower `-c` or unload another model. - **Server is not in router mode:** Start it without `--model`, `-m`, or `-hf`. + +To remove the `llama.cpp` provider and `/llama`, disable `llama.cpp` under Built-in in `pi config`, or set `"extensions": ["-builtin:llama.cpp"]` in [settings](settings.md#resources). diff --git a/packages/coding-agent/docs/mcp.md b/packages/coding-agent/docs/mcp.md index 72df5d2e7..a95ed4d47 100644 --- a/packages/coding-agent/docs/mcp.md +++ b/packages/coding-agent/docs/mcp.md @@ -181,7 +181,7 @@ Extensions can add servers for the current session with `pi.registerMcpServer(na ## Other MCP extensions -An installed extension that registers the `/mcp` command, such as `pi-mcp-adapter`, replaces the built-in MCP support: pi then neither reads `mcp.json` in sessions nor connects servers, and `/mcp` belongs to that extension. Remove the extension to use the built-in support. Likewise, an extension that registers a tool named `codemode` or `tool_search` replaces the built-in tool of that name. `pi mcp` shell commands always use the built-in support. +An installed extension that registers the `/mcp` command, such as `pi-mcp-adapter`, replaces the built-in MCP support: pi then neither reads `mcp.json` in sessions nor connects servers, and `/mcp` belongs to that extension. Remove the extension to use the built-in support. To turn off the built-in support without installing another extension, disable `mcp` under Built-in in `pi config`, or set `"extensions": ["-builtin:mcp"]` in [settings](settings.md#resources); `pi mcp` shell commands still work. Likewise, an extension that registers a tool named `codemode` or `tool_search` replaces the built-in tool of that name. `pi mcp` shell commands always use the built-in support. ## SDK diff --git a/packages/coding-agent/docs/packages.md b/packages/coding-agent/docs/packages.md index 0d5de1cb6..284dd8cb4 100644 --- a/packages/coding-agent/docs/packages.md +++ b/packages/coding-agent/docs/packages.md @@ -118,7 +118,7 @@ For each resource type: Filters narrow the package manifest. They do not expose resources that the package itself did not declare. -Run `pi config` to enable or disable discovered resources. It starts with personal configuration; press Tab to switch scope, or run `pi config --local` to start with project overrides. +Run `pi config` to enable or disable discovered resources and pi's built-in extensions. It starts with personal configuration; press Tab to switch scope, or run `pi config --local` to start with project overrides. ## Understand scope and identity diff --git a/packages/coding-agent/docs/sdk.md b/packages/coding-agent/docs/sdk.md index a8c545ae8..5d8794398 100644 --- a/packages/coding-agent/docs/sdk.md +++ b/packages/coding-agent/docs/sdk.md @@ -109,7 +109,7 @@ Use `DefaultResourceLoader` when you want standard discovery with selected overr -Inline extension factories can be supplied through `DefaultResourceLoader`. Give one an `InlineExtension` name only when it needs a stable name in diagnostics and startup output. A named inline extension with `replaceable: true` is left out when another extension registers a tool, command, or flag with a name it registers during loading, instead of both loading with a conflict. The CLI's built-in codemode, tool search, and MCP extensions are replaceable. +Inline extension factories can be supplied through `DefaultResourceLoader`. Give one an `InlineExtension` name only when it needs a stable name in diagnostics and startup output. A named inline extension with `replaceable: true` is left out when another extension registers a tool, command, or flag with a name it registers during loading, instead of both loading with a conflict. The CLI's built-in codemode, tool search, and MCP extensions are replaceable. A named entry with `builtin: true` is not an inline extension: it supplies the code of the `builtin:` extension, which loads like a configured extension file. It loads by default, is listed in `pi config`, and is disabled by `-builtin:` in the `extensions` setting or by `noExtensions`; `additionalExtensionPaths: ["builtin:"]` loads it explicitly. It loads after project trust is resolved, so it cannot handle `project_trust`. The CLI's built-in extensions use it. diff --git a/packages/coding-agent/docs/settings.md b/packages/coding-agent/docs/settings.md index d861ba306..7001969e0 100644 --- a/packages/coding-agent/docs/settings.md +++ b/packages/coding-agent/docs/settings.md @@ -143,6 +143,8 @@ Resource paths in user settings resolve from the agent directory. Paths in proje Resource arrays support glob exclusions with `!pattern`, exact inclusion with `+path`, and exact exclusion with `-path`. Pi loads resources listed in both user-level and project settings. +The built-in extensions are named `builtin:mcp`, `builtin:llama.cpp`, `builtin:codemode`, and `builtin:tool-search` in `extensions`. They load by default; `-builtin:mcp` disables one. A `+builtin:` or `-builtin:` entry in project settings overrides the user setting. `pi config` lists them under Built-in. `--no-extensions` disables them too, and `-e builtin:` loads one explicitly. + ## Updates, telemetry, and warnings | Setting | Type | Default | Description | diff --git a/packages/coding-agent/src/cli/args.ts b/packages/coding-agent/src/cli/args.ts index bc7bb29fc..17f27513f 100644 --- a/packages/coding-agent/src/cli/args.ts +++ b/packages/coding-agent/src/cli/args.ts @@ -310,8 +310,8 @@ ${chalk.bold("Options:")} --exclude-tools, -xt Comma-separated denylist of tool names to disable Applies to built-in, extension, and custom tools --thinking Set thinking level: off, minimal, low, medium, high, xhigh, max - --extension, -e Load an extension file (can be used multiple times) - --no-extensions, -ne Disable extension discovery (explicit -e paths still work) + --extension, -e Load an extension file or builtin: (can be used multiple times) + --no-extensions, -ne Disable extension discovery and built-in extensions (explicit -e paths still work) --skill Load a skill file or directory (can be used multiple times) --no-skills, -ns Disable skills discovery and loading --prompt-template Load a prompt template file or directory (can be used multiple times) diff --git a/packages/coding-agent/src/core/agent-session.ts b/packages/coding-agent/src/core/agent-session.ts index cd60b024e..1ab92f177 100644 --- a/packages/coding-agent/src/core/agent-session.ts +++ b/packages/coding-agent/src/core/agent-session.ts @@ -131,7 +131,7 @@ import { } from "./session-manager.ts"; import type { CacheWarmingMode, SettingsManager } from "./settings-manager.ts"; import type { SlashCommandInfo } from "./slash-commands.ts"; -import { createSyntheticSourceInfo, type SourceInfo } from "./source-info.ts"; +import { BUILTIN_PATH_PREFIX, createSyntheticSourceInfo, isSyntheticPath, type SourceInfo } from "./source-info.ts"; import { buildSystemPrompt, buildSystemPromptSections, @@ -3222,7 +3222,7 @@ export class AgentSession { }> { return entries.map((entry) => { const source = this.getExtensionSourceLabel(entry.extensionPath); - const baseDir = entry.extensionPath.startsWith("<") ? undefined : dirname(entry.extensionPath); + const baseDir = isSyntheticPath(entry.extensionPath) ? undefined : dirname(entry.extensionPath); return { path: entry.path, metadata: { @@ -3236,7 +3236,7 @@ export class AgentSession { } private getExtensionSourceLabel(extensionPath: string): string { - if (extensionPath.startsWith("<")) { + if (isSyntheticPath(extensionPath)) { return `extension:${extensionPath.replace(/[<>]/g, "")}`; } const base = basename(extensionPath); @@ -3430,7 +3430,7 @@ export class AgentSession { name, { definition, - sourceInfo: createSyntheticSourceInfo(``, { source: "builtin" }), + sourceInfo: createSyntheticSourceInfo(`${BUILTIN_PATH_PREFIX}${name}`, { source: "builtin" }), }, ]), ); @@ -3464,7 +3464,9 @@ export class AgentSession { .filter((definition) => isAllowedTool(definition.name)) .map((definition) => ({ definition, - sourceInfo: createSyntheticSourceInfo(``, { source: "builtin" }), + sourceInfo: createSyntheticSourceInfo(`${BUILTIN_PATH_PREFIX}${definition.name}`, { + source: "builtin", + }), })), runner, ); diff --git a/packages/coding-agent/src/core/crash-log.ts b/packages/coding-agent/src/core/crash-log.ts index ef62cc046..645614992 100644 --- a/packages/coding-agent/src/core/crash-log.ts +++ b/packages/coding-agent/src/core/crash-log.ts @@ -2,6 +2,7 @@ import { mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { dirname, join } from "node:path"; import { getAgentDir, VERSION } from "../config.ts"; import type { Extension } from "./extensions/types.ts"; +import { isSyntheticPath } from "./source-info.ts"; export interface CrashRecord { timestamp: string; @@ -51,7 +52,7 @@ function normalizeStackPath(value: string): string { function stackContainsPath(stack: string, targetPath: string, includeDescendants: boolean): boolean { const target = normalizeStackPath(targetPath); - if (!target || target.startsWith("<")) return false; + if (!target || isSyntheticPath(target)) return false; const caseInsensitive = /^[a-z]:\//iu.test(target); const haystack = caseInsensitive ? stack.toLowerCase() : stack; const needle = caseInsensitive ? target.toLowerCase() : target; diff --git a/packages/coding-agent/src/core/extensions/loader.ts b/packages/coding-agent/src/core/extensions/loader.ts index 55afbe1f8..d2367f780 100644 --- a/packages/coding-agent/src/core/extensions/loader.ts +++ b/packages/coding-agent/src/core/extensions/loader.ts @@ -17,7 +17,7 @@ import type { ExecOptions } from "../exec.ts"; import { execCommand } from "../exec.ts"; import { type McpServerConfig, McpServerRegistry, validateMcpServerConfig } from "../mcp-servers.ts"; import { readPiManifest } from "../pi-manifest.ts"; -import { createSyntheticSourceInfo } from "../source-info.ts"; +import { createSyntheticSourceInfo, getSyntheticPathSource, isSyntheticPath } from "../source-info.ts"; import { time } from "../timings.ts"; import type { ModelRouteRequest, VirtualModelDefinition } from "../virtual-models.ts"; import type { @@ -573,11 +573,8 @@ async function loadExtensionModule(extensionPath: string, cacheToken?: Extension * Create an Extension object with empty collections. */ function createExtension(extensionPath: string, resolvedPath: string): Extension { - const source = - extensionPath.startsWith("<") && extensionPath.endsWith(">") - ? extensionPath.slice(1, -1).split(":")[0] || "temporary" - : "local"; - const baseDir = extensionPath.startsWith("<") ? undefined : path.dirname(resolvedPath); + const source = getSyntheticPathSource(extensionPath) ?? "local"; + const baseDir = isSyntheticPath(extensionPath) ? undefined : path.dirname(resolvedPath); return { path: extensionPath, diff --git a/packages/coding-agent/src/core/extensions/types.ts b/packages/coding-agent/src/core/extensions/types.ts index ee363fb01..ddbaa8464 100644 --- a/packages/coding-agent/src/core/extensions/types.ts +++ b/packages/coding-agent/src/core/extensions/types.ts @@ -1987,7 +1987,10 @@ export type ExtensionFactory = (pi: ExtensionAPI) => void | Promise; export type InlineExtension = | ExtensionFactory | { - /** Display name shown as `` in the startup Extensions list. */ + /** + * Display name shown as `` in the startup Extensions list and errors. With + * `builtin`, the extension is named `builtin:name` in errors and diagnostics. + */ name: string; factory: ExtensionFactory; /** Omit this extension from the startup Extensions list. */ @@ -2000,6 +2003,15 @@ export type InlineExtension = * tools, commands, flags, and event handlers. */ replaceable?: boolean; + /** + * Supply the code of the `builtin:` extension instead of loading as an inline extension. + * `builtin:` is an extension resource like a file: it loads by default, `pi config` lists + * it, `-builtin:` in the `extensions` setting and `--no-extensions` disable it, and + * `-e builtin:` loads it explicitly. It is hidden from the startup Extensions list and + * loads after project trust is resolved, so it cannot handle `project_trust`. The CLI's built-in + * extensions use it. + */ + builtin?: boolean; }; // ============================================================================ diff --git a/packages/coding-agent/src/core/package-manager.ts b/packages/coding-agent/src/core/package-manager.ts index be42365a9..b5bb19d74 100644 --- a/packages/coding-agent/src/core/package-manager.ts +++ b/packages/coding-agent/src/core/package-manager.ts @@ -45,6 +45,7 @@ import { stripBom } from "../utils/text.ts"; import { isStdoutTakenOver } from "./output-guard.ts"; import { type PiManifest, readPiManifest } from "./pi-manifest.ts"; import type { PackageSource, SettingsManager } from "./settings-manager.ts"; +import { BUILTIN_PATH_PREFIX } from "./source-info.ts"; const NETWORK_TIMEOUT_MS = 10000; const UPDATE_CHECK_CONCURRENCY = 4; @@ -132,6 +133,8 @@ interface PackageManagerOptions { cwd: string; agentDir: string; settingsManager: SettingsManager; + /** Names of built-in extensions, resolved as `builtin:` extension resources. */ + builtinExtensions?: string[]; } type SourceScope = "user" | "project" | "temporary"; @@ -187,6 +190,8 @@ interface ResourceAccumulator { * 4 package resource (origin: "package") */ function resourcePrecedenceRank(m: PathMetadata): number { + // Built-in extensions load after file and package extensions, whichever scope enables them. + if (m.source === "builtin") return 5; if (m.origin === "package") return 4; const scopeBase = m.scope === "project" ? 0 : 2; return scopeBase + (m.source === "local" ? 0 : 1); @@ -808,6 +813,7 @@ export class DefaultPackageManager implements PackageManager { private cwd: string; private agentDir: string; private settingsManager: SettingsManager; + private builtinExtensions: string[]; private globalNpmRoot: string | undefined; private globalNpmRootCommandKey: string | undefined; private progressCallback: ProgressCallback | undefined; @@ -816,6 +822,7 @@ export class DefaultPackageManager implements PackageManager { this.cwd = resolvePath(options.cwd); this.agentDir = resolvePath(options.agentDir); this.settingsManager = options.settingsManager; + this.builtinExtensions = options.builtinExtensions ?? []; } setProgressCallback(callback: ProgressCallback | undefined): void { @@ -961,6 +968,23 @@ export class DefaultPackageManager implements PackageManager { this.addAutoDiscoveredResources(accumulator, globalSettings, projectSettings, globalBaseDir, projectBaseDir); + // Built-in extensions are enabled unless the user `extensions` setting excludes them, for example + // with `-builtin:mcp`. A matching `+`, `-`, or `!` entry in the project setting overrides that. + for (const name of this.builtinExtensions) { + const path = `${BUILTIN_PATH_PREFIX}${name}`; + const projectEnabled = applyAutoloadDisabledPatterns( + [path], + getOverridePatterns(projectSettings.extensions ?? []), + projectBaseDir, + ).get(path); + this.addResource( + accumulator.extensions, + path, + { source: "builtin", scope: projectEnabled === undefined ? "user" : "project", origin: "top-level" }, + projectEnabled ?? isEnabledByOverrides(path, globalSettings.extensions ?? [], globalBaseDir), + ); + } + return this.toResolvedPaths(accumulator); } @@ -970,7 +994,13 @@ export class DefaultPackageManager implements PackageManager { ): Promise { const accumulator = this.createAccumulator(); const scope: SourceScope = options?.temporary ? "temporary" : options?.local ? "project" : "user"; - const packageSources = sources.map((source) => ({ pkg: source as PackageSource, scope })); + // `-e builtin:` loads a built-in extension; the resource loader reports unknown names. + for (const source of sources.filter((source) => source.startsWith(BUILTIN_PATH_PREFIX))) { + this.addResource(accumulator.extensions, source, { source: "builtin", scope, origin: "top-level" }, true); + } + const packageSources = sources + .filter((source) => !source.startsWith(BUILTIN_PATH_PREFIX)) + .map((source) => ({ pkg: source as PackageSource, scope })); await this.resolvePackageSources(packageSources, accumulator); return this.toResolvedPaths(accumulator); } diff --git a/packages/coding-agent/src/core/resource-loader.ts b/packages/coding-agent/src/core/resource-loader.ts index e8aa6091e..4f61d10f8 100644 --- a/packages/coding-agent/src/core/resource-loader.ts +++ b/packages/coding-agent/src/core/resource-loader.ts @@ -17,7 +17,13 @@ import { loadExtensionFromFactory, loadExtensionsCached, } from "./extensions/loader.ts"; -import type { Extension, ExtensionRuntime, InlineExtension, LoadExtensionsResult } from "./extensions/types.ts"; +import type { + Extension, + ExtensionFactory, + ExtensionRuntime, + InlineExtension, + LoadExtensionsResult, +} from "./extensions/types.ts"; import { findGitPaths } from "./footer-data-provider.ts"; import { DefaultPackageManager, type PathMetadata, type ResolvedResource } from "./package-manager.ts"; import type { PromptTemplate } from "./prompt-templates.ts"; @@ -25,7 +31,13 @@ import { loadPromptTemplates } from "./prompt-templates.ts"; import { SettingsManager } from "./settings-manager.ts"; import type { Skill } from "./skills.ts"; import { loadSkills } from "./skills.ts"; -import { createSourceInfo, type SourceInfo } from "./source-info.ts"; +import { + BUILTIN_PATH_PREFIX, + createSourceInfo, + getSyntheticPathSource, + isSyntheticPath, + type SourceInfo, +} from "./source-info.ts"; import { resetTimings } from "./timings.ts"; export interface ResourceExtensionPaths { @@ -93,6 +105,13 @@ function mergeExtensionWarnings( ]; } +type BuiltinExtension = Exclude; + +/** Built-in extensions supply the code of `builtin:` extension paths (see `InlineExtension`). */ +export function isBuiltinExtension(input: InlineExtension): input is BuiltinExtension { + 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 @@ -275,6 +294,7 @@ export class DefaultResourceLoader implements ResourceLoader { private additionalPromptTemplatePaths: string[]; private additionalThemePaths: string[]; private extensionFactories: InlineExtension[]; + private builtinExtensions: Map; private noExtensions: boolean; private noSkills: boolean; private noPromptTemplates: boolean; @@ -327,16 +347,19 @@ export class DefaultResourceLoader implements ResourceLoader { 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.extensionFactories = options.extensionFactories ?? []; this.noExtensions = options.noExtensions ?? false; this.noSkills = options.noSkills ?? false; this.noPromptTemplates = options.noPromptTemplates ?? false; @@ -626,9 +649,11 @@ export class DefaultResourceLoader implements ResourceLoader { }); const enabledExtensions = resolvedPaths.extensions.filter((r) => r.enabled).map((r) => r.path); const cliEnabledExtensions = cliExtensionPaths.extensions.filter((r) => r.enabled).map((r) => r.path); - const extensionPaths = this.noExtensions - ? cliEnabledExtensions - : this.mergePaths(cliEnabledExtensions, enabledExtensions); + // 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, @@ -650,61 +675,79 @@ export class DefaultResourceLoader implements ResourceLoader { } private resolveExtensionLoadPath(path: string): string { - return resolvePath(path, this.cwd, { normalizeUnicodeSpaces: true }); + return isSyntheticPath(path) ? path : resolvePath(path, this.cwd, { normalizeUnicodeSpaces: true }); + } + + /** Load extension paths: files from disk and `builtin:` paths from the built-in extensions. */ + private async loadExtensionPaths(paths: string[], runtime?: ExtensionRuntime): Promise { + const isBuiltinPath = (path: string) => 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; } private async loadFinalExtensionSet( extensionPaths: string[], preTrustExtensions: LoadExtensionsResult | undefined, ): Promise { - if (!preTrustExtensions) { - const extensionsResult = await loadExtensionsCached(extensionPaths, this.cwd, this.eventBus); - const inlineExtensions = await this.loadExtensionFactories(extensionsResult.runtime); - extensionsResult.extensions = omitReplacedExtensions([ - ...extensionsResult.extensions, - ...inlineExtensions.extensions, - ]); - extensionsResult.errors.push(...inlineExtensions.errors); - this.addExtensionConflictDiagnostics(extensionsResult); - return extensionsResult; - } - + // Without a pre-trust pass nothing is preloaded, and inline extensions load here. + const preloaded = preTrustExtensions?.extensions ?? []; const preloadedByPath = new Map( - preTrustExtensions.extensions + preloaded .filter((extension) => !extension.path.startsWith(" [extension.resolvedPath, extension]), ); const failedPreloadPaths = new Set( - preTrustExtensions.errors.map((error) => this.resolveExtensionLoadPath(error.path)), + (preTrustExtensions?.errors ?? []).map((error) => this.resolveExtensionLoadPath(error.path)), ); const remainingPaths = extensionPaths.filter((path) => { const resolvedPath = this.resolveExtensionLoadPath(path); return !preloadedByPath.has(resolvedPath) && !failedPreloadPaths.has(resolvedPath); }); - const remainingExtensions = await loadExtensionsCached( - remainingPaths, - this.cwd, - this.eventBus, - preTrustExtensions.runtime, - ); + const remainingExtensions = await this.loadExtensionPaths(remainingPaths, preTrustExtensions?.runtime); const loadedByPath = new Map(preloadedByPath); for (const extension of remainingExtensions.extensions) { loadedByPath.set(extension.resolvedPath, extension); } - const inlineExtensions = preTrustExtensions.extensions.filter((extension) => - extension.path.startsWith(" extension.path.startsWith(" loadedByPath.get(this.resolveExtensionLoadPath(path))) .filter((extension): extension is Extension => extension !== undefined); - orderedExtensions.push(...inlineExtensions); + orderedExtensions.push(...inlineExtensions.extensions); const extensionsResult: LoadExtensionsResult = { extensions: omitReplacedExtensions(orderedExtensions), - errors: [...preTrustExtensions.errors, ...remainingExtensions.errors], - warnings: [...(preTrustExtensions.warnings ?? []), ...(remainingExtensions.warnings ?? [])], - runtime: preTrustExtensions.runtime, + errors: [...(preTrustExtensions?.errors ?? []), ...remainingExtensions.errors, ...inlineExtensions.errors], + warnings: [...(preTrustExtensions?.warnings ?? []), ...(remainingExtensions.warnings ?? [])], + runtime: remainingExtensions.runtime, }; this.addExtensionConflictDiagnostics(extensionsResult); return extensionsResult; @@ -856,7 +899,7 @@ export class DefaultResourceLoader implements ResourceLoader { return undefined; } - if (resourcePath.startsWith("<")) { + if (isSyntheticPath(resourcePath)) { return this.getDefaultSourceInfoForPath(resourcePath); } @@ -894,10 +937,11 @@ export class DefaultResourceLoader implements ResourceLoader { } private getDefaultSourceInfoForPath(filePath: string): SourceInfo { - if (filePath.startsWith("<") && filePath.endsWith(">")) { + const syntheticSource = getSyntheticPathSource(filePath); + if (syntheticSource) { return { path: filePath, - source: filePath.slice(1, -1).split(":")[0] || "temporary", + source: syntheticSource, scope: "temporary", origin: "top-level", }; @@ -954,7 +998,7 @@ export class DefaultResourceLoader implements ResourceLoader { } private resolveResourcePath(p: string): string { - return resolvePath(p, this.cwd, { trim: true }); + return isSyntheticPath(p) ? p : resolvePath(p, this.cwd, { trim: true }); } private loadThemes( diff --git a/packages/coding-agent/src/core/source-info.ts b/packages/coding-agent/src/core/source-info.ts index c8c9837d1..765395d64 100644 --- a/packages/coding-agent/src/core/source-info.ts +++ b/packages/coding-agent/src/core/source-info.ts @@ -11,6 +11,23 @@ export interface SourceInfo { baseDir?: string; } +/** Prefix of built-in tool and extension paths, such as `builtin:read` or `builtin:mcp`. */ +export const BUILTIN_PATH_PREFIX = "builtin:"; + +/** + * Source of a path that names no file: `builtin` for `builtin:`, or the prefix of an + * angle-bracket path such as `inline` for ``. Undefined for file paths. + */ +export function getSyntheticPathSource(path: string): string | undefined { + if (path.startsWith(BUILTIN_PATH_PREFIX)) return "builtin"; + if (path.startsWith("<") && path.endsWith(">")) return path.slice(1, -1).split(":")[0] || "temporary"; + return undefined; +} + +export function isSyntheticPath(path: string): boolean { + return path.startsWith(BUILTIN_PATH_PREFIX) || path.startsWith("<"); +} + export function createSourceInfo(path: string, metadata: PathMetadata): SourceInfo { return { path, diff --git a/packages/coding-agent/src/extensions/index.ts b/packages/coding-agent/src/extensions/index.ts index 3f464d4b6..81461dddd 100644 --- a/packages/coding-agent/src/extensions/index.ts +++ b/packages/coding-agent/src/extensions/index.ts @@ -5,10 +5,10 @@ import mcpExtension from "./mcp/index.ts"; import toolSearchExtension from "./tool-search/index.ts"; export const builtInExtensions: InlineExtension[] = [ - { name: "llama.cpp", factory: llamaExtension, hidden: true }, + { name: "llama.cpp", factory: llamaExtension, builtin: true }, // Replaceable: an extension that registers `codemode`, `tool_search`, or `/mcp` (such as a third-party // MCP extension) takes over instead of running alongside the built-in one. - { name: "codemode", factory: codemodeExtension, hidden: true, replaceable: true }, - { name: "tool-search", factory: toolSearchExtension, hidden: true, replaceable: true }, - { name: "mcp", factory: mcpExtension, hidden: true, replaceable: true }, + { name: "codemode", factory: codemodeExtension, replaceable: true, builtin: true }, + { name: "tool-search", factory: toolSearchExtension, replaceable: true, builtin: true }, + { name: "mcp", factory: mcpExtension, replaceable: true, builtin: true }, ]; diff --git a/packages/coding-agent/src/modes/interactive/components/config-selector.ts b/packages/coding-agent/src/modes/interactive/components/config-selector.ts index 217f261a9..a8054886e 100644 --- a/packages/coding-agent/src/modes/interactive/components/config-selector.ts +++ b/packages/coding-agent/src/modes/interactive/components/config-selector.ts @@ -18,6 +18,7 @@ import { import { CONFIG_DIR_NAME } from "../../../config.ts"; import type { PathMetadata, ResolvedPaths, ResolvedResource } from "../../../core/package-manager.ts"; import type { PackageSource, SettingsManager } from "../../../core/settings-manager.ts"; +import { BUILTIN_PATH_PREFIX } from "../../../core/source-info.ts"; import { canonicalizePath, isLocalPath, resolvePath } from "../../../utils/paths.ts"; import { theme } from "../theme/theme.ts"; import { DynamicBorder } from "./dynamic-border.ts"; @@ -84,6 +85,9 @@ function getGroupLabel(metadata: PathMetadata, agentDir: string): string { if (metadata.origin === "package") { return `${metadata.source} (${metadata.scope})`; } + if (metadata.source === "builtin") { + return metadata.scope === "user" ? "Built-in" : "Built-in (project override)"; + } // Top-level resources if (metadata.source === "auto") { if (metadata.baseDir) { @@ -131,7 +135,9 @@ function buildGroups(resolved: ResolvedPaths, agentDir: string): ResourceGroup[] const fileName = basename(path); const parentFolder = basename(dirname(path)); let displayName: string; - if (resourceType === "extensions" && parentFolder !== "extensions") { + if (metadata.source === "builtin") { + displayName = path.slice(BUILTIN_PATH_PREFIX.length); + } else if (resourceType === "extensions" && parentFolder !== "extensions") { displayName = `${parentFolder}/${fileName}`; } else if (resourceType === "skills" && fileName === "SKILL.md") { displayName = parentFolder; @@ -679,7 +685,9 @@ class ResourceList implements Component, Focusable { return !(state === "inherit" && this.isInheritedGlobalItem(item) && target === pattern); }); if (state !== "inherit") { - if (this.isInheritedGlobalItem(item) && !updated.includes(pattern)) updated.push(pattern); + // Project entries name inherited files to override them. Built-in paths need no entry. + if (this.isInheritedGlobalItem(item) && item.metadata.source !== "builtin" && !updated.includes(pattern)) + updated.push(pattern); updated.push(`${state === "load" ? "+" : "-"}${pattern}`); } this.setProjectTopLevelPaths(item.resourceType, updated); @@ -795,7 +803,7 @@ class ResourceList implements Component, Focusable { private getResourcePatternForScope(item: ResourceItem, scope: SettingsScope): string { const sourceScope = this.getItemScope(item); - if (scope !== sourceScope) return item.path; + if (scope !== sourceScope || item.metadata.source === "builtin") return item.path; const baseDir = item.metadata.baseDir ?? this.getTopLevelBaseDir(sourceScope); return relative(baseDir, item.path); } @@ -852,6 +860,7 @@ class ResourceList implements Component, Focusable { } private getResourcePattern(item: ResourceItem): string { + if (item.metadata.source === "builtin") return item.path; const scope = item.metadata.scope as "user" | "project"; const baseDir = item.metadata.baseDir ?? this.getTopLevelBaseDir(scope); return relative(baseDir, item.path); diff --git a/packages/coding-agent/src/modes/interactive/interactive-mode.ts b/packages/coding-agent/src/modes/interactive/interactive-mode.ts index 25c5be24a..c4e316076 100644 --- a/packages/coding-agent/src/modes/interactive/interactive-mode.ts +++ b/packages/coding-agent/src/modes/interactive/interactive-mode.ts @@ -638,7 +638,8 @@ export class InteractiveMode { } private getAutocompleteSourceTag(sourceInfo?: SourceInfo): string | undefined { - if (!sourceInfo) { + // Built-in extension commands are untagged, like built-in commands. + if (!sourceInfo || sourceInfo.source === "builtin") { return undefined; } diff --git a/packages/coding-agent/src/package-manager-cli.ts b/packages/coding-agent/src/package-manager-cli.ts index 53239daf8..5cf23497d 100644 --- a/packages/coding-agent/src/package-manager-cli.ts +++ b/packages/coding-agent/src/package-manager-cli.ts @@ -31,7 +31,7 @@ import type { InlineExtension } from "./core/extensions/types.ts"; import { ModelRuntime } from "./core/model-runtime.ts"; import { DefaultPackageManager } from "./core/package-manager.ts"; import { type AppMode, resolveProjectTrusted } from "./core/project-trust.ts"; -import { DefaultResourceLoader } from "./core/resource-loader.ts"; +import { DefaultResourceLoader, isBuiltinExtension } from "./core/resource-loader.ts"; import { SettingsManager } from "./core/settings-manager.ts"; import { hasTrustRequiringProjectResources, ProjectTrustStore } from "./core/trust-manager.ts"; import { spawnProcess, spawnProcessSync, waitForChildProcess } from "./utils/child-process.ts"; @@ -839,14 +839,18 @@ export async function handleConfigCommand( return true; } reportSettingsErrors(settingsManager, "config command"); + const builtinExtensions = (runtimeOptions.extensionFactories ?? []) + .filter(isBuiltinExtension) + .map((input) => input.name); const globalSettingsManager = SettingsManager.create(cwd, agentDir, { projectTrusted: false }); const globalResolvedPaths = await new DefaultPackageManager({ cwd, agentDir, settingsManager: globalSettingsManager, + builtinExtensions, }).resolve(); const projectResolvedPaths = settingsManager.isProjectTrusted() - ? await new DefaultPackageManager({ cwd, agentDir, settingsManager }).resolve() + ? await new DefaultPackageManager({ cwd, agentDir, settingsManager, builtinExtensions }).resolve() : globalResolvedPaths; await selectConfig({ diff --git a/packages/coding-agent/src/utils/paths.ts b/packages/coding-agent/src/utils/paths.ts index a8bcaae26..6a5a0cfb8 100644 --- a/packages/coding-agent/src/utils/paths.ts +++ b/packages/coding-agent/src/utils/paths.ts @@ -43,9 +43,9 @@ export function getFileRevision(path: string): string | undefined { } /** - * Returns true if the value is NOT a package source (npm:, git:, etc.) - * or a remote URL protocol. Bare names, relative paths, and file: URLs - * are considered local. + * Returns true if the value is NOT a package source (npm:, git:, etc.), + * a built-in extension (builtin:), or a remote URL protocol. Bare names, + * relative paths, and file: URLs are considered local. */ export function isLocalPath(value: string): boolean { const trimmed = value.trim(); @@ -56,7 +56,8 @@ export function isLocalPath(value: string): boolean { trimmed.startsWith("github:") || trimmed.startsWith("http:") || trimmed.startsWith("https:") || - trimmed.startsWith("ssh:") + trimmed.startsWith("ssh:") || + trimmed.startsWith("builtin:") ) { return false; } diff --git a/packages/coding-agent/test/agent-session-dynamic-tools.test.ts b/packages/coding-agent/test/agent-session-dynamic-tools.test.ts index 5edc1f6c0..afeb0f2d1 100644 --- a/packages/coding-agent/test/agent-session-dynamic-tools.test.ts +++ b/packages/coding-agent/test/agent-session-dynamic-tools.test.ts @@ -153,7 +153,7 @@ describe("AgentSession dynamic tool registration", () => { origin: "top-level", }); expect(readTool?.sourceInfo).toMatchObject({ - path: "", + path: "builtin:read", source: "builtin", scope: "temporary", origin: "top-level", diff --git a/packages/coding-agent/test/llama-extension.test.ts b/packages/coding-agent/test/llama-extension.test.ts index 4acc5c78a..29ee924e4 100644 --- a/packages/coding-agent/test/llama-extension.test.ts +++ b/packages/coding-agent/test/llama-extension.test.ts @@ -46,7 +46,7 @@ describe("llama.cpp extension", () => { process.cwd(), createEventBus(), runtime, - "", + "builtin:llama.cpp", ); expect(extension.commands.get("llama")?.description).toBe("Manage llama.cpp router models"); diff --git a/packages/coding-agent/test/package-command-paths.test.ts b/packages/coding-agent/test/package-command-paths.test.ts index 1d5343492..e3e192eae 100644 --- a/packages/coding-agent/test/package-command-paths.test.ts +++ b/packages/coding-agent/test/package-command-paths.test.ts @@ -15,11 +15,12 @@ import lockfile from "proper-lockfile"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { ENV_AGENT_DIR, PACKAGE_NAME, VERSION } from "../src/config.ts"; import { ModelRuntime } from "../src/core/model-runtime.ts"; -import type { ResolvedPaths } from "../src/core/package-manager.ts"; +import { DefaultPackageManager, type ResolvedPaths } from "../src/core/package-manager.ts"; import { InMemorySettingsStorage, SettingsManager } from "../src/core/settings-manager.ts"; import { ProjectTrustStore } from "../src/core/trust-manager.ts"; import { main } from "../src/main.ts"; import { ConfigSelectorComponent } from "../src/modes/interactive/components/config-selector.ts"; +import { initTheme } from "../src/modes/interactive/theme/theme.ts"; import { handlePackageCommand } from "../src/package-manager-cli.ts"; import { allowNetwork } from "./test-network-env.ts"; @@ -525,6 +526,67 @@ if (process.platform !== "win32") fs.chmodSync(piPath, 0o755); expect(settingsManager.getProjectSettings().packages).toEqual([]); }); + it("toggles built-in extensions in config global mode", async () => { + const settingsManager = SettingsManager.fromStorage(new InMemorySettingsStorage(), { projectTrusted: true }); + const resolvedPaths = await new DefaultPackageManager({ + cwd: projectDir, + agentDir, + settingsManager, + builtinExtensions: ["llama.cpp", "mcp"], + }).resolve(); + const selector = new ConfigSelectorComponent( + { global: resolvedPaths, project: resolvedPaths }, + settingsManager, + projectDir, + agentDir, + () => {}, + () => {}, + () => {}, + 24, + "global", + ); + + const list = selector.getResourceList(); + initTheme("dark"); + expect(list.render(80).join("\n")).toContain("Built-in"); + expect(list.render(80).join("\n")).toContain("llama.cpp"); + list.handleInput(" "); + expect(settingsManager.getGlobalSettings().extensions).toEqual(["-builtin:llama.cpp"]); + list.handleInput(" "); + expect(settingsManager.getGlobalSettings().extensions).toEqual(["+builtin:llama.cpp"]); + }); + + it("cycles project built-in extension overrides in config local mode", async () => { + const storage = new InMemorySettingsStorage(); + storage.withLock("global", () => JSON.stringify({ extensions: ["-builtin:mcp"] })); + const settingsManager = SettingsManager.fromStorage(storage, { projectTrusted: true }); + const resolvedPaths = await new DefaultPackageManager({ + cwd: projectDir, + agentDir, + settingsManager, + builtinExtensions: ["mcp"], + }).resolve(); + const selector = new ConfigSelectorComponent( + { global: resolvedPaths, project: resolvedPaths }, + settingsManager, + projectDir, + agentDir, + () => {}, + () => {}, + () => {}, + 24, + "project", + ); + + const list = selector.getResourceList(); + list.handleInput(" "); + expect(settingsManager.getProjectSettings().extensions).toEqual(["+builtin:mcp"]); + list.handleInput(" "); + expect(settingsManager.getProjectSettings().extensions).toEqual(["-builtin:mcp"]); + list.handleInput(" "); + expect(settingsManager.getProjectSettings().extensions).toEqual([]); + }); + it("shows a friendly error for unknown install options", async () => { const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); diff --git a/packages/coding-agent/test/package-manager.test.ts b/packages/coding-agent/test/package-manager.test.ts index 07cdc9d60..a81054bbe 100644 --- a/packages/coding-agent/test/package-manager.test.ts +++ b/packages/coding-agent/test/package-manager.test.ts @@ -124,6 +124,35 @@ describe("DefaultPackageManager", () => { expect(result.extensions.some((r) => r.path === extPath && r.enabled)).toBe(true); }); + it("should resolve built-in extensions with user exclusions and project overrides", async () => { + const pm = new DefaultPackageManager({ + cwd: tempDir, + agentDir, + settingsManager, + builtinExtensions: ["mcp", "llama.cpp"], + }); + const builtins = async () => + (await pm.resolve()).extensions.map((r) => [r.path, r.enabled, r.metadata.source, r.metadata.scope]); + + expect(await builtins()).toEqual([ + ["builtin:mcp", true, "builtin", "user"], + ["builtin:llama.cpp", true, "builtin", "user"], + ]); + + settingsManager.setExtensionPaths(["-builtin:mcp"]); + settingsManager.setProjectExtensionPaths(["+builtin:mcp", "-builtin:llama.cpp"]); + expect(await builtins()).toEqual([ + ["builtin:mcp", true, "builtin", "project"], + ["builtin:llama.cpp", false, "builtin", "project"], + ]); + + settingsManager.setProjectExtensionPaths([]); + expect(await builtins()).toEqual([ + ["builtin:mcp", false, "builtin", "user"], + ["builtin:llama.cpp", true, "builtin", "user"], + ]); + }); + it("should resolve skill paths from settings", async () => { const skillDir = join(agentDir, "skills", "my-skill"); mkdirSync(skillDir, { recursive: true }); diff --git a/packages/coding-agent/test/resource-loader.test.ts b/packages/coding-agent/test/resource-loader.test.ts index fe4467322..d996f4b10 100644 --- a/packages/coding-agent/test/resource-loader.test.ts +++ b/packages/coding-agent/test/resource-loader.test.ts @@ -1087,6 +1087,101 @@ export default function(pi: ExtensionAPI) { expect(runner.getCommand("mcp")?.description).toBe("other mcp"); expect(runner.getCommand("llama")?.description).toBe("built-in llama"); }); + + it("should skip built-in extensions disabled in settings", async () => { + writeFileSync(join(agentDir, "settings.json"), JSON.stringify({ extensions: ["-builtin:mcp"] })); + const loaded: string[] = []; + const loader = new DefaultResourceLoader({ + cwd, + agentDir, + extensionFactories: [ + { name: "mcp", builtin: true, factory: () => void loaded.push("mcp") }, + { name: "llama", builtin: true, factory: () => void loaded.push("llama") }, + ], + }); + await loader.reload(); + + expect(loader.getExtensions().extensions.map((extension) => extension.path)).toEqual(["builtin:llama"]); + expect(loader.getExtensions().extensions[0].sourceInfo).toMatchObject({ + path: "builtin:llama", + source: "builtin", + }); + expect(loader.getExtensions().extensions[0].hidden).toBe(true); + expect(loaded).toEqual(["llama"]); + }); + + it("should load built-in extensions after file extensions with and without trust resolution", async () => { + const userExtDir = join(agentDir, "extensions"); + mkdirSync(userExtDir, { recursive: true }); + writeFileSync(join(userExtDir, "user.ts"), "export default function() {}"); + mkdirSync(join(cwd, ".pi"), { recursive: true }); + // A project override gives the built-in project scope, which must not move it ahead. + writeFileSync(join(cwd, ".pi", "settings.json"), JSON.stringify({ extensions: ["+builtin:mcp"] })); + const loader = new DefaultResourceLoader({ + cwd, + agentDir, + extensionFactories: [{ name: "mcp", builtin: true, factory: () => {} }], + }); + const expected = [join(userExtDir, "user.ts"), "builtin:mcp"]; + + await loader.reload({ resolveProjectTrust: async () => true }); + expect(loader.getExtensions().extensions.map((extension) => extension.path)).toEqual(expected); + await loader.reload(); + expect(loader.getExtensions().extensions.map((extension) => extension.path)).toEqual(expected); + }); + + it("should disable built-in extensions with noExtensions unless loaded with -e builtin:", async () => { + const loaded: string[] = []; + const loader = new DefaultResourceLoader({ + cwd, + agentDir, + noExtensions: true, + additionalExtensionPaths: ["builtin:mcp", "builtin:missing"], + extensionFactories: [ + { name: "mcp", builtin: true, factory: () => void loaded.push("mcp") }, + { name: "llama", builtin: true, factory: () => void loaded.push("llama") }, + ], + }); + await loader.reload(); + + expect(loader.getExtensions().extensions.map((extension) => extension.path)).toEqual(["builtin:mcp"]); + expect(loader.getExtensions().errors).toEqual([ + { path: "builtin:missing", error: "Unknown built-in extension: builtin:missing" }, + ]); + expect(loaded).toEqual(["mcp"]); + }); + + it("should apply project built-in extension overrides after trust resolves", async () => { + writeFileSync(join(agentDir, "settings.json"), JSON.stringify({ extensions: ["-builtin:mcp"] })); + mkdirSync(join(cwd, ".pi"), { recursive: true }); + writeFileSync( + join(cwd, ".pi", "settings.json"), + JSON.stringify({ extensions: ["+builtin:mcp", "-builtin:llama"] }), + ); + const loaded: string[] = []; + const loader = new DefaultResourceLoader({ + cwd, + agentDir, + extensionFactories: [ + { name: "mcp", builtin: true, factory: () => void loaded.push("mcp") }, + { name: "plain", factory: () => void loaded.push("plain") }, + { name: "llama", builtin: true, factory: () => void loaded.push("llama") }, + ], + }); + await loader.reload({ + resolveProjectTrust: async ({ extensionsResult }) => { + // Built-in extensions wait until project settings are known. + expect(extensionsResult.extensions.map((extension) => extension.path)).toEqual([""]); + return true; + }, + }); + + expect(loader.getExtensions().extensions.map((extension) => extension.path)).toEqual([ + "builtin:mcp", + "", + ]); + expect(loaded).toEqual(["plain", "mcp"]); + }); }); describe("loadProjectContextFiles - nested worktree dedup", () => {