copilotAgent.ts ×185

Frontier kind: Code frontier

unlabeled · c_89f78a20d85a

171 tests · 49789 LOC · 297 files · introduces 0 tests · 1576 LOC · 3 files

Introduces — evidence that enters the hierarchy at this concept

Code
216 ranges1576 lines · 3 files
Tests
0 tests

Contains — complete concept membership

All code (extent)
4476 ranges49789 lines · 297 files · Browse complete extent
All tests (intent)
171 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.

3 files ranked by introduced lines: 1576 introduced LOC across 216 ranges. Expand a file to inspect source; the > gutter marks introduced lines.

src/vs/platform/agentHost/node/copilot/copilotAgent.ts 1286 introduced LOC · 185 ranges

Open complete file

1 > /*--------------------------------------------------------------------------------------------- copilotAgent.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 { CopilotClient, RuntimeConnection, type CopilotClientOptions, type GitHubTelemetryNotification } from '@github/copilot-sdk';
7 > import * as fs from 'fs/promises';
8 > import * as os from 'os';
9 > import { pathToFileURL } from 'url';
10 > import { CancelablePromise, createCancelablePromise, DeferredPromise, Delayer, disposableTimeout, Limiter, SequencerByKey } from '../../../../base/common/async.js';
11 > import { type CancellationToken } from '../../../../base/common/cancellation.js';
12 > import { CancellationError } from '../../../../base/common/errors.js';
13 > import { Emitter, Event } from '../../../../base/common/event.js';
14 > import { combinedDisposable, Disposable, DisposableMap, MutableDisposable } from '../../../../base/common/lifecycle.js';
15 > import { ResourceMap } from '../../../../base/common/map.js';
16 > import { FileAccess } from '../../../../base/common/network.js';
17 > import { formatTokenCount } from '../../../../base/common/numbers.js';
18 > import { equals } from '../../../../base/common/objects.js';
19 > import { autorun, observableValue, type ISettableObservable } from '../../../../base/common/observable.js';
20 > import { delimiter, dirname, join } from '../../../../base/common/path.js';
21 > import { basename as resourceBasename, isEqual, isEqualOrParent, joinPath as resourceJoinPath, relativePath } from '../../../../base/common/resources.js';
22 > import { URI } from '../../../../base/common/uri.js';
23 > import { generateUuid } from '../../../../base/common/uuid.js';
24 > import { rgDiskPath } from '../../../../base/node/ripgrep.js';
25 > import { localize } from '../../../../nls.js';
26 > import { IParsedAgent, IParsedPlugin, IParsedRule, IParsedSkill, parseAgentFile, parsePlugin, parseRuleFile, parseSkillFile, PluginFormat } from '../../../agentPlugins/common/pluginParsers.js';
27 > import { AiAgentEnvValue, AiAgentEnvVar } from '../../../chat/common/aiAgentEnv.js';
28 > import { IFileService } from '../../../files/common/files.js';
29 > import { IInstantiationService } from '../../../instantiation/common/instantiation.js';
30 > import { ILogService, LogLevel } from '../../../log/common/log.js';
31 > import { ITelemetryService } from '../../../telemetry/common/telemetry.js';
32 > import { INativeEnvironmentService } from '../../../../platform/environment/common/environment.js';
33 > import { workspacelessScratchDir } from '../workspacelessScratchDir.js';
34 > import { IAgentHostCheckpointService } from '../../common/agentHostCheckpointService.js';
35 > import { IAgentHostReviewService } from '../../common/agentHostReviewService.js';
36 > import { createPricingMetaFromBilling, hasLongContextSurcharge, normalizeCAPIBilling, type ICAPIModelBilling } from '../../common/agentModelPricing.js';
37 > import { createAgentModelByokMeta } from '../../common/agentModelByokMeta.js';
38 > import { AgentHostConfigKey, agentHostCustomizationConfigSchema, DEFAULT_SESSION_CUSTOMIZATION_DISCOVERY_MODE, toContainerCustomization } from '../../common/agentHostCustomizationConfig.js';
39 > import { CopilotCliConfigKey, copilotCliConfigSchema, type CopilotSdkLogLevelSetting } from '../../common/copilotCliConfig.js';
40 > import { AgentHostMcpServersConfigKey, AgentHostPreferLongContextEnabledConfigKey, AgentHostSessionSyncEnabledConfigKey, AgentHostSystemProxyEnabledConfigKey, AutoApproveLevel, SessionMode, migrateLegacyAutopilotConfig, platformRootSchema, platformSessionSchema, type AgentHostMcpServers } from '../../common/agentHostSchema.js';
41 > import { IAgentPluginManager, ISyncedCustomization } from '../../common/agentPluginManager.js';
42 > import { AgentSessionEntry, decodeProviderData, encodeProviderData, prepareSideChatPrompt, stripSideChatContext, type IPersistedChat } from '../agentPeerChats.js';
43 > import { AgentSession, AgentSignal, AuthenticateParams, IActiveClient, IAgent, IAgentChatDataChange, IAgentChats, IAgentLegacyChat, IAgentCreateChatForkSource, IAgentCreateChatOptions, IAgentCreateChatResult, IAgentCreateSessionConfig, IAgentCreateSessionResult, IAgentDescriptor, IAgentHostManagedSettingsSnapshot, IAgentHostNetworkEndpoint, IAgentMaterializeSessionEvent, IAgentModelInfo, IAgentResolveSessionConfigParams, IAgentSessionConfigCompletionsParams, IAgentSessionMetadata, IAgentSessionProjectInfo, IAgentSpawnChatEvent, IMcpNotification, IRestoredSubagentSession, SubagentChatSignal } from '../../common/agentService.js';
44 > import { getReasoningEffortDescription, getReasoningEffortLabel } from '../../common/reasoningEffort.js';
45 > import type { IAgentServerToolHost } from '../../common/agentServerTools.js';
46 > import { IAgentHostOTelService } from '../../common/otel/agentHostOTelService.js';
47 > import { SessionConfigKey } from '../../common/sessionConfigKeys.js';
48 > import { ICopilotConfigSlashCommandState } from '../../common/copilotConfigSlashCommands.js';
49 > import { getCopilotHomePath } from '../../common/copilotHome.js';
50 > import { ISessionDataService, SESSION_DB_FILENAME } from '../../common/sessionDataService.js';
51 > import { IAgentHostProxyResolver } from '../agentHostProxyResolver.js';
52 > import type { ResolveSessionConfigResult, SessionConfigCompletionsResult } from '../../common/state/protocol/commands.js';
53 > import { ProtectedResourceMetadata, type AgentSelection, type ChildCustomizationType, type ConfigPropertySchema, type ConfigSchema, type ModelSelection, type ToolDefinition } from '../../common/state/protocol/state.js';
54 > import { ActionType, type SessionAction } from '../../common/state/sessionActions.js';
55 > import { AgentCustomization, CustomizationLoadStatus, CustomizationType, RuleCustomization, ChatInputResponseKind, SkillCustomization, customizationId, buildChatUri, buildDefaultChatUri, isDefaultChatUri, parseChatUri, parseRequiredSessionUriFromChatUri, parseSubagentSessionUri, AH_META_WORKSPACELESS_DB_KEY, type ChildCustomization, type ClientPluginCustomization, type Customization, type DirectoryCustomization, type HookCustomization, type MessageAttachment, type PendingMessage, type PluginCustomization, type PolicyState, type ChatInputAnswer, type ToolCallResult, type Turn } from '../../common/state/sessionState.js';
56 > import { ActiveClientToolSet } from '../activeClientState.js';
57 > import { IAgentConfigurationService } from '../agentConfigurationService.js';
58 > import { IAgentHostGitHubEndpointService } from '../agentHostGitHubEndpointService.js';
59 > import { IAgentHostCompletions } from '../agentHostCompletions.js';
60 > import { IAgentHostGitService } from '../../common/agentHostGitService.js';
61 > import { applyMcpServerEnablement, findMcpChildId, type IMcpServerRuntimeState } from '../shared/mcpCustomizationController.js';
62 > import { AgentHostStateManager, IAgentHostStateManager } from '../agentHostStateManager.js';
63 >
64 > interface ICopilotRuntimeManagedSettingsSdk {
65 > getManagedSettings(input?: { token?: string; host?: string }): Promise<{ account?: string; resolved: IAgentHostManagedSettingsSnapshot }>;
66 > }
67 >
68 function isCopilotRuntimeManagedSettingsSdk(value: unknown): value is ICopilotRuntimeManagedSettingsSdk {
69 return typeof value === 'object' && value !== null && 'getManagedSettings' in value
70 && typeof (value as { getManagedSettings?: unknown }).getManagedSettings === 'function';
71 }
72 > import { IByokLmBridgeRegistry } from '../byokLmBridgeRegistry.js'; copilotAgent.ts
73 > import { SessionWorkingDirectoryMissingError } from '../shared/worktreeIsolation.js';
74 > import { buildSessionEventLogFromTurns } from './buildSessionEvents.js';
75 > import { CopilotAgentSession, type CopilotSdkMode } from './copilotAgentSession.js';
76 > import { ICopilotSessionContext, projectFromCopilotContext } from './copilotGitProject.js';
77 > import { parsedPluginsEqual, toChildCustomizations } from './copilotPluginConverters.js';
78 > import { CopilotGitHubTelemetryForwarder } from './copilotGitHubTelemetryForwarder.js';
79 > import { CopilotSessionLauncher, ContextSizeConfigKey, ThinkingLevelConfigKey, getCopilotContextTier, resolveCopilotReasoningEffort, type CopilotSessionLaunchPlan, type IActiveClientSnapshot } from './copilotSessionLauncher.js';
80 > import { ShellManager } from './copilotShellTools.js';
81 > import { isAgentHostTelemetryService } from '../agentHostTelemetryService.js';
82 > import { ICopilotApiService, type IRestrictedTelemetryContext } from '../shared/copilotApiService.js';
83 > import { AgentHostGitHubTelemetryRouter } from '../agentHostGitHubTelemetryRouter.js';
84 > import { CopilotSlashCommandCompletionProvider, ICopilotRuntimeSlashCommandQueryOptions } from './copilotSlashCommandCompletionProvider.js';
85 > import { DiscoveredType, SessionCustomizationDiscovery, areDiscoveredDirectoriesEqual, type IDiscoveredDirectory } from './sessionCustomizationDiscovery.js';
86 > import { COPILOT_INTEGRATION_ID } from '../../../endpoint/common/licenseAgreement.js';
87 > import { getAppNodeModulesPath } from '../appNodeModules.js';
88 > import { CopilotSlashCommandProvider } from './copilotSlashCommandProvider.js';
89 >
90 > const RUNTIME_SLASH_COMMAND_COMPLETION_WAIT_MS = 300;
91 > const COPILOT_CAPI_URL = 'https://api.githubcopilot.com';
92 > /**
93 > * Proxy env vars that indicate the environment already configures a proxy.
94 > */
95 > const COPILOT_PROXY_ENV_KEYS = ['HTTPS_PROXY', 'https_proxy', 'HTTP_PROXY', 'http_proxy', 'ALL_PROXY', 'all_proxy'] as const;
96 > /**
97 > * Proxy env vars we set when injecting the resolved CAPI proxy.
98 > */
99 > const COPILOT_PROXY_SET_ENV_KEYS = ['HTTP_PROXY', 'HTTPS_PROXY'] as const;
100 >
101 async function fileExists(filePath: string): Promise<boolean> {
102 try {
107 }
108 }
110 function isLinuxMuslRuntime(): boolean {
111 if (process.platform !== 'linux') {
116 return !report?.header?.glibcVersionRuntime;
117 }
119 function getCopilotPlatformPackageCandidates(): string[] {
120 const platformArch = `${process.platform}-${process.arch}`;
126 return isLinuxMuslRuntime() ? linuxCandidates.reverse() : linuxCandidates;
127 }
129 async function resolveCopilotCliPath(nodeModulesUri: URI): Promise<string> {
130 const tried: string[] = [];
145 throw new Error(`Unable to resolve @github/copilot CLI path. Tried: ${tried.join(', ')}`);
146 }
148 > export type ICopilotPluginInfo = IParsedPlugin & { readonly pluginDir?: URI };
149 >
150 > /**
151 > * A session that has been requested by a client but has not yet been
152 > * materialized into a real Copilot SDK session, worktree, or persisted
153 > * metadata. Created by {@link CopilotAgent.createSession} when no fork is
154 > * requested, and consumed by {@link CopilotAgent._materializeProvisional}
155 > * on the first {@link CopilotAgent.sendMessage}.
156 > *
157 > * Until materialization the session occupies only an in-memory slot and
158 > * an entry in the state manager. Disposing a provisional session is a
159 > * cheap no-op compared with tearing down a real session — there is no
160 > * worktree to remove and no on-disk state to delete.
161 > *
162 > * `model` absorbs {@link CopilotAgent.changeModel} updates that arrive
163 > * before the first message. The latest provider-owned session config is read
164 > * straight from the state manager via
165 > * {@link IAgentConfigurationService.getSessionConfigValues} at
166 > * materialization time, so no bespoke forwarding is required for it.
167 > */
168 > interface IProvisionalSession {
169 > readonly sessionId: string;
170 > readonly sessionUri: URI;
171 > /**
172 > * Folder the user picked at create time. Used as both the
173 > * pre-worktree working directory and the customization directory
174 > * (plugin discovery is anchored to the original folder, not to a
175 > * worktree path that may not exist yet).
176 > */
177 > readonly workingDirectory: URI;
178 > /** Most recent model selection. Updated by `changeModel` while provisional. */
179 > model: ModelSelection | undefined;
180 > /** Most recent custom agent selection. Updated by `changeAgent` while provisional. */
181 > agent: AgentSelection | undefined;
182 > /** Project info eagerly resolved at create time so the summary renders. */
183 > readonly project: IAgentSessionProjectInfo | undefined;
184 > /** Whether this session is workspace-less (surfaced in the sessions UI as a "Quick Chat"). */
185 > readonly workspaceless?: boolean;
186 > }
187 >
188 > interface ICopilotAgentSessionIdentity {
189 > readonly sessionUri: URI;
190 > readonly chatChannelUri: URI;
191 > }
192 >
193 function toRestrictedTelemetryEndpoint(endpoint: string | undefined): string | undefined {
194 return endpoint ? `${endpoint.replace(/\/+$/, '')}/telemetry` : undefined;
195 }
197 > export { COPILOT_AGENT_HOST_SYSTEM_MESSAGE } from './prompts/systemMessage.js';
198 >
199 > type ModelInfo = Awaited<ReturnType<CopilotClient['rpc']['models']['list']>>['models'][number];
200 >
201 > interface ISerializedModelSelection {
202 > id?: unknown;
203 > config?: unknown;
204 > }
205 >
206 > /**
207 > * Subset of the JSON-RPC `MessageConnection` we reach into via the SDK's private `connection` field to wire plan mode.
208 > * See {@link CopilotAgent._enablePlanModeOnClient}.
209 > */
210 > interface IExitPlanModeConnection {
211 > sendRequest(method: string, params: unknown): Promise<unknown>;
212 > onRequest(method: string, handler: (params: IExitPlanModeRequestParams) => Promise<IExitPlanModeResponse>): { dispose(): void };
213 > }
214 >
215 > /**
216 > * Payload of the CLI's `exitPlanMode.request` RPC. The CLI dispatches one
217 > * per `exit_plan_mode` tool invocation when the session was created with
218 > * `requestExitPlanMode: true`.
219 > */
220 > export interface IExitPlanModeRequestParams {
221 > readonly sessionId: string;
222 > readonly summary: string;
223 > readonly planContent: string;
224 > readonly actions: readonly string[];
225 > readonly recommendedAction: string;
226 > }
227 >
228 > /**
229 > * Response for the CLI's `exitPlanMode.request` RPC. The CLI feeds this
230 > * directly into `session.respondToExitPlanMode`, which resolves the
231 > * pending tool call and (when approved) updates the SDK's `currentMode`.
232 > */
233 > export interface IExitPlanModeResponse {
234 > readonly approved: boolean;
235 > readonly selectedAction?: string;
236 > readonly autoApproveEdits?: boolean;
237 > readonly feedback?: string;
238 > }
239 >
240 > /**
241 > * Rebases `uri` from under `fromDir` onto `toDir`, preserving the relative path.
242 > * Returns `undefined` when `uri` is not equal to or under `fromDir`.
243 > */
244 > export function rebaseUnder(uri: URI, fromDir: URI, toDir: URI): URI | undefined {
245 if (!isEqualOrParent(uri, fromDir)) {
246 return undefined;
252 return rel.length === 0 ? toDir : resourceJoinPath(toDir, rel);
253 }
255 > /**
256 > * Per-session container. Owns the session's default (main) chat and any
257 > * additional peer chats, keeping all chats of a session together in a single
258 > * {@link CopilotAgent._sessions} map (no parallel maps). The default chat is
259 > * optional because a Copilot session can exist as a provisional record (in
260 > * {@link CopilotAgent._provisionalSessions}) whose SDK-backed default chat has
261 > * not materialized yet — a peer chat may still be created on it. Disposing the
262 > * entry disposes the default chat and every peer chat.
263 > *
264 > * Exported for tests, which inject fake sessions into the container.
265 > */
266 > export class CopilotSessionEntry extends AgentSessionEntry<CopilotAgentSession> { }
267 >
268 > /**
269 > * Agent provider backed by the Copilot SDK {@link CopilotClient}.
270 > */
271 > export class CopilotAgent extends Disposable implements IAgent {
272 > readonly id = 'copilotcli' as const;
273 >
274 > private readonly _onDidSessionProgress = this._register(new Emitter<AgentSignal>());
275 > readonly onDidSessionProgress = this._onDidSessionProgress.event;
276 > /**
277 > * Membership channel for chats the agent spawns itself — sub-agents
278 > * delegated by a tool call (the same fan-out the `subagent_started` /
279 > * `subagent_completed` signals drive). The orchestrator routes these into
280 > * the chat catalog so harness-spawned and user-driven chats share one path.
281 > */
282 > private readonly _onDidSpawnChat = this._register(new Emitter<IAgentSpawnChatEvent>());
283 > readonly onDidSpawnChat = this._onDidSpawnChat.event;
284 > private readonly _onDidMaterializeSession = this._register(new Emitter<IAgentMaterializeSessionEvent>());
285 > readonly onDidMaterializeSession = this._onDidMaterializeSession.event;
286 > /**
287 > * Per-session MCP notifications, fanned in from every active
288 > * {@link CopilotAgentSession}. Each session contributes a single
289 > * subscription, disposed alongside the session.
290 > */
291 > private readonly _onMcpNotification = this._register(new Emitter<IMcpNotification>());
292 > readonly onMcpNotification = this._onMcpNotification.event;
293 > private readonly _models = observableValue<readonly IAgentModelInfo[]>(this, []);
294 > readonly models = this._models;
295 > /**
296 > * The two sources merged into {@link _models}: CAPI models from the CLI's
297 > * `models.list` and BYOK models from the renderer bridge registry's serving
298 > * window. Tracked separately so each can refresh independently without
299 > * clobbering the other; {@link _publishModels} concatenates them for the
300 > * picker.
301 > */
302 > private _capiModels: readonly IAgentModelInfo[] = [];
303 > private _byokModels: readonly IAgentModelInfo[] = [];
304 >
305 > /** Model IDs whose long-context tier costs the same as the default tier. */
306 > private readonly _freeLongContextModels = new Set<string>();
307 >
308 > /**
309 > * Bounded exponential-backoff retry for {@link _refreshModels}. The SDK's
310 > * `models.list` RPC can fail transiently (e.g. a `429 "too many requests"`
311 > * right after startup). Without a retry the model picker would stay empty
312 > * until the next external refresh trigger (a GitHub token change, a CLI
313 > * client restart, or the host's periodic scheduler), so we retry a few
314 > * times before giving up. Overridable in tests to avoid real delays.
315 > */
316 > protected readonly _modelRefreshMaxAttempts: number = 5;
317 > protected readonly _modelRefreshBaseDelayMs: number = 1_000;
318 > protected readonly _modelRefreshMaxDelayMs: number = 30_000;
319 > /** Pending model-refresh retry timer; cleared on a fresh refresh, shutdown, or dispose. */
320 > private readonly _modelRefreshRetry = this._register(new MutableDisposable());
321 > /**
322 > * Invalidates model requests bound to a superseded token/client/catalog
323 > * source. Token identity alone is insufficient: restarting the client for
324 > * a `COPILOT_GH_HOST` change keeps the same token while changing the CAPI
325 > * endpoint whose catalog is authoritative.
326 > */
327 > private _modelCatalogGeneration = 0;
328 > /**
329 > * Forced refreshes are deferred to the next task so related lifecycle
330 > * changes (for example an auth update arriving with a startup-config
331 > * change) collapse into one enumeration of the final token/client source.
332 > */
333 > private _scheduledModelRefresh: { readonly deferred: DeferredPromise<void>; generation: number } | undefined;
334 > private readonly _modelRefreshSchedule = this._register(new MutableDisposable());
335 > /**
336 > * In-flight {@link refreshModels} call, so overlapping triggers (an auth
337 > * token change landing on top of a periodic tick) collapse into a single
338 > * `models.list` request. Only covers the request itself: {@link _refreshModels}
339 > * returns as soon as it *schedules* a backoff retry, so a pending retry
340 > * never suppresses a later tick — which is what lets the scheduler act as
341 > * the long-term retry path once the bounded attempts are exhausted.
342 > */
343 > private _modelRefreshInFlight: Promise<void> | undefined;
344 >
345 > private _client: CopilotClient | undefined;
346 > private _clientStarting: Promise<CopilotClient> | undefined;
347 > private _clientStopping: Promise<void> | undefined;
348 > /**
349 > * Proxy URL injected into the running client's subprocess env (`undefined`
350 > * when none was injected). Used to detect when a token change alters the
351 > * token-discovered CAPI endpoint's proxy so we can restart the client.
352 > */
353 > private _appliedProxy: string | undefined;
354 > private _githubToken: string | undefined;
355 > private _serverToolHost: IAgentServerToolHost | undefined;
356 >
357 > setServerToolHost(host: IAgentServerToolHost): void {
358 > this._serverToolHost = host;
359 > }
360 >
361 > /** Reflects the `rt=1` field on the GitHub Copilot bearer token; gates enhanced GH telemetry. */
362 > private _restrictedTelemetryEnabled = false;
363 > private readonly _onDidChangeRestrictedTelemetry = this._register(new Emitter<void>());
364 > readonly onDidChangeRestrictedTelemetry = this._onDidChangeRestrictedTelemetry.event;
365 >
366 > get restrictedTelemetryEnabled(): boolean {
367 return this._restrictedTelemetryEnabled;
368 }
370 > private readonly _sessions = this._register(new DisposableMap<string, CopilotSessionEntry>());
371 > /**
372 > * Live `chatUri → backing` map for additional (non-default) peer chats,
373 > * keyed by chat channel URI string. Records the SDK chat id (and
374 > * optional model override) that backs each peer chat so the agent can
375 > * resume it without consulting on-disk persistence. Populated by
376 > * {@link createChat} on creation and by {@link materializeChat} on
377 > * restore; the orchestrator now owns the durable peer-chat catalog (the
378 > * agent no longer writes `copilot.chats`).
379 > */
380 > private readonly _chatBackings = new Map<string, IPersistedChat>();
381 > /**
382 > * Fires when a peer chat's opaque `providerData` blob changes after
383 > * creation (e.g. a per-chat model switch), so the orchestrator re-persists
384 > * the refreshed token. See {@link IAgent.onDidChangeChatData}.
385 > */
386 > private readonly _onDidChangeChatData = this._register(new Emitter<IAgentChatDataChange>());
387 > readonly onDidChangeChatData: Event<IAgentChatDataChange> = this._onDidChangeChatData.event;
388 > /**
389 > * Per-session MCP-notification subscriptions, keyed by `sessionId`.
390 > * Disposed in lockstep with the matching {@link _sessions} entry so
391 > * the fan-in does not leak listeners as sessions come and go.
392 > */
393 > private readonly _mcpNotificationSubs = this._register(new DisposableMap<string>());
394 > /**
395 > * In-flight {@link _resumeSession} promises, keyed by sessionId. Used to
396 > * deduplicate concurrent resume requests for the same session so that
397 > * we never construct two {@link CopilotAgentSession} entries for the
398 > * same id — `_sessions` is a {@link DisposableMap} whose `set()` would
399 > * dispose the in-flight first entry mid-{@link CopilotAgentSession.initializeSession},
400 > * leaving the second caller with a half-initialised, eventless session.
401 > */
402 > private readonly _resumingSessions = new Map<string, Promise<CopilotAgentSession>>();
403 > /**
404 > * Sessions created by a client but not yet materialized into a Copilot
405 > * SDK session + worktree + on-disk metadata. Materialization is deferred
406 > * until the first {@link sendMessage}, at which point the entry moves
407 > * out of this map and into {@link _sessions}. See {@link IProvisionalSession}.
408 > */
409 > private readonly _provisionalSessions = new Map<string, IProvisionalSession>();
410 > private readonly _sessionSequencer = new SequencerByKey<string>();
411 > private _shutdownPromise: Promise<void> | undefined;
412 > private readonly _plugins: PluginController;
413 > private readonly _sessionLauncher: CopilotSessionLauncher;
414 > private readonly _gitHubTelemetryForwarder: CopilotGitHubTelemetryForwarder;
415 > private readonly _githubTelemetryRouter: AgentHostGitHubTelemetryRouter | undefined;
416 > readonly onDidCustomizationsChange: Event<void>;
417 > /** Per-session active client state for tools + plugin snapshot tracking. */
418 > private readonly _activeClients = new ResourceMap<ActiveClient>();
419 > private readonly _slashCommandProvider: CopilotSlashCommandProvider;
420 >
421 > constructor(
422 @ILogService private readonly _logService: ILogService,
423 @IInstantiationService private readonly _instantiationService: IInstantiationService,
491 }));
492 }
494 > /**
495 > * Translates the sub-agent fan-out signals into the first-class spawned-
496 > * chat channel: `subagent_started` -> {@link onDidSpawnChat}
497 > * (carrying the spawning tool call as the chat's parent edge). A completed
498 > * subagent chat stays live and subscribable (it is removed only on session
499 > * teardown), so there is no corresponding end event. The signals themselves
500 > * are left untouched so the existing sub-agent behavior is preserved.
501 > */
502 > private _emitSpawnedChatForSubagentSignal(signal: AgentSignal): void {
503 const spawn = SubagentChatSignal.toSpawnEvent(signal);
504 if (spawn) {
506 }
507 }
509 > private _lastSessionSyncEnabled: boolean = this._isSessionSyncEnabled();
510 > private _lastRubberDuckEnabled: boolean = this._isRubberDuckEnabled();
511 > private _lastCopilotSdkLogLevelSetting: CopilotSdkLogLevelSetting = this._getCopilotSdkLogLevelSetting();
512 > private _lastEnterpriseHost: string | undefined = this._getEnterpriseHost();
513 > private _lastSystemProxyEnabled: boolean = this._isSystemProxyEnabled();
514 >
515 > private _isSessionSyncEnabled(): boolean {
516 return this._configurationService.getRootValue(platformRootSchema, AgentHostSessionSyncEnabledConfigKey) === true;
517 }
519 > private _isRubberDuckEnabled(): boolean {
520 return this._configurationService.getRootValue(copilotCliConfigSchema, CopilotCliConfigKey.RubberDuck) === true;
521 }
523 > private _getCopilotSdkLogLevelSetting(): CopilotSdkLogLevelSetting {
524 return this._configurationService.getRootValue(copilotCliConfigSchema, CopilotCliConfigKey.CopilotSdkLogLevel) ?? 'info';
525 }
527 > private _resolveCopilotSdkLogLevel(configured: CopilotSdkLogLevelSetting): NonNullable<CopilotClientOptions['logLevel']> {
528 return configured === 'trace' || this._logService.getLevel() === LogLevel.Trace ? 'all' : 'info';
529 }
531 > private _getEnterpriseHost(): string | undefined {
532 return this._gitHubEndpointService.getEnterpriseHost();
533 }
535 > private _isPreferLongContextEnabled(): boolean {
536 return this._configurationService.getRootValue(platformRootSchema, AgentHostPreferLongContextEnabledConfigKey) === true;
537 }
539 > private _isSystemProxyEnabled(): boolean {
540 return this._configurationService.getRootValue(platformRootSchema, AgentHostSystemProxyEnabledConfigKey) !== false;
541 }
543 > /**
544 > * Restarts the CLI client when a config value that is only read at client
545 > * startup has changed. Any active sessions are disposed before the client is
546 > * stopped; the latest values are picked up the next time {@link _ensureClient}
547 > * runs. An in-flight start aborts if any startup value changes.
548 > */
549 > private async _restartClientIfStartupConfigChanged(): Promise<void> {
550 const sessionSync = this._isSessionSyncEnabled();
551 const rubberDuck = this._isRubberDuckEnabled();
584 }
585 }
587 > protected _createCopilotClient(options: CopilotClientOptions): CopilotClient {
588 return new CopilotClient(options);
589 }
591 > // ---- auth ---------------------------------------------------------------
592 >
593 > getDescriptor(): IAgentDescriptor {
594 return {
595 provider: 'copilotcli',
599 };
600 }
602 > getProtectedResources(): ProtectedResourceMetadata[] {
603 return [
604 this._gitHubEndpointService.getCopilotResource(),
606 ];
607 }
609 > async getNetworkDiagnosticsEndpoints(): Promise<readonly IAgentHostNetworkEndpoint[]> {
610 let capiUrl = process.env['VSCODE_AGENT_HOST_CAPI_URL_OVERRIDE'] || COPILOT_CAPI_URL;
611 if (this._githubToken) {
623 ];
624 }
626 > async getNetworkDiagnosticsAccount(): Promise<string | undefined> {
627 return this._githubToken ? this._copilotApiService.resolveUserLogin?.(this._githubToken) : undefined;
628 }
630 > async getManagedSettingsDiagnostics(): Promise<IAgentHostManagedSettingsSnapshot> {
631 const nodeModulesUri = FileAccess.asFileUri(getAppNodeModulesPath());
632 const cliPath = await resolveCopilotCliPath(nodeModulesUri);
650 };
651 }
653 > getCustomizations(): readonly Customization[] {
654 return this._plugins.getConfiguredHostCustomizations();
655 }
657 > async getSessionCustomizations(session: URI): Promise<readonly Customization[]> {
658 const directory = await this._getSessionCustomizationDirectory(session);
659 const activeClient = this._getOrCreateActiveClient(session, directory);
666 return applyMcpServerEnablement(customizations, desired);
667 }
669 > async handleMcpRequest(session: URI, serverName: string, method: string, params: Record<string, unknown> | undefined): Promise<unknown> {
670 const sessionId = AgentSession.id(session);
671 const entry = this._findAnySession(sessionId);
675 return entry.handleMcpRequest(serverName, method, params);
676 }
678 > async startMcpServer(session: URI, id: string): Promise<void> {
679 const sessionId = AgentSession.id(session);
680 await this._findAnySession(sessionId)?.startMcpServer(id);
681 }
683 > async stopMcpServer(session: URI, id: string): Promise<void> {
684 const sessionId = AgentSession.id(session);
685 await this._findAnySession(sessionId)?.stopMcpServer(id);
686 }
688 > private async _getSessionCustomizationDirectory(session: URI): Promise<URI | undefined> {
689 const sessionId = AgentSession.id(session);
690 const provisional = this._provisionalSessions.get(sessionId);
699 return entry?.customizationDirectory ?? metadata?.workingDirectory ?? metadata?.customizationDirectory;
700 }
702 > async authenticate(resource: string, token: string): Promise<boolean> {
703 if (resource === this._gitHubEndpointService.getRepoResource().resource) {
704 return true;
717 return true;
718 }
720 > async handleAuthenticationToken(params: AuthenticateParams): Promise<boolean> {
721 let handled = false;
722 for (const [, entry] of this._sessions) {
728 return handled;
729 }
731 > private _updateRestrictedTelemetry(githubToken: string | undefined): void {
732 // Safe default synchronously: keep restricted/enhanced telemetry disabled until the minted
733 // CAPI Copilot session token confirms the `rt=1` opt-in. The GitHub token here carries no
740 }
741 }
743 > private async _resolveRestrictedTelemetry(githubToken: string): Promise<void> {
744 try {
745 const ctx = await this._copilotApiService.resolveRestrictedTelemetryContext(githubToken);
755 }
756 }
758 > private _applyRestrictedTelemetry(context: IRestrictedTelemetryContext | undefined): void {
759 const rtEnabled = context?.restrictedTelemetryEnabled === true;
760 if (rtEnabled !== this._restrictedTelemetryEnabled) {
772 }
773 }
775 > private async _routeGitHubTelemetry(notification: GitHubTelemetryNotification): Promise<void> {
776 const router = this._githubTelemetryRouter;
777 if (!router?.isTarget(notification)) {
808 }
809 }
811 > /**
812 > * {@link IAgent.refreshModels}. Coalesces onto an in-flight refresh and
813 > * never rejects — {@link _refreshModels} already logs and retains the last
814 > * known-good list on failure.
815 > *
816 > * Only safe for callers with no new input to apply (the host's periodic
817 > * scheduler). Triggers that invalidate the in-flight request — a rotated
818 > * token, a restarted client — must call {@link _scheduleModelRefresh} so they
819 > * are not answered by a refresh bound to the superseded input.
820 > */
821 > refreshModels(): Promise<void> {
822 return this._scheduledModelRefresh?.deferred.p ?? this._modelRefreshInFlight ?? this._startModelRefresh(++this._modelCatalogGeneration);
823 }
825 > /**
826 > * Invalidates an in-flight refresh immediately, then starts one refresh on
827 > * the next task. Repeated lifecycle triggers before that task
828 > * share the same deferred and enumerate only the final token/client source.
829 > */
830 > private _scheduleModelRefresh(): Promise<void> {
831 const generation = ++this._modelCatalogGeneration;
832 if (this._scheduledModelRefresh) {
863 return scheduled.deferred.p;
864 }
866 > private _startModelRefresh(generation: number): Promise<void> {
867 const refresh = this._refreshModels(0, generation).finally(() => {
868 if (this._modelRefreshInFlight === refresh) {
873 return refresh;
874 }
876 > private async _refreshModels(attempt = 0, generation = this._modelCatalogGeneration): Promise<void> {
877 // A fresh refresh (e.g. a token change) supersedes any scheduled retry.
878 this._modelRefreshRetry.clear();
920 }
921 }
923 > /**
924 > * Re-emit the merged CAPI + BYOK model list to the picker. A fresh array is
925 > * allocated each call so the observable always notifies its consumers.
926 > */
927 > private _publishModels(): void {
928 this._models.set([...this._capiModels, ...this._byokModels], undefined);
929 }
931 > /**
932 > * (Re)publish the renderer BYOK models from the bridge registry's serving
933 > * window. Triggered when any renderer bridge connects, disconnects, or
934 > * reports a model change — the registry owns enumeration (with its own
935 > * connect-time retry) and caches the serving window's models, so this is a
936 > * cheap synchronous read of that cache.
937 > *
938 > * Each model is surfaced under the provider-qualified id `vendor/id` so a
939 > * selection round-trips to the per-session provider config synthesized by
940 > * `resolveByokSessionConfig`.
941 > */
942 > private _refreshByokModels(): void {
943 if (this._shutdownPromise) {
944 return;
958 this._publishModels();
959 }
961 > /**
962 > * Equal-jitter exponential backoff for model-refresh retries. Doubles the
963 > * base delay per attempt (capped at {@link _modelRefreshMaxDelayMs}) and
964 > * picks a random point in the upper half of that window, so the returned
965 > * delay lands in `[exp/2, exp]`. The jitter avoids synchronized retries
966 > * across windows/agents hitting a shared rate limit, while the `exp/2`
967 > * floor keeps a minimum spacing between attempts.
968 > */
969 > private _modelRefreshBackoff(attempt: number): number {
970 const exp = Math.min(this._modelRefreshMaxDelayMs, this._modelRefreshBaseDelayMs * 2 ** attempt);
971 return Math.round(exp / 2 + Math.random() * (exp / 2));
972 }
974 > private _stopClient(): Promise<void> {
975 if (this._clientStopping) {
976 return this._clientStopping;
1002 return stopping;
1003 }
1005 > /**
1006 > * Enables plan mode by injecting `requestExitPlanMode: true` into the
1007 > * payload of every `session.create` / `session.resume` JSON-RPC request,
1008 > * and registers a connection-level handler for the resulting
1009 > * `exitPlanMode.request` RPC the CLI sends back.
1010 > *
1011 > * The SDK (`@github/copilot-sdk@^0.3.0`) does not expose `onExitPlanMode`
1012 > * in its public {@link SessionConfig} surface, so both the wire flag and
1013 > * the response handler are wired through the SDK's private
1014 > * `MessageConnection`. Once the SDK adds first-class support, this shim
1015 > * should be removed.
1016 > */
1017 > protected _enablePlanModeOnClient(client: CopilotClient): void {
1018 // `connection` is declared private on `CopilotClient` at the type
1019 // level but is a plain field at runtime — see the SDK's compiled
1040 };
1041 }
1043 > // ---- client lifecycle ---------------------------------------------------
1044 >
1045 > private async _ensureClient(): Promise<CopilotClient> {
1046 if (this._shutdownPromise) {
1047 throw new CancellationError();
1202 return clientStarting;
1203 }
1205 > // ---- session management -------------------------------------------------
1206 >
1207 > private _createThinkingLevelConfigSchemaProperty(supportedReasoningEfforts: readonly string[] | undefined, defaultReasoningEffort: string | undefined): ConfigPropertySchema | undefined {
1208 if (!supportedReasoningEfforts?.length) {
1209 return undefined;
1220 };
1221 }
1223 > /**
1224 > * Synthesize a `contextSize` config property when the model exposes a `long_context` pricing tier with a distinct
1225 > * context-max. Picker surfaces this as the "Context Size" button. Mirrors `getContextSizeOptions` in
1226 > * `extensions/copilot/src/extension/chat/vscode-node/languageModelAccess.ts`.
1227 > *
1228 > * The `enum` values are the two context-window sizes (in tokens), smallest first, so the numeric token counts
1229 > * flow to the client. The chosen value comes back in the model's `config` bag and is mapped to the SDK's
1230 > * two-valued `contextTier` at the SDK boundary by {@link getCopilotContextTier}, using the model's long-context
1231 > * window from {@link _longContextWindowFor}.
1232 > */
1233 > private _createContextSizeConfigSchemaProperty(billing: ICAPIModelBilling | undefined): ConfigPropertySchema | undefined {
1234 const tokenPrices = billing?.tokenPrices;
1235 const defaultMax = tokenPrices?.contextMax;
1267 };
1268 }
1270 > /**
1271 > * The model's long-context window (in tokens): the largest size offered by its "Context Size" picker
1272 > * (the max numeric value in the synthesized `contextSize` {@link ConfigPropertySchema.enum}). Used by
1273 > * {@link getCopilotContextTier} to decide whether a numeric selection opts into `long_context`.
1274 > * Returns `undefined` when the model exposes no such picker (or the model list isn't loaded yet),
1275 > * leaving the SDK on its default tier.
1276 > */
1277 > private _longContextWindowFor(modelId: string | undefined): number | undefined {
1278 if (!modelId) {
1279 return undefined;
1283 return numericWindows && numericWindows.length > 0 ? Math.max(...numericWindows) : undefined;
1284 }
1286 > /**
1287 > * Whether the model has a long-context window available at no additional cost.
1288 > * When true the model should always run in `long_context` tier without showing
1289 > * a context-size picker.
1290 > */
1291 > private _isFreeLongContext(modelId: string | undefined): boolean {
1292 return !!modelId && this._freeLongContextModels.has(modelId);
1293 }
1295 > /**
1296 > * Builds the open `_meta` model picker bag from the SDK's billing and picker metadata.
1297 > */
1298 > private _createModelPickerMeta(modelInfo: ModelInfo, billing: ICAPIModelBilling | undefined): Record<string, unknown> | undefined {
1299 return createPricingMetaFromBilling(billing, modelInfo.modelPickerPriceCategory, modelInfo.modelPickerCategory);
1300 }
1302 > private _createModelConfigSchema(m: ModelInfo, billing: ICAPIModelBilling | undefined): ConfigSchema | undefined {
1303 const properties: ConfigSchema['properties'] = {};
1304 const thinkingLevel = this._createThinkingLevelConfigSchemaProperty(m.supportedReasoningEfforts, m.defaultReasoningEffort);
1315 return { type: 'object', properties };
1316 }
1318 > private _serializeModelSelection(model: ModelSelection): string {
1319 return JSON.stringify(model);
1320 }
1322 > private _parseModelSelection(raw: string | undefined): ModelSelection | undefined {
1323 if (!raw) {
1324 return undefined;
1348 return { id: raw };
1349 }
1351 > private _serializeAgentSelection(agent: AgentSelection): string {
1352 return JSON.stringify({ uri: agent.uri });
1353 }
1355 > private _parseAgentSelection(raw: string | undefined): AgentSelection | undefined {
1356 if (!raw) {
1357 return undefined;
1367 return undefined;
1368 }
1370 > /**
1371 > * Resolves an {@link AgentSelection}'s SDK-facing name from the plugin
1372 > * snapshot that is, or will be, applied to the SDK session.
1373 > */
1374 > private _resolveAgentName(snapshot: IActiveClientSnapshot, agent: AgentSelection): string | undefined {
1375 for (const plugin of snapshot.plugins) {
1376 const found = plugin.agents.find(a => a.uri.toString() === agent.uri);
1381 return undefined;
1382 }
1384 > async listSessions(): Promise<IAgentSessionMetadata[]> {
1385 this._logService.info('[Copilot] Listing sessions...');
1386 const client = await this._ensureClient();
1414 return result;
1415 }
1417 > async getSessionMetadata(session: URI): Promise<IAgentSessionMetadata | undefined> {
1418 const sessionId = AgentSession.id(session);
1419 const storedMetadata = await this._readStoredSessionMetadata(session);
1445 };
1446 }
1448 > private async _listModels(gitHubToken: string): Promise<IAgentModelInfo[]> {
1449 this._logService.info('[Copilot] Listing models...');
1450 const client = await this._ensureClient();
1481 return result;
1482 }
1484 > /**
1485 > * Resolves the working directory for a {@link createSession} call: the caller-supplied folder, else a
1486 > * still-provisional session's folder for an idempotent re-create, else — when the session is workspace-less
1487 > * (no `workingDirectory` supplied) — a stable per-session scratch directory.
1488 > */
1489 > private async _resolveCreateWorkingDirectory(sessionConfig: IAgentCreateSessionConfig, sessionId: string, isWorkspaceless: boolean): Promise<URI> {
1490 const existing = sessionConfig.workingDirectory ?? this._provisionalSessions.get(sessionId)?.workingDirectory;
1491 if (existing) {
1507 return workingDirectory;
1508 }
1510 > /**
1511 > * Stable per-session scratch directory for a workspace-less chat:
1512 > * `<userHome>/.copilot/chats/<sessionId>`. Deterministic, persistent, and
1513 > * cleaned up on session delete (see {@link _cleanupWorkspacelessScratchDir}).
1514 > */
1515 > private _workspacelessScratchDir(sessionId: string): URI {
1516 return workspacelessScratchDir(this._environmentService.userHome, sessionId);
1517 }
1519 > /** Ensures a workspace-less chat's scratch dir exists (mkdir -p), recreating it if it was reaped. */
1520 > private async _ensureWorkspacelessScratchDir(scratchDir: URI, sessionId: string): Promise<void> {
1521 try {
1522 await fs.mkdir(scratchDir.fsPath, { recursive: true });
1526 }
1527 }
1529 > /** Removes a workspace-less chat's stable scratch dir on session delete/dispose. */
1530 > private async _cleanupWorkspacelessScratchDir(scratchDir: URI, sessionId: string): Promise<void> {
1531 try {
1532 await fs.rm(scratchDir.fsPath, { recursive: true, force: true });
1536 }
1537 }
1539 > // ---- Chat surface ------------------------------------------------------
1540 > //
1541 > // The chat-addressed operation surface (see
1542 > // {@link IAgent.chats}). The orchestrator owns the feature-level
1543 > // `(session, chat)` mapping and hands these methods a single,
1544 > // concrete chat channel URI: the default chat channel or an additional
1545 > // peer chat channel. Each method re-derives the `(session, chat)` pair
1546 > // the agent's internal SDK storage is keyed by via
1547 > // {@link _resolveChatTarget}.
1548 >
1549 > /**
1550 > * Maps a resolved chat URI to the `(session, chat)` pair the agent's
1551 > * internal storage is keyed by. A peer (`ahp-chat`) chat carries its
1552 > * owning session in its URI. The default chat is addressed by its
1553 > * deterministic chat channel URI.
1554 > */
1555 > private _resolveChatTarget(chat: URI): { session: URI; chat: URI } {
1556 const parsed = parseChatUri(chat);
1557 if (!parsed) {
1560 return { session: URI.parse(parsed.session), chat: chat };
1561 }
1563 > private _getChatContext(chatOrSession: URI): { session: URI; sessionId: string; chatKey: string; target: CopilotAgentSession | undefined; isPeerChat: boolean } {
1564 // Accept either a chat channel URI or a bare session URI: per the AHP
1565 // convention the default chat's URI equals the session URI, so callers
1579 };
1580 }
1582 > /**
1583 > * Resolve the session's materialized default (main) chat by raw session id,
1584 > * or `undefined` when the session is provisional or not in memory. The
1585 > * default chat is the primary {@link CopilotAgentSession} of the owning
1586 > * {@link CopilotSessionEntry}.
1587 > */
1588 > private _findAnySession(sessionId: string): CopilotAgentSession | undefined {
1589 return this._sessions.get(sessionId)?.defaultChat;
1590 }
1592 > private _getRuntimeSlashCommands(sessionId: string, options?: ICopilotRuntimeSlashCommandQueryOptions) {
1593 const session = this._findAnySession(sessionId);
1594 if (session) {
1597 return this._slashCommandProvider.getSlashCommands(options);
1598 }
1600 > /**
1601 > * Resolve a live peer (non-default) chat — its own SDK chat — by
1602 > * looking it up within the owning session's entry. Returns `undefined` when
1603 > * the session (or the peer chat) is not in memory.
1604 > */
1605 > private _findPeerChat(session: URI, chat: URI): CopilotAgentSession | undefined {
1606 return this._sessions.get(AgentSession.id(session))?.getPeerChat(chat.toString());
1607 }
1609 > /**
1610 > * Return the owning session's entry, creating an empty one (no default chat
1611 > * yet) if needed so a peer chat can be hosted on a still-provisional parent.
1612 > */
1613 > private _ensureEntry(sessionId: string): CopilotSessionEntry {
1614 let entry = this._sessions.get(sessionId);
1615 if (!entry) {
1619 return entry;
1620 }
1622 > /**
1623 > * Chat-addressed surface for the chats within a session.
1624 > */
1625 > readonly chats: IAgentChats = {
1626 > createChat: (chat: URI, options?: IAgentCreateChatOptions): Promise<IAgentCreateChatResult | void> => {
1627 return this._createChat(chat, options);
1628 },
1629 > fork: (chat: URI, source: IAgentCreateChatForkSource, options?: IAgentCreateChatOptions): Promise<IAgentCreateChatResult | void> => { copilotAgent.ts
1630 return this._createChat(chat, { ...options, fork: source });
1631 },
1632 > disposeChat: (chatUri: URI): Promise<void> => { copilotAgent.ts
1633 const { session, chat } = this._resolveChatTarget(chatUri);
1634 return this._disposeChat(session, chat);
1635 },
1636 > sendMessage: (chatUri: URI, prompt: string, workingDirectory: URI | undefined, attachments?: readonly MessageAttachment[], turnId?: string, senderClientId?: string): Promise<void> => { copilotAgent.ts
1637 return this._sendMessage(chatUri, prompt, attachments, turnId, senderClientId, workingDirectory);
1638 },
1639 > abort: (chatUri: URI): Promise<void> => { copilotAgent.ts
1640 return this._abortSession(chatUri);
1641 },
1642 > changeModel: (chatUri: URI, model: ModelSelection): Promise<void> => { copilotAgent.ts
1643 return this._changeModel(chatUri, model);
1644 },
1645 > changeAgent: (chatUri: URI, agent: AgentSelection | undefined): Promise<void> => { copilotAgent.ts
1646 return this._changeAgent(chatUri, agent);
1647 },
1648 > getMessages: (chat: URI): Promise<readonly Turn[]> => { copilotAgent.ts
1649 return this.getSessionMessages(chat);
1650 },
1651 > }; copilotAgent.ts
1652 >
1653 > async createSession(config?: IAgentCreateSessionConfig): Promise<IAgentCreateSessionResult> {
1654 const sessionConfig = config ?? {};
1655
1813 return { session: sessionUri, workingDirectory, provisional: true, ...(project ? { project } : {}) };
1814 }
1816 > /**
1817 > * Materializes an imported conversation into a real, editable Copilot
1818 > * session. Translates the supplied turns into a Copilot event log, seeds it
1819 > * at the CLI's native per-session store, then resumes the session so the
1820 > * SDK reconstitutes the turns as genuine backend events (editable / forkable
1821 > * / truncatable). The turns arrive with fresh UUID ids assigned by the
1822 > * service layer, so the seeded event ids and the seeded protocol turns stay
1823 > * aligned. Mirrors the immediate-materialization shape of the fork path.
1824 > */
1825 > private async _importConversation(sessionConfig: IAgentCreateSessionConfig, sessionId: string, workingDirectory: URI): Promise<IAgentCreateSessionResult> {
1826 const importConfig = sessionConfig.importConversation!;
1827 const sessionUri = AgentSession.uri(this.id, sessionId);
1858 });
1859 }
1861 > /**
1862 > * Promotes a {@link IProvisionalSession} into a real Copilot SDK session
1863 > * by performing the work that {@link createSession} previously did
1864 > * eagerly: resolves the working directory (creating a worktree if
1865 > * `isolation === 'worktree'`), instantiates the {@link CopilotAgentSession},
1866 > * persists session metadata, and notifies the {@link IAgentService} via
1867 > * {@link onDidMaterializeSession} so it can fire the deferred
1868 > * `sessionAdded` protocol notification.
1869 > *
1870 > * Called from {@link sendMessage} immediately before a turn is dispatched.
1871 > * Already runs inside the session sequencer, so concurrent sends serialize
1872 > * naturally.
1873 > *
1874 > * The latest model lives on the provisional record (kept in sync via
1875 > * {@link changeModel}). The latest provider-owned session config is read
1876 > * straight from the state manager via
1877 > * {@link IAgentConfigurationService.getSessionConfigValues} so any
1878 > * `SessionConfigChanged` actions that arrived after `createSession` are
1879 > * honoured without bespoke forwarding.
1880 > */
1881 > private async _materializeProvisional(sessionId: string, resolvedWorkingDirectory?: URI): Promise<CopilotAgentSession> {
1882 const provisional = this._provisionalSessions.get(sessionId);
1883 if (!provisional) {
1957 return agentSession;
1958 }
1960 > private async _resolveAgentWhenMaterializing(provisional: IProvisionalSession, snapshot: IActiveClientSnapshot, workingDirectory: URI | undefined): Promise<{ agent: AgentSelection; name: string } | undefined> {
1961 const agent = provisional.agent;
1962 if (!agent) {
1977 return undefined;
1978 }
1979 > private _getAlternativeAgentForWorktree(provisional: IProvisionalSession, workingDirectory: URI | undefined): AgentSelection | undefined { copilotAgent.ts
1980 const agent = provisional.agent;
1981 if (!agent) {
1992 return alternativeAgentUri ? { uri: alternativeAgentUri.toString() } : undefined;
1993 }
1995 > async resolveSessionConfig(params: IAgentResolveSessionConfigParams): Promise<ResolveSessionConfigResult> {
1996 // Isolation / branch are contributed by the host (see
1997 // AgentService._withIsolationSchema); this agent only owns its platform
2011 };
2012 }
2014 > async sessionConfigCompletions(_params: IAgentSessionConfigCompletionsParams): Promise<SessionConfigCompletionsResult> {
2015 // Branch completions (the only dynamic Copilot property) are owned by the
2016 // host now; no provider-specific completions remain.
2017 return { items: [] };
2018 }
2020 > getOrCreateActiveClient(session: URI, client: { readonly clientId: string; readonly displayName?: string }): IActiveClient {
2021 const activeClient = this._getOrCreateActiveClient(session, undefined);
2022 // Anchor the customization directory (best-effort, idempotent) so
2031 return activeClient.getOrCreateHandle(client.clientId, client.displayName);
2032 }
2034 > removeActiveClient(session: URI, clientId: string): void {
2035 const sessionId = AgentSession.id(session);
2036 this._logService.info(`[Copilot:${sessionId}] removeActiveClient: clientId=${clientId}`);
2037 this._activeClients.get(session)?.removeClient(clientId);
2038 }
2040 > onClientToolCallComplete(session: URI, chat: URI, toolCallId: string, result: ToolCallResult): void {
2041 const sessionId = AgentSession.id(session);
2042 // Peer (non-default) chats own their SDK chat within the owning
2060 }
2061 }
2063 > private async _sendMessage(chat: URI, prompt: string, attachments?: readonly MessageAttachment[], turnId?: string, senderClientId?: string, workingDirectory?: URI): Promise<void> {
2064 const context = this._getChatContext(chat);
2065 // Additional (non-default) chats are backed by their own SDK
2129 });
2130 }
2132 > /**
2133 > * Translates the AHP-side `mode` to the Copilot SDK's three-mode space
2134 > * (`interactive` / `plan` / `autopilot`). With Autopilot living on the
2135 > * `mode` axis the mapping is now direct:
2136 > *
2137 > * - `mode='plan'` → SDK `plan`.
2138 > * - `mode='autopilot'` → SDK `autopilot` (autonomous, continue-until-done).
2139 > * - `mode='interactive'` → SDK `interactive`.
2140 > *
2141 > * Tool auto-approval is governed independently by the orthogonal
2142 > * `autoApprove` axis (Default / Bypass), enforced by the agent
2143 > * host's own permission handler — which the SDK still invokes even under
2144 > * autopilot mode.
2145 > *
2146 > * Returns `undefined` when no mode is configured for the session, so
2147 > * the SDK's current mode is left untouched.
2148 > */
2149 > private _resolveSdkMode(session: URI): CopilotSdkMode | undefined {
2150 const sessionKey = session.toString();
2151 const mode = this._configurationService.getEffectiveValue(sessionKey, platformSessionSchema, SessionConfigKey.Mode);
2161 }
2162 }
2164 > /**
2165 > * Reads the session's current `mode` and `autoApprove` axis values so the
2166 > * slash-command completion provider can hide config-action toggles that would
2167 > * be a no-op (e.g. `/autopilot on` while already in autopilot).
2168 > */
2169 > private _getSessionConfigState(sessionId: string): ICopilotConfigSlashCommandState {
2170 const sessionKey = AgentSession.uri(this.id, sessionId).toString();
2171 return {
2174 };
2175 }
2177 > setPendingMessages(chat: URI, steeringMessage: PendingMessage | undefined, _queuedMessages: readonly PendingMessage[]): void {
2178 const context = this._getChatContext(chat);
2179 if (!context.target) {
2191 // No SDK-level enqueue is needed.
2192 }
2194 > async getSessionMessages(session: URI): Promise<readonly Turn[]> {
2195 // If the URI describes a subagent child session (`<parent>/subagent/<toolCallId>`),
2196 // load the parent's events once and extract the child's filtered turns.
2245 return entry.getMessages();
2246 }
2248 > async getSubagentSessions(session: URI): Promise<readonly IRestoredSubagentSession[]> {
2249 // Only the root SDK session entry owns the event log; peer-chat and
2250 // subagent URIs are derived from it and have no subagents of their own.
2267 return entry ? entry.getSubagentSessions() : [];
2268 }
2270 > async disposeSession(session: URI): Promise<void> {
2271 const sessionId = AgentSession.id(session);
2272 await this._sessionSequencer.queue(sessionId, async () => {
2291 });
2292 }
2294 > /**
2295 > * Non-destructive counterpart to {@link disposeSession}: releases the
2296 > * session's in-memory resources (SDK session/connection, cached entry,
2297 > * active clients, MCP subscriptions) but preserves all durable data — the
2298 > * SDK session log, session database, and worktree stay on disk. The session
2299 > * transparently resumes on the next access via {@link _resumeSession}.
2300 > *
2301 > * No-ops for sessions that have nothing durable to resume from (provisional
2302 > * sessions) or that aren't currently held in memory, and for sessions with a
2303 > * running turn — disconnecting mid-turn would strand the SDK session.
2304 > */
2305 > async releaseSession(session: URI): Promise<void> {
2306 const sessionId = AgentSession.id(session);
2307 await this._sessionSequencer.queue(sessionId, async () => {
2325 });
2326 }
2328 > private async _abortSession(chat: URI): Promise<void> {
2329 const context = this._getChatContext(chat);
2330 if (context.isPeerChat) {
2336 });
2337 }
2339 > private async _createChat(chat: URI, options?: IAgentCreateChatOptions): Promise<IAgentCreateChatResult | void> {
2340 if (isDefaultChatUri(chat)) {
2341 return;
2488 return result;
2489 }
2491 > /**
2492 > * Resolves the {@link CopilotAgentSession} backing a chat URI — the
2493 > * session's default chat (keyed by session id) or an additional peer chat
2494 > * (keyed by the chat URI) — resuming it from disk if necessary.
2495 > */
2496 > private async _resolveChatEntry(session: URI, chatUri: URI): Promise<CopilotAgentSession | undefined> {
2497 const sessionId = AgentSession.id(session);
2498 if (isDefaultChatUri(chatUri) || isEqual(chatUri, session)) {
2501 return this._ensureChatSession(session, chatUri);
2502 }
2504 > /**
2505 > * Forks {@link sourceEntry}'s SDK chat at {@link turnId} via the
2506 > * SDK `sessions.fork` RPC and copies its database into {@link targetDbDir}
2507 > * so the forked chat inherits turn event IDs and file-edit
2508 > * snapshots. Returns the new SDK session id.
2509 > */
2510 > private async _forkSdkChat(client: CopilotClient, sourceEntry: CopilotAgentSession, turnId: string, targetDbDir: URI): Promise<{ sessionId: string; inheritedTurnCount: number }> {
2511 const sourceTurns = await sourceEntry.getMessages();
2512 const sourceTurnIndex = sourceTurns.findIndex(turn => turn.id === turnId);
2541 return { sessionId: newSessionId, inheritedTurnCount };
2542 }
2544 > private async _disposeChat(session: URI, chat: URI): Promise<void> {
2545 if (isDefaultChatUri(chat)) {
2546 return;
2574 }
2575 }
2577 > /**
2578 > * Re-attaches the in-memory backing for a peer chat on session restore,
2579 > * decoding the opaque `providerData` the orchestrator persisted at creation
2580 > * (or the latest {@link onDidChangeChatData}). After this resolves
2581 > * the chat's backing SDK chat can be resumed lazily via
2582 > * {@link _ensureChatSession}. When `providerData` is `undefined` (a legacy
2583 > * session persisted before the orchestrator owned the catalog) the agent
2584 > * falls back to a one-time read of its own `copilot.chats` blob. Best-effort
2585 > * — a corrupt/unknown blob is logged and dropped rather than thrown.
2586 > */
2587 > async materializeChat(chat: URI, providerData: string | undefined): Promise<void> {
2588 if (isDefaultChatUri(chat)) {
2589 return;
2613 this._chatBackings.set(chatKey, backing);
2614 }
2616 > /**
2617 > * Migration-only enumeration of the session's peer chats from the agent's
2618 > * legacy `copilot.chats` catalog, mapping each entry to its channel URI and
2619 > * the same opaque `providerData` blob {@link materializeChat}
2620 > * decodes. The orchestrator calls this once to drain legacy chats into its
2621 > * own catalog.
2622 > */
2623 > async listLegacyChats(session: URI): Promise<readonly IAgentLegacyChat[]> {
2624 const persisted = await this._readPersistedChats(session);
2625 const result: IAgentLegacyChat[] = [];
2629 return result;
2630 }
2632 > /**
2633 > * Resolves the live backing for a peer chat from the in-memory
2634 > * {@link _chatBackings} map, falling back once to the agent's legacy
2635 > * `copilot.chats` catalog (seeding the live map) for sessions that have not
2636 > * been materialized via {@link materializeChat}.
2637 > */
2638 > private async _resolveChatBacking(session: URI, chat: URI): Promise<IPersistedChat | undefined> {
2639 const chatKey = chat.toString();
2640 const live = this._chatBackings.get(chatKey);
2653 return info;
2654 }
2656 > /**
2657 > * Returns the SDK-backed {@link CopilotAgentSession} for an additional peer
2658 > * chat, resuming its backing SDK chat if it is not already in
2659 > * memory (e.g. after a process restart). Returns `undefined` when the chat
2660 > * has no known backing chat.
2661 > */
2662 > private async _ensureChatSession(session: URI, chat: URI): Promise<CopilotAgentSession | undefined> {
2663 const chatKey = chat.toString();
2664 const existing = this._findPeerChat(session, chat);
2717 });
2718 }
2720 > async truncateSession(session: URI, turnId: string | undefined, chat: URI): Promise<void> {
2721 const sessionId = AgentSession.id(session);
2722 if (this._provisionalSessions.has(sessionId)) {
2759 });
2760 }
2762 > private async _changeModel(chat: URI, model: ModelSelection): Promise<void> {
2763 const longContextWindow = this._longContextWindowFor(model.id);
2764 const freeLongContext = this._isFreeLongContext(model.id);
2788 await this._storeSessionMetadata(context.session, model, undefined, undefined, undefined);
2789 }
2791 > private async _changeAgent(chat: URI, agent: AgentSelection | undefined): Promise<void> {
2792 const context = this._getChatContext(chat);
2793 if (context.isPeerChat) {
2814 await this._storeSessionAgentMetadata(context.session, agent);
2815 }
2817 > async shutdown(): Promise<void> {
2818 this._shutdownPromise ??= (async () => {
2819 // Invalidate any request that started before teardown. Token
2836 return this._shutdownPromise;
2837 }
2839 > respondToPermissionRequest(requestId: string, approved: boolean): void {
2840 for (const entry of this._sessions.values()) {
2841 for (const chat of entry.allChatSessions()) {
2846 }
2847 }
2849 > respondToUserInputRequest(requestId: string, response: ChatInputResponseKind, answers?: Record<string, ChatInputAnswer>): void {
2850 for (const entry of this._sessions.values()) {
2851 for (const chat of entry.allChatSessions()) {
2856 }
2857 }
2859 > /**
2860 > * Returns true if this provider owns the given session ID. Includes
2861 > * provisional sessions that have not yet been materialized.
2862 > */
2863 > hasSession(session: URI): boolean {
2864 const sessionId = AgentSession.id(session);
2865 return this._sessions.has(sessionId) || this._provisionalSessions.has(sessionId);
2866 }
2868 > // ---- helpers ------------------------------------------------------------
2869 >
2870 > private async _configureProxyEnv(env: Record<string, string | undefined>): Promise<void> {
2871 const proxy = await this._resolveProxyForSdk(env);
2872 this._appliedProxy = proxy;
2878 }
2879 }
2881 > private async _resolveProxyForSdk(env: Record<string, string | undefined> = process.env): Promise<string | undefined> {
2882 if (!this._isSystemProxyEnabled()) {
2883 return undefined;
2907 }
2908 }
2910 > /**
2911 > * When the GitHub token changes, the token-discovered CAPI endpoint (and so
2912 > * the resolved proxy) can change. The proxy is baked into the SDK subprocess
2913 > * env at client start, so if it would now differ we stop the running client
2914 > * here; the next `_ensureClient` re-resolves it against the new token. No-op
2915 > * when no client is running/starting or the proxy is unchanged.
2916 > */
2917 > private async _restartClientIfProxyChanged(): Promise<void> {
2918 if (!this._client && !this._clientStarting) {
2919 return;
2941 await this._stopClient();
2942 }
2944 > /**
2945 > * Disposes every peer chat hosted on the owning session's entry and drops
2946 > * their live backings from {@link _chatBackings}. The chat URI encodes its
2947 > * parent session, so we recover it via {@link parseChatUri}.
2948 > */
2949 > private _disposeChildChats(sessionId: string): void {
2950 const entry = this._sessions.get(sessionId);
2951 if (entry) {
2961 }
2962 }
2964 > private _getOrCreateActiveClient(session: URI, directory: URI | undefined): ActiveClient {
2965 let client = this._activeClients.get(session);
2966 if (!client) {
2973 return client;
2974 }
2976 > /**
2977 > * Instantiates a {@link CopilotAgentSession} for the given session id.
2978 > * The caller is responsible for awaiting {@link CopilotAgentSession.initializeSession}
2979 > * and, on success, registering the entry in {@link _sessions}. The
2980 > * session is intentionally **not** registered here so a concurrent
2981 > * {@link _resumeSession} for the same id cannot dispose this entry mid-init
2982 > * via {@link DisposableMap.set}.
2983 > */
2984 > private _createAgentSession(launchPlan: CopilotSessionLaunchPlan, customizationDirectory: URI | undefined, activeClient: ActiveClient, identity?: ICopilotAgentSessionIdentity): CopilotAgentSession {
2985 const sessionUri = identity?.sessionUri ?? AgentSession.uri(this.id, launchPlan.sessionId);
2986 const chatChannelUri = identity?.chatChannelUri ?? URI.parse(buildDefaultChatUri(sessionUri));
3013 return agentSession;
3014 }
3016 > /**
3017 > * Register a freshly initialised session in `_sessions`, or — if
3018 > * shutdown has already started between init beginning and resolving —
3019 > * dispose the session and throw {@link CancellationError}. Without this
3020 > * guard an in-flight `_resumeSession` / `_materializeProvisional` whose
3021 > * `initializeSession()` resolves after `dispose()` has run would call
3022 > * `_sessions.set(...)` on a disposed `DisposableMap`, leaking the
3023 > * session and reproducing the very 'Trying to add a disposable to a
3024 > * DisposableStore that has already been disposed' warning this fix
3025 > * exists to prevent.
3026 > */
3027 > private _registerInitializedSession(sessionId: string, agentSession: CopilotAgentSession): void {
3028 if (this._shutdownPromise) {
3029 agentSession.dispose();
3042 entry.setDefaultChat(defaultChatKey, new CopilotSessionEntry(agentSession));
3043 }
3045 > private async _destroyAndDisposeSession(sessionId: string): Promise<void> {
3046 await this._releaseSessionResources(sessionId);
3047 }
3049 > /**
3050 > * Tears down a session's in-memory resources without deleting any durable
3051 > * data: the SDK session is disconnected, peer chats and MCP subscriptions
3052 > * are disposed, the `_sessions` entry is dropped, and active clients are
3053 > * released. The on-disk SDK session log, session database, and worktree are
3054 > * left untouched, so the session can be resumed later via
3055 > * {@link _resumeSession}. Shared by the non-destructive {@link releaseSession}
3056 > * path and the destructive {@link _destroyAndDisposeSession} path (the
3057 > * latter reaps the worktree afterwards).
3058 > */
3059 > private async _releaseSessionResources(sessionId: string): Promise<void> {
3060 // Tear down any peer chats owned by this session first so their SDK
3061 // chats don't leak when the parent is deleted/disposed
3090 this._activeClients.delete(sessionUri);
3091 }
3093 > protected _resumeSession(sessionId: string): Promise<CopilotAgentSession> {
3094 const existing = this._resumingSessions.get(sessionId);
3095 if (existing) {
3106 return promise;
3107 }
3109 > private async _doResumeSession(sessionId: string): Promise<CopilotAgentSession> {
3110 this._logService.info(`[Copilot:${sessionId}] _resumeSession called — session not in memory, resuming...`);
3111 const client = await this._ensureClient();
3176 return agentSession;
3177 }
3179 > // ---- session metadata persistence --------------------------------------
3180 >
3181 > private static readonly _META_MODEL = 'copilot.model';
3182 > private static readonly _META_AGENT = 'copilot.agent';
3183 > private static readonly _META_CWD = 'copilot.workingDirectory';
3184 > private static readonly _META_CUSTOMIZATION_DIRECTORY = 'copilot.customizationDirectory';
3185 > private static readonly _META_PROJECT_RESOLVED = 'copilot.project.resolved';
3186 > private static readonly _META_PROJECT_URI = 'copilot.project.uri';
3187 > private static readonly _META_PROJECT_DISPLAY_NAME = 'copilot.project.displayName';
3188 > /** Persisted catalog of additional (non-default) peer chats, keyed by chatId. */
3189 > private static readonly _META_CHATS = 'copilot.chats';
3190 >
3191 > /**
3192 > * Reads the agent's legacy peer-chat catalog (`copilot.chats`) for a
3193 > * session. Each entry maps a chatId (the `ahp-chat` authority) to the SDK
3194 > * chat that backs it (and its optional model override). The agent
3195 > * no longer *writes* this catalog — the orchestrator owns the durable
3196 > * peer-chat catalog via `providerData` — but the read is retained for one
3197 > * release to drain sessions persisted before that migration (see
3198 > * {@link getChats} and {@link materializeChat}).
3199 > */
3200 > private async _readPersistedChats(session: URI): Promise<Map<string, IPersistedChat>> {
3201 const ref = await this._sessionDataService.tryOpenDatabase(session);
3202 if (!ref) {
3231 }
3232 }
3234 >
3235 > private async _storeSessionMetadata(session: URI, model: ModelSelection | undefined, workingDirectory: URI | undefined, customizationDirectory: URI | undefined, project: IAgentSessionProjectInfo | undefined, projectResolved = project !== undefined): Promise<void> {
3236 const dbRef = this._sessionDataService.openDatabase(session);
3237 const db = dbRef.object;
3259 }
3260 }
3262 > private async _readSessionMetadata(session: URI): Promise<{ model?: ModelSelection; agent?: AgentSelection; workingDirectory?: URI; customizationDirectory?: URI; workspaceless?: boolean }> {
3263 const ref = await this._sessionDataService.tryOpenDatabase(session);
3264 if (!ref) {
3284 }
3285 }
3287 > private async _readStoredSessionMetadata(session: URI): Promise<{ model?: ModelSelection; agent?: AgentSelection; workingDirectory?: URI; customizationDirectory?: URI; project?: IAgentSessionProjectInfo; resolved: boolean; workspaceless?: boolean } | undefined> {
3288 const ref = await this._sessionDataService.tryOpenDatabase(session);
3289 if (!ref) {
3316 }
3317 }
3319 > /**
3320 > * Persists (or clears) the selected custom agent for a session. Writing
3321 > * `undefined` clears the stored selection by writing an empty string,
3322 > * which later cold reads treat as "no custom agent" because
3323 > * `_parseAgentSelection` short-circuits on falsy metadata values.
3324 > */
3325 > private async _storeSessionAgentMetadata(session: URI, agent: AgentSelection | undefined): Promise<void> {
3326 const dbRef = this._sessionDataService.openDatabase(session);
3327 try {
3334 }
3335 }
3337 > private async _storeSessionProjectResolution(session: URI, project: IAgentSessionProjectInfo | undefined): Promise<void> {
3338 await this._storeSessionMetadata(session, undefined, undefined, undefined, project, true);
3339 }
3341 > private _resolveSessionProject(context: ICopilotSessionContext | undefined, limiter: Limiter<IAgentSessionProjectInfo | undefined>, projectByContext: Map<string, Promise<IAgentSessionProjectInfo | undefined>>): Promise<IAgentSessionProjectInfo | undefined> {
3342 const key = this._projectContextKey(context);
3343 if (!key) {
3352 return project;
3353 }
3355 > private _projectContextKey(context: ICopilotSessionContext | undefined): string | undefined {
3356 if (context?.cwd) {
3357 return `cwd:${context.cwd}`;
3365 return undefined;
3366 }
3368 > override dispose(): void {
3369 for (const ac of this._activeClients.values()) {
3370 ac.dispose();
3375 }).finally(() => super.dispose());
3376 }
3377 > } copilotAgent.ts
3378 >
3379 > interface IResolvedCustomization {
3380 > readonly customization: PluginCustomization;
3381 > readonly pluginDir?: URI;
3382 > readonly plugin?: IParsedPlugin;
3383 > /**
3384 > * The original client-published input. Retained so a later
3385 > * {@link SessionPluginController.retryFailedClientSyncIfNeeded} can
3386 > * re-issue the sync without needing the caller to re-supply it (in
3387 > * particular, the opaque `nonce` is preserved).
3388 > */
3389 > readonly input?: ClientPluginCustomization;
3390 > }
3391 >
3392 > export const REFRESH_DEBOUNCE_MS = 100;
3393 >
3394 > /**
3395 > * A per-working-directory bundle of customizations the agent host
3396 > * discovered itself from disk (workspace + user-home conventions).
3397 > *
3398 > * Owns a {@link SessionCustomizationDiscovery} (filesystem scan +
3399 > * watchers) and maps discovered files into an in-memory
3400 > * {@link IParsedPlugin} while preserving original file URIs.
3401 > *
3402 > * Refreshes itself when the discovery fires `onDidChange`. The owning
3403 > * {@link PluginController} is notified via the supplied `onDidRefresh`
3404 > * callback so it can re-fire its own change event and (indirectly) cause
3405 > * sessions to pick up the new bundle through the existing
3406 > * `isOutdated` snapshot path.
3407 > */
3408 > class SessionDiscoveredEntry extends Disposable {
3409 >
3410 >
3411 > private readonly _discovery: SessionCustomizationDiscovery;
3412 > private readonly _refreshDelayer = this._register(new Delayer<void>(REFRESH_DEBOUNCE_MS));
3413 > private _refreshPromise: CancelablePromise<void> | null = null;
3414 > private _pendingRefreshNotify = false;
3415 >
3416 > private _customizations: readonly DirectoryCustomization[] = [];
3417 > private _directories: readonly IDiscoveredDirectory[] | undefined;
3418 > private _settled: Promise<void>;
3419 >
3420 > constructor(
3421 workingDirectory: URI,
3422 userHome: URI,
3438 }));
3439 }
3441 > override dispose(): void {
3442 this._refreshPromise?.cancel();
3443 this._refreshPromise = null;
3444 super.dispose();
3445 }
3447 > whenSettled(): Promise<void> {
3448 return this._settled;
3449 }
3451 > currentCustomizations(): readonly DirectoryCustomization[] {
3452 return this._customizations;
3453 }
3455 > private _queueRefresh(notify: boolean, delay = REFRESH_DEBOUNCE_MS): Promise<void> {
3456 this._refreshPromise?.cancel();
3457 this._refreshPromise = null;
3483 }, delay);
3484 }
3486 > private async _refresh(token: CancellationToken): Promise<boolean> {
3487 try {
3488 const mode = this._configurationService.getRootValue(agentHostCustomizationConfigSchema, AgentHostConfigKey.SessionCustomizationDiscoveryMode)
3535 }
3536 }
3537 > } copilotAgent.ts
3538 >
3539 > export function toDiscoveredDirectoryCustomizations(directories: readonly IDiscoveredDirectory[], fileService: IFileService): Promise<DirectoryCustomization[]> {
3540 return Promise.all(directories.map(async directory => {
3541 const protocolUri = directory.uri.toString();
3553 }));
3554 }
3556 function toDirectoryContentsType(type: DiscoveredType): ChildCustomizationType {
3557 switch (type) {
3567 }
3568 }
3570 async function toDiscoveredChildCustomization(file: URI, type: DiscoveredType, fileService: IFileService): Promise<ChildCustomization> {
3571 const uri = file.toString();
3627 };
3628 }
3630 >
3631 > /**
3632 > * Projects already-parsed discovered customizations into an in-memory
3633 > * {@link IParsedPlugin} while preserving original source URIs.
3634 > */
3635 > export function mapToParsedPlugin(customizations: readonly DirectoryCustomization[]): IParsedPlugin | undefined {
3636 if (customizations.length === 0) {
3637 return undefined;
3691 };
3692 }
3694 > /**
3695 > * Process-wide plugin state shared across all sessions.
3696 > *
3697 > * Owns:
3698 > * - host-configured customizations (read from root config, watched, parsed)
3699 > * - the {@link IAgentPluginManager} that materializes plugin source URIs
3700 > * into a nonce-deduped on-disk cache (one shared directory for all
3701 > * sessions and clients)
3702 > * - parsing + resolution helpers used by both host- and client-side
3703 > * customizations
3704 > *
3705 > * Per-session state (client-published customizations and on-disk
3706 > * customization discovery for the session's working directory) lives on {@link SessionPluginController},
3707 > * one per {@link CopilotAgentSession}. Each session controller holds
3708 > * a reference back to this shared controller for the resolve/sync
3709 > * helpers it needs.
3710 > */
3711 > class PluginController extends Disposable {
3712 > private readonly _onDidChange = this._register(new Emitter<void>());
3713 > /** Fires when host customizations change. Session controllers forward this. */
3714 > readonly onDidChange = this._onDidChange.event;
3715 >
3716 > private _hostCustomizations: readonly IResolvedCustomization[] = [];
3717 > private _hostSync: Promise<readonly IResolvedCustomization[]> = Promise.resolve([]);
3718 > private _hostRevision = 0;
3719 > private _lastAppliedRefs: readonly Customization[] = [];
3720 >
3721 > constructor(
3722 private readonly _getClient: () => Promise<CopilotClient>,
3723 @IAgentPluginManager public readonly pluginManager: IAgentPluginManager,
3736 }));
3737 }
3739 > public getConfiguredHostCustomizations(): readonly Customization[] {
3740 return this._hostCustomizations.map(item => item.customization);
3741 }
3743 > public get configurationService(): IAgentConfigurationService {
3744 return this._configurationService;
3745 }
3747 > /**
3748 > * Snapshot the resolved host customizations (loading or loaded). Used by
3749 > * {@link SessionPluginController} to compose its per-session view.
3750 > */
3751 > public hostCustomizations(): readonly IResolvedCustomization[] {
3752 return this._hostCustomizations;
3753 }
3755 > /** In-flight host sync; awaited by `getCustomizationsSettled` consumers. */
3756 > public hostSync(): Promise<readonly IResolvedCustomization[]> {
3757 return this._hostSync;
3758 }
3760 > public getUserHome(): URI {
3761 return this._environmentService.userHome;
3762 }
3764 > public async getClient(): Promise<CopilotClient> {
3765 return this._getClient();
3766 }
3768 > /**
3769 > * Construct a per-session controller bound to the given customization
3770 > * directory. The returned controller is a {@link Disposable} owned by
3771 > * the caller; disposing it releases the session's disk-discovery
3772 > * watchers and detaches from this controller's change event.
3773 > */
3774 > public createSessionController(session: URI, directory: URI | undefined): SessionPluginController {
3775 return this._instantiationService.createInstance(SessionPluginController, this, session, directory);
3776 }
3778 > /**
3779 > * Reads the current host customizations from the root config and
3780 > * resolves them. Skips the update when the configured refs have not
3781 > * changed since the last application.
3782 > */
3783 > private _applyHostCustomizations(): void {
3784 const entries = this._configurationService.getRootValue(agentHostCustomizationConfigSchema, AgentHostConfigKey.Customizations) ?? [];
3785 const customizations = entries.map(toContainerCustomization);
3808 });
3809 }
3811 > public async resolveConfiguredCustomization(customization: PluginCustomization): Promise<IResolvedCustomization> {
3812 const pluginDir = URI.parse(customization.uri);
3813 const parsed = await this.tryParsePlugin(pluginDir);
3831 };
3832 }
3834 > public async resolveSyncedCustomization(item: ISyncedCustomization, clientId: string, input: ClientPluginCustomization | undefined): Promise<IResolvedCustomization> {
3835 const baseCustomization: PluginCustomization = { ...item.customization, clientId };
3836 if (!item.pluginDir) {
3859 };
3860 }
3862 > public async tryParsePlugin(pluginDir: URI): Promise<IParsedPlugin | undefined> {
3863 try {
3864 return await parsePlugin(pluginDir, this._fileService, undefined, this.getUserHome(), pluginDir);
3868 }
3869 }
3870 > } copilotAgent.ts
3871 >
3872 > /**
3873 > * Per-client slice of {@link SessionPluginController} customization state.
3874 > * One entry exists per active client that has contributed customizations to
3875 > * the session.
3876 > */
3877 > interface IClientCustomizationState {
3878 > /** Monotonic revision used to detect and ignore stale in-flight syncs for this client. */
3879 > revision: number;
3880 > /** This client's resolved customizations (Loading/Loaded/Error per item). */
3881 > customizations: readonly IResolvedCustomization[];
3882 > /** This client's in-flight (or settled) sync promise. */
3883 > sync: Promise<readonly IResolvedCustomization[]>;
3884 > /** The raw inputs last passed to {@link SessionPluginController.sync} for this client. */
3885 > inputs: readonly ClientPluginCustomization[];
3886 > }
3887 >
3888 > /**
3889 > * Per-session view over {@link PluginController}.
3890 > *
3891 > * Owns the session-scoped slice of plugin state — published client
3892 > * customizations and on-disk-discovered customizations under the session's
3893 > * customization directory — and exposes a {@link onDidPublish} stream of
3894 > * {@link SessionAction}s targeted at *this* session (no cross-session
3895 > * routing).
3896 > *
3897 > * Created via {@link PluginController.createSessionController}. The
3898 > * caller owns the returned disposable and disposes it when the session
3899 > * (provisional or materialized) is torn down.
3900 > */
3901 > class SessionPluginController extends Disposable {
3902 > private readonly _onDidPublish = this._register(new Emitter<SessionAction>());
3903 > /** Per-session action stream (reset + per-item updates). */
3904 > readonly onDidPublish = this._onDidPublish.event;
3905 >
3906 > private readonly _previousDirectories: URI[] = [];
3907 > private _indexedDesiredCustomizations: readonly Customization[] | undefined;
3908 > private readonly _desiredCustomizationById = new Map<string, Customization | ChildCustomization>();
3909 > /**
3910 > * Live runtime state (`state`/`channel`) per MCP server customization id,
3911 > * kept up to date by the owning session from its MCP controller. Overlaid
3912 > * onto published customizations by {@link _overlayMcpState} so a re-sync
3913 > * preserves the live state of otherwise-unchanged MCP servers instead of
3914 > * resetting them to the `Stopped` default baked into
3915 > * `makeMcpServerCustomization`. Exposed (not injected) so the session can
3916 > * write to it once it holds this controller.
3917 > */
3918 > public readonly mcpServerStates: ISettableObservable<ReadonlyMap<string, IMcpServerRuntimeState>> = observableValue(this, new Map());
3919 > /**
3920 > * Per-client customization state, keyed by `clientId`. Each active client
3921 > * contributing customizations to this session has one entry; the published
3922 > * customization list is the union across all entries (deduplicated by URI,
3923 > * first-inserted client wins). Insertion order is preserved so the merged
3924 > * order stays stable across updates.
3925 > */
3926 > private readonly _clients = new Map<string, IClientCustomizationState>();
3927 >
3928 > private readonly _sessionDiscovered: MutableDisposable<SessionDiscoveredEntry> = this._register(new MutableDisposable());
3929 >
3930 > constructor(
3931 private readonly _parent: PluginController,
3932 private readonly _session: URI,
3938 super();
3939 }
3941 > public get directory(): URI | undefined {
3942 return this._directory;
3943 }
3945 > /**
3946 > * Anchor (or re-anchor) the session's customization directory.
3947 > * Only ever transitions from `undefined` → set; once a directory has
3948 > * been bound the discovered entry is pinned to it for the remainder
3949 > * of the session.
3950 > */
3951 > public setDirectory(directory: URI | undefined): void {
3952 if (this._directory || !directory) {
3953 return;
3955 this._directory = directory;
3956 }
3958 > /**
3959 > * Move the session's customization anchor to a new directory (e.g. from the
3960 > * user-picked folder to the worktree at materialization). Recreates the
3961 > * discovered entry so discovery/watchers re-scan the new directory.
3962 > */
3963 > public reanchor(directory: URI): void {
3964 if (this._directory && isEqual(this._directory, directory)) {
3965 return;
3972 }
3973 }
3975 > public getCustomizations(): readonly Customization[] {
3976 const result: Customization[] = [
3977 ...this._parent.hostCustomizations().map(item => this._projectForPublish(item.customization)),
3985 return result;
3986 }
3988 > /**
3989 > * The union of every active client's resolved customizations,
3990 > * deduplicated by URI with the first-inserted client winning. Order
3991 > * follows client insertion order, then per-client order.
3992 > */
3993 > private _flattenClientCustomizations(): readonly IResolvedCustomization[] {
3994 const seen = new Set<string>();
3995 const result: IResolvedCustomization[] = [];
4005 return result;
4006 }
4008 > /**
4009 > * Settled variant of {@link getCustomizations}: awaits the in-flight
4010 > * host sync, every in-flight client sync, and the discovered entry's
4011 > * initial scan + parse before snapshotting the list. Callers that
4012 > * publish customizations into session state at session creation time
4013 > * MUST use this — the synchronous variant can return an empty list
4014 > * for a brand-new working directory because {@link SessionDiscoveredEntry}
4015 > * kicks off its `_refresh()` without anyone awaiting it.
4016 > */
4017 > public async getCustomizationsSettled(): Promise<readonly Customization[]> {
4018 const entry = this._discoveredEntry();
4019 await Promise.all([
4024 return this.getCustomizations();
4025 }
4027 > /** Returns the parsed plugins currently enabled for this session, awaiting any pending sync. */
4028 > public async getAppliedPlugins(): Promise<readonly ICopilotPluginInfo[]> {
4029 const entry = this._discoveredEntry();
4030 const [host] = await Promise.all([
4052 ];
4053 }
4055 > /**
4056 > * Sync the published customizations for a single client of this session,
4057 > * keyed by `clientId`. Replaces only that client's slice; other clients'
4058 > * customizations are untouched. The published session-state list is the
4059 > * union across all clients.
4060 > *
4061 > * @param quiet when `true`, suppress {@link onDidPublish} events for
4062 > * this sync. Used during eager-create paths where there is no
4063 > * session listener yet; the session-state snapshot picks up the
4064 > * final view directly when the session materializes.
4065 > */
4066 > public sync(clientId: string, customizations: ClientPluginCustomization[], options?: { quiet?: boolean }) {
4067 const quiet = options?.quiet === true;
4068 let client = this._clients.get(clientId);
4147 })));
4148 }
4150 > /**
4151 > * Remove a client's customization contribution from this session,
4152 > * publishing the updated (union) customization list so the removed
4153 > * client's plugins disappear from session state.
4154 > */
4155 > public removeClient(clientId: string): void {
4156 const client = this._clients.get(clientId);
4157 if (!client) {
4169 });
4170 }
4172 > /** The raw input customizations last synced for `clientId` (empty when absent). */
4173 > public clientInputs(clientId: string): readonly ClientPluginCustomization[] {
4174 return this._clients.get(clientId)?.inputs ?? [];
4175 }
4177 > /**
4178 > * Re-issue each client's last sync if any of its previously-synced
4179 > * customizations is currently in an error state. Used to recover from
4180 > * transient sync failures (e.g. a `vscode-agent-host://` connection drop
4181 > * during reconnection) at message boundaries. Re-syncs **only** the
4182 > * errored items and always non-quiet so listeners observe recovery.
4183 > */
4184 > public async retryFailedClientSyncIfNeeded(): Promise<void> {
4185 await Promise.all([...this._clients.values()].map(client => client.sync.catch(() => { })));
4186 for (const [clientId, client] of [...this._clients]) {
4199 }
4200 }
4202 > private _discoveredEntry(): SessionDiscoveredEntry | undefined {
4203 if (!this._directory) {
4204 return undefined;
4217 return this._sessionDiscovered.value;
4218 }
4220 > private _isEnabled(customization: Customization): boolean {
4221 return this._desiredEnabled(customization) ?? customization.enabled !== false;
4222 }
4224 > private _applyEnablement<T extends Customization>(customization: T): T {
4225 const enabled = this._isEnabled(customization);
4226 if (customization.type === CustomizationType.McpServer) {
4238 return changed ? { ...customization, enabled, children } : customization;
4239 }
4241 > private _desiredEnabled(customization: Customization | ChildCustomization): boolean | undefined {
4242 const exact = this._getDesiredCustomization(customization.id);
4243 if (exact) {
4260 return undefined;
4261 }
4263 > private _getDesiredCustomization(id: string): Customization | ChildCustomization | undefined {
4264 const customizations = this._stateManager.getSessionState(this._session.toString())?.customizations;
4265 if (customizations !== this._indexedDesiredCustomizations) {
4277 return this._desiredCustomizationById.get(id);
4278 }
4280 > /**
4281 > * Projects a raw customization into its published form: applies reducer-backed
4282 > * per-session enablement, then overlays the latest
4283 > * known MCP runtime `state`/`channel` (see {@link mcpServerStates}).
4284 > * Every publish path runs customizations through this so enablement and
4285 > * live MCP state stay consistent. Object identity is preserved when
4286 > * neither step changes anything, keeping downstream equality checks
4287 > * stable.
4288 > */
4289 > private _projectForPublish<T extends Customization>(customization: T): T {
4290 return this._overlayMcpState(this._applyEnablement(customization));
4291 }
4293 > /**
4294 > * Overlays the latest known MCP runtime `state`/`channel` (see
4295 > * {@link mcpServerStates}) onto a customization and its children,
4296 > * preserving object identity when nothing is overlaid so downstream
4297 > * equality checks stay stable.
4298 > */
4299 > private _overlayMcpState<T extends Customization>(customization: T): T {
4300 const overlays = this.mcpServerStates.get();
4301 if (overlays.size === 0) {
4324 return changed ? { ...customization, children: overlaidChildren } : customization;
4325 }
4326 > } copilotAgent.ts
4327 >
4328 > /**
4329 > * A per-(session, clientId) handle returned by
4330 > * {@link CopilotAgent.getOrCreateActiveClient}. Reads/writes flow straight
4331 > * through to the owning session's {@link ActiveClient} (the multi-client
4332 > * container), so assigning `tools` / `customizations` updates only this
4333 > * client's slice.
4334 > */
4335 > class CopilotActiveClientHandle implements IActiveClient {
4336 > constructor(
4337 private readonly _owner: ActiveClient,
4338 readonly clientId: string,
4339 readonly displayName: string | undefined,
4340 ) { }
4342 > get tools(): readonly ToolDefinition[] {
4343 return this._owner.toolSet.get(this.clientId);
4344 }
4345 > set tools(tools: readonly ToolDefinition[]) { copilotAgent.ts
4346 this._owner.toolSet.set(this.clientId, tools);
4347 }
4349 > get customizations(): readonly ClientPluginCustomization[] {
4350 return this._owner.pluginController.clientInputs(this.clientId);
4351 }
4352 > set customizations(customizations: readonly ClientPluginCustomization[]) { copilotAgent.ts
4353 // Fire-and-forget: progress and the settled result flow out via the
4354 // controller's `onDidPublish` session actions, not the setter.
4355 this._owner.pluginController.sync(this.clientId, [...customizations]).catch(() => { /* logged inside sync */ });
4356 }
4357 > } copilotAgent.ts
4358 >
4359 > /**
4360 > * Tracks per-session active client contributions (tools and plugins) across
4361 > * potentially several active clients. Owns the session's
4362 > * {@link SessionPluginController}, which is the authoritative source for both
4363 > * the plugin snapshot (host + all clients + session-discovered) and
4364 > * per-session action events, and the {@link ActiveClientToolSet} that merges
4365 > * every client's tools. Disposing this tears down the controller and any disk
4366 > * watchers it created.
4367 > */
4368 > class ActiveClient extends Disposable {
4369 > /**
4370 > * Live, multi-client registry of contributed tools. Shared by reference
4371 > * with the session's {@link CopilotAgentSession} so a window reload (new
4372 > * `clientId`, identical tools) is reflected at tool-call stamp time without
4373 > * restarting the SDK session, and so tool calls are attributed to the
4374 > * contributing client.
4375 > */
4376 > readonly toolSet = new ActiveClientToolSet();
4377 >
4378 > public readonly pluginController: SessionPluginController;
4379 >
4380 > private readonly _handles = new Map<string, CopilotActiveClientHandle>();
4381 >
4382 > constructor(
4383 private readonly _sessionUri: URI,
4384 pluginController: SessionPluginController,
4394 }));
4395 }
4397 > /** Get (or lazily create) the stable handle for `clientId`. */
4398 > getOrCreateHandle(clientId: string, displayName: string | undefined): CopilotActiveClientHandle {
4399 let handle = this._handles.get(clientId);
4400 if (!handle) {
4404 return handle;
4405 }
4407 > /** Drop a client's tool and customization contributions from this session. */
4408 > removeClient(clientId: string): void {
4409 this._handles.delete(clientId);
4410 this.toolSet.delete(clientId);
4411 this.pluginController.removeClient(clientId);
4412 }
4414 > async snapshot(): Promise<IActiveClientSnapshot> {
4415 return {
4416 tools: this.toolSet.merged(),
4419 };
4420 }
4422 > private _getMcpServers(): AgentHostMcpServers {
4423 const servers = this._configurationService.getRootValue(platformRootSchema, AgentHostMcpServersConfigKey) ?? {};
4424
4425 return structuredClone(servers);
4426 }
4428 > /**
4429 > * Returns `true` when the SDK session must be disposed and resumed to
4430 > * pick up a changed config. Compares ONLY plugins and the structural
4431 > * (merged) tool set (name + description + inputSchema). The owning
4432 > * `clientId`s are deliberately excluded — a clientId-only change is
4433 > * reflected live via {@link toolSet} and never requires a restart.
4434 > */
4435 > async requiresRestart(snap: IActiveClientSnapshot): Promise<boolean> {
4436 const plugins = await this.pluginController.getAppliedPlugins();
4437 if (!parsedPluginsEqual(snap.plugins, plugins)) {
4443 return !this.toolSet.structuralEquals(snap.tools);
4444 }
4445 > } copilotAgent.ts
src/vs/platform/agentHost/node/copilot/sessionCustomizationDiscovery.ts 244 introduced LOC · 30 ranges

Open complete file

1 > /*--------------------------------------------------------------------------------------------- sessionCustomizationDiscovery.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 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) {
54 return false;
65 return true;
66 }
68 function compareDiscoveredDirectory(a: IDiscoveredDirectory, b: IDiscoveredDirectory): number {
69 const byType = compareStrings(a.type, b.type);
73 return compareStrings(a.uri.toString(), b.uri.toString());
74 }
76 function areDiscoveredFilesEqual(a: readonly IDiscoveredFile[], b: readonly IDiscoveredFile[]): boolean {
77 if (a.length !== b.length) {
89 return true;
90 }
92 function compareDiscoveredFile(a: IDiscoveredFile, b: IDiscoveredFile): number {
93 return compareStrings(a.uri.toString(), b.uri.toString());
94 }
96 function compareDirectoryCustomization(a: DirectoryCustomization, b: DirectoryCustomization): number {
97 const byUri = compareStrings(a.uri, b.uri);
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 {
183 if (token.isCancellationRequested) {
185 }
186 }
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 {
199 let entry = map.get(watchUri);
207 entry.resourcesToWatch.add(resourceToWatch);
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,
231 private readonly _userHome: URI,
247 }));
248 }
250 > private _scheduleRefresh(): void {
251 this._onDidChange.fire();
252 }
254 > private async writeCustomizationDiscoveryDebugLog(payload: Record<string, unknown>): Promise<void> {
255 if (!CUSTOMIZATION_DISCOVERY_DEBUG_LOG_PATH) {
256 return;
267 }
268 }
270 > private async getDiscoveredDirectories(client: CopilotClient, token: CancellationToken): Promise<readonly IDiscoveredDirectory[]> {
271 throwIfCancelled(token);
272
357 return result.sort(compareDiscoveredDirectory);
358 }
360 > private getHooksDiscoveryPaths(): IDiscoveredDirectory[] {
361 const byUri = new ResourceMap<IDiscoveredDirectory>();
362 const add = (uri: URI, name: string): void => {
388 return [...byUri.values()];
389 }
391 > private async _updateWatchers(discoveredDirectories: readonly IDiscoveredDirectory[], token: CancellationToken): Promise<void> {
392 const nextWatchRootUris = new ResourceMap<IWatchSpec>();
393 const toResolve = new ResourceSet();
481 this._reconcileWatchers(nextWatchRootUris);
482 }
484 >
485 > public async discover(client: CopilotClient, token: CancellationToken): Promise<readonly DirectoryCustomization[]> {
486 await this.writeCustomizationDiscoveryDebugLog({
487 method: 'discover',
526 }
527 }
529 > private async discoverAgents(discoveryRequest: AgentsDiscoverRequest, client: CopilotClient, token: CancellationToken): Promise<AgentCustomization[]> {
530 const agents: AgentCustomization[] = [];
531
539 return agents;
540 }
542 > private async discoverRules(discoveryRequest: AgentsDiscoverRequest, client: CopilotClient, token: CancellationToken): Promise<RuleCustomization[]> {
543 const rules: RuleCustomization[] = [];
544 const seenRuleUris = new Set<string>();
600 return rules;
601 }
603 > private _isAgentInstructionSource(instruction: InstructionSource): boolean {
604 if (instruction.type === 'home' || instruction.type === 'repo' || instruction.type === 'model') {
605 return true;
609 return AGENT_INSTRUCTION_FILENAMES.has(filename);
610 }
612 > private async discoverSkills(discoveryRequest: AgentsDiscoverRequest, client: CopilotClient, token: CancellationToken): Promise<SkillCustomization[]> {
613 const skills: SkillCustomization[] = [];
614
622 return skills;
623 }
625 > private async discoverHooks(token: CancellationToken): Promise<HookCustomization[]> {
626 const seen = new ResourceSet();
627 const discoveredDirectories: IDiscoveredDirectory[] = [];
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);
659 let stat: IFileStatWithMetadata | undefined = undefined;
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) {
670 throwIfCancelled(token);
694 }
695 }
697 > private async toDirectoryCustomizations(type: ChildCustomizationType, customizations: readonly ChildCustomization[], allDiscoveredDirectories: readonly IDiscoveredDirectory[], result: DirectoryCustomization[]): Promise<void> {
698 const discoveredDirectories = allDiscoveredDirectories.filter(d => {
699 if (type === CustomizationType.Agent) {
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({
829 method: 'scan',
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;
875 for (const segment of path) {
892 return true;
893 }
895 > private _reconcileWatchers(nextWatchRootUris: ResourceMap<IWatchSpec>): void {
896 // Dispose watchers that are gone or whose recursive flag changed.
897 for (const [rootUri, watcher] of this._watchers.entries()) {
921 }
922 }
924 > private _disposeAllWatchers(): void {
925 for (const watcher of this._watchers.values()) {
926 watcher.disposable.dispose();
928 this._watchers.clear();
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[]>();
937 await Promise.all(roots.map(async root => {
980 }
981 }
983 > private async _scanRoot(base: URI, root: ISearchRoot, seen: ResourceSet, result: IDiscoveredDirectory[], watchRootUris: ResourceMap<IWatchSpec>, token: CancellationToken): Promise<void> {
984 throwIfCancelled(token);
985
1073 }
1074 }
1076 > private async _scanForHooks(root: ISearchRoot, rootUri: URI, stat: IFileStatWithMetadata | undefined, seen: ResourceSet, result: IDiscoveredDirectory[], token: CancellationToken): Promise<void> {
1077 const files: IDiscoveredFile[] = [];
1078 // hooks are recursively discovered as `*.json` under the root.
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 > };
src/vs/platform/agentHost/common/otel/agentHostOTelService.ts 46 introduced LOC · 1 range

Open complete file

1 > /*--------------------------------------------------------------------------------------------- agentHostOTelService.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 type { TelemetryConfig } from '@github/copilot-sdk';
7 > import type { URI } from '../../../../base/common/uri.js';
8 > import { createDecorator } from '../../../instantiation/common/instantiation.js';
9 >
10 >
11 > /**
12 > * Lean service that wires the @github/copilot-sdk telemetry hook to either:
13 > *
14 > * - **External-only mode**: pass user-configured exporter settings straight through
15 > * so the SDK's spawned CLI exports OTel data directly to the user's sink.
16 > * - **DB mode** (`COPILOT_OTEL_DB_SPAN_EXPORTER_ENABLED=true`): point the SDK at a
17 > * loopback OTLP/HTTP receiver, persist all spans into a local SQLite store, and
18 > * optionally fan-out to a user-configured external sink as well.
19 > *
20 > * The interface lives in `common/` so consumers (DI registration, tests, callers
21 > * in other layers) can import it without pulling in the node-only concrete
22 > * implementation and its transitive native dependencies (`node:sqlite`).
23 > */
24 > export interface IAgentHostOTelService {
25 > readonly _serviceBrand: undefined;
26 >
27 > /**
28 > * Returns the telemetry config to hand to `new CopilotClient({ telemetry })`,
29 > * starting the loopback receiver + store on first call when in DB mode.
30 > * Resolves to `undefined` when telemetry is disabled.
31 > */
32 > getSdkTelemetryConfig(): Promise<TelemetryConfig | undefined>;
33 >
34 > /**
35 > * Path of the SQLite span store, or `undefined` when DB mode is off.
36 > */
37 > getSpansDbPath(): URI | undefined;
38 >
39 > /**
40 > * Drain any in-flight outbound forwarding. Safe to call concurrently with
41 > * ongoing ingestion.
42 > */
43 > flush(): Promise<void>;
44 > }
45 >
46 > export const IAgentHostOTelService = createDecorator<IAgentHostOTelService>('agentHostOTelService');