agentPluginServiceImpl.ts ×42

Frontier kind: Code frontier

unlabeled · c_d41ee05fd071

87 tests · 38834 LOC · 182 files · introduces 0 tests · 335 LOC · 1 file

Introduces — evidence that enters the hierarchy at this concept

Code
42 ranges335 lines · 1 files
Tests
0 tests

Contains — complete concept membership

All code (extent)
3089 ranges38834 lines · 182 files · Browse complete extent
All tests (intent)
87 testsBrowse complete intent

Neighbourhood graph

The orange circle is the focus. Violet and green circles are every ancestor and descendant, broader and narrower, at any distance; blue squares and pink diamonds are the introduced files and exact introduced tests of every visible concept, not only the focus's. Arrows point from broader to narrower concepts and bridge only concepts omitted from this view. Undirected links show source or test introduction. Concept and file size follows LOC; exact test nodes use test-count units.

Introduced files, introduced tests, and structurally relevant concept specialization

In the embedded map, ordinary wheel input scrolls the page; use the visible controls to zoom and drag to pan. Open the full-screen map for canvas navigation: wheel pans, Ctrl/Command plus wheel zooms, and arrow keys pan when this region is focused. On touch screens, open the full-screen map to pan or pinch. If JavaScript or WebGL is unavailable, use the native relationship evidence on this page.

Graph controls are ready.

Interactive rendering requires JavaScript and WebGL. Use the native relationship evidence on this page while the interactive map is unavailable.

Native relationship evidence

Every exact file and test below is linked only from the concept that introduces it.

Introduced tests

Every collected test enters the hierarchy at exactly one concept.

No tests are introduced at this concept. Its intent tests are introduced by other concepts.

Introduced code

Every collected source range enters the hierarchy at exactly one concept.

1 file ranked by introduced lines: 335 introduced LOC across 42 ranges. Expand a file to inspect source; the > gutter marks introduced lines.

src/vs/workbench/contrib/chat/common/plugins/agentPluginServiceImpl.ts 335 introduced LOC · 42 ranges

Open complete file

