Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
microsoft
GitHub Repository: microsoft/vscode
Path: blob/main/extensions/copilot/src/extension/chatSessions/claude/node/claudeCodeAgent.ts
13405 views
1
/*---------------------------------------------------------------------------------------------
2
* Copyright (c) Microsoft Corporation. All rights reserved.
3
* Licensed under the MIT License. See License.txt in the project root for license information.
4
*--------------------------------------------------------------------------------------------*/
5
6
import { EffortLevel, McpServerConfig, Options, PermissionMode, Query, SDKUserMessage, SdkPluginConfig } from '@anthropic-ai/claude-agent-sdk';
7
import Anthropic from '@anthropic-ai/sdk';
8
import * as l10n from '@vscode/l10n';
9
import type * as vscode from 'vscode';
10
import { IChatDebugFileLoggerService } from '../../../../platform/chat/common/chatDebugFileLoggerService';
11
import { INativeEnvService } from '../../../../platform/env/common/envService';
12
import { ILogService } from '../../../../platform/log/common/logService';
13
import { IMcpService } from '../../../../platform/mcp/common/mcpService';
14
import { IOTelService, type ISpanHandle, SpanStatusCode, type TraceContext } from '../../../../platform/otel/common/index';
15
import { deriveClaudeOTelEnv } from '../../../../platform/otel/common/agentOTelEnv';
16
import { CapturingToken } from '../../../../platform/requestLogger/common/capturingToken';
17
import { IWorkspaceService } from '../../../../platform/workspace/common/workspaceService';
18
import { DeferredPromise } from '../../../../util/vs/base/common/async';
19
import { Disposable, DisposableMap } from '../../../../util/vs/base/common/lifecycle';
20
import { isWindows } from '../../../../util/vs/base/common/platform';
21
import { URI } from '../../../../util/vs/base/common/uri';
22
import { IInstantiationService } from '../../../../util/vs/platform/instantiation/common/instantiation';
23
import { LanguageModelToolMCPSource } from '../../../../vscodeTypes';
24
import { IClaudePluginService } from './claudeSkills';
25
import { ExternalEditTracker } from '../../common/externalEditTracker';
26
import { buildMcpServersFromRegistry } from '../common/claudeMcpServerRegistry';
27
import { dispatchMessage, KnownClaudeError } from '../common/claudeMessageDispatch';
28
import { IClaudeRuntimeDataService } from '../common/claudeRuntimeDataService';
29
import { ClaudeSessionUri } from '../common/claudeSessionUri';
30
import { IClaudeToolPermissionService } from '../common/claudeToolPermissionService';
31
import { IClaudeCodeSdkService } from './claudeCodeSdkService';
32
import { ClaudeLanguageModelServer } from './claudeLanguageModelServer';
33
import { resolvePromptToContentBlocks } from './claudePromptResolver';
34
import { ClaudeSettingsChangeTracker } from './claudeSettingsChangeTracker';
35
import { ParsedClaudeModelId } from '../common/claudeModelId';
36
import { IClaudeSessionStateService } from '../common/claudeSessionStateService';
37
import { ClaudeOTelTracker } from './claudeOTelTracker';
38
39
// Manages Claude Code agent interactions and language model server lifecycle
40
export class ClaudeAgentManager extends Disposable {
41
private _langModelServer: ClaudeLanguageModelServer | undefined;
42
private _sessions = this._register(new DisposableMap<string, ClaudeCodeSession>());
43
44
private async getLangModelServer(): Promise<ClaudeLanguageModelServer> {
45
if (!this._langModelServer) {
46
this._langModelServer = this.instantiationService.createInstance(ClaudeLanguageModelServer);
47
await this._langModelServer.start();
48
}
49
50
return this._langModelServer;
51
}
52
53
constructor(
54
@ILogService private readonly logService: ILogService,
55
@IInstantiationService private readonly instantiationService: IInstantiationService,
56
) {
57
super();
58
}
59
60
public async handleRequest(
61
claudeSessionId: string,
62
request: vscode.ChatRequest,
63
stream: vscode.ChatResponseStream,
64
token: vscode.CancellationToken,
65
isNewSession: boolean,
66
yieldRequested?: () => boolean
67
): Promise<vscode.ChatResult> {
68
try {
69
const langModelServer = await this.getLangModelServer();
70
71
this.logService.trace(`[ClaudeAgentManager] Handling request for sessionId=${claudeSessionId}.`);
72
let session = this._sessions.get(claudeSessionId);
73
if (session) {
74
this.logService.trace(`[ClaudeAgentManager] Reusing Claude session ${claudeSessionId}.`);
75
} else {
76
this.logService.trace(`[ClaudeAgentManager] Creating Claude session for sessionId=${claudeSessionId}.`);
77
session = this.instantiationService.createInstance(ClaudeCodeSession, langModelServer, claudeSessionId, isNewSession);
78
this._sessions.set(claudeSessionId, session);
79
}
80
81
await session.invoke(
82
request,
83
stream,
84
yieldRequested,
85
token,
86
);
87
88
return {};
89
} catch (invokeError) {
90
// Check if this is an abort/cancellation error - don't show these as errors to the user
91
const isAbortError = invokeError instanceof Error && (
92
invokeError.name === 'AbortError' ||
93
invokeError.message?.includes('aborted') ||
94
invokeError.message?.includes('cancelled') ||
95
invokeError.message?.includes('canceled')
96
);
97
if (isAbortError) {
98
this.logService.trace('[ClaudeAgentManager] Request was aborted/cancelled');
99
return {};
100
}
101
102
this.logService.error(invokeError as Error);
103
const errorMessage = (invokeError instanceof KnownClaudeError) ? invokeError.message : l10n.t('Claude CLI Error: {0}', invokeError.message);
104
stream.markdown(l10n.t('Error: {0}', errorMessage));
105
return {
106
// This currently can't be used by the sessions API https://github.com/microsoft/vscode/issues/263111
107
errorDetails: { message: errorMessage },
108
};
109
}
110
}
111
}
112
113
/**
114
* Represents a queued chat request waiting to be processed by the Claude session
115
*/
116
interface QueuedRequest {
117
readonly request: vscode.ChatRequest;
118
readonly stream: vscode.ChatResponseStream;
119
readonly token: vscode.CancellationToken;
120
readonly yieldRequested?: () => boolean;
121
readonly deferred: DeferredPromise<void>;
122
readonly modelId: ParsedClaudeModelId;
123
readonly permissionMode: PermissionMode;
124
readonly effort: EffortLevel | undefined;
125
readonly toolsSnapshot: ReadonlySet<string>;
126
}
127
128
export class ClaudeCodeSession extends Disposable {
129
private static readonly GATEWAY_IDLE_TIMEOUT_MS = 10 * 60 * 1000; // 10 minutes
130
131
private _queryGenerator: Query | undefined;
132
/** The deferred promise that should be resolved when the session should wake up and consume from the queued requests. */
133
private _pendingPrompt: DeferredPromise<void> | undefined;
134
/** Requests waiting to be sent to the SDK. */
135
private _queuedRequests: QueuedRequest[] = [];
136
/** Requests that have been sent to the SDK and are awaiting completion; index 0 is the request currently being processed. */
137
private _inFlightRequests: QueuedRequest[] = [];
138
private _abortController = new AbortController();
139
private _editTracker: ExternalEditTracker;
140
private _settingsChangeTracker: ClaudeSettingsChangeTracker;
141
private _currentModelId: ParsedClaudeModelId | undefined;
142
private _currentPermissionMode: PermissionMode = 'acceptEdits';
143
private _currentEffort: EffortLevel | undefined;
144
private _isResumed: boolean;
145
private _pendingRestart = false;
146
private _sessionStarting: Promise<void> | undefined;
147
private _currentToolNames: ReadonlySet<string> | undefined;
148
private _gateway: vscode.McpGateway | undefined;
149
private _gatewayIdleTimeout: ReturnType<typeof setTimeout> | undefined;
150
private _otelTracker: ClaudeOTelTracker;
151
152
private get _currentRequest(): QueuedRequest | undefined {
153
return this._inFlightRequests[0];
154
}
155
156
/**
157
* Sets the model on the active SDK session, or stores it for the next session start.
158
*/
159
private async _setModel(modelId: ParsedClaudeModelId): Promise<void> {
160
if (modelId === this._currentModelId) {
161
return;
162
}
163
this._currentModelId = modelId;
164
if (this._queryGenerator) {
165
const sdkId = modelId.toSdkModelId();
166
this.logService.trace(`[ClaudeCodeSession] Setting model to ${sdkId} on active session`);
167
await this._queryGenerator.setModel(sdkId);
168
}
169
}
170
171
/**
172
* Sets the permission mode on the active SDK session, or stores it for the next session start.
173
*/
174
private async _setPermissionMode(mode: PermissionMode): Promise<void> {
175
if (mode === this._currentPermissionMode) {
176
return;
177
}
178
this._currentPermissionMode = mode;
179
if (this._queryGenerator) {
180
this.logService.trace(`[ClaudeCodeSession] Setting permission mode to ${mode} on active session`);
181
await this._queryGenerator.setPermissionMode(mode);
182
}
183
}
184
185
constructor(
186
private readonly langModelServer: ClaudeLanguageModelServer,
187
public readonly sessionId: string,
188
isNewSession: boolean,
189
@ILogService private readonly logService: ILogService,
190
@IWorkspaceService private readonly workspaceService: IWorkspaceService,
191
@INativeEnvService private readonly envService: INativeEnvService,
192
@IInstantiationService private readonly instantiationService: IInstantiationService,
193
@IClaudeCodeSdkService private readonly claudeCodeService: IClaudeCodeSdkService,
194
@IClaudeToolPermissionService private readonly toolPermissionService: IClaudeToolPermissionService,
195
@IClaudeSessionStateService private readonly sessionStateService: IClaudeSessionStateService,
196
@IClaudeRuntimeDataService private readonly runtimeDataService: IClaudeRuntimeDataService,
197
@IMcpService private readonly mcpService: IMcpService,
198
@IClaudePluginService private readonly claudePluginService: IClaudePluginService,
199
@IOTelService private readonly _otelService: IOTelService,
200
@IChatDebugFileLoggerService private readonly _debugFileLogger: IChatDebugFileLoggerService,
201
) {
202
super();
203
this._isResumed = !isNewSession;
204
this._otelTracker = new ClaudeOTelTracker(this.sessionId, this._otelService, this.sessionStateService);
205
this._debugFileLogger.startSession(this.sessionId).catch(err => {
206
this.logService.error('[ClaudeCodeSession] Failed to start debug log session', err);
207
});
208
this._register({
209
dispose: () => {
210
this._debugFileLogger.endSession(this.sessionId).catch(err => {
211
this.logService.error('[ClaudeCodeSession] Failed to end debug log session', err);
212
});
213
}
214
});
215
// Initialize edit tracker with plan directory as ignored
216
const planDirUri = URI.joinPath(this.envService.userHome, '.claude', 'plans');
217
this._editTracker = new ExternalEditTracker([planDirUri]);
218
this._settingsChangeTracker = this._createSettingsChangeTracker();
219
}
220
221
/**
222
* Creates and configures the settings change tracker with path resolvers.
223
* Add additional path resolvers here for new file types to track.
224
*/
225
private _createSettingsChangeTracker(): ClaudeSettingsChangeTracker {
226
const tracker = this.instantiationService.createInstance(ClaudeSettingsChangeTracker);
227
228
// Track CLAUDE.md files
229
tracker.registerPathResolver(() => {
230
const paths: URI[] = [];
231
// User-level CLAUDE.md
232
paths.push(URI.joinPath(this.envService.userHome, '.claude', 'CLAUDE.md'));
233
// Project-level CLAUDE.md files
234
for (const folder of this.workspaceService.getWorkspaceFolders()) {
235
paths.push(URI.joinPath(folder, '.claude', 'CLAUDE.md'));
236
paths.push(URI.joinPath(folder, '.claude', 'CLAUDE.local.md'));
237
paths.push(URI.joinPath(folder, 'CLAUDE.md'));
238
paths.push(URI.joinPath(folder, 'CLAUDE.local.md'));
239
}
240
return paths;
241
});
242
243
// Track settings/hooks files
244
tracker.registerPathResolver(() => {
245
const paths: URI[] = [];
246
// User-level settings
247
paths.push(URI.joinPath(this.envService.userHome, '.claude', 'settings.json'));
248
// Project-level settings files
249
for (const folder of this.workspaceService.getWorkspaceFolders()) {
250
paths.push(URI.joinPath(folder, '.claude', 'settings.json'));
251
paths.push(URI.joinPath(folder, '.claude', 'settings.local.json'));
252
}
253
return paths;
254
});
255
256
// Track agent files in agents directories
257
tracker.registerDirectoryResolver(() => {
258
const dirs: URI[] = [];
259
// User-level agents directory
260
dirs.push(URI.joinPath(this.envService.userHome, '.claude', 'agents'));
261
// Project-level agents directory
262
for (const folder of this.workspaceService.getWorkspaceFolders()) {
263
dirs.push(URI.joinPath(folder, '.claude', 'agents'));
264
}
265
return dirs;
266
}, '.md');
267
268
return tracker;
269
}
270
271
public override dispose(): void {
272
this._cancelGatewayIdleTimer();
273
this._disposeGateway();
274
this._abortController.abort();
275
this._inFlightRequests.forEach(req => {
276
if (!req.deferred.isSettled) {
277
req.deferred.error(new Error('Session disposed'));
278
}
279
});
280
this._inFlightRequests = [];
281
this._queuedRequests.forEach(req => {
282
if (!req.deferred.isSettled) {
283
req.deferred.error(new Error('Session disposed'));
284
}
285
});
286
this._queuedRequests = [];
287
this._pendingPrompt?.error(new Error('Session disposed'));
288
this._pendingPrompt = undefined;
289
super.dispose();
290
}
291
292
/**
293
* Invokes the Claude Code session with a user prompt
294
* @param request The full chat request
295
* @param stream Response stream for sending results back to VS Code
296
* @param yieldRequested Function to check if the user has requested to interrupt
297
* @param token Cancellation token for request cancellation
298
*/
299
public async invoke(
300
request: vscode.ChatRequest,
301
stream: vscode.ChatResponseStream,
302
yieldRequested: (() => boolean) | undefined,
303
token: vscode.CancellationToken,
304
): Promise<void> {
305
if (this._store.isDisposed) {
306
throw new Error('Session disposed');
307
}
308
309
this._cancelGatewayIdleTimer();
310
311
// Snapshot per-request metadata from session state
312
const modelId = this.sessionStateService.getModelIdForSession(this.sessionId);
313
if (!modelId) {
314
throw new Error(`Model not set for session ${this.sessionId}. State must be committed before invoking.`);
315
}
316
const permissionMode = this.sessionStateService.getPermissionModeForSession(this.sessionId);
317
const effort = this.sessionStateService.getReasoningEffortForSession(this.sessionId);
318
const toolsSnapshot = this._computeToolsSnapshot(request.tools);
319
320
// Add this request to the queue with its metadata snapshot
321
const deferred = new DeferredPromise<void>();
322
const queuedRequest: QueuedRequest = {
323
request,
324
stream,
325
token,
326
yieldRequested,
327
deferred,
328
modelId,
329
permissionMode,
330
effort,
331
toolsSnapshot,
332
};
333
334
this._queuedRequests.push(queuedRequest);
335
336
if (!this._queryGenerator) {
337
await this._startSession(token);
338
}
339
340
// Wake up the iterable if it's awaiting the next request.
341
if (this._pendingPrompt) {
342
const pendingPrompt = this._pendingPrompt;
343
this._pendingPrompt = undefined;
344
pendingPrompt.complete();
345
}
346
347
return deferred.p;
348
}
349
350
/**
351
* Starts a new Claude Code session with the configured options.
352
* Guards against concurrent starts (e.g., from yield restart racing with a new invoke).
353
*/
354
private async _startSession(token: vscode.CancellationToken): Promise<void> {
355
// If a session start is already in progress, wait for it rather than starting a second
356
if (this._sessionStarting) {
357
await this._sessionStarting;
358
return;
359
}
360
361
const startPromise = this._doStartSession(token);
362
this._sessionStarting = startPromise;
363
try {
364
await startPromise;
365
} finally {
366
this._sessionStarting = undefined;
367
}
368
}
369
370
private async _doStartSession(token: vscode.CancellationToken): Promise<void> {
371
const folderInfo = this.sessionStateService.getFolderInfoForSession(this.sessionId);
372
if (!folderInfo) {
373
throw new Error(`No folder info found for session ${this.sessionId}. State must be committed before invoking.`);
374
}
375
const headRequest = this._queuedRequests[0];
376
if (!headRequest) {
377
throw new Error(`No queued request to start session ${this.sessionId} with.`);
378
}
379
380
// Seed session state from the head request's metadata
381
this._currentModelId = headRequest.modelId;
382
this._currentPermissionMode = headRequest.permissionMode;
383
this._currentEffort = headRequest.effort;
384
this._currentToolNames = headRequest.toolsSnapshot;
385
386
const { cwd, additionalDirectories } = folderInfo;
387
388
// Build options for the Claude Code SDK
389
this.logService.trace(`appRoot: ${this.envService.appRoot}`);
390
const pathSep = isWindows ? ';' : ':';
391
const mcpServers: Record<string, McpServerConfig> = await buildMcpServersFromRegistry(this.instantiationService) ?? {};
392
393
// Create or reuse the MCP gateway for this session
394
try {
395
this._gateway ??= await this.mcpService.startMcpGateway(ClaudeSessionUri.forSessionId(this.sessionId)) ?? undefined;
396
if (this._gateway) {
397
for (const server of this._gateway.servers) {
398
const serverId = server.label.toLowerCase().replace(/[^a-z0-9_-]/g, '_').replace(/^_+|_+$/g, '') || `vscode-mcp-server-${Object.keys(mcpServers).length}`;
399
mcpServers[serverId] = {
400
type: 'http',
401
url: server.address.toString(),
402
};
403
}
404
}
405
} catch (error) {
406
const errorMessage = error instanceof Error ? (error.stack ?? error.message) : String(error);
407
this.logService.warn(`[ClaudeCodeSession] Failed to start MCP gateway: ${errorMessage}`);
408
}
409
410
// Build plugins from skill directories
411
const plugins: SdkPluginConfig[] = [];
412
try {
413
const pluginLocations = await this.claudePluginService.getPluginLocations(token);
414
for (const pluginLocation of pluginLocations) {
415
plugins.push({ type: 'local', path: pluginLocation.fsPath });
416
}
417
if (plugins.length > 0) {
418
this.logService.info(`[ClaudeCodeSession] Passing ${plugins.length} plugin(s) from skill locations`);
419
}
420
} catch (error) {
421
const errorMessage = error instanceof Error ? (error.stack ?? error.message) : String(error);
422
this.logService.warn(`[ClaudeCodeSession] Failed to resolve skill locations for plugins: ${errorMessage}`);
423
}
424
425
// Take a snapshot of settings files so we can detect changes
426
await this._settingsChangeTracker.takeSnapshot();
427
428
const serverConfig = this.langModelServer.getConfig();
429
const options: Options = {
430
cwd,
431
additionalDirectories,
432
// We allow this because we handle the visibility of
433
// the permission mode ourselves in the options
434
allowDangerouslySkipPermissions: true,
435
abortController: this._abortController,
436
effort: headRequest.effort,
437
executable: process.execPath as 'node', // get it to fork the EH node process
438
// TODO: CAPI does not yet support the WebSearch tool
439
// Once it does, we can re-enable it.
440
disallowedTools: ['WebSearch'],
441
// Use sessionId for new sessions, resume for existing ones (mutually exclusive)
442
...(this._isResumed
443
? { resume: this.sessionId }
444
: { sessionId: this.sessionId }),
445
// Pass the model selection to the SDK
446
model: headRequest.modelId.toSdkModelId(),
447
// Pass the permission mode to the SDK
448
permissionMode: headRequest.permissionMode,
449
includeHookEvents: true,
450
mcpServers,
451
plugins,
452
settings: {
453
env: {
454
ANTHROPIC_BASE_URL: `http://localhost:${serverConfig.port}`,
455
ANTHROPIC_AUTH_TOKEN: `${serverConfig.nonce}.${this.sessionId}`,
456
CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC: '1',
457
USE_BUILTIN_RIPGREP: '0',
458
PATH: `${this.envService.appRoot}/node_modules/@vscode/ripgrep/bin${pathSep}${process.env.PATH}`,
459
// Forward OTel configuration to the Claude SDK subprocess
460
...deriveClaudeOTelEnv(this._otelService.config),
461
},
462
attribution: {
463
commit: '',
464
pr: '',
465
},
466
},
467
canUseTool: async (name, input) => {
468
if (!this._currentRequest) {
469
return { behavior: 'deny', message: 'No active request' };
470
}
471
this.logService.trace(`[ClaudeCodeSession]: canUseTool: ${name}(${JSON.stringify(input)})`);
472
return this.toolPermissionService.canUseTool(name, input, {
473
toolInvocationToken: this._currentRequest.request.toolInvocationToken,
474
permissionMode: this._currentPermissionMode,
475
stream: this._currentRequest.stream
476
});
477
},
478
systemPrompt: {
479
type: 'preset',
480
preset: 'claude_code'
481
},
482
settingSources: ['user', 'project', 'local'],
483
stderr: data => this.logService.error(`claude-agent-sdk stderr: ${data}`)
484
};
485
486
this.logService.trace(`claude-agent-sdk: Starting query`);
487
this._queryGenerator = await this.claudeCodeService.query({
488
prompt: this._createPromptIterable(),
489
options
490
});
491
492
// Cache runtime data (agents, etc.) for the customization provider.
493
// Fire-and-forget to avoid blocking session startup — error handling is inside the service.
494
void this.runtimeDataService.update(this._queryGenerator);
495
496
497
// Start the message processing loop (fire-and-forget, but _processMessages
498
// handles all errors internally via try/catch → _cleanup)
499
void this._processMessages().catch(err => {
500
this.logService.error('[ClaudeCodeSession] Unhandled error in message processing loop', err);
501
});
502
}
503
504
private async *_createPromptIterable(): AsyncIterable<SDKUserMessage> {
505
while (true) {
506
// Wait for a request to be available
507
while (this._queuedRequests.length === 0) {
508
this._pendingPrompt = new DeferredPromise<void>();
509
await this._pendingPrompt.p;
510
}
511
const request = this._queuedRequests.shift()!;
512
513
// Check settings file changes when no other request is in flight
514
if (this._inFlightRequests.length === 0 && await this._settingsChangeTracker.hasChanges()) {
515
this.logService.trace('[ClaudeCodeSession] Settings files changed, restarting session with resume');
516
this._queuedRequests.unshift(request);
517
this._pendingRestart = true;
518
this._isResumed = true;
519
return;
520
}
521
522
// Check non-hot-swappable changes that require a session restart
523
if (request.effort !== this._currentEffort || !this._toolsMatch(request.toolsSnapshot)) {
524
this._queuedRequests.unshift(request);
525
this._pendingRestart = true;
526
this._isResumed = true;
527
return;
528
}
529
530
// Hot-swap model and permission mode on the active session
531
await this._setModel(request.modelId);
532
await this._setPermissionMode(request.permissionMode);
533
534
// Mark this request as yielded to the SDK; it becomes the current request.
535
this._inFlightRequests.push(request);
536
537
// Increment user-initiated message count for this model
538
// This is used by the language model server to track which requests are user-initiated
539
this.langModelServer.incrementUserInitiatedMessageCount(request.modelId.toEndpointModelId());
540
541
// Resolve the prompt content blocks now that this request is being handled
542
const prompt = await resolvePromptToContentBlocks(request.request);
543
544
// Create a capturing token for this request to group tool calls under the request
545
// we use the last text block in the prompt as the label for the token, since that is most representative of the user's intent
546
const promptLabel = prompt.filter(p => p.type === 'text').at(-1)?.text ?? 'Claude Session Prompt';
547
this.sessionStateService.setCapturingTokenForSession(
548
this.sessionId,
549
new CapturingToken(promptLabel, 'claude', undefined, undefined, this.sessionId)
550
);
551
552
// Start OTel tracking for this request
553
this._otelTracker.startRequest(request.modelId.toEndpointModelId());
554
555
// Emit user_message span event for the debug panel
556
this._otelTracker.emitUserMessage(promptLabel);
557
558
yield {
559
type: 'user',
560
message: {
561
role: 'user',
562
content: prompt
563
},
564
priority: 'now',
565
parent_tool_use_id: null,
566
session_id: this.sessionId,
567
// NOTE: messageId seems to be in the format request_<uuid> but it doesn't seem
568
// to be a problem to use as the message ID for the SDK.
569
uuid: request.request.id as `${string}-${string}-${string}-${string}-${string}`
570
};
571
}
572
}
573
574
/**
575
* Processes messages from the Claude Code query generator
576
* Routes messages to appropriate handlers and manages request completion
577
*/
578
private async _processMessages(): Promise<void> {
579
const otelToolSpans = new Map<string, ISpanHandle>();
580
const otelHookSpans = new Map<string, ISpanHandle>();
581
const subagentTraceContexts = new Map<string, TraceContext>();
582
try {
583
const unprocessedToolCalls = new Map<string, Anthropic.Beta.Messages.BetaToolUseBlock>();
584
for await (const message of this._queryGenerator!) {
585
// Mark session as resumed after first SDK message confirms session exists on disk.
586
// This ensures future restarts (yield, settings change) use `resume` instead of `sessionId`.
587
if (message.session_id && !this._isResumed) {
588
this._isResumed = true;
589
}
590
591
// Skip if no current request (e.g., after yield cleared it)
592
if (!this._currentRequest) {
593
this.logService.trace('[ClaudeCodeSession] Skipping message - no current request');
594
continue;
595
}
596
597
const currentRequest = this._currentRequest;
598
599
// Check if current request was cancelled
600
if (currentRequest.token.isCancellationRequested) {
601
throw new Error('Request was cancelled');
602
}
603
604
// Track OTel metrics from SDK messages
605
this._otelTracker.onMessage(message, subagentTraceContexts);
606
607
this.logService.trace(`claude-agent-sdk Message: ${JSON.stringify(message, null, 2)}`);
608
609
let result;
610
try {
611
result = this.instantiationService.invokeFunction(dispatchMessage, message, this.sessionId, {
612
stream: currentRequest.stream,
613
toolInvocationToken: currentRequest.request.toolInvocationToken,
614
editTracker: this._editTracker,
615
token: currentRequest.token,
616
}, {
617
unprocessedToolCalls,
618
otelToolSpans,
619
otelHookSpans,
620
parentTraceContext: this._otelTracker.traceContext,
621
subagentTraceContexts,
622
});
623
} catch (dispatchError) {
624
this.logService.warn(`[ClaudeCodeSession] Failed to dispatch message (stream may be disposed after yield): ${dispatchError}`);
625
}
626
627
if (currentRequest.yieldRequested?.()) {
628
this.logService.trace('[ClaudeCodeSession] Yield requested - signaling session completion so next request can start');
629
630
// Complete the current request gracefully but don't kill the session
631
if (!currentRequest.deferred.isSettled) {
632
await currentRequest.deferred.complete();
633
}
634
}
635
636
if (result?.requestComplete) {
637
// End the invoke_agent span for this request
638
this._otelTracker.endRequest();
639
// Clear the capturing token so subsequent requests get their own
640
this.sessionStateService.setCapturingTokenForSession(this.sessionId, undefined);
641
const completed = this._inFlightRequests.shift();
642
if (completed && !completed.deferred.isSettled) {
643
await completed.deferred.complete();
644
}
645
if (this._inFlightRequests.length === 0 && this._queuedRequests.length === 0) {
646
this._startGatewayIdleTimer();
647
}
648
subagentTraceContexts.clear();
649
}
650
}
651
// Generator ended normally - clean up so next invoke starts fresh
652
throw new Error('Session ended unexpectedly');
653
} catch (error) {
654
// Graceful restart: the prompt iterable detected a non-hot-swappable change
655
// (effort or tools). Preserve queued requests and start a fresh session.
656
if (this._pendingRestart) {
657
this._pendingRestart = false;
658
this._restartSession();
659
const headToken = this._queuedRequests[0]?.token;
660
if (headToken) {
661
await this._startSession(headToken);
662
}
663
return;
664
}
665
666
// Clear the capturing token so it doesn't leak across sessions or error boundaries
667
this.sessionStateService.setCapturingTokenForSession(this.sessionId, undefined);
668
// End invoke_agent span with error if still open
669
this._otelTracker.endRequestWithError(error.message);
670
671
// Resets session state so the next session start can begin fresh.
672
// Preserves the sessionId for SDK resume.
673
674
this._queryGenerator = undefined;
675
this._abortController = new AbortController();
676
677
// Rejects all pending requests and clears the queues.
678
679
this._inFlightRequests.forEach(req => {
680
if (!req.deferred.isSettled) {
681
req.deferred.error(error);
682
}
683
});
684
this._inFlightRequests = [];
685
this._queuedRequests.forEach(req => {
686
if (!req.deferred.isSettled) {
687
req.deferred.error(error);
688
}
689
});
690
this._queuedRequests = [];
691
if (this._pendingPrompt && !this._pendingPrompt.isSettled) {
692
this._pendingPrompt.error(error);
693
}
694
this._pendingPrompt = undefined;
695
} finally {
696
// Clean up any remaining OTel spans
697
for (const [, span] of otelToolSpans) {
698
span.setStatus(SpanStatusCode.ERROR, 'session ended before tool completed');
699
span.end();
700
}
701
otelToolSpans.clear();
702
for (const [, span] of otelHookSpans) {
703
span.setStatus(SpanStatusCode.ERROR, 'session ended before hook completed');
704
span.end();
705
}
706
otelHookSpans.clear();
707
// End any lingering invoke_agent span
708
this._otelTracker.endRequestWithError('session ended');
709
}
710
}
711
712
/**
713
* Restarts the session by aborting the current SDK connection.
714
* The abort causes _processMessages to enter error cleanup, which
715
* rejects any remaining requests and resets session state.
716
*/
717
private _restartSession(): void {
718
this._queryGenerator = undefined;
719
this._abortController.abort();
720
this._abortController = new AbortController();
721
this._isResumed = true;
722
}
723
724
// #region Gateway Lifecycle
725
726
private _cancelGatewayIdleTimer(): void {
727
if (this._gatewayIdleTimeout !== undefined) {
728
clearTimeout(this._gatewayIdleTimeout);
729
this._gatewayIdleTimeout = undefined;
730
}
731
}
732
733
private _startGatewayIdleTimer(): void {
734
this._cancelGatewayIdleTimer();
735
this._gatewayIdleTimeout = setTimeout(() => {
736
this._gatewayIdleTimeout = undefined;
737
this._disposeGateway();
738
this._restartSession();
739
}, ClaudeCodeSession.GATEWAY_IDLE_TIMEOUT_MS);
740
}
741
742
private _disposeGateway(): void {
743
this._gateway?.dispose();
744
this._gateway = undefined;
745
}
746
747
// #endregion
748
749
/**
750
* Computes a snapshot of the MCP tool names from a chat request's tools map.
751
*/
752
private _computeToolsSnapshot(tools: vscode.ChatRequest['tools']): ReadonlySet<string> {
753
// TODO: Handle the enabled/disabled (true/false) state per tool once we have UI for it
754
return new Set(
755
[...tools]
756
.filter(([tool]) => tool.source instanceof LanguageModelToolMCPSource)
757
.map(([tool]) => tool.name)
758
);
759
}
760
761
/**
762
* Checks whether a tools snapshot matches the current session's tools.
763
*/
764
private _toolsMatch(snapshot: ReadonlySet<string>): boolean {
765
if (!this._currentToolNames) {
766
return true;
767
}
768
769
if (snapshot.size !== this._currentToolNames.size) {
770
return false;
771
}
772
773
for (const name of snapshot) {
774
if (!this._currentToolNames.has(name)) {
775
return false;
776
}
777
}
778
779
return true;
780
}
781
782
}
783
784