src/vs/platform/agentHost/node/copilot/sessionCustomizationDiscovery.ts

1122 LOC · 1063 covered · 59 uncovered · 208 ranges · 386 concepts · 41 introducers · 171 tests

File neighbourhood

The centred file is linked to every concept that introduces one of its ranges, every test that runs code from the file, and the gray connector concepts standing between those tests and the file's own introducer concepts. Undirected links join concepts to every file where they introduce source and concepts to the tests they introduce; arrows show specialization between the displayed concepts and bridge only concepts omitted from this view. Concept colors match the source ranges below; connector concepts have no source color and are shown in gray.

Focused file, its introducer and connector concepts, their introduced files, and tests that run code from the file

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 related-file, concept, and source links on this page.

Graph controls are ready.

Interactive rendering requires JavaScript and WebGL. Use the related-file, concept, and source links on this page while the interactive map is unavailable.

1 > /*--------------------------------------------------------------------------------------------- copilotAgent.ts ×185
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 type { CopilotClient } from '@github/copilot-sdk';
7 > import { appendFile, mkdir } from 'fs/promises';
8 > import { CancellationToken } from '../../../../base/common/cancellation.js';
9 > import { CancellationError } from '../../../../base/common/errors.js';
10 > import { Emitter, Event } from '../../../../base/common/event.js';
11 > import { Disposable, type IDisposable } from '../../../../base/common/lifecycle.js';
12 > import { ResourceMap, ResourceSet } from '../../../../base/common/map.js';
13 > import { joinPath, dirname as uriDirname, extUriBiasedIgnorePathCase } from '../../../../base/common/resources.js';
14 > import { compare as compareStrings } from '../../../../base/common/strings.js';
15 > import { URI } from '../../../../base/common/uri.js';
16 > import { basename, isAbsolute, dirname as nodeDirname } from '../../../../base/common/path.js';
17 > import { IFileService, IFileStatWithMetadata } from '../../../files/common/files.js';
18 > import { ILogService } from '../../../log/common/log.js';
19 > import type { AgentsDiscoverRequest, InstructionSource } from './copilotRCP.js';
20 > import { AgentCustomization, ChildCustomization, CustomizationLoadStatus, CustomizationType, DirectoryCustomization, HookCustomization, RuleCustomization, SkillCustomization, customizationId } from '../../common/state/sessionState.js';
21 > import { ChildCustomizationType } from '../../common/state/protocol/state.js';
22 > import { toAgentCustomizationMeta } from '../../common/meta/agentCustomizationMeta.js';
23 > import { raceCancellationError } from '../../../../base/common/async.js';
24 >
25 > /**
26 > * The kinds of customizations the agent host discovers from disk.
27 > *
28 > * Re-declared on the platform side so this module has no dependency on the
29 > * workbench-side `PromptsType` enum.
30 > */
31 > export const enum DiscoveredType {
32 > Agent = 'agent',
33 > Skill = 'skill',
34 > Instruction = 'instruction',
35 > Hook = 'hook',
36 > AgentInstruction = 'agentInstruction',
37 > }
38 >
39 > export interface IDiscoveredDirectory {
40 > readonly uri: URI;
41 > readonly type: DiscoveredType;
42 > readonly name: string;
43 > readonly writable: boolean;
44 > readonly files: readonly IDiscoveredFile[];
45 > }
46 >
47 > export interface IDiscoveredFile {
48 > readonly uri: URI;
49 > readonly etag: string;
50 > }
51 >
52 > export function areDiscoveredDirectoriesEqual(a: readonly IDiscoveredDirectory[], b: readonly IDiscoveredDirectory[]): boolean {
53 > if (a.length !== b.length) { sessionCustomizationDiscovery.ts ×6
54 return false;
55 }
57 > for (let i = 0; i < a.length; i++) {
58 > const left = a[i];
59 > const right = b[i];
60 > if (left.type !== right.type || left.uri.toString() !== right.uri.toString() || !areDiscoveredFilesEqual(left.files, right.files)) {
61 > return false; copilotAgent.ts ×2
62 > }
65 > return true;
66 > }
68 > function compareDiscoveredDirectory(a: IDiscoveredDirectory, b: IDiscoveredDirectory): number { sessionCustomizationDiscovery.ts ×16
69 > const byType = compareStrings(a.type, b.type);
70 > if (byType !== 0) {
72 > }
73 > return compareStrings(a.uri.toString(), b.uri.toString()); sessionCustomizationDiscovery.ts ×16
74 > }
76 > function areDiscoveredFilesEqual(a: readonly IDiscoveredFile[], b: readonly IDiscoveredFile[]): boolean { sessionCustomizationDiscovery.ts ×6
77 > if (a.length !== b.length) {
78 > return false; copilotAgent.ts ×2
79 > }
81 > for (let i = 0; i < a.length; i++) {
82 > const left = a[i]; sessionCustomizationDiscovery.ts ×3
83 > const right = b[i];
84 > if (left.uri.toString() !== right.uri.toString() || left.etag !== right.etag) {
85 return false;
86 }
89 > return true;
90 > }
92 > function compareDiscoveredFile(a: IDiscoveredFile, b: IDiscoveredFile): number { sessionCustomizationDiscovery.ts ×1
93 > return compareStrings(a.uri.toString(), b.uri.toString());
94 > }
96 > function compareDirectoryCustomization(a: DirectoryCustomization, b: DirectoryCustomization): number { sessionCustomizationDiscovery.ts ×37
97 > const byUri = compareStrings(a.uri, b.uri);
98 > if (byUri !== 0) {
99 > return byUri;
100 > }
101 return compareStrings(a.contents, b.contents);
102 }
104 > /**
105 > * Maximum recursion depth when traversing subdirectories for instruction files.
106 > */
107 > const MAX_INSTRUCTIONS_RECURSION_DEPTH = 5;
108 > const MAX_HOOKS_RECURSION_DEPTH = 8;
109 >
110 > const AGENT_FILE_SUFFIX = '.agent.md';
111 > const MARKDOWN_SUFFIX = '.md';
112 > const INSTRUCTION_FILE_SUFFIX = '.instructions.md';
113 > const HOOK_FILE_SUFFIX = '.json';
114 > const SKILL_FILENAME = 'SKILL.md';
115 > const README_FILENAME = 'README.md';
116 > const CUSTOMIZATION_DISCOVERY_DEBUG_LOG_PATH = undefined; //'/tmp/copilot-customization-discovery-debug.log';
117 > const AGENT_INSTRUCTION_FILENAMES = new Set(['agents.md', 'claude.md', 'gemini.md', 'copilot-instructions.md']);
118 >
119 > interface ISearchRoot {
120 > readonly path: readonly string[];
121 > readonly type: DiscoveredType;
122 > readonly recursive?: boolean; // whether to watch recursively for changes (defaults to false)
123 > readonly name: string;
124 > }
125 >
126 > interface IFixedDiscoveryFile {
127 > readonly path: readonly string[];
128 > readonly filenames: string[];
129 > readonly type: DiscoveredType;
130 > }
131 >
132 > type PathToUri = (path: string) => URI;
133 >
134 > /**
135 > * Builds the list of search roots for a given working directory and user home.
136 > * Skills require a depth-2 scan (`<skillDir>/SKILL.md`), agents are scanned at
137 > * a single directory depth, and instructions/hooks are recursively scanned.
138 > */
139 > const searchRoots: { workspace: ISearchRoot[]; user: ISearchRoot[] } = {
140 > workspace: [
141 > { path: ['.github', 'agents'], type: DiscoveredType.Agent, name: '.github' },
142 > { path: ['.claude', 'agents'], type: DiscoveredType.Agent, name: '.claude' },
143 > { path: ['.github', 'skills'], recursive: true, type: DiscoveredType.Skill, name: '.github' },
144 > { path: ['.agents', 'skills'], recursive: true, type: DiscoveredType.Skill, name: '.agents' },
145 > { path: ['.claude', 'skills'], recursive: true, type: DiscoveredType.Skill, name: '.claude' },
146 > { path: ['.github', 'instructions'], recursive: true, type: DiscoveredType.Instruction, name: '.github' },
147 > { path: ['.github', 'hooks'], recursive: true, type: DiscoveredType.Hook, name: '.github' },
148 >
149 > ],
150 > user: [
151 > { path: ['.copilot', 'agents'], type: DiscoveredType.Agent, name: '~/.copilot' },
152 > { path: ['.agents', 'skills'], recursive: true, type: DiscoveredType.Skill, name: '~/.agents' },
153 > { path: ['.copilot', 'skills'], recursive: true, type: DiscoveredType.Skill, name: '~/.copilot' },
154 > { path: ['.copilot', 'instructions'], recursive: true, type: DiscoveredType.Instruction, name: '~/.copilot' },
155 > { path: ['.copilot', 'hooks'], recursive: true, type: DiscoveredType.Hook, name: '~/.copilot' },
156 > ],
157 > };
158 >
159 >
160 > /**
161 > * Builds the list of instruction file candidates used by the Copilot CLI.
162 > *
163 > * Returns paths with filenames for workspace and user-home
164 > * locations
165 > */
166 > const fixedDiscoveryFiles: { workspace: IFixedDiscoveryFile[]; user: IFixedDiscoveryFile[] } = {
167 > workspace: [
168 > { path: ['.github'], filenames: ['copilot-instructions.md'], type: DiscoveredType.AgentInstruction },
169 > { path: [], filenames: ['AGENTS.md', 'CLAUDE.md', 'GEMINI.md'], type: DiscoveredType.AgentInstruction },
170 > { path: ['.claude'], filenames: ['CLAUDE.md'], type: DiscoveredType.AgentInstruction },
171 > { path: ['.github', 'copilot'], filenames: ['settings.json', 'settings.local.json'], type: DiscoveredType.Hook },
172 > { path: ['.claude'], filenames: ['settings.json', 'settings.local.json'], type: DiscoveredType.Hook },
173 > ],
174 > user: [
175 > { path: ['.copilot'], filenames: ['copilot-instructions.md'], type: DiscoveredType.AgentInstruction },
176 > ],
177 > };
178 >
179 > // Back-compat alias for tests and callers that referenced the old symbol name.
180 > const agentInstructions = fixedDiscoveryFiles;
181 >
182 > function throwIfCancelled(token: CancellationToken): void { sessionCustomizationDiscovery.ts ×16
183 > if (token.isCancellationRequested) {
184 > throw new CancellationError(); sessionCustomizationDiscovery.ts ×1
185 > }
188 > interface IWatchSpec {
189 > readonly recursive: boolean;
190 > readonly resourcesToWatch: ResourceSet;
191 > }
192 >
193 > /**
194 > * Register a watcher for `watchUri` and add `resourceToWatch` to its set of
195 > * trigger URIs. If a non-recursive entry already exists and `recursive` is
196 > * true, upgrade it to recursive while preserving the accumulated trigger URIs.
197 > */
198 > function addWatch(map: ResourceMap<IWatchSpec>, watchUri: URI, recursive: boolean, resourceToWatch: URI): void { sessionCustomizationDiscovery.ts ×6
199 > let entry = map.get(watchUri);
200 > if (!entry) {
201 > entry = { recursive, resourcesToWatch: new ResourceSet() };
202 > map.set(watchUri, entry);
203 > } else if (recursive && !entry.recursive) {
204 entry = { recursive: true, resourcesToWatch: entry.resourcesToWatch };
205 map.set(watchUri, entry);
206 }
207 > entry.resourcesToWatch.add(resourceToWatch); sessionCustomizationDiscovery.ts ×6
208 > }
210 > /**
211 > * Discovers customization files (agents, skills, instructions, and hooks)
212 > * under well-known directories of the session's working directory and the
213 > * user's home, and emits {@link onDidChange} when any of those directories
214 > * change on disk.
215 > *
216 > *
217 > * Workspace roots take precedence over user-home roots when the same URI is
218 > * discovered through multiple paths (de-duped by URI).
219 > */
220 > export class SessionCustomizationDiscovery extends Disposable {
221 >
222 > private readonly _onDidChange = this._register(new Emitter<void>());
223 > readonly onDidChange: Event<void> = this._onDidChange.event;
224 >
225 > private _discoveredDirectories: readonly IDiscoveredDirectory[] | undefined = undefined;
226 >
227 > private readonly _watchers = new ResourceMap<IWatchSpec & { readonly disposable: IDisposable }>();
228 >
229 > constructor(
230 > private readonly _workingDirectory: URI, sessionCustomizationDiscovery.ts ×16
231 > private readonly _userHome: URI,
232 > private readonly _pathToUri: PathToUri = URI.file,
233 > @IFileService private readonly _fileService: IFileService,
234 > @ILogService private readonly _logService: ILogService,
235 > ) {
236 > super();
237 > this._register({ dispose: () => this._disposeAllWatchers() });
238 > this._register(this._fileService.onDidFilesChange(e => {
239 > for (const watcher of this._watchers.values()) { sessionCustomizationDiscovery.ts ×2
240 > for (const uri of watcher.resourcesToWatch) {
241 > if (e.affects(uri)) {
242 > this._scheduleRefresh();
243 > return;
244 > }
245 > }
248 > }
250 > private _scheduleRefresh(): void {
251 > this._onDidChange.fire(); sessionCustomizationDiscovery.ts ×2
252 > }
254 > private async writeCustomizationDiscoveryDebugLog(payload: Record<string, unknown>): Promise<void> {
255 > if (!CUSTOMIZATION_DISCOVERY_DEBUG_LOG_PATH) { sessionCustomizationDiscovery.ts ×16
256 > return;
257 > }
258
259 try {
260 await mkdir(nodeDirname(CUSTOMIZATION_DISCOVERY_DEBUG_LOG_PATH), { recursive: true });
261 await appendFile(CUSTOMIZATION_DISCOVERY_DEBUG_LOG_PATH, `${JSON.stringify({
262 timestamp: new Date().toISOString(),
263 ...payload,
264 }, undefined, 2)}\n`, 'utf8');
265 } catch (err) {
266 this._logService.error(`[SessionCustomizationDiscovery] Failed to write discovery debug log: ${err instanceof Error ? err.message : String(err)}`);
267 }
270 > private async getDiscoveredDirectories(client: CopilotClient, token: CancellationToken): Promise<readonly IDiscoveredDirectory[]> {
271 > throwIfCancelled(token); sessionCustomizationDiscovery.ts ×37
272 >
273 > const p: AgentsDiscoverRequest = { projectPaths: [this._workingDirectory.fsPath] };
274 > const result = this.getHooksDiscoveryPaths();
275 > const workspaceAgentInstructionFiles: IDiscoveredFile[] = [];
276 > const userAgentInstructionFiles: IDiscoveredFile[] = [];
277 >
278 > try {
279 > const [agentDiscovery, instructionDiscovery, skillDiscovery] = await Promise.all([
280 > raceCancellationError(client.rpc.agents.getDiscoveryPaths(p), token),
281 > raceCancellationError(client.rpc.instructions.getDiscoveryPaths(p), token),
282 > raceCancellationError(client.rpc.skills.getDiscoveryPaths(p), token)
283 > ]);
285 > // Process agent discovery paths
286 > for (const agentPath of agentDiscovery?.paths ?? []) { sessionCustomizationDiscovery.ts ×37
287 > throwIfCancelled(token); sessionCustomizationDiscovery.ts ×2
288 > result.push({
289 > uri: this._pathToUri(agentPath.path),
290 > type: DiscoveredType.Agent,
291 > files: [],
292 > name: basename(agentPath.path),
293 > writable: true
294 > });
295 > }
297 > // Process instruction discovery paths
298 > for (const instructionPath of instructionDiscovery?.paths ?? []) { sessionCustomizationDiscovery.ts ×37
299 > throwIfCancelled(token); sessionCustomizationDiscovery.ts ×18
300 > if (instructionPath.kind === 'file') {
301 > const fileUri = this._pathToUri(instructionPath.path);
302 > const discoveredFile: IDiscoveredFile = { uri: fileUri, etag: '' };
303 > if (extUriBiasedIgnorePathCase.isEqualOrParent(fileUri, this._workingDirectory)) {
304 > workspaceAgentInstructionFiles.push(discoveredFile); sessionCustomizationDiscovery.ts ×5
305 > } else if (extUriBiasedIgnorePathCase.isEqualOrParent(fileUri, this._userHome)) { sessionCustomizationDiscovery.ts ×18
306 > userAgentInstructionFiles.push(discoveredFile); sessionCustomizationDiscovery.ts ×3
307 > }
309 > } else if (instructionPath.kind === 'directory') {
311 > uri: this._pathToUri(instructionPath.path),
312 > type: DiscoveredType.Instruction,
313 > files: [],
314 > name: basename(instructionPath.path),
315 > writable: true
316 > });
317 > }
319 > if (workspaceAgentInstructionFiles.length > 0) { sessionCustomizationDiscovery.ts ×9
321 > uri: this._workingDirectory,
322 > type: DiscoveredType.AgentInstruction,
323 > files: workspaceAgentInstructionFiles,
324 > name: '',
325 > writable: false
326 > });
327 > }
328 > if (userAgentInstructionFiles.length > 0) { sessionCustomizationDiscovery.ts ×9
330 > uri: this._userHome,
331 > type: DiscoveredType.AgentInstruction,
332 > files: userAgentInstructionFiles,
333 > name: '',
334 > writable: false
335 > });
336 > }
338 > // Process skill discovery paths
339 > for (const skillPath of skillDiscovery?.paths ?? []) { sessionCustomizationDiscovery.ts ×37
340 > throwIfCancelled(token); sessionCustomizationDiscovery.ts ×2
341 > result.push({
342 > uri: this._pathToUri(skillPath.path),
343 > type: DiscoveredType.Skill,
344 > files: [],
345 > name: basename(skillPath.path),
346 > writable: true
347 > });
348 > }
351 > if (err instanceof CancellationError) { sessionCustomizationDiscovery.ts ×3
352 throw err;
353 }
354 > this._logService.debug(`[SessionCustomizationDiscovery] Error getting discovery paths: ${err instanceof Error ? err.message : String(err)}`); sessionCustomizationDiscovery.ts ×3
355 > }
357 > return result.sort(compareDiscoveredDirectory);
358 > }
360 > private getHooksDiscoveryPaths(): IDiscoveredDirectory[] {
361 > const byUri = new ResourceMap<IDiscoveredDirectory>(); sessionCustomizationDiscovery.ts ×37
362 > const add = (uri: URI, name: string): void => {
363 > if (!byUri.has(uri)) {
364 > byUri.set(uri, { uri, type: DiscoveredType.Hook, files: [], name, writable: true });
365 > }
366 > };
367 >
368 > for (const root of searchRoots.workspace) {
369 > if (root.type === DiscoveredType.Hook) {
370 > add(joinPath(this._workingDirectory, ...root.path), root.name);
371 > }
372 > }
373 > for (const root of searchRoots.user) {
374 > if (root.type === DiscoveredType.Hook) {
375 > add(joinPath(this._userHome, ...root.path), root.name);
376 > }
377 > }
378 > for (const root of fixedDiscoveryFiles.workspace) {
379 > if (root.type === DiscoveredType.Hook) {
380 > add(joinPath(this._workingDirectory, ...root.path), basename(joinPath(this._workingDirectory, ...root.path).path));
381 > }
382 > }
383 > for (const root of fixedDiscoveryFiles.user) {
384 > if (root.type === DiscoveredType.Hook) {
385 add(joinPath(this._userHome, ...root.path), basename(joinPath(this._userHome, ...root.path).path));
386 }
388 > return [...byUri.values()];
389 > }
391 > private async _updateWatchers(discoveredDirectories: readonly IDiscoveredDirectory[], token: CancellationToken): Promise<void> {
392 > const nextWatchRootUris = new ResourceMap<IWatchSpec>(); sessionCustomizationDiscovery.ts ×37
393 > const toResolve = new ResourceSet();
394 > const recursiveByDirectory = new ResourceMap<boolean>();
395 >
396 > for (const discoveredDir of discoveredDirectories) {
397 > throwIfCancelled(token);
398 >
399 > const dirUri = discoveredDir.uri;
400 > const recursive = discoveredDir.type === DiscoveredType.Skill ||
401 > discoveredDir.type === DiscoveredType.Instruction ||
402 > discoveredDir.type === DiscoveredType.Hook;
403 > recursiveByDirectory.set(dirUri, recursive);
404 > toResolve.add(dirUri);
405 >
406 > let current = dirUri;
407 > while (!extUriBiasedIgnorePathCase.isEqual(current, this._workingDirectory) && !extUriBiasedIgnorePathCase.isEqual(current, this._userHome)) {
408 > const parent = uriDirname(current);
409 > if (extUriBiasedIgnorePathCase.isEqual(parent, current)) {
410 break;
411 }
412 > toResolve.add(parent); sessionCustomizationDiscovery.ts ×37
413 > current = parent;
414 > }
415 >
416 > for (const file of discoveredDir.files) {
417 > throwIfCancelled(token); sessionCustomizationDiscovery.ts ×18
418 >
419 > let currentFilePath = file.uri;
420 > while (!extUriBiasedIgnorePathCase.isEqual(currentFilePath, this._workingDirectory) && !extUriBiasedIgnorePathCase.isEqual(currentFilePath, this._userHome)) {
421 > const parent = uriDirname(currentFilePath);
422 > if (extUriBiasedIgnorePathCase.isEqual(parent, currentFilePath)) {
423 break;
424 }
425 > toResolve.add(parent); sessionCustomizationDiscovery.ts ×18
426 > currentFilePath = parent;
427 > }
428 > }
430 >
431 > throwIfCancelled(token);
432 >
433 > const toResolveArray = [...toResolve];
434 > const statResults = await this._fileService.resolveAll(toResolveArray.map(resource => ({ resource })));
435 > const existingDirectories = new ResourceSet();
436 > for (let i = 0; i < statResults.length; i++) {
437 > const result = statResults[i];
438 > if (result.success && result.stat?.isDirectory) {
439 > existingDirectories.add(toResolveArray[i]); sessionCustomizationDiscovery.ts ×9
440 > }
442 >
443 > for (const discoveredDir of discoveredDirectories) {
444 > throwIfCancelled(token);
445 >
446 > const dirUri = discoveredDir.uri;
447 > const recursive = recursiveByDirectory.get(dirUri) ?? false;
448 > if (existingDirectories.has(dirUri)) {
449 > addWatch(nextWatchRootUris, dirUri, recursive, dirUri); sessionCustomizationDiscovery.ts ×9
450 > }
452 > let current = dirUri;
453 > while (!extUriBiasedIgnorePathCase.isEqual(current, this._workingDirectory) && !extUriBiasedIgnorePathCase.isEqual(current, this._userHome)) {
454 > const parent = uriDirname(current);
455 > if (extUriBiasedIgnorePathCase.isEqual(parent, current)) {
456 break;
457 }
458 > if (existingDirectories.has(parent)) { sessionCustomizationDiscovery.ts ×37
459 > addWatch(nextWatchRootUris, parent, false, current); sessionCustomizationDiscovery.ts ×9
460 > }
461 > current = parent; sessionCustomizationDiscovery.ts ×37
462 > }
463 >
464 > for (const file of discoveredDir.files) {
465 > throwIfCancelled(token); sessionCustomizationDiscovery.ts ×18
466 >
467 > let currentFilePath = file.uri;
468 > while (!extUriBiasedIgnorePathCase.isEqual(currentFilePath, this._workingDirectory) && !extUriBiasedIgnorePathCase.isEqual(currentFilePath, this._userHome)) {
469 > const parent = uriDirname(currentFilePath);
470 > if (extUriBiasedIgnorePathCase.isEqual(parent, currentFilePath)) {
471 break;
472 }
473 > if (existingDirectories.has(parent)) { sessionCustomizationDiscovery.ts ×18
474 > addWatch(nextWatchRootUris, parent, false, currentFilePath);
475 > }
476 > currentFilePath = parent;
477 > }
478 > }
480 >
481 > this._reconcileWatchers(nextWatchRootUris);
482 > }
484 >
485 > public async discover(client: CopilotClient, token: CancellationToken): Promise<readonly DirectoryCustomization[]> {
486 > await this.writeCustomizationDiscoveryDebugLog({ sessionCustomizationDiscovery.ts ×37
487 > method: 'discover',
488 > workingDirectory: this._workingDirectory.toString(),
489 > userHome: this._userHome.toString(),
490 > });
491 > if (!this._discoveredDirectories) {
492 > this._discoveredDirectories = await this.getDiscoveredDirectories(client, token);
493 > }
494 >
495 > throwIfCancelled(token);
496 >
497 > const p: AgentsDiscoverRequest = { projectPaths: [this._workingDirectory.fsPath] };
498 >
499 > try {
500 > const [agents, rules, skills, hooks] = await Promise.all([
501 > this.discoverAgents(p, client, token),
502 > this.discoverRules(p, client, token),
503 > this.discoverSkills(p, client, token),
504 > this.discoverHooks(token),
505 > this._updateWatchers(this._discoveredDirectories, token)
506 > ]);
507 > throwIfCancelled(token);
508 > const result: DirectoryCustomization[] = [];
509 > await this.toDirectoryCustomizations(CustomizationType.Agent, agents, this._discoveredDirectories, result);
510 > await this.toDirectoryCustomizations(CustomizationType.Rule, rules, this._discoveredDirectories, result);
511 > await this.toDirectoryCustomizations(CustomizationType.Skill, skills, this._discoveredDirectories, result);
512 > await this.toDirectoryCustomizations(CustomizationType.Hook, hooks, this._discoveredDirectories, result);
513 > const sortedResult = result.sort(compareDirectoryCustomization);
514 > await this.writeCustomizationDiscoveryDebugLog({
515 > method: 'discover',
516 > result: sortedResult.map(customization => ({
517 > contents: customization.contents,
518 > uri: customization.uri,
519 > children: (customization.children ?? []).map(child => ({ type: child.type, uri: child.uri, name: child.name })),
520 > })),
521 > });
522 > return sortedResult;
523 > } catch (err) {
524 this._logService.error(`[SessionCustomizationDiscovery] Error during discovery: ${err instanceof Error ? err.message : String(err)}`);
525 return [];
526 }
529 > private async discoverAgents(discoveryRequest: AgentsDiscoverRequest, client: CopilotClient, token: CancellationToken): Promise<AgentCustomization[]> {
530 > const agents: AgentCustomization[] = []; sessionCustomizationDiscovery.ts ×37
531 >
532 > const agentDiscovery = await raceCancellationError(client.rpc.agents.discover(discoveryRequest), token);
533 > for (const agent of agentDiscovery.agents) {
534 > if (agent.path) { sessionCustomizationDiscovery.ts ×1
535 > const uri = this._pathToUri(agent.path);
536 > agents.push({ type: CustomizationType.Agent, uri: uri.toString(), id: agent.id, name: agent.name, description: agent.description, _meta: toAgentCustomizationMeta({ userInvocable: agent.userInvocable }) });
537 > }
538 > }
540 > }
542 > private async discoverRules(discoveryRequest: AgentsDiscoverRequest, client: CopilotClient, token: CancellationToken): Promise<RuleCustomization[]> {
543 > const rules: RuleCustomization[] = []; sessionCustomizationDiscovery.ts ×37
544 > const seenRuleUris = new Set<string>();
545 >
546 > const instructionDiscovery = await raceCancellationError(client.rpc.instructions.discover(discoveryRequest), token);
547 > await this.writeCustomizationDiscoveryDebugLog({
548 > method: 'discoverRules.instructions.discover',
549 > sources: instructionDiscovery.sources.map(source => ({
551 > label: source.label,
552 > sourcePath: source.sourcePath,
553 > applyTo: source.applyTo,
554 > type: source.type,
556 > });
557 >
558 > for (const instruction of instructionDiscovery.sources) {
560 > if (isAbsolute(instruction.sourcePath)) {
561 > uri = this._pathToUri(instruction.sourcePath);
562 > } else {
563 uri = joinPath(this._workingDirectory, instruction.sourcePath);
564 }
565 > const uriString = uri.toString(); sessionCustomizationDiscovery.ts ×18
566 > rules.push({
567 > type: CustomizationType.Rule,
568 > uri: uriString,
569 > id: instruction.id,
570 > name: instruction.label,
571 > description: instruction.description,
572 > globs: instruction.applyTo ? [...instruction.applyTo] : undefined,
573 > alwaysApply: this._isAgentInstructionSource(instruction),
574 > });
575 > seenRuleUris.add(uriString);
576 > }
578 > for (const directory of this._discoveredDirectories ?? []) {
579 > if (directory.type !== DiscoveredType.AgentInstruction) {
580 > continue;
581 > }
583 > for (const file of directory.files) {
584 > const uri = file.uri.toString();
585 > if (seenRuleUris.has(uri)) {
587 > }
589 > rules.push({
590 > type: CustomizationType.Rule,
591 > uri,
592 > id: customizationId(uri),
593 > name: basename(file.uri.path),
594 > alwaysApply: true,
595 > });
596 > seenRuleUris.add(uri);
597 > }
600 > return rules;
601 > }
603 > private _isAgentInstructionSource(instruction: InstructionSource): boolean {
604 > if (instruction.type === 'home' || instruction.type === 'repo' || instruction.type === 'model') { sessionCustomizationDiscovery.ts ×18
606 > }
608 > const filename = basename(instruction.sourcePath).toLowerCase();
609 > return AGENT_INSTRUCTION_FILENAMES.has(filename);
612 > private async discoverSkills(discoveryRequest: AgentsDiscoverRequest, client: CopilotClient, token: CancellationToken): Promise<SkillCustomization[]> {
613 > const skills: SkillCustomization[] = []; sessionCustomizationDiscovery.ts ×37
614 >
615 > const skillDiscovery = await raceCancellationError(client.rpc.skills.discover(discoveryRequest), token);
616 > for (const skill of skillDiscovery.skills) {
617 > if (skill.path) { sessionCustomizationDiscovery.ts ×3
618 > const uri = this._pathToUri(skill.path);
619 > skills.push({ type: CustomizationType.Skill, uri: uri.toString(), id: skill.path, name: skill.name, description: skill.description });
620 > }
621 > }
623 > }
625 > private async discoverHooks(token: CancellationToken): Promise<HookCustomization[]> {
626 > const seen = new ResourceSet(); sessionCustomizationDiscovery.ts ×37
627 > const discoveredDirectories: IDiscoveredDirectory[] = [];
628 >
629 > const hookRootsWorkspace = searchRoots.workspace.filter(root => root.type === DiscoveredType.Hook);
630 > const hookRootsUser = searchRoots.user.filter(root => root.type === DiscoveredType.Hook);
631 > const fixedHookFilesWorkspace = fixedDiscoveryFiles.workspace.filter(root => root.type === DiscoveredType.Hook);
632 > const fixedHookFilesUser = fixedDiscoveryFiles.user.filter(root => root.type === DiscoveredType.Hook);
633 >
634 > await Promise.all([
635 > ...hookRootsWorkspace.map(root => this._discoverHookRoot(this._workingDirectory, root, seen, discoveredDirectories, token)),
636 > ...hookRootsUser.map(root => this._discoverHookRoot(this._userHome, root, seen, discoveredDirectories, token)),
637 > this._discoverFixedHookFiles(this._workingDirectory, fixedHookFilesWorkspace, seen, discoveredDirectories, token),
638 > this._discoverFixedHookFiles(this._userHome, fixedHookFilesUser, seen, discoveredDirectories, token),
639 > ]);
640 >
641 > const hooks: HookCustomization[] = [];
642 > for (const directory of discoveredDirectories) {
643 > for (const file of directory.files) {
644 > const uri = file.uri.toString(); sessionCustomizationDiscovery.ts ×3
645 > hooks.push({
646 > type: CustomizationType.Hook,
647 > id: customizationId(uri),
648 > uri,
649 > name: basename(file.uri.path),
650 > });
651 > }
653 > hooks.sort((a, b) => compareStrings(a.uri, b.uri));
654 > return hooks;
655 > }
657 > private async _discoverHookRoot(base: URI, root: ISearchRoot, seen: ResourceSet, result: IDiscoveredDirectory[], token: CancellationToken): Promise<void> {
658 > const rootUri = joinPath(base, ...root.path); sessionCustomizationDiscovery.ts ×37
659 > let stat: IFileStatWithMetadata | undefined = undefined;
660 > try {
661 > stat = await this._fileService.resolve(rootUri, { resolveMetadata: true });
662 > } catch {
663 > // Root does not exist (or is unreadable) — still discover as an empty source folder.
664 > }
665 > await this._scanForHooks(root, rootUri, stat, seen, result, token);
666 > }
668 > private async _discoverFixedHookFiles(base: URI, roots: readonly IFixedDiscoveryFile[], seen: ResourceSet, result: IDiscoveredDirectory[], token: CancellationToken): Promise<void> {
669 > for (const root of roots) { sessionCustomizationDiscovery.ts ×37
670 > throwIfCancelled(token);
671 >
672 > const rootUri = joinPath(base, ...root.path);
673 > const files: IDiscoveredFile[] = [];
674 > let stat: IFileStatWithMetadata | undefined = undefined;
675 > try {
676 > stat = await this._fileService.resolve(rootUri, { resolveMetadata: true });
677 > } catch {
678 > // Root does not exist (or is unreadable) — still discover as an empty source folder.
679 > }
680 >
681 > for (const child of stat?.children ?? []) {
682 > throwIfCancelled(token); sessionCustomizationDiscovery.ts ×3
683 >
684 > if (child.isFile && root.filenames.includes(child.name)) {
685 > if (!seen.has(child.resource)) {
686 > seen.add(child.resource);
687 > files.push({ uri: child.resource, etag: child.etag });
688 > }
689 > }
690 > }
691 > if (files.length > 0) { sessionCustomizationDiscovery.ts ×37
692 > result.push({ uri: rootUri, type: DiscoveredType.Hook, files: files.sort(compareDiscoveredFile), name: basename(rootUri.path), writable: true }); sessionCustomizationDiscovery.ts ×3
693 > }
695 > }
697 > private async toDirectoryCustomizations(type: ChildCustomizationType, customizations: readonly ChildCustomization[], allDiscoveredDirectories: readonly IDiscoveredDirectory[], result: DirectoryCustomization[]): Promise<void> {
698 > const discoveredDirectories = allDiscoveredDirectories.filter(d => { sessionCustomizationDiscovery.ts ×37
699 > if (type === CustomizationType.Agent) {
700 > return d.type === DiscoveredType.Agent;
701 > }
702 > if (type === CustomizationType.Rule) {
703 > return d.type === DiscoveredType.Instruction || d.type === DiscoveredType.AgentInstruction;
704 > }
705 > if (type === CustomizationType.Hook) {
706 > return d.type === DiscoveredType.Hook;
707 > }
708 > return d.type === DiscoveredType.Skill;
709 > });
710 > const candidateOutputDirectories = type === CustomizationType.Rule
711 > ? discoveredDirectories.filter(d => d.type !== DiscoveredType.AgentInstruction || extUriBiasedIgnorePathCase.isEqual(d.uri, this._workingDirectory) || extUriBiasedIgnorePathCase.isEqual(d.uri, this._userHome))
712 > : discoveredDirectories;
713 > const outputDirectories = type === CustomizationType.Skill
714 > ? candidateOutputDirectories.filter(directory => !candidateOutputDirectories.some(candidate =>
715 > !extUriBiasedIgnorePathCase.isEqual(directory.uri, candidate.uri) sessionCustomizationDiscovery.ts ×2
716 > && extUriBiasedIgnorePathCase.isEqualOrParent(directory.uri, candidate.uri) sessionCustomizationDiscovery.ts ×1
718 > : candidateOutputDirectories;
719 > const byParent = new ResourceMap<{ readonly uri: URI; readonly name: string; readonly writable: boolean; readonly children: ChildCustomization[] }>();
720 > for (const discoveredDirectory of outputDirectories) {
721 > byParent.set(discoveredDirectory.uri, {
722 > uri: discoveredDirectory.uri,
723 > name: discoveredDirectory.name || basename(discoveredDirectory.uri.path),
724 > writable: discoveredDirectory.writable,
725 > children: []
726 > });
727 > }
728 >
729 > const fixedHookDirectoryUris = type === CustomizationType.Hook
730 > ? new ResourceSet([
731 > ...fixedDiscoveryFiles.workspace
732 > .filter(root => root.type === DiscoveredType.Hook)
733 > .map(root => joinPath(this._workingDirectory, ...root.path)),
734 > ...fixedDiscoveryFiles.user
735 > .filter(root => root.type === DiscoveredType.Hook)
736 > .map(root => joinPath(this._userHome, ...root.path)),
737 > ])
738 > : undefined;
739 >
740 > const agentInstructionDirectoryUris = new ResourceSet(
741 > outputDirectories
742 > .filter(directory => directory.type === DiscoveredType.AgentInstruction)
743 > .map(directory => directory.uri)
744 > );
745 >
746 > for (const customization of customizations) {
747 > if (customization.type !== type) { sessionCustomizationDiscovery.ts ×5
748 continue;
749 }
751 > const childUri = URI.parse(customization.uri);
752 > let bestParent = outputDirectories.find(d => extUriBiasedIgnorePathCase.isEqualOrParent(childUri, d.uri));
753 > if (!bestParent && customization.type === CustomizationType.Rule && customization.alwaysApply && customization.name.match(/\.md$/i)) {
754 bestParent = outputDirectories.find(d =>
755 d.type === DiscoveredType.AgentInstruction && extUriBiasedIgnorePathCase.isEqualOrParent(childUri, d.uri)
756 ) ?? outputDirectories.find(d => d.type === DiscoveredType.AgentInstruction);
757 }
758 > if (bestParent) { sessionCustomizationDiscovery.ts ×5
759 > for (const candidate of outputDirectories) { sessionCustomizationDiscovery.ts ×2
760 > if (extUriBiasedIgnorePathCase.isEqualOrParent(childUri, candidate.uri) && candidate.uri.path.length > bestParent.uri.path.length) {
761 > bestParent = candidate; sessionCustomizationDiscovery.ts ×5
762 > }
764 > }
766 > const parentUri = bestParent?.uri ?? uriDirname(childUri);
767 > let entry = byParent.get(parentUri);
768 > if (!entry) {
769 > this._logService.error(`[SessionCustomizationDiscovery] BUG: customization '${customization.uri}' of type '${customization.type}' is outside discovered directories; creating fallback directory '${parentUri.toString()}'.`); sessionCustomizationDiscovery.ts ×3
770 > entry = {
771 > uri: parentUri,
772 > name: basename(parentUri.path),
773 > writable: true,
774 > children: []
775 > };
776 > byParent.set(parentUri, entry);
777 > }
778 > entry.children.push(customization); sessionCustomizationDiscovery.ts ×5
779 > }
781 > for (const { uri, name, writable, children } of byParent.values()) {
782 > if (type === CustomizationType.Hook && fixedHookDirectoryUris?.has(uri) && children.length === 0) {
783 > continue;
784 > }
785 >
786 > if (type === CustomizationType.Rule && agentInstructionDirectoryUris.has(uri)) {
787 > const existingChildren: ChildCustomization[] = []; sessionCustomizationDiscovery.ts ×18
788 > for (const child of children) {
789 > const childUri = URI.parse(child.uri);
790 > try {
791 > const stat = await this._fileService.resolve(childUri, { resolveMetadata: true });
792 > if (stat.isFile) { sessionCustomizationDiscovery.ts ×2
793 > existingChildren.push(child);
794 > }
796 > // Ignore missing agent-instruction files; they should not surface. sessionCustomizationDiscovery.ts ×2
797 > }
799 > if (existingChildren.length === 0) {
801 > }
802 > children.length = 0; sessionCustomizationDiscovery.ts ×2
803 > children.push(...existingChildren);
804 > }
806 > children.sort((a, b) => compareStrings(a.uri, b.uri));
807 > result.push({
808 > type: CustomizationType.Directory,
809 > id: customizationId(uri.toString()),
810 > uri: uri.toString(),
811 > name,
812 > enabled: true,
813 > contents: type,
814 > writable,
815 > load: { kind: CustomizationLoadStatus.Loaded },
816 > children,
817 > });
818 > }
819 > }
821 >
822 > /**
823 > * Returns the list of discovered customization directories and files in a sorted way.
824 > * Also sets up watchers for all discovered root directories (recursively if specified by the root or if already watching recursively).
825 > * Each call performs a fresh scan scoped to the provided cancellation token.
826 > */
827 > public async scan(token: CancellationToken): Promise<readonly IDiscoveredDirectory[]> {
828 > await this.writeCustomizationDiscoveryDebugLog({ sessionCustomizationDiscovery.ts ×16
829 > method: 'scan',
830 > workingDirectory: this._workingDirectory.toString(),
831 > userHome: this._userHome.toString(),
832 > });
833 > throwIfCancelled(token);
834 >
835 > const nextWatchRootUris = new ResourceMap<IWatchSpec>();
836 > const seen = new ResourceSet();
837 > const result: IDiscoveredDirectory[] = [];
838 >
839 > // Workspace first so it wins on URI conflicts.
840 > await Promise.all([
841 > ...searchRoots.workspace.map(root => this._scanRoot(this._workingDirectory, root, seen, result, nextWatchRootUris, token)),
842 > ...searchRoots.user.map(root => this._scanRoot(this._userHome, root, seen, result, nextWatchRootUris, token)),
843 > this._scanFixedDiscoveryFiles(this._workingDirectory, fixedDiscoveryFiles.workspace, seen, result, nextWatchRootUris, token),
844 > this._scanFixedDiscoveryFiles(this._userHome, fixedDiscoveryFiles.user, seen, result, nextWatchRootUris, token)
845 > ]);
846 >
847 > throwIfCancelled(token);
848 >
849 > this._reconcileWatchers(nextWatchRootUris);
850 > const sortedResult = result.sort(compareDiscoveredDirectory);
851 > await this.writeCustomizationDiscoveryDebugLog({
852 > method: 'scan',
853 > result: sortedResult.map(directory => ({
854 > type: directory.type,
855 > uri: directory.uri.toString(),
856 > files: directory.files.map(file => file.uri.toString()),
857 > })),
858 > });
859 > return sortedResult;
860 > }
862 > /**
863 > * Walk the ancestor chain of `path` from `base`. For every ancestor
864 > * directory that exists, register a non-recursive watcher whose trigger
865 > * URI is the next path segment, so the handler fires when an intermediate
866 > * directory (e.g. `.github`, `.github/agents`, `.copilot`) is created and
867 > * a re-scan is needed to pick up newly-discoverable content.
868 > *
869 > * Returns true when every ancestor exists as a directory (i.e. the leaf
870 > * may exist). Returns false when an ancestor is missing or not a directory,
871 > * in which case the caller can short-circuit.
872 > */
873 > private async _watchAncestors(base: URI, path: readonly string[], watchRootUris: ResourceMap<IWatchSpec>, token: CancellationToken): Promise<boolean> {
874 > let current = base; sessionCustomizationDiscovery.ts ×16
875 > for (const segment of path) {
876 > const parent = current;
877 > const child = joinPath(parent, segment);
878 > if (!watchRootUris.has(parent)) {
879 > throwIfCancelled(token);
880 > try {
881 > const stat = await this._fileService.resolve(parent);
882 > if (!stat.isDirectory) { sessionCustomizationDiscovery.ts ×3
883 return false;
884 }
886 > return false;
887 > }
888 > }
889 > addWatch(watchRootUris, parent, false, child); sessionCustomizationDiscovery.ts ×3
890 > current = child;
891 > }
893 > }
895 > private _reconcileWatchers(nextWatchRootUris: ResourceMap<IWatchSpec>): void {
896 > // Dispose watchers that are gone or whose recursive flag changed. sessionCustomizationDiscovery.ts ×16
897 > for (const [rootUri, watcher] of this._watchers.entries()) {
898 > const next = nextWatchRootUris.get(rootUri); sessionCustomizationDiscovery.ts ×3
899 > if (!next || next.recursive !== watcher.recursive) {
900 watcher.disposable.dispose();
901 this._watchers.delete(rootUri);
902 }
905 > for (const [rootUri, next] of nextWatchRootUris.entries()) {
906 > const existing = this._watchers.get(rootUri); sessionCustomizationDiscovery.ts ×6
907 > if (existing) {
908 > // Refresh trigger URIs in place; the underlying watcher is unchanged. sessionCustomizationDiscovery.ts ×3
909 > existing.resourcesToWatch.clear();
910 > for (const uri of next.resourcesToWatch) {
911 > existing.resourcesToWatch.add(uri);
912 > }
913 > continue;
914 > }
916 > const disposable = this._fileService.watch(rootUri, { recursive: next.recursive, excludes: [] });
917 > this._watchers.set(rootUri, { recursive: next.recursive, resourcesToWatch: next.resourcesToWatch, disposable });
918 > } catch (err) {
919 this._logService.warn(`[SessionCustomizationDiscovery] Failed to watch '${rootUri.toString()}': ${err instanceof Error ? err.message : String(err)}`);
920 }
924 > private _disposeAllWatchers(): void {
925 > for (const watcher of this._watchers.values()) { sessionCustomizationDiscovery.ts ×16
926 > watcher.disposable.dispose(); sessionCustomizationDiscovery.ts ×6
927 > }
928 > this._watchers.clear(); sessionCustomizationDiscovery.ts ×16
929 > }
931 > /**
932 > * For fixed discovery files (e.g. AGENTS.md, copilot-instructions.md,
933 > * settings.json), create one discovered directory per type at the base.
934 > */
935 > private async _scanFixedDiscoveryFiles(base: URI, roots: IFixedDiscoveryFile[], seen: ResourceSet, result: IDiscoveredDirectory[], watchRootUris: ResourceMap<IWatchSpec>, token: CancellationToken): Promise<void> {
936 > const filesByType = new Map<DiscoveredType, IDiscoveredFile[]>(); sessionCustomizationDiscovery.ts ×16
937 > await Promise.all(roots.map(async root => {
938 > throwIfCancelled(token);
939 >
940 > if (!await this._watchAncestors(base, root.path, watchRootUris, token)) {
942 > }
944 > const rootUri = joinPath(base, ...root.path);
945 > let stat: IFileStatWithMetadata;
946 > try {
947 > stat = await this._fileService.resolve(rootUri, { resolveMetadata: true });
948 > } catch {
949 > // Root does not exist (or is unreadable) — nothing to discover or watch. sessionCustomizationDiscovery.ts ×1
950 > return;
951 > }
952 > if (!stat.isDirectory || !stat.children) { sessionCustomizationDiscovery.ts ×16
953 return;
954 }
956 > // Trigger refresh only for the specific filenames this root cares about
957 > // (e.g. AGENTS.md at the workspace root) — not for every direct child.
958 > for (const filename of root.filenames) {
959 > addWatch(watchRootUris, rootUri, false, joinPath(rootUri, filename));
960 > }
961 > for (const entry of stat.children) {
962 > throwIfCancelled(token); sessionCustomizationDiscovery.ts ×2
963 >
964 > if (entry.isFile && root.filenames.includes(entry.name)) {
965 > const uri = joinPath(rootUri, entry.name); sessionCustomizationDiscovery.ts ×2
966 > if (!seen.has(uri)) {
967 > seen.add(uri);
968 > const files = filesByType.get(root.type) ?? [];
969 > files.push({ uri, etag: entry.etag });
970 > filesByType.set(root.type, files);
971 > }
972 > }
975 >
976 > for (const [type, files] of filesByType.entries()) {
977 > if (files.length > 0) { sessionCustomizationDiscovery.ts ×2
978 > result.push({ uri: base, type, files: files.sort(compareDiscoveredFile), name: '', writable: false });
979 > }
980 > }
983 > private async _scanRoot(base: URI, root: ISearchRoot, seen: ResourceSet, result: IDiscoveredDirectory[], watchRootUris: ResourceMap<IWatchSpec>, token: CancellationToken): Promise<void> {
984 > throwIfCancelled(token); sessionCustomizationDiscovery.ts ×16
985 >
986 > const rootUri = joinPath(base, ...root.path);
987 > let stat: IFileStatWithMetadata | undefined = undefined;
988 > let children: IFileStatWithMetadata[] = [];
989 > try {
990 > stat = await this._fileService.resolve(rootUri, { resolveMetadata: true });
991 > children = stat.children ?? []; sessionCustomizationDiscovery.ts ×1
993 > // Root does not exist (or is unreadable) — still discover it as a possible source folder.
994 > }
995 >
996 > // Filenames are dynamic for these roots, so we watch the whole directory.
997 > // `addWatch` upgrades to recursive if any root requests it.
998 > await this._watchAncestors(base, root.path, watchRootUris, token);
999 > addWatch(watchRootUris, rootUri, root.recursive ?? false, rootUri);
1000 >
1001 > if (root.type === DiscoveredType.Skill) {
1002 > const files: IDiscoveredFile[] = [];
1003 > await Promise.all(children.map(async child => {
1004 > throwIfCancelled(token); sessionCustomizationDiscovery.ts ×2
1005 >
1006 > if (child.isDirectory) {
1007 > const skillFile = joinPath(child.resource, SKILL_FILENAME);
1008 > try {
1009 > const skillStat = await this._fileService.resolve(skillFile, { resolveMetadata: true });
1010 > if (skillStat.isFile && !seen.has(skillFile)) {
1011 > seen.add(skillFile);
1012 > files.push({ uri: skillFile, etag: skillStat.etag });
1013 > }
1014 > } catch {
1015 // SKILL.md missing — skip this skill directory.
1016 }
1019 > result.push({ uri: rootUri, type: root.type, files: files.sort(compareDiscoveredFile), name: root.name, writable: true });
1020 > } else if (root.type === DiscoveredType.Agent) {
1021 > const files: IDiscoveredFile[] = [];
1022 > // agents are markdown files directly under the root (no subdirectory scanning),
1023 > // excluding only exact-case README.md.
1024 > for (const child of children) {
1025 > throwIfCancelled(token); sessionCustomizationDiscovery.ts ×1
1026 >
1027 > if (child.isFile) {
1028 > const filename = child.name;
1029 > if (filename.endsWith(MARKDOWN_SUFFIX) && filename !== README_FILENAME && !seen.has(child.resource)) {
1030 > seen.add(child.resource);
1031 > files.push({ uri: child.resource, etag: child.etag });
1032 > }
1033 > }
1034 > }
1035 > result.push({ uri: rootUri, type: root.type, files: files.sort(compareDiscoveredFile), name: root.name, writable: true }); sessionCustomizationDiscovery.ts ×16
1036 >
1037 > } else if (root.type === DiscoveredType.Instruction) {
1038 > const files: IDiscoveredFile[] = [];
1039 > // instructions are all .instructions.md files directly under the root or in a subdirectory
1040 > const findInstructions = async (stat: IFileStatWithMetadata, recursionLevel: number): Promise<void> => {
1041 > throwIfCancelled(token); sessionCustomizationDiscovery.ts ×3
1042 >
1043 > for (const child of stat.children ?? []) {
1044 > throwIfCancelled(token);
1045 >
1046 > if (child.isFile) {
1047 > const name = child.name.toLowerCase();
1048 > if (name.endsWith(INSTRUCTION_FILE_SUFFIX) && !seen.has(child.resource)) {
1049 > seen.add(child.resource);
1050 > files.push({ uri: child.resource, etag: child.etag });
1051 > }
1052 > } else if (child.isDirectory && recursionLevel < MAX_INSTRUCTIONS_RECURSION_DEPTH) {
1053 > let childStat: IFileStatWithMetadata | undefined = undefined; sessionCustomizationDiscovery.ts ×2
1054 > try {
1055 > childStat = await this._fileService.resolve(child.resource, { resolveMetadata: true });
1056 > } catch {
1057 // Ignore unreadable subdirectories.
1058 }
1059 > if (childStat) { sessionCustomizationDiscovery.ts ×2
1060 > await findInstructions(childStat, recursionLevel + 1);
1061 > }
1062 > }
1064 > };
1066 > await findInstructions(stat, 0); sessionCustomizationDiscovery.ts ×3
1067 > }
1068 > result.push({ uri: rootUri, type: root.type, files: files.sort(compareDiscoveredFile), name: root.name, writable: true }); sessionCustomizationDiscovery.ts ×16
1069 > } else if (root.type === DiscoveredType.Hook) {
1070 > await this._scanForHooks(root, rootUri, stat, seen, result, token);
1071 > } else {
1072 this._logService.warn(`[SessionCustomizationDiscovery] Unrecognized root type '${root.type}' for root '${rootUri.toString()}'`);
1073 }
1076 > private async _scanForHooks(root: ISearchRoot, rootUri: URI, stat: IFileStatWithMetadata | undefined, seen: ResourceSet, result: IDiscoveredDirectory[], token: CancellationToken): Promise<void> {
1077 > const files: IDiscoveredFile[] = []; sessionCustomizationDiscovery.ts ×16
1078 > // hooks are recursively discovered as `*.json` under the root.
1079 > const findHooks = async (directoryStat: IFileStatWithMetadata, recursionLevel: number): Promise<void> => {
1080 > throwIfCancelled(token); sessionCustomizationDiscovery.ts ×3
1081 >
1082 > for (const child of directoryStat.children ?? []) {
1083 > throwIfCancelled(token);
1084 >
1085 > if (child.isFile) {
1086 > const name = child.name.toLowerCase();
1087 > if (name.endsWith(HOOK_FILE_SUFFIX) && !seen.has(child.resource)) {
1088 > seen.add(child.resource);
1089 > files.push({ uri: child.resource, etag: child.etag });
1090 > }
1091 > } else if (child.isDirectory && recursionLevel < MAX_HOOKS_RECURSION_DEPTH) {
1092 > let childStat: IFileStatWithMetadata | undefined = undefined; sessionCustomizationDiscovery.ts ×2
1093 > try {
1094 > childStat = await this._fileService.resolve(child.resource, { resolveMetadata: true });
1095 > } catch {
1096 // Ignore unreadable subdirectories.
1097 }
1098 > if (childStat) { sessionCustomizationDiscovery.ts ×2
1099 > await findHooks(childStat, recursionLevel + 1);
1100 > }
1101 > }
1103 > };
1105 > await findHooks(stat, 0); sessionCustomizationDiscovery.ts ×3
1106 > }
1107 > result.push({ uri: rootUri, type: root.type, files: files.sort(compareDiscoveredFile), name: root.name, writable: true }); sessionCustomizationDiscovery.ts ×16
1108 >
1109 > }
1111 >
1112 >
1113 >
1114 > // Test-only helpers — exported as `_internal` to discourage production use.
1115 > export const _internal = {
1116 > AGENT_FILE_SUFFIX,
1117 > INSTRUCTION_FILE_SUFFIX,
1118 > SKILL_FILENAME,
1119 > searchRoots,
1120 > fixedDiscoveryFiles,
1121 > agentInstructions,
1122 > };