src/vs/platform/agentHost/node/claude/claudeAgentSession.ts

1121 LOC · 1052 covered · 69 uncovered · 131 ranges · 357 concepts · 47 introducers · 200 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 > /*--------------------------------------------------------------------------------------------- claudeAgent.ts ×91
2 > * Copyright (c) Microsoft Corporation. All rights reserved.
3 > * Licensed under the MIT License. See License.txt in the project root for license information.
4 > *--------------------------------------------------------------------------------------------*/
5 >
6 > import type { McpSdkServerConfigWithInstance, OnElicitation, Options, PermissionMode, SDKUserMessage } from '@anthropic-ai/claude-agent-sdk';
7 > import type { CallToolResult } from '@modelcontextprotocol/sdk/types.js';
8 > import { CancellationError } from '../../../../base/common/errors.js';
9 > import { Emitter, Event } from '../../../../base/common/event.js';
10 > import { Disposable, DisposableStore } from '../../../../base/common/lifecycle.js';
11 > import { isEqual } from '../../../../base/common/resources.js';
12 > import { URI } from '../../../../base/common/uri.js';
13 > import { INativeEnvironmentService } from '../../../environment/common/environment.js';
14 > import { IFileService } from '../../../files/common/files.js';
15 > import { IInstantiationService } from '../../../instantiation/common/instantiation.js';
16 > import { ILogService } from '../../../log/common/log.js';
17 > import { IAgentConfigurationService } from '../agentConfigurationService.js';
18 > import { ISyncedCustomization } from '../../common/agentPluginManager.js';
19 > import { ClaudePermissionMode } from '../../common/claudeSessionConfigKeys.js';
20 > import { ClaudeRuntimeEffortLevel, toRuntimeEffortLevel, resolveClaudeEffort } from '../../common/claudeModelConfig.js';
21 > import { AgentSignal, IAgentSessionProjectInfo } from '../../common/agentService.js';
22 > import type { IAgentServerToolHost } from '../../common/agentServerTools.js';
23 > import { PendingRequestRegistry } from '../../common/pendingRequestRegistry.js';
24 > import { ISessionDatabase, ISessionDataService } from '../../common/sessionDataService.js';
25 > import { ActionType } from '../../common/state/sessionActions.js';
26 > import { PendingMessage, ChatInputAnswer, ChatInputRequest, ChatInputResponseKind, ToolCallContributorKind, ToolCallPendingConfirmationState, type AgentSelection, type ModelSelection, type ToolDefinition } from '../../common/state/protocol/state.js';
27 > import { isDefaultChatUri, type Customization, type ToolCallResult } from '../../common/state/sessionState.js';
28 > import { IClaudeAgentSdkService } from './claudeAgentSdkService.js';
29 > import { buildClientMcpServers, buildOptions } from './claudeSdkOptions.js';
30 > import { toSdkModelId } from './claudeModelId.js';
31 > import { buildServerToolMcpServer, CLAUDE_SERVER_TOOL_MCP_SERVER_NAME, serverToolAllowList } from './claudeServerToolMcpServer.js';
32 > import { ClaudeSessionMetadataStore } from './claudeSessionMetadataStore.js';
33 > import { convertToolCallResult } from './clientTools/claudeClientToolResult.js';
34 > import { readClaudePermissionMode } from './claudeSessionPermissionMode.js';
35 > import { SessionClientToolsDiff } from './clientTools/claudeSessionClientToolsModel.js';
36 > import { SessionClientCustomizationsDiff } from './customizations/claudeSessionClientCustomizationsModel.js';
37 > import { ClaudeCustomizationWatcher, buildDiscoveredCustomizations, resolveClaudeAgentName } from './customizations/claudeSessionCustomizationDiscovery.js';
38 > import { applyMcpServerEnablement, findMcpChildId, findMcpServerName, getEffectiveMcpServerCustomizations } from '../shared/mcpCustomizationController.js';
39 > import { scanClaudeDiskCustomizations } from './customizations/scan/claudeAgentSkillScan.js';
40 > import { scanClaudeHooks } from './customizations/scan/claudeHookScan.js';
41 > import { scanClaudeMcpServers } from './customizations/scan/claudeMcpScan.js';
42 > import { scanClaudeNativePlugins } from './customizations/scan/claudeNativePluginScan.js';
43 > import { AgentHostStateManager, IAgentHostStateManager } from '../agentHostStateManager.js';
44 > import { scanClaudeRules } from './customizations/scan/claudeRuleScan.js';
45 > import { resolvePromptToContentBlocks } from './claudePromptResolver.js';
46 > import type { ClaudeTransport } from './claudeProxyService.js';
47 > import { ClaudeSdkPipeline, IRematerializer, type ISdkResolvedCustomizations } from './claudeSdkPipeline.js';
48 > import { SubagentRegistry } from './claudeSubagentRegistry.js';
49 > import { ClaudePermissionKind } from './claudeToolDisplay.js';
50 >
51 > // Re-export for callers that import IRematerializer from the session.
52 > export type { IRematerializer } from './claudeSdkPipeline.js';
53 >
54 > /**
55 > * Inputs to {@link ClaudeAgentSession.materialize}. Carries the
56 > * agent-supplied dependencies that the session itself does not own
57 > * (proxy auth, the `canUseTool` closure that bridges back to the
58 > * agent's per-session lookup, and the resume-vs-fresh discriminator).
59 > */
60 > export interface IMaterializeContext {
61 > readonly transport: ClaudeTransport;
62 > readonly canUseTool: NonNullable<Options['canUseTool']>;
63 > readonly onElicitation: OnElicitation;
64 > readonly isResume: boolean;
65 > /**
66 > * Working directory the host resolved for this session's first send (e.g. an
67 > * isolated worktree). When present it becomes the session's
68 > * {@link ClaudeAgentSession.workingDirectory}, overriding the
69 > * {@link ClaudeAgentSession.workspace} the session was based on. Omitted when
70 > * the session works directly in its `workspace` (folder / workspace-less).
71 > */
72 > readonly workingDirectory?: URI;
73 > /**
74 > * Agent host's server-tool host. When present, the session exposes the
75 > * agent host's server tools (feedback "comments" today, more in the future)
76 > * as an in-process MCP server and advertises them as server tools. Omitted
77 > * by providers that don't support server-side tools.
78 > */
79 > readonly serverToolHost?: IAgentServerToolHost;
80 > }
81 >
82 > function resolveCurrentPermissionMode( claudeAgentSession.ts ×2
83 > configurationService: IAgentConfigurationService,
84 > sessionUri: URI,
85 > permissionModeFallback: ClaudePermissionMode,
86 > ): ClaudePermissionMode {
87 > return readClaudePermissionMode(configurationService, sessionUri) ?? permissionModeFallback;
88 > }
90 > /**
91 > * Per-session coordinator. Owns:
92 > * • Per-session identity (sessionId / sessionUri / workspace /
93 > * workingDirectory).
94 > * • The {@link ClaudeSdkPipeline} that drives the SDK Query lifecycle
95 > * and emits every {@link AgentSignal} for this session (router-
96 > * mapped per-message signals plus `ChatTurnComplete` and
97 > * `steering_consumed`).
98 > * • Pending-permission and pending-user-input registries (Phase 7),
99 > * surfaced via `requestPermission` / `requestUserInput`.
100 > */
101 > export class ClaudeAgentSession extends Disposable {
102 >
103 > private _pipeline: ClaudeSdkPipeline | undefined;
104 > private readonly _chatChannelUri: URI;
105 >
106 > /**
107 > * URI under which this chat's per-chat resources (its session database,
108 > * metadata overlay, config scope and server-tool advertisement) are keyed.
109 > * The default chat uses the real session URI; an additional peer chat uses
110 > * its own `ahp-chat` channel URI so its chat state stays isolated
111 > * from the default chat's. `sessionUri` always remains the real session URI
112 > * and `chatChannelUri` always the chat channel — they are never overloaded.
113 > */
114 > private get _storageUri(): URI {
115 > return isDefaultChatUri(this._chatChannelUri) ? this.sessionUri : this._chatChannelUri;
116 > }
117 >
118 > private get _sessionCustomizations(): readonly Customization[] {
119 > return this._stateManager.getSessionState(this.sessionUri.toString())?.customizations ?? []; claudeAgentSession.ts ×1
120 > }
122 > /** Pre-materialize model selection. Mutable; flows into `Options.model` on first installPipeline. */
123 > private _provisionalModel: ModelSelection | undefined;
124 > /**
125 > * Pre-materialize custom-agent selection. Mutable; flows into
126 > * `Options.agent` (resolved to the SDK agent name) on materialize
127 > * and on every rematerializer call. Mid-session changes via
128 > * {@link setAgent} flip {@link clientCustomizationsDiff} dirty so the
129 > * next `send()` rebinds and the new agent reaches the SDK on the
130 > * rebuilt `Query`. The SDK's `Options.agent` is captured at startup
131 > * — there is no runtime control-plane equivalent.
132 > */
133 > private _provisionalAgent: AgentSelection | undefined;
134 > /** Pre-materialize `IAgentCreateSessionConfig.config` bag. Read at materialize time. */
135 > readonly provisionalConfig: Record<string, unknown> | undefined;
136 > /** Resolved project metadata captured at create time (if any). */
137 > readonly project: IAgentSessionProjectInfo | undefined;
138 > /** Always-present abort controller; wired into `Options.abortController` at materialize time. */
139 > readonly abortController: AbortController;
140 >
141 > /**
142 > * The actual directory work is done in. Defaults to {@link workspace} until
143 > * the host hands the session a resolved working directory (e.g. an isolated
144 > * worktree) at {@link materialize} time. `undefined` only when the session is
145 > * workspace-less and has no resolved directory yet.
146 > */
147 > get workingDirectory(): URI | undefined {
148 > return this._workingDirectory ?? this.workspace; claudeAgentSession.ts ×1
149 > }
150 > private _workingDirectory: URI | undefined; claudeAgent.ts ×91
151 > private readonly _customizationWatcher = this._register(new DisposableStore());
152 >
153 > /** Exposed for the materializer's MCP-server build closure. */
154 > get pendingClientToolCalls(): PendingRequestRegistry<CallToolResult> { return this._pendingClientToolCalls; }
155 > /** Snapshot of permission-mode fallback used when live read is undefined. */
156 > get permissionModeFallback(): ClaudePermissionMode { return this._permissionModeFallback; }
157 >
158 > static createProvisional(
159 > sessionId: string, claudeAgentSession.ts ×4
160 > sessionUri: URI,
161 > chatChannelUri: URI,
162 > workspace: URI | undefined,
163 > project: IAgentSessionProjectInfo | undefined,
164 > model: ModelSelection | undefined,
165 > agent: AgentSelection | undefined,
166 > config: Record<string, unknown> | undefined,
167 > pendingClientToolCalls: PendingRequestRegistry<CallToolResult>,
168 > permissionModeFallback: ClaudePermissionMode,
169 > metadataStore: ClaudeSessionMetadataStore,
170 > instantiationService: IInstantiationService,
171 > ): ClaudeAgentSession {
172 > return instantiationService.createInstance(
173 > ClaudeAgentSession,
174 > sessionId,
175 > sessionUri,
176 > chatChannelUri,
177 > workspace,
178 > project,
179 > model,
180 > agent,
181 > config,
182 > new AbortController(),
183 > pendingClientToolCalls,
184 > new SessionClientToolsDiff(),
185 > permissionModeFallback,
186 > metadataStore,
187 > );
188 > }
190 > /**
191 > * Phase 12 — per-session registry of Task tool calls that spawn
192 > * subagents (`SubagentSpawn` records keyed by `tool_use_id`, plus a
193 > * reverse index from inner `tool_use_id` to its parent Task). Owned
194 > * here so the registry dies with the session; consumers in the live
195 > * mapper (`ClaudeSdkMessageRouter` / `claudeMapSessionEvents` /
196 > * `claudeSubagentSignals`) and the `canUseTool` bridge read from
197 > * the same instance via the session.
198 > */
199 > readonly subagents: SubagentRegistry = this._register(new SubagentRegistry());
200 >
201 > /**
202 > * Phase 7 / S3.2. Tool-permission deferreds parked inside
203 > * {@link Options.canUseTool}. Keyed by SDK `tool_use_id`.
204 > */
205 > private readonly _pendingPermissions = new PendingRequestRegistry<boolean>();
206 >
207 > /**
208 > * Phase 7 / S3.2. User-input deferreds parked for interactive tools
209 > * (`AskUserQuestion`, `ExitPlanMode`). Keyed by `ChatInputRequest.id`.
210 > */
211 > private readonly _pendingUserInputs = new PendingRequestRegistry<{ response: ChatInputResponseKind; answers?: Record<string, ChatInputAnswer> }>();
212 >
213 > /**
214 > * Phase 10 — owns the workbench-registered client-tool snapshot
215 > * (via {@link SessionClientToolsDiff.model}) plus the
216 > * "changed since last successful build" dirty bit. Read by the
217 > * agent's sendMessage diff check; used by the materialize /
218 > * rematerializer flow to pin the SDK build against a specific
219 > * snapshot. See {@link SessionClientToolsDiff} for the C6 race
220 > * semantics this collaborator enforces.
221 > */
222 > readonly toolDiff: SessionClientToolsDiff;
223 >
224 > /**
225 > * Phase 11 — per-session **client-pushed** synced customization
226 > * snapshot + enablement map. Owns the workbench-supplied
227 > * {@link ISyncedCustomization} list, the per-URI enablement bits,
228 > * and the dirty flag drained at the next {@link send} pre-flight.
229 > * Exists from `createProvisional` onward so client-side reads /
230 > * toggles work uniformly before and after materialize.
231 > *
232 > * Server-side (SDK-discovered) customizations are NOT stored here
233 > * — they're fetched on demand from the live `Query` in
234 > * {@link getSessionCustomizations}.
235 > *
236 > * See {@link SessionClientCustomizationsDiff}.
237 > */
238 > readonly clientCustomizationsDiff: SessionClientCustomizationsDiff = this._register(new SessionClientCustomizationsDiff());
239 >
240 > private readonly _onDidSessionProgress = this._register(new Emitter<AgentSignal>());
241 > readonly onDidSessionProgress: Event<AgentSignal> = this._onDidSessionProgress.event;
242 >
243 > /**
244 > * Real Copilot credits (in nano-AIU) billed by CAPI for the current
245 > * turn, summed across every `/v1/messages` request the SDK made
246 > * (including subagents). Fed by {@link recordTurnCredits} from the
247 > * proxy's `onDidReportCredits`, reset at the start of each {@link send},
248 > * and attached to the turn's `ChatUsage` signal by
249 > * {@link _enrichSignalWithCredits}. Unlike the SDK's `total_cost_usd`
250 > * (an Anthropic-list-price estimate), this is what CAPI actually bills.
251 > */
252 > private _currentTurnNanoAiu = 0;
253 >
254 > /**
255 > * Transport the session materialized under (Phase 19). Defaults to `proxy`
256 > * until {@link materialize} resolves it from {@link IMaterializeContext}.
257 > * Gates {@link _enrichSignalWithCredits} so native turns never carry a
258 > * Copilot credits overlay (the proxy is the only credit source).
259 > */
260 > private _transportKind: ClaudeTransport['kind'] = 'proxy';
261 >
262 > /**
263 > * Accumulate proxy-reported billed credits for the in-flight turn.
264 > * Called from {@link ClaudeAgent} for every proxy `onDidReportCredits`
265 > * routed to this session. Ignores non-positive / non-finite values.
266 > */
267 > recordTurnCredits(totalNanoAiu: number): void {
268 > if (Number.isFinite(totalNanoAiu) && totalNanoAiu > 0) { claudeAgentSession.ts ×2
269 > this._currentTurnNanoAiu += totalNanoAiu;
270 > }
271 > }
273 > /**
274 > * Inject the turn's accumulated Copilot credits into its `ChatUsage`
275 > * signal as `_meta.copilotUsage.totalNanoAiu` — the well-known key the
276 > * workbench prefers over `_meta.cost` when rendering per-turn credits.
277 > * All other signals pass through untouched.
278 > */
279 > private _enrichSignalWithCredits(signal: AgentSignal): AgentSignal {
280 > if (this._transportKind !== 'proxy' || signal.kind !== 'action' || signal.action.type !== ActionType.ChatUsage || this._currentTurnNanoAiu <= 0) { claudeAgentSession.ts ×5
281 > return signal;
282 > }
283 > const usage = signal.action.usage; claudeAgentSession.ts ×2
284 > return {
285 > ...signal,
286 > action: {
287 > ...signal.action,
288 > usage: {
289 > ...usage,
290 > _meta: {
291 > ...usage._meta,
292 > copilotUsage: { totalNanoAiu: this._currentTurnNanoAiu },
293 > },
294 > },
295 > },
296 > };
299 > /**
300 > * Stamps the MCP {@link ToolCallContributor} onto a `ChatToolCallStart` for
301 > * an external `mcp__<server>__<tool>` call, resolved from this session's
302 > * cached customization snapshot. Owned here because the session owns the
303 > * customization data; the stream mapper stays free of it. (The in-process
304 > * `mcp__client__` server already carries a Client contributor from the mapper.)
305 > */
306 > private _enrichSignalWithMcpContributor(signal: AgentSignal): AgentSignal {
307 > if (signal.kind !== 'action' || signal.action.type !== ActionType.ChatToolCallStart || signal.action.contributor !== undefined) { claudeAgentSession.ts ×5
308 > return signal;
309 > }
310 > const toolName = signal.action.toolName; claudeAgent.ts ×1
311 > if (!toolName.startsWith('mcp__')) {
312 > return signal;
313 > }
314 const serverName = toolName.split('__')[1];
315 > const customizationId = serverName ? findMcpChildId(this._lastCustomizations, serverName) : undefined; claudeAgentSession.ts ×5
316 > if (customizationId === undefined) {
317 return signal;
318 }
319 return { ...signal, action: { ...signal.action, contributor: { kind: ToolCallContributorKind.MCP, customizationId } } };
322 > constructor(
323 > readonly sessionId: string, claudeAgentSession.ts ×4
324 > readonly sessionUri: URI,
325 > readonly chatChannelUri: URI,
326 > readonly workspace: URI | undefined,
327 > project: IAgentSessionProjectInfo | undefined,
328 > model: ModelSelection | undefined,
329 > agent: AgentSelection | undefined,
330 > config: Record<string, unknown> | undefined,
331 > abortController: AbortController,
332 > private readonly _pendingClientToolCalls: PendingRequestRegistry<CallToolResult>,
333 > toolDiff: SessionClientToolsDiff,
334 > private readonly _permissionModeFallback: ClaudePermissionMode,
335 > private readonly _metadataStore: ClaudeSessionMetadataStore,
336 > @IInstantiationService private readonly _instantiationService: IInstantiationService,
337 > @IAgentConfigurationService private readonly _configurationService: IAgentConfigurationService,
338 > @IAgentHostStateManager private readonly _stateManager: AgentHostStateManager,
339 > @IClaudeAgentSdkService private readonly _sdkService: IClaudeAgentSdkService,
340 > @ISessionDataService private readonly _sessionDataService: ISessionDataService,
341 > @ILogService private readonly _logService: ILogService,
342 > @IFileService private readonly _fileService: IFileService,
343 > @INativeEnvironmentService private readonly _environmentService: INativeEnvironmentService,
344 > ) {
345 > super();
346 > this._chatChannelUri = chatChannelUri;
347 > this.project = project;
348 > this._provisionalModel = model;
349 > this._provisionalAgent = agent;
350 > this.provisionalConfig = config;
351 > this.abortController = abortController;
352 > this.toolDiff = this._register(toolDiff);
353 > this._register(this.clientCustomizationsDiff.onDidChange(() => this._onDidCustomizationsChange.fire()));
354 >
355 > this._watchCustomizations(this.workspace);
356 > }
358 > private _watchCustomizations(directory: URI | undefined): void {
359 > this._customizationWatcher.clear(); claudeAgentSession.ts ×4
360 > const watcher = this._customizationWatcher.add(new ClaudeCustomizationWatcher(
361 > directory,
362 > this._environmentService.userHome,
363 > this._fileService,
364 > this._logService,
365 > ));
366 > this._customizationWatcher.add(watcher.onDidChange(() => this._onDidCustomizationsChange.fire()));
367 > }
369 > /**
370 > * One-shot SDK assistant-message uuid that the next materialize / rebuild
371 > * resumes *up to and including* (the SDK's `Options.resumeSessionAt`).
372 > * Staged by {@link truncateToTurn}; read by the next build and cleared
373 > * only once that build *succeeds* (so a thrown / cancelled rebuild keeps
374 > * the anchor staged and the next send retries the truncation rather than
375 > * silently proceeding without it and undoing the checkpoint restore).
376 > */
377 > private _pendingResumeSessionAt: string | undefined;
378 >
379 > /**
380 > * In-place truncation to `turnId` ("Restore Checkpoint"): prune the
381 > * per-turn DB rows (file edits, checkpoint refs) past the boundary AND
382 > * stage the SDK resume anchor that the next rebuild applies via
383 > * `Options.resumeSessionAt`. These two halves are one invariant — pruning
384 > * without staging the anchor would drop DB rows while the SDK still
385 > * replays the truncated turns; staging without pruning would leave stale
386 > * rows — so they live behind a single call rather than two the caller
387 > * could half-invoke. The prune runs first because it is the fallible half:
388 > * a DB failure then rejects without leaving an anchor staged for the next
389 > * turn. `turnId` is the protocol turn id (DB key); `resumeAnchorUuid` is
390 > * the SDK assistant-message uuid the agent resolved for it.
391 > */
392 > async truncateToTurn(turnId: string, resumeAnchorUuid: string): Promise<void> {
393 > await this._withDatabase(db => db.deleteTurnsAfter(turnId)); claudeAgentSession.ts ×1
394 > this._pendingResumeSessionAt = resumeAnchorUuid;
395 > }
397 > /** Prunes all per-turn DB rows (remove-all truncation). */
398 > async pruneAllTurns(): Promise<void> {
399 > await this._withDatabase(db => db.deleteAllTurns()); claudeAgentSession.ts ×1
400 > }
402 > /**
403 > * Runs `fn` against a short-lived, ref-counted session DB handle so the
404 > * write is safe regardless of the pipeline's own dbRef lifecycle (the
405 > * ref-count keeps the shared DB alive; disposing only decrements).
406 > */
407 > private async _withDatabase(fn: (db: ISessionDatabase) => Promise<void>): Promise<void> {
408 > const ref = this._sessionDataService.openDatabase(this._storageUri); claudeAgentSession.ts ×1
409 > try {
410 > await fn(ref.object);
411 > } finally {
412 > ref.dispose();
413 > }
414 > }
416 > /**
417 > * Bring the session up: build SDK `Options`, start the SDK, open the
418 > * session-scoped DB ref, construct the pipeline, and attach the
419 > * rematerializer used for yield-restart (e.g. after a client-tool
420 > * snapshot change). Idempotent on re-call: extra calls throw rather
421 > * than silently re-materialize.
422 > *
423 > * If the supplied {@link IMaterializeContext.proxyHandle}'s underlying
424 > * `abortController` fires while `sdk.startup()` is in flight, the SDK
425 > * unwinds via the controller; if `startup` resolves anyway, the
426 > * `WarmQuery` is asyncDisposed and a {@link CancellationError} is
427 > * thrown (Q8 belt-and-suspenders).
428 > */
429 > async materialize(ctx: IMaterializeContext): Promise<void> {
430 > if (this._pipeline) { claudeAgentSession.ts ×14
431 throw new Error('ClaudeAgentSession is already materialized');
432 }
433 > // Adopt the host-resolved working directory (e.g. an isolated worktree) claudeAgentSession.ts ×14
434 > // before it's read below; falls back to the session's `workspace` when the
435 > // host didn't resolve a dedicated directory.
436 > if (ctx.workingDirectory && !isEqual(ctx.workingDirectory, this.workingDirectory)) {
437 > this._workingDirectory = ctx.workingDirectory; claudeAgentSession.ts ×1
438 > this._watchCustomizations(ctx.workingDirectory);
439 > }
440 > if (!this.workingDirectory) { claudeAgentSession.ts ×14
441 throw new Error(`Cannot materialize Claude session ${this.sessionId}: workingDirectory is required`);
442 }
443 > this._transportKind = ctx.transport.kind; claudeAgentSession.ts ×14
444 >
445 > const permissionMode = readClaudePermissionMode(this._configurationService, this._storageUri) ?? this._permissionModeFallback;
446 > const { mcpServers, allowedTools } = await this._buildStartupToolWiring(ctx.serverToolHost);
447 > const agentName = await resolveClaudeAgentName(this._provisionalAgent, this._fileService, this._logService, this.sessionId);
448 >
449 > const options = await buildOptions(
450 > {
451 > sessionId: this.sessionId,
452 > workingDirectory: this.workingDirectory,
453 > model: this._provisionalModel,
454 > abortController: this.abortController,
455 > permissionMode,
456 > canUseTool: ctx.canUseTool,
457 > onElicitation: ctx.onElicitation,
458 > isResume: ctx.isResume,
459 > resumeSessionAt: this._pendingResumeSessionAt,
460 > mcpServers,
461 > allowedTools,
462 > plugins: this.clientCustomizationsDiff.consume(this._desiredClientPluginPaths()),
463 > agent: agentName,
464 > },
465 > ctx.transport,
466 > data => this._logService.error(`[Claude SDK stderr] ${data}`),
467 > );
468 >
469 > this._logService.info(`[Claude] session ${this.sessionId}: enableFileCheckpointing=${options.enableFileCheckpointing} isResume=${ctx.isResume}`);
470 >
471 > const warm = await this._sdkService.startup({ options });
472 >
473 > if (this.abortController.signal.aborted) {
474 await warm[Symbol.asyncDispose]();
475 throw new CancellationError();
476 }
478 > const dbRef = this._sessionDataService.openDatabase(this._storageUri);
479 > let pipeline: ClaudeSdkPipeline;
480 > try {
481 > pipeline = this._register(this._instantiationService.createInstance(
482 > ClaudeSdkPipeline,
483 > this.sessionId,
484 > this.sessionUri,
485 > this._chatChannelUri,
486 > warm,
487 > this.abortController,
488 > dbRef,
489 > this.subagents,
490 > (toolName: string) => this.toolDiff.model.ownerOf(toolName),
491 > ));
492 > } catch (err) {
493 dbRef.dispose();
494 await warm[Symbol.asyncDispose]();
495 throw err;
496 }
497 > this._register(pipeline.onDidProduceSignal(s => this._onDidSessionProgress.fire(this._enrichSignalWithMcpContributor(this._enrichSignalWithCredits(s))))); claudeAgentSession.ts ×14
498 > this._pipeline = pipeline;
499 > // The materialize succeeded with the staged anchor applied to `Options`
500 > // — clear it now so it isn't re-applied. A throw before this point (e.g.
501 > // `startup` / pipeline-create) leaves it staged for the next retry.
502 > this._pendingResumeSessionAt = undefined;
503 >
504 > // Seed the pipeline's bijective config cache so a rebuild re-applies
505 > // the user's last-chosen model / effort without losing the picker
506 > // config. Read provisional state directly off the session.
507 > pipeline.seedCurrentConfig(
508 > toSdkModelId(this._provisionalModel?.id),
509 > toRuntimeEffortLevel(resolveClaudeEffort(this._provisionalModel)),
510 > permissionMode,
511 > );
512 >
513 > // Fresh sessions persist their customization-directory / model /
514 > // permissionMode overlay so a later resume re-reads them. Resume
515 > // sessions skip the write because they READ from the overlay
516 > // upstream and would otherwise overwrite their source.
517 > if (!ctx.isResume) {
519 > await this._metadataStore.write(this._storageUri, {
520 > customizationDirectory: this.workingDirectory,
521 > model: this._provisionalModel,
522 > permissionMode,
523 > transport: ctx.transport.kind,
524 > });
525 > } catch (err) {
526 this._logService.error(`[Claude] Failed to persist customization directory; aborting materialize`, err);
527 throw err;
528 }
531 > // Final pre-commit abort gate. The first gate above caught aborts
532 > // that landed while `sdk.startup()` was in flight; this one catches
533 > // aborts that landed during the metadata write (a separate async
534 > // boundary). Without it, a racing `disposeSession` could complete
535 > // before this method returns and leave the pipeline live.
536 > if (this.abortController.signal.aborted) {
537 > throw new CancellationError(); claudeAgent.ts ×1
538 > }
540 > pipeline.attachRematerializer(async (_reason) => {
541 > const liveMode = readClaudePermissionMode(this._configurationService, this._storageUri) ?? this._permissionModeFallback; claudeAgentSession.ts ×1
542 > try {
543 > const { mcpServers: rebuildMcp, allowedTools: rebuildAllowedTools } = await this._buildStartupToolWiring(ctx.serverToolHost);
544 > const rebuildAgentName = await resolveClaudeAgentName(this._provisionalAgent, this._fileService, this._logService, this.sessionId);
545 > const rebuildAbort = new AbortController();
546 > const rebuildOptions = await buildOptions(
547 > {
548 > sessionId: this.sessionId,
549 > workingDirectory: this.workingDirectory!,
550 > model: this._provisionalModel,
551 > abortController: rebuildAbort,
552 > permissionMode: liveMode,
553 > canUseTool: ctx.canUseTool,
554 > onElicitation: ctx.onElicitation,
555 > isResume: true,
556 > resumeSessionAt: this._pendingResumeSessionAt,
557 > mcpServers: rebuildMcp,
558 > allowedTools: rebuildAllowedTools,
559 > plugins: this.clientCustomizationsDiff.consume(this._desiredClientPluginPaths()),
560 > agent: rebuildAgentName,
561 > },
562 > ctx.transport,
563 > data => this._logService.error(`[Claude SDK stderr] ${data}`),
564 > );
565 > this._logService.info(`[Claude] session ${this.sessionId}: resume rebuild agent=${rebuildOptions.agent ?? '(none)'}`);
566 > const rebuildWarm = await this._sdkService.startup({ options: rebuildOptions });
567 > // Rebuild succeeded with the anchor applied — clear it so it
568 > // isn't re-applied. A throw above keeps it staged (handled in the
569 > // catch alongside the tool/customization diffs) so the next send
570 > // retries the truncation instead of dropping the restore.
571 > this._pendingResumeSessionAt = undefined;
572 > return { warm: rebuildWarm, abortController: rebuildAbort };
573 > } catch (err) {
574 > this.toolDiff.markDirty(); claudeAgentSession.ts ×1
575 > this.clientCustomizationsDiff.markDirty();
576 > throw err;
577 > }
579 > await this._reconcileMcpServerEnablement();
580 >
581 > // Advertise the agent host's server tools on this session so the client
582 > // sees them as server-provided. Execution happens in-process via the
583 > // server-tool MCP server built in `_buildStartupToolWiring`.
584 > ctx.serverToolHost?.advertise(this._storageUri.toString()); claudeAgentSession.ts ×14
585 >
586 > // Surface the SDK-resolved customization tier to the workbench.
587 > // Pre-materialize, getSessionCustomizations returns only the
588 > // client-pushed slice; firing here prompts the workbench to refetch
589 > // and pick up the bundled `Discovered in Claude` entry.
590 > this._onDidCustomizationsChange.fire();
591 > }
593 > /**
594 > * Build the SDK tool wiring shared by the initial materialize and every
595 > * yield-restart rematerialize: the in-process MCP servers plus the
596 > * auto-approve allow-list.
597 > *
598 > * The MCP servers are the workbench client tools (which round-trip to the
599 > * workbench) plus, when a server-tool host is wired, the agent host's own
600 > * server tools (executed in-process). `mcpServers` is `undefined` when
601 > * neither is present so `Options.mcpServers` is omitted entirely and the
602 > * SDK keeps its default; `allowedTools` carries the SDK-prefixed server tool
603 > * names (so they auto-approve without prompting) and is `undefined` when no
604 > * server-tool host is wired.
605 > *
606 > * Keeping both in one place ensures the two startup paths can never drift,
607 > * and that a newly registered server tool is wired everywhere at once.
608 > */
609 > private async _buildStartupToolWiring(
610 > serverToolHost: IAgentServerToolHost | undefined, claudeAgentSession.ts ×14
611 > ): Promise<{ mcpServers: Record<string, McpSdkServerConfigWithInstance> | undefined; allowedTools: readonly string[] | undefined }> {
612 > const clientServers = await buildClientMcpServers(this.toolDiff, this._pendingClientToolCalls, this._sdkService);
613 > const serverToolServer = serverToolHost
614 ? await buildServerToolMcpServer(serverToolHost, this._storageUri.toString(), this._sdkService)
615 > : undefined; claudeAgentSession.ts ×14
616 > const mcpServers = (!clientServers && !serverToolServer)
617 > ? undefined claudeAgentSession.ts ×1
619 > ...(clientServers ?? {}),
620 > ...(serverToolServer ? { [CLAUDE_SERVER_TOOL_MCP_SERVER_NAME]: serverToolServer } : {}),
621 > };
622 > // Exclude server tools that require user confirmation from the claudeAgentSession.ts ×14
623 > // auto-approve allow-list so the SDK surfaces them via `canUseTool`
624 > // (the host then renders a custom confirmation) instead of running them
625 > // silently.
626 > const autoApproveToolNames = serverToolHost
627 ? serverToolHost.toolNames.filter(name => !serverToolHost.requiresConfirmation(name))
628 > : undefined; claudeAgentSession.ts ×14
629 > return { mcpServers, allowedTools: autoApproveToolNames ? serverToolAllowList(autoApproveToolNames) : undefined };
630 > }
632 > /** True once {@link materialize} has installed the SDK pipeline. */
633 > get isPipelineReady(): boolean { return this._pipeline !== undefined; }
634 >
635 > /**
636 > * Whether this chat currently has a turn in flight or queued. False when
637 > * provisional (no pipeline) or idle between turns. Used by non-destructive
638 > * idle release to avoid disconnecting mid-turn.
639 > */
640 > get hasActiveTurn(): boolean { return this._pipeline?.hasActiveTurn ?? false; }
641 >
642 > /** Pre-materialize model selection accessor (read by materializer to build Options). */
643 > get provisionalModel(): ModelSelection | undefined { return this._provisionalModel; }
644 >
645 > private _requirePipeline(): ClaudeSdkPipeline {
646 > if (!this._pipeline) { claudeAgentSession.ts ×2
647 > throw new Error('ClaudeAgentSession is not materialized'); claudeAgentSession.ts ×1
648 > }
649 > return this._pipeline; claudeAgentSession.ts ×5
652 > get isResumed(): boolean { return this._requirePipeline().isResumed; }
653 >
654 > /**
655 > * Abort the live SDK subprocess and await its full teardown so the
656 > * session id is released. No-op when the session was never materialized
657 > * (no subprocess to stop). Used by remove-all truncation before it
658 > * recreates a fresh session under the same id — the CLI keeps the id
659 > * locked until the old subprocess exits.
660 > */
661 > async shutdownLiveQuery(): Promise<void> {
662 > await this._pipeline?.shutdownAndWait(); claudeSdkPipeline.ts ×2
663 > }
665 > /**
666 > * Seed the pipeline's current + applied config cache from
667 > * materialize-time `Options`. The SDK already starts with these
668 > * values, so the cache prevents a redundant first `setModel` /
669 > * `applyFlagSettings` call.
670 > */
671 > seedBijectiveState(state: { model?: string; effort?: ClaudeRuntimeEffortLevel; permissionMode?: PermissionMode }): void {
672 this._requirePipeline().seedCurrentConfig(state.model, state.effort, state.permissionMode);
673 }
675 > attachRematerializer(rematerializer: IRematerializer): void {
676 this._requirePipeline().attachRematerializer(rematerializer);
677 }
679 > /**
680 > * Send a user prompt. Performs the per-turn pre-flight before
681 > * yielding to the pipeline:
682 > *
683 > * - If {@link toolDiff} or {@link clientCustomizationsDiff} reports the
684 > * live `Query` is out of sync with the workbench's view, yield-restart
685 > * so the SDK picks up the new `Options.mcpServers` / `Options.plugins`.
686 > * `Query.reloadPlugins()` cannot help here — the SDK's plugin URI set
687 > * is captured at startup, so any add / remove / nonce-bump must go
688 > * through a full rebuild. The rebind itself re-applies the live
689 > * `permissionMode` via the rematerializer.
690 > * - Otherwise forward the live `permissionMode` to the bound `Query` so
691 > * a `SessionConfigChanged` action that arrived between turns wins.
692 > * The pipeline's bijective cache dedupes a no-op `setPermissionMode`,
693 > * so this is free when nothing changed.
694 > *
695 > * Model / effort are not threaded through here — the pipeline's current
696 > * model / effort (set eagerly via {@link setModel}) is whatever
697 > * the SDK has been told.
698 > */
699 > async send(prompt: SDKUserMessage, turnId: string): Promise<void> {
700 > const pipeline = this._requirePipeline(); claudeAgentSession.ts ×2
701 > // New turn: reset the per-turn credit accumulator so proxy reports
702 > // for this turn's `/v1/messages` calls sum from zero.
703 > this._currentTurnNanoAiu = 0;
704 > if (this.toolDiff.hasDifference || this.clientCustomizationsDiff.hasDifferenceFrom(this._desiredClientPluginPaths()) || this._pendingResumeSessionAt !== undefined) {
705 > await this._rebindForSyncedState(); claudeAgentSession.ts ×2
706 > } else { claudeSdkPipeline.ts ×4
707 > await pipeline.setPermissionMode(resolveCurrentPermissionMode(this._configurationService, this._storageUri, this._permissionModeFallback)); claudeAgentSession.ts ×2
708 > }
709 > await this._reconcileMcpServerEnablement(); claudeSdkPipeline.ts ×4
710 > return pipeline.send(prompt, turnId);
713 > /**
714 > * Single yield-restart that covers both client-tool and
715 > * customization divergence in one trip. Drains the parked
716 > * client-tool MCP handlers (same as the original tool-only
717 > * rebind), then triggers the pipeline rebind — the rematerializer
718 > * reads `toolDiff` and reducer-backed client plugin paths while
719 > * building the new `Options`, so the bit on each diff clears in
720 > * lockstep with the SDK actually receiving the new values. Fires
721 > * `_onDidCustomizationsChange` afterwards so the workbench
722 > * refetches `getSessionCustomizations` and picks up any newly
723 > * resolved server-side entries from the rebuilt `Query`.
724 > */
725 > private async _rebindForSyncedState(): Promise<void> {
726 > this._pendingClientToolCalls.rejectAll(new CancellationError()); claudeAgentSession.ts ×2
727 > await this._requirePipeline().rebindForRestart();
728 > this._onDidCustomizationsChange.fire();
729 > }
731 > /**
732 > * Cancel the in-flight SDK turn. Mirrors the production reference;
733 > * see {@link ClaudeSdkPipeline.abort}. Also denies any parked
734 > * permission / user-input requests so the SDK's `canUseTool`
735 > * callback (and any interactive tool waiting on user input) unwinds
736 > * with a deny / cancel result instead of leaving stale UI behind.
737 > */
738 > abort(): void {
739 > this._pendingPermissions.denyAll(false); claudeAgent.ts ×2
740 > this._pendingUserInputs.denyAll({ response: ChatInputResponseKind.Cancel });
741 > this._requirePipeline().abort();
742 > }
744 > /**
745 > * Eagerly apply a model change and persist the new selection. Safe to
746 > * call before or after materialize:
747 > *
748 > * - Pre-materialize: stash the model on the session so the first SDK
749 > * startup picks it up via `Options.model` / `Options.effort`.
750 > * - Post-materialize: queue the change on the pipeline; the SDK
751 > * applies it on the NEXT user request via
752 > * `Query.setModel` / `Query.applyFlagSettings`. `'max'` flows through
753 > * unchanged — see {@link toRuntimeEffortLevel}.
754 > *
755 > * In both cases the new model is persisted to the per-session
756 > * metadata overlay so a later resume sees the user's choice.
757 > */
758 > async setModel(model: ModelSelection): Promise<void> {
759 > this._provisionalModel = model; claudeAgentSession.ts ×2
760 > if (this._pipeline) {
761 > await this._pipeline.setModel(toSdkModelId(model.id)); claudeSdkPipeline.ts ×2
762 > // Always push the resolved effort, including `undefined`. Switching
763 > // to a model that does not support reasoning effort (e.g. Haiku)
764 > // resolves to `undefined`, which must actively CLEAR any effort the
765 > // SDK is still applying from a prior effort-capable model — otherwise
766 > // the next turn replays e.g. `'high'` onto Haiku and the API 400s
767 > // (`output_config.effort ... does not support reasoning effort`).
768 > await this._pipeline.setEffort(toRuntimeEffortLevel(resolveClaudeEffort(model)));
769 > }
770 > await this._metadataStore.write(this._storageUri, { model }); claudeAgentSession.ts ×2
771 > }
773 > /**
774 > * Pre-materialize custom-agent selection accessor.
775 > */
776 > get provisionalAgent(): AgentSelection | undefined { return this._provisionalAgent; }
777 >
778 > /**
779 > * Change (or clear with `undefined`) the selected custom agent for this
780 > * session. The SDK captures `Options.agent` at startup with no
781 > * working runtime control (`applyFlagSettings({ agent })` exists on
782 > * the SDK surface but doesn't actually swap the live agent), so
783 > * post-materialize calls flip {@link clientCustomizationsDiff}
784 > * dirty and the next `send()` pre-flight rebinds with the new agent
785 > * baked into the rebuilt `Query`. Persisted to the per-session
786 > * metadata overlay so a resume picks up the choice.
787 > */
788 > async setAgent(agent: AgentSelection | undefined): Promise<void> {
789 > if (this._provisionalAgent === agent) { claudeAgentSession.ts ×3
790 return;
791 }
792 > this._provisionalAgent = agent; claudeAgentSession.ts ×3
793 > if (this._pipeline) {
794 > // Force a rebind on the next send(); the SDK has no working claudeAgentSession.ts ×1
795 > // runtime hook to swap the agent in place.
796 > this.clientCustomizationsDiff.markDirty();
797 > }
798 > await this._metadataStore.write(this._storageUri, { agent: agent ?? null }); claudeAgentSession.ts ×3
799 > }
801 > /**
802 > * Inject a steering message. Builds the `priority: 'now'`
803 > * {@link SDKUserMessage} and hands it to the pipeline; the pipeline
804 > * inherits the parent's turnId (CONTEXT.md M10) and fires
805 > * `steering_consumed` when the SDK accepts it. No-op if the pipeline
806 > * is aborted.
807 > */
808 > injectSteering(steeringMessage: PendingMessage): void {
809 > const pipeline = this._requirePipeline(); claudeSdkPipeline.ts ×3
810 > if (pipeline.isAborted) {
811 return;
812 }
813 > const contentBlocks = resolvePromptToContentBlocks( claudeSdkPipeline.ts ×3
814 > steeringMessage.message.text,
815 > steeringMessage.message.attachments,
816 > );
817 > const sdkMessage: SDKUserMessage = {
818 > type: 'user',
819 > message: { role: 'user', content: contentBlocks },
820 > session_id: this.sessionId,
821 > parent_tool_use_id: null,
822 > priority: 'now',
823 > // Reuse the protocol PendingMessage.id as the SDK uuid — same
824 > // pattern as `ClaudeAgent.sendMessage` reusing turnId. The SDK's
825 > // `uuid` field is typed as a branded UUID, but the cast at the
826 > // boundary is the convention for both code paths.
827 > uuid: steeringMessage.id as `${string}-${string}-${string}-${string}-${string}`,
828 > };
829 > pipeline.injectSteering(sdkMessage, steeringMessage.id);
830 > }
832 > /** Live permission-mode change. Forwards to the pipeline; the pipeline remembers it for re-application after a rebind. */
833 > setPermissionMode(mode: PermissionMode): Promise<void> {
834 > return this._requirePipeline().setPermissionMode(mode); claudeAgent.ts ×4
835 > }
837 > // #region Phase 7 / S3.2 — pending state
838 >
839 > /**
840 > * Atomically register a pending-permission deferred and fire the
841 > * `pending_confirmation` signal. The SDK is blocked on the returned
842 > * promise inside its `canUseTool` callback until
843 > * {@link respondToPermissionRequest} resolves it. Resolves with
844 > * `false` if the pipeline is aborted.
845 > */
846 > requestPermission(args: {
847 > readonly toolUseID: string; claudeAgentSession.ts ×2
848 > readonly state: ToolCallPendingConfirmationState;
849 > readonly permissionKind: ClaudePermissionKind;
850 > readonly permissionPath?: string;
851 > /** Phase 12 step 5 — when the confirmation belongs to a subagent context, route it to the subagent session. */
852 > readonly parentToolCallId?: string;
853 > }): Promise<boolean> {
854 > if (!this._pipeline || this._pipeline.isAborted) {
855 return Promise.resolve(false);
856 }
857 > return this._pendingPermissions.registerAndFire(args.toolUseID, () => { claudeAgentSession.ts ×2
858 > this._onDidSessionProgress.fire({
859 > kind: 'pending_confirmation',
860 > chat: this._chatChannelUri,
861 > state: args.state,
862 > permissionKind: args.permissionKind,
863 > ...(args.permissionPath !== undefined ? { permissionPath: args.permissionPath } : {}),
864 > ...(args.parentToolCallId !== undefined ? { parentToolCallId: args.parentToolCallId } : {}),
865 > });
866 > });
867 > }
869 > respondToPermissionRequest(requestId: string, approved: boolean): boolean {
870 > return this._pendingPermissions.respond(requestId, approved); claudeAgentSession.ts ×1
871 > }
873 > /**
874 > * Fire a {@link ActionType.ChatInputRequested} action and park on
875 > * a deferred until {@link respondToUserInputRequest} resolves it.
876 > * Resolves with `{ response: Cancel }` if the pipeline is aborted.
877 > */
878 > requestUserInput(request: ChatInputRequest, parentToolCallId?: string): Promise<{ response: ChatInputResponseKind; answers?: Record<string, ChatInputAnswer> }> {
879 > if (!this._pipeline || this._pipeline.isAborted || !this._pipeline.hasActiveTurn) { claudeAgentSession.ts ×2
880 return Promise.resolve({ response: ChatInputResponseKind.Cancel });
881 }
882 > return this._pendingUserInputs.registerAndFire(request.id, () => { claudeAgentSession.ts ×2
883 > this._onDidSessionProgress.fire({
884 > kind: 'action',
885 > resource: this._chatChannelUri,
886 > action: {
887 > type: ActionType.ChatInputRequested,
888 > request,
889 > },
890 > ...(parentToolCallId !== undefined ? { parentToolCallId } : {}),
891 > });
892 > });
893 > }
895 > respondToUserInputRequest(
896 > requestId: string, claudeAgentSession.ts ×1
897 > response: ChatInputResponseKind,
898 > answers?: Record<string, ChatInputAnswer>,
899 > ): boolean {
900 > return this._pendingUserInputs.respond(requestId, { response, answers });
901 > }
903 > // #endregion
904 >
905 > // #region Phase 10 — client tools
906 >
907 > /** Replace a client's registered tools (full replacement). */
908 > setClientTools(clientId: string, tools: readonly ToolDefinition[]): void {
909 > this.toolDiff.model.setTools(clientId, tools); claudeAgentSession.ts ×1
910 > }
912 > /** This client's registered tools (empty when absent). */
913 > getClientTools(clientId: string): readonly ToolDefinition[] {
914 return this.toolDiff.model.getTools(clientId);
915 }
917 > /** Remove a client's tool contribution from this session. */
918 > removeClientTools(clientId: string): void {
919 this.toolDiff.model.removeClient(clientId);
920 }
922 > /** Remove a client's customization contribution from this session. */
923 > removeClientCustomizations(clientId: string): void {
924 this.clientCustomizationsDiff.model.removeClient(clientId);
925 }
927 > /**
928 > * Resolve a parked client-tool MCP handler with the workbench-supplied
929 > * result. Returns `true` if a matching deferred was found and settled.
930 > * Unknown ids are a benign no-op — `agentSideEffects.ts` forwards every
931 > * `ChatToolCallComplete` envelope, so SDK-owned tool completions land
932 > * here too and must NOT throw.
933 > */
934 > completeClientToolCall(toolCallId: string, result: ToolCallResult): boolean {
935 > const converted = convertToolCallResult(result, toolCallId); claudeAgentSession.ts ×1
936 > return this._pendingClientToolCalls.respond(toolCallId, converted);
937 > }
939 > /**
940 > * Drive a yield-restart so the SDK picks up the new client-tool set
941 > * on its next user request. Public entry point for callers that need
942 > * to force a tool-only rebind; internal pre-flight goes through
943 > * {@link _rebindForSyncedState}.
944 > */
945 > async rebindForClientTools(): Promise<void> {
946 await this._rebindForSyncedState();
947 }
949 > // #endregion
950 >
951 > // #region Phase 11 — customizations / plugins
952 >
953 > /**
954 > * Merged fire-and-forget signal that this session's customization
955 > * surface changed. Fires from three sources:
956 > *
957 > * 1. Client-side writes (`adoptClientCustomizations`) — via the
958 > * {@link SessionClientCustomizationsDiff} observable wired up in the
959 > * constructor.
960 > * 2. Materialize completes — surfaces the server-side
961 > * (SDK-discovered) tier to the workbench for the first time.
962 > * 3. The send() pre-flight rebind completes — the rebuilt SDK's
963 > * resolved set may have changed.
964 > *
965 > * Drives a workbench refetch of {@link getSessionCustomizations}.
966 > * Does NOT itself trigger any SDK action — the dirty bit on
967 > * {@link SessionClientCustomizationsDiff} drives plugin rebinds,
968 > * and only flips on client-side writes.
969 > */
970 > private readonly _onDidCustomizationsChange = this._register(new Emitter<void>());
971 > readonly onDidCustomizationsChange: Event<void> = this._onDidCustomizationsChange.event;
972 >
973 > /**
974 > * Adopt the result of a global {@link IAgentPluginManager.syncCustomizations}
975 > * pass (**client-pushed** path). The agent owns the manager (it's
976 > * a process-wide singleton with a shared on-disk cache) and pushes
977 > * the resulting snapshot down here. Flips the client-side dirty bit
978 > * so the next {@link send} pre-flight reloads SDK plugins.
979 > */
980 > adoptClientCustomizations(clientId: string, synced: readonly ISyncedCustomization[]): void {
981 > this.clientCustomizationsDiff.model.setSyncedCustomizations(clientId, synced); claudeAgent.ts ×2
982 > }
984 > /**
985 > * Snapshot of the **client-pushed** customizations on this session.
986 > * Does NOT include server-side (SDK-discovered) entries — use
987 > * {@link getSessionCustomizations} for the merged view.
988 > */
989 > getClientCustomizations(): readonly ISyncedCustomization[] {
990 return this.clientCustomizationsDiff.model.state.get().synced;
991 }
993 > /** Snapshot of the last {@link getSessionCustomizations} result, read by {@link _enrichSignalWithMcpContributor}. */
994 > private _lastCustomizations: readonly Customization[] = [];
995 >
996 > /**
997 > * Project the union of (a) **client-pushed** customizations and
998 > * (b) the **server-side** (SDK-discovered) view (commands / agents
999 > * / MCP servers, including those the SDK discovered on its own
1000 > * from `~/.claude/**`) onto the protocol's
1001 > * {@link Customization} surface, with reducer-backed enablement
1002 > * applied to client-pushed entries.
1003 > *
1004 > * Pre-materialize sessions return only the client-pushed projection
1005 > * — the SDK side has no Query to query yet. A failure to read the
1006 > * SDK snapshot is warn-logged and the client-pushed projection is
1007 > * still returned, so a transient SDK hiccup doesn't blank the UI.
1008 > */
1009 > async getSessionCustomizations(): Promise<readonly Customization[]> {
1010 > const { synced } = this.clientCustomizationsDiff.model.state.get(); claudeAgentSession.ts ×3
1011 > const userHome = this._environmentService.userHome;
1012 > const [discovered, rules, mcpServers, hooks, nativePlugins] = await Promise.all([
1013 > scanClaudeDiskCustomizations(this.workingDirectory, userHome, this._fileService),
1014 > scanClaudeRules(this.workingDirectory, userHome, this._fileService),
1015 > scanClaudeMcpServers(this.workingDirectory, userHome, this._fileService),
1016 > scanClaudeHooks(this.workingDirectory, userHome, this._fileService),
1017 > scanClaudeNativePlugins(this.workingDirectory, userHome, this._fileService, this._logService),
1018 > ]);
1019 >
1020 > // Post-materialize, the live SDK snapshot filters the disk set down to
1021 > // what the session actually loaded (and surfaces SDK-only items as
1022 > // non-editable). Pre-materialize there is no Query, so the full disk
1023 > // set is shown. A transient SDK read failure leaves `sdk` undefined,
1024 > // falling back to the unfiltered disk set rather than blanking the UI.
1025 > let sdk: ISdkResolvedCustomizations | undefined;
1026 > if (this._pipeline) {
1028 > sdk = await this._pipeline.snapshotResolvedCustomizations();
1029 > } catch (err) {
1030 > this._logService.warn(`[Claude:${this.sessionId}] snapshotResolvedCustomizations failed`, err); claudeAgentSession.ts ×1
1031 > }
1034 > // `buildDiscoveredCustomizations` also folds in the read-only "Built-in"
1035 > // surfacing (curated pre-materialize, SDK-derived post-materialize) for
1036 > // both agents and skills, so the SDK-vs-curated decision lives in one place.
1037 > const discoveredCustomizations = buildDiscoveredCustomizations([...discovered, ...rules], mcpServers, hooks, nativePlugins, this.workingDirectory, userHome, sdk);
1038 >
1039 > // Final projection: the client-pushed tier first, then the discovered
1040 > // tier, with session MCP enablement applied to both.
1041 > const state = this._sessionCustomizations;
1042 > const desiredById = new Map(state.map(customization => [customization.id, customization.enabled]));
1043 > const result: Customization[] = synced.map(item => ({
1044 > ...item.customization, claudeAgentSession.ts ×1
1045 > enabled: desiredById.get(item.customization.id) ?? item.customization.enabled,
1047 > result.push(...discoveredCustomizations);
1048 > // Cache for the MCP-contributor signal enrichment (see
1049 > // {@link _enrichSignalWithMcpContributor}).
1050 > const projected = applyMcpServerEnablement(result, state);
1051 > this._lastCustomizations = projected;
1052 > return projected;
1053 > }
1055 > private async _reconcileMcpServerEnablement(): Promise<void> {
1056 > const pipeline = this._requirePipeline(); claudeAgentSession.ts ×5
1057 > const state = this._sessionCustomizations;
1058 > const desired = new Map(getEffectiveMcpServerCustomizations(state).map(server => [server.name, server.enabled]));
1059 > if (desired.size === 0) {
1060 > return; claudeAgentSession.ts ×1
1061 > }
1063 > if (!await pipeline.reconcileMcpServerEnablement(desired)) {
1064 throw new Error(`Claude SDK cannot reconcile MCP server enablement`);
1065 }
1068 > private _desiredClientPluginPaths(): readonly URI[] {
1069 > const state = this._sessionCustomizations; claudeAgentSession.ts ×14
1070 > const desiredById = new Map(state.map(customization => [customization.id, customization.enabled]));
1071 > const paths: URI[] = [];
1072 > for (const synced of this.clientCustomizationsDiff.model.state.get().synced) {
1073 > if (synced.pluginDir && (desiredById.get(synced.customization.id) ?? synced.customization.enabled) !== false) { claudeAgentSession.ts ×1
1074 > paths.push(synced.pluginDir);
1075 > }
1076 > }
1077 > return paths; claudeAgentSession.ts ×14
1078 > }
1080 > async startMcpServer(id: string): Promise<void> {
1081 const serverName = await this._resolveMcpServerName(id);
1082 if (!serverName) {
1083 this._logService.warn(`[Claude:${this.sessionId}] Cannot start unknown MCP server customization ${id}`);
1084 return;
1085 }
1086 const handled = await this._requirePipeline().startMcpServer(serverName);
1087 if (!handled) {
1088 await this._rebindForSyncedState();
1089 }
1090 this._onDidCustomizationsChange.fire();
1091 }
1093 > async stopMcpServer(id: string): Promise<void> {
1094 const serverName = await this._resolveMcpServerName(id);
1095 if (!serverName) {
1096 this._logService.warn(`[Claude:${this.sessionId}] Cannot stop unknown MCP server customization ${id}`);
1097 return;
1098 }
1099 const handled = await this._requirePipeline().stopMcpServer(serverName);
1100 if (!handled) {
1101 this._logService.warn(`[Claude:${this.sessionId}] MCP server stop is not supported by the current SDK`);
1102 return;
1103 }
1104 this._onDidCustomizationsChange.fire();
1105 }
1107 > private async _resolveMcpServerName(id: string): Promise<string | undefined> {
1108 return findMcpServerName(this._lastCustomizations, id) ?? findMcpServerName(await this.getSessionCustomizations(), id);
1109 }
1111 > // #endregion
1112 >
1113 > override dispose(): void {
1114 > // Resolve parked deferreds before tearing the pipeline down so the claudeAgentSession.ts ×4
1115 > // SDK's canUseTool callback unwinds with a deny and the loop exits.
1116 > this._pendingPermissions.denyAll(false);
1117 > this._pendingUserInputs.denyAll({ response: ChatInputResponseKind.Cancel });
1118 > this._pendingClientToolCalls.rejectAll(new CancellationError());
1119 > super.dispose();
1120 > }