Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
microsoft
GitHub Repository: microsoft/vscode
Path: blob/main/src/vs/workbench/contrib/chat/common/plugins/agentPluginServiceImpl.ts
13405 views
1
/*---------------------------------------------------------------------------------------------
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 { Iterable } from '../../../../../base/common/iterator.js';
8
import { parse as parseJSONC } from '../../../../../base/common/json.js';
9
import { untildify } from '../../../../../base/common/labels.js';
10
import { Disposable, DisposableStore } from '../../../../../base/common/lifecycle.js';
11
import { ResourceSet } from '../../../../../base/common/map.js';
12
import { equals } from '../../../../../base/common/objects.js';
13
import { autorun, derived, derivedOpts, IObservable, ObservablePromise, observableSignal, observableValue } from '../../../../../base/common/observable.js';
14
import {
15
posix,
16
win32
17
} from '../../../../../base/common/path.js';
18
import {
19
basename, 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
parseComponentPathConfig,
39
resolveComponentDirs,
40
readSkills,
41
readMarkdownComponents,
42
parseMcpServerDefinitionMap,
43
detectPluginFormat,
44
type IPluginFormatConfig,
45
type IParsedHookGroup,
46
} from '../../../../../platform/agentPlugins/common/pluginParsers.js';
47
import { Extensions, IExtensionFeaturesRegistry, IExtensionFeatureTableRenderer, IRenderedData, IRowData, ITableData } from '../../../../services/extensionManagement/common/extensionFeatures.js';
48
import * as extensionsRegistry from '../../../../services/extensions/common/extensionsRegistry.js';
49
import { IPathService } from '../../../../services/path/common/pathService.js';
50
import { ChatConfiguration } from '../constants.js';
51
import { EnablementModel, IEnablementModel } from '../enablement.js';
52
import { HookType } from '../promptSyntax/hookTypes.js';
53
import { IAgentPluginRepositoryService } from './agentPluginRepositoryService.js';
54
import { agentPluginDiscoveryRegistry, IAgentPlugin, IAgentPluginDiscovery, IAgentPluginHook, IAgentPluginInstruction, IAgentPluginMcpServerDefinition, IAgentPluginService } from './agentPluginService.js';
55
import { IMarketplacePlugin, IPluginMarketplaceService } from './pluginMarketplaceService.js';
56
57
// Re-export shared helpers so existing consumers (including tests) continue to work.
58
export { shellQuotePluginRootInCommand, resolveMcpServersMap, convertBareEnvVarsToVsCodeSyntax } from '../../../../../platform/agentPlugins/common/pluginParsers.js';
59
60
/**
61
* Converts platform-layer parsed hook groups to the workbench's {@link IAgentPluginHook} type.
62
* The canonical type strings from the platform layer map directly to {@link HookType} enum values.
63
*/
64
function toAgentPluginHooks(groups: readonly IParsedHookGroup[]): IAgentPluginHook[] {
65
return groups
66
.filter(g => Object.values(HookType).includes(g.type as HookType))
67
.map(g => ({
68
type: g.type as HookType,
69
hooks: g.commands,
70
uri: g.uri,
71
originalId: g.originalId,
72
}));
73
}
74
75
/** File suffixes accepted for rule/instruction files (longest first for correct name stripping). */
76
const RULE_FILE_SUFFIXES = ['.instructions.md', '.mdc', '.md'];
77
78
/**
79
* Resolves the workspace folder that contains the plugin URI for cwd resolution,
80
* falling back to the first workspace folder for plugins outside the workspace.
81
*/
82
function resolveWorkspaceRoot(pluginUri: URI, workspaceContextService: IWorkspaceContextService): URI | undefined {
83
const defaultFolder = workspaceContextService.getWorkspace().folders[0];
84
const folder = workspaceContextService.getWorkspaceFolder(pluginUri) ?? defaultFolder;
85
return folder?.uri;
86
}
87
88
export class AgentPluginService extends Disposable implements IAgentPluginService {
89
90
declare readonly _serviceBrand: undefined;
91
92
public readonly plugins: IObservable<readonly IAgentPlugin[]>;
93
public readonly enablementModel: IEnablementModel;
94
95
constructor(
96
@IInstantiationService instantiationService: IInstantiationService,
97
@IConfigurationService configurationService: IConfigurationService,
98
@IStorageService storageService: IStorageService,
99
) {
100
super();
101
102
this.enablementModel = this._register(new EnablementModel('agentPlugins.enablement', storageService));
103
104
const pluginsEnabled = observableConfigValue(ChatConfiguration.PluginsEnabled, true, configurationService);
105
106
const discoveries: IAgentPluginDiscovery[] = [];
107
for (const descriptor of agentPluginDiscoveryRegistry.getAll()) {
108
const discovery = instantiationService.createInstance(descriptor);
109
this._register(discovery);
110
discoveries.push(discovery);
111
discovery.start(this.enablementModel);
112
}
113
114
115
this.plugins = derived(read => {
116
if (!pluginsEnabled.read(read)) {
117
return [];
118
}
119
return this._dedupeAndSort(discoveries.flatMap(d => d.plugins.read(read)));
120
});
121
}
122
123
private _dedupeAndSort(plugins: readonly IAgentPlugin[]): readonly IAgentPlugin[] {
124
const unique: IAgentPlugin[] = [];
125
const seen = new ResourceSet();
126
127
for (const plugin of plugins) {
128
if (seen.has(plugin.uri)) {
129
continue;
130
}
131
132
seen.add(plugin.uri);
133
unique.push(plugin);
134
}
135
136
unique.sort((a, b) => a.uri.toString().localeCompare(b.uri.toString()));
137
return unique;
138
}
139
}
140
141
type PluginEntry = IAgentPlugin;
142
143
/**
144
* Describes a single discovered plugin source, before the shared
145
* infrastructure builds the full {@link IAgentPlugin} from it.
146
*/
147
interface IPluginSource {
148
readonly uri: URI;
149
readonly fromMarketplace: IMarketplacePlugin | undefined;
150
/** Called when remove is invoked on the plugin */
151
remove(): void;
152
}
153
154
/**
155
* Shared base class for plugin discovery implementations. Contains the common
156
* logic for reading plugin contents (commands, skills, agents, hooks, MCP server
157
* definitions) from the filesystem and watching for live updates.
158
*
159
* Subclasses implement {@link _discoverPluginSources} to determine *which*
160
* plugins exist, while this class handles the rest.
161
*/
162
export abstract class AbstractAgentPluginDiscovery extends Disposable implements IAgentPluginDiscovery {
163
164
private readonly _pluginEntries = new Map<string, { plugin: PluginEntry; store: DisposableStore; format: IPluginFormatConfig }>();
165
166
private readonly _plugins = observableValue<readonly IAgentPlugin[]>('discoveredAgentPlugins', []);
167
public readonly plugins: IObservable<readonly IAgentPlugin[]> = this._plugins;
168
169
private _discoverVersion = 0;
170
protected _enablementModel!: IEnablementModel;
171
172
constructor(
173
protected readonly _fileService: IFileService,
174
protected readonly _pathService: IPathService,
175
protected readonly _logService: ILogService,
176
protected readonly _workspaceContextService: IWorkspaceContextService,
177
) {
178
super();
179
}
180
181
public abstract start(enablementModel: IEnablementModel): void;
182
183
protected async _refreshPlugins(): Promise<void> {
184
const version = ++this._discoverVersion;
185
const plugins = await this._discoverAndBuildPlugins();
186
if (version !== this._discoverVersion || this._store.isDisposed) {
187
return;
188
}
189
190
this._plugins.set(plugins, undefined);
191
}
192
193
/** Subclasses return plugin sources to discover. */
194
protected abstract _discoverPluginSources(): Promise<readonly IPluginSource[]>;
195
196
private async _discoverAndBuildPlugins(): Promise<readonly IAgentPlugin[]> {
197
const sources = await this._discoverPluginSources();
198
const plugins: IAgentPlugin[] = [];
199
const seenPluginUris = new Set<string>();
200
201
for (const source of sources) {
202
const key = source.uri.toString();
203
if (!seenPluginUris.has(key)) {
204
seenPluginUris.add(key);
205
const format = await detectPluginFormat(source.uri, this._fileService);
206
plugins.push(this._toPlugin(source.uri, format, source.fromMarketplace, () => source.remove()));
207
}
208
}
209
210
this._disposePluginEntriesExcept(seenPluginUris);
211
212
plugins.sort((a, b) => a.uri.toString().localeCompare(b.uri.toString()));
213
return plugins;
214
}
215
216
protected async _pathExists(resource: URI): Promise<boolean> {
217
try {
218
await this._fileService.resolve(resource);
219
return true;
220
} catch {
221
return false;
222
}
223
}
224
225
private _toPlugin(uri: URI, format: IPluginFormatConfig, fromMarketplace: IMarketplacePlugin | undefined, removeCallback: () => void): IAgentPlugin {
226
const key = uri.toString();
227
const existing = this._pluginEntries.get(key);
228
if (existing) {
229
if (existing.format.format !== format.format) {
230
existing.store.dispose();
231
this._pluginEntries.delete(key);
232
} else {
233
return existing.plugin;
234
}
235
}
236
237
const store = new DisposableStore();
238
const enablement = derived(r => this._enablementModel.readEnabled(key, r));
239
240
// Track current component directories for the file watcher. These are
241
// updated whenever the manifest is read (inside each component reader).
242
const manifest = observableValue<Record<string, unknown> | undefined>('agentPluginManifest', undefined);
243
244
const observeComponent = <T>(
245
prop: string,
246
doRead: (uris: readonly URI[]) => Promise<readonly T[]>,
247
tryReadEmbedded?: (section: unknown) => Promise<T[] | undefined>,
248
defaultPath = prop,
249
): IObservable<readonly T[]> => {
250
const secondObs = derivedOpts({ equalsFn: equals }, reader => manifest.read(reader)?.[prop]);
251
252
const wrapped = derived(reader => {
253
const section = secondObs.read(reader);
254
if (tryReadEmbedded) {
255
if (section && typeof section === 'object' && !Array.isArray(section) && !(hasKey(section, { paths: true }))) {
256
return { kind: 'const', data: new ObservablePromise(tryReadEmbedded(section)) } as const;
257
}
258
}
259
260
const paths = parseComponentPathConfig(section);
261
const dirs = resolveComponentDirs(uri, defaultPath, paths);
262
for (const d of dirs) {
263
const watcher = this._fileService.createWatcher(d, { recursive: false, excludes: [] });
264
reader.store.add(watcher);
265
reader.store.add(watcher.onDidChange(() => changeTrigger.trigger(undefined)));
266
}
267
268
return { kind: 'dirs', dirs: dirs } as const;
269
});
270
271
const changeTrigger = observableSignal('fileChange');
272
273
const promised = derived(reader => {
274
const w = wrapped.read(reader);
275
if (w.kind === 'const') {
276
return w.data.promiseResult;
277
} else {
278
changeTrigger.read(reader); // re-run when a relevant file change occurs
279
const promise = new ObservablePromise(doRead(w.dirs));
280
return promise.promiseResult;
281
}
282
});
283
284
const result = promised.map((w, r) => w.read(r)?.data ?? Iterable.empty());
285
286
return result.recomputeInitiallyAndOnChange(store);
287
};
288
289
const manifestUri = joinPath(uri, format.manifestPath);
290
const commands = observeComponent('commands', d => readMarkdownComponents(d, this._fileService));
291
const skills = observeComponent('skills', d => readSkills(uri, d, this._fileService));
292
const agents = observeComponent('agents', d => readMarkdownComponents(d, this._fileService));
293
const instructions = observeComponent('rules', d => this._readRules(d));
294
const hooks = observeComponent(
295
'hooks',
296
paths => this._readHooksFromPaths(uri, paths, format),
297
async section => {
298
const userHome = (await this._pathService.userHome()).fsPath;
299
const workspaceRoot = resolveWorkspaceRoot(uri, this._workspaceContextService);
300
return toAgentPluginHooks(format.parseHooks(manifestUri, section, uri, workspaceRoot, userHome));
301
},
302
format.hookConfigPath,
303
);
304
305
const mcpServerDefinitions = observeComponent(
306
'mcpServers',
307
paths => this._readMcpDefinitionsFromPaths(paths, uri.fsPath, format),
308
async section => parseMcpServerDefinitionMap(manifestUri, { mcpServers: section }, uri.fsPath, format),
309
'.mcp.json',
310
);
311
312
// Read the manifest initially and re-read whenever manifest files change.
313
const readManifest = async () => {
314
manifest.set(await this._readManifest(uri, format), undefined);
315
};
316
317
const manifestWatcher = this._fileService.createWatcher(
318
manifestUri,
319
{ recursive: false, excludes: [] },
320
);
321
store.add(manifestWatcher);
322
store.add(manifestWatcher.onDidChange(() => readManifest()));
323
324
readManifest();
325
326
const plugin: PluginEntry = {
327
uri,
328
label: fromMarketplace?.name ?? basename(uri),
329
enablement,
330
remove: removeCallback,
331
hooks,
332
commands,
333
skills,
334
agents,
335
instructions,
336
mcpServerDefinitions,
337
fromMarketplace,
338
};
339
340
this._pluginEntries.set(key, { store, plugin, format });
341
342
return plugin;
343
}
344
345
private async _readManifest(pluginUri: URI, format: IPluginFormatConfig): Promise<Record<string, unknown> | undefined> {
346
const json = await this._readJsonFile(joinPath(pluginUri, format.manifestPath));
347
if (json && typeof json === 'object') {
348
return json as Record<string, unknown>;
349
}
350
return undefined;
351
}
352
353
/**
354
* Reads hook definitions from a list of resolved paths (JSON files).
355
* Each path is tried in order; the first one that contains valid hook
356
* JSON is used.
357
*/
358
private async _readHooksFromPaths(pluginUri: URI, paths: readonly URI[], format: IPluginFormatConfig): Promise<readonly IAgentPluginHook[]> {
359
const userHome = (await this._pathService.userHome()).fsPath;
360
const workspaceRoot = resolveWorkspaceRoot(pluginUri, this._workspaceContextService);
361
for (const hookPath of paths) {
362
const json = await this._readJsonFile(hookPath);
363
if (json) {
364
try {
365
return toAgentPluginHooks(format.parseHooks(hookPath, json, pluginUri, workspaceRoot, userHome));
366
} catch (e) {
367
this._logService.info(`[AgentPluginDiscovery] Failed to parse hooks from ${hookPath.toString()}:`, e);
368
}
369
}
370
}
371
return [];
372
}
373
374
/**
375
* Reads MCP server definitions from a list of resolved paths (JSON files).
376
* Definitions from all files are merged; the first definition for a given
377
* server name wins.
378
*/
379
private async _readMcpDefinitionsFromPaths(paths: readonly URI[], pluginFsPath: string, format: IPluginFormatConfig): Promise<readonly IAgentPluginMcpServerDefinition[]> {
380
const merged = new Map<string, IAgentPluginMcpServerDefinition>();
381
for (const mcpPath of paths) {
382
const json = await this._readJsonFile(mcpPath);
383
for (const def of parseMcpServerDefinitionMap(mcpPath, json, pluginFsPath, format)) {
384
if (!merged.has(def.name)) {
385
merged.set(def.name, def);
386
}
387
}
388
}
389
return [...merged.values()].sort((a, b) => a.name.localeCompare(b.name));
390
}
391
392
private async _readJsonFile(uri: URI): Promise<unknown | undefined> {
393
try {
394
const fileContents = await this._fileService.readFile(uri);
395
return parseJSONC(fileContents.value.toString());
396
} catch {
397
return undefined;
398
}
399
}
400
401
/**
402
* Scans directories for rule/instruction files (`.mdc`, `.md`,
403
* `.instructions.md`), returning `{ uri, name }` entries where name is
404
* derived from the filename minus the matched suffix.
405
*/
406
private async _readRules(dirs: readonly URI[]): Promise<readonly IAgentPluginInstruction[]> {
407
const seen = new Set<string>();
408
const items: IAgentPluginInstruction[] = [];
409
410
const matchSuffix = (filename: string): string | undefined => {
411
const lower = filename.toLowerCase();
412
return RULE_FILE_SUFFIXES.find(s => lower.endsWith(s));
413
};
414
415
const addItem = (name: string, uri: URI) => {
416
if (!seen.has(name)) {
417
seen.add(name);
418
items.push({ uri, name });
419
}
420
};
421
422
for (const dir of dirs) {
423
let stat;
424
try {
425
stat = await this._fileService.resolve(dir);
426
} catch {
427
continue;
428
}
429
430
if (stat.isFile) {
431
const suffix = matchSuffix(basename(dir));
432
if (suffix) {
433
addItem(basename(dir).slice(0, -suffix.length), dir);
434
}
435
continue;
436
}
437
438
if (!stat.isDirectory || !stat.children) {
439
continue;
440
}
441
442
for (const child of stat.children) {
443
if (!child.isFile) {
444
continue;
445
}
446
const suffix = matchSuffix(child.name);
447
if (suffix) {
448
addItem(child.name.slice(0, -suffix.length), child.resource);
449
}
450
}
451
}
452
453
items.sort((a, b) => a.name.localeCompare(b.name));
454
return items;
455
}
456
457
private _disposePluginEntriesExcept(keep: Set<string>): void {
458
for (const [key, entry] of this._pluginEntries) {
459
if (!keep.has(key)) {
460
entry.store.dispose();
461
this._pluginEntries.delete(key);
462
}
463
}
464
}
465
466
public override dispose(): void {
467
this._disposePluginEntriesExcept(new Set<string>());
468
super.dispose();
469
}
470
}
471
472
export class ConfiguredAgentPluginDiscovery extends AbstractAgentPluginDiscovery {
473
474
private readonly _pluginLocationsConfig: IObservable<Record<string, boolean>>;
475
476
constructor(
477
@IConfigurationService private readonly _configurationService: IConfigurationService,
478
@IFileService fileService: IFileService,
479
@IPluginMarketplaceService private readonly _pluginMarketplaceService: IPluginMarketplaceService,
480
@IWorkspaceContextService workspaceContextService: IWorkspaceContextService,
481
@IPathService pathService: IPathService,
482
@ILogService logService: ILogService,
483
) {
484
super(fileService, pathService, logService, workspaceContextService);
485
this._pluginLocationsConfig = observableConfigValue<Record<string, boolean>>(ChatConfiguration.PluginLocations, {}, _configurationService);
486
}
487
488
public override start(enablementModel: IEnablementModel): void {
489
this._enablementModel = enablementModel;
490
const scheduler = this._register(new RunOnceScheduler(() => this._refreshPlugins(), 0));
491
this._register(autorun(reader => {
492
this._pluginLocationsConfig.read(reader);
493
scheduler.schedule();
494
}));
495
scheduler.schedule();
496
}
497
498
protected override async _discoverPluginSources(): Promise<readonly IPluginSource[]> {
499
const sources: IPluginSource[] = [];
500
const config = this._pluginLocationsConfig.get();
501
const userHome = await this._getUserHome();
502
503
for (const [path, enabled] of Object.entries(config)) {
504
if (!path.trim() || enabled === false) {
505
continue;
506
}
507
508
const resources = this._resolvePluginPath(path.trim(), userHome);
509
for (const resource of resources) {
510
let stat;
511
try {
512
stat = await this._fileService.resolve(resource);
513
} catch {
514
this._logService.debug(`[ConfiguredAgentPluginDiscovery] Could not resolve plugin path: ${resource.toString()}`);
515
continue;
516
}
517
518
if (!stat.isDirectory) {
519
this._logService.debug(`[ConfiguredAgentPluginDiscovery] Plugin path is not a directory: ${resource.toString()}`);
520
continue;
521
}
522
523
const fromMarketplace = this._pluginMarketplaceService.getMarketplacePluginMetadata(stat.resource);
524
const configKey = path;
525
sources.push({
526
uri: stat.resource,
527
fromMarketplace,
528
remove: () => this._removePluginPath(configKey),
529
});
530
}
531
}
532
533
return sources;
534
}
535
536
private async _getUserHome(): Promise<string> {
537
const userHome = await this._pathService.userHome();
538
return userHome.scheme === 'file' ? userHome.fsPath : userHome.path;
539
}
540
541
/**
542
* Resolves a plugin path to one or more resource URIs. Supports:
543
* - Absolute paths (used directly)
544
* - Tilde paths (expanded to user home directory)
545
* - Relative paths (resolved against each workspace folder)
546
*/
547
private _resolvePluginPath(path: string, userHome: string): URI[] {
548
if (path.startsWith('~')) {
549
path = untildify(path, userHome);
550
}
551
552
// Handle absolute paths
553
if (win32.isAbsolute(path) || posix.isAbsolute(path)) {
554
return [URI.file(path)];
555
}
556
557
return this._workspaceContextService.getWorkspace().folders.map(
558
folder => joinPath(folder.uri, path)
559
);
560
}
561
562
/**
563
* Removes a plugin path from `chat.pluginLocations` in the most specific
564
* config target where the key is defined.
565
*/
566
private _removePluginPath(configKey: string): void {
567
const inspected = this._configurationService.inspect<Record<string, boolean>>(ChatConfiguration.PluginLocations);
568
569
const targets = [
570
ConfigurationTarget.WORKSPACE_FOLDER,
571
ConfigurationTarget.WORKSPACE,
572
ConfigurationTarget.USER_LOCAL,
573
ConfigurationTarget.USER_REMOTE,
574
ConfigurationTarget.USER,
575
ConfigurationTarget.APPLICATION,
576
];
577
578
for (const target of targets) {
579
const mapping = getConfigValueInTarget(inspected, target);
580
if (mapping && Object.prototype.hasOwnProperty.call(mapping, configKey)) {
581
const updated = { ...mapping };
582
delete updated[configKey];
583
this._configurationService.updateValue(
584
ChatConfiguration.PluginLocations,
585
updated,
586
target,
587
);
588
return;
589
}
590
}
591
}
592
}
593
594
export class MarketplaceAgentPluginDiscovery extends AbstractAgentPluginDiscovery {
595
596
constructor(
597
@IPluginMarketplaceService private readonly _pluginMarketplaceService: IPluginMarketplaceService,
598
@IAgentPluginRepositoryService private readonly _pluginRepositoryService: IAgentPluginRepositoryService,
599
@IFileService fileService: IFileService,
600
@IPathService pathService: IPathService,
601
@ILogService logService: ILogService,
602
@IWorkspaceContextService workspaceContextService: IWorkspaceContextService,
603
) {
604
super(fileService, pathService, logService, workspaceContextService);
605
}
606
607
public override start(enablementModel: IEnablementModel): void {
608
this._enablementModel = enablementModel;
609
const scheduler = this._register(new RunOnceScheduler(() => this._refreshPlugins(), 0));
610
this._register(autorun(reader => {
611
this._pluginMarketplaceService.installedPlugins.read(reader);
612
scheduler.schedule();
613
}));
614
scheduler.schedule();
615
}
616
617
protected override async _discoverPluginSources(): Promise<readonly IPluginSource[]> {
618
const installed = this._pluginMarketplaceService.installedPlugins.get();
619
const sources: IPluginSource[] = [];
620
621
for (const entry of installed) {
622
let stat;
623
try {
624
stat = await this._fileService.resolve(entry.pluginUri);
625
} catch {
626
this._logService.debug(`[MarketplaceAgentPluginDiscovery] Could not resolve installed plugin: ${entry.pluginUri.toString()}`);
627
continue;
628
}
629
630
if (!stat.isDirectory) {
631
this._logService.debug(`[MarketplaceAgentPluginDiscovery] Installed plugin path is not a directory: ${entry.pluginUri.toString()}`);
632
continue;
633
}
634
635
sources.push({
636
uri: stat.resource,
637
fromMarketplace: entry.plugin,
638
remove: () => {
639
this._enablementModel.remove(stat.resource.toString());
640
this._pluginMarketplaceService.removeInstalledPlugin(entry.pluginUri);
641
642
// Pass remaining installed descriptors so the repository service
643
// can skip deletion when other plugins share the same cache dir.
644
const remaining = this._pluginMarketplaceService.installedPlugins.get();
645
this._pluginRepositoryService.cleanupPluginSource(
646
entry.plugin,
647
remaining.map(e => e.plugin.sourceDescriptor),
648
).catch(error => {
649
this._logService.error('[MarketplaceAgentPluginDiscovery] Failed to clean up plugin source', error);
650
});
651
},
652
});
653
}
654
655
return sources;
656
}
657
}
658
659
// ---------------------------------------------------------------------------
660
// Extension-contributed plugin discovery
661
// ---------------------------------------------------------------------------
662
663
interface IRawChatPluginContribution {
664
readonly path: string;
665
readonly when?: string;
666
}
667
668
const epPlugins = extensionsRegistry.ExtensionsRegistry.registerExtensionPoint<IRawChatPluginContribution[]>({
669
extensionPoint: 'chatPlugins',
670
jsonSchema: {
671
description: localize('chatPlugins.schema.description', 'Contributes agent plugins for chat.'),
672
type: 'array',
673
items: {
674
additionalProperties: false,
675
type: 'object',
676
defaultSnippets: [{
677
body: {
678
path: './relative/path/to/plugin/',
679
}
680
}],
681
required: ['path'],
682
properties: {
683
path: {
684
description: localize('chatPlugins.property.path', 'Path to the agent plugin root directory relative to the extension root.'),
685
type: 'string'
686
},
687
when: {
688
description: localize('chatPlugins.property.when', '(Optional) A condition which must be true to enable this plugin.'),
689
type: 'string'
690
}
691
}
692
}
693
}
694
});
695
696
export class ExtensionAgentPluginDiscovery extends AbstractAgentPluginDiscovery {
697
698
private readonly _extensionPlugins = new Map<string, { uri: URI; when: ContextKeyExpression | undefined; extensionId: string }>();
699
private readonly _whenKeys = new Set<string>();
700
701
constructor(
702
@ICommandService private readonly _commandService: ICommandService,
703
@IContextKeyService private readonly _contextKeyService: IContextKeyService,
704
@IDialogService private readonly _dialogService: IDialogService,
705
@IFileService fileService: IFileService,
706
@IPathService pathService: IPathService,
707
@ILogService logService: ILogService,
708
@IWorkspaceContextService workspaceContextService: IWorkspaceContextService,
709
) {
710
super(fileService, pathService, logService, workspaceContextService);
711
}
712
713
public override start(enablementModel: IEnablementModel): void {
714
this._enablementModel = enablementModel;
715
const scheduler = this._register(new RunOnceScheduler(() => this._refreshPlugins(), 0));
716
this._register(this._contextKeyService.onDidChangeContext(e => {
717
if (e.affectsSome(this._whenKeys)) {
718
scheduler.schedule();
719
}
720
}));
721
epPlugins.setHandler((_extensions, delta) => {
722
for (const ext of delta.added) {
723
for (const raw of ext.value) {
724
if (!raw.path) {
725
ext.collector.error(localize('extension.plugin.missing.path', "Extension '{0}' cannot register a chatPlugins entry without a path.", ext.description.identifier.value));
726
continue;
727
}
728
const pluginUri = joinPath(ext.description.extensionLocation, raw.path);
729
if (!isEqualOrParent(pluginUri, ext.description.extensionLocation)) {
730
ext.collector.error(localize('extension.plugin.invalid.path', "Extension '{0}' chatPlugins entry '{1}' resolves outside the extension.", ext.description.identifier.value, raw.path));
731
continue;
732
}
733
let whenExpr: ContextKeyExpression | undefined;
734
if (raw.when) {
735
whenExpr = ContextKeyExpr.deserialize(raw.when);
736
if (!whenExpr) {
737
ext.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));
738
continue;
739
}
740
}
741
this._extensionPlugins.set(extensionPluginKey(ext.description.identifier, raw.path), { uri: pluginUri, when: whenExpr, extensionId: ext.description.identifier.value });
742
}
743
}
744
for (const ext of delta.removed) {
745
for (const raw of ext.value) {
746
this._extensionPlugins.delete(extensionPluginKey(ext.description.identifier, raw.path));
747
}
748
}
749
this._rebuildWhenKeys();
750
scheduler.schedule();
751
});
752
}
753
754
private _rebuildWhenKeys(): void {
755
this._whenKeys.clear();
756
for (const { when } of this._extensionPlugins.values()) {
757
if (when) {
758
for (const key of when.keys()) {
759
this._whenKeys.add(key);
760
}
761
}
762
}
763
}
764
765
protected override async _discoverPluginSources(): Promise<readonly IPluginSource[]> {
766
const sources: IPluginSource[] = [];
767
for (const [, entry] of this._extensionPlugins) {
768
if (entry.when && !this._contextKeyService.contextMatchesRules(entry.when)) {
769
continue;
770
}
771
let stat;
772
try {
773
stat = await this._fileService.resolve(entry.uri);
774
} catch {
775
this._logService.debug(`[ExtensionAgentPluginDiscovery] Could not resolve extension plugin path: ${entry.uri.toString()}`);
776
continue;
777
}
778
if (!stat.isDirectory) {
779
this._logService.debug(`[ExtensionAgentPluginDiscovery] Extension plugin path is not a directory: ${entry.uri.toString()}`);
780
continue;
781
}
782
sources.push({
783
uri: stat.resource,
784
fromMarketplace: undefined,
785
remove: () => this._promptUninstallExtension(entry.extensionId),
786
});
787
}
788
return sources;
789
}
790
791
private async _promptUninstallExtension(extensionId: string): Promise<void> {
792
const { confirmed } = await this._dialogService.confirm({
793
message: localize('uninstallExtensionForPlugin', "This plugin is provided by the extension '{0}'. Do you want to uninstall the extension?", extensionId),
794
});
795
if (confirmed) {
796
await this._commandService.executeCommand('workbench.extensions.uninstallExtension', extensionId);
797
}
798
}
799
}
800
801
function extensionPluginKey(extensionId: ExtensionIdentifier, path: string): string {
802
return `${extensionId.value}/${path}`;
803
}
804
805
class ChatPluginsDataRenderer extends Disposable implements IExtensionFeatureTableRenderer {
806
readonly type = 'table' as const;
807
808
shouldRender(manifest: IExtensionManifest): boolean {
809
return !!manifest.contributes?.chatPlugins?.length;
810
}
811
812
render(manifest: IExtensionManifest): IRenderedData<ITableData> {
813
const contributions = manifest.contributes?.chatPlugins ?? [];
814
if (!contributions.length) {
815
return { data: { headers: [], rows: [] }, dispose: () => { } };
816
}
817
818
const headers = [
819
localize('chatPluginsPath', "Path"),
820
localize('chatPluginsWhen', "When"),
821
];
822
823
const rows: IRowData[][] = contributions.map(d => [
824
d.path,
825
d.when ?? '-',
826
]);
827
828
return {
829
data: { headers, rows },
830
dispose: () => { }
831
};
832
}
833
}
834
835
Registry.as<IExtensionFeaturesRegistry>(Extensions.ExtensionFeaturesRegistry).registerExtensionFeature({
836
id: 'chatPlugins',
837
label: localize('chatPlugins', "Chat Plugins"),
838
access: {
839
canToggle: false
840
},
841
renderer: new SyncDescriptor(ChatPluginsDataRenderer),
842
});
843
844