src/vs/platform/agentHost/node/agentService.ts

3903 LOC · 2982 covered · 921 uncovered · 665 ranges · 917 concepts · 191 introducers · 483 tests

File neighbourhood

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

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

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

Graph controls are ready.

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

1 > /*--------------------------------------------------------------------------------------------- agentService.ts ×122
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 { open, unlink, type FileHandle } from 'fs/promises';
7 > import { decodeBase64, VSBuffer } from '../../../base/common/buffer.js';
8 > import { DeferredPromise, disposableTimeout, ResourceQueue } from '../../../base/common/async.js';
9 > import { toErrorMessage } from '../../../base/common/errorMessage.js';
10 > import { Emitter } from '../../../base/common/event.js';
11 > import { Disposable, DisposableMap, DisposableResourceMap, DisposableStore, IDisposable, MutableDisposable } from '../../../base/common/lifecycle.js';
12 > import { ResourceMap } from '../../../base/common/map.js';
13 > import { getExtensionForMimeType, getMediaMime } from '../../../base/common/mime.js';
14 > import { Schemas } from '../../../base/common/network.js';
15 > import { IObservable, observableValue } from '../../../base/common/observable.js';
16 > import { dirname as resourcesDirname, extname as resourcesExtname, extUriBiasedIgnorePathCase, isEqual, isEqualOrParent, joinPath } from '../../../base/common/resources.js';
17 > import { URI } from '../../../base/common/uri.js';
18 > import { generateUuid } from '../../../base/common/uuid.js';
19 > import { hasKey } from '../../../base/common/types.js';
20 > import { localize } from '../../../nls.js';
21 > import { FileChangeType, FileOperationResult, IFileChange, IFileService, toFileOperationResult, type FileChangesEvent } from '../../files/common/files.js';
22 > import { InstantiationService } from '../../instantiation/common/instantiationService.js';
23 > import { ServiceCollection } from '../../instantiation/common/serviceCollection.js';
24 > import { ILogService } from '../../log/common/log.js';
25 > import { AgentProvider, AgentSession, AgentSignal, AgentHostSessionReleaseGraceMsEnvVar, IAgent, IAgentChatDataChange, IAgentCreateChatOptions, IAgentCreateChatResult, IAgentCreateChatSideChatSelection, IAgentCreateChatSideChatSource, IAgentCreateSessionConfig, IAgentCreateSessionResult, IAgentHostAuthTokenRequest, IAgentHostManagedSettingsDiagnostics, IAgentHostNetworkDiagnosticsInfo, IAgentHostNetworkEndpoint, IAgentHostNetworkFetchResult, IAgentMaterializeSessionEvent, IAgentModelInfo, IAgentResolveSessionConfigParams, IAgentService, IAgentSessionConfigCompletionsParams, IAgentSessionMetadata, IAgentSpawnChatEvent, AuthenticateParams, AuthenticateResult, IMcpNotification, IRestoredSubagentSession, SubagentChatSignal } from '../common/agentService.js';
26 > import { ISessionDataService, SESSION_ATTACHMENTS_DIRNAME } from '../common/sessionDataService.js';
27 > import { SessionConfigKey } from '../common/sessionConfigKeys.js';
28 > import type { IAgentCustomizationSettingsRegistration } from '../common/agentCustomizationSettings.js';
29 > import { parseChangesetUri } from '../common/changesetUri.js';
30 > import { ActionType, ActionEnvelope, AuthRequiredReason, INotification, type ChatAction, type IRootConfigChangedAction, type SessionAction, type TerminalAction, type ClientAnnotationsAction, type ClientChangesetAction } from '../common/state/sessionActions.js';
31 > import type { CompletionsParams, CompletionsResult, CreateTerminalParams, ResolveSessionConfigResult, SessionConfigCompletionsResult, SessionConfigPropertySchema } from '../common/state/protocol/commands.js';
32 > import type { InvokeChangesetOperationParams, InvokeChangesetOperationResult } from '../common/state/protocol/channels-changeset/commands.js';
33 > import { AhpErrorCodes, AHP_SESSION_NOT_FOUND, ContentEncoding, JSON_RPC_INTERNAL_ERROR, ProtocolError, ResourceChangeType, ResourceType, ResourceWriteMode, type CreateResourceWatchParams, type CreateResourceWatchResult, type DirectoryEntry, type ResourceCopyParams, type ResourceCopyResult, type ResourceDeleteParams, type ResourceDeleteResult, type ResourceListResult, type ResourceMkdirParams, type ResourceMkdirResult, type ResourceMoveParams, type ResourceMoveResult, type ResourceReadResult, type ResourceResolveParams, type ResourceResolveResult, type ResourceWatchState, type ResourceWriteParams, type ResourceWriteResult, type IStateSnapshot } from '../common/state/sessionProtocol.js';
34 > import { ChangesSummary, ChatInteractivity, ChatOriginKind, MessageAttachmentKind, type ChatOrigin, type Message, type MessageAttachment, type MessageResourceAttachment } from '../common/state/protocol/state.js';
35 > import type { ChatPendingMessageSetAction, ChatTurnStartedAction } from '../common/state/protocol/actions.js';
36 > import { ISessionGitHubState, ISessionGitState, MessageKind, ResponsePartKind, SESSION_META_GITHUB_KEY, SESSION_META_GIT_KEY, readSessionSpawnDepth, withSessionSpawnDepth, SessionStatus, ToolCallStatus, ToolResultContentType, AH_META_WORKSPACELESS_DB_KEY, AH_META_IS_ARCHIVED_DB_KEY, AH_META_IS_DONE_DB_KEY, buildChatUri, buildDefaultChatUri, buildResourceWatchChannelUri, buildSubagentChatUri, buildSubagentSessionUriPrefix, hostBuildInfoFromProduct, isAhpChatChannel, isDefaultChatUri, isSubagentChatUri, isSubagentSession, parseDefaultChatUri, parseRequiredSessionUriFromChatUri, parseResourceWatchChannelUri, parseSubagentSessionUri, readSessionGitState, readSessionWorkspaceless, withSessionGitHubState, withSessionGitState, withSessionWorkspaceless, type SessionConfigState, type SessionSummary, type ToolResultSubagentContent, type Turn } from '../common/state/sessionState.js';
37 > import { readToolCallMeta } from '../common/meta/agentToolCallMeta.js';
38 > import { IProductService } from '../../product/common/productService.js';
39 > import { buildBoundedSideChatSourceContext, getSideChatPartialResponse } from './agentPeerChats.js';
40 > import { AgentConfigurationService, IAgentConfigurationService } from './agentConfigurationService.js';
41 > import { AgentHostTerminalManager, IAgentHostTerminalManager } from './agentHostTerminalManager.js';
42 > import { ISessionDbUriFields, parseSessionDbUri } from './shared/fileEditTracker.js';
43 > import { IGitBlobUriFields, parseGitBlobUri } from './gitDiffContent.js';
44 > import { AgentHostStateManager, IAgentHostStateManager } from './agentHostStateManager.js';
45 > import { IAgentHostGitService } from '../common/agentHostGitService.js';
46 > import { AgentSideEffects } from './agentSideEffects.js';
47 > import { AgentHostLocalTurns } from './agentHostLocalTurns.js';
48 > import { AgentServerToolHost } from './shared/agentServerToolHost.js';
49 > import { buildServerToolGroups } from './shared/serverToolGroups.js';
50 > import { type IChatContextSnapshot, type ISessionServerToolAccessor } from './shared/sessionServerTools.js';
51 >
52 > import { WorktreeIsolation, WORKTREE_META_REPOSITORY_ROOT, worktreeProjectFromRepositoryRoot } from './shared/worktreeIsolation.js';
53 > import { AgentHostChangesetService } from './agentHostChangesetService.js';
54 > import { AgentHostFileMonitorService, IAgentHostFileMonitorService } from './agentHostFileMonitorService.js';
55 > import { IAgentHostCheckpointService, NULL_CHECKPOINT_SERVICE } from '../common/agentHostCheckpointService.js';
56 > import { IAgentHostReviewService } from '../common/agentHostReviewService.js';
57 > import { AgentHostChangesetCoordinator } from './agentHostChangesetCoordinator.js';
58 > import { AgentHostCompletions, IAgentHostCompletions } from './agentHostCompletions.js';
59 > import { AgentHostFileCompletionProvider } from './agentHostFileCompletionProvider.js';
60 > import { AgentHostRenameCompletionProvider } from './agentHostRenameCommand.js';
61 > import { AgentHostSkillCompletionProvider } from './agentHostSkillCompletionProvider.js';
62 > import { AgentHostWorkspaceFiles } from './agentHostWorkspaceFiles.js';
63 > import { CopilotApiService, ICopilotApiService } from './shared/copilotApiService.js';
64 > import { INetworkDiagnosticsService } from './networkDiagnosticsService.js';
65 > import { parseMcpChannelUri } from './shared/mcpCustomizationController.js';
66 > import { toAgentClientUri } from '../common/agentClientUri.js';
67 > import { AgentHostChangesetOperationService } from './agentHostChangesetOperationService.js';
68 > import { AgentHostGitStateService } from './agentHostGitStateService.js';
69 > import { AgentHostGitHubEndpointService, IAgentHostGitHubEndpointService } from './agentHostGitHubEndpointService.js';
70 > import { ITelemetryService } from '../../telemetry/common/telemetry.js';
71 > import { NullTelemetryService } from '../../telemetry/common/telemetryUtils.js';
72 > import { AgentHostAuthenticationService } from './agentHostAuthenticationService.js';
73 > import { updateAgentHostTelemetryLevelFromConfig } from './agentHostTelemetryService.js';
74 > import { AgentHostOctoKitService, IAgentHostOctoKitService } from './shared/agentHostOctoKitService.js';
75 > import { IAgentHostChangesetService, CHANGESET_DB_METADATA_KEYS, META_CHANGES_SUMMARY } from '../common/agentHostChangesetService.js';
76 > import { IAgentHostChangesetSubscriptionService } from '../common/agentHostChangesetSubscriptionService.js';
77 > import { AgentHostChangesetSubscriptionService } from './agentHostChangesetSubscriptionService.js';
78 > import { GIT_DB_METADATA_KEYS, IAgentHostGitStateService, META_GIT_STATE, META_GITHUB_STATE } from '../common/agentHostGitStateService.js';
79 > import { IAgentHostChangesetOperationService } from '../common/agentHostChangesetOperationService.js';
80 > import { AgentHostCommitOperationContribution } from './agentHostCommitOperationProvider.js';
81 > import { AgentHostDiscardChangesOperationContribution } from './agentHostDiscardChangesOperationProvider.js';
82 > import { AgentHostPullRequestOperationContribution } from './agentHostPullRequestOperationProvider.js';
83 > import { AgentHostSyncOperationContribution } from './agentHostSyncOperationProvider.js';
84 > import { AgentHostReviewService } from './agentHostReviewService.js';
85 >
86 > /**
87 > * Grace period before an empty, unsubscribed session is garbage-collected
88 > * via {@link AgentService._runSessionGc}. Gives a disconnected client time
89 > * to reconnect (or a workspace switch to settle) before we tear down the
90 > * provider-side session, worktree, and on-disk state.
91 > */
92 > const SESSION_GC_GRACE_MS = 30_000;
93 >
94 > const HOST_OWNED_SESSION_CONFIG_KEYS = [
95 > SessionConfigKey.Isolation,
96 > SessionConfigKey.Branch,
97 > SessionConfigKey.WorktreeBranchPrefix,
98 > SessionConfigKey.WorktreeIncludeFiles,
99 > ] as const;
100 >
101 > function omitHostOwnedSessionConfig<T>(config: Record<string, T>): Record<string, T> { agentService.ts ×9
102 > const result = { ...config };
103 > for (const key of HOST_OWNED_SESSION_CONFIG_KEYS) {
104 > delete result[key];
105 > }
106 > return result;
107 > }
109 > /**
110 > * Grace period before an idle resource watch is torn down after its last
111 > * subscriber unsubscribes (mirrors {@link SESSION_GC_GRACE_MS}). Within
112 > * this window, a re-subscribe (or reconnect) reuses the still-running
113 > * {@link IFileService} watcher so transient drop-outs don't miss change
114 > * events. Resource watch action envelopes flow through the normal
115 > * envelope replay buffer for the same reason.
116 > */
117 > const RESOURCE_WATCH_GRACE_MS = 30_000;
118 >
119 > /** Bound on how long {@link AgentService.subscribe} waits for a pending subagent chat to register before giving up. */
120 > const SUBAGENT_CHAT_PENDING_TIMEOUT_MS = 15_000;
121 >
122 > /**
123 > * Grace period before an idle session (one with turns, no remaining
124 > * subscribers) is released from memory via {@link AgentService._maybeEvictIdleSession}.
125 > * Deferring the release aligns it with the client disconnect-grace window: a
126 > * client that disconnects and quickly reconnects (or a rapid unsubscribe/
127 > * re-subscribe) reuses the live provider SDK session instead of forcing an
128 > * immediate {@link IAgent.releaseSession} (SDK `disconnect`) followed by a
129 > * resume-from-disk. Releasing synchronously on every last-unsubscribe churns
130 > * the shared provider runtime and races concurrent session operations.
131 > *
132 > * Overridable via {@link AgentHostSessionReleaseGraceMsEnvVar} (test hook).
133 > */
134 > const SESSION_RELEASE_GRACE_MS = (() => {
135 > const raw = process.env[AgentHostSessionReleaseGraceMsEnvVar];
136 > const parsed = raw !== undefined ? parseInt(raw, 10) : NaN;
137 > return Number.isFinite(parsed) && parsed >= 0 ? parsed : 30_000;
138 > })();
139 >
140 > /**
141 > * Session-database metadata key under which the orchestrator persists its own
142 > * catalog of additional (non-default) peer chats for a session. The value is a
143 > * JSON array of {@link IPersistedPeerChat}. This is the orchestrator's single
144 > * source of truth for peer-chat enumeration on restore. When the key is absent
145 > * the session predates orchestrator-owned persistence and a one-time migration
146 > * drains the agent's legacy `*.chats` (see
147 > * {@link AgentService._migrateLegacyPeerChats}).
148 > */
149 > const PEER_CHATS_METADATA_KEY = 'peerChats';
150 >
151 > /**
152 > * Session-database metadata key written on a peer chat's *backing* SDK session
153 > * (see {@link IAgentCreateChatResult.backingSession}). Its presence marks that
154 > * session as an internal peer-chat backing that must never surface as a
155 > * top-level session; the value is the owning peer chat's channel URI string.
156 > * Persisted, so it survives a host restart without re-stamping.
157 > */
158 > const PEER_CHAT_BACKING_METADATA_KEY = 'peerChatBacking';
159 >
160 > /**
161 > * A single entry in the orchestrator's persisted peer-chat catalog. `uri` is
162 > * the peer chat's channel URI; `providerData` is the opaque, agent-owned blob
163 > * (see {@link IAgentCreateChatResult.providerData}) handed back to the agent on
164 > * restore — the orchestrator never parses it. `providerData` may be omitted,
165 > * in which case the agent recovers its backing from its own persistence on
166 > * {@link IAgent.materializeChat}. `origin` records the chat's provenance
167 > * (currently only {@link ChatOriginKind.SideChat}, carrying the source chat and
168 > * stable source turn id) so it survives a restart; omitted for plain peer chats.
169 > */
170 > interface IPersistedPeerChat {
171 > readonly uri: string;
172 > readonly providerData?: string;
173 > readonly origin?: ChatOrigin;
174 > }
175 >
176 > /**
177 > * The agent service implementation that runs inside the agent-host utility
178 > * process. Dispatches to registered {@link IAgent} instances based
179 > * on the provider identifier in the session configuration.
180 > */
181 > export class AgentService extends Disposable implements IAgentService {
182 > declare readonly _serviceBrand: undefined;
183 >
184 > private readonly _resourceWriteQueue = this._register(new ResourceQueue());
185 >
186 > /** Protocol: fires when state is mutated by an action. */
187 > private readonly _onDidAction = this._register(new Emitter<ActionEnvelope>());
188 > readonly onDidAction = this._onDidAction.event;
189 >
190 > /** Protocol: fires for ephemeral notifications (sessionAdded/Removed). */
191 > private readonly _onDidNotification = this._register(new Emitter<INotification>());
192 > readonly onDidNotification = this._onDidNotification.event;
193 >
194 > /** Protocol: fires for MCP server-originated notifications routed over `mcp://` channels. */
195 > private readonly _onMcpNotification = this._register(new Emitter<IMcpNotification>());
196 > readonly onMcpNotification = this._onMcpNotification.event;
197 >
198 > /** Authoritative state manager for the sessions process protocol. */
199 > private readonly _stateManager: AgentHostStateManager;
200 >
201 > /** Exposes the state manager for co-hosting a WebSocket protocol server. */
202 > get stateManager(): AgentHostStateManager { return this._stateManager; }
203 >
204 > /** Exposes the configuration service so agent providers can share root config plumbing. */
205 > get configurationService(): IAgentConfigurationService { return this._configurationService; }
206 >
207 > /** Exposes the GitHub endpoint service so agent providers share GitHub (Enterprise) resource resolution. */
208 > get gitHubEndpointService(): IAgentHostGitHubEndpointService { return this._gitHubEndpointService; }
209 >
210 > /** Registered providers keyed by their {@link AgentProvider} id. */
211 > private readonly _providers = new Map<AgentProvider, IAgent>();
212 > /** Maps each active session URI (toString) to its owning provider. */
213 > private readonly _sessionToProvider = new Map<string, AgentProvider>();
214 > /**
215 > * Sessions that have opted in to bring-up progress, keyed by provider id.
216 > * A session is added here when its `createSession` carries a
217 > * {@link IAgentCreateSessionConfig.progressToken} and removed once it
218 > * materializes (the SDK is now resolved) or is disposed. The SDK download is
219 > * host-level and shared across every session of a provider, so this only
220 > * records *interest*: as long as one or more sessions of a provider is
221 > * registered, {@link emitDownloadProgress} surfaces that provider's download as a single
222 > * progress stream keyed by the download's own identity (the package id),
223 > * rather than one stream per session.
224 > */
225 > private readonly _downloadProgressInterest = new Map<AgentProvider, Set<string>>();
226 > /** Subscriptions to provider progress events; cleared when providers change. */
227 > private readonly _providerSubscriptions = this._register(new DisposableStore());
228 > /**
229 > * Per-session tail of in-flight persisted peer-chat catalog writes, keyed by
230 > * session URI string. Read-modify-write updates to the {@link
231 > * PEER_CHATS_METADATA_KEY} blob are chained per session so a `createChat`,
232 > * `disposeChat`, and `onDidChangeChatData` racing for the same
233 > * session can't clobber each other's edits.
234 > */
235 > private readonly _peerChatCatalogWrites = new Map<string, Promise<void>>();
236 > private readonly _authService: AgentHostAuthenticationService;
237 > /** Default provider used when no explicit provider is specified. */
238 > private _defaultProvider: AgentProvider | undefined;
239 > /** Observable registered agents, drives `root/agentsChanged` via {@link AgentSideEffects}. */
240 > private readonly _agents = observableValue<readonly IAgent[]>('agents', []);
241 > /** Shared side-effect handler for action dispatch and session lifecycle. */
242 > private readonly _sideEffects: AgentSideEffects;
243 > /** Owns static / per-turn changeset compute, publish, persist, restore. */
244 > private readonly _changesets: IAgentHostChangesetService;
245 > /** Shared active changeset subscription registry. */
246 > private readonly _changesetSubscriptions: IAgentHostChangesetSubscriptionService;
247 > /** Owns changeset operation contributions and handler activation. */
248 > private readonly _changesetOperationService: IAgentHostChangesetOperationService;
249 > private readonly _reviewService: IAgentHostReviewService;
250 > /** Owns AgentService-side orchestration of the changeset feature. */
251 > private readonly _changesetCoordinator: AgentHostChangesetCoordinator;
252 > /** Owns session git-state probing and git-backed catalogue decoration. */
253 > private readonly _gitStateService: IAgentHostGitStateService;
254 > /** Manages PTY-backed terminals for the agent host protocol. */
255 > private readonly _terminalManager: AgentHostTerminalManager;
256 > /** Persists host-injected `/rename` / `!command` turns for restore & fork/truncate. */
257 > private readonly _localTurns: AgentHostLocalTurns;
258 > /** Server-side host for the agent host's server tools. */
259 > private readonly _serverToolHost: AgentServerToolHost;
260 > private readonly _configurationService: AgentConfigurationService;
261 > /**
262 > * Host-owned worktree isolation controller. Set post-construction via
263 > * {@link setWorktreeIsolation} because it depends on the branch-name
264 > * generator, which is wired after this service is built. All worktree
265 > * behavior — schema contribution, first-send resolution, project /
266 > * announcement, archive, and cleanup — is driven from the host so individual
267 > * agents stay unaware of the folder-vs-worktree distinction.
268 > */
269 > private _worktree: WorktreeIsolation | undefined;
270 > /** Single source of truth for GitHub (Enterprise) endpoints and protected resources. */
271 > private readonly _gitHubEndpointService: IAgentHostGitHubEndpointService;
272 > /** Pluggable completion item providers (e.g. workspace file completions, agent-specific @-mentions). */
273 > private readonly _completions: IAgentHostCompletions;
274 > private _skillCompletionProviderRegistered = false;
275 > /** Backs {@link getNetworkDiagnosticsInfo} / {@link diagnosticsFetch}; wired via {@link setNetworkDiagnosticsService}. */
276 > private _networkDiagnostics: INetworkDiagnosticsService | undefined;
277 >
278 > /**
279 > * Authoritative server-side per-resource subscription refcount, keyed by
280 > * resource URI string and valued by the set of subscribed protocol
281 > * client IDs. Populated by {@link subscribe} (or {@link addSubscriber}
282 > * for handshake fast-paths) and drained by {@link unsubscribe}. When a
283 > * resource's set becomes empty, the resource is dropped from the map and
284 > * {@link _maybeEvictIdleSession} is invoked to release any cached state
285 > * for it.
286 > */
287 > private readonly _resourceSubscribers = new ResourceMap<Set<string>>();
288 > private readonly _restoreSessionInFlight = new Map<string, Promise<void>>();
289 > private readonly _restoreSubagentInFlight = new Map<string, Promise<void>>();
290 >
291 > /** Subagent chats armed for a bounded wait (once execution is confirmed); resolved by {@link _onChatSpawned}, awaited by {@link subscribe}. */
292 > private readonly _pendingSubagentChats = new Map<string /* subagentChatUri */, DeferredPromise<void>>();
293 > private readonly _pendingSubagentChatTimeouts = this._register(new DisposableMap<string /* subagentChatUri */, IDisposable>());
294 > /** Subagent chats announced via `_meta.subagentChatUri` but still awaiting confirmation, keyed by `${channel}:${toolCallId}`. */
295 > private readonly _pendingSubagentToolCalls = new Map<string, string /* subagentChatUri */>();
296 >
297 > /**
298 > * Pending {@link _runSessionGc} timers, keyed by session URI. A timer is
299 > * armed when a session loses its last subscriber while still empty (no
300 > * turns, no active turn) — see {@link _maybeScheduleSessionGc}. Cleared
301 > * whenever any client subscribes again or the timer fires.
302 > */
303 > private readonly _pendingSessionGc = this._register(new DisposableResourceMap<IDisposable>());
304 >
305 > /**
306 > * Pending {@link _maybeEvictIdleSession} timers, keyed by session URI. A
307 > * timer is armed when an idle session (with turns) loses its last subscriber
308 > * — see {@link unsubscribe}. Cleared when any client subscribes again
309 > * ({@link addSubscriber}) or the timer fires. Deferring the release avoids
310 > * churning the provider SDK session on rapid disconnect/reconnect cycles.
311 > */
312 > private readonly _pendingSessionRelease = this._register(new DisposableResourceMap<IDisposable>());
313 >
314 > /**
315 > * Active resource watches keyed by the channel URI string
316 > * (`ahp-resource-watch:/<encoded>`).
317 > *
318 > * Each entry owns the {@link IFileService} watcher together with the
319 > * decoded descriptor, the subscriber refcount, and the optional
320 > * grace-window dispose timer. The watch URI itself is fully
321 > * self-describing — {@link createResourceWatch} just encodes the
322 > * caller's params into the URI and returns it. State only exists
323 > * here once at least one client has subscribed.
324 > *
325 > * Lifecycle:
326 > * - First subscriber to a channel: {@link onResourceWatchSubscribed}
327 > * parses the URI, creates the {@link IFileService} watcher, and
328 > * installs the entry with `subscribers = 1`.
329 > * - Subsequent subscribers bump the refcount and cancel any pending
330 > * grace-window dispose timer.
331 > * - {@link onResourceWatchUnsubscribed} drops the refcount; when it
332 > * reaches zero we arm a {@link RESOURCE_WATCH_GRACE_MS} dispose
333 > * timer rather than tearing down immediately, giving disconnected
334 > * clients time to reconnect.
335 > */
336 > private readonly _resourceWatches = this._register(new DisposableMap<string, IActiveResourceWatch>());
337 >
338 > /** Exposes the terminal manager for use by agent providers. */
339 > get terminalManager(): IAgentHostTerminalManager { return this._terminalManager; }
340 >
341 > /** Exposes the completions service for use by agent providers (e.g. to register agent-scoped completion item providers). */
342 > get completionsService(): IAgentHostCompletions { return this._completions; }
343 >
344 > /**
345 > * Trigger characters announced to clients via `InitializeResult.completionTriggerCharacters`.
346 > * Aggregated from all registered {@link IAgentHostCompletionItemProvider}s.
347 > */
348 > get completionTriggerCharacters(): readonly string[] { return this._completions.triggerCharacters; }
349 >
350 > constructor(
351 > private readonly _logService: ILogService, agentService.ts ×10
352 > private readonly _fileService: IFileService,
353 > private readonly _sessionDataService: ISessionDataService,
354 > private readonly _productService: IProductService,
355 > private readonly _gitService: IAgentHostGitService,
356 > private readonly _checkpointService: IAgentHostCheckpointService = NULL_CHECKPOINT_SERVICE,
357 > private readonly _rootConfigResource?: URI,
358 > private readonly _telemetryService: ITelemetryService = NullTelemetryService,
359 > _fileMonitorService?: IAgentHostFileMonitorService,
360 > copilotApiService?: ICopilotApiService,
361 > fetchFn?: typeof globalThis.fetch,
362 > providerConfigurations: readonly IAgentCustomizationSettingsRegistration[] = [],
363 > ) {
364 > super();
365 > this._logService.info('AgentService initialized');
366 > this._authService = new AgentHostAuthenticationService(_logService);
367 > this._stateManager = this._register(new AgentHostStateManager(_logService, {
368 > hostBuildInfo: hostBuildInfoFromProduct(this._productService),
369 > changesetStateRetention: {
370 > // The cache calls this lazily after construction. If a future state-manager
371 > // initialization path registers changesets before `_changesets` is assigned,
372 > // keep the entry pinned rather than evicting with incomplete liveness data.
373 > canEvict: changeset => this._changesets ? this._isChangesetEvictable(changeset) : false,
374 > },
375 > }));
376 > this._register(this._stateManager.onDidEmitEnvelope(e => this._onDidAction.fire(e)));
377 > this._register(this._stateManager.onDidEmitEnvelope(e => this._trackPendingSubagentChatFromEnvelope(e)));
378 > this._register(this._stateManager.onDidEmitNotification(e => this._onDidNotification.fire(e)));
379 >
380 > // Build a local instantiation scope so downstream components can
381 > // consume {@link IAgentConfigurationService} (and later {@link ILogService})
382 > // via DI rather than being plumbed plain-class references.
383 > const configurationService = this._register(new AgentConfigurationService(this._stateManager, this._logService, this._rootConfigResource, providerConfigurations));
384 > this._configurationService = configurationService;
385 > const fileMonitorService = _fileMonitorService ?? this._register(new AgentHostFileMonitorService(this._fileService, this._logService));
386 > updateAgentHostTelemetryLevelFromConfig(this._telemetryService, this._stateManager.rootState.config?.values);
387 > const services = new ServiceCollection(
388 > [ILogService, this._logService],
389 > [IAgentService, this],
390 > [IProductService, this._productService],
391 > [IAgentConfigurationService, configurationService],
392 > [IAgentHostStateManager, this._stateManager],
393 > [IAgentHostFileMonitorService, fileMonitorService],
394 > [IAgentHostGitService, this._gitService],
395 > [ITelemetryService, this._telemetryService],
396 > // The outer agent-host process DI registers `ISessionDataService`,
397 > // but this nested strict `InstantiationService` does not inherit it.
398 > // Add it explicitly so `@ISessionDataService` injection into the
399 > // changeset service (and any future sibling) resolves correctly.
400 > [ISessionDataService, this._sessionDataService],
401 > );
402 > const instantiationService = this._register(new InstantiationService(services, /*strict*/ true));
403 > this._gitHubEndpointService = this._register(instantiationService.createInstance(AgentHostGitHubEndpointService));
404 > services.set(IAgentHostGitHubEndpointService, this._gitHubEndpointService);
405 > // A GitHub Enterprise URI change repoints every agent's GitHub resource
406 > // identity to a different authorization server, so the client must obtain a
407 > // token for the new resource. One root-channel `auth/required` covers all
408 > // agents (the URI is host-level config).
409 > this._register(this._gitHubEndpointService.onDidChange(() => {
410 this._stateManager.emitAuthRequired({
411 resource: this._gitHubEndpointService.getCopilotResource().resource,
412 reason: AuthRequiredReason.Required,
413 });
415 > const agentHostOctoKitService = instantiationService.createInstance(AgentHostOctoKitService, fetchFn);
416 > services.set(IAgentHostOctoKitService, agentHostOctoKitService);
417 > const effectiveCopilotApiService = copilotApiService ?? instantiationService.createInstance(CopilotApiService, fetchFn);
418 > services.set(ICopilotApiService, effectiveCopilotApiService);
419 >
420 > this._gitStateService = this._register(instantiationService.createInstance(AgentHostGitStateService));
421 > services.set(IAgentHostGitStateService, this._gitStateService);
422 >
423 > // The checkpoint service is constructed in the outer agent-host
424 > // DI scope and passed via {@link _checkpointService}; register it
425 > // in the inner service collection so the changeset service /
426 > // side effects can resolve it via DI.
427 > services.set(IAgentHostCheckpointService, this._checkpointService);
428 >
429 > // The subscription service manages the lifecycle of changeset subscriptions. The service
430 > // is also consulted by other services when refreshing changesets and changeset operations.
431 > this._changesetSubscriptions = instantiationService.createInstance(AgentHostChangesetSubscriptionService);
432 > services.set(IAgentHostChangesetSubscriptionService, this._changesetSubscriptions);
433 >
434 > // The operation contribution service manages the lifecycle of changeset operations.
435 > this._changesetOperationService = this._register(instantiationService.createInstance(AgentHostChangesetOperationService));
436 > services.set(IAgentHostChangesetOperationService, this._changesetOperationService);
437 >
438 > // The changes review service is responsible for managing review/unreview state for changeset changes.
439 > this._reviewService = this._register(instantiationService.createInstance(AgentHostReviewService));
440 > services.set(IAgentHostReviewService, this._reviewService);
441 >
442 > // The changeset service is responsible for computing, publishing, and persisting changesets.
443 > this._changesets = this._register(instantiationService.createInstance(AgentHostChangesetService));
444 > services.set(IAgentHostChangesetService, this._changesets);
445 >
446 > // The coordinator owns all AgentService-side orchestration of the changeset feature: lifecycle
447 > // hooks, listSessions overlay, subscription URI routing, and the deferred-refresh state machine.
448 > this._changesetCoordinator = this._register(instantiationService.createInstance(AgentHostChangesetCoordinator));
449 > this._register(this._stateManager.onDidChangeSessionActiveTurn(e => this._changesetCoordinator.onSessionTurnActiveChanged(e.session, e.active)));
450 >
451 > // Register the changeset operation contributions.
452 > this._register(this._changesetOperationService.registerContribution(instantiationService.createInstance(AgentHostCommitOperationContribution)));
453 > this._register(this._changesetOperationService.registerContribution(instantiationService.createInstance(AgentHostPullRequestOperationContribution)));
454 > this._register(this._changesetOperationService.registerContribution(instantiationService.createInstance(AgentHostSyncOperationContribution)));
455 > this._register(this._changesetOperationService.registerContribution(instantiationService.createInstance(AgentHostDiscardChangesOperationContribution)));
456 >
457 > this._completions = this._register(instantiationService.createInstance(AgentHostCompletions));
458 > // Built-in generic provider: completes files in the session's workspace folder.
459 > const workspaceFiles = this._register(instantiationService.createInstance(AgentHostWorkspaceFiles));
460 > this._register(this._completions.registerProvider(
461 > new AgentHostFileCompletionProvider(this._stateManager, workspaceFiles),
462 > ));
463 > // Built-in generic provider: offers the `/rename` slash command for any
464 > // session that already has history. Execution is handled server-side in
465 > // AgentSideEffects (redirected to a SessionTitleChanged action).
466 > this._register(this._completions.registerProvider(
467 > new AgentHostRenameCompletionProvider(
468 > session => (this._stateManager.getSessionState(session)?.turns.length ?? 0) > 0,
469 > ),
470 > ));
471 >
472 > // Terminal management — the terminal manager listens to the state
473 > // manager's action stream and dispatches PTY output back through it.
474 > // Created before AgentSideEffects and registered in the local scope so
475 > // AgentSideEffects can consume it via DI (for inline `!command`
476 > // execution).
477 > this._terminalManager = this._register(instantiationService.createInstance(AgentHostTerminalManager));
478 > services.set(IAgentHostTerminalManager, this._terminalManager);
479 >
480 > this._localTurns = new AgentHostLocalTurns(this._sessionDataService, this._logService);
481 >
482 > this._sideEffects = this._register(instantiationService.createInstance(AgentSideEffects, this._stateManager, {
483 > getAgent: session => this._findProviderForSession(session),
484 > sessionDataService: this._sessionDataService,
485 > localTurns: this._localTurns,
486 > agents: this._agents,
487 > copilotApiService: effectiveCopilotApiService,
488 > getGitHubCopilotToken: () => {
489 > return this.getAuthToken({ agentService.ts ×1
490 > resource: this._gitHubEndpointService.getCopilotResource().resource,
491 > scopes: this._gitHubEndpointService.getCopilotResource().scopes_supported,
492 > });
493 > },
494 > resolveWorkingDirectoryBeforeSend: params => this._resolveWorkingDirectoryBeforeSend(params), agentService.ts ×10
495 > resolveChatAttachmentTurns: resource => this._resolveChatAttachmentTurns(resource),
496 > onTurnComplete: async session => {
497 // Refresh the git state for the session.
498 const workingDirStr = this._stateManager.getSessionState(session)?.workingDirectories?.[0];
499 void this._gitStateService.refreshSessionGitState(session, workingDirStr ? URI.parse(workingDirStr) : undefined);
500
501 // Check for a GitHub pull request associated with the session's branch.
502 void this._gitStateService.attachSessionGitHubPullRequest(session.toString());
503 },
505 >
506 > // Server-side tools, executed in-process against each session's own
507 > // state. The set of groups (and their display) is the single source of
508 > // truth in `serverToolGroups.ts`; the session-management group's runtime
509 > // dependency (this service) is injected via the accessor.
510 > this._serverToolHost = new AgentServerToolHost(this._stateManager, buildServerToolGroups(this._createSessionServerToolAccessor()));
511 > }
513 > /**
514 > * The registered providers. Exposed so process-lifetime background jobs
515 > * (notably {@link AgentModelRefreshScheduler}) can observe registrations
516 > * without this service owning an ambient recurring timer of its own.
517 > */
518 > get agents(): IObservable<readonly IAgent[]> {
519 return this._agents;
520 }
522 > // ---- provider registration ----------------------------------------------
523 >
524 > /**
525 > * Injects the host-owned {@link WorktreeIsolation} controller and forwards it
526 > * to the collaborators that consult it. Called once at startup (from
527 > * agentHostMain / agentHostServerMain) after the branch-name generator has
528 > * been wired.
529 > */
530 > setWorktreeIsolation(worktree: WorktreeIsolation): void {
531 > this._worktree = worktree; agentService.ts ×9
532 > this._configurationService.setWorktreeIsolation(worktree);
533 > this._sideEffects.setWorktreeIsolation(worktree);
534 > }
536 > private _toProviderConfig<T extends { readonly config?: Record<string, unknown> }>(request: T): T {
537 > if (!this._worktree || !request.config) { agentService.ts ×2
538 > return request; agentService.ts ×1
539 > }
540 > return { ...request, config: omitHostOwnedSessionConfig(request.config) }; agentService.ts ×9
543 > /**
544 > * Host-owned first-send hook (invoked by {@link AgentSideEffects} before the
545 > * agent locks its subprocess cwd). Resolves the working directory the session
546 > * will actually run in and hands it to the agent at send time:
547 > * - `worktree` isolation: the isolated worktree, created here on the first
548 > * send (see {@link _resolveWorktreeBeforeSend});
549 > * - `folder` isolation: the picked folder;
550 > * - workspace-less: `undefined` (the agent runs in its own scratch dir).
551 > */
552 > private async _resolveWorkingDirectoryBeforeSend(params: { session: string; chat: string; turnId: string; prompt: string }): Promise<URI | undefined> {
553 > const sessionId = AgentSession.id(params.session); agentService.ts ×5
554 > const pickedFolder = this._configurationService.getEffectiveWorkingDirectory(params.session);
555 > const pickedFolderUri = pickedFolder ? URI.parse(pickedFolder) : undefined;
556 >
557 > // Only worktree-isolation sessions defer directory resolution to the first
558 > // send (so the prompt can name the branch); folder / workspace-less
559 > // sessions run directly in the picked folder.
560 > if (!this._worktree?.isWorkingDirectoryPending(sessionId)) {
561 > return pickedFolderUri
562 ? this._configurationService.resolveWorkingDirectoryForResume(params.session, pickedFolderUri)
563 > : undefined; agentService.ts ×5
564 > }
565
566 // Fall back to the picked folder when worktree creation failed so the
567 // session still materializes in the user's folder rather than nowhere.
568 return await this._resolveWorktreeBeforeSend({ ...params, sessionId, pickedFolderUri }) ?? pickedFolderUri;
571 > private async _resolveChatAttachmentTurns(resource: string): Promise<readonly Turn[]> {
572 > const readTurns = () => { agentService.ts ×4
573 > const state = this._stateManager.getChatState(resource) ?? this._stateManager.getDefaultChatState(resource);
574 > return state?.turns;
575 > };
576 > const existing = readTurns();
577 > if (existing) {
578 return existing;
579 }
581 > const sessionUri = URI.parse(isAhpChatChannel(resource) ? parseRequiredSessionUriFromChatUri(resource) : resource);
582 > if (!this._stateManager.getSessionState(sessionUri.toString())) {
583 await this.restoreSession(sessionUri);
584 > } else { agentService.ts ×4
585 > const provider = this._findProviderForSession(sessionUri);
586 > if (provider) {
587 > await this._restorePeerChats(provider, sessionUri);
588 > }
589 > }
590 > return readTurns() ?? [];
591 > }
593 > /**
594 > * Creates the session's isolated worktree on the first send (deferred so the
595 > * user's prompt can name the branch), surfaces the "Created isolated worktree"
596 > * announcement as the first markdown response part of the turn, and returns
597 > * the created worktree URI. Idempotent; safe to call once the worktree exists.
598 > * Returns `undefined` when worktree creation failed. Only invoked for sessions
599 > * whose worktree is still pending (see {@link _resolveWorkingDirectoryBeforeSend}).
600 > */
601 > private async _resolveWorktreeBeforeSend(params: { session: string; chat: string; turnId: string; prompt: string; sessionId: string; pickedFolderUri: URI | undefined }): Promise<URI | undefined> {
602 const { sessionId, pickedFolderUri } = params;
603 const worktree = this._worktree;
604 if (!worktree) {
605 return undefined;
606 }
607 try {
608 await worktree.resolveOnFirstSend({
609 sessionUri: URI.parse(params.session),
610 sessionId,
611 workingDirectory: pickedFolderUri,
612 config: this._configurationService.getSessionConfigValues(params.session),
613 prompt: params.prompt,
614 githubToken: this.getAuthToken({
615 resource: this._gitHubEndpointService.getCopilotResource().resource,
616 scopes: this._gitHubEndpointService.getCopilotResource().scopes_supported,
617 }),
618 });
619 } catch (err) {
620 this._logService.warn(`[AgentService] worktree resolution failed for ${params.session}: ${toErrorMessage(err)}`);
621 }
622 const announcement = worktree.takePendingAnnouncement(sessionId);
623 if (announcement !== undefined) {
624 this._stateManager.dispatchServerAction(params.chat, {
625 type: ActionType.ChatResponsePart,
626 turnId: params.turnId,
627 part: { kind: ResponsePartKind.Markdown, id: generateUuid(), content: announcement },
628 });
629 }
630 return worktree.getResolvedWorktree(sessionId);
631 }
633 > registerProvider(provider: IAgent): void {
634 > if (this._providers.has(provider.id)) { agentService.ts ×14
635 > throw new Error(`Agent provider already registered: ${provider.id}`); agentService.ts ×1
636 > }
637 > this._logService.info(`Registering agent provider: ${provider.id}`); agentService.ts ×14
638 > this._providers.set(provider.id, provider);
639 > provider.setServerToolHost?.(this._serverToolHost);
640 > // Deterministic subagent membership ordering: apply a spawned subagent's
641 > // catalog membership (via the spawn-channel handlers) BEFORE
642 > // AgentSideEffects — registered next — handles the same signal and starts
643 > // a turn on the subagent chat, which requires that chat to already exist.
644 > // Registering this listener ahead of the side-effects listener makes the
645 > // ordering independent of when the agent registers its own subagent->spawn
646 > // bridge; addChat/removeChat are idempotent, so the overlap is safe.
647 > this._providerSubscriptions.add(provider.onDidSessionProgress(signal => this._sequenceSpawnedChat(signal)));
648 > this._providerSubscriptions.add(this._sideEffects.registerProgressListener(provider));
649 > if (provider.onDidMaterializeSession) {
650 > this._providerSubscriptions.add(provider.onDidMaterializeSession(e => this._onDidMaterializeSession(e))); agentService.ts ×1
651 > }
652 > if (provider.onMcpNotification) { agentService.ts ×14
653 this._providerSubscriptions.add(provider.onMcpNotification(e => this._onMcpNotification.fire(e)));
654 }
655 > if (provider.onDidChangeChatData) { agentService.ts ×14
656 > this._providerSubscriptions.add(provider.onDidChangeChatData(e => this._onChatDataChanged(e))); agentService.ts ×1
657 > }
658 > if (provider.onDidSpawnChat) { agentService.ts ×14
659 > this._providerSubscriptions.add(provider.onDidSpawnChat(e => this._onChatSpawned(e))); agentService.ts ×1
660 > }
661 > this._registerSkillCompletionProvider(); agentService.ts ×14
662 > if (!this._defaultProvider) {
663 > this._defaultProvider = provider.id;
664 > }
665 >
666 > // Update root state with current agents list
667 > this._updateAgents();
668 > }
670 > private _registerSkillCompletionProvider(): void {
671 > if (this._skillCompletionProviderRegistered) { agentService.ts ×14
672 > return; agentService.ts ×1
673 > }
674 > this._skillCompletionProviderRegistered = true; agentService.ts ×14
675 > const provider = this._register(new AgentHostSkillCompletionProvider(
676 > session => this._findProviderForSession(session),
677 > ));
678 > this._register(this._completions.registerProvider(provider));
679 > }
681 > // ---- auth ---------------------------------------------------------------
682 >
683 > async authenticate(params: AuthenticateParams): Promise<AuthenticateResult> {
684 > return this._authService.authenticate(params, this._providers.values()); agentHostAuthenticationService.ts ×4
685 > }
687 > getAuthToken(request: IAgentHostAuthTokenRequest): string | undefined {
688 > return this._authService.getAuthToken(request); agentHostAuthenticationService.ts ×2
689 > }
691 > // ---- Changeset operation handlers --------------------------------------
692 >
693 > async invokeChangesetOperation(params: InvokeChangesetOperationParams): Promise<InvokeChangesetOperationResult> {
694 return this._changesetOperationService.invokeChangesetOperation(params);
695 }
697 > // ---- MCP `mcp://` channel routing --------------------------------------
698 >
699 > async handleMcpRequest(channel: string, method: string, params: Record<string, unknown> | undefined): Promise<unknown> {
700 const route = parseMcpChannelUri(channel);
701 if (!route) {
702 throw new Error(`Method not found: invalid mcp:// channel ${channel}`);
703 }
704 const provider = this._providers.get(route.providerId);
705 if (!provider || !provider.handleMcpRequest) {
706 throw new Error(`Method not found: no provider for mcp:// channel ${channel}`);
707 }
708 const sessionUri = AgentSession.uri(route.providerId, route.sessionId);
709 return provider.handleMcpRequest(sessionUri, route.serverName, method, params);
710 }
712 > // ---- session management -------------------------------------------------
713 >
714 > /**
715 > * Builds the dependency surface the session server-tool group needs, bound
716 > * to this service so the group stays decoupled from the concrete host.
717 > */
718 > private _createSessionServerToolAccessor(): ISessionServerToolAccessor {
719 > return { agentService.ts ×10
720 > listSessions: () => this.listSessions(),
721 > createSession: config => this.createSession(config),
722 > getModels: () => {
723 const models: IAgentModelInfo[] = [];
724 for (const provider of this._providers.values()) {
725 models.push(...provider.models.get());
726 }
727 return models;
728 },
729 > startPrompt: (session, chat, prompt) => this._startSessionPrompt(session, chat, prompt), agentService.ts ×10
730 > createChat: (session, chat, options) => this.createChat(session, chat, (options?.title !== undefined || options?.model !== undefined)
731 ? { ...(options.title !== undefined ? { title: options.title } : {}), ...(options.model !== undefined ? { model: { id: options.model.id } } : {}) }
732 : undefined),
733 > deleteSession: session => this.disposeSession(session), agentService.ts ×10
734 > getChatContext: (session, chatId) => this._getChatContext(session, chatId),
735 > // Reads the `create_session` spawn depth from a session's `_meta` (0 when absent).
736 > getSessionSpawnDepth: session => readSessionSpawnDepth(this._stateManager.getSessionSummary(session.toString())?._meta),
737 > // Stamps a session's `create_session` spawn depth into its `_meta` (merging existing keys).
738 > setSessionSpawnDepth: (session, depth) => this._stateManager.dispatchServerAction(session.toString(), {
739 type: ActionType.SessionMetaChanged,
740 _meta: withSessionSpawnDepth(this._stateManager.getSessionSummary(session.toString())?._meta, depth),
741 }),
743 > }
745 > /**
746 > * Starts the first turn on a freshly-created session by dispatching a
747 > * `ChatTurnStarted` and routing it through the same side-effects path a
748 > * client-initiated turn takes (which sends the message to the provider).
749 > */
750 > private async _startSessionPrompt(session: URI, chat: URI, prompt: string): Promise<void> {
751 const message: Message = { text: prompt, origin: { kind: MessageKind.User } };
752 const action = { type: ActionType.ChatTurnStarted, turnId: generateUuid(), startedAt: new Date().toISOString(), message } as const;
753 this._stateManager.dispatchServerAction(chat.toString(), action);
754 this._sideEffects.handleAction(chat.toString(), action);
755 }
757 > /**
758 > * Reads a point-in-time snapshot of a session's chat conversation for the
759 > * `get_session_context` server tool. Targets the session's default chat, or a
760 > * specific peer chat when `chatId` is provided. Returns `undefined` when no
761 > * live conversation state exists (e.g. a cold/unsubscribed session).
762 > */
763 > private _getChatContext(session: URI, chatId?: string): IChatContextSnapshot | undefined {
764 const chatState = chatId
765 ? this._stateManager.getChatState(buildChatUri(session.toString(), chatId))
766 : this._stateManager.getDefaultChatState(session.toString());
767 if (!chatState) {
768 return undefined;
769 }
770 return {
771 turns: chatState.turns,
772 ...(chatState.activeTurn ? { activeTurn: { message: chatState.activeTurn.message, responseParts: chatState.activeTurn.responseParts } } : {}),
773 hasMoreHistory: !!chatState.turnsNextCursor,
774 };
775 }
777 > async listSessions(): Promise<IAgentSessionMetadata[]> {
778 > this._logService.trace('[AgentService] listSessions called'); agentService.ts ×4
779 > const results = await Promise.all(
780 > [...this._providers.values()].map(p => p.listSessions())
781 > );
782 > const flat = results.flat();
783 >
784 > // Overlay persisted custom titles from per-session databases.
785 > const overlaid = await Promise.all(flat.map(async (s): Promise<IAgentSessionMetadata | undefined> => {
786 > try { agentService.ts ×4
787 > const ref = await this._sessionDataService.tryOpenDatabase(s.session);
788 > if (!ref) {
789 > return s; agentService.ts ×1
790 > }
791 > try { agentService.ts ×11
792 > // Batch the always-required keys (title / read / archive
793 > // flags) with any keys the changeset coordinator asks for
794 > // so the session DB is hit exactly once. The coordinator
795 > // returns `undefined` when a live source can already
796 > // answer the catalogue question, avoiding the
797 > // potentially-large persisted blobs entirely.
798 > const sessionStr = s.session.toString();
799 > const changesetKeys = this._changesetCoordinator.getListMetadataKeys(sessionStr);
800 > const metadataKeys: Record<string, true> = changesetKeys
801 > ? { customTitle: true, isRead: true, [AH_META_IS_ARCHIVED_DB_KEY]: true, [AH_META_IS_DONE_DB_KEY]: true, [AH_META_WORKSPACELESS_DB_KEY]: true, [PEER_CHAT_BACKING_METADATA_KEY]: true, [WORKTREE_META_REPOSITORY_ROOT]: true, ...GIT_DB_METADATA_KEYS, ...changesetKeys }
802 : { customTitle: true, isRead: true, [AH_META_IS_ARCHIVED_DB_KEY]: true, [AH_META_IS_DONE_DB_KEY]: true, [AH_META_WORKSPACELESS_DB_KEY]: true, [PEER_CHAT_BACKING_METADATA_KEY]: true, [WORKTREE_META_REPOSITORY_ROOT]: true, ...GIT_DB_METADATA_KEYS };
803 > const m = await ref.object.getMetadataObject(metadataKeys); agentService.ts ×4
804 > // This session is an internal peer-chat backing (e.g. a agentService.ts ×11
805 > // Claude peer chat's SDK session, enumerated by the agent's
806 > // own `listSessions`). Drop it so it never leaks as a
807 > // standalone top-level session — mirrors the subagent filter
808 > // on the state-manager overlay path below.
809 > if (m[PEER_CHAT_BACKING_METADATA_KEY]) {
810 > return undefined; agentService.ts ×5
811 > }
812 > let updated = s; agentService.ts ×11
813 > if (m.customTitle) {
814 > updated = { ...updated, summary: m.customTitle }; agentService.ts ×1
815 > }
816 > if (m.isRead !== undefined) { agentService.ts ×11
817 updated = { ...updated, isRead: m.isRead === 'true' };
818 }
819 > if (m[AH_META_IS_ARCHIVED_DB_KEY] !== undefined) { agentService.ts ×11
820 updated = { ...updated, isArchived: m[AH_META_IS_ARCHIVED_DB_KEY] === 'true' };
821 > } else if (m[AH_META_IS_DONE_DB_KEY] !== undefined) { agentService.ts ×11
822 updated = { ...updated, isArchived: m[AH_META_IS_DONE_DB_KEY] === 'true' };
823 }
824 > if (m[META_GIT_STATE]) { agentService.ts ×11
825 try {
826 const gitState = JSON.parse(m[META_GIT_STATE]) as ISessionGitState;
827 updated = { ...updated, _meta: withSessionGitState(updated._meta, gitState) };
828 } catch (e) {
829 this._logService.warn(`[AgentService][listSessions] Failed to parse Git state for ${s.session}`, e);
830 }
831 }
832 > if (m[META_GITHUB_STATE]) { agentService.ts ×11
833 try {
834 const gitHubState = JSON.parse(m[META_GITHUB_STATE]) as ISessionGitHubState;
835 updated = { ...updated, _meta: withSessionGitHubState(updated._meta, gitHubState) };
836 } catch (e) {
837 this._logService.warn(`[AgentService][listSessions] Failed to parse GitHub state for ${s.session}`, e);
838 }
839 }
841 > if (m[AH_META_WORKSPACELESS_DB_KEY] !== undefined) {
842 > updated = { ...updated, _meta: withSessionWorkspaceless(updated._meta, m[AH_META_WORKSPACELESS_DB_KEY] === 'true') }; agentService.ts ×1
843 > }
845 > // Worktree-isolated sessions run out of `<repo>.worktrees/<name>` but
846 > // must group under the repository in the sessions UI. Merge the repo
847 > // project persisted alongside the worktree metadata so a list refresh
848 > // doesn't revert the workspace name to the worktree directory. No-op
849 > // for folder sessions (key absent).
850 > const worktreeProject = worktreeProjectFromRepositoryRoot(m[WORKTREE_META_REPOSITORY_ROOT]);
851 > if (worktreeProject) {
852 updated = { ...updated, project: worktreeProject };
853 }
855 > return this._changesetCoordinator.decorateListEntry(updated, m as Record<string, string | undefined>);
856 > } finally {
857 > ref.dispose();
858 > }
859 > } catch (e) { agentService.ts ×4
860 this._logService.warn(`[AgentService] Failed to read session metadata overlay for ${s.session}`, e);
861 }
862 return s;
863 > })); agentService.ts ×4
864 > const result = overlaid.filter((s): s is IAgentSessionMetadata => s !== undefined);
865 >
866 > // Overlay live session state from the state manager.
867 > // For the title, prefer the state manager's value when it is
868 > // non-empty, so SDK-sourced titles are not overwritten by the
869 > // initial empty placeholder. The default changeset catalogue lives
870 > // on `state.changesets` (seeded after `createSession` /
871 > // `restoreSession` and refreshed after each compute pass) and the
872 > // chip aggregate on the catalog summary's `changes`; both must be
873 > // surfaced here so a fresh `listSessions` call returns the same values
874 > // subscribers see via the per-session action stream and
875 > // `notify/sessionSummaryChanged`.
876 > const withStatus = result.map(s => {
877 > const liveSummary = this._stateManager.getSessionSummary(s.session.toString()); agentService.ts ×4
878 > if (liveSummary) {
879 > // Overlay the live `_meta` over the DB-derived value. The live agentService.ts ×5
880 > // `_meta` is the freshest source (e.g. the GitHub state is
881 > // published here as soon as a PR is created), so a freshly-created
882 > // session that has not yet persisted its state to its session
883 > // database still reports it here. Keep the DB value as the base so
884 > // any keys absent from the live `_meta` are preserved.
885 > const _meta = liveSummary._meta !== undefined || s._meta !== undefined
886 > ? { ...s._meta, ...liveSummary._meta } agentService.ts ×1
887 > : undefined; agentService.ts ×1
888 > const liveWorkingDir = liveSummary.workingDirectories?.[0]; agentService.ts ×5
889 > return {
890 > ...s,
891 > summary: liveSummary.title || s.summary,
892 > status: liveSummary.status,
893 > activity: liveSummary.activity,
894 > modifiedTime: Date.parse(liveSummary.modifiedAt),
895 > project: liveSummary.project
896 > ? { uri: URI.parse(liveSummary.project.uri), displayName: liveSummary.project.displayName }
897 : s.project,
898 > workingDirectory: typeof liveWorkingDir === 'string' agentService.ts ×5
899 > ? URI.parse(liveWorkingDir) agentService.ts ×1
900 > : s.workingDirectory, agentService.ts ×1
901 > changes: liveSummary.changes ?? s.changes, agentService.ts ×5
902 > changesets: this._stateManager.getSessionState(s.session.toString())?.changesets ?? s.changesets,
903 > ...(_meta !== undefined ? { _meta } : {}),
904 > };
905 > }
908 >
909 > // Overlay any session known to state but missing from the providers'
910 > // `listSessions` snapshot, so renderer-side caches don't evict a
911 > // live/active session (which would close the chat view holding the
912 > // in-flight response bubble). Two cases need this: a provider can
913 > // transiently drop a session (e.g. `CopilotAgent.listSessions` returns
914 > // an empty array right after `session/turnComplete`), and a provisional
915 > // session (created but not yet materialized — see `createSession`) that
916 > // has had any turn activity must stay visible until it materializes.
917 > // Idle provisional sessions are deliberately *not* overlaid so the
918 > // new-session composer's eagerly-created session doesn't leak into the
919 > // list before its first message (#321269).
920 > const known = new Set(withStatus.map(s => s.session.toString()));
921 > const additions: IAgentSessionMetadata[] = [];
922 > for (const summary of this._stateManager.getOverlaySessionSummaries()) {
923 > if (known.has(summary.resource)) { agentHostStateManager.ts ×2
924 > continue; agentService.ts ×5
925 > }
926 > // Subagent sessions are nested under their parent and must never agentService.ts ×1
927 > // surface as top-level entries in the session list.
928 > if (isSubagentSession(summary.resource)) {
929 > continue; agentService.ts ×1
930 > }
932 > const summaryWorkingDir = summary.workingDirectories?.[0];
933 > additions.push({ agentHostStateManager.ts ×2
934 > session: URI.parse(summary.resource),
935 > startTime: Date.parse(summary.createdAt),
936 > modifiedTime: Date.parse(summary.modifiedAt),
937 > summary: summary.title,
938 > status: summary.status,
939 > activity: summary.activity,
940 > workingDirectory: typeof summaryWorkingDir === 'string' ? URI.parse(summaryWorkingDir) : undefined,
941 > ...(summary.project ? { project: { uri: URI.parse(summary.project.uri), displayName: summary.project.displayName } } : {}),
942 > changes: summary.changes,
943 > // This overlay path never opens the session database (unlike the
944 > // provider-returned sessions handled above), so carry the
945 > // in-memory `summary._meta` directly. It holds the live state
946 > // (e.g. the GitHub state published when a PR is created), so a
947 > // freshly-created session that the provider transiently omits
948 > // still reports it here.
949 > ...(summary._meta !== undefined ? { _meta: summary._meta } : {}),
950 > });
951 > }
952 > const combined = additions.length > 0 ? [...withStatus, ...additions] : withStatus; agentService.ts ×4
953 >
954 > this._logService.trace(`[AgentService] listSessions returned ${combined.length} sessions (${additions.length} state-manager fallback)`);
955 > return combined;
956 > }
958 > async createSession(config?: IAgentCreateSessionConfig): Promise<URI> {
959 > const providerId = config?.provider ?? this._defaultProvider; agentService.ts ×8
960 > const provider = providerId ? this._providers.get(providerId) : undefined;
961 > if (!provider) {
962 > throw new Error(`No agent provider registered for: ${providerId ?? '(none)'}`); agentService.ts ×1
963 > }
965 > // When forking, build the old→new turn ID mapping before creating the
966 > // session so the agent can use it to remap per-turn data. If the
967 > // source has no turns to copy (e.g. a still-provisional session), a
968 > // "fork" is indistinguishable from a fresh session, so we drop the
969 > // fork parameter and fall through to the regular create path.
970 > if (config?.fork) { agentService.ts ×8
971 > const sourceState = this._stateManager.getSessionState(config.fork.session.toString()); agentService.ts ×5
972 > const sourceTurns = sourceState?.turns.slice(0, config.fork.turnIndex + 1) ?? [];
973 > if (sourceTurns.length === 0) {
974 config = { ...config, fork: undefined };
975 > } else { agentService.ts ×5
976 > const turnIdMapping = new Map<string, string>();
977 > for (const t of sourceTurns) {
978 > turnIdMapping.set(t.id, generateUuid());
979 > }
980 > // The SDK fork boundary must be a concrete (SDK-backed) turn.
981 > // When the client forked at a host-injected local turn
982 > // (`/rename` / `!command`), redirect the agent to the preceding
983 > // concrete turn while still seeding the local turns up to the
984 > // fork point into the new session's protocol state below.
985 > const concreteForkTurnId = this._localTurns.resolveConcreteTurnId(buildDefaultChatUri(config.fork.session).toString(), config.fork.turnId);
986 > config = {
987 > ...config,
988 > fork: { ...config.fork, turnIdMapping, ...(concreteForkTurnId !== undefined ? { turnId: concreteForkTurnId } : {}) },
989 > };
990 > }
991 > }
993 > // When importing a conversation, assign fresh UUID turn ids up front so
994 > // the provider seeds an event log whose ids match the protocol turns we
995 > // seed below — keeping edit / fork / truncate addressable at the SDK
996 > // boundary.
997 > if (config?.importConversation) { agentService.ts ×8
998 const importedTurns = config.importConversation.turns.map(t => ({ ...t, id: generateUuid() }));
999 config = { ...config, importConversation: { ...config.importConversation, turns: importedTurns } };
1000 }
1002 > // Resolve host-owned isolation before provider creation. Providers such as
1003 > // Codex may schedule eager prewarming from createSession; marking a
1004 > // client-chosen worktree session pending first prevents that prewarm from
1005 > // materializing in the picked folder before the host creates the worktree.
1006 > const initializeSideEffects = this._sideEffects.initialize();
1007 > const sessionConfig = await this._resolveCreatedSessionConfig(provider, config);
1008 > const deferWorktreeCreation = sessionConfig?.values?.[SessionConfigKey.Isolation] === 'worktree' && !config?.fork && !config?.importConversation; agentService.ts ×8
1009 >
1010 > this._logService.trace(`[AgentService] createSession: initializing auto-approver and creating session...`);
1011 > const [, created] = await Promise.all([
1012 > initializeSideEffects,
1013 > this._createProviderSession(provider, config, deferWorktreeCreation),
1014 > ]);
1015 > const session = created.session; agentService.ts ×16
1016 > this._logService.trace(`[AgentService] createSession: initialization complete`);
1017 >
1018 > // Cancel any pending GC armed for this URI. A client may be
1019 > // re-issuing `createSession` for an existing URI mid-grace (e.g.
1020 > // during a reconnect that returned `missing`); without this, the
1021 > // timer would still fire and dispose the just-revived session
1022 > // before the follow-up `subscribe` arrives.
1023 > this._cancelPendingSessionGc(session);
1024 > this._cancelPendingSessionRelease(session);
1025 >
1026 > this._logService.trace(`[AgentService] createSession: provider=${provider.id} model=${config?.model?.id ?? '(default)'}`); agentService.ts ×8
1027 > this._sessionToProvider.set(session.toString(), provider.id);
1028 > // Record this session's opt-in so a cold SDK download triggered at
1029 > // materialization (first message) is surfaced as progress. The download
1030 > // is provider-global, so we only track interest here; emission is keyed
1031 > // by the download's own identity, not this token. Cleared on
1032 > // materialize/dispose.
1033 > if (config?.progressToken) {
1034 let sessions = this._downloadProgressInterest.get(provider.id);
1035 if (!sessions) {
1036 sessions = new Set<string>();
1037 this._downloadProgressInterest.set(provider.id, sessions);
1038 }
1039 sessions.add(session.toString());
1040 }
1041 > this._logService.trace(`[AgentService] createSession returned: ${session.toString()}`); agentService.ts ×16
1042 >
1043 > // Resolve config and seed the initial customization set in parallel so
1044 > // both are available before we register the session in the state
1045 > // manager. Seeding `state.customizations` directly (instead of
1046 > // dispatching `SessionCustomizationsChanged` after the fact) means
1047 > // the very first snapshot a subscriber sees already contains
1048 > // host/global customizations and the custom agents they contribute,
1049 > // so the agent picker doesn't have to wait for a follow-up republish
1050 > // (`RootConfigChanged`, plugin reload, or the first message's
1051 > // `setClientCustomizations`). Subsequent updates flow through the
1052 > // existing `SessionCustomizationsChanged` / `SessionCustomizationUpdated`
1053 > // actions published by `PluginController`.
1054 > const initialCustomizations = await (provider.getSessionCustomizations
1055 ? provider.getSessionCustomizations(session).catch(err => {
1056 this._logService.error('[AgentService] createSession: failed to resolve initial customizations', err);
1057 return undefined;
1058 })
1059 > : Promise.resolve(undefined)); agentService.ts ×16
1060 >
1061 > // When forking, populate the new session's protocol state with
1062 > // the source session's turns so the client sees the forked history.
1063 > if (config?.fork) { agentService.ts ×8
1064 > const sourceState = this._stateManager.getSessionState(config.fork.session.toString()); agentService.ts ×5
1065 > const sourceChatUri = buildDefaultChatUri(config.fork.session).toString();
1066 > const newChatUri = buildDefaultChatUri(session).toString();
1067 > let sourceTurns: Turn[] = [];
1068 > if (sourceState && config.fork.turnIdMapping) {
1069 > const originalSlice = sourceState.turns.slice(0, config.fork.turnIndex + 1);
1070 > const mapping = config.fork.turnIdMapping;
1071 > sourceTurns = originalSlice.map(t => ({ ...t, id: mapping.get(t.id) ?? generateUuid() }));
1072 > // Re-persist forked local turns (`/rename`, `!command`) under the
1073 > // new session's default chat. `record` (keyed by turn id)
1074 > // overwrites any rows a DB copy carried with the SOURCE chat URI,
1075 > // and seeds the in-memory index for same-process fork/truncate.
1076 > this._persistForkedLocalTurns(session.toString(), sourceChatUri, newChatUri, originalSlice, sourceTurns, mapping);
1077 > }
1078 >
1079 > // Prefix the forked session's title so consumers (sidebar, chat
1080 > // model) can distinguish it from the source without each surface
1081 > // reinventing the convention. Avoid double-prefixing when a user
1082 > // forks an already-forked session.
1083 > const forkedTitlePrefix = localize('agentHost.forkedTitlePrefix', "Forked: ");
1084 > const sourceTitle = sourceState?.title;
1085 > const forkedTitle = sourceTitle
1086 > ? (sourceTitle.startsWith(forkedTitlePrefix) ? sourceTitle : `${forkedTitlePrefix}${sourceTitle}`)
1087 : localize('agentHost.forkedSessionFallback', "Forked Session");
1088 > const summary = this._buildInitialSummary(provider, session, config, created, forkedTitle); agentService.ts ×5
1089 > const state = this._stateManager.createSession(summary);
1090 > state.config = sessionConfig;
1091 > this._stateManager.seedDefaultChatTurns(summary.resource, sourceTurns);
1092 > state.activeClients = config.activeClient ? [config.activeClient] : [];
1093 > if (initialCustomizations && initialCustomizations.length > 0) {
1094 state.customizations = [...initialCustomizations];
1095 }
1097 > // Refine the forked session's placeholder `Forked: …` title into one
1098 > // derived from the inherited chat. Forks seed pre-existing
1099 > // turns, so the normal first-message/first-turn title generation
1100 > // never fires for them — this is the fork-time equivalent.
1101 > if (sourceTurns.length > 0) {
1102 > this._sideEffects.generateForkedTitle(summary.resource, undefined, sourceTurns, forkedTitle, sourceTitle);
1103 > }
1104 > } else if (config?.importConversation) { agentService.ts ×16
1105 // An imported conversation arrives with pre-existing turns (assigned
1106 // fresh UUID ids above). Seed them into the new session's protocol
1107 // state so the client renders the imported history immediately; the
1108 // provider has already seeded the matching SDK event log so those
1109 // turns are editable / forkable / truncatable.
1110 const importedTurns = [...config.importConversation.turns];
1111 const importedTitle = this._buildImportedTitle(importedTurns);
1112 const summary = this._buildInitialSummary(provider, session, config, created, importedTitle);
1113 const state = this._stateManager.createSession(summary);
1114 state.config = sessionConfig;
1115 this._stateManager.seedDefaultChatTurns(summary.resource, importedTurns);
1116 state.activeClients = config.activeClient ? [config.activeClient] : [];
1117 if (initialCustomizations && initialCustomizations.length > 0) {
1118 state.customizations = [...initialCustomizations];
1119 }
1120
1121 // Refine the placeholder title into one generated from the imported
1122 // conversation, mirroring forks. Imports seed pre-existing turns, so
1123 // the normal first-message title generation never fires; without this
1124 // the session would keep showing the raw first-message clip while
1125 // sibling sessions show clean generated titles — making imports look
1126 // like a different kind of session.
1127 if (importedTurns.length > 0) {
1128 this._sideEffects.generateForkedTitle(summary.resource, undefined, importedTurns, importedTitle);
1129 }
1130 > } else { agentService.ts ×16
1131 > // Provisional sessions defer the `sessionAdded` notification and
1132 > // the `SessionReady` lifecycle transition until the agent fires
1133 > // {@link IAgent.onDidMaterializeSession} (typically on first
1134 > // `sendMessage`). Until then, the state exists in memory so
1135 > // clients can subscribe and stream config / model changes that
1136 > // the agent will pick up at materialization time.
1137 > const summary = this._buildInitialSummary(provider, session, config, created, '');
1138 > const state = this._stateManager.createSession(summary, { emitNotification: !created.provisional });
1139 > state.config = sessionConfig;
1140 > state.activeClients = config?.activeClient ? [config.activeClient] : [];
1141 > if (initialCustomizations && initialCustomizations.length > 0) {
1142 state.customizations = [...initialCustomizations];
1143 }
1145 > // Persist initial config values so a subsequent `restoreSession` can
1146 > // re-hydrate them. We persist the full resolved values (not just the
1147 > // user's input) so clients can render them on restore without having
1148 > // to re-resolve. Mid-session changes are persisted by `AgentSideEffects`
1149 > // when handling `SessionConfigChanged`.
1150 > if (sessionConfig?.values && Object.keys(sessionConfig.values).length > 0 && !created.provisional) { agentService.ts ×8
1151 > this._persistConfigValues(session, sessionConfig.values); agentService.ts ×3
1152 > }
1154 > this._changesetCoordinator.onSessionCreated(session.toString());
1155 >
1156 > if (!created.provisional) {
1157 > // Persist the AH-owned workspace-less marker now that the session DB agentService.ts ×5
1158 > // exists, from the value `_buildInitialSummary` inferred. Provisional
1159 > // sessions defer this to `_onDidMaterializeSession`.
1160 > this._persistWorkspaceless(session, readSessionWorkspaceless(this._stateManager.getSessionSummary(session.toString())?._meta));
1161 >
1162 > // `SessionReady` transitions the session lifecycle from
1163 > // `Creating` to `Ready`. For provisional sessions we defer
1164 > // this to {@link _onDidMaterializeSession} so subscribers
1165 > // don't see `Ready` until the agent actually has an SDK
1166 > // session, working directory, etc.
1167 > this._stateManager.dispatchServerAction(session.toString(), { type: ActionType.SessionReady });
1168 > }
1170 > // Refresh the git state for the session.
1171 > const workingDirectory = created.workingDirectory ?? config?.workingDirectory;
1172 > void this._gitStateService.refreshSessionGitState(session.toString(), workingDirectory); agentService.ts ×8
1173 >
1174 > return session;
1175 > }
1177 > async createChat(session: URI, chat: URI, options?: IAgentCreateChatOptions): Promise<void> {
1178 > const sessionKey = session.toString(); agentService.ts ×8
1179 > const provider = this._findProviderForSession(session);
1180 > if (!provider) {
1181 throw new Error(`[AgentService] createChat: no provider for session ${sessionKey}`);
1182 }
1183 > if (!this._supportsChats(provider)) { agentService.ts ×8
1184 throw new Error(`[AgentService] createChat: provider ${provider.id} does not support multiple chats`);
1185 }
1187 > // When forking, resolve the source chat's turns up to the fork point and
1188 > // mint fresh turn IDs for the new chat. The agent uses the mapping to
1189 > // remap per-turn data in the forked chat; the seeded turns make
1190 > // the new chat surface the forked history immediately.
1191 > let forkedTurns: Turn[] | undefined;
1192 > let forkedTitle: string | undefined;
1193 > let forkedSourceTitle: string | undefined;
1194 > let createOptions = options;
1195 > // Side chats validate and persist their provenance without seeding host-visible turns.
1196 > let sideChatOrigin: ChatOrigin | undefined;
1197 > if (options?.sideChat) {
1198 > const resolvedSideChat = this._resolveSideChatOrigin(session, options.sideChat); agentService.ts ×9
1199 > sideChatOrigin = resolvedSideChat.origin;
1200 > createOptions = {
1201 > ...options,
1202 > sideChat: {
1203 > ...options.sideChat,
1204 > source: URI.parse(resolvedSideChat.sourceChat),
1205 > ...(resolvedSideChat.providerAnchorTurnId ? { providerAnchorTurnId: resolvedSideChat.providerAnchorTurnId } : {}),
1206 > ...(resolvedSideChat.sourceContext ? { sourceContext: resolvedSideChat.sourceContext } : {}),
1207 > ...(resolvedSideChat.partialResponse ? { partialResponse: resolvedSideChat.partialResponse } : {}),
1208 > },
1209 > };
1210 > }
1211 > if (options?.fork) { agentService.ts ×8
1212 > const sourceKey = options.fork.source.toString(); agentService.ts ×3
1213 > const peerState = this._stateManager.getChatState(sourceKey);
1214 > const sourceState = peerState ?? this._stateManager.getDefaultChatState(sourceKey);
1215 > // Canonical chat URI the source's local turns are keyed by: when the
1216 > // source was found as a peer chat it is `sourceKey`; otherwise it was
1217 > // addressed by session URI and its default chat URI is canonical.
1218 > const sourceChatUri = peerState ? sourceKey : buildDefaultChatUri(sourceKey);
1219 > const sourceTurns = sourceState?.turns ?? [];
1220 > const forkIndex = sourceTurns.findIndex(t => t.id === options.fork!.turnId);
1221 > if (forkIndex < 0) {
1222 > // The fork point is unknown, so a fork is indistinguishable from a agentService.ts ×1
1223 > // fresh chat. Drop the fork to avoid the provider inheriting the
1224 > // whole backend chat while the UI is seeded with no turns.
1225 > createOptions = { ...options, fork: undefined };
1226 > } else { agentService.ts ×3
1227 > const slice = sourceTurns.slice(0, forkIndex + 1); agentService.ts ×4
1228 > const turnIdMapping = new Map<string, string>();
1229 > for (const t of slice) {
1230 > turnIdMapping.set(t.id, generateUuid());
1231 > }
1232 > forkedTurns = slice.map(t => ({ ...t, id: turnIdMapping.get(t.id) ?? generateUuid() }));
1233 >
1234 > // Carry forked host-injected local turns (`/rename`, `!command`)
1235 > // into the new chat so they survive reload and anchor future
1236 > // fork/truncate.
1237 > this._persistForkedLocalTurns(sessionKey, sourceChatUri, chat.toString(), slice, forkedTurns, turnIdMapping);
1238 >
1239 > const forkedTitlePrefix = localize('agentHost.forkedTitlePrefix', "Forked: ");
1240 > forkedSourceTitle = sourceState?.title || this._stateManager.getSessionState(sessionKey)?.title;
1241 > forkedTitle = forkedSourceTitle
1242 > ? (forkedSourceTitle.startsWith(forkedTitlePrefix) ? forkedSourceTitle : `${forkedTitlePrefix}${forkedSourceTitle}`) agentService.ts ×1
1243 > : localize('agentHost.forkedChatFallback', "Forked Chat"); agentService.ts ×1
1244 > // The SDK fork boundary must be a concrete (SDK-backed) turn. When agentService.ts ×4
1245 > // the client forked at a host-injected local turn, redirect the
1246 > // agent to the preceding concrete turn (the local turns are still
1247 > // seeded into the new chat's protocol state above).
1248 > const concreteForkTurnId = this._localTurns.resolveConcreteTurnId(sourceChatUri, options.fork.turnId);
1249 > createOptions = { ...options, fork: { ...options.fork, turnIdMapping, ...(concreteForkTurnId !== undefined ? { turnId: concreteForkTurnId } : {}) } };
1250 > }
1253 > // Spin up the backing chat in the harness first, then register
1254 > // the chat in the catalog so a `session/chatAdded` only reaches
1255 > // subscribers once the chat can actually receive messages. The agent
1256 > // returns the opaque `providerData` blob the orchestrator persists for
1257 > // restore (it never parses it); single-chat-only agents return `void`.
1258 > const createResult = await this._createChat(provider, chat, createOptions);
1259 > const providerData = createResult?.providerData; agentService.ts ×4
1260 > this._stateManager.addChat(sessionKey, chat.toString(), { agentService.ts ×8
1261 > ...(forkedTitle !== undefined ? { title: forkedTitle } : options?.title !== undefined ? { title: options.title } : {}),
1262 > ...(forkedTurns !== undefined ? { turns: forkedTurns } : {}),
1263 > ...(providerData !== undefined ? { providerData } : {}),
1264 > ...(sideChatOrigin !== undefined ? { origin: sideChatOrigin } : {}),
1265 > });
1266 >
1267 > // Persist the new peer chat into the orchestrator-owned catalog so it is
1268 > // re-enumerated and re-materialized on the next restore without asking
1269 > // the agent. Side-chat provenance is persisted alongside providerData.
1270 > void this._persistPeerChat(session, chat, providerData, sideChatOrigin);
1271 >
1272 > // When the agent backs this peer chat with its own separately-enumerable
1273 > // SDK session (e.g. Claude), mark that session so it is filtered out of
1274 > // the top-level session list instead of leaking as a standalone session.
1275 > if (createResult?.backingSession) {
1276 > this._markPeerChatBacking(createResult.backingSession, chat); agentService.ts ×5
1277 > }
1279 > // Refine the forked chat's placeholder `Forked: …` title into one
1280 > // derived from the inherited chat. Forks seed pre-existing
1281 > // turns, so the normal first-message/first-turn title generation never
1282 > // fires for them — this is the fork-time equivalent.
1283 > if (forkedTurns && forkedTurns.length > 0 && forkedTitle !== undefined) { agentService.ts ×8
1284 > this._sideEffects.generateForkedTitle(sessionKey, chat.toString(), forkedTurns, forkedTitle, forkedSourceTitle); agentService.ts ×4
1285 > }
1288 > /**
1289 > * Validates a side chat's source and returns its {@link ChatOriginKind.SideChat}
1290 > * origin. Throws when the source chat is not part of `session` or when the
1291 > * referenced completed or active turn is absent.
1292 > */
1293 > private _resolveSideChatOrigin(session: URI, sideChat: IAgentCreateChatSideChatSource): { origin: ChatOrigin; sourceChat: string; selection?: IAgentCreateChatSideChatSelection; providerAnchorTurnId?: string; sourceContext?: string; partialResponse?: string } {
1294 > const sessionKey = session.toString(); agentService.ts ×9
1295 > const sourceKey = sideChat.source.toString();
1296 > const { sourceChatKey, sourceSessionKey, sourceState } = this._resolveSessionSourceChat(session, sideChat.source);
1297 > // The source chat MUST belong to the target session. Older callers may
1298 > // still address the main chat by session URI; synced AHP clients send the
1299 > // actual default-chat URI.
1300 > if (sourceSessionKey !== sessionKey) {
1301 > throw new Error(`[AgentService] createChat: side chat source ${sourceKey} does not belong to session ${sessionKey}`); agentService.ts ×4
1302 > }
1303 > // The bounded turn must be a real completed or currently-active turn. agentService.ts ×2
1304 > const activeTurn = sourceState?.activeTurn?.id === sideChat.turnId ? sourceState.activeTurn : undefined; agentService.ts ×9
1305 > const hasCompletedTurn = sourceState?.turns.some(t => t.id === sideChat.turnId) ?? false;
1306 > if (!hasCompletedTurn && !activeTurn) {
1307 > throw new Error(`[AgentService] createChat: side chat source turn ${sideChat.turnId} not found in ${sourceKey}`); agentService.ts ×1
1308 > }
1309 > const isLocalSourceTurn = !activeTurn && this._localTurns.isLocal(sourceChatKey, sideChat.turnId); agentPeerChats.ts ×2
1310 > const providerAnchorTurnId = isLocalSourceTurn ? this._localTurns.resolveConcreteTurnId(sourceChatKey, sideChat.turnId) : undefined; agentService.ts ×9
1311 > const partialResponse = getSideChatPartialResponse(activeTurn);
1312 > const sourceContext = (activeTurn || isLocalSourceTurn)
1313 > ? buildBoundedSideChatSourceContext(sourceState?.turns ?? [], sideChat.turnId, activeTurn) agentService.ts ×3
1314 > : undefined; agentService.ts ×1
1315 > const selection = sideChat.selection?.text.trim() agentService.ts ×9
1316 > ? sideChat.selection agentService.ts ×1
1317 > : sideChat.selection agentService.ts ×1
1318 > ? (() => { throw new Error('[AgentService] createChat: side chat selection text must be non-empty'); })() agentService.ts ×1
1319 > : undefined; agentService.ts ×3
1320 > return { agentService.ts ×9
1321 > origin: {
1322 > kind: ChatOriginKind.SideChat,
1323 > chat: sourceChatKey,
1324 > turnId: sideChat.turnId,
1325 > ...(selection ? { selection } : {}),
1326 > },
1327 > sourceChat: sourceChatKey,
1328 > ...(selection ? { selection } : {}),
1329 > ...(providerAnchorTurnId ? { providerAnchorTurnId } : {}),
1330 > ...(sourceContext ? { sourceContext } : {}),
1331 > ...(partialResponse ? { partialResponse } : {}),
1332 > };
1333 > }
1335 > private _resolveSessionSourceChat(session: URI, source: URI): { sourceChatKey: string; sourceSessionKey: string; sourceState: ReturnType<AgentHostStateManager['getChatState']> | undefined } {
1336 > const sessionKey = session.toString(); agentService.ts ×9
1337 > const sourceKey = source.toString();
1338 > const sourceSessionKey = isAhpChatChannel(sourceKey) ? parseRequiredSessionUriFromChatUri(sourceKey) : sourceKey;
1339 > const defaultChatKey = this._stateManager.getSessionState(sessionKey)?.defaultChat ?? buildDefaultChatUri(sessionKey);
1340 > const sourceChatKey = sourceKey === sessionKey
1341 > ? defaultChatKey agentService.ts ×1
1342 > : this._stateManager.getChatState(sourceKey) agentService.ts ×1
1343 > ? sourceKey agentService.ts ×3
1344 > : isDefaultChatUri(sourceKey) && sourceSessionKey === sessionKey agentService.ts ×4
1345 ? defaultChatKey
1346 > : sourceKey; agentService.ts ×4
1347 > return { agentService.ts ×9
1348 > sourceSessionKey,
1349 > sourceChatKey,
1350 > sourceState: sourceChatKey === defaultChatKey
1351 > ? (this._stateManager.getChatState(defaultChatKey) ?? this._stateManager.getDefaultChatState(sessionKey)) agentService.ts ×2
1352 > : this._stateManager.getChatState(sourceChatKey), agentService.ts ×4
1354 > }
1356 > async disposeChat(session: URI, chat: URI): Promise<void> {
1357 > const sessionKey = session.toString(); agentService.ts ×3
1358 > const provider = this._findProviderForSession(session);
1359 > this._stateManager.removeChat(sessionKey, chat.toString());
1360 > // Drop the chat from the orchestrator-owned catalog so it isn't
1361 > // re-materialized on the next restore.
1362 > void this._removePersistedPeerChat(session, chat);
1363 > if (provider) {
1364 > await this._disposeChat(provider, chat);
1365 > }
1366 > }
1368 > // ---- Chat dispatch adapter ---------------------------------------------
1369 > //
1370 > // The orchestrator owns the feature-level `(session, chat)` →
1371 > // `(agent, session, chat)` mapping. It dispatches against an agent's
1372 > // chat-addressed surface ({@link IAgent.chats}) and session lifecycle
1373 > // ({@link IAgent.createSession}/{@link IAgent.disposeSession}).
1374 >
1375 > /** Whether `provider` can host additional (peer) chats. */
1376 > private _supportsChats(provider: IAgent): boolean {
1377 > return !!provider.chats; agentService.ts ×8
1378 > }
1380 > private async _createProviderSession(provider: IAgent, config: IAgentCreateSessionConfig | undefined, deferWorktreeCreation: boolean): Promise<IAgentCreateSessionResult> {
1381 > const requestedSessionId = deferWorktreeCreation && config?.session ? AgentSession.id(config.session) : undefined; agentService.ts ×16
1382 > if (requestedSessionId) {
1383 > this._worktree?.notePending(requestedSessionId); agentService.ts ×3
1384 > }
1386 > let created: IAgentCreateSessionResult | undefined;
1387 > try {
1388 > created = await provider.createSession(config ? this._toProviderConfig(config) : undefined);
1389 > if (deferWorktreeCreation && created.provisional) {
1390 > this._worktree?.notePending(AgentSession.id(created.session)); agentService.ts ×3
1391 > }
1392 > return created; agentService.ts ×16
1393 > } finally {
1394 > const returnedPendingSessionId = created?.provisional ? AgentSession.id(created.session) : undefined;
1395 > if (requestedSessionId && requestedSessionId !== returnedPendingSessionId) {
1396 > this._worktree?.clearPending(requestedSessionId); agentService.ts ×3
1397 > }
1399 > }
1401 > private async _disposeSession(provider: IAgent, session: URI): Promise<void> {
1402 > await provider.disposeSession(session); agentService.ts ×4
1403 > }
1405 > /**
1406 > * Reconstruct the turns for a chat. `chat` is the concrete chat channel URI,
1407 > * except for legacy restore paths that still address subagent sessions.
1408 > */
1409 > private async _getChatMessages(provider: IAgent, chat: URI): Promise<readonly Turn[]> {
1410 > const turns = await provider.chats.getMessages(chat); agentService.ts ×13
1411 > // Host-owned worktree restore announcement: re-inject the "Created isolated
1412 > // worktree" message at the top of the default chat's first turn from
1413 > // persisted metadata. No-op for folder sessions and non-default chats (peer
1414 > // / subagent). Agents stay unaware of worktrees.
1415 > if (this._worktree && isDefaultChatUri(chat)) {
1416 return this._worktree.applyRestoreAnnouncement(URI.parse(parseRequiredSessionUriFromChatUri(chat.toString())), turns);
1417 }
1418 > return turns; agentService.ts ×13
1419 > }
1421 > /**
1422 > * Merges persisted host-injected local turns (`/rename`, `!command`) for
1423 > * `chatUri` back into that chat's SDK-derived `turns`, positioned after
1424 > * their anchor turn (the concrete turn they were recorded after). Locals
1425 > * anchored before any real turn are prepended; locals whose anchor is absent
1426 > * from the SDK turns (e.g. truncated away) are dropped. Also seeds the
1427 > * in-memory local-turn index so fork/truncate resolve correctly before the
1428 > * next reload.
1429 > */
1430 > private async _interleaveLocalTurns(sessionStr: string, chatUri: string, turns: readonly Turn[]): Promise<Turn[]> {
1431 > const records = await this._localTurns.loadForChat(sessionStr, chatUri); agentService.ts ×13
1432 > if (records.length === 0) {
1433 > return [...turns]; agentService.ts ×1
1434 > }
1435 > const knownIds = new Set(turns.map(t => t.id)); agentService.ts ×3
1436 > const byAnchor = new Map<string, Turn[]>();
1437 > const head: Turn[] = [];
1438 > for (const record of records) {
1439 > let turn: Turn;
1440 > try {
1441 > turn = JSON.parse(record.payload) as Turn;
1442 > } catch {
1443 continue;
1444 }
1445 > if (record.anchorTurnId === undefined) { agentService.ts ×3
1446 > head.push(turn); agentService.ts ×1
1447 > } else if (knownIds.has(record.anchorTurnId)) { agentService.ts ×3
1448 > const list = byAnchor.get(record.anchorTurnId) ?? [];
1449 > list.push(turn);
1450 > byAnchor.set(record.anchorTurnId, list);
1451 > }
1452 > // else: orphaned (anchor truncated away) → drop.
1453 > }
1454 > const merged: Turn[] = [...head];
1455 > for (const turn of turns) {
1456 > merged.push(turn);
1457 > const locals = byAnchor.get(turn.id);
1458 > if (locals) {
1459 > merged.push(...locals);
1460 > }
1461 > }
1462 > return merged;
1465 > /**
1466 > * Re-persists forked host-injected local turns (`/rename`, `!command`) into
1467 > * a newly forked chat so they survive reload and anchor future
1468 > * fork/truncate. `originalSlice[i]` and `forkedTurns[i]` are the source turn
1469 > * and its remapped copy (same length, 1:1); `mapping` is the old→new turn id
1470 > * map used to remap each local turn's anchor. `persistSession` owns the
1471 > * destination database; `sourceChatUri` / `newChatUri` key the source and
1472 > * destination local-turn indexes.
1473 > *
1474 > * Shared by the {@link createSession} (default-chat) and {@link createChat}
1475 > * (peer-chat) fork paths.
1476 > */
1477 > private _persistForkedLocalTurns(persistSession: string, sourceChatUri: string, newChatUri: string, originalSlice: readonly Turn[], forkedTurns: readonly Turn[], mapping: ReadonlyMap<string, string>): void {
1478 > for (let i = 0; i < originalSlice.length; i++) { agentService.ts ×2
1479 > const original = originalSlice[i];
1480 > if (!this._localTurns.isLocal(sourceChatUri, original.id)) {
1481 > continue;
1482 > }
1483 > const originalAnchor = this._localTurns.resolveConcreteTurnId(sourceChatUri, original.id); agentService.ts ×1
1484 > const newAnchor = originalAnchor !== undefined ? mapping.get(originalAnchor) : undefined; agentService.ts ×2
1485 > this._localTurns.record(persistSession, newChatUri, forkedTurns[i], newAnchor);
1486 > }
1487 > }
1489 > /**
1490 > * Create (or fork) the peer chat `chat` within `session`. `chat` is
1491 > * always a peer URI here (the default chat is created implicitly with
1492 > * the session), so no default-chat resolution is needed.
1493 > */
1494 > private _createChat(provider: IAgent, chat: URI, options: IAgentCreateChatOptions | undefined): Promise<IAgentCreateChatResult | void> {
1495 > const convOptions: IAgentCreateChatOptions | undefined = options && (options.title !== undefined || options.model !== undefined || options.sideChat !== undefined) agentService.ts ×4
1496 > ? { agentService.ts ×1
1497 > ...(options.title !== undefined ? { title: options.title } : {}),
1498 > ...(options.model !== undefined ? { model: options.model } : {}),
1499 > ...(options.sideChat !== undefined ? { sideChat: options.sideChat } : {}),
1500 > }
1501 > : undefined; agentService.ts ×1
1502 > return options?.fork agentService.ts ×4
1503 > ? provider.chats.fork(chat, options.fork, convOptions) agentService.ts ×4
1504 > : provider.chats.createChat(chat, convOptions); agentService.ts ×1
1507 > private async _disposeChat(provider: IAgent, chat: URI): Promise<void> {
1508 > await provider.chats.disposeChat(chat); agentService.ts ×3
1509 > }
1511 > /**
1512 > * Derives a placeholder title for an imported session from its first user
1513 > * turn (imports seed pre-existing turns, so the normal first-message title
1514 > * generation never fires). Deliberately unprefixed: an imported session is a
1515 > * continuation of the source chat, not a distinct kind of session, so it
1516 > * should read like any other. The placeholder is later refined into a
1517 > * generated title (see the `importConversation` branch in `createSession`),
1518 > * but a neutral non-empty fallback is kept so the session still reads like a
1519 > * normal chat when generation is unavailable or fails.
1520 > */
1521 > private _buildImportedTitle(turns: readonly Turn[]): string {
1522 const firstText = turns.find(t => t.message?.text?.trim())?.message.text.trim();
1523 if (!firstText) {
1524 return localize('agentHost.importedSessionFallback', "New Session");
1525 }
1526 const MAX = 60;
1527 return firstText.length > MAX ? `${firstText.slice(0, MAX)}...` : firstText;
1528 }
1530 > private _buildInitialSummary(provider: IAgent, session: URI, config: IAgentCreateSessionConfig | undefined, created: { project?: { uri: URI; displayName: string }; workingDirectory?: URI }, title: string): SessionSummary {
1531 > const now = new Date().toISOString(); agentService.ts ×16
1532 > const primaryWorkingDir = (created.workingDirectory ?? config?.workingDirectory)?.toString();
1533 > return {
1534 > resource: session.toString(),
1535 > provider: provider.id,
1536 > title,
1537 > status: SessionStatus.Idle,
1538 > createdAt: now,
1539 > modifiedAt: now,
1540 > ...(created.project ? { project: { uri: created.project.uri.toString(), displayName: created.project.displayName } } : {}),
1541 > workingDirectories: primaryWorkingDir ? [primaryWorkingDir] : undefined,
1542 > // Workspace-less is inferred at create from an absent input
1543 > // `workingDirectory` (the host assigns a scratch cwd, so it can't be
1544 > // re-inferred later) and tagged on the generic `_meta` bag.
1545 > ...(config && !config.fork && !config.workingDirectory ? { _meta: withSessionWorkspaceless(undefined, true) } : {}),
1546 > };
1547 > }
1549 > /**
1550 > * Listen for an agent transitioning a provisional session into a fully
1551 > * materialized SDK session. The agent has already created the worktree
1552 > * (if any) and persisted on-disk metadata; we need to:
1553 > * - Refresh the in-memory summary with the resolved working directory
1554 > * and project metadata.
1555 > * - Persist any config values now that we have a real on-disk session.
1556 > * - Emit the deferred `notify/sessionAdded` so other clients learn of
1557 > * the session.
1558 > * - Dispatch `SessionReady` so subscribers see the lifecycle transition.
1559 > * - Lazily attach git state for the (possibly new) working directory.
1560 > */
1561 > private _onDidMaterializeSession(e: IAgentMaterializeSessionEvent): void {
1562 const sessionKey = e.session.toString();
1563 // The session is now materialized — its SDK is resolved (any cold
1564 // download already finished), so no further progress is expected for it.
1565 this._clearDownloadProgressInterest(sessionKey);
1566 const state = this._stateManager.getSessionState(sessionKey);
1567 if (!state) {
1568 this._logService.warn(`[AgentService] onDidMaterializeSession for unknown session: ${sessionKey}`);
1569 return;
1570 }
1571 const currentSummary = this._stateManager.getSessionSummary(sessionKey);
1572 if (!currentSummary) {
1573 this._logService.warn(`[AgentService] onDidMaterializeSession missing summary for session: ${sessionKey}`);
1574 return;
1575 }
1576 // The agent no longer knows about worktrees; the host's worktree project
1577 // (created in the first-send hook) wins for worktree-isolated sessions, and
1578 // falls back to whatever the agent reported for folder sessions.
1579 const project = this._worktree?.createdWorktreeProject(AgentSession.id(e.session)) ?? e.project;
1580 const summary: SessionSummary = {
1581 ...currentSummary,
1582 ...(project ? { project: { uri: project.uri.toString(), displayName: project.displayName } } : {}),
1583 workingDirectories: e.workingDirectory ? [e.workingDirectory.toString()] : currentSummary.workingDirectories,
1584 modifiedAt: new Date().toISOString(),
1585 };
1586 const configValues = state.config?.values;
1587 if (configValues && Object.keys(configValues).length > 0) {
1588 this._persistConfigValues(e.session, configValues);
1589 }
1590 // Persist the AH-owned workspace-less marker now that the session has a
1591 // real on-disk database (deferred from create for provisional sessions).
1592 this._persistWorkspaceless(e.session, readSessionWorkspaceless(summary._meta));
1593 // `markSessionPersisted` writes the summary into state and fires
1594 // the deferred `SessionAdded` notification atomically so subscribers
1595 // see consistent state through both paths.
1596 this._stateManager.markSessionPersisted(sessionKey, summary);
1597 this._stateManager.dispatchServerAction(sessionKey, { type: ActionType.SessionReady });
1598
1599 // Attach git state for the working directory (if present)
1600 void this._gitStateService.refreshSessionGitState(e.session.toString(), e.workingDirectory);
1601
1602 // If a client subscribed to this session's uncommitted changeset
1603 // before the working directory was known, the coordinator drains
1604 // the deferred refresh now that the working directory is set.
1605 this._changesetCoordinator.onSessionMaterialized(sessionKey);
1606 }
1608 > /** Drop a session's download-progress opt-in, if any. */
1609 > private _clearDownloadProgressInterest(sessionKey: string): void {
1610 > for (const [provider, sessions] of this._downloadProgressInterest) { agentService.ts ×4
1611 if (sessions.delete(sessionKey) && sessions.size === 0) {
1612 this._downloadProgressInterest.delete(provider);
1613 }
1614 }
1617 > /**
1618 > * Surface a host-level SDK download as client progress. The downloader fires
1619 > * process-global frames keyed by package id (which equals the provider id);
1620 > * because the download is shared across every session of that provider, we
1621 > * emit a SINGLE `progress` stream keyed by that package id — not one per
1622 > * session — so the client shows exactly one indicator no matter how many
1623 > * sessions of the provider are awaiting it. Frames are only emitted while at
1624 > * least one session has opted in (supplied a
1625 > * {@link IAgentCreateSessionConfig.progressToken} on `createSession`). A
1626 > * terminal frame reports `total === progress` (using `receivedBytes` when the
1627 > * size was never known) so the client dismisses the indicator deterministically.
1628 > *
1629 > * `displayName` is the provider's brand noun (e.g. `Claude`). It is woven
1630 > * into the notification's localized, human-readable `message` (e.g.
1631 > * "Downloading Claude agent") so a generic client can render the indicator
1632 > * verbatim without knowing the resource is an agent SDK. No trailing
1633 > * ellipsis: clients render progress as "<title>: <percent>", so an ellipsis
1634 > * would read as an unusual "…:" (see #324455).
1635 > */
1636 > emitDownloadProgress(packageId: string, displayName: string, receivedBytes: number, totalBytes: number | undefined, terminal: boolean): void {
1637 const sessions = this._downloadProgressInterest.get(packageId);
1638 if (!sessions || sessions.size === 0) {
1639 return;
1640 }
1641 // On a terminal frame force `progress === total` so clients treat the
1642 // operation as complete (covers both the determinate case and the
1643 // indeterminate one where `totalBytes` was never known, plus failures —
1644 // the real error surfaces via the session-failure path).
1645 const total = terminal ? receivedBytes : totalBytes;
1646 const message = localize('agentHost.download.agentSdkTitle', "Downloading {0} agent", displayName);
1647 // `progressToken` is the download's own stable identity (the package id),
1648 // shared by every session of the provider, so the client coalesces all
1649 // frames into one indicator and dismisses it on the terminal frame.
1650 this._stateManager.emitProgress({ progressToken: packageId, progress: receivedBytes, total, message });
1651 if (terminal) {
1652 this._downloadProgressInterest.delete(packageId);
1653 }
1654 }
1656 > private _persistWorkspaceless(session: URI, workspaceless: boolean): void {
1657 > let ref; agentService.ts ×5
1658 > try {
1659 > ref = this._sessionDataService.openDatabase(session);
1660 > } catch (err) {
1661 > this._logService.warn(`[AgentService] Failed to open session database to persist workspaceless for ${session.toString()}: ${toErrorMessage(err)}`); agentService.ts ×1
1662 > return;
1663 > }
1664 > ref.object.setMetadata(AH_META_WORKSPACELESS_DB_KEY, workspaceless ? 'true' : 'false').catch(err => { agentService.ts ×5
1665 this._logService.warn(`[AgentService] Failed to persist workspaceless for ${session.toString()}: ${toErrorMessage(err)}`);
1666 > }).finally(() => { agentService.ts ×5
1667 > ref.dispose(); agentService.ts ×1
1668 > }); agentService.ts ×5
1669 > }
1671 > private _persistConfigValues(session: URI, values: Record<string, unknown>): void {
1672 > let ref; agentService.ts ×3
1673 > try {
1674 > ref = this._sessionDataService.openDatabase(session);
1675 > } catch (err) {
1676 > this._logService.warn(`[AgentService] Failed to open session database to persist configValues for ${session.toString()}: ${toErrorMessage(err)}`); agentService.ts ×1
1677 > return;
1678 > }
1679 > ref.object.setMetadata('configValues', JSON.stringify(values)).catch(err => { agentService.ts ×2
1680 this._logService.warn(`[AgentService] Failed to persist configValues for ${session.toString()}: ${toErrorMessage(err)}`);
1681 > }).finally(() => { agentService.ts ×2
1682 > ref.dispose();
1683 > });
1686 > private async _resolveCreatedSessionConfig(provider: IAgent, config: IAgentCreateSessionConfig | undefined): Promise<SessionConfigState | undefined> {
1687 > if (!config?.config && !config?.workingDirectory) { agentHostChangesetService.ts ×3
1688 > return undefined; agentService.ts ×1
1689 > }
1690 > const params: IAgentResolveSessionConfigParams = { agentService.ts ×1
1691 > provider: provider.id,
1692 > workingDirectory: config.workingDirectory,
1693 > config: config.config,
1694 > };
1695 > try {
1696 > // Wrap with the host's isolation schema so the created config carries the
1697 > // `isolation` / `branch` values (and their git-derived defaults). The
1698 > // agent's own `resolveSessionConfig` omits them (isolation is host-owned),
1699 > // so without this a fresh worktree session's isolation is `undefined` at
1700 > // create time — the pending mark below is skipped and the send falls back
1701 > // to folder even though the user picked worktree.
1702 > const resolved = await this._withIsolationSchema(await provider.resolveSessionConfig(this._toProviderConfig(params)), params);
1703 > return { schema: resolved.schema, values: resolved.values };
1704 > } catch (err) {
1705 this._logService.error(`[AgentService] Failed to resolve created session config for provider ${provider.id}`, err);
1706 return config.config ? { schema: { type: 'object', properties: {} }, values: config.config } : undefined;
1707 }
1710 > async resolveSessionConfig(params: IAgentResolveSessionConfigParams): Promise<ResolveSessionConfigResult> {
1711 > const providerId = params.provider ?? this._defaultProvider; agentService.ts ×3
1712 > const provider = providerId ? this._providers.get(providerId) : undefined;
1713 > if (!provider) {
1714 throw new Error(`No agent provider registered for: ${providerId ?? '(none)'}`);
1715 }
1716 > return this._withIsolationSchema(await provider.resolveSessionConfig(this._toProviderConfig(params)), params); agentService.ts ×3
1717 > }
1719 > /**
1720 > * Host-owned contribution of the shared `isolation` (folder / worktree),
1721 > * `branch`, `worktreeBranchPrefix`, and `worktreeIncludeFiles` session-config
1722 > * properties on top of whatever an agent returned from `resolveSessionConfig`. Provider-returned
1723 > * properties and values with these keys are replaced by the host contribution.
1724 > */
1725 > private async _withIsolationSchema(result: ResolveSessionConfigResult, params: IAgentResolveSessionConfigParams): Promise<ResolveSessionConfigResult> {
1726 > if (!this._worktree) { agentService.ts ×5
1727 > return result; agentService.ts ×1
1728 > }
1729 > const iso = await this._worktree.resolveIsolationConfig({ workingDirectory: params.workingDirectory, config: params.config }); agentService.ts ×9
1730 > const properties: Record<string, SessionConfigPropertySchema> = {
1731 > [SessionConfigKey.Isolation]: iso.isolationProperty.protocol,
1732 > ...omitHostOwnedSessionConfig(result.schema.properties),
1733 > };
1734 > if (iso.branchProperty) {
1735 > properties[SessionConfigKey.Branch] = iso.branchProperty.protocol; agentService.ts ×5
1736 > }
1737 > if (iso.worktreeBranchPrefixProperty) { agentService.ts ×9
1738 > properties[SessionConfigKey.WorktreeBranchPrefix] = iso.worktreeBranchPrefixProperty.protocol; agentService.ts ×5
1739 > }
1740 > if (iso.worktreeIncludeFilesProperty) { agentService.ts ×9
1741 > properties[SessionConfigKey.WorktreeIncludeFiles] = iso.worktreeIncludeFilesProperty.protocol; agentService.ts ×5
1742 > }
1743 > const values = omitHostOwnedSessionConfig(result.values); agentService.ts ×9
1744 > values[SessionConfigKey.Isolation] = iso.isolationValue;
1745 > if (iso.branchProperty && iso.branchValue !== undefined) { agentService.ts ×5
1746 > values[SessionConfigKey.Branch] = iso.branchValue; agentService.ts ×5
1747 > }
1748 > if (iso.worktreeBranchPrefixProperty && typeof params.config?.[SessionConfigKey.WorktreeBranchPrefix] === 'string') { agentService.ts ×5
1749 > values[SessionConfigKey.WorktreeBranchPrefix] = params.config[SessionConfigKey.WorktreeBranchPrefix]; agentService.ts ×4
1750 > }
1751 > if (iso.worktreeIncludeFilesProperty agentService.ts ×9
1752 > && Array.isArray(params.config?.[SessionConfigKey.WorktreeIncludeFiles]) agentService.ts ×5
1753 > && params.config[SessionConfigKey.WorktreeIncludeFiles].every(pattern => typeof pattern === 'string')) { agentService.ts ×5
1754 > values[SessionConfigKey.WorktreeIncludeFiles] = params.config[SessionConfigKey.WorktreeIncludeFiles]; agentService.ts ×3
1755 > }
1756 > return { schema: { ...result.schema, properties }, values }; agentService.ts ×9
1759 > async sessionConfigCompletions(params: IAgentSessionConfigCompletionsParams): Promise<SessionConfigCompletionsResult> {
1760 > // The host owns branch completions for every agent (they share the same agentService.ts ×4
1761 > // git-backed branch list); all other properties stay provider-specific.
1762 > if (params.property === SessionConfigKey.Branch && this._worktree) {
1763 return this._worktree.branchCompletions(params.workingDirectory, params.query);
1764 }
1765 > const providerId = params.provider ?? this._defaultProvider; agentService.ts ×4
1766 > const provider = providerId ? this._providers.get(providerId) : undefined;
1767 > if (!provider) {
1768 throw new Error(`No agent provider registered for: ${providerId ?? '(none)'}`);
1769 }
1770 > return provider.sessionConfigCompletions(this._toProviderConfig(params)); agentService.ts ×4
1771 > }
1773 > async completions(params: CompletionsParams): Promise<CompletionsResult> {
1774 return this._completions.completions(params);
1775 }
1777 > async getCompletionTriggerCharacters(): Promise<readonly string[]> {
1778 return this._completions.triggerCharacters;
1779 }
1781 > async disposeSession(session: URI): Promise<void> {
1782 > this._logService.trace(`[AgentService] disposeSession: ${session.toString()}`); agentService.ts ×2
1783 > const provider = this._findProviderForSession(session);
1784 > if (provider) {
1785 > await this._disposeSession(provider, session); agentService.ts ×4
1786 > this._sessionToProvider.delete(session.toString());
1787 > this._clearDownloadProgressInterest(session.toString());
1788 > }
1789 > // Remove any worktree this process created for the session (host-owned; agentService.ts ×2
1790 > // agents stay unaware).
1791 > await this._worktree?.removeCreatedWorktree(AgentSession.id(session));
1792 > this._changesetCoordinator.onSessionDisposed(session.toString());
1793 > this._sideEffects.cancelSessionTitleGeneration(session.toString());
1794 > // Remove all subagent sessions for this parent
1795 > this._sideEffects.removeSubagentSessions(session.toString());
1796 > this._stateManager.deleteSession(session.toString());
1797 > // Remove the VS Code per-session data directory (metadata DB + checkpoints) to mirror the SDK-side cleanup
1798 > // performed by the provider above. No-op when the directory does not exist.
1799 > await this._sessionDataService.deleteSessionData(session);
1800 > }
1802 > // ---- Protocol methods ---------------------------------------------------
1803 >
1804 > async createTerminal(params: CreateTerminalParams): Promise<void> {
1805 await this._terminalManager.createTerminal(params);
1806 }
1808 > async disposeTerminal(terminal: URI): Promise<void> {
1809 this._terminalManager.disposeTerminal(terminal.toString());
1810 }
1812 > async subscribe(resource: URI, clientId: string): Promise<IStateSnapshot> {
1813 > this._logService.trace(`[AgentService] subscribe: ${resource.toString()}`); agentService.ts ×7
1814 > const resourceStr = resource.toString();
1815 > // Register the subscriber up front so a concurrent unsubscribe cannot
1816 > // evict the session state while we are awaiting restore. On any failure
1817 > // path below we must roll the registration back, otherwise the leaked
1818 > // refcount would permanently pin (or block eviction of) the resource.
1819 > // {@link addSubscriber} is the single point that triggers the
1820 > // uncommitted-changeset refresh on the 0→1 transition (covers both
1821 > // the cold-snapshot path here and the handshake fast-path used by
1822 > // {@link ProtocolServerHandler} when state is already cached).
1823 > this.addSubscriber(resource, clientId);
1824 > try {
1825 > // Check for terminal state
1826 > const terminalState = this._terminalManager.getTerminalState(resourceStr);
1827 > if (terminalState) {
1828 return { resource: resourceStr, state: terminalState, fromSeq: this._stateManager.serverSeq };
1829 }
1831 > let snapshot = this._stateManager.getSnapshot(resourceStr);
1832 > const parsedChangeset = parseChangesetUri(resourceStr);
1833 > if (snapshot && parsedChangeset && !this._stateManager.getSessionState(parsedChangeset.sessionUri)) {
1834 await this._changesetCoordinator.restoreSessionIfChangesetSubscription(resource, s => this.restoreSession(s));
1835 snapshot = this._stateManager.getSnapshot(resourceStr);
1836 }
1837 > if (!snapshot) { agentService.ts ×7
1838 > // Chat channel URIs carry their owning session URI. The chat agentService.ts ×5
1839 > // snapshot only materializes once that session is restored
1840 > // (which seeds the default chat state), so restore the parent
1841 > // session rather than the chat URI itself. This makes the
1842 > // chat-channel subscribe self-sufficient and independent of
1843 > // whether the session channel was subscribed first.
1844 > const parsedChatSession = parseDefaultChatUri(resourceStr);
1845 > if (parsedChatSession !== undefined) {
1846 > if (!this._stateManager.getSessionState(parsedChatSession)) { agentService.ts ×6
1847 const parentUri = URI.parse(parsedChatSession);
1848 const parsedSubagentParent = parseSubagentSessionUri(parentUri);
1849 if (parsedSubagentParent) {
1850 await this._restoreSubagentSession(parsedChatSession, parsedSubagentParent.parentSession);
1851 } else {
1852 await this.restoreSession(parentUri);
1853 }
1854 }
1855 > snapshot = this._stateManager.getSnapshot(resourceStr); agentService.ts ×6
1856 > }
1858 > if (!snapshot) { agentService.ts ×7
1859 > if (isSubagentChatUri(resource)) { agentService.ts ×5
1860 > // May be mid-registration; wait rather than fail immediately. agentService.ts ×6
1861 > snapshot = await this._awaitPendingSubagentChat(resourceStr);
1862 > } else { agentService.ts ×5
1863 > // Changeset URIs are routed through the coordinator (which agentHostChangesetCoordinator.ts ×3
1864 > // owns its URI shape, the unknown-id early throw, and turn
1865 > // / static seeding). Other URIs fall through to the
1866 > // subagent / session-default path below.
1867 > const handled = await this._changesetCoordinator.tryHandleSubscribe(resource, s => this.restoreSession(s));
1868 > if (handled) { agentService.ts ×5
1869 snapshot = this._stateManager.getSnapshot(resourceStr);
1870 > } else { agentService.ts ×5
1871 > // Try subagent restore before regular session restore
1872 > const parsedSubagent = parseSubagentSessionUri(resource);
1873 > if (parsedSubagent) {
1874 > await this._restoreSubagentSession(resourceStr, parsedSubagent.parentSession); agentService.ts ×10
1875 > } else { agentService.ts ×5
1876 > await this.restoreSession(resource); agentService.ts ×1
1877 > }
1878 > snapshot = this._stateManager.getSnapshot(resourceStr); agentService.ts ×5
1879 > }
1882 > if (!snapshot) { agentService.ts ×1
1883 > throw new Error(`Cannot subscribe to unknown resource: ${resourceStr}`); agentService.ts ×1
1884 > }
1886 > // Ensure git state has been computed for this session. When the snapshot
1887 > // already existed (e.g. seeded by list query, or restored earlier), the
1888 > // restore path that normally calls `_attachGitState` is skipped — so
1889 > // trigger it lazily here for the first subscriber. `_attachGitState`
1890 > // is async and updates `_meta.git` once ready, which clients see via
1891 > // the normal state-update stream.
1892 > const sessionState = this._stateManager.getSessionState(resourceStr);
1893 > if (!isAhpChatChannel(resourceStr) && sessionState && readSessionGitState(sessionState._meta) === undefined) { agentService.ts ×7
1894 > const workingDirectory = sessionState.workingDirectories?.[0] agentService.ts ×2
1895 > ? URI.parse(sessionState.workingDirectories[0]) agentHostChangesetService.ts ×1
1896 > : undefined; agentService.ts ×5
1897 > void this._gitStateService.refreshSessionGitState(resourceStr, workingDirectory); agentService.ts ×2
1898 > }
1900 > return snapshot;
1901 > } catch (err) { agentService.ts ×7
1902 > this.unsubscribe(resource, clientId); agentService.ts ×1
1903 > throw err;
1904 > }
1907 > /** Waits for an armed subagent chat to register (or its wait to time out); returns `undefined` if not armed or never registered. */
1908 > private async _awaitPendingSubagentChat(subagentChatUri: string): Promise<IStateSnapshot | undefined> {
1909 > const pending = this._pendingSubagentChats.get(subagentChatUri); agentService.ts ×6
1910 > if (!pending) {
1911 > return undefined; reducer.ts ×1
1912 > }
1913 > await pending.p; agentService.ts ×4
1914 > return this._stateManager.getSnapshot(subagentChatUri);
1917 > addSubscriber(resource: URI, clientId: string): void {
1918 > let set = this._resourceSubscribers.get(resource); agentService.ts ×1
1919 > const wasUnsubscribed = !set || set.size === 0;
1920 > if (!set) {
1921 > set = new Set();
1922 > this._resourceSubscribers.set(resource, set);
1923 > }
1924 > set.add(clientId);
1925 > // A new subscriber means the session is being observed again; cancel
1926 > // any pending GC or idle-release armed while it had no subscribers.
1927 > this._cancelPendingSessionGc(resource);
1928 > this._cancelPendingSessionRelease(resource);
1929 > // 0→1 transition — covers both the full subscribe path AND the
1930 > // handshake fast-path used by `ProtocolServerHandler` when state is
1931 > // already cached. The coordinator decides whether the URI is one
1932 > // it cares about (e.g. uncommitted changeset → trigger refresh).
1933 > if (wasUnsubscribed) {
1934 > this._changesetCoordinator.onFirstSubscriber(resource);
1935 > }
1936 > }
1938 > unsubscribe(resource: URI, clientId: string): void {
1939 > const set = this._resourceSubscribers.get(resource); agentService.ts ×7
1940 > if (!set) {
1941 return;
1942 }
1943 > set.delete(clientId); agentService.ts ×7
1944 > if (set.size > 0) {
1945 > return; agentService.ts ×1
1946 > }
1947 > this._resourceSubscribers.delete(resource); agentService.ts ×7
1948 > this._changesetCoordinator.onLastSubscriber(resource);
1949 > this._stateManager.onChangesetLivenessChanged();
1950 > // An empty session whose last subscriber dropped is a candidate for
1951 > // full GC (provider session, worktree, on-disk state). Sessions with
1952 > // at least one turn fall through to {@link _maybeEvictIdleSession},
1953 > // which only drops the in-memory cache and lets the session be
1954 > // restored from disk later. Skipping eviction here for empty
1955 > // sessions ensures their state stays observable so a re-subscribe
1956 > // can re-arm GC.
1957 > if (this._maybeScheduleSessionGc(resource)) {
1958 > return; agentService.ts ×3
1959 > }
1960 > // Defer the idle-session release behind a grace window rather than agentService.ts ×2
1961 > // releasing synchronously. A client that reconnects (or re-subscribes)
1962 > // within the window cancels this via {@link _cancelPendingSessionRelease}
1963 > // and keeps the live provider SDK session, avoiding a disconnect/resume
1964 > // churn cycle that races concurrent session operations on the shared
1965 > // provider runtime. A zero grace releases on the next tick.
1966 > this._pendingSessionRelease.set(resource, disposableTimeout(() => {
1967 > this._pendingSessionRelease.deleteAndDispose(resource); agentService.ts ×10
1968 > this._maybeEvictIdleSession(resource);
1969 > }, SESSION_RELEASE_GRACE_MS)); agentService.ts ×2
1972 > private _cancelPendingSessionRelease(resource: URI): void {
1973 > this._pendingSessionRelease.deleteAndDispose(resource); agentService.ts ×2
1974 > }
1976 > /**
1977 > * If `resource` names a session that no client is still subscribed to and
1978 > * that has produced no turns (and has no active turn), schedule a delayed
1979 > * {@link _runSessionGc} to fully tear it down — provider session, worktree,
1980 > * persisted state and all. Sessions with at least one turn are left to the
1981 > * existing {@link _maybeEvictIdleSession} path which only drops cached
1982 > * state and lets the session be restored from disk later.
1983 > *
1984 > * The delay ({@link SESSION_GC_GRACE_MS}) gives a disconnected client time
1985 > * to reconnect or a workspace switch to settle. Any subsequent subscribe
1986 > * (or createSession on the same URI) cancels the timer via
1987 > * {@link _cancelPendingSessionGc}.
1988 > *
1989 > * Returns `true` if a GC timer was armed (existing or newly scheduled),
1990 > * so callers can skip alternative cleanup paths.
1991 > */
1992 > private _maybeScheduleSessionGc(resource: URI): boolean {
1993 > // Subagent URIs are backed by the parent session; the parent's GC is agentService.ts ×7
1994 > // scheduled when its own subscriber count reaches zero.
1995 > if (parseSubagentSessionUri(resource)) {
1996 > return false; agentService.ts ×2
1997 > }
1998 > const key = resource.toString(); agentService.ts ×1
1999 > const state = this._stateManager.getSessionState(key);
2000 > if (!state) {
2001 > return false; agentService.ts ×1
2002 > }
2003 > if (state.turns.length > 0 || state.activeTurn !== undefined) { agentService.ts ×7
2004 > return false; agentService.ts ×1
2005 > }
2006 > this._pendingSessionGc.set(resource, disposableTimeout(() => { agentService.ts ×3
2007 > this._pendingSessionGc.deleteAndDispose(resource); agentService.ts ×5
2008 > this._runSessionGc(resource).catch(err => {
2009 this._logService.error(err, `[AgentService] GC failed for ${key}`);
2010 > }); agentService.ts ×5
2011 > }, SESSION_GC_GRACE_MS)); agentService.ts ×3
2012 > return true;
2015 > private _cancelPendingSessionGc(resource: URI): void {
2016 > this._pendingSessionGc.deleteAndDispose(resource); agentService.ts ×2
2017 > }
2019 > /**
2020 > * Fires {@link SESSION_GC_GRACE_MS} after a session lost its last
2021 > * subscriber while empty. Re-checks both invariants (still no subscribers,
2022 > * still empty) before tearing the session down via {@link disposeSession}.
2023 > * The cached state may already have been evicted by
2024 > * {@link _maybeEvictIdleSession}; in that case we still proceed because
2025 > * "evicted + no resubscribe" implies no client is observing the session.
2026 > */
2027 > private async _runSessionGc(resource: URI): Promise<void> {
2028 > const key = resource.toString(); agentService.ts ×5
2029 > if (this._resourceSubscribers.has(resource)) {
2030 return;
2031 }
2032 > const state = this._stateManager.getSessionState(key); agentService.ts ×5
2033 > if (state && (state.turns.length > 0 || state.activeTurn !== undefined)) {
2034 return;
2035 }
2036 > this._logService.info(`[AgentService] GC: disposing empty unsubscribed session ${key}`); agentService.ts ×5
2037 > await this.disposeSession(resource);
2038 > }
2040 > /**
2041 > * If `resource` names an idle session and no client is still subscribed to
2042 > * it (or, for a subagent URI, no sibling subagent under the same parent is
2043 > * still subscribed), release its in-memory footprint: drop the cached AHP
2044 > * state from the state manager AND ask the provider to release the session's
2045 > * SDK resources ({@link IAgent.releaseSession}). Subagent URIs evict the
2046 > * parent session entry; the parent owns the materialized turn tree that
2047 > * backs every subagent view. Nothing durable is deleted — the next subscribe
2048 > * rehydrates the session via {@link restoreSession} and the provider resumes
2049 > * the SDK session on demand.
2050 > */
2051 > private _maybeEvictIdleSession(resource: URI): void {
2052 > const key = resource.toString(); agentService.ts ×10
2053 > if (this._resourceSubscribers.has(resource)) {
2054 return;
2055 }
2056 > // Walk up the subagent ancestry: the SDK session and its turn tree are agentService.ts ×10
2057 > // owned by the root session, so eviction must target the root.
2058 > let evictionTarget = resource;
2059 > {
2060 > let parsed;
2061 > while ((parsed = parseSubagentSessionUri(evictionTarget))) {
2062 > evictionTarget = parsed.parentSession; agentService.ts ×2
2063 > }
2065 > // Don't evict if the root or any of its subagent descendants still has subscribers.
2066 > if (this._resourceSubscribers.has(evictionTarget)) {
2067 return;
2068 }
2069 > for (const subscribedUri of this._resourceSubscribers.keys()) { agentService.ts ×10
2070 > if (this._isSubagentDescendantOf(subscribedUri, evictionTarget)) { agentService.ts ×4
2071 > return;
2072 > }
2073 > }
2074 > const evictionTargetKey = evictionTarget.toString(); agentService.ts ×10
2075 > // A restore/resume racing this unsubscribe means a client is about to
2076 > // observe the session again; releasing now would tear down state that
2077 > // the in-flight rehydrate is populating.
2078 > if (this._restoreSessionInFlight.has(evictionTargetKey)) {
2079 return;
2080 }
2081 > const targetState = this._stateManager.getSessionState(evictionTargetKey); agentService.ts ×10
2082 > if (!targetState || targetState.activeTurn !== undefined) {
2083 return;
2084 }
2085 > this._logService.info(`[AgentService] Evicting idle session: ${evictionTargetKey} (triggered by unsubscribe of ${key})`); agentService.ts ×10
2086 > // Also evict any sibling subagent entries cached under the parent: their
2087 > // authoritative state is the parent's turn tree, and dropping the parent
2088 > // would leave them orphaned.
2089 > const subagentPrefix = buildSubagentSessionUriPrefix(evictionTarget);
2090 > for (const cachedKey of this._stateManager.getSessionUrisWithPrefix(subagentPrefix)) {
2091 > this._stateManager.removeSession(cachedKey); agentService.ts ×4
2092 > }
2093 > this._stateManager.removeSession(evictionTargetKey); agentService.ts ×10
2094 > // Release the provider's in-memory SDK session in lockstep with the
2095 > // cached state. Non-destructive: durable data is preserved so the
2096 > // session resumes transparently on the next access. Fire-and-forget —
2097 > // the provider sequences the release internally and re-checks its own
2098 > // invariants (e.g. a turn that started after this call).
2099 > const provider = this._findProviderForSession(evictionTarget);
2100 > provider?.releaseSession?.(evictionTarget).catch(err => {
2101 this._logService.error(err, `[AgentService] Failed to release idle session ${evictionTargetKey}`);
2103 > }
2105 > // Returns true when a changeset is safe to drop from the in-memory cache.
2106 > private _isChangesetEvictable(changeset: string): boolean {
2107 const changesetUri = URI.parse(changeset);
2108 // A direct changeset subscriber is rendering this expanded URI. Keep
2109 // the state alive so future envelopes still target an existing object.
2110 if (this._resourceSubscribers.has(changesetUri)) {
2111 return false;
2112 }
2113 const parsed = parseChangesetUri(changeset);
2114 // This guard only handles recognized changeset URIs; leave anything else alone.
2115 if (!parsed) {
2116 return false;
2117 }
2118 const sessionUri = URI.parse(parsed.sessionUri);
2119 // A parent-session subscriber can still receive catalogue count updates
2120 // from this changeset, so keep the backing state while the session is observed.
2121 if (this._resourceSubscribers.has(sessionUri)) {
2122 return false;
2123 }
2124 // Subagent views are backed by the parent session tree; treat any
2125 // subscribed descendant as a parent-session pin for cache eviction.
2126 for (const subscribedUri of this._resourceSubscribers.keys()) {
2127 if (this._isSubagentDescendantOf(subscribedUri, sessionUri)) {
2128 return false;
2129 }
2130 }
2131 // If a git/session/uncommitted changeset recompute is currently running for this changeset URI,
2132 // do not evict its cached state yet. Once the compute is done,
2133 // it is safe to evict because the state is just a cache and can be recreated later.
2134 return !this._changesets.isStaticChangesetComputeActive(changeset);
2135 }
2137 > private _isSubagentDescendantOf(resource: URI, parent: URI): boolean {
2138 > let parsed = parseSubagentSessionUri(resource); agentService.ts ×4
2139 > while (parsed) {
2140 > if (isEqual(parsed.parentSession, parent)) {
2141 > return true;
2142 > }
2143 parsed = parseSubagentSessionUri(parsed.parentSession);
2144 }
2145 return false;
2148 > /**
2149 > * Per-client sequencer that serialises action dispatches whose
2150 > * processing requires an asynchronous prelude (e.g. snapshotting
2151 > * user-message attachments into the session database before the
2152 > * action is reduced into state). Actions that don't need any
2153 > * asynchronous prelude bypass the queue entirely as long as no
2154 > * earlier action from the same client is still pending.
2155 > *
2156 > * todo@connor4312: we can drop this when sending a message become a command
2157 > */
2158 > private readonly _clientDispatchQueues = new Map<string, Promise<void>>();
2159 >
2160 > dispatchAction(channel: string, action: SessionAction | ChatAction | TerminalAction | ClientChangesetAction | ClientAnnotationsAction | IRootConfigChangedAction, clientId: string, clientSeq: number): void {
2161 > this._logService.trace(`[AgentService] dispatchAction: type=${action.type}, clientId=${clientId}, clientSeq=${clientSeq}`, action); agentService.ts ×9
2162 >
2163 > // Clients dispatch chat (chat) actions against a chat channel
2164 > // URI. Keep that chat channel for the optimistic state apply and for
2165 > // per-chat routing in side effects, while deriving the owning session
2166 > // URI for all session-scoped work (attachment snapshotting, agent
2167 > // lookup, telemetry, permissions — all keyed by session).
2168 > const chatChannel = isAhpChatChannel(channel) ? channel : undefined;
2169 > const sessionChannel = chatChannel ? parseRequiredSessionUriFromChatUri(chatChannel) : channel;
2170 >
2171 > const pending = this._clientDispatchQueues.get(clientId);
2172 > if (!pending && !this._needsAsyncRewrite(sessionChannel, action)) {
2173 > this._dispatchActionNow(channel, sessionChannel, action, clientId, clientSeq); agentService.ts ×1
2174 > return;
2175 > }
2176 > const next = (pending ?? Promise.resolve()).then(async () => { agentService.ts ×9
2177 > const rewritten: SessionAction | ChatAction | TerminalAction | ClientChangesetAction | ClientAnnotationsAction | IRootConfigChangedAction = this._needsAsyncRewrite(sessionChannel, action) agentService.ts ×10
2178 > ? await this._rewriteUserMessageAttachments(sessionChannel, action, clientId)
2179 : action;
2180 > if (rewritten.type === ActionType.ChangesetFilesReviewChanged) { agentService.ts ×10
2181 await this._reviewService.setReviewState(channel, rewritten.files, rewritten.reviewed);
2182 const changeset = parseChangesetUri(channel);
2183 if (!changeset) {
2184 throw new Error(`Invalid changeset URI: ${channel}`);
2185 }
2186 this._changesets.refreshBranchChangeset(changeset.sessionUri);
2187 }
2188 > this._dispatchActionNow(channel, sessionChannel, rewritten, clientId, clientSeq); agentService.ts ×10
2189 > }).catch(err => { agentService.ts ×9
2190 this._logService.error(`[AgentService] async dispatchAction failed: ${toErrorMessage(err)}`);
2191 > }); agentService.ts ×9
2192 >
2193 > this._clientDispatchQueues.set(clientId, next.finally(() => {
2194 > if (this._clientDispatchQueues.get(clientId) === next) { agentService.ts ×10
2195 this._clientDispatchQueues.delete(clientId);
2196 }
2197 > })); agentService.ts ×9
2198 > }
2200 > private _dispatchActionNow(channel: string, sessionChannel: string, action: SessionAction | ChatAction | TerminalAction | ClientChangesetAction | ClientAnnotationsAction | IRootConfigChangedAction, clientId: string, clientSeq: number): void {
2201 > const origin = { clientId, clientSeq }; agentService.ts ×9
2202 > this._stateManager.dispatchClientAction(channel, action, origin);
2203 > if (action.type === ActionType.RootConfigChanged) {
2204 > this._configurationService.persistRootConfig(); agentService.ts ×1
2205 > }
2206 > this._sideEffects.handleAction(channel, action, clientId); agentService.ts ×9
2207 > }
2209 > private _needsAsyncRewrite(channel: string, action: SessionAction | ChatAction | TerminalAction | ClientChangesetAction | ClientAnnotationsAction | IRootConfigChangedAction): action is ChatTurnStartedAction | ChatPendingMessageSetAction {
2210 > if (action.type !== ActionType.ChatTurnStarted && action.type !== ActionType.ChatPendingMessageSet) { agentService.ts ×9
2211 > return false; agentService.ts ×1
2212 > }
2213 > const attachmentsRootStr = this._attachmentsRoot(channel).toString(); agentService.ts ×5
2214 > return !!action.message.attachments?.some(a => this._isRewritableAttachment(a, attachmentsRootStr)); agentService.ts ×9
2215 > }
2216 > private _isRewritableAttachment(attachment: MessageAttachment, attachmentsRootStr: string): boolean { agentService.ts ×122
2217 > if (attachment.type === MessageAttachmentKind.EmbeddedResource) { agentService.ts ×2
2218 > return true; agentService.ts ×2
2219 > }
2220 > if (attachment.type === MessageAttachmentKind.Resource) { agentService.ts ×1
2221 > // Don't try to fetch directories or already-rewritten attachments agentService.ts ×1
2222 > // (whose URIs already point under our session attachments folder).
2223 > if (attachment.displayKind === 'directory') {
2224 > return false; agentService.ts ×1
2225 > }
2226 > if (attachment.uri.startsWith(attachmentsRootStr)) { agentService.ts ×1
2227 > return false; agentService.ts ×1
2228 > }
2229 > return true; agentService.ts ×2
2230 > }
2231 > return false; agentService.ts ×4
2234 > private _attachmentsRoot(session: string): URI {
2235 > return joinPath(this._sessionDataService.getSessionDataDir(URI.parse(session)), SESSION_ATTACHMENTS_DIRNAME); agentService.ts ×5
2236 > }
2238 > /**
2239 > * Snapshot inline / client-resident attachment payloads onto disk
2240 > * under the session's data directory and rewrite the action to
2241 > * reference them via local `file:` URIs. Keeps potentially large
2242 > * blobs (e.g. pasted images) out of the in-memory state tree while
2243 > * letting the agent consume them via the standard {@link IFileService}
2244 > * surface — no special URI scheme or blob round-tripping needed.
2245 > *
2246 > * Failures are isolated per-attachment: if a rewrite cannot be
2247 > * performed (no client connection registered, `resourceRead` rejects,
2248 > * etc.) the original attachment is preserved so the agent still has a
2249 > * chance to make use of it.
2250 > */
2251 > private async _rewriteUserMessageAttachments<T extends ChatTurnStartedAction | ChatPendingMessageSetAction>(channel: string, action: T, clientId: string): Promise<T> {
2252 > const attachments = action.message.attachments; agentService.ts ×10
2253 > if (!attachments?.length) {
2254 return action;
2255 }
2256 > const attachmentsRoot = this._attachmentsRoot(channel); agentService.ts ×10
2257 > const attachmentsRootStr = attachmentsRoot.toString();
2258 > const rewritten = await Promise.all(attachments.map(a => this._rewriteSingleAttachment(a, attachmentsRoot, attachmentsRootStr, clientId)));
2259 > return {
2260 > ...action,
2261 > message: { ...action.message, attachments: rewritten },
2262 > };
2263 > }
2265 > private async _rewriteSingleAttachment(attachment: MessageAttachment, attachmentsRoot: URI, attachmentsRootStr: string, clientId: string): Promise<MessageAttachment> {
2266 > try { agentService.ts ×10
2267 > if (attachment.type === MessageAttachmentKind.EmbeddedResource) {
2268 > const bytes = decodeBase64(attachment.data).buffer; agentService.ts ×2
2269 > const basename = this._attachmentBasename(attachment.label, attachment.contentType);
2270 > return this._writeAndRewrite(attachment, bytes, basename, attachmentsRoot);
2271 > }
2272 > if (attachment.type === MessageAttachmentKind.Resource && this._isRewritableAttachment(attachment, attachmentsRootStr)) { agentService.ts ×10
2273 > const originalUri = URI.parse(attachment.uri); agentService.ts ×2
2274 > // If the attachment references a file that already exists on the agent
2275 > // host side, leave it untouched rather than snapshotting a client copy (#319314).
2276 > if (originalUri.scheme === Schemas.file && await this._fileExistsSafe(originalUri)) {
2277 > return attachment; agentService.ts ×3
2278 > }
2280 > const bytes = await this._readClientResource(originalUri, clientId);
2281 > const basename = this._attachmentBasename(attachment.label, getMediaMime(originalUri.path)); agentService.ts ×2
2282 > return this._writeAndRewrite(attachment, bytes, basename, attachmentsRoot);
2283 > }
2284 > } catch (err) { agentService.ts ×10
2285 > this._logService.warn(`[AgentService] Failed to rewrite attachment '${attachment.label}': ${toErrorMessage(err)}`); agentService.ts ×3
2286 > }
2287 > return attachment;
2290 > /**
2291 > * Like {@link IFileService.exists} but never throws (e.g. when no provider
2292 > * is registered for the URI scheme), returning `false` in that case.
2293 > */
2294 > private async _fileExistsSafe(uri: URI): Promise<boolean> {
2295 > try { agentService.ts ×3
2296 > return await this._fileService.exists(uri);
2297 > } catch {
2298 return false;
2299 }
2302 > /**
2303 > * Reads `originalUri` through the `vscode-agent-client` filesystem
2304 > * provider so it is fetched from the originating client. Falls back to
2305 > * a direct read against `originalUri` when no client filesystem
2306 > * authority is registered for `clientId` (e.g. unit tests, in-process
2307 > * agent host with a local URI).
2308 > */
2309 > private async _readClientResource(originalUri: URI, clientId: string): Promise<Uint8Array> {
2310 > const proxiedUri = clientId ? toAgentClientUri(originalUri, clientId) : originalUri; agentService.ts ×6
2311 > try {
2312 > const contents = await this._fileService.readFile(proxiedUri);
2313 return contents.value.buffer;
2314 > } catch (err) { agentService.ts ×6
2315 > if (proxiedUri !== originalUri) {
2316 > try {
2317 > const contents = await this._fileService.readFile(originalUri);
2318 > return contents.value.buffer; agentService.ts ×2
2319 > } catch { agentService.ts ×6
2320 > // ignore agentService.ts ×3
2321 > }
2323 > throw err; agentService.ts ×3
2324 > }
2327 > private async _writeAndRewrite(
2328 > original: MessageAttachment, agentService.ts ×3
2329 > bytes: Uint8Array,
2330 > basename: string,
2331 > attachmentsRoot: URI,
2332 > ): Promise<MessageResourceAttachment> {
2333 > const id = generateUuid();
2334 > const target = joinPath(attachmentsRoot, id, basename);
2335 > await this._fileService.writeFile(target, VSBuffer.wrap(bytes));
2336 > const rewritten: MessageResourceAttachment = {
2337 > type: MessageAttachmentKind.Resource,
2338 > uri: target.toString(),
2339 > label: original.label,
2340 > displayKind: original.displayKind,
2341 > range: original.range,
2342 > _meta: original._meta,
2343 > };
2344 > if (original.type === MessageAttachmentKind.Resource && original.selection) {
2345 > rewritten.selection = original.selection; agentService.ts ×1
2346 > }
2347 > return rewritten; agentService.ts ×3
2348 > }
2350 > /**
2351 > * Pick a sensible on-disk basename for the snapshotted attachment,
2352 > * preserving a usable extension where possible so the SDK and other
2353 > * downstream consumers can detect the right type from the path alone.
2354 > */
2355 > private _attachmentBasename(label: string, contentType: string | undefined): string {
2356 > const safeLabel = (label || 'attachment').replace(/[\\/:*?"<>|\u0000-\u001f]/g, '_'); agentService.ts ×3
2357 > if (resourcesExtname(URI.file(safeLabel))) {
2358 > return safeLabel;
2359 > }
2360 > const ext = contentType ? getExtensionForMimeType(contentType) : undefined;
2361 > return ext ? `${safeLabel}${ext}` : safeLabel;
2362 > }
2364 > async resourceList(uri: URI): Promise<ResourceListResult> {
2365 > let stat; agentService.ts ×3
2366 > try {
2367 > stat = await this._fileService.resolve(uri);
2368 > } catch {
2369 > throw new ProtocolError(AhpErrorCodes.NotFound, `Directory not found: ${uri.toString()}`); agentService.ts ×1
2370 > }
2372 > if (!stat.isDirectory) {
2373 > throw new ProtocolError(AhpErrorCodes.NotFound, `Not a directory: ${uri.toString()}`);
2374 > }
2375
2376 > const entries: DirectoryEntry[] = (stat.children ?? []).map(child => ({ agentService.ts ×3
2377 name: child.name,
2378 type: child.isDirectory ? 'directory' : 'file',
2379 > })); agentService.ts ×3
2380 > return { entries };
2381 > }
2383 > async restoreSession(session: URI): Promise<void> {
2384 > const sessionStr = session.toString(); agentService.ts ×17
2385 >
2386 > // Already in state manager - nothing to do.
2387 > if (this._stateManager.getSessionState(sessionStr)) {
2388 return;
2389 }
2391 > const inFlight = this._restoreSessionInFlight.get(sessionStr);
2392 > if (inFlight) {
2393 > return inFlight; agentService.ts ×1
2394 > }
2396 > const restore = this._doRestoreSession(session, sessionStr);
2397 > this._restoreSessionInFlight.set(sessionStr, restore);
2398 > try {
2399 > await restore;
2400 > } finally {
2401 > if (this._restoreSessionInFlight.get(sessionStr) === restore) {
2402 > this._restoreSessionInFlight.delete(sessionStr);
2403 > }
2404 > }
2405 > }
2407 > private async _doRestoreSession(session: URI, sessionStr: string): Promise<void> {
2408 > if (this._stateManager.getSessionState(sessionStr)) { agentService.ts ×17
2409 return;
2410 }
2412 > const agent = this._findProviderForSession(session);
2413 > if (!agent) {
2414 throw new ProtocolError(AHP_SESSION_NOT_FOUND, `No agent for session: ${sessionStr}`);
2415 }
2417 > const meta = await this._getSessionMetadataForRestore(agent, session);
2418 > if (!meta) {
2419 > throw new ProtocolError(AHP_SESSION_NOT_FOUND, `Session not found on backend: ${sessionStr}`); agentService.ts ×1
2420 > }
2422 > const defaultChatUri = URI.parse(buildDefaultChatUri(sessionStr));
2423 > let turns: readonly Turn[];
2424 > try {
2425 > turns = await this._getChatMessages(agent, defaultChatUri);
2426 > } catch (err) {
2427 > if (err instanceof ProtocolError) { agentService.ts ×2
2428 throw err;
2429 }
2430 > const message = err instanceof Error ? err.message : String(err); agentService.ts ×2
2431 > throw new ProtocolError(JSON_RPC_INTERNAL_ERROR, `Failed to restore session ${sessionStr}: ${message}`);
2432 > }
2434 > // Check for persisted metadata in the session database
2435 > let title = meta.summary ?? 'Session';
2436 > let isRead: boolean | undefined; agentService.ts ×17
2437 > let isArchived: boolean | undefined;
2438 > let persistedConfigValues: Record<string, string> | undefined;
2439 > let changes: ChangesSummary | undefined;
2440 > let gitMetadata: Record<string, string | undefined> | undefined;
2441 > let changesetMetadata: Record<string, string | undefined> | undefined;
2442 > let sessionMetadata: Record<string, unknown> | undefined;
2443 > const ref = this._sessionDataService.tryOpenDatabase?.(session);
2444 > if (ref) {
2445 > try { agentService.ts ×13
2446 > const db = await ref;
2447 > if (db) {
2448 > try { agentService.ts ×10
2449 > const m = await db.object.getMetadataObject({
2450 > customTitle: true,
2451 > isRead: true,
2452 > [AH_META_IS_ARCHIVED_DB_KEY]: true,
2453 > [AH_META_IS_DONE_DB_KEY]: true,
2454 > configValues: true,
2455 > [AH_META_WORKSPACELESS_DB_KEY]: true,
2456 > ...GIT_DB_METADATA_KEYS,
2457 > ...CHANGESET_DB_METADATA_KEYS,
2458 > });
2459 > if (m.customTitle) {
2460 > title = m.customTitle; agentService.ts ×1
2461 > }
2462 > if (m.isRead !== undefined) { agentService.ts ×10
2463 isRead = m.isRead === 'true';
2464 }
2465 > if (m[AH_META_IS_ARCHIVED_DB_KEY] !== undefined) { agentService.ts ×10
2466 isArchived = m[AH_META_IS_ARCHIVED_DB_KEY] === 'true';
2467 > } else if (m[AH_META_IS_DONE_DB_KEY] !== undefined) { agentService.ts ×10
2468 isArchived = m[AH_META_IS_DONE_DB_KEY] === 'true';
2469 }
2471 > changesetMetadata = m as Record<string, string | undefined>;
2472 > if (changesetMetadata[META_CHANGES_SUMMARY]) {
2473 try {
2474 changes = JSON.parse(changesetMetadata[META_CHANGES_SUMMARY]);
2475 } catch (err) {
2476 this._logService.warn(`[AgentService] Failed to parse changes summary for ${sessionStr}: ${toErrorMessage(err)}`);
2477 }
2478 }
2480 > gitMetadata = m as Record<string, string | undefined>;
2481 >
2482 > if (gitMetadata[META_GIT_STATE]) {
2483 try {
2484 const gitState = JSON.parse(gitMetadata[META_GIT_STATE]);
2485 sessionMetadata = { [SESSION_META_GIT_KEY]: gitState };
2486 } catch (err) {
2487 this._logService.warn(`[AgentService] Failed to parse Git state for ${sessionStr}: ${toErrorMessage(err)}`);
2488 }
2489 }
2491 > if (gitMetadata[META_GITHUB_STATE]) {
2492 try {
2493 const githubState = JSON.parse(gitMetadata[META_GITHUB_STATE]);
2494 sessionMetadata = {
2495 ...(sessionMetadata ? sessionMetadata : {}),
2496 [SESSION_META_GITHUB_KEY]: githubState
2497 };
2498 } catch (err) {
2499 this._logService.warn(`[AgentService] Failed to parse GitHub state for ${sessionStr}: ${toErrorMessage(err)}`);
2500 }
2501 }
2503 > if (m[AH_META_WORKSPACELESS_DB_KEY] !== undefined) {
2504 > sessionMetadata = withSessionWorkspaceless(sessionMetadata, m[AH_META_WORKSPACELESS_DB_KEY] === 'true'); agentService.ts ×1
2505 > }
2507 > if (m.configValues) {
2508 > try { agentService.ts ×2
2509 > persistedConfigValues = JSON.parse(m.configValues);
2510 > } catch (err) {
2511 > this._logService.warn(`[AgentService] Failed to parse persisted configValues for ${sessionStr}: ${toErrorMessage(err)}`); agentService.ts ×1
2512 > }
2514 > } finally { agentService.ts ×10
2515 > db.dispose();
2516 > }
2517 > }
2518 > } catch { agentService.ts ×13
2519 // Best-effort: fall back to agent-provided metadata
2520 }
2522 >
2523 > // Encode isRead/isArchived as status bitmask flags
2524 > let status: SessionStatus = SessionStatus.Idle;
2525 > if (isRead) {
2526 status |= SessionStatus.IsRead;
2527 }
2528 > if (isArchived) { agentService.ts ×13
2529 status |= SessionStatus.IsArchived;
2530 }
2532 > const summary: SessionSummary = {
2533 > resource: sessionStr,
2534 > provider: agent.id,
2535 > title,
2536 > status,
2537 > createdAt: new Date(meta.startTime).toISOString(),
2538 > modifiedAt: new Date(meta.modifiedTime).toISOString(),
2539 > ...(meta.project ? { project: { uri: meta.project.uri.toString(), displayName: meta.project.displayName } } : {}), agentService.ts ×17
2540 > changes: meta.changes ?? changes,
2541 > workingDirectories: meta.workingDirectory ? [meta.workingDirectory.toString()] : undefined,
2542 > _meta: (sessionMetadata || meta._meta) ? { ...(meta._meta ?? {}), ...(sessionMetadata ?? {}) } : undefined,
2543 > };
2544 >
2545 > const [defaultDraft, defaultChatTitle] = await Promise.all([
2546 > this._getChatDraft(session, defaultChatUri),
2547 > this._readPersistedChatTitle(session, defaultChatUri),
2548 > ]);
2549 > const mergedTurns = await this._interleaveLocalTurns(sessionStr, defaultChatUri.toString(), turns); agentService.ts ×13
2550 > this._stateManager.restoreSession(summary, mergedTurns, { draft: defaultDraft, defaultChatTitle });
2551 >
2552 > const promises: Promise<unknown>[] = [];
2553 > // Eagerly register subagent child sessions discovered in the event log
2554 > // so the client's per-subagent subscriptions resolve from in-memory
2555 > // state (hitting `restoreSubagent skipped existing`) instead of each
2556 > // re-fetching and re-reconstructing the full parent event log. The
2557 > // agent serves these from the same reconstruction it already produced
2558 > // for the parent turns above, so this adds no extra event-log reads.
2559 > promises.push((async () => {
2560 > if (agent.getSubagentSessions) {
2561 > try { agentService.ts ×4
2562 > const children = await agent.getSubagentSessions(session);
2563 > for (const child of children) {
2564 > this._registerRestoredSubagent(child, summary, sessionStr);
2565 > }
2566 > } catch (err) {
2567 this._logService.warn(`[AgentService] restoreSession failed to eagerly register subagents session=${sessionStr}`, err);
2568 }
2570 > })()); agentService.ts ×13
2571 >
2572 > // Restore any additional (non-default) peer chats the provider has
2573 > // persisted for this session, seeding each with its own history and
2574 > // persisted title so they reappear after a process restart.
2575 > promises.push(this._restorePeerChats(agent, session));
2576 >
2577 > // Register the static changeset URIs and reseed them from any
2578 > // persisted file lists in the batched metadata read. The catalogue
2579 > // itself is seeded on `state.changesets` synchronously by the
2580 > // `setSessionChangesets` call above. The coordinator drains any
2581 > // uncommitted refresh deferred by an earlier `addSubscriber` —
2582 > // `addSubscriber`'s 0→1 trigger may have fired for
2583 > // `<session>/changeset/uncommitted` before this restore ran (e.g.
2584 > // active-session autorun subscribing in parallel with the
2585 > // chat-view); now that `summary.workingDirectory` is populated,
2586 > // re-triggering the refresh dispatches to the compute path.
2587 > this._changesetCoordinator.onSessionRestored(sessionStr, changesetMetadata ?? {}); agentService.ts ×17
2588 >
2589 > // Restore persisted `_meta` (e.g. git state) onto the new session
2590 > // state. This dispatches a SessionMetaChanged action.
2591 > if (meta._meta) {
2592 this._stateManager.setSessionMeta(sessionStr, meta._meta);
2593 }
2595 > // Resolve the session config so clients (e.g. the running-session
2596 > // auto-approve picker) can render session-mutable properties for
2597 > // sessions that were not created in the current process lifetime.
2598 > // Overlay any values the user previously selected (persisted via
2599 > // `SessionConfigChanged`) on top of the provider's resolved defaults.
2600 > const [restoredConfig, restoredCustomizations] = await Promise.all([
2601 > this._resolveCreatedSessionConfig(agent, {
2602 > workingDirectory: meta.workingDirectory,
2603 > config: persistedConfigValues,
2604 > }),
2605 > agent.getSessionCustomizations
2606 > ? agent.getSessionCustomizations(session).catch(err => { agentService.ts ×3
2607 this._logService.error('[AgentService] restoreSession: failed to resolve session customizations', err);
2608 return undefined;
2610 > : Promise.resolve(undefined), agentService.ts ×1
2611 > ...promises agentService.ts ×17
2612 > ]);
2613 > if (restoredConfig) { agentService.ts ×13
2614 > this._stateManager.setSessionConfig(sessionStr, restoredConfig); agentService.ts ×1
2615 > }
2616 > // Seed restored session customizations into state so the very first agentService.ts ×13
2617 > // snapshot after selecting an existing session contains effective
2618 > // instructions/agents without waiting for a follow-up republish.
2619 > if (restoredCustomizations && restoredCustomizations.length > 0) { agentService.ts ×17
2620 > this._stateManager.setSessionCustomizations(sessionStr, restoredCustomizations); agentService.ts ×3
2621 > }
2623 > this._logService.info(`[AgentService] Restored session ${sessionStr} with ${turns.length} turns`);
2624 >
2625 > // Refresh the git state for the session.
2626 > void this._gitStateService.refreshSessionGitState(sessionStr, meta.workingDirectory);
2627 >
2628 > // Check for a GitHub pull request associated with the session's branch.
2629 > void this._gitStateService.attachSessionGitHubPullRequest(sessionStr);
2632 > /**
2633 > * Restores the additional (non-default) peer chats for a session.
2634 > *
2635 > * Enumeration is driven by the orchestrator's OWN persisted catalog (the
2636 > * {@link PEER_CHATS_METADATA_KEY} blob). For each catalog entry the agent's
2637 > * in-memory backing is re-attached via
2638 > * {@link IAgent.materializeChat} (handing back the opaque
2639 > * `providerData` blob) BEFORE its history is read, then the chat is
2640 > * re-registered in the state manager with its persisted title and draft so
2641 > * it reappears after a process restart. Best-effort: a chat whose history
2642 > * fails to load is restored with no turns rather than dropped.
2643 > *
2644 > * When the orchestrator catalog is absent ({@link _readPersistedPeerChatCatalog}
2645 > * returns `undefined`) the session predates orchestrator-owned persistence:
2646 > * a one-time migration ({@link _migrateLegacyPeerChats}) drains the agent's
2647 > * legacy `*.chats` enumeration into the catalog so it is never consulted
2648 > * again.
2649 > */
2650 > private async _restorePeerChats(agent: IAgent, session: URI): Promise<void> {
2651 > const persisted = await this._readPersistedPeerChatCatalog(session); agentService.ts ×13
2652 > if (persisted !== undefined) {
2653 > // The orchestrator owns the catalog: enumerate from it. agentService.ts ×4
2654 > await this._restorePeerChatsFromCatalog(agent, session, persisted);
2655 > return;
2656 > }
2657 > // No orchestrator catalog yet: one-time migration from legacy `*.chats`. agentService.ts ×3
2658 > await this._migrateLegacyPeerChats(agent, session);
2661 > /**
2662 > * One-time migration for sessions persisted before the orchestrator owned
2663 > * the peer-chat catalog: enumerate the agent's legacy `*.chats`
2664 > * ({@link IAgent.listLegacyChats}), restore them via the same path as the
2665 > * new catalog, then write the orchestrator {@link PEER_CHATS_METADATA_KEY}
2666 > * blob so subsequent restores read the new catalog and never consult the
2667 > * legacy read again. No-op when the agent has no legacy enumeration or none
2668 > * is persisted.
2669 > */
2670 > private async _migrateLegacyPeerChats(agent: IAgent, session: URI): Promise<void> {
2671 > const legacy = await agent.listLegacyChats?.(session); agentService.ts ×3
2672 > if (!legacy || legacy.length === 0) {
2673 > // Write an empty catalog sentinel so `_readPersistedPeerChatCatalog` agentService.ts ×1
2674 > // returns `[]` on subsequent restores and this migration never re-runs.
2675 > await this._enqueuePeerChatCatalogWrite(session, () => []);
2676 > return;
2677 > }
2678 > const entries: IPersistedPeerChat[] = legacy.map(chat => ({ agentService.ts ×1
2679 > uri: chat.uri.toString(),
2680 > ...(chat.providerData !== undefined ? { providerData: chat.providerData } : {}),
2681 > }));
2682 > await this._restorePeerChatsFromCatalog(agent, session, entries);
2683 > // Single atomic write: the key is absent before and complete after, so no
2684 > // partial catalog can survive a crash mid-migration (which would make
2685 > // `_readPersistedPeerChatCatalog` return a proper subset and permanently
2686 > // skip re-migration). The callback takes no parameter so `entries` here is
2687 > // the full migrated set, not the (absent) current catalog.
2688 > await this._enqueuePeerChatCatalogWrite(session, () => [...entries]);
2691 > /**
2692 > * Restores a set of peer chats from an enumerated catalog. Loads each
2693 > * chat's history in parallel (after re-attaching its backing) but restores
2694 > * them in catalog order, so the catalog never reorders by which chat's
2695 > * history/title happened to resolve first.
2696 > */
2697 > private async _restorePeerChatsFromCatalog(agent: IAgent, session: URI, entries: readonly IPersistedPeerChat[]): Promise<void> {
2698 > const restored = await Promise.all(entries.map(async (entry) => { agentService.ts ×3
2699 > let chatUri: URI; agentService.ts ×6
2700 > try {
2701 > chatUri = URI.parse(entry.uri);
2702 > } catch (err) {
2703 this._logService.warn(`[AgentService] Skipping malformed persisted peer chat URI '${entry.uri}': ${toErrorMessage(err)}`);
2704 return undefined;
2705 }
2706 > // Re-attach the agent's in-memory backing for the chat BEFORE agentService.ts ×6
2707 > // reading its history, so `getSessionMessages` can resolve the
2708 > // chat. Best-effort: a corrupt/unknown blob must not abort
2709 > // the restore — the chat is then surfaced with history but no live
2710 > // backing.
2711 > if (agent.materializeChat) {
2712 > try { agentService.ts ×2
2713 > await agent.materializeChat(chatUri, entry.providerData);
2714 > } catch (err) {
2715 this._logService.warn(`[AgentService] Failed to materialize peer chat ${entry.uri}: ${toErrorMessage(err)}`);
2716 }
2718 > let turns: readonly Turn[] = []; agentService.ts ×6
2719 > try {
2720 > turns = await this._getChatMessages(agent, chatUri);
2721 > } catch (err) {
2722 this._logService.warn(`[AgentService] Failed to load history for peer chat ${chatUri.toString()}: ${toErrorMessage(err)}`);
2723 }
2724 > const [title, draft] = await Promise.all([ agentService.ts ×6
2725 > this._readPersistedChatTitle(session, chatUri),
2726 > this._getChatDraft(session, chatUri),
2727 > ]);
2728 > const mergedTurns = await this._interleaveLocalTurns(session.toString(), chatUri.toString(), turns);
2729 > return { chatUri, title, turns: mergedTurns, draft, providerData: entry.providerData, origin: entry.origin };
2730 > })); agentService.ts ×3
2731 > for (const item of restored) {
2732 > if (!item) { agentService.ts ×6
2733 continue;
2734 }
2735 > const { chatUri, title, turns, draft, providerData, origin } = item; agentService.ts ×6
2736 > this._stateManager.restoreChat(session.toString(), chatUri.toString(), {
2737 > title,
2738 > turns: [...turns],
2739 > draft,
2740 > ...(providerData !== undefined ? { providerData } : {}),
2741 > ...(origin !== undefined ? { origin } : {}),
2742 > });
2743 > }
2746 > /**
2747 > * Re-persists a peer chat's opaque `providerData` blob when the agent
2748 > * reports it changed (e.g. per-chat model switch or fork remap).
2749 > */
2750 > private _onChatDataChanged(e: IAgentChatDataChange): void {
2751 > const sessionStr = parseDefaultChatUri(e.chat); agentService.ts ×2
2752 > if (sessionStr === undefined) {
2753 this._logService.warn(`[AgentService] onDidChangeChatData for malformed chat URI: ${e.chat.toString()}`);
2754 return;
2755 }
2756 > void this._persistPeerChat(URI.parse(sessionStr), e.chat, e.providerData); agentService.ts ×2
2757 > }
2759 > /**
2760 > * Deterministic membership sequencer for agent-spawned chats,
2761 > * driven off {@link IAgent.onDidSessionProgress}: a `subagent_started` adds
2762 > * the subagent chat to the catalog via the same spawn-channel handler
2763 > * ({@link _onChatSpawned}) used by {@link IAgent.onDidSpawnChat}.
2764 > * A completed subagent chat stays live and subscribable, so completion is
2765 > * not sequenced here; subagent chats are removed only on session teardown.
2766 > * Registered before {@link AgentSideEffects} so the subagent chat exists
2767 > * before its turn starts; addChat is idempotent so overlapping with the
2768 > * agent's own spawn bridge is safe.
2769 > */
2770 > private _sequenceSpawnedChat(signal: AgentSignal): void {
2771 > const spawn = SubagentChatSignal.toSpawnEvent(signal); agentService.ts ×2
2772 > if (spawn) {
2773 > this._onChatSpawned(spawn); agentService.ts ×1
2774 > }
2777 > /** Marks a subagent chat as pending once its confirmed tool call reaches (or is about to reach) `Running`. */
2778 > private _trackPendingSubagentChatFromEnvelope(envelope: ActionEnvelope): void {
2779 > const { channel, action } = envelope; agentService.ts ×14
2780 > if (action.type === ActionType.ChatToolCallStart || action.type === ActionType.ChatToolCallDelta || action.type === ActionType.ChatToolCallReady) {
2781 > const key = `${channel}:${action.toolCallId}`; agentService.ts ×2
2782 > // Providers stamp `toolKind`/`subagentChatUri` on whichever action
2783 > // first reveals it (Copilot at Start, Claude at Ready) — later
2784 > // actions for the same tool call don't repeat it, so fall back to
2785 > // what we already recorded for this tool call.
2786 > const subagentChatUri = readToolCallMeta(action).subagentChatUri ?? this._pendingSubagentToolCalls.get(key);
2787 > if (subagentChatUri === undefined) {
2788 > return; agentService.ts ×1
2789 > }
2790 > if (action.type === ActionType.ChatToolCallReady && action.confirmed) { agentService.ts ×2
2791 > // Goes straight to Running — arm the bounded wait now. agentService.ts ×1
2792 > this._pendingSubagentToolCalls.delete(key);
2793 > this._armPendingSubagentChat(subagentChatUri);
2794 > return;
2795 > }
2796 > // Still streaming or awaiting confirmation. Remember the URI so a agentService.ts ×6
2797 > // later ChatToolCallConfirmed can arm the wait once (if ever)
2798 > // confirmed, without timing out while the user is still deciding.
2799 > this._pendingSubagentToolCalls.set(key, subagentChatUri);
2800 > return;
2801 > }
2802 > if (action.type === ActionType.ChatToolCallConfirmed) { agentService.ts ×14
2803 > const key = `${channel}:${action.toolCallId}`; agentService.ts ×3
2804 > const subagentChatUri = this._pendingSubagentToolCalls.get(key);
2805 > if (subagentChatUri === undefined) {
2806 return;
2807 }
2808 > this._pendingSubagentToolCalls.delete(key); agentService.ts ×3
2809 > if (action.approved) {
2810 > this._armPendingSubagentChat(subagentChatUri); agentService.ts ×1
2811 > }
2812 > // Denied: the subagent will never spawn; nothing to resolve since agentService.ts ×3
2813 > // the wait was never armed while awaiting confirmation.
2814 > return;
2815 > }
2816 > if (action.type === ActionType.ChatToolCallComplete) { agentService.ts ×14
2817 // Defensive cleanup: a tool call can complete without ever being
2818 // confirmed (e.g. cancelled by other means) while still tracked.
2819 this._pendingSubagentToolCalls.delete(`${channel}:${action.toolCallId}`);
2820 }
2823 > private _armPendingSubagentChat(subagentChatUri: string): void {
2824 > if (this._pendingSubagentChats.has(subagentChatUri) || this._stateManager.getSnapshot(subagentChatUri)) { agentService.ts ×4
2825 return;
2826 }
2827 > const deferred = new DeferredPromise<void>(); agentService.ts ×4
2828 > this._pendingSubagentChats.set(subagentChatUri, deferred);
2829 > this._pendingSubagentChatTimeouts.set(subagentChatUri, disposableTimeout(() => {
2830 > this._pendingSubagentChats.delete(subagentChatUri); agentService.ts ×1
2831 > this._pendingSubagentChatTimeouts.deleteAndDispose(subagentChatUri);
2832 > deferred.complete();
2833 > }, SUBAGENT_CHAT_PENDING_TIMEOUT_MS)); agentService.ts ×4
2834 > }
2836 > private _resolvePendingSubagentChat(resource: string): void {
2837 > const deferred = this._pendingSubagentChats.get(resource); agentService.ts ×4
2838 > if (!deferred) {
2839 > return; agentService.ts ×1
2840 > }
2841 > this._pendingSubagentChats.delete(resource); agentService.ts ×1
2842 > this._pendingSubagentChatTimeouts.deleteAndDispose(resource);
2843 > deferred.complete();
2846 > /**
2847 > * Routes an agent-spawned chat (e.g. a sub-agent delegated by a tool
2848 > * call) straight into the chat catalog via {@link IAgentHostStateManager.addChat},
2849 > * so harness-spawned chats and user-driven chats share ONE membership path.
2850 > * The {@link IAgentSpawnChatEvent.parent} spawn edge is recorded as
2851 > * the chat's {@link ChatOriginKind.Tool} origin. Spawned chats are
2852 > * not written to the orchestrator's persisted peer-chat catalog — they are
2853 > * transient children re-derived from the parent's event log on restore.
2854 > */
2855 > private _onChatSpawned(e: IAgentSpawnChatEvent): void {
2856 > this._stateManager.addChat(e.session.toString(), e.chat.toString(), { agentService.ts ×4
2857 > ...(e.title !== undefined ? { title: e.title } : {}),
2858 > ...(e.parent ? {
2859 > origin: { kind: ChatOriginKind.Tool, chat: e.parent.chat.toString(), toolCallId: e.parent.toolCallId }, agentService.ts ×1
2860 > // Subagent worker chats are observable but not directly steerable:
2861 > // the user watches them and steers the lead chat. Mark read-only so
2862 > // the UI hides the composer and shows a lock (the agent-team pattern).
2863 > interactivity: ChatInteractivity.ReadOnly,
2864 > } : {}), agentService.ts ×4
2865 > });
2866 > this._resolvePendingSubagentChat(e.chat.toString());
2867 > }
2869 > /**
2870 > * Reads the orchestrator's persisted peer-chat catalog for a session.
2871 > * Returns `undefined` when the session has no catalog yet (a legacy session
2872 > * predating orchestrator-owned persistence, or a corrupt blob); the caller
2873 > * then performs a one-time migration from the agent's legacy `*.chats`
2874 > * enumeration (see {@link _restorePeerChats} / {@link _migrateLegacyPeerChats}).
2875 > * An empty array means the session is known to have no peer chats, so
2876 > * migration is skipped.
2877 > */
2878 > private async _readPersistedPeerChatCatalog(session: URI): Promise<IPersistedPeerChat[] | undefined> {
2879 > const ref = await this._sessionDataService.tryOpenDatabase?.(session); agentService.ts ×13
2880 > if (!ref) {
2881 > return undefined; agentService.ts ×3
2882 > }
2883 > try { agentService.ts ×5
2884 > const raw = await ref.object.getMetadata(PEER_CHATS_METADATA_KEY);
2885 > if (raw === undefined) {
2886 > return undefined; agentService.ts ×1
2887 > }
2888 > const parsed = JSON.parse(raw); agentService.ts ×4
2889 > if (!Array.isArray(parsed)) {
2890 this._logService.warn(`[AgentService] Ignoring malformed peer-chat catalog for ${session.toString()}`);
2891 return undefined;
2892 }
2893 > return parsed agentService.ts ×4
2894 > .filter((entry): entry is IPersistedPeerChat => typeof entry?.uri === 'string')
2895 > .map(entry => ({
2896 > uri: entry.uri, agentService.ts ×1
2897 > ...(typeof entry.providerData === 'string' ? { providerData: entry.providerData } : {}),
2898 > ...(entry.origin !== undefined ? { origin: entry.origin } : {}),
2899 > })); agentService.ts ×4
2900 > } catch (err) {
2901 this._logService.warn(`[AgentService] Failed to read peer-chat catalog for ${session.toString()}: ${toErrorMessage(err)}`);
2902 return undefined;
2903 > } finally { agentService.ts ×5
2904 > ref.dispose();
2905 > }
2908 > /**
2909 > * Marks a peer chat's backing SDK session (in that session's own DB) so
2910 > * {@link listSessions} filters it out of the top-level session list. The
2911 > * marker is persisted, so it survives a host restart. Best-effort: a failure
2912 > * only means the backing session may transiently reappear in the list.
2913 > */
2914 > private _markPeerChatBacking(backingSession: URI, chat: URI): void {
2915 > let ref; agentService.ts ×5
2916 > try {
2917 > ref = this._sessionDataService.openDatabase(backingSession);
2918 > } catch (err) {
2919 this._logService.warn(`[AgentService] Failed to open backing session database to mark peer-chat backing for ${backingSession.toString()}: ${toErrorMessage(err)}`);
2920 return;
2921 }
2922 > ref.object.setMetadata(PEER_CHAT_BACKING_METADATA_KEY, chat.toString()).catch(err => { agentService.ts ×5
2923 this._logService.warn(`[AgentService] Failed to mark peer-chat backing for ${backingSession.toString()}: ${toErrorMessage(err)}`);
2924 > }).finally(() => { agentService.ts ×5
2925 > ref.dispose();
2926 > });
2927 > }
2929 > /**
2930 > * Inserts or updates a single peer chat in the orchestrator's persisted
2931 > * catalog, recording its opaque `providerData` verbatim (or clearing it when
2932 > * `undefined`). When `origin` is supplied it is stored as the chat's
2933 > * provenance; when omitted (e.g. a provider-driven `providerData` refresh via
2934 > * {@link _onChatDataChanged}) any previously persisted origin is preserved so
2935 > * a data refresh never drops a side chat's source boundary. Serialized per
2936 > * session via {@link _enqueuePeerChatCatalogWrite}.
2937 > */
2938 > private _persistPeerChat(session: URI, chat: URI, providerData: string | undefined, origin?: ChatOrigin): Promise<void> {
2939 > const chatUri = chat.toString(); agentService.ts ×4
2940 > return this._enqueuePeerChatCatalogWrite(session, entries => {
2941 > const existing = entries.find(entry => entry.uri === chatUri); agentService.ts ×1
2942 > const effectiveOrigin = origin ?? existing?.origin;
2943 > const next = entries.filter(entry => entry.uri !== chatUri);
2944 > next.push({
2945 > uri: chatUri,
2946 > ...(providerData !== undefined ? { providerData } : {}),
2947 > ...(effectiveOrigin !== undefined ? { origin: effectiveOrigin } : {}),
2948 > });
2949 > return next;
2950 > }); agentService.ts ×4
2951 > }
2953 > /**
2954 > * Removes a peer chat from the orchestrator's persisted catalog. Serialized
2955 > * per session via {@link _enqueuePeerChatCatalogWrite}.
2956 > */
2957 > private _removePersistedPeerChat(session: URI, chat: URI): Promise<void> {
2958 > const chatUri = chat.toString(); agentService.ts ×3
2959 > return this._enqueuePeerChatCatalogWrite(session, entries => entries.filter(entry => entry.uri !== chatUri));
2960 > }
2962 > /**
2963 > * Chains a read-modify-write of a session's persisted peer-chat catalog
2964 > * behind any in-flight write for the same session, so concurrent
2965 > * create/dispose/data-change updates can't clobber each other.
2966 > */
2967 > private _enqueuePeerChatCatalogWrite(session: URI, mutate: (entries: IPersistedPeerChat[]) => IPersistedPeerChat[]): Promise<void> {
2968 > const key = session.toString(); agentService.ts ×5
2969 > const previous = this._peerChatCatalogWrites.get(key) ?? Promise.resolve();
2970 > const next = previous
2971 > .catch(() => { /* a failed prior write must not block later ones */ })
2972 > .then(() => this._applyPeerChatCatalogWrite(session, mutate));
2973 > this._peerChatCatalogWrites.set(key, next.finally(() => {
2974 > if (this._peerChatCatalogWrites.get(key) === next) {
2975 this._peerChatCatalogWrites.delete(key);
2976 }
2977 > })); agentService.ts ×5
2978 > return next;
2979 > }
2981 > private async _applyPeerChatCatalogWrite(session: URI, mutate: (entries: IPersistedPeerChat[]) => IPersistedPeerChat[]): Promise<void> {
2982 > const ref = await this._sessionDataService.tryOpenDatabase?.(session); agentService.ts ×5
2983 > if (!ref) {
2984 > return; agentService.ts ×1
2985 > }
2986 > try { agentService.ts ×3
2987 > let current: IPersistedPeerChat[] = [];
2988 > try {
2989 > const raw = await ref.object.getMetadata(PEER_CHATS_METADATA_KEY);
2990 > if (raw !== undefined) {
2991 > const parsed = JSON.parse(raw); agentService.ts ×2
2992 > if (Array.isArray(parsed)) {
2993 > current = parsed
2994 > .filter((entry): entry is IPersistedPeerChat => typeof entry?.uri === 'string')
2995 > .map(entry => ({
2996 > uri: entry.uri, agentService.ts ×1
2997 > ...(typeof entry.providerData === 'string' ? { providerData: entry.providerData } : {}),
2998 > ...(entry.origin !== undefined ? { origin: entry.origin } : {}),
2999 > })); agentService.ts ×2
3000 > }
3001 > }
3002 > } catch (err) { agentService.ts ×5
3003 this._logService.warn(`[AgentService] Replacing malformed peer-chat catalog for ${session.toString()}: ${toErrorMessage(err)}`);
3004 }
3005 > const updated = mutate(current); agentService.ts ×3
3006 > await ref.object.setMetadata(PEER_CHATS_METADATA_KEY, JSON.stringify(updated));
3007 > } catch (err) {
3008 > this._logService.warn(`[AgentService] Failed to persist peer-chat catalog for ${session.toString()}: ${toErrorMessage(err)}`); agentService.ts ×1
3009 > } finally { agentService.ts ×3
3010 > ref.dispose();
3011 > }
3014 > /** Reads a chat's persisted custom title (default or peer chat), if any. */
3015 > private async _readPersistedChatTitle(session: URI, chatUri: URI): Promise<string | undefined> {
3016 > const ref = await this._sessionDataService.tryOpenDatabase?.(session); agentService.ts ×13
3017 > if (!ref) {
3018 > return undefined; agentService.ts ×3
3019 > }
3020 > try { agentService.ts ×5
3021 > return (await ref.object.getMetadata(`customChatTitle:${chatUri.toString()}`)) ?? undefined;
3022 > } catch { agentService.ts ×13
3023 return undefined;
3024 > } finally { agentService.ts ×5
3025 > ref.dispose();
3026 > }
3029 > private async _getChatDraft(session: URI, chatUri: URI): Promise<Message | undefined> {
3030 > const ref = await this._sessionDataService.tryOpenDatabase(session); agentService.ts ×13
3031 > if (!ref) {
3032 > return undefined; agentService.ts ×3
3033 > }
3034 > try { agentService.ts ×5
3035 > return await ref.object.getChatDraft(chatUri);
3036 > } finally {
3037 > ref.dispose();
3038 > }
3041 > private async _getSessionMetadataForRestore(agent: IAgent, session: URI): Promise<IAgentSessionMetadata | undefined> {
3042 > const sessionStr = session.toString(); agentService.ts ×17
3043 > if (agent.getSessionMetadata) {
3044 > try {
3045 > return await this._withWorktreeProject(session, await agent.getSessionMetadata(session));
3046 > } catch (err) {
3047 > if (err instanceof ProtocolError) { agentService.ts ×5
3048 throw err;
3049 }
3050 > try { agentService.ts ×5
3051 > return await this._withWorktreeProject(session, await this._getSessionMetadataFromCatalog(agent, session));
3052 > } catch (fallbackErr) {
3053 if (fallbackErr instanceof ProtocolError) {
3054 const message = err instanceof Error ? err.message : String(err);
3055 throw new ProtocolError(fallbackErr.code, `Failed to get session metadata for ${sessionStr}: ${message}; ${fallbackErr.message}`, fallbackErr.data);
3056 }
3057 throw fallbackErr;
3058 }
3061
3062 // Older providers only expose catalog enumeration. Keep the fallback so
3063 // restore remains compatible, but providers with a direct lookup avoid
3064 // blocking session open on a full catalog refresh.
3065 return this._withWorktreeProject(session, await this._getSessionMetadataFromCatalog(agent, session));
3068 > /**
3069 > * Merges the repository project for a worktree-isolated session onto its
3070 > * restored metadata so the session groups under the repository (not the
3071 > * `<repo>.worktrees/<name>` directory) in the sessions UI. No-op for folder
3072 > * sessions and for `undefined` metadata. Host-owned so agents stay unaware.
3073 > */
3074 > private async _withWorktreeProject(session: URI, meta: IAgentSessionMetadata | undefined): Promise<IAgentSessionMetadata | undefined> {
3075 > if (!meta || !this._worktree) { agentService.ts ×17
3076 > return meta;
3077 > }
3078 const project = await this._worktree.resolveWorktreeProject(session);
3079 > return project ? { ...meta, project } : meta; agentService.ts ×17
3080 > }
3082 > private async _getSessionMetadataFromCatalog(agent: IAgent, session: URI): Promise<IAgentSessionMetadata | undefined> {
3083 > const sessionStr = session.toString(); agentService.ts ×5
3084 > let allSessions;
3085 > try {
3086 > allSessions = await agent.listSessions();
3087 > } catch (err) {
3088 if (err instanceof ProtocolError) {
3089 throw err;
3090 }
3091 const message = err instanceof Error ? err.message : String(err);
3092 throw new ProtocolError(JSON_RPC_INTERNAL_ERROR, `Failed to list sessions for ${sessionStr}: ${message}`);
3093 }
3094 > return allSessions.find(s => s.session.toString() === sessionStr); agentService.ts ×5
3095 > }
3097 > async resourceRead(uri: URI): Promise<ResourceReadResult> {
3098 > // Handle session-db: URIs that reference file-edit content stored agentService.ts ×5
3099 > // in a per-session SQLite database.
3100 > const dbFields = parseSessionDbUri(uri.toString());
3101 > if (dbFields) {
3102 return this._fetchSessionDbContent(dbFields);
3103 }
3105 > // Handle git-blob: URIs that reference file content at a specific
3106 > // git commit (the merge-base used as diff baseline). The URI
3107 > // encodes the session it belongs to so we can find the right
3108 > // working directory to run `git show` from.
3109 > const blobFields = parseGitBlobUri(uri.toString());
3110 > if (blobFields) {
3111 return this._fetchGitBlobContent(blobFields);
3112 }
3114 > try {
3115 > const content = await this._fileService.readFile(uri);
3116 return {
3117 data: content.value.toString(),
3118 encoding: ContentEncoding.Utf8,
3119 contentType: 'text/plain',
3120 };
3121 > } catch (e) { agentService.ts ×5
3122 > const error = e instanceof Error ? e : new Error(String(e));
3123 > const result = toFileOperationResult(error);
3124 > if (result === FileOperationResult.FILE_NOT_FOUND) {
3125 > throw new ProtocolError(AhpErrorCodes.NotFound, `Content not found: ${uri.toString()}`); agentService.ts ×1
3126 > }
3127 > if (result === FileOperationResult.FILE_PERMISSION_DENIED) { agentService.ts ×2
3128 throw new ProtocolError(AhpErrorCodes.PermissionDenied, `Permission denied: ${uri.toString()}`);
3129 }
3130 > throw new ProtocolError(JSON_RPC_INTERNAL_ERROR, `Failed to read content: ${uri.toString()}: ${toErrorMessage(error)}`); agentService.ts ×2
3131 > }
3134 > async resourceWrite(params: ResourceWriteParams): Promise<ResourceWriteResult> {
3135 const fileUri = typeof params.uri === 'string' ? URI.parse(params.uri) : URI.revive(params.uri);
3136 try {
3137 const parent = await this._fileService.stat(resourcesDirname(fileUri));
3138 if (!parent.isDirectory) {
3139 throw new ProtocolError(AhpErrorCodes.NotFound, `Parent directory not found: ${fileUri.toString()}`);
3140 }
3141 } catch (e) {
3142 if (e instanceof ProtocolError) {
3143 throw e;
3144 }
3145 const result = toFileOperationResult(e as Error);
3146 if (result === FileOperationResult.FILE_PERMISSION_DENIED) {
3147 throw new ProtocolError(AhpErrorCodes.PermissionDenied, `Permission denied: ${fileUri.toString()}`);
3148 }
3149 throw new ProtocolError(AhpErrorCodes.NotFound, `Parent directory not found: ${fileUri.toString()}`);
3150 }
3151 let content: VSBuffer;
3152 if (params.encoding === ContentEncoding.Base64) {
3153 content = decodeBase64(params.data);
3154 } else {
3155 content = VSBuffer.fromString(params.data);
3156 }
3157 const mode = params.mode ?? ResourceWriteMode.Truncate;
3158 const position = params.position ?? 0;
3159 try {
3160 await this._resourceWriteQueue.queueFor(fileUri, async () => {
3161 if (params.ifMatch !== undefined || mode !== ResourceWriteMode.Truncate || position !== 0) {
3162 await this._resourceWriteWithMode(fileUri, content, mode, position, params);
3163 } else if (params.createOnly) {
3164 await this._createFileExclusive(fileUri, content);
3165 } else {
3166 await this._fileService.writeFile(fileUri, content);
3167 }
3168 }, extUriBiasedIgnorePathCase);
3169 return {};
3170 } catch (e) {
3171 if (e instanceof ProtocolError) {
3172 throw e;
3173 }
3174 const result = toFileOperationResult(e as Error);
3175 if (params.createOnly && (result === FileOperationResult.FILE_MODIFIED_SINCE || result === FileOperationResult.FILE_MOVE_CONFLICT)) {
3176 throw new ProtocolError(AhpErrorCodes.AlreadyExists, `File already exists: ${fileUri.toString()}`);
3177 }
3178 if (result === FileOperationResult.FILE_MODIFIED_SINCE) {
3179 const message = params.ifMatch !== undefined
3180 ? `ifMatch precondition failed for: ${fileUri.toString()}`
3181 : `File changed while writing: ${fileUri.toString()}`;
3182 throw new ProtocolError(AhpErrorCodes.Conflict, message);
3183 }
3184 if (result === FileOperationResult.FILE_MOVE_CONFLICT) {
3185 throw new ProtocolError(AhpErrorCodes.AlreadyExists, `File already exists: ${fileUri.toString()}`);
3186 }
3187 if (result === FileOperationResult.FILE_PERMISSION_DENIED) {
3188 throw new ProtocolError(AhpErrorCodes.PermissionDenied, `Permission denied: ${fileUri.toString()}`);
3189 }
3190 throw new ProtocolError(AhpErrorCodes.NotFound, `Failed to write file: ${fileUri.toString()}`);
3191 }
3192 }
3194 > private async _createFileExclusive(fileUri: URI, content: VSBuffer): Promise<void> {
3195 if (fileUri.scheme !== Schemas.file) {
3196 await this._fileService.createFile(fileUri, content, { overwrite: false });
3197 return;
3198 }
3199
3200 let handle: FileHandle;
3201 try {
3202 handle = await open(fileUri.fsPath, 'wx');
3203 } catch (error) {
3204 if (isErrorWithCode(error, 'EEXIST')) {
3205 throw new ProtocolError(AhpErrorCodes.AlreadyExists, `File already exists: ${fileUri.toString()}`);
3206 }
3207 throw error;
3208 }
3209
3210 let failure: unknown;
3211 try {
3212 await handle.writeFile(content.buffer);
3213 } catch (error) {
3214 failure = error;
3215 }
3216 try {
3217 await handle.close();
3218 } catch (error) {
3219 failure = failure ? new AggregateError([failure, error]) : error;
3220 }
3221 if (failure) {
3222 try {
3223 await unlink(fileUri.fsPath);
3224 } catch (cleanupError) {
3225 throw new AggregateError([failure, cleanupError], `Failed to create and clean up file: ${fileUri.toString()}`);
3226 }
3227 throw failure;
3228 }
3229 }
3231 > /**
3232 > * Slow-path for {@link resourceWrite} when the caller requested a
3233 > * non-default {@link ResourceWriteMode}, supplied a `position`, or
3234 > * provided an `ifMatch` etag precondition. Reads the current file
3235 > * contents (when needed) and produces a single `writeFile` call that
3236 > * realises the requested splice. A missing file is treated as
3237 > * empty for `append` and `insert` (so the operation behaves like a
3238 > * create); for `truncate` it falls through to a normal write.
3239 > */
3240 > private async _resourceWriteWithMode(
3241 fileUri: URI,
3242 data: VSBuffer,
3243 mode: ResourceWriteMode,
3244 position: number,
3245 params: ResourceWriteParams,
3246 ): Promise<void> {
3247 let existing: VSBuffer | undefined;
3248 let currentEtag: string | undefined;
3249 let currentMtime: number | undefined;
3250 try {
3251 const file = await this._fileService.readFile(fileUri);
3252 existing = file.value;
3253 currentEtag = file.etag;
3254 currentMtime = file.mtime;
3255 } catch (e) {
3256 if (toFileOperationResult(e as Error) !== FileOperationResult.FILE_NOT_FOUND) {
3257 throw e;
3258 }
3259 }
3260
3261 if (params.createOnly && existing !== undefined) {
3262 throw new ProtocolError(AhpErrorCodes.AlreadyExists, `File already exists: ${fileUri.toString()}`);
3263 }
3264
3265 if (params.ifMatch !== undefined) {
3266 // Missing file with an ifMatch is always a conflict (the caller
3267 // believed they had the etag for an existing file).
3268 if (existing === undefined || currentEtag !== params.ifMatch) {
3269 throw new ProtocolError(AhpErrorCodes.Conflict, `ifMatch precondition failed for: ${fileUri.toString()}`);
3270 }
3271 }
3272
3273 const base = existing ?? VSBuffer.alloc(0);
3274 let next: VSBuffer;
3275 switch (mode) {
3276 case ResourceWriteMode.Append: {
3277 const eof = base.byteLength;
3278 const splitAt = Math.max(0, eof - position);
3279 next = VSBuffer.concat([base.slice(0, splitAt), data, base.slice(splitAt, eof)]);
3280 break;
3281 }
3282 case ResourceWriteMode.Insert: {
3283 const splitAt = Math.min(position, base.byteLength);
3284 next = VSBuffer.concat([base.slice(0, splitAt), data, base.slice(splitAt, base.byteLength)]);
3285 break;
3286 }
3287 case ResourceWriteMode.Truncate:
3288 default: {
3289 const splitAt = Math.min(position, base.byteLength);
3290 next = VSBuffer.concat([base.slice(0, splitAt), data]);
3291 break;
3292 }
3293 }
3294 if (params.createOnly) {
3295 await this._createFileExclusive(fileUri, next);
3296 } else {
3297 await this._fileService.writeFile(fileUri, next, { etag: currentEtag, mtime: currentMtime });
3298 }
3299 }
3301 > async resourceCopy(params: ResourceCopyParams): Promise<ResourceCopyResult> {
3302 const source = URI.parse(params.source);
3303 const destination = URI.parse(params.destination);
3304 try {
3305 await this._fileService.copy(source, destination, !params.failIfExists);
3306 return {};
3307 } catch (e) {
3308 const result = toFileOperationResult(e as Error);
3309 if (result === FileOperationResult.FILE_MOVE_CONFLICT) {
3310 throw new ProtocolError(AhpErrorCodes.AlreadyExists, `Destination already exists: ${destination.toString()}`);
3311 }
3312 if (result === FileOperationResult.FILE_PERMISSION_DENIED) {
3313 throw new ProtocolError(AhpErrorCodes.PermissionDenied, `Permission denied: ${source.toString()}`);
3314 }
3315 throw new ProtocolError(AhpErrorCodes.NotFound, `Source not found: ${source.toString()}`);
3316 }
3317 }
3319 > async resourceDelete(params: ResourceDeleteParams): Promise<ResourceDeleteResult> {
3320 const fileUri = URI.parse(params.uri);
3321 try {
3322 await this._fileService.del(fileUri, { recursive: params.recursive });
3323 return {};
3324 } catch (e) {
3325 if (toFileOperationResult(e as Error) === FileOperationResult.FILE_PERMISSION_DENIED) {
3326 throw new ProtocolError(AhpErrorCodes.PermissionDenied, `Permission denied: ${fileUri.toString()}`);
3327 }
3328 throw new ProtocolError(AhpErrorCodes.NotFound, `Resource not found: ${fileUri.toString()}`);
3329 }
3330 }
3332 > async resourceMove(params: ResourceMoveParams): Promise<ResourceMoveResult> {
3333 const source = URI.parse(params.source);
3334 const destination = URI.parse(params.destination);
3335 try {
3336 await this._fileService.move(source, destination, !params.failIfExists);
3337 return {};
3338 } catch (e) {
3339 const result = toFileOperationResult(e as Error);
3340 if (result === FileOperationResult.FILE_MOVE_CONFLICT) {
3341 throw new ProtocolError(AhpErrorCodes.AlreadyExists, `Destination already exists: ${destination.toString()}`);
3342 }
3343 if (result === FileOperationResult.FILE_PERMISSION_DENIED) {
3344 throw new ProtocolError(AhpErrorCodes.PermissionDenied, `Permission denied: ${source.toString()}`);
3345 }
3346 throw new ProtocolError(AhpErrorCodes.NotFound, `Source not found: ${source.toString()}`);
3347 }
3348 }
3350 > async resourceResolve(params: ResourceResolveParams): Promise<ResourceResolveResult> {
3351 const uri = typeof params.uri === 'string' ? URI.parse(params.uri) : URI.revive(params.uri);
3352 try {
3353 const stat = await this._fileService.stat(uri);
3354 let type: ResourceType;
3355 if (stat.isSymbolicLink && params.followSymlinks === false) {
3356 // `IFileService.stat` always follows symlinks in its
3357 // type-classification logic, so `followSymlinks: false`
3358 // only changes how we report the result — we surface the
3359 // link itself rather than the target.
3360 type = ResourceType.Symlink;
3361 } else if (stat.isDirectory) {
3362 type = ResourceType.Directory;
3363 } else {
3364 type = ResourceType.File;
3365 }
3366 const result: ResourceResolveResult = {
3367 uri: uri.toString(),
3368 type,
3369 ...(stat.size !== undefined ? { size: stat.size } : {}),
3370 ...(stat.mtime !== undefined ? { mtime: new Date(stat.mtime).toISOString() } : {}),
3371 ...(stat.ctime !== undefined ? { ctime: new Date(stat.ctime).toISOString() } : {}),
3372 ...(stat.etag ? { etag: stat.etag } : {}),
3373 };
3374 return result;
3375 } catch (e) {
3376 if (toFileOperationResult(e as Error) === FileOperationResult.FILE_PERMISSION_DENIED) {
3377 throw new ProtocolError(AhpErrorCodes.PermissionDenied, `Permission denied: ${uri.toString()}`);
3378 }
3379 throw new ProtocolError(AhpErrorCodes.NotFound, `Resource not found: ${uri.toString()}`);
3380 }
3381 }
3383 > async resourceMkdir(params: ResourceMkdirParams): Promise<ResourceMkdirResult> {
3384 const uri = typeof params.uri === 'string' ? URI.parse(params.uri) : URI.revive(params.uri);
3385 try {
3386 // `IFileService.createFolder` is idempotent for an existing
3387 // directory and creates parents as needed, matching the
3388 // `mkdir -p` semantics required by the spec.
3389 const existing = await this._fileService.stat(uri).catch(() => undefined);
3390 if (existing && !existing.isDirectory) {
3391 throw new ProtocolError(AhpErrorCodes.AlreadyExists, `Path exists and is not a directory: ${uri.toString()}`);
3392 }
3393 await this._fileService.createFolder(uri);
3394 return {};
3395 } catch (e) {
3396 if (e instanceof ProtocolError) {
3397 throw e;
3398 }
3399 if (toFileOperationResult(e as Error) === FileOperationResult.FILE_PERMISSION_DENIED) {
3400 throw new ProtocolError(AhpErrorCodes.PermissionDenied, `Permission denied: ${uri.toString()}`);
3401 }
3402 throw new ProtocolError(AhpErrorCodes.NotFound, `Failed to create directory: ${uri.toString()}`);
3403 }
3404 }
3406 > async createResourceWatch(params: CreateResourceWatchParams): Promise<CreateResourceWatchResult> {
3407 const root = typeof params.uri === 'string' ? URI.parse(params.uri) : URI.revive(params.uri);
3408 // Verify the URI exists before we mint a channel; spec requires
3409 // `NotFound` when the URI is missing rather than silently producing
3410 // a watcher that will never fire. The watcher itself is not
3411 // attached here — encoding the descriptor into the channel URI
3412 // lets `subscribe` materialise the underlying IFileService
3413 // watcher lazily on the first subscriber, and tear it down again
3414 // after the last unsubscribe (with a grace window).
3415 try {
3416 await this._fileService.stat(root);
3417 } catch (e) {
3418 if (toFileOperationResult(e as Error) === FileOperationResult.FILE_PERMISSION_DENIED) {
3419 throw new ProtocolError(AhpErrorCodes.PermissionDenied, `Permission denied: ${root.toString()}`);
3420 }
3421 throw new ProtocolError(AhpErrorCodes.NotFound, `Resource not found: ${root.toString()}`);
3422 }
3423
3424 const channel = buildResourceWatchChannelUri({
3425 root: root.toString(),
3426 recursive: params.recursive === true,
3427 excludes: params.excludes,
3428 includes: params.includes,
3429 });
3430 return { channel };
3431 }
3433 > /**
3434 > * Notifies the agent service that a client subscribed to a resource
3435 > * watch channel. On the first subscriber the underlying
3436 > * {@link IFileService} watcher is attached; subsequent subscribers
3437 > * bump the refcount and cancel any pending grace dispose. Returns
3438 > * the decoded descriptor for use as the subscribe snapshot, or
3439 > * `undefined` when `channel` is not a recognisable
3440 > * `ahp-resource-watch:` URI.
3441 > */
3442 > onResourceWatchSubscribed(channel: string): ResourceWatchState | undefined {
3443 const descriptor = parseResourceWatchChannelUri(channel);
3444 if (!descriptor) {
3445 return undefined;
3446 }
3447 const existing = this._resourceWatches.get(channel);
3448 if (existing) {
3449 existing.subscribers++;
3450 if (existing.pendingGc) {
3451 existing.pendingGc.clear();
3452 }
3453 return existing.descriptor;
3454 }
3455 // First subscriber — materialise the IFileService watcher.
3456 const disposables = new DisposableStore();
3457 try {
3458 const root = URI.parse(descriptor.root);
3459 const watchOptions = {
3460 recursive: descriptor.recursive,
3461 excludes: descriptor.excludes?.items ?? [],
3462 includes: descriptor.includes?.items,
3463 };
3464 if (descriptor.recursive) {
3465 // Correlated watchers are non-recursive only, so register
3466 // an uncorrelated recursive watch and filter the global
3467 // stream by descendants of the watched root.
3468 disposables.add(this._fileService.watch(root, watchOptions));
3469 disposables.add(this._fileService.onDidFilesChange(event => {
3470 const filtered = collectChangesUnderRoot(event, root);
3471 if (filtered.length > 0) {
3472 this._dispatchResourceWatchChanges(channel, filtered);
3473 }
3474 }));
3475 } else {
3476 const watcher = this._fileService.createWatcher(root, { ...watchOptions, recursive: false });
3477 disposables.add(watcher);
3478 disposables.add(watcher.onDidChange(event => {
3479 this._dispatchResourceWatchChanges(channel, collectChanges(event));
3480 }));
3481 }
3482 } catch (e) {
3483 disposables.dispose();
3484 this._logService.warn(`[AgentService] Failed to start IFileService watcher for ${channel}: ${e instanceof Error ? e.message : String(e)}`);
3485 return undefined;
3486 }
3487 this._resourceWatches.set(channel, {
3488 channel,
3489 descriptor,
3490 subscribers: 1,
3491 disposables,
3492 pendingGc: disposables.add(new MutableDisposable()),
3493 dispose: () => disposables.dispose(),
3494 });
3495 return descriptor;
3496 }
3498 > /**
3499 > * Counterpart to {@link onResourceWatchSubscribed}. Decrements the
3500 > * subscriber refcount for a watch channel; when it reaches zero the
3501 > * watcher is held for {@link RESOURCE_WATCH_GRACE_MS} before being
3502 > * disposed, giving a transient disconnect time to resubscribe.
3503 > */
3504 > onResourceWatchUnsubscribed(channel: string): boolean {
3505 const entry = this._resourceWatches.get(channel);
3506 if (!entry) {
3507 return false;
3508 }
3509 entry.subscribers = Math.max(0, entry.subscribers - 1);
3510 if (entry.subscribers > 0) {
3511 return true;
3512 }
3513 entry.pendingGc.value = disposableTimeout(() => {
3514 const current = this._resourceWatches.get(channel);
3515 if (!current || current.subscribers > 0) {
3516 return;
3517 }
3518 this._resourceWatches.deleteAndDispose(channel);
3519 }, RESOURCE_WATCH_GRACE_MS);
3520 return true;
3521 }
3523 > private _dispatchResourceWatchChanges(channel: string, raw: readonly IFileChange[]): void {
3524 if (raw.length === 0) {
3525 return;
3526 }
3527 const items = raw.map(c => ({
3528 uri: c.resource.toString(),
3529 type: c.type === FileChangeType.ADDED ? ResourceChangeType.Added
3530 : c.type === FileChangeType.DELETED ? ResourceChangeType.Deleted
3531 : ResourceChangeType.Updated,
3532 }));
3533 this._stateManager.dispatchServerAction(channel, {
3534 type: ActionType.ResourceWatchChanged,
3535 changes: { items },
3536 });
3537 }
3539 > async shutdown(): Promise<void> {
3540 > this._logService.info('AgentService: shutting down all providers...'); agentService.ts ×1
3541 > const promises: Promise<void>[] = [];
3542 > for (const provider of this._providers.values()) {
3543 > promises.push(provider.shutdown());
3544 > }
3545 > await Promise.all(promises);
3546 > // Drain any worktrees this process created so none leak on shutdown.
3547 > await this._worktree?.removeAllCreatedWorktrees();
3548 > this._sessionToProvider.clear();
3549 > this._downloadProgressInterest.clear();
3550 > }
3552 > /**
3553 > * Wire the network diagnostics service backing {@link getNetworkDiagnosticsInfo}
3554 > * and {@link diagnosticsFetch}. A setter rather than a constructor argument
3555 > * because the service depends on the agent-host proxy resolver, which the
3556 > * remote server constructs lazily — after this service.
3557 > */
3558 > setNetworkDiagnosticsService(service: INetworkDiagnosticsService): void {
3559 > this._networkDiagnostics = service; agentService.ts ×5
3560 > }
3562 > async getNetworkDiagnosticsInfo(): Promise<IAgentHostNetworkDiagnosticsInfo> {
3563 > if (!this._networkDiagnostics) { agentService.ts ×5
3564 throw new Error('Network diagnostics unavailable: service not wired');
3565 }
3566 > const providers = [...this._providers.values()]; agentService.ts ×5
3567 > const contributions = await Promise.all(providers.map(async provider => {
3568 > try {
3569 > return await provider.getNetworkDiagnosticsEndpoints?.() ?? [];
3570 > } catch (error) {
3571 > this._logService.warn(`[AgentService] Failed to resolve network diagnostics endpoints for ${provider.id}: ${error instanceof Error ? error.message : String(error)}`);
3572 > return [];
3573 > }
3574 > }));
3575 > const accounts = await Promise.all(providers.map(async provider => {
3576 > try {
3577 > return await provider.getNetworkDiagnosticsAccount?.();
3578 > } catch (error) {
3579 this._logService.warn(`[AgentService] Failed to resolve network diagnostics account for ${provider.id}: ${error instanceof Error ? error.message : String(error)}`);
3580 return undefined;
3581 }
3582 > })); agentService.ts ×5
3583 > const endpoints: IAgentHostNetworkEndpoint[] = [];
3584 > const seen = new Set<string>();
3585 > for (const endpoint of contributions.flat()) {
3586 > let key: string;
3587 > try {
3588 > key = new URL(endpoint.url).toString();
3589 > } catch {
3590 key = endpoint.url;
3591 }
3592 > if (!seen.has(key)) { agentService.ts ×5
3593 > seen.add(key);
3594 > endpoints.push(endpoint);
3595 > }
3596 > }
3597 > return this._networkDiagnostics.getInfo(endpoints, accounts.find(account => !!account));
3598 > }
3600 > async getManagedSettingsDiagnostics(): Promise<readonly IAgentHostManagedSettingsDiagnostics[]> {
3601 > const providers = [...this._providers.values()].filter(provider => provider.getManagedSettingsDiagnostics); agentService.ts ×2
3602 > return Promise.all(providers.map(async provider => {
3603 > try {
3604 > return { provider: provider.id, snapshot: await provider.getManagedSettingsDiagnostics!() };
3605 > } catch (error) {
3606 > return { provider: provider.id, error: error instanceof Error ? error.message : String(error) }; agentService.ts ×1
3607 > }
3608 > })); agentService.ts ×2
3609 > }
3611 > async diagnosticsFetch(url: string): Promise<IAgentHostNetworkFetchResult> {
3612 if (!this._networkDiagnostics) {
3613 throw new Error('Network diagnostics unavailable: service not wired');
3614 }
3615 return this._networkDiagnostics.fetch(url);
3616 }
3618 > // ---- helpers ------------------------------------------------------------
3619 >
3620 > private async _fetchSessionDbContent(fields: ISessionDbUriFields): Promise<ResourceReadResult> {
3621 const sessionUri = URI.parse(fields.sessionUri);
3622 const ref = this._sessionDataService.openDatabase(sessionUri);
3623 try {
3624 const content = await ref.object.readFileEditContent(fields.toolCallId, fields.filePath);
3625 if (!content) {
3626 throw new ProtocolError(AhpErrorCodes.NotFound, `File edit not found: toolCallId=${fields.toolCallId}, filePath=${fields.filePath}`);
3627 }
3628 const bytes = fields.part === 'before' ? content.beforeContent : content.afterContent;
3629 if (!bytes) {
3630 throw new ProtocolError(AhpErrorCodes.NotFound, `No ${fields.part} content for: toolCallId=${fields.toolCallId}, filePath=${fields.filePath}`);
3631 }
3632 return {
3633 data: new TextDecoder().decode(bytes),
3634 encoding: ContentEncoding.Utf8,
3635 contentType: 'text/plain',
3636 };
3637 } finally {
3638 ref.dispose();
3639 }
3640 }
3642 > private async _fetchGitBlobContent(fields: IGitBlobUriFields): Promise<ResourceReadResult> {
3643 if (!this._gitService) {
3644 throw new ProtocolError(AhpErrorCodes.NotFound, `git service unavailable for: ${fields.repoRelativePath}`);
3645 }
3646 const workingDirectory = this._stateManager.getSessionState(fields.sessionUri)?.workingDirectories?.[0];
3647 if (!workingDirectory) {
3648 throw new ProtocolError(AhpErrorCodes.NotFound, `Session has no working directory for git-blob URI: ${fields.sessionUri}`);
3649 }
3650 const blob = await this._gitService.showBlob(URI.parse(workingDirectory), fields.sha, fields.repoRelativePath);
3651 if (!blob) {
3652 throw new ProtocolError(AhpErrorCodes.NotFound, `git blob not found: ${fields.sha}:${fields.repoRelativePath}`);
3653 }
3654 return {
3655 data: blob.toString(),
3656 encoding: ContentEncoding.Utf8,
3657 contentType: 'text/plain',
3658 };
3659 }
3661 > /**
3662 > * Restores a subagent session from its parent session's event history.
3663 > * Loads the parent's raw messages, filters for events belonging to
3664 > * the subagent (by `parentToolCallId`), and builds the child session's
3665 > * turns from those events.
3666 > */
3667 > private async _restoreSubagentSession(subagentUri: string, parentSession: URI): Promise<void> {
3668 > if (this._stateManager.getSessionState(subagentUri)) { agentService.ts ×10
3669 return;
3670 }
3672 > const inFlight = this._restoreSubagentInFlight.get(subagentUri);
3673 > if (inFlight) {
3674 > return inFlight; agentService.ts ×1
3675 > }
3677 > const restore = this._doRestoreSubagentSession(subagentUri, parentSession);
3678 > this._restoreSubagentInFlight.set(subagentUri, restore);
3679 > try {
3680 > await restore;
3681 > } finally {
3682 > if (this._restoreSubagentInFlight.get(subagentUri) === restore) {
3683 > this._restoreSubagentInFlight.delete(subagentUri);
3684 > }
3685 > }
3686 > }
3688 > private async _doRestoreSubagentSession(subagentUri: string, parentSession: URI): Promise<void> {
3689 > // Ensure the parent session is loaded first agentService.ts ×10
3690 > const parentSessionKey = parentSession.toString();
3691 > if (!this._stateManager.getSessionState(parentSessionKey)) {
3692 try {
3693 await this.restoreSession(parentSession);
3694 } catch {
3695 this._logService.warn(`[AgentService] Cannot restore parent session for subagent: ${parentSessionKey}`);
3696 return;
3697 }
3698 }
3700 > const parentState = this._stateManager.getSessionState(parentSessionKey);
3701 > if (!parentState) {
3702 return;
3703 }
3705 > // Search completed turns and active turn for the subagent content metadata
3706 > const allTurns = [...parentState.turns];
3707 > if (parentState.activeTurn) {
3708 allTurns.push(parentState.activeTurn as Turn);
3709 }
3711 > let subagentContent: ToolResultSubagentContent | undefined;
3712 > for (const turn of allTurns) {
3713 > for (const part of turn.responseParts) {
3714 > if (part.kind === ResponsePartKind.ToolCall) {
3715 > const tc = part.toolCall;
3716 > // Check both completed and running tool calls — running
3717 > // tool calls receive subagent content via ContentChanged
3718 > const content = tc.status === ToolCallStatus.Completed
3719 > ? tc.content
3720 : (tc.status === ToolCallStatus.Running ? tc.content : undefined);
3721 > if (content) { agentService.ts ×10
3722 > for (const c of content) {
3723 > if (c.type === ToolResultContentType.Subagent && c.resource === subagentUri) {
3724 > subagentContent = c;
3725 > break;
3726 > }
3727 > }
3728 > }
3729 > }
3730 > }
3731 > if (subagentContent) {
3732 > break;
3733 > }
3734 > }
3735 >
3736 > // Load the subagent's turns from the agent (which knows how to
3737 > // extract them from the parent session's event log).
3738 > let childTurns: readonly Turn[] = [];
3739 > const agent = this._findProviderForSession(parentSession);
3740 > if (agent) {
3741 > try {
3742 > childTurns = await this._getChatMessages(agent, URI.parse(subagentUri));
3743 > } catch (err) {
3744 this._logService.warn(`[AgentService] Failed to load subagent turns for ${subagentUri}`, err);
3745 }
3747 >
3748 > // Use metadata from subagent content if available, otherwise synthesize
3749 > const title = subagentContent?.title ?? 'Subagent';
3750 >
3751 > const subagentNow = new Date().toISOString();
3752 > // Local turns for a subagent chat are persisted in the parent session's
3753 > // database (its chat URI resolves to the parent session), keyed by the
3754 > // subagent chat URI.
3755 > const mergedChildTurns = await this._interleaveLocalTurns(parentSession.toString(), subagentUri, childTurns);
3756 > this._stateManager.restoreSession(
3757 > {
3758 > resource: subagentUri,
3759 > provider: 'subagent',
3760 > title,
3761 > status: SessionStatus.Idle,
3762 > createdAt: subagentNow,
3763 > modifiedAt: subagentNow,
3764 > ...(parentState?.project ? { project: parentState.project } : {}),
3765 > },
3766 > mergedChildTurns,
3767 > );
3768 > this._logService.info(`[AgentService] Restored subagent session: ${subagentUri} with ${childTurns.length} turn(s)`);
3769 > }
3771 > /**
3772 > * Registers a subagent child session's state up-front from data the agent
3773 > * already reconstructed for the parent, so a later subscribe-driven
3774 > * {@link _restoreSubagentSession} finds it present and returns early
3775 > * instead of re-reading the parent event log. No-op if already registered.
3776 > */
3777 > private _registerRestoredSubagent(child: IRestoredSubagentSession, parentSummary: SessionSummary, parentSessionStr: string): void {
3778 > const resourceStr = child.resource.toString(); agentService.ts ×4
3779 > if (this._stateManager.getSessionState(resourceStr)) {
3780 return;
3781 }
3782 > const registeredNow = new Date().toISOString(); agentService.ts ×4
3783 > this._stateManager.restoreSession(
3784 > {
3785 > resource: resourceStr,
3786 > provider: 'subagent',
3787 > title: child.title,
3788 > status: SessionStatus.Idle,
3789 > createdAt: registeredNow,
3790 > modifiedAt: registeredNow,
3791 > ...(parentSummary.project ? { project: parentSummary.project } : {}),
3792 > },
3793 > [...child.turns],
3794 > );
3795 >
3796 > // Mirror the live `_handleSubagentStarted` flow on restore: surface the
3797 > // subagent as a read-only peer chat in the PARENT session's catalog so it
3798 > // reappears as a tab (and the inline "Open Agent" link can reveal it)
3799 > // after a restart. Uses the same `ahp-chat://subagent/...` chat URI form
3800 > // as the live path so the sessions provider parses and surfaces it.
3801 > const subagentChatUri = buildSubagentChatUri(parentSessionStr, child.toolCallId);
3802 > this._stateManager.addChat(parentSessionStr, subagentChatUri, {
3803 > title: child.title,
3804 > turns: [...child.turns],
3805 > origin: { kind: ChatOriginKind.Tool, chat: buildDefaultChatUri(parentSessionStr), toolCallId: child.toolCallId },
3806 > interactivity: ChatInteractivity.ReadOnly,
3807 > });
3808 > }
3810 > private _findProviderForSession(session: URI | string): IAgent | undefined {
3811 > const key = typeof session === 'string' ? session : session.toString(); agentService.ts ×2
3812 > const providerId = this._sessionToProvider.get(key);
3813 > if (providerId) {
3814 > return this._providers.get(providerId); agentService.ts ×1
3815 > }
3816 > const schemeProvider = AgentSession.provider(session); agentService.ts ×1
3817 > if (schemeProvider) {
3818 > return this._providers.get(schemeProvider);
3819 > }
3820 // Fallback: try the default provider (handles resumed sessions not yet tracked)
3821 if (this._defaultProvider) {
3822 return this._providers.get(this._defaultProvider);
3823 }
3824 return undefined;
3827 > /**
3828 > * Sets the agents observable to trigger model re-fetch and
3829 > * `root/agentsChanged` via the autorun in {@link AgentSideEffects}.
3830 > */
3831 > private _updateAgents(): void {
3832 > this._agents.set([...this._providers.values()], undefined); agentService.ts ×14
3833 > }
3835 > override dispose(): void {
3836 > for (const provider of this._providers.values()) { agentService.ts ×10
3837 > provider.dispose(); agentService.ts ×14
3838 > }
3839 > this._providers.clear(); agentService.ts ×10
3840 > super.dispose();
3841 > }
3843 >
3844 function isErrorWithCode(error: unknown, code: string): boolean {
3845 return error instanceof Error && hasErrorCode(error, code);
3846 }
3848 function hasErrorCode(error: Error | { code: unknown }, code: string): boolean {
3849 return hasKey(error, { code: true }) && error.code === code;
3850 }
3852 > /**
3853 > * Runtime owner of an active resource watch — pairs the {@link IFileService}
3854 > * watcher disposables with the subscriber refcount and the optional
3855 > * grace-window timer used to delay disposal after the last unsubscribe.
3856 > */
3857 > interface IActiveResourceWatch extends IDisposable {
3858 > readonly channel: string;
3859 > readonly descriptor: ResourceWatchState;
3860 > subscribers: number;
3861 > readonly disposables: DisposableStore;
3862 > pendingGc: MutableDisposable<IDisposable>;
3863 > }
3864 >
3865 > /**
3866 > * Flatten a {@link FileChangesEvent} into a synthetic {@link IFileChange}
3867 > * list. The event stores only URI arrays publicly (the underlying
3868 > * `IFileChange[]` is private), so we reconstruct one entry per URI per
3869 > * change type. The synthetic shape is sufficient for translation into
3870 > * `ResourceWatchChangedAction` items.
3871 > */
3872 function collectChanges(event: FileChangesEvent): IFileChange[] {
3873 const out: IFileChange[] = [];
3874 for (const resource of event.rawAdded) {
3875 out.push({ resource, type: FileChangeType.ADDED });
3876 }
3877 for (const resource of event.rawUpdated) {
3878 out.push({ resource, type: FileChangeType.UPDATED });
3879 }
3880 for (const resource of event.rawDeleted) {
3881 out.push({ resource, type: FileChangeType.DELETED });
3882 }
3883 return out;
3884 }
3886 > /**
3887 > * Variant of {@link collectChanges} that restricts the output to changes
3888 > * inside `root` (inclusive). Used for the recursive watch fallback,
3889 > * which feeds off the uncorrelated global stream and must filter out
3890 > * unrelated events.
3891 > */
3892 function collectChangesUnderRoot(event: FileChangesEvent, root: URI): IFileChange[] {
3893 const out: IFileChange[] = [];
3894 const accept = (resource: URI, type: FileChangeType) => {
3895 if (isEqualOrParent(resource, root)) {
3896 out.push({ resource, type });
3897 }
3898 };
3899 for (const resource of event.rawAdded) { accept(resource, FileChangeType.ADDED); }
3900 for (const resource of event.rawUpdated) { accept(resource, FileChangeType.UPDATED); }
3901 for (const resource of event.rawDeleted) { accept(resource, FileChangeType.DELETED); }
3902 return out;
3903 }