1 > /*--------------------------------------------------------------------------------------------- agentPluginServiceImpl.ts
2 > * Copyright (c) Microsoft Corporation. All rights reserved.
3 > * Licensed under the MIT License. See License.txt in the project root for license information.
4 > *--------------------------------------------------------------------------------------------*/
5 >
6 > import { RunOnceScheduler } from '../../../../../base/common/async.js';
7 > import { Event } from '../../../../../base/common/event.js';
8 > import { Iterable } from '../../../../../base/common/iterator.js';
9 > import { parse as parseJSONC } from '../../../../../base/common/json.js';
10 > import { untildify } from '../../../../../base/common/labels.js';
11 > import { Disposable, DisposableStore } from '../../../../../base/common/lifecycle.js';
12 > import { equals } from '../../../../../base/common/objects.js';
13 > import { autorun, derived, derivedOpts, IObservable, IReader, ISettableObservable, ITransaction, observableFromEvent, ObservablePromise, observableSignal, observableValue, transaction } from '../../../../../base/common/observable.js';
14 > import {
15 > posix,
16 > win32
17 > } from '../../../../../base/common/path.js';
18 > import {
19 > basename, isEqual, isEqualOrParent, joinPath
20 > } from '../../../../../base/common/resources.js';
21 > import { hasKey } from '../../../../../base/common/types.js';
22 > import { URI } from '../../../../../base/common/uri.js';
23 > import { ICommandService } from '../../../../../platform/commands/common/commands.js';
24 > import { ConfigurationTarget, getConfigValueInTarget, IConfigurationService } from '../../../../../platform/configuration/common/configuration.js';
25 > import { IFileService } from '../../../../../platform/files/common/files.js';
26 > import { IInstantiationService } from '../../../../../platform/instantiation/common/instantiation.js';
27 > import { ContextKeyExpr, ContextKeyExpression, IContextKeyService } from '../../../../../platform/contextkey/common/contextkey.js';
28 > import { ILogService } from '../../../../../platform/log/common/log.js';
29 > import { observableConfigValue } from '../../../../../platform/observable/common/platformObservableUtils.js';
30 > import { IStorageService } from '../../../../../platform/storage/common/storage.js';
31 > import { IWorkspaceContextService } from '../../../../../platform/workspace/common/workspace.js';
32 > import { localize } from '../../../../../nls.js';
33 > import { IDialogService } from '../../../../../platform/dialogs/common/dialogs.js';
34 > import { ExtensionIdentifier, IExtensionManifest } from '../../../../../platform/extensions/common/extensions.js';
35 > import { SyncDescriptor } from '../../../../../platform/instantiation/common/descriptors.js';
36 > import { Registry } from '../../../../../platform/registry/common/platform.js';
37 > import {
38 > resolvePluginComponentDirs,
39 > getPluginManifestComponent,
40 > readPluginSkills,
41 > readMarkdownComponents,
42 > readPluginManifest,
43 > readPluginMcpServers,
44 > parseMcpServerDefinitionMap,
45 > detectPluginFormat,
46 > type PluginComponent,
47 > type IPluginFormatConfig,
48 > type IParsedHookGroup,
49 > } from '../../../../../platform/agentPlugins/common/pluginParsers.js';
50 > import { Extensions, IExtensionFeaturesRegistry, IExtensionFeatureTableRenderer, IRenderedData, IRowData, ITableData } from '../../../../services/extensionManagement/common/extensionFeatures.js';
51 > import * as extensionsRegistry from '../../../../services/extensions/common/extensionsRegistry.js';
52 > import { IPathService } from '../../../../services/path/common/pathService.js';
53 > import { ChatConfiguration } from '../constants.js';
54 > import { ContributionEnablementState, EnablementModel, IEnablementModel } from '../enablement.js';
55 > import { HookType } from '../promptSyntax/hookTypes.js';
56 > import { AgentPluginCollisionEnablementModel, getAgentPluginPolicyId, getCanonicalAgentPluginCollisionGroups, getSortedAgentPlugins, IDiscoveredAgentPlugins, isAgentPluginBlockedByPolicy } from './agentPluginEnablement.js';
57 > import { IAgentPluginRepositoryService } from './agentPluginRepositoryService.js';
58 > import { AgentPluginDiscoveryPriority, agentPluginDiscoveryRegistry, IAgentPlugin, IAgentPluginDiscovery, IAgentPluginHook, IAgentPluginInstruction, IAgentPluginService } from './agentPluginService.js';
59 > import { IMarketplacePlugin, IPluginMarketplaceService } from './pluginMarketplaceService.js';
60 >
61 > // Re-export shared helpers so existing consumers (including tests) continue to work.
62 > export { shellQuotePluginRootInCommand, resolveMcpServersMap, convertBareEnvVarsToVsCodeSyntax } from '../../../../../platform/agentPlugins/common/pluginParsers.js';
63 >
64 > /**
65 > * Converts platform-layer parsed hook groups to the workbench's {@link IAgentPluginHook} type.
66 > * The canonical type strings from the platform layer map directly to {@link HookType} enum values.
67 > */
68 function toAgentPluginHooks(groups: readonly IParsedHookGroup[]): IAgentPluginHook[] {
69 return groups
76 }));
77 }
79 > /** File suffixes accepted for rule/instruction files (longest first for correct name stripping). */
80 > const RULE_FILE_SUFFIXES = ['.instructions.md', '.mdc', '.md'];
81 >
82 > /**
83 > * Resolves the workspace folder that contains the plugin URI for cwd resolution,
84 > * falling back to the first workspace folder for plugins outside the workspace.
85 > */
86 function resolveWorkspaceRoot(pluginUri: URI, workspaceContextService: IWorkspaceContextService): URI | undefined {
87 const defaultFolder = workspaceContextService.getWorkspace().folders[0];
89 return folder?.uri;
90 }
92 > export class AgentPluginService extends Disposable implements IAgentPluginService {
93 >
94 > declare readonly _serviceBrand: undefined;
95 >
96 > public readonly plugins: IObservable<readonly IAgentPlugin[]>;
97 > public readonly enablementModel: IEnablementModel;
98 >
99 > constructor(
100 @IInstantiationService instantiationService: IInstantiationService,
101 @IConfigurationService configurationService: IConfigurationService,
169 }));
170 }
172 >
173 > interface IAgentPluginDiscoveryWithPriority {
174 > readonly discovery: IAgentPluginDiscovery;
175 > readonly priority: AgentPluginDiscoveryPriority;
176 > readonly order: number;
177 > }
178 >
179 function readDiscoveredAgentPlugins(discoveries: readonly IAgentPluginDiscoveryWithPriority[], reader: IReader): readonly IDiscoveredAgentPlugins[] | undefined {
180 const result: IDiscoveredAgentPlugins[] = [];
188 return result;
189 }
191 > /**
192 > * A discovered plugin. Extends the public {@link IAgentPlugin} with a settable
193 > * `policyBlocked` observable that the service writes to when enterprise policy
194 > * blocks the plugin.
195 > */
196 > interface PluginEntry extends IAgentPlugin {
197 > readonly policyBlocked: ISettableObservable<boolean>;
198 > }
199 >
200 > /**
201 > * Marks a plugin as blocked (or unblocked) by enterprise policy. Safe to call
202 > * for any {@link IAgentPlugin}; entries without a settable observable (e.g. test
203 > * doubles) are ignored.
204 > */
205 function setPolicyBlocked(plugin: IAgentPlugin, blocked: boolean, tx: ITransaction): boolean {
206 const obs = plugin.policyBlocked as ISettableObservable<boolean> | undefined;
214 return false;
215 }
217 > /**
218 > * Minimal shape of a parsed plugin manifest. Known fields are typed; unknown
219 > * keys (e.g. `commands`, `skills`, `hooks`, `mcpServers`) remain `unknown` and
220 > * are parsed by the component readers.
221 > *
222 > * NOTE: `name` is typed as `string | undefined` to express intent, but
223 > * consumers must still runtime-validate it (manifests are untrusted JSON).
224 > */
225 > interface IPluginManifest {
226 > readonly name?: string;
227 > readonly [key: string]: unknown;
228 > }
229 >
230 > /**
231 > * Describes a single discovered plugin source, before the shared
232 > * infrastructure builds the full {@link IAgentPlugin} from it.
233 > */
234 > interface IPluginSource {
235 > readonly uri: URI;
236 > readonly fromMarketplace: IMarketplacePlugin | undefined;
237 > /** Repository root that serves as the boundary for component path resolution. */
238 > readonly repositoryUri?: URI;
239 > /** Called when remove is invoked on the plugin; absent for policy-managed plugins */
240 > remove?(): void;
241 > }
242 >
243 > /**
244 > * Shared base class for plugin discovery implementations. Contains the common
245 > * logic for reading plugin contents (commands, skills, agents, hooks, MCP server
246 > * definitions) from the filesystem and watching for live updates.
247 > *
248 > * Subclasses implement {@link _discoverPluginSources} to determine *which*
249 > * plugins exist, while this class handles the rest.
250 > */
251 > export abstract class AbstractAgentPluginDiscovery extends Disposable implements IAgentPluginDiscovery {
252 >
253 > private readonly _pluginEntries = new Map<string, { plugin: PluginEntry; store: DisposableStore; format: IPluginFormatConfig }>();
254 >
255 > private readonly _plugins = observableValue<readonly IAgentPlugin[] | undefined>('discoveredAgentPlugins', undefined);
256 > public readonly plugins: IObservable<readonly IAgentPlugin[] | undefined> = this._plugins;
257 >
258 > private _discoverVersion = 0;
259 > protected _enablementModel!: IEnablementModel;
260 >
261 > constructor(
262 protected readonly _fileService: IFileService,
263 protected readonly _pathService: IPathService,
267 super();
268 }
270 > public abstract start(enablementModel: IEnablementModel): void;
271 >
272 > protected async _refreshPlugins(): Promise<void> {
273 const version = ++this._discoverVersion;
274 const plugins = await this._discoverAndBuildPlugins(version);
279 this._plugins.set(plugins, undefined);
280 }
282 > /** Subclasses return plugin sources to discover. */
283 > protected abstract _discoverPluginSources(): Promise<readonly IPluginSource[]>;
284 >
285 > private async _discoverAndBuildPlugins(version: number): Promise<readonly IAgentPlugin[]> {
286 const sources = await this._discoverPluginSources();
287 if (!this._isCurrentRefresh(version)) {
318 return plugins;
319 }
321 > private _isCurrentRefresh(version: number): boolean {
322 return version === this._discoverVersion && !this._store.isDisposed;
323 }
325 > protected async _pathExists(resource: URI): Promise<boolean> {
326 try {
327 await this._fileService.resolve(resource);
331 }
332 }
334 > private async _toPlugin(uri: URI, format: IPluginFormatConfig, fromMarketplace: IMarketplacePlugin | undefined, repositoryUri: URI | undefined, removeCallback: (() => void) | undefined, version: number): Promise<IAgentPlugin> {
335 const key = uri.toString();
336 const existing = this._pluginEntries.get(key);
496 return plugin;
497 }
499 > /**
500 > * Reads hook definitions from a list of resolved paths (JSON files).
501 > * Each path is tried in order; the first one that contains valid hook
502 > * JSON is used.
503 > */
504 > private async _readHooksFromPaths(pluginUri: URI, paths: readonly URI[], format: IPluginFormatConfig): Promise<readonly IAgentPluginHook[]> {
505 const userHome = await this._pathService.userHome();
506 const workspaceRoot = resolveWorkspaceRoot(pluginUri, this._workspaceContextService);
517 return [];
518 }
520 > private async _readJsonFile(uri: URI): Promise<unknown | undefined> {
521 try {
522 const fileContents = await this._fileService.readFile(uri);
526 }
527 }
529 > /**
530 > * Scans directories for rule/instruction files (`.mdc`, `.md`,
531 > * `.instructions.md`), returning `{ uri, name }` entries where name is
532 > * derived from the filename minus the matched suffix.
533 > */
534 > private async _readRules(dirs: readonly URI[]): Promise<readonly IAgentPluginInstruction[]> {
535 const seen = new Set<string>();
536 const items: IAgentPluginInstruction[] = [];
582 return items;
583 }
585 > private _disposePluginEntriesExcept(keep: Set<string>): void {
586 for (const [key, entry] of this._pluginEntries) {
587 if (!keep.has(key)) {
591 }
592 }
594 > public override dispose(): void {
595 this._disposePluginEntriesExcept(new Set<string>());
596 super.dispose();
597 }
599 >
600 > export class ConfiguredAgentPluginDiscovery extends AbstractAgentPluginDiscovery {
601 >
602 > private readonly _pluginLocationsConfig: IObservable<Record<string, boolean>>;
603 > private readonly _enterpriseEnabledPluginsConfig: IObservable<Record<string, boolean>>;
604 >
605 > constructor(
606 @IConfigurationService private readonly _configurationService: IConfigurationService,
607 @IFileService fileService: IFileService,
625 );
626 }
628 > public override start(enablementModel: IEnablementModel): void {
629 this._enablementModel = enablementModel;
630 const scheduler = this._register(new RunOnceScheduler(() => this._refreshPlugins(), 0));
636 scheduler.schedule();
637 }
639 > protected override async _discoverPluginSources(): Promise<readonly IPluginSource[]> {
640 const sources: IPluginSource[] = [];
641 const userHome = await this._getUserHome();
673 return sources;
674 }
676 > private async _addPluginSource(sources: IPluginSource[], resource: URI, label: string, remove?: () => void): Promise<void> {
677 let stat;
678 try {
694 });
695 }
697 > private async _getUserHome(): Promise<string> {
698 const userHome = await this._pathService.userHome();
699 return userHome.scheme === 'file' ? userHome.fsPath : userHome.path;
700 }
702 > /**
703 > * Resolves a user-configured plugin path to one or more resource URIs.
704 > * Supports absolute paths, tilde paths (expanded to user home), and
705 > * workspace-relative paths.
706 > */
707 > private _resolvePluginPath(path: string, userHome: string): URI[] {
708 if (path.startsWith('~')) {
709 path = untildify(path, userHome);
718 );
719 }
721 > /**
722 > * Resolves an enterprise plugin ID of the form `<plugin>@<marketplace>` to
723 > * the Copilot CLI install convention `~/.copilot/installed-plugins/<marketplace>/<plugin>/`.
724 > * Returns `undefined` for anything that doesn't match the ID shape.
725 > */
726 > private _resolveEnterprisePluginId(id: string, userHome: string): URI | undefined {
727 const idMatch = id.match(/^([^@/\\~]+)@([^@/\\~]+)$/);
728 if (!idMatch) {
732 return URI.file(`${userHome}/.copilot/installed-plugins/${marketplace}/${plugin}`);
733 }
735 > /**
736 > * Removes a plugin path from `chat.pluginLocations` in the most specific
737 > * config target where the key is defined.
738 > */
739 > private _removePluginPath(configKey: string): void {
740 const inspected = this._configurationService.inspect<Record<string, boolean>>(ChatConfiguration.PluginLocations);
741
763 }
764 }
766 >
767 > export class MarketplaceAgentPluginDiscovery extends AbstractAgentPluginDiscovery {
768 >
769 > constructor(
770 @IPluginMarketplaceService private readonly _pluginMarketplaceService: IPluginMarketplaceService,
771 @IAgentPluginRepositoryService private readonly _pluginRepositoryService: IAgentPluginRepositoryService,
777 super(fileService, pathService, logService, workspaceContextService);
778 }
780 > public override start(enablementModel: IEnablementModel): void {
781 this._enablementModel = enablementModel;
782 const scheduler = this._register(new RunOnceScheduler(() => this._refreshPlugins(), 0));
787 scheduler.schedule();
788 }
790 > protected override async _discoverPluginSources(): Promise<readonly IPluginSource[]> {
791 const installed = this._pluginMarketplaceService.installedPlugins.get();
792 const sources: IPluginSource[] = [];
831 return sources;
832 }
834 >
835 > // ---------------------------------------------------------------------------
836 > // Copilot CLI plugin discovery
837 > // ---------------------------------------------------------------------------
838 >
839 > /**
840 > * Directory under the Copilot CLI home where installed plugins are cached.
841 > * Layout is two levels deep: `<marketplace>/<plugin>/`. Direct (non-marketplace)
842 > * installs use the reserved marketplace segment `_direct`.
843 > *
844 > * See `src/plugins/manager.ts` in the copilot-agent-runtime repo.
845 > */
846 > const COPILOT_CLI_INSTALLED_PLUGINS_DIR = '.copilot/installed-plugins';
847 >
848 > /**
849 > * Discovers plugins installed by the Copilot CLI under
850 > * `~/.copilot/installed-plugins/<marketplace>/<plugin>/`. Each leaf directory
851 > * is treated as a plugin root, allowing CLI-installed plugins (both
852 > * marketplace and direct) to surface in VS Code without a separate install.
853 > */
854 > export class CopilotCliAgentPluginDiscovery extends AbstractAgentPluginDiscovery {
855 >
856 > constructor(
857 @IFileService fileService: IFileService,
858 @IPathService pathService: IPathService,
863 super(fileService, pathService, logService, workspaceContextService);
864 }
866 > public override start(enablementModel: IEnablementModel): void {
867 this._enablementModel = enablementModel;
868 const scheduler = this._register(new RunOnceScheduler(() => this._refreshPlugins(), 0));
933 scheduler.schedule();
934 }
936 > private async _getInstalledPluginsDir(): Promise<URI> {
937 const userHome = await this._pathService.userHome();
938 return joinPath(userHome, COPILOT_CLI_INSTALLED_PLUGINS_DIR);
939 }
941 > protected override async _discoverPluginSources(): Promise<readonly IPluginSource[]> {
942 const root = await this._getInstalledPluginsDir();
943
987 return sources;
988 }
990 > private async _promptRemove(resource: URI): Promise<void> {
991 const { confirmed } = await this._dialogService.confirm({
992 message: localize('copilotCliPlugin.remove.confirm', "This plugin was installed by the Copilot CLI. Remove it from disk?"),
1005 }
1006 }
1008 >
1009 > // ---------------------------------------------------------------------------
1010 > // Extension-contributed plugin discovery
1011 > // ---------------------------------------------------------------------------
1012 >
1013 > interface IRawChatPluginContribution {
1014 > readonly path: string;
1015 > readonly when?: string;
1016 > }
1017 >
1018 > const epPlugins = extensionsRegistry.ExtensionsRegistry.registerExtensionPoint<IRawChatPluginContribution[]>({
1019 > extensionPoint: 'chatPlugins',
1020 > jsonSchema: {
1021 > description: localize('chatPlugins.schema.description', 'Contributes agent plugins for chat.'),
1022 > type: 'array',
1023 > items: {
1024 > additionalProperties: false,
1025 > type: 'object',
1026 > defaultSnippets: [{
1027 > body: {
1028 > path: './relative/path/to/plugin/',
1029 > }
1030 > }],
1031 > required: ['path'],
1032 > properties: {
1033 > path: {
1034 > description: localize('chatPlugins.property.path', 'Path to the agent plugin root directory relative to the extension root.'),
1035 > type: 'string'
1036 > },
1037 > when: {
1038 > description: localize('chatPlugins.property.when', '(Optional) A condition which must be true to enable this plugin.'),
1039 > type: 'string'
1040 > }
1041 > }
1042 > }
1043 > }
1044 > });
1045 >
1046 > export class ExtensionAgentPluginDiscovery extends AbstractAgentPluginDiscovery {
1047 >
1048 > private readonly _extensionPlugins = new Map<string, { uri: URI; when: ContextKeyExpression | undefined; extensionId: string }>();
1049 > private readonly _whenKeys = new Set<string>();
1050 >
1051 > constructor(
1052 @ICommandService private readonly _commandService: ICommandService,
1053 @IContextKeyService private readonly _contextKeyService: IContextKeyService,
1060 super(fileService, pathService, logService, workspaceContextService);
1061 }
1063 > public override start(enablementModel: IEnablementModel): void {
1064 this._enablementModel = enablementModel;
1065 const scheduler = this._register(new RunOnceScheduler(() => this._refreshPlugins(), 0));
1103 scheduler.schedule();
1104 }
1106 > private _rebuildWhenKeys(): void {
1107 this._whenKeys.clear();
1108 for (const { when } of this._extensionPlugins.values()) {
1114 }
1115 }
1117 > protected override async _discoverPluginSources(): Promise<readonly IPluginSource[]> {
1118 const sources: IPluginSource[] = [];
1119 for (const [, entry] of this._extensionPlugins) {
1140 return sources;
1141 }
1143 > private async _promptUninstallExtension(extensionId: string): Promise<void> {
1144 const { confirmed } = await this._dialogService.confirm({
1145 message: localize('uninstallExtensionForPlugin', "This plugin is provided by the extension '{0}'. Do you want to uninstall the extension?", extensionId),
1149 }
1150 }
1152 >
1153 function extensionPluginKey(extensionId: ExtensionIdentifier, path: string): string {
1154 return `${extensionId.value}/${path}`;
1155 }
1157 class ChatPluginsDataRenderer extends Disposable implements IExtensionFeatureTableRenderer {
1158 readonly type = 'table' as const;
1160 > shouldRender(manifest: IExtensionManifest): boolean {
1161 return !!manifest.contributes?.chatPlugins?.length;
1162 }
1164 > render(manifest: IExtensionManifest): IRenderedData<ITableData> {
1165 const contributions = manifest.contributes?.chatPlugins ?? [];
1166 if (!contributions.length) {
1183 };
1184 }
1186 >
1187 > Registry.as<IExtensionFeaturesRegistry>(Extensions.ExtensionFeaturesRegistry).registerExtensionFeature({
1188 > id: 'chatPlugins',
1189 > label: localize('chatPlugins', "Chat Plugins"),
1190 > access: {
1191 > canToggle: false
1192 > },
1193 > renderer: new SyncDescriptor(ChatPluginsDataRenderer),
1194 > });