refactor(coding-agent): resolve built-in extensions as builtin:<name> paths (#10159)

Built-in extensions (mcp, llama.cpp, codemode, tool-search) are now extension resources like files: the package manager resolves them as builtin:<name>, the extensions setting disables them with -builtin:<name> (per project too), pi config lists them, and --no-extensions turns them off unless loaded with -e builtin:<name>. They load after project trust is resolved and after file and package extensions.

Built-in extensions and tools are named builtin:<name> in errors, diagnostics, RPC source info, and bug reports.
This commit is contained in:
Armin Ronacher 2026-09-29 10:51:01 +02:00 • committed by GitHub
parent 1d74741e17
commit 4259686d92
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
25 changed files with 387 additions and 76 deletions

View file

@ -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:<name>` 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:<name>` (for example `builtin:mcp` and `builtin:read`) in errors, diagnostics, RPC source info, and bug reports, instead of `<inline:name>` and `<builtin:name>`. 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:<name>`, for example `pi -ne -e builtin:mcp`.
### Fixed

View file

@ -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 <path>`<br>
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`<br>
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 <path>`<br>
Loads a skill file or directory and is repeatable.
- `-ns`, `--no-skills`<br>

View file

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

View file

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

View file

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

View file

@ -109,7 +109,7 @@ Use `DefaultResourceLoader` when you want standard discovery with selected overr
<a id="inlineextension"></a>
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:<name>` extension, which loads like a configured extension file. It loads by default, is listed in `pi config`, and is disabled by `-builtin:<name>` in the `extensions` setting or by `noExtensions`; `additionalExtensionPaths: ["builtin:<name>"]` loads it explicitly. It loads after project trust is resolved, so it cannot handle `project_trust`. The CLI's built-in extensions use it.
<a id="codemode-mcp"></a>

View file

@ -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:<name>` or `-builtin:<name>` entry in project settings overrides the user setting. `pi config` lists them under Built-in. `--no-extensions` disables them too, and `-e builtin:<name>` loads one explicitly.
## Updates, telemetry, and warnings
| Setting | Type | Default | Description |

View file

@ -310,8 +310,8 @@ ${chalk.bold("Options:")}
--exclude-tools, -xt <tools> Comma-separated denylist of tool names to disable
Applies to built-in, extension, and custom tools
--thinking <level> Set thinking level: off, minimal, low, medium, high, xhigh, max
--extension, -e <path> Load an extension file (can be used multiple times)
--no-extensions, -ne Disable extension discovery (explicit -e paths still work)
--extension, -e <path> Load an extension file or builtin:<name> (can be used multiple times)
--no-extensions, -ne Disable extension discovery and built-in extensions (explicit -e paths still work)
--skill <path> Load a skill file or directory (can be used multiple times)
--no-skills, -ns Disable skills discovery and loading
--prompt-template <path> Load a prompt template file or directory (can be used multiple times)

View file

@ -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(`<builtin:${name}>`, { 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(`<builtin:${definition.name}>`, { source: "builtin" }),
sourceInfo: createSyntheticSourceInfo(`${BUILTIN_PATH_PREFIX}${definition.name}`, {
source: "builtin",
}),
})),
runner,
);

View file

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

View file

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

View file

@ -1987,7 +1987,10 @@ export type ExtensionFactory = (pi: ExtensionAPI) => void | Promise<void>;
export type InlineExtension =
| ExtensionFactory
| {
/** Display name shown as `<inline:name>` in the startup Extensions list. */
/**
* Display name shown as `<inline:name>` 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:<name>` extension instead of loading as an inline extension.
* `builtin:<name>` is an extension resource like a file: it loads by default, `pi config` lists
* it, `-builtin:<name>` in the `extensions` setting and `--no-extensions` disable it, and
* `-e builtin:<name>` 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;
};
// ============================================================================

View file

@ -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:<name>` 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<ResolvedPaths> {
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:<name>` 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);
}

View file

@ -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<InlineExtension, ExtensionFactory>;
/** Built-in extensions supply the code of `builtin:<name>` 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<string, BuiltinExtension>;
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:<name>` paths from the built-in extensions. */
private async loadExtensionPaths(paths: string[], runtime?: ExtensionRuntime): Promise<LoadExtensionsResult> {
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<LoadExtensionsResult> {
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("<inline:"))
.map((extension) => [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("<inline:"),
);
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.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(

View file

@ -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:<name>`, or the prefix of an
* angle-bracket path such as `inline` for `<inline:name>`. 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,

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -153,7 +153,7 @@ describe("AgentSession dynamic tool registration", () => {
origin: "top-level",
});
expect(readTool?.sourceInfo).toMatchObject({
path: "<builtin:read>",
path: "builtin:read",
source: "builtin",
scope: "temporary",
origin: "top-level",

View file

@ -46,7 +46,7 @@ describe("llama.cpp extension", () => {
process.cwd(),
createEventBus(),
runtime,
"<inline:llama.cpp>",
"builtin:llama.cpp",
);
expect(extension.commands.get("llama")?.description).toBe("Manage llama.cpp router models");

View file

@ -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(() => {});

View file

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

View file

@ -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:<name>", 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(["<inline:plain>"]);
return true;
},
});
expect(loader.getExtensions().extensions.map((extension) => extension.path)).toEqual([
"builtin:mcp",
"<inline:plain>",
]);
expect(loaded).toEqual(["plain", "mcp"]);
});
});
describe("loadProjectContextFiles - nested worktree dedup", () => {