Path: blob/main/src/vs/workbench/contrib/chat/common/plugins/agentPluginServiceImpl.ts
13405 views
/*---------------------------------------------------------------------------------------------1* Copyright (c) Microsoft Corporation. All rights reserved.2* Licensed under the MIT License. See License.txt in the project root for license information.3*--------------------------------------------------------------------------------------------*/45import { RunOnceScheduler } from '../../../../../base/common/async.js';6import { Iterable } from '../../../../../base/common/iterator.js';7import { parse as parseJSONC } from '../../../../../base/common/json.js';8import { untildify } from '../../../../../base/common/labels.js';9import { Disposable, DisposableStore } from '../../../../../base/common/lifecycle.js';10import { ResourceSet } from '../../../../../base/common/map.js';11import { equals } from '../../../../../base/common/objects.js';12import { autorun, derived, derivedOpts, IObservable, ObservablePromise, observableSignal, observableValue } from '../../../../../base/common/observable.js';13import {14posix,15win3216} from '../../../../../base/common/path.js';17import {18basename, isEqualOrParent, joinPath19} from '../../../../../base/common/resources.js';20import { hasKey } from '../../../../../base/common/types.js';21import { URI } from '../../../../../base/common/uri.js';22import { ICommandService } from '../../../../../platform/commands/common/commands.js';23import { ConfigurationTarget, getConfigValueInTarget, IConfigurationService } from '../../../../../platform/configuration/common/configuration.js';24import { IFileService } from '../../../../../platform/files/common/files.js';25import { IInstantiationService } from '../../../../../platform/instantiation/common/instantiation.js';26import { ContextKeyExpr, ContextKeyExpression, IContextKeyService } from '../../../../../platform/contextkey/common/contextkey.js';27import { ILogService } from '../../../../../platform/log/common/log.js';28import { observableConfigValue } from '../../../../../platform/observable/common/platformObservableUtils.js';29import { IStorageService } from '../../../../../platform/storage/common/storage.js';30import { IWorkspaceContextService } from '../../../../../platform/workspace/common/workspace.js';31import { localize } from '../../../../../nls.js';32import { IDialogService } from '../../../../../platform/dialogs/common/dialogs.js';33import { ExtensionIdentifier, IExtensionManifest } from '../../../../../platform/extensions/common/extensions.js';34import { SyncDescriptor } from '../../../../../platform/instantiation/common/descriptors.js';35import { Registry } from '../../../../../platform/registry/common/platform.js';36import {37parseComponentPathConfig,38resolveComponentDirs,39readSkills,40readMarkdownComponents,41parseMcpServerDefinitionMap,42detectPluginFormat,43type IPluginFormatConfig,44type IParsedHookGroup,45} from '../../../../../platform/agentPlugins/common/pluginParsers.js';46import { Extensions, IExtensionFeaturesRegistry, IExtensionFeatureTableRenderer, IRenderedData, IRowData, ITableData } from '../../../../services/extensionManagement/common/extensionFeatures.js';47import * as extensionsRegistry from '../../../../services/extensions/common/extensionsRegistry.js';48import { IPathService } from '../../../../services/path/common/pathService.js';49import { ChatConfiguration } from '../constants.js';50import { EnablementModel, IEnablementModel } from '../enablement.js';51import { HookType } from '../promptSyntax/hookTypes.js';52import { IAgentPluginRepositoryService } from './agentPluginRepositoryService.js';53import { agentPluginDiscoveryRegistry, IAgentPlugin, IAgentPluginDiscovery, IAgentPluginHook, IAgentPluginInstruction, IAgentPluginMcpServerDefinition, IAgentPluginService } from './agentPluginService.js';54import { IMarketplacePlugin, IPluginMarketplaceService } from './pluginMarketplaceService.js';5556// Re-export shared helpers so existing consumers (including tests) continue to work.57export { shellQuotePluginRootInCommand, resolveMcpServersMap, convertBareEnvVarsToVsCodeSyntax } from '../../../../../platform/agentPlugins/common/pluginParsers.js';5859/**60* Converts platform-layer parsed hook groups to the workbench's {@link IAgentPluginHook} type.61* The canonical type strings from the platform layer map directly to {@link HookType} enum values.62*/63function toAgentPluginHooks(groups: readonly IParsedHookGroup[]): IAgentPluginHook[] {64return groups65.filter(g => Object.values(HookType).includes(g.type as HookType))66.map(g => ({67type: g.type as HookType,68hooks: g.commands,69uri: g.uri,70originalId: g.originalId,71}));72}7374/** File suffixes accepted for rule/instruction files (longest first for correct name stripping). */75const RULE_FILE_SUFFIXES = ['.instructions.md', '.mdc', '.md'];7677/**78* Resolves the workspace folder that contains the plugin URI for cwd resolution,79* falling back to the first workspace folder for plugins outside the workspace.80*/81function resolveWorkspaceRoot(pluginUri: URI, workspaceContextService: IWorkspaceContextService): URI | undefined {82const defaultFolder = workspaceContextService.getWorkspace().folders[0];83const folder = workspaceContextService.getWorkspaceFolder(pluginUri) ?? defaultFolder;84return folder?.uri;85}8687export class AgentPluginService extends Disposable implements IAgentPluginService {8889declare readonly _serviceBrand: undefined;9091public readonly plugins: IObservable<readonly IAgentPlugin[]>;92public readonly enablementModel: IEnablementModel;9394constructor(95@IInstantiationService instantiationService: IInstantiationService,96@IConfigurationService configurationService: IConfigurationService,97@IStorageService storageService: IStorageService,98) {99super();100101this.enablementModel = this._register(new EnablementModel('agentPlugins.enablement', storageService));102103const pluginsEnabled = observableConfigValue(ChatConfiguration.PluginsEnabled, true, configurationService);104105const discoveries: IAgentPluginDiscovery[] = [];106for (const descriptor of agentPluginDiscoveryRegistry.getAll()) {107const discovery = instantiationService.createInstance(descriptor);108this._register(discovery);109discoveries.push(discovery);110discovery.start(this.enablementModel);111}112113114this.plugins = derived(read => {115if (!pluginsEnabled.read(read)) {116return [];117}118return this._dedupeAndSort(discoveries.flatMap(d => d.plugins.read(read)));119});120}121122private _dedupeAndSort(plugins: readonly IAgentPlugin[]): readonly IAgentPlugin[] {123const unique: IAgentPlugin[] = [];124const seen = new ResourceSet();125126for (const plugin of plugins) {127if (seen.has(plugin.uri)) {128continue;129}130131seen.add(plugin.uri);132unique.push(plugin);133}134135unique.sort((a, b) => a.uri.toString().localeCompare(b.uri.toString()));136return unique;137}138}139140type PluginEntry = IAgentPlugin;141142/**143* Describes a single discovered plugin source, before the shared144* infrastructure builds the full {@link IAgentPlugin} from it.145*/146interface IPluginSource {147readonly uri: URI;148readonly fromMarketplace: IMarketplacePlugin | undefined;149/** Called when remove is invoked on the plugin */150remove(): void;151}152153/**154* Shared base class for plugin discovery implementations. Contains the common155* logic for reading plugin contents (commands, skills, agents, hooks, MCP server156* definitions) from the filesystem and watching for live updates.157*158* Subclasses implement {@link _discoverPluginSources} to determine *which*159* plugins exist, while this class handles the rest.160*/161export abstract class AbstractAgentPluginDiscovery extends Disposable implements IAgentPluginDiscovery {162163private readonly _pluginEntries = new Map<string, { plugin: PluginEntry; store: DisposableStore; format: IPluginFormatConfig }>();164165private readonly _plugins = observableValue<readonly IAgentPlugin[]>('discoveredAgentPlugins', []);166public readonly plugins: IObservable<readonly IAgentPlugin[]> = this._plugins;167168private _discoverVersion = 0;169protected _enablementModel!: IEnablementModel;170171constructor(172protected readonly _fileService: IFileService,173protected readonly _pathService: IPathService,174protected readonly _logService: ILogService,175protected readonly _workspaceContextService: IWorkspaceContextService,176) {177super();178}179180public abstract start(enablementModel: IEnablementModel): void;181182protected async _refreshPlugins(): Promise<void> {183const version = ++this._discoverVersion;184const plugins = await this._discoverAndBuildPlugins();185if (version !== this._discoverVersion || this._store.isDisposed) {186return;187}188189this._plugins.set(plugins, undefined);190}191192/** Subclasses return plugin sources to discover. */193protected abstract _discoverPluginSources(): Promise<readonly IPluginSource[]>;194195private async _discoverAndBuildPlugins(): Promise<readonly IAgentPlugin[]> {196const sources = await this._discoverPluginSources();197const plugins: IAgentPlugin[] = [];198const seenPluginUris = new Set<string>();199200for (const source of sources) {201const key = source.uri.toString();202if (!seenPluginUris.has(key)) {203seenPluginUris.add(key);204const format = await detectPluginFormat(source.uri, this._fileService);205plugins.push(this._toPlugin(source.uri, format, source.fromMarketplace, () => source.remove()));206}207}208209this._disposePluginEntriesExcept(seenPluginUris);210211plugins.sort((a, b) => a.uri.toString().localeCompare(b.uri.toString()));212return plugins;213}214215protected async _pathExists(resource: URI): Promise<boolean> {216try {217await this._fileService.resolve(resource);218return true;219} catch {220return false;221}222}223224private _toPlugin(uri: URI, format: IPluginFormatConfig, fromMarketplace: IMarketplacePlugin | undefined, removeCallback: () => void): IAgentPlugin {225const key = uri.toString();226const existing = this._pluginEntries.get(key);227if (existing) {228if (existing.format.format !== format.format) {229existing.store.dispose();230this._pluginEntries.delete(key);231} else {232return existing.plugin;233}234}235236const store = new DisposableStore();237const enablement = derived(r => this._enablementModel.readEnabled(key, r));238239// Track current component directories for the file watcher. These are240// updated whenever the manifest is read (inside each component reader).241const manifest = observableValue<Record<string, unknown> | undefined>('agentPluginManifest', undefined);242243const observeComponent = <T>(244prop: string,245doRead: (uris: readonly URI[]) => Promise<readonly T[]>,246tryReadEmbedded?: (section: unknown) => Promise<T[] | undefined>,247defaultPath = prop,248): IObservable<readonly T[]> => {249const secondObs = derivedOpts({ equalsFn: equals }, reader => manifest.read(reader)?.[prop]);250251const wrapped = derived(reader => {252const section = secondObs.read(reader);253if (tryReadEmbedded) {254if (section && typeof section === 'object' && !Array.isArray(section) && !(hasKey(section, { paths: true }))) {255return { kind: 'const', data: new ObservablePromise(tryReadEmbedded(section)) } as const;256}257}258259const paths = parseComponentPathConfig(section);260const dirs = resolveComponentDirs(uri, defaultPath, paths);261for (const d of dirs) {262const watcher = this._fileService.createWatcher(d, { recursive: false, excludes: [] });263reader.store.add(watcher);264reader.store.add(watcher.onDidChange(() => changeTrigger.trigger(undefined)));265}266267return { kind: 'dirs', dirs: dirs } as const;268});269270const changeTrigger = observableSignal('fileChange');271272const promised = derived(reader => {273const w = wrapped.read(reader);274if (w.kind === 'const') {275return w.data.promiseResult;276} else {277changeTrigger.read(reader); // re-run when a relevant file change occurs278const promise = new ObservablePromise(doRead(w.dirs));279return promise.promiseResult;280}281});282283const result = promised.map((w, r) => w.read(r)?.data ?? Iterable.empty());284285return result.recomputeInitiallyAndOnChange(store);286};287288const manifestUri = joinPath(uri, format.manifestPath);289const commands = observeComponent('commands', d => readMarkdownComponents(d, this._fileService));290const skills = observeComponent('skills', d => readSkills(uri, d, this._fileService));291const agents = observeComponent('agents', d => readMarkdownComponents(d, this._fileService));292const instructions = observeComponent('rules', d => this._readRules(d));293const hooks = observeComponent(294'hooks',295paths => this._readHooksFromPaths(uri, paths, format),296async section => {297const userHome = (await this._pathService.userHome()).fsPath;298const workspaceRoot = resolveWorkspaceRoot(uri, this._workspaceContextService);299return toAgentPluginHooks(format.parseHooks(manifestUri, section, uri, workspaceRoot, userHome));300},301format.hookConfigPath,302);303304const mcpServerDefinitions = observeComponent(305'mcpServers',306paths => this._readMcpDefinitionsFromPaths(paths, uri.fsPath, format),307async section => parseMcpServerDefinitionMap(manifestUri, { mcpServers: section }, uri.fsPath, format),308'.mcp.json',309);310311// Read the manifest initially and re-read whenever manifest files change.312const readManifest = async () => {313manifest.set(await this._readManifest(uri, format), undefined);314};315316const manifestWatcher = this._fileService.createWatcher(317manifestUri,318{ recursive: false, excludes: [] },319);320store.add(manifestWatcher);321store.add(manifestWatcher.onDidChange(() => readManifest()));322323readManifest();324325const plugin: PluginEntry = {326uri,327label: fromMarketplace?.name ?? basename(uri),328enablement,329remove: removeCallback,330hooks,331commands,332skills,333agents,334instructions,335mcpServerDefinitions,336fromMarketplace,337};338339this._pluginEntries.set(key, { store, plugin, format });340341return plugin;342}343344private async _readManifest(pluginUri: URI, format: IPluginFormatConfig): Promise<Record<string, unknown> | undefined> {345const json = await this._readJsonFile(joinPath(pluginUri, format.manifestPath));346if (json && typeof json === 'object') {347return json as Record<string, unknown>;348}349return undefined;350}351352/**353* Reads hook definitions from a list of resolved paths (JSON files).354* Each path is tried in order; the first one that contains valid hook355* JSON is used.356*/357private async _readHooksFromPaths(pluginUri: URI, paths: readonly URI[], format: IPluginFormatConfig): Promise<readonly IAgentPluginHook[]> {358const userHome = (await this._pathService.userHome()).fsPath;359const workspaceRoot = resolveWorkspaceRoot(pluginUri, this._workspaceContextService);360for (const hookPath of paths) {361const json = await this._readJsonFile(hookPath);362if (json) {363try {364return toAgentPluginHooks(format.parseHooks(hookPath, json, pluginUri, workspaceRoot, userHome));365} catch (e) {366this._logService.info(`[AgentPluginDiscovery] Failed to parse hooks from ${hookPath.toString()}:`, e);367}368}369}370return [];371}372373/**374* Reads MCP server definitions from a list of resolved paths (JSON files).375* Definitions from all files are merged; the first definition for a given376* server name wins.377*/378private async _readMcpDefinitionsFromPaths(paths: readonly URI[], pluginFsPath: string, format: IPluginFormatConfig): Promise<readonly IAgentPluginMcpServerDefinition[]> {379const merged = new Map<string, IAgentPluginMcpServerDefinition>();380for (const mcpPath of paths) {381const json = await this._readJsonFile(mcpPath);382for (const def of parseMcpServerDefinitionMap(mcpPath, json, pluginFsPath, format)) {383if (!merged.has(def.name)) {384merged.set(def.name, def);385}386}387}388return [...merged.values()].sort((a, b) => a.name.localeCompare(b.name));389}390391private async _readJsonFile(uri: URI): Promise<unknown | undefined> {392try {393const fileContents = await this._fileService.readFile(uri);394return parseJSONC(fileContents.value.toString());395} catch {396return undefined;397}398}399400/**401* Scans directories for rule/instruction files (`.mdc`, `.md`,402* `.instructions.md`), returning `{ uri, name }` entries where name is403* derived from the filename minus the matched suffix.404*/405private async _readRules(dirs: readonly URI[]): Promise<readonly IAgentPluginInstruction[]> {406const seen = new Set<string>();407const items: IAgentPluginInstruction[] = [];408409const matchSuffix = (filename: string): string | undefined => {410const lower = filename.toLowerCase();411return RULE_FILE_SUFFIXES.find(s => lower.endsWith(s));412};413414const addItem = (name: string, uri: URI) => {415if (!seen.has(name)) {416seen.add(name);417items.push({ uri, name });418}419};420421for (const dir of dirs) {422let stat;423try {424stat = await this._fileService.resolve(dir);425} catch {426continue;427}428429if (stat.isFile) {430const suffix = matchSuffix(basename(dir));431if (suffix) {432addItem(basename(dir).slice(0, -suffix.length), dir);433}434continue;435}436437if (!stat.isDirectory || !stat.children) {438continue;439}440441for (const child of stat.children) {442if (!child.isFile) {443continue;444}445const suffix = matchSuffix(child.name);446if (suffix) {447addItem(child.name.slice(0, -suffix.length), child.resource);448}449}450}451452items.sort((a, b) => a.name.localeCompare(b.name));453return items;454}455456private _disposePluginEntriesExcept(keep: Set<string>): void {457for (const [key, entry] of this._pluginEntries) {458if (!keep.has(key)) {459entry.store.dispose();460this._pluginEntries.delete(key);461}462}463}464465public override dispose(): void {466this._disposePluginEntriesExcept(new Set<string>());467super.dispose();468}469}470471export class ConfiguredAgentPluginDiscovery extends AbstractAgentPluginDiscovery {472473private readonly _pluginLocationsConfig: IObservable<Record<string, boolean>>;474475constructor(476@IConfigurationService private readonly _configurationService: IConfigurationService,477@IFileService fileService: IFileService,478@IPluginMarketplaceService private readonly _pluginMarketplaceService: IPluginMarketplaceService,479@IWorkspaceContextService workspaceContextService: IWorkspaceContextService,480@IPathService pathService: IPathService,481@ILogService logService: ILogService,482) {483super(fileService, pathService, logService, workspaceContextService);484this._pluginLocationsConfig = observableConfigValue<Record<string, boolean>>(ChatConfiguration.PluginLocations, {}, _configurationService);485}486487public override start(enablementModel: IEnablementModel): void {488this._enablementModel = enablementModel;489const scheduler = this._register(new RunOnceScheduler(() => this._refreshPlugins(), 0));490this._register(autorun(reader => {491this._pluginLocationsConfig.read(reader);492scheduler.schedule();493}));494scheduler.schedule();495}496497protected override async _discoverPluginSources(): Promise<readonly IPluginSource[]> {498const sources: IPluginSource[] = [];499const config = this._pluginLocationsConfig.get();500const userHome = await this._getUserHome();501502for (const [path, enabled] of Object.entries(config)) {503if (!path.trim() || enabled === false) {504continue;505}506507const resources = this._resolvePluginPath(path.trim(), userHome);508for (const resource of resources) {509let stat;510try {511stat = await this._fileService.resolve(resource);512} catch {513this._logService.debug(`[ConfiguredAgentPluginDiscovery] Could not resolve plugin path: ${resource.toString()}`);514continue;515}516517if (!stat.isDirectory) {518this._logService.debug(`[ConfiguredAgentPluginDiscovery] Plugin path is not a directory: ${resource.toString()}`);519continue;520}521522const fromMarketplace = this._pluginMarketplaceService.getMarketplacePluginMetadata(stat.resource);523const configKey = path;524sources.push({525uri: stat.resource,526fromMarketplace,527remove: () => this._removePluginPath(configKey),528});529}530}531532return sources;533}534535private async _getUserHome(): Promise<string> {536const userHome = await this._pathService.userHome();537return userHome.scheme === 'file' ? userHome.fsPath : userHome.path;538}539540/**541* Resolves a plugin path to one or more resource URIs. Supports:542* - Absolute paths (used directly)543* - Tilde paths (expanded to user home directory)544* - Relative paths (resolved against each workspace folder)545*/546private _resolvePluginPath(path: string, userHome: string): URI[] {547if (path.startsWith('~')) {548path = untildify(path, userHome);549}550551// Handle absolute paths552if (win32.isAbsolute(path) || posix.isAbsolute(path)) {553return [URI.file(path)];554}555556return this._workspaceContextService.getWorkspace().folders.map(557folder => joinPath(folder.uri, path)558);559}560561/**562* Removes a plugin path from `chat.pluginLocations` in the most specific563* config target where the key is defined.564*/565private _removePluginPath(configKey: string): void {566const inspected = this._configurationService.inspect<Record<string, boolean>>(ChatConfiguration.PluginLocations);567568const targets = [569ConfigurationTarget.WORKSPACE_FOLDER,570ConfigurationTarget.WORKSPACE,571ConfigurationTarget.USER_LOCAL,572ConfigurationTarget.USER_REMOTE,573ConfigurationTarget.USER,574ConfigurationTarget.APPLICATION,575];576577for (const target of targets) {578const mapping = getConfigValueInTarget(inspected, target);579if (mapping && Object.prototype.hasOwnProperty.call(mapping, configKey)) {580const updated = { ...mapping };581delete updated[configKey];582this._configurationService.updateValue(583ChatConfiguration.PluginLocations,584updated,585target,586);587return;588}589}590}591}592593export class MarketplaceAgentPluginDiscovery extends AbstractAgentPluginDiscovery {594595constructor(596@IPluginMarketplaceService private readonly _pluginMarketplaceService: IPluginMarketplaceService,597@IAgentPluginRepositoryService private readonly _pluginRepositoryService: IAgentPluginRepositoryService,598@IFileService fileService: IFileService,599@IPathService pathService: IPathService,600@ILogService logService: ILogService,601@IWorkspaceContextService workspaceContextService: IWorkspaceContextService,602) {603super(fileService, pathService, logService, workspaceContextService);604}605606public override start(enablementModel: IEnablementModel): void {607this._enablementModel = enablementModel;608const scheduler = this._register(new RunOnceScheduler(() => this._refreshPlugins(), 0));609this._register(autorun(reader => {610this._pluginMarketplaceService.installedPlugins.read(reader);611scheduler.schedule();612}));613scheduler.schedule();614}615616protected override async _discoverPluginSources(): Promise<readonly IPluginSource[]> {617const installed = this._pluginMarketplaceService.installedPlugins.get();618const sources: IPluginSource[] = [];619620for (const entry of installed) {621let stat;622try {623stat = await this._fileService.resolve(entry.pluginUri);624} catch {625this._logService.debug(`[MarketplaceAgentPluginDiscovery] Could not resolve installed plugin: ${entry.pluginUri.toString()}`);626continue;627}628629if (!stat.isDirectory) {630this._logService.debug(`[MarketplaceAgentPluginDiscovery] Installed plugin path is not a directory: ${entry.pluginUri.toString()}`);631continue;632}633634sources.push({635uri: stat.resource,636fromMarketplace: entry.plugin,637remove: () => {638this._enablementModel.remove(stat.resource.toString());639this._pluginMarketplaceService.removeInstalledPlugin(entry.pluginUri);640641// Pass remaining installed descriptors so the repository service642// can skip deletion when other plugins share the same cache dir.643const remaining = this._pluginMarketplaceService.installedPlugins.get();644this._pluginRepositoryService.cleanupPluginSource(645entry.plugin,646remaining.map(e => e.plugin.sourceDescriptor),647).catch(error => {648this._logService.error('[MarketplaceAgentPluginDiscovery] Failed to clean up plugin source', error);649});650},651});652}653654return sources;655}656}657658// ---------------------------------------------------------------------------659// Extension-contributed plugin discovery660// ---------------------------------------------------------------------------661662interface IRawChatPluginContribution {663readonly path: string;664readonly when?: string;665}666667const epPlugins = extensionsRegistry.ExtensionsRegistry.registerExtensionPoint<IRawChatPluginContribution[]>({668extensionPoint: 'chatPlugins',669jsonSchema: {670description: localize('chatPlugins.schema.description', 'Contributes agent plugins for chat.'),671type: 'array',672items: {673additionalProperties: false,674type: 'object',675defaultSnippets: [{676body: {677path: './relative/path/to/plugin/',678}679}],680required: ['path'],681properties: {682path: {683description: localize('chatPlugins.property.path', 'Path to the agent plugin root directory relative to the extension root.'),684type: 'string'685},686when: {687description: localize('chatPlugins.property.when', '(Optional) A condition which must be true to enable this plugin.'),688type: 'string'689}690}691}692}693});694695export class ExtensionAgentPluginDiscovery extends AbstractAgentPluginDiscovery {696697private readonly _extensionPlugins = new Map<string, { uri: URI; when: ContextKeyExpression | undefined; extensionId: string }>();698private readonly _whenKeys = new Set<string>();699700constructor(701@ICommandService private readonly _commandService: ICommandService,702@IContextKeyService private readonly _contextKeyService: IContextKeyService,703@IDialogService private readonly _dialogService: IDialogService,704@IFileService fileService: IFileService,705@IPathService pathService: IPathService,706@ILogService logService: ILogService,707@IWorkspaceContextService workspaceContextService: IWorkspaceContextService,708) {709super(fileService, pathService, logService, workspaceContextService);710}711712public override start(enablementModel: IEnablementModel): void {713this._enablementModel = enablementModel;714const scheduler = this._register(new RunOnceScheduler(() => this._refreshPlugins(), 0));715this._register(this._contextKeyService.onDidChangeContext(e => {716if (e.affectsSome(this._whenKeys)) {717scheduler.schedule();718}719}));720epPlugins.setHandler((_extensions, delta) => {721for (const ext of delta.added) {722for (const raw of ext.value) {723if (!raw.path) {724ext.collector.error(localize('extension.plugin.missing.path', "Extension '{0}' cannot register a chatPlugins entry without a path.", ext.description.identifier.value));725continue;726}727const pluginUri = joinPath(ext.description.extensionLocation, raw.path);728if (!isEqualOrParent(pluginUri, ext.description.extensionLocation)) {729ext.collector.error(localize('extension.plugin.invalid.path', "Extension '{0}' chatPlugins entry '{1}' resolves outside the extension.", ext.description.identifier.value, raw.path));730continue;731}732let whenExpr: ContextKeyExpression | undefined;733if (raw.when) {734whenExpr = ContextKeyExpr.deserialize(raw.when);735if (!whenExpr) {736ext.collector.error(localize('extension.plugin.invalid.when', "Extension '{0}' chatPlugins entry '{1}' has an invalid when clause: '{2}'.", ext.description.identifier.value, raw.path, raw.when));737continue;738}739}740this._extensionPlugins.set(extensionPluginKey(ext.description.identifier, raw.path), { uri: pluginUri, when: whenExpr, extensionId: ext.description.identifier.value });741}742}743for (const ext of delta.removed) {744for (const raw of ext.value) {745this._extensionPlugins.delete(extensionPluginKey(ext.description.identifier, raw.path));746}747}748this._rebuildWhenKeys();749scheduler.schedule();750});751}752753private _rebuildWhenKeys(): void {754this._whenKeys.clear();755for (const { when } of this._extensionPlugins.values()) {756if (when) {757for (const key of when.keys()) {758this._whenKeys.add(key);759}760}761}762}763764protected override async _discoverPluginSources(): Promise<readonly IPluginSource[]> {765const sources: IPluginSource[] = [];766for (const [, entry] of this._extensionPlugins) {767if (entry.when && !this._contextKeyService.contextMatchesRules(entry.when)) {768continue;769}770let stat;771try {772stat = await this._fileService.resolve(entry.uri);773} catch {774this._logService.debug(`[ExtensionAgentPluginDiscovery] Could not resolve extension plugin path: ${entry.uri.toString()}`);775continue;776}777if (!stat.isDirectory) {778this._logService.debug(`[ExtensionAgentPluginDiscovery] Extension plugin path is not a directory: ${entry.uri.toString()}`);779continue;780}781sources.push({782uri: stat.resource,783fromMarketplace: undefined,784remove: () => this._promptUninstallExtension(entry.extensionId),785});786}787return sources;788}789790private async _promptUninstallExtension(extensionId: string): Promise<void> {791const { confirmed } = await this._dialogService.confirm({792message: localize('uninstallExtensionForPlugin', "This plugin is provided by the extension '{0}'. Do you want to uninstall the extension?", extensionId),793});794if (confirmed) {795await this._commandService.executeCommand('workbench.extensions.uninstallExtension', extensionId);796}797}798}799800function extensionPluginKey(extensionId: ExtensionIdentifier, path: string): string {801return `${extensionId.value}/${path}`;802}803804class ChatPluginsDataRenderer extends Disposable implements IExtensionFeatureTableRenderer {805readonly type = 'table' as const;806807shouldRender(manifest: IExtensionManifest): boolean {808return !!manifest.contributes?.chatPlugins?.length;809}810811render(manifest: IExtensionManifest): IRenderedData<ITableData> {812const contributions = manifest.contributes?.chatPlugins ?? [];813if (!contributions.length) {814return { data: { headers: [], rows: [] }, dispose: () => { } };815}816817const headers = [818localize('chatPluginsPath', "Path"),819localize('chatPluginsWhen', "When"),820];821822const rows: IRowData[][] = contributions.map(d => [823d.path,824d.when ?? '-',825]);826827return {828data: { headers, rows },829dispose: () => { }830};831}832}833834Registry.as<IExtensionFeaturesRegistry>(Extensions.ExtensionFeaturesRegistry).registerExtensionFeature({835id: 'chatPlugins',836label: localize('chatPlugins', "Chat Plugins"),837access: {838canToggle: false839},840renderer: new SyncDescriptor(ChatPluginsDataRenderer),841});842843844