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

2312 LOC · 2145 covered · 167 uncovered · 412 ranges · 357 concepts · 158 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 { CCAModel } from '@vscode/copilot-api';
7 > import type { ModelInfo, OnElicitation, Options, SDKSessionInfo, SDKUserMessage } from '@anthropic-ai/claude-agent-sdk';
8 > import type { CallToolResult } from '@modelcontextprotocol/sdk/types.js';
9 > import { SequencerByKey } from '../../../../base/common/async.js';
10 > import { CancellationToken } from '../../../../base/common/cancellation.js';
11 > import { CancellationError } from '../../../../base/common/errors.js';
12 > import { Emitter, Event } from '../../../../base/common/event.js';
13 > import { Disposable, DisposableMap } from '../../../../base/common/lifecycle.js';
14 > import { IObservable, observableValue } from '../../../../base/common/observable.js';
15 > import { URI } from '../../../../base/common/uri.js';
16 > import { generateUuid } from '../../../../base/common/uuid.js';
17 > import { localize } from '../../../../nls.js';
18 > import { IInstantiationService } from '../../../instantiation/common/instantiation.js';
19 > import { INativeEnvironmentService } from '../../../environment/common/environment.js';
20 > import { ILogService } from '../../../log/common/log.js';
21 > import { IProductService } from '../../../product/common/productService.js';
22 > import { IAgentPluginManager, ISyncedCustomization } from '../../common/agentPluginManager.js';
23 > import { AgentSessionEntry, buildSideChatSourceContext, decodeProviderData, encodeProviderData, prepareSideChatPrompt, stripSideChatContext, type IPersistedChat } from '../agentPeerChats.js';
24 > import { AgentHostConfigKey, agentHostCustomizationConfigSchema } from '../../common/agentHostCustomizationConfig.js';
25 > import { createSchema, platformSessionSchema, schemaProperty } from '../../common/agentHostSchema.js';
26 > import { ClaudePermissionMode, ClaudeSessionConfigKey, narrowClaudePermissionMode } from '../../common/claudeSessionConfigKeys.js';
27 > import { createClaudeThinkingLevelSchema, isClaudeEffortLevel } from '../../common/claudeModelConfig.js';
28 > import { SessionConfigKey } from '../../common/sessionConfigKeys.js';
29 > import { AgentProvider, AgentSession, AgentSignal, CLAUDE_AGENT_PROVIDER_ID, IActiveClient, IAgent, IAgentChatDataChange, IAgentChats, IAgentCreateChatForkSource, IAgentCreateChatOptions, IAgentCreateChatResult, IAgentCreateSessionConfig, IAgentCreateSessionResult, IAgentDescriptor, IAgentMaterializeSessionEvent, IAgentModelInfo, IAgentResolveSessionConfigParams, IAgentSessionConfigCompletionsParams, IAgentSessionMetadata, IAgentSessionProjectInfo, IAgentSpawnChatEvent, SubagentChatSignal } from '../../common/agentService.js';
30 > import { ensureWorkspacelessScratchDir } from '../workspacelessScratchDir.js';
31 > import { ActionType, AuthRequiredReason, type AuthRequiredParams } from '../../common/state/sessionActions.js';
32 > import type { ResolveSessionConfigResult, SessionConfigCompletionsResult } from '../../common/state/protocol/commands.js';
33 > import { AHP_AUTH_REQUIRED, ProtocolError } from '../../common/state/sessionProtocol.js';
34 > import { PolicyState, ProtectedResourceMetadata, type AgentSelection, type ModelSelection, type ToolDefinition } from '../../common/state/protocol/state.js';
35 > import { isSubagentSession, parseSubagentSessionUri, buildDefaultChatUri, parseChatUri, parseRequiredSessionUriFromChatUri, isDefaultChatUri, ChatInputResponseKind, type ChatState, type ClientPluginCustomization, type Customization, type MessageAttachment, type PendingMessage, type ChatInputAnswer, type ToolCallResult, type Turn } from '../../common/state/sessionState.js';
36 > import { IAgentConfigurationService } from '../agentConfigurationService.js';
37 > import { IAgentHostGitHubEndpointService } from '../agentHostGitHubEndpointService.js';
38 > import { IAgentHostGitService } from '../../common/agentHostGitService.js';
39 > import { PendingRequestRegistry } from '../../common/pendingRequestRegistry.js';
40 > import { projectFromCopilotContext } from '../copilot/copilotGitProject.js';
41 > import { ICopilotApiService } from '../shared/copilotApiService.js';
42 > import { IClaudeAgentSdkService } from './claudeAgentSdkService.js';
43 > import { buildModelEnumerationOptions } from './claudeSdkOptions.js';
44 > import { mapSessionMessagesToTurns, resolveForkAnchorUuid } from './claudeReplayMapper.js';
45 > import { getSubagentTranscript } from './claudeSubagentResolver.js';
46 > import { ClaudeAgentSession } from './claudeAgentSession.js';
47 > import { handleCanUseTool } from './claudeCanUseTool.js';
48 > import { handleElicitation } from './claudeElicitationBridge.js';
49 > import type { IAgentServerToolHost } from '../../common/agentServerTools.js';
50 > import { createPricingMetaFromBilling, normalizeCAPIBilling } from '../../common/agentModelPricing.js';
51 > import { tryParseClaudeModelId } from './claudeModelId.js';
52 > import { resolvePromptToContentBlocks } from './claudePromptResolver.js';
53 > import { IClaudeProxyHandle, IClaudeProxyService, type ClaudeTransport } from './claudeProxyService.js';
54 > import { readClaudePermissionMode } from './claudeSessionPermissionMode.js';
55 > import { ClaudeSessionMetadataStore, IClaudeSessionOverlay } from './claudeSessionMetadataStore.js';
56 > import { AgentHostStateManager, IAgentHostStateManager } from '../agentHostStateManager.js';
57 >
58 > const USER_AGENT_PREFIX = 'vscode_claude_code';
59 >
60 > /**
61 > * Returns true if `m` is a Claude-family model that should be advertised
62 > * to clients picking a model for the Claude provider.
63 > *
64 > * Combines the same surface checks the extension uses (vendor, picker
65 > * eligibility, tool-call support, `/v1/messages` endpoint) with a parse
66 > * of the model id via {@link tryParseClaudeModelId}, which excludes
67 > * synthetic ids like `auto` that aren't real Claude endpoints.
68 > */
69 > function isClaudeModel(m: CCAModel): boolean { claudeAgent.ts ×3
70 > return (
71 > m.vendor === 'Anthropic' &&
72 > !!m.supported_endpoints?.includes('/v1/messages') &&
73 > !!m.model_picker_enabled &&
74 > !!m.capabilities?.supports?.tool_calls &&
75 > tryParseClaudeModelId(m.id) !== undefined
76 > );
77 > }
79 > /**
80 > * Augments the published `@vscode/copilot-api` `CCAModelSupports` with the
81 > * per-model `adaptive_thinking` / `reasoning_effort` fields the runtime
82 > * CAPI `/models` payload already carries but the SDK type doesn't yet
83 > * declare. Tracked at microsoft/vscode-capi#85; remove this when the SDK
84 > * catches up. Mirror of the same pattern at
85 > * `extensions/copilot/src/platform/endpoint/common/endpointProvider.ts`
86 > * (its locally-declared `IChatModelCapabilities`).
87 > */
88 > interface IClaudeModelSupports {
89 > readonly adaptive_thinking?: boolean;
90 > readonly reasoning_effort?: readonly string[];
91 > }
92 >
93 > /**
94 > * Project a {@link CCAModel} into the agent host's
95 > * {@link IAgentModelInfo} surface. The returned `provider` is the
96 > * agent's id (`'claude'`) — clients filter the root state's model list
97 > * by provider, so this must match {@link ClaudeAgent.id}, NOT the
98 > * upstream `vendor: 'Anthropic'` field.
99 > */
100 > function toAgentModelInfo(m: CCAModel, provider: AgentProvider): IAgentModelInfo { claudeAgent.ts ×3
101 > const supports = m.capabilities?.supports;
102 > const supportedEfforts = ((supports as IClaudeModelSupports | undefined)?.reasoning_effort ?? []).filter(isClaudeEffortLevel);
103 > const configSchema = createClaudeThinkingLevelSchema(supportedEfforts);
104 > const policyState = m.policy?.state as PolicyState | undefined;
105 > const billing = normalizeCAPIBilling(m.billing);
106 > // priceCategory may appear as a top-level model field depending on the CAPI version.
107 > const priceCategory = typeof m.model_picker_price_category === 'string'
108 ? m.model_picker_price_category
109 > : undefined; claudeAgent.ts ×3
110 > return {
111 > provider,
112 > // CAPI/endpoint format, dotted version (e.g. `claude-haiku-4.5`) — the
113 > // canonical id through `ModelSelection.id`. Convert to SDK format at SDK
114 > // seams via `toSdkModelId`.
115 > id: m.id,
116 > name: m.name,
117 > maxContextWindow: m.capabilities?.limits?.max_context_window_tokens,
118 > maxOutputTokens: m.capabilities?.limits?.max_output_tokens,
119 > maxPromptTokens: m.capabilities?.limits?.max_prompt_tokens,
120 > supportsVision: !!supports?.vision,
121 > ...(configSchema ? { configSchema } : {}),
122 > ...(policyState ? { policyState } : {}),
123 > _meta: createPricingMetaFromBilling(billing, priceCategory),
124 > };
125 > }
127 > /**
128 > * Project an SDK {@link ModelInfo} into the agent host's
129 > * {@link IAgentModelInfo} surface for the native (BYO-Anthropic) transport.
130 > * Carries NO commercial metadata (no `policyState`, no pricing `_meta`) —
131 > * those are Copilot/CAPI concepts. Reuses the shared effort-schema helpers so
132 > * the thinking-level picker matches the proxied projection.
133 > */
134 > export function fromSdkModelInfo(m: ModelInfo, provider: AgentProvider): IAgentModelInfo {
135 > const supportedEfforts = (m.supportedEffortLevels ?? []).filter(isClaudeEffortLevel); claudeAgent.ts ×1
136 > const configSchema = createClaudeThinkingLevelSchema(supportedEfforts);
137 > return {
138 > provider,
139 > // SDK-canonical id (`m.value`, e.g. `claude-sonnet-4-5-20250929`). Native
140 > // ids are SDK format end to end; `toSdkModelId` is identity at this seam.
141 > id: m.value,
142 > name: m.displayName,
143 > supportsVision: false,
144 > ...(configSchema ? { configSchema } : {}),
145 > };
146 > }
148 > // Single source of truth for narrowing an arbitrary runtime value to
149 > // the closed `ClaudePermissionMode` union now lives in
150 > // `../../common/claudeSessionConfigKeys.ts` so it can be shared by
151 > // `ClaudeAgent`, `ClaudeSessionMetadataStore`, and any other consumer
152 > // that needs the same narrowing semantics. The live per-session read
153 > // helper lives in `./claudeSessionPermissionMode.ts` so the session
154 > // and materializer can read directly without threading callbacks
155 > // through the agent.
156 >
157 > // Provisional session state is hosted directly on {@link ClaudeAgentSession}
158 > // (pre-materialize fields: project, abortController, provisionalModel,
159 > // provisionalConfig). The legacy `IClaudeProvisionalSession` map shape
160 > // was retired in Phase 10.5 Step 3a.
161 >
162 > /**
163 > * Claude active-client handle. Tools read/write through the live session's
164 > * {@link SessionClientToolsModel}; customization assignment kicks off the
165 > * agent's async sync (via the provided closure). The handle caches the last
166 > * assigned customization inputs so the getter reflects what the client most
167 > * recently published.
168 > */
169 > class ClaudeActiveClientHandle implements IActiveClient {
170 > private _customizations: readonly ClientPluginCustomization[] = [];
171 >
172 > constructor(
173 > readonly clientId: string, claudeAgent.ts ×3
174 > readonly displayName: string | undefined,
175 > private readonly _getTools: () => readonly ToolDefinition[],
176 > private readonly _setTools: (tools: readonly ToolDefinition[]) => void,
177 > private readonly _syncCustomizations: (customizations: readonly ClientPluginCustomization[]) => void,
178 > ) { }
180 > get tools(): readonly ToolDefinition[] {
181 return this._getTools();
182 }
183 > set tools(tools: readonly ToolDefinition[]) { claudeAgent.ts ×91
184 > this._setTools(tools); claudeAgent.ts ×3
185 > }
187 > get customizations(): readonly ClientPluginCustomization[] {
188 return this._customizations;
189 }
190 > set customizations(customizations: readonly ClientPluginCustomization[]) { claudeAgent.ts ×91
191 this._customizations = customizations;
192 this._syncCustomizations(customizations);
193 }
195 >
196 > /**
197 > * Phase 4 skeleton {@link IAgent} provider for the Claude Agent SDK.
198 > *
199 > * What is implemented:
200 > * - Provider id, descriptor, and protected resources surface so root
201 > * state advertises Claude alongside Copilot CLI.
202 > * - GitHub token capture via {@link authenticate} and lazy acquisition
203 > * of an {@link IClaudeProxyHandle} from {@link IClaudeProxyService}.
204 > * - {@link models} observable derived from {@link ICopilotApiService.models}
205 > * filtered to Claude-family entries via {@link isClaudeModel}.
206 > *
207 > * What is stubbed:
208 > * - All other {@link IAgent} methods throw `Error('TODO: Phase N')`. The
209 > * exact phase numbers reference the roadmap in
210 > * `src/vs/platform/agentHost/node/claude/roadmap.md`.
211 > *
212 > * The class is intentionally lean: each subsequent phase adds one
213 > * concern (sessions, sendMessage, permissions, etc.) so the surface area
214 > * of any single review stays small.
215 > */
216 > export class ClaudeAgent extends Disposable implements IAgent {
217 > readonly id: AgentProvider = CLAUDE_AGENT_PROVIDER_ID;
218 >
219 > private readonly _onDidSessionProgress = this._register(new Emitter<AgentSignal>());
220 > readonly onDidSessionProgress = this._onDidSessionProgress.event;
221 >
222 > private readonly _onDidCustomizationsChange = this._register(new Emitter<void>());
223 > readonly onDidCustomizationsChange = this._onDidCustomizationsChange.event;
224 >
225 > private readonly _onDidRequireAuth = this._register(new Emitter<Omit<AuthRequiredParams, 'channel'>>());
226 > readonly onDidRequireAuth = this._onDidRequireAuth.event;
227 >
228 > private readonly _models = observableValue<readonly IAgentModelInfo[]>(this, []);
229 > readonly models: IObservable<readonly IAgentModelInfo[]> = this._models;
230 > /**
231 > * In-flight {@link refreshModels} call, so overlapping triggers (an auth
232 > * token change, a transport flip, or a periodic tick from the host's
233 > * model-refresh scheduler) collapse into a single enumeration instead of
234 > * racing each other's writes to {@link _models}.
235 > */
236 > private _modelRefreshInFlight: Promise<void> | undefined;
237 >
238 > private _githubToken: string | undefined;
239 > private _proxyHandle: IClaudeProxyHandle | undefined;
240 > private _serverToolHost: IAgentServerToolHost | undefined;
241 >
242 > /**
243 > * Resolved host transport mode (Phase 19). `proxy` (default) routes through
244 > * the Copilot-CAPI proxy; `native` talks to Anthropic directly on the user's
245 > * own credentials. Resolved once from the `ClaudeUseCopilotProxy` root
246 > * config value and kept current by an `onDidRootConfigChange` subscription.
247 > * Config changes affect FUTURE sessions only — never an in-flight subprocess.
248 > */
249 > private _transportMode: 'proxy' | 'native' = 'proxy';
250 >
251 > /**
252 > * Memoized teardown promise. Set on the first call to {@link shutdown},
253 > * returned by every subsequent call. Mirrors `CopilotAgent.shutdown`
254 > * at copilotAgent.ts:1246. Phase 5 has no async work so the race
255 > * is benign, but the contract is locked now so Phase 6's real
256 > * async teardown (Query.interrupt(), in-flight metadata writes)
257 > * cannot regress.
258 > */
259 > private _shutdownPromise: Promise<void> | undefined;
260 >
261 > /**
262 > * Live in-memory session entries, keyed by raw session id (not URI).
263 > * Each {@link ClaudeSessionEntry} owns its {@link ClaudeAgentSession} plus
264 > * any per-session disposables registered against it (e.g. the forward
265 > * subscription to the session's `onDidSessionProgress` event). Disposing
266 > * the map disposes every entry, which in turn disposes everything
267 > * registered to it — no parallel maps, no implicit lockstep invariants.
268 > * {@link createSession} is the only writer; {@link disposeSession} and
269 > * {@link shutdown} remove via {@link DisposableMap.deleteAndDispose}, which
270 > * is idempotent if the key has already been removed.
271 > */
272 > private readonly _sessions = this._register(new DisposableMap<string, ClaudeSessionEntry>());
273 >
274 > /**
275 > * Live, in-memory peer-chat backings keyed by the chat's `ahp-chat` channel
276 > * URI string. Populated by {@link createChat} on creation and by
277 > * {@link materializeChat} on session restore (decoding the opaque
278 > * `providerData` the orchestrator persisted). This is the live source of the
279 > * `chatUri → sdkSessionId` mapping.
280 > */
281 > private readonly _chatBackings = new Map<string, IPersistedChat>();
282 >
283 > /**
284 > * Fires when a peer chat's opaque `providerData` blob changes after creation
285 > * (e.g. a per-chat model switch) so the orchestrator can re-persist the
286 > * refreshed token. See {@link IAgent.onDidChangeChatData}.
287 > */
288 > private readonly _onDidChangeChatData = this._register(new Emitter<IAgentChatDataChange>());
289 > readonly onDidChangeChatData: Event<IAgentChatDataChange> = this._onDidChangeChatData.event;
290 >
291 > /**
292 > * Membership channel for chats the agent spawns itself — today the
293 > * sub-agent chats delegated by a `Task`/`Agent` tool call (and, when the
294 > * harness gains them, Claude Teams teammates). Derived from the
295 > * `subagent_started` / `subagent_completed` signals that already flow on
296 > * {@link onDidSessionProgress}, so the orchestrator records the spawn edge
297 > * on the unified chat catalog. See {@link IAgent.onDidSpawnChat}.
298 > */
299 > private readonly _onDidSpawnChat = this._register(new Emitter<IAgentSpawnChatEvent>());
300 > readonly onDidSpawnChat: Event<IAgentSpawnChatEvent> = this._onDidSpawnChat.event;
301 >
302 > /** Stable active-client handles, keyed by `${sessionId}\0${clientId}`. */
303 > private readonly _activeClientHandles = new Map<string, ClaudeActiveClientHandle>();
304 >
305 > /**
306 > * Phase 6: fired once per session when {@link _materializeProvisional}
307 > * promotes a provisional record into a real {@link ClaudeAgentSession}.
308 > * The {@link IAgentService} subscribes via the platform contract
309 > * (`agentService.ts:412`) to dispatch the deferred `sessionAdded`
310 > * notification — observers don't see the session in their list until
311 > * persistence has settled.
312 > */
313 > private readonly _onDidMaterializeSession = this._register(new Emitter<IAgentMaterializeSessionEvent>());
314 > readonly onDidMaterializeSession = this._onDidMaterializeSession.event;
315 >
316 > /**
317 > * Per-session-id serializer shared by {@link disposeSession} and
318 > * {@link shutdown}. Phase 5 dispose work is synchronous, so the queued
319 > * tasks resolve immediately and the sequencer is mostly a no-op. The
320 > * routing is locked in now (per plan section 3.3.4 / section 3.3.6) so
321 > * Phase 6's real async teardown (`Query.interrupt()`, in-flight metadata
322 > * writes) inherits per-session serialization for free — a concurrent
323 > * `disposeSession(uri)` already in flight is awaited before
324 > * `shutdown()` reuses the same key.
325 > */
326 > private readonly _disposeSequencer = new SequencerByKey<string>();
327 >
328 > /**
329 > * Phase 6: per-session-id serializer for {@link sendMessage}. Held
330 > * across both {@link _materializeProvisional} AND `entry.send()` so
331 > * two concurrent first-message calls on the same session collapse
332 > * into one materialize plus two ordered sends. Separate from
333 > * {@link _disposeSequencer} so a `disposeSession` racing a first send
334 > * still serializes against in-flight teardown without deadlocking
335 > * inside the send sequencer (different key spaces, single
336 > * race-resolution lattice via the underlying `AbortController`).
337 > */
338 > private readonly _sessionSequencer = new SequencerByKey<string>();
339 >
340 > private readonly _metadataStore: ClaudeSessionMetadataStore;
341 >
342 > /**
343 > * Unified per-session lookup. Returns the session's default chat whether it
344 > * is still provisional or already materialized; callers branch on
345 > * {@link ClaudeAgentSession.isPipelineReady} when behavior differs.
346 > */
347 > private _findAnySession(sessionId: string): ClaudeAgentSession | undefined {
348 > return this._sessions.get(sessionId)?.defaultChat;
349 > }
350 >
351 > /**
352 > * Resolve the live {@link ClaudeAgentSession} for a chat — the session's
353 > * default (main) chat, or an additional peer chat addressed by its
354 > * `ahp-chat` channel URI — via a single uniform lookup in the owning
355 > * session's chat map. Returns `undefined` when the session (or the chat) is
356 > * not in memory.
357 > */
358 > private _findChat(session: URI, chat: URI | undefined): ClaudeAgentSession | undefined {
359 > const entry = this._sessions.get(AgentSession.id(session)); claudeAgent.ts ×6
360 > if (!entry) {
361 return undefined;
362 }
363 > return entry.getChat((chat ?? URI.parse(buildDefaultChatUri(session))).toString()); claudeAgent.ts ×6
364 > }
366 > private _getChatContext(chatOrSession: URI): { session: URI; sessionId: string; chatKey: string; target: ClaudeAgentSession | undefined; isPeerChat: boolean } {
367 > // Accept either a chat channel URI or a bare session URI: per the AHP claudeAgent.ts ×1
368 > // convention the default chat's URI equals the session URI, so callers
369 > // that address the default chat by the session URI resolve here in one
370 > // place rather than each operational method re-deriving it.
371 > const chat = parseChatUri(chatOrSession) ? chatOrSession : URI.parse(buildDefaultChatUri(chatOrSession));
372 > const session = URI.parse(parseRequiredSessionUriFromChatUri(chat));
373 > const sessionId = AgentSession.id(session);
374 > const chatKey = chat.toString();
375 > const resolved = this._sessions.get(sessionId)?.resolveChat(chatKey);
376 > return {
377 > session,
378 > sessionId,
379 > chatKey,
380 > target: resolved?.chatSession,
381 > isPeerChat: resolved ? !resolved.isDefault : chatKey !== buildDefaultChatUri(session),
382 > };
383 > }
385 > /**
386 > * Resolve a live {@link ClaudeAgentSession} by its SDK chat id,
387 > * searching every session entry's default chat and its peer chats. Used by
388 > * SDK-id-addressed callbacks — proxy credit reports and the `canUseTool`
389 > * permission bridge — which carry the SDK session id, not the chat URI.
390 > */
391 > private _findSessionBySdkId(sdkSessionId: string): ClaudeAgentSession | undefined {
392 > for (const entry of this._sessions.values()) { claudeAgent.ts ×2
393 > for (const chat of entry.allChatSessions()) {
394 > if (chat.sessionId === sdkSessionId) {
395 > return chat;
396 > }
397 > }
398 }
399 return undefined;
402 > /** Wrap a {@link ClaudeAgentSession} in a chat-leaf entry and forward its events. */
403 > private _wireEntry(session: ClaudeAgentSession): ClaudeSessionEntry {
404 > const entry = new ClaudeSessionEntry(session); claudeAgent.ts ×3
405 > entry.addDisposable(session.onDidSessionProgress(signal => {
406 > this._onDidSessionProgress.fire(signal); claudeAgent.ts ×3
407 > this._emitSpawnedChatEvents(signal);
408 > })); claudeAgent.ts ×3
409 > entry.addDisposable(session.onDidCustomizationsChange(() => this._onDidCustomizationsChange.fire()));
410 > return entry;
411 > }
413 > /**
414 > * Create a session container seeding its default (main) chat as the first
415 > * entry in the uniform chat map, keyed by the session's default-chat URI.
416 > */
417 > private _seedSessionEntry(sessionId: string, session: URI, mainSession: ClaudeAgentSession): ClaudeSessionEntry {
418 > const container = new ClaudeSessionEntry(); claudeAgent.ts ×3
419 > container.setDefaultChat(buildDefaultChatUri(session), this._wireEntry(mainSession));
420 > this._sessions.set(sessionId, container);
421 > return container;
422 > }
424 > /**
425 > * Bridges the agent's `subagent_started` signal onto the
426 > * {@link onDidSpawnChat} membership channel. The signals are still forwarded
427 > * verbatim on {@link onDidSessionProgress} (the orchestrator's
428 > * `AgentSideEffects` keeps driving the sub-agent turn + parent tool-call
429 > * content); this event only mirrors the spawn into the unified chat catalog.
430 > * A completed subagent chat stays live and subscribable (it is removed only
431 > * on session teardown), so there is no corresponding end event. The catalog
432 > * add is idempotent so the overlap with the orchestrator's own membership
433 > * sequencing is safe.
434 > */
435 > private _emitSpawnedChatEvents(signal: AgentSignal): void {
436 > const spawn = SubagentChatSignal.toSpawnEvent(signal); claudeAgent.ts ×3
437 > if (spawn) {
438 > this._onDidSpawnChat.fire(spawn); claudeAgent.ts ×1
439 > }
442 > constructor(
443 > @ILogService private readonly _logService: ILogService, claudeAgent.ts ×7
444 > @ICopilotApiService private readonly _copilotApiService: ICopilotApiService,
445 > @IClaudeProxyService private readonly _claudeProxyService: IClaudeProxyService,
446 > @IClaudeAgentSdkService private readonly _sdkService: IClaudeAgentSdkService,
447 > @IAgentHostStateManager private readonly _stateManager: AgentHostStateManager,
448 > @IAgentHostGitService private readonly _gitService: IAgentHostGitService,
449 > @IAgentConfigurationService private readonly _configurationService: IAgentConfigurationService,
450 > @IAgentHostGitHubEndpointService private readonly _gitHubEndpointService: IAgentHostGitHubEndpointService,
451 > @IInstantiationService private readonly _instantiationService: IInstantiationService,
452 > @IAgentPluginManager private readonly _pluginManager: IAgentPluginManager,
453 > @IProductService private readonly _productService: IProductService,
454 > @INativeEnvironmentService private readonly _environmentService: INativeEnvironmentService,
455 > ) {
456 > super();
457 > this._metadataStore = _instantiationService.createInstance(ClaudeSessionMetadataStore, this.id);
458 > // CAPI reports each request's billed credits via the proxy (the SDK
459 > // strips `copilot_usage` from its `result`). Route every report to
460 > // the originating session by the session id the proxy decoded from
461 > // the Bearer token, so the session can surface real per-turn credits.
462 > this._register(this._claudeProxyService.onDidReportCredits(e => {
463 > this._findSessionBySdkId(e.sessionId)?.recordTurnCredits(e.totalNanoAiu); claudeAgentSession.ts ×2
464 > })); claudeAgent.ts ×7
465 >
466 > // Phase 19: resolve the transport mode now and re-resolve reactively.
467 > // A flip only affects sessions materialized afterwards; in-flight
468 > // subprocesses keep their original transport. When native, kick off an
469 > // initial model refresh since no GitHub auth (which would otherwise
470 > // trigger it) is required.
471 > this._transportMode = this._resolveTransportMode();
472 > this._register(this._configurationService.onDidRootConfigChange(() => {
473 > const next = this._resolveTransportMode(); claudeAgent.ts ×2
474 > if (next !== this._transportMode) {
475 > this._transportMode = next;
476 > // Proxy and native enumerate different catalogs. Do not retain
477 > // models from the previous transport if the replacement cannot
478 > // enumerate its own list.
479 > this._models.set([], undefined);
480 > void this._startModelRefresh();
481 > // Flipping into proxy makes GitHub Copilot auth newly required.
482 > // If no proxy handle was ever established, proactively ask the
483 > // client to authenticate rather than waiting for the next command
484 > // to fail with `AHP_AUTH_REQUIRED`. A handle persists across a
485 > // proxy→native→proxy round-trip (cleared only on dispose), so this
486 > // fires only when a credential is genuinely missing.
487 > if (next === 'proxy' && !this._proxyHandle) {
488 > this._onDidRequireAuth.fire({ claudeAgent.ts ×1
489 > resource: this._gitHubEndpointService.getCopilotResource().resource,
490 > reason: AuthRequiredReason.Required,
491 > });
492 > }
494 > })); claudeAgent.ts ×7
495 > if (this._transportMode === 'native') {
496 > // Only native bootstraps its model list here. Proxy mode fetches claudeAgent.ts ×1
497 > // models from CAPI, which needs the GitHub token — so its first
498 > // refresh is triggered by `authenticate()` once that token arrives
499 > // (a refresh now would just hit the no-token early-return). Native
500 > // needs no GitHub auth and nothing else triggers a refresh, so we
501 > // kick off the initial enumeration ourselves. (Transport *flips*
502 > // after construction are covered by the `onDidRootConfigChange`
503 > // subscription above.) `queueMicrotask` runs it off the ctor stack.
504 > queueMicrotask(() => { void this._startModelRefresh(); });
505 > }
508 > private _resolveTransportMode(): 'proxy' | 'native' {
509 > // Defaults to proxied when the `claudeUseCopilotProxy` root value is unset. claudeAgent.ts ×7
510 > const useProxy = this._configurationService.getRootValue(agentHostCustomizationConfigSchema, AgentHostConfigKey.ClaudeUseCopilotProxy) ?? true;
511 > return useProxy ? 'proxy' : 'native';
512 > }
514 > // #region Descriptor + auth
515 >
516 > getDescriptor(): IAgentDescriptor {
517 > return { claudeAgent.ts ×1
518 > provider: this.id,
519 > displayName: localize('claudeAgent.displayName', "Claude"),
520 > description: localize('claudeAgent.description', "Claude agent backed by the Anthropic Claude Agent SDK"),
521 > capabilities: { multipleChats: { fork: true, sideChat: true } },
522 > };
523 > }
525 > getProtectedResources(): ProtectedResourceMetadata[] {
526 > // Native (BYO-Anthropic) mode needs no GitHub Copilot auth — the SDK owns claudeAgent.ts ×2
527 > // the Anthropic credential — so the required Copilot resource is dropped.
528 > // The optional repo resource is kept for git operations either way.
529 > if (this._transportMode !== 'proxy') {
530 > return [this._gitHubEndpointService.getRepoResource()]; claudeAgent.ts ×1
531 > }
532 > return [ claudeAgent.ts ×1
533 > this._gitHubEndpointService.getCopilotResource(),
534 > this._gitHubEndpointService.getRepoResource(),
535 > ];
538 > /**
539 > * Resolve the active {@link ClaudeTransport}. In native mode the transport
540 > * is always ready (the SDK owns credentials); in proxied mode a started
541 > * proxy handle is required, otherwise {@link AHP_AUTH_REQUIRED} is thrown.
542 > */
543 > private _ensureAuthenticated(): ClaudeTransport {
544 > if (this._transportMode !== 'proxy') { claudeAgent.ts ×3
545 return { kind: 'native' };
546 }
547 > const handle = this._proxyHandle; claudeAgent.ts ×3
548 > if (!handle) {
549 > throw new ProtocolError( claudeAgent.ts ×1
550 > AHP_AUTH_REQUIRED,
551 > 'Authentication is required to use Claude',
552 > this.getProtectedResources(),
553 > );
554 > }
555 > return { kind: 'proxy', handle }; claudeAgent.ts ×1
558 > async authenticate(resource: string, token: string): Promise<boolean> {
559 > if (resource === this._gitHubEndpointService.getRepoResource().resource) { claudeAgent.ts ×6
560 return true;
561 }
562 > if (resource !== this._gitHubEndpointService.getCopilotResource().resource) { claudeAgent.ts ×6
563 > return false; claudeAgent.ts ×1
564 > }
565 > // Native (BYO-Anthropic) mode needs no proxy and no GitHub token. Record claudeAgent.ts ×6
566 > // the token (harmless; lets a later flip back to proxy reuse it) but do
567 > // NOT start the proxy or treat the absence of a token as unauthenticated.
568 > if (this._transportMode !== 'proxy') {
569 > this._githubToken = token; claudeAgent.ts ×1
570 > return true;
571 > }
572 > const tokenChanged = this._githubToken !== token; claudeAgent.ts ×5
573 > if (!tokenChanged && this._proxyHandle) { claudeAgent.ts ×6
574 > this._logService.info('[Claude] Auth token unchanged'); claudeAgent.ts ×1
575 > return true;
576 > }
577 > // Acquire the new handle BEFORE committing the token or disposing claudeAgent.ts ×5
578 > // the old one. If `start()` throws, leave `_githubToken` and
579 > // `_proxyHandle` untouched so the next `authenticate()` call still
580 > // sees the token as new and retries — otherwise a transient proxy
581 > // startup failure would leave us in a "token recorded, no proxy
582 > // running" state and the retry path would short-circuit as
583 > // "unchanged" and falsely return true.
584 > //
585 > // The proxy server's refcount stays >= 1 throughout this swap
586 > // because the new handle is acquired before the old one is
587 > // disposed; {@link IClaudeProxyService} applies most-recent-token-
588 > // wins on subsequent `start()` calls.
589 > const newHandle = await this._claudeProxyService.start(token);
590 > const oldHandle = this._proxyHandle;
591 > this._proxyHandle = newHandle;
592 > this._githubToken = token;
593 > this._logService.info('[Claude] Auth token updated');
594 > oldHandle?.dispose(); claudeAgent.ts ×6
595 > if (tokenChanged) {
596 > // A different account can have different model entitlements. Do claudeAgent.ts ×1
597 > // not retain the previous token's catalog if enumeration for the
598 > // replacement token fails.
599 > this._models.set([], undefined);
600 > }
601 > void this._startModelRefresh(); claudeAgent.ts ×5
602 > return true;
605 > /**
606 > * Whether the Claude provider routes through the Copilot-CAPI proxy.
607 > * Reads the resolved {@link _transportMode} (Phase 19), which the
608 > * constructor seeds from the `ClaudeUseCopilotProxy` root config value.
609 > */
610 > private _isProxyEnabled(): boolean {
611 > return this._transportMode === 'proxy'; claudeAgent.ts ×5
612 > }
614 > /**
615 > * {@link IAgent.refreshModels}. Coalesces onto an in-flight refresh and
616 > * never rejects — {@link _refreshModels} already logs and handles failure.
617 > *
618 > * Only safe for callers with no new input to apply (the host's periodic
619 > * scheduler). Triggers that invalidate the in-flight request — a rotated
620 > * token, a transport flip — must call {@link _startModelRefresh} so they
621 > * are not answered by a refresh bound to the superseded input.
622 > */
623 > refreshModels(): Promise<void> {
624 > return this._modelRefreshInFlight ?? this._startModelRefresh(); claudeAgent.ts ×1
625 > }
627 > /**
628 > * Unconditionally begins a refresh, superseding any in-flight one as the
629 > * coalescing target. The superseded request stays harmless: its own
630 > * stale-write guard drops the result if the token or transport moved on.
631 > */
632 > private _startModelRefresh(): Promise<void> {
633 > const refresh = this._refreshModels().finally(() => { claudeAgent.ts ×5
634 > if (this._modelRefreshInFlight === refresh) {
635 > this._modelRefreshInFlight = undefined;
636 > }
637 > });
638 > this._modelRefreshInFlight = refresh;
639 > return refresh;
640 > }
642 > private async _refreshModels(): Promise<void> {
643 > const proxyAtStart = this._isProxyEnabled(); claudeAgent.ts ×5
644 > const tokenAtStart = this._githubToken;
645 > if (proxyAtStart && !tokenAtStart) {
646 > this._models.set([], undefined); claudeAgent.ts ×1
647 > return;
648 > }
649 > try { claudeAgent.ts ×2
650 > const filtered = proxyAtStart
651 > ? await this._fetchProxyModels(tokenAtStart!) claudeAgent.ts ×5
652 > : await this._fetchNativeModels(); claudeAgent.ts ×2
653 > // Stale-write guard: bail if the transport flipped, or (proxy) the
654 > // token rotated, while we were awaiting — a newer refresh already
655 > // published the right list.
656 > if (this._isProxyEnabled() !== proxyAtStart || (proxyAtStart && this._githubToken !== tokenAtStart)) { claudeAgent.ts ×5
657 > return; claudeAgent.ts ×1
658 > }
659 > this._logService.info(`[Claude] Models refreshed. Count: ${filtered.length}, ${filtered.map(m => m.name).join(', ')}`); claudeAgent.ts ×2
660 > this._models.set(filtered, undefined);
661 > } catch (err) {
662 > this._logService.error(err, '[Claude] Failed to refresh models'); claudeAgent.ts ×1
663 > // Keep the last known-good catalog. A periodic refresh is advisory;
664 > // a transient service failure must not make every model disappear.
665 > // Input changes that invalidate the catalog clear it at the point
666 > // where that input changes.
667 > }
670 > /**
671 > * Native (BYO-Anthropic) model source: enumerate the SDK's built-in /
672 > * subscription models by opening a throwaway {@link IClaudeAgentSdkService.query}
673 > * (workspace-free options that read the user's real `~/.claude` config) and
674 > * calling `Query.supportedModels()` on it, then `close()`. The prompt never
675 > * yields, so no turn runs and no session transcript is written (verified
676 > * Phase 19 E2E). Projected with no commercial metadata.
677 > */
678 > private async _fetchNativeModels(): Promise<readonly IAgentModelInfo[]> {
679 > // A prompt iterable that never yields: enumeration only needs the claudeAgent.ts ×2
680 > // control-request channel (`Query.supportedModels()`), not a real turn.
681 > const neverYieldingPrompt: AsyncIterable<SDKUserMessage> = {
682 > [Symbol.asyncIterator]: () => ({ next: () => new Promise<IteratorResult<SDKUserMessage>>(() => { /* never resolves */ }) }),
683 > };
684 > const options = buildModelEnumerationOptions();
685 > const query = await this._sdkService.query({ prompt: neverYieldingPrompt, options });
686 > try {
687 > const models = await query.supportedModels();
688 > return models.map(m => fromSdkModelInfo(m, this.id));
689 > } finally {
690 > // `close()` terminates the subprocess; aborting the controller is a
691 > // belt-and-suspenders teardown for anything `close()` leaves pending.
692 > query.close();
693 > options.abortController?.abort();
694 > }
695 > }
697 > /**
698 > * Proxied (Copilot-CAPI) model source: fetch via {@link ICopilotApiService},
699 > * keep the Claude family, and surface the CAPI-flagged chat-default first.
700 > * The picker treats `models[0]` as the de facto default (modelPicker.ts:144
701 > * — `_selectedModel ?? models[0]`) since `IAgentModelInfo` carries no
702 > * explicit `isDefault` bit; the stable comparator returns 0 for equal-
703 > * priority models so CAPI's ordering wins on ties.
704 > */
705 > private async _fetchProxyModels(token: string): Promise<readonly IAgentModelInfo[]> {
706 > const userAgent = `${USER_AGENT_PREFIX}/${this._productService.version}`; claudeAgent.ts ×5
707 > const all = await this._copilotApiService.models(token, { headers: { 'User-Agent': userAgent }, suppressIntegrationId: true });
708 > return all
709 > .filter(isClaudeModel)
710 > .sort((a, b) => Number(b.is_chat_default) - Number(a.is_chat_default))
711 > .map(m => toAgentModelInfo(m, this.id));
712 > }
714 > // #endregion
715 >
716 > // #region Stubs — implemented in later phases
717 >
718 > async createSession(config: IAgentCreateSessionConfig = {}): Promise<IAgentCreateSessionResult> {
719 > this._ensureAuthenticated(); claudeAgent.ts ×4
720 > if (config.fork) {
721 > return this._forkSession(config, config.fork); claudeAgent.ts ×4
722 > }
723 > const sessionId = config.session ? AgentSession.id(config.session) : generateUuid(); claudeAgent.ts ×4
724 > const sessionUri = AgentSession.uri(this.id, sessionId);
725 >
726 > const existing = this._findAnySession(sessionId);
727 > if (existing) {
728 > // Re-apply the eager active client on reconnect: AgentService reissues claudeAgent.ts ×1
729 > // `createSession` for an existing URI, so the reconnected client's
730 > // tools/customizations must still reach Claude (mirrors Copilot).
731 > await this._seedEagerActiveClient(sessionUri, config.activeClient);
732 > if (!existing.isPipelineReady) {
733 > return {
734 > session: existing.sessionUri,
735 > workingDirectory: existing.workingDirectory,
736 > provisional: true,
737 > ...(existing.project ? { project: existing.project } : {}),
738 > };
739 > }
740 return { session: sessionUri, workingDirectory: config.workingDirectory };
741 }
743 > // A workspace-less session (no `workingDirectory` supplied, and not a
744 > // fork) runs in a stable per-session scratch dir shared with the Copilot
745 > // agent; without a cwd Claude throws at materialize. The workspace-less
746 > // marker itself is owned/persisted centrally by the AH service.
747 > const workingDirectory = config.workingDirectory ?? await ensureWorkspacelessScratchDir(this._environmentService.userHome, sessionId);
749 > // Only probe for a project when the caller supplied a real folder; a
750 > // scratch dir is never a code project.
751 > const project = config.workingDirectory
752 > ? await projectFromCopilotContext({ cwd: config.workingDirectory.fsPath }, this._gitService) claudeAgent.ts ×1
753 > : undefined; claudeAgent.ts ×2
755 > const permissionMode = this._resolvePermissionMode(config.config);
756 >
757 > const session = ClaudeAgentSession.createProvisional(
758 > sessionId,
759 > sessionUri,
760 > URI.parse(buildDefaultChatUri(sessionUri)),
761 > workingDirectory,
762 > project,
763 > config.model,
764 > config.agent,
765 > config.config,
766 > new PendingRequestRegistry<CallToolResult>(),
767 > permissionMode,
768 > this._metadataStore,
769 > this._instantiationService,
770 > );
771 > this._seedSessionEntry(sessionId, sessionUri, session);
772 > await this._seedEagerActiveClient(sessionUri, config.activeClient);
774 > return {
775 > session: sessionUri,
776 > workingDirectory,
777 > provisional: true,
778 > ...(project ? { project } : {}), claudeAgent.ts ×4
779 > };
780 > }
782 > /**
783 > * Seed the eagerly-claimed active client (tools + customizations) into the
784 > * SDK at session creation, mirroring the Copilot agent. Runs for fresh AND
785 > * reconnected sessions: when the workbench session state already carries the
786 > * active client, no follow-up `session/activeClientSet` is dispatched to
787 > * trigger the customization sync, so the built-in skills bundle would never
788 > * reach Claude otherwise. Progress is suppressed (`quiet`) because the AH
789 > * service has not created the session state yet — a
790 > * `SessionCustomizationUpdated` envelope would be orphaned; the completed
791 > * snapshot is provided via `getSessionCustomizations` immediately after.
792 > */
793 > private async _seedEagerActiveClient(sessionUri: URI, activeClient: IAgentCreateSessionConfig['activeClient']): Promise<void> {
794 > if (!activeClient) { claudeAgent.ts ×5
795 > return; claudeAgent.ts ×1
796 > }
797 > const handle = this.getOrCreateActiveClient(sessionUri, { clientId: activeClient.clientId, displayName: activeClient.displayName }); claudeAgent.ts ×1
798 > handle.tools = activeClient.tools;
799 > if (activeClient.customizations !== undefined) {
800 > await this.syncClientCustomizations(sessionUri, activeClient.clientId, activeClient.customizations, { quiet: true });
801 > }
804 > /**
805 > * In-place "Restore Checkpoint" truncation. Keeps turns
806 > * `[0..turnId]` INCLUSIVE (or removes all turns when `turnId` is
807 > * omitted) on the **same** session id / URI — unlike fork, which mints a
808 > * new id. The `turnId` path resolves the protocol turn to its SDK
809 > * assistant-envelope uuid ({@link resolveForkAnchorUuid}) and stages it
810 > * as a one-shot `resumeSessionAt` anchor that the next turn's rebuild
811 > * applies (the truncation finalizes when the next turn writes the
812 > * branch). Serialized on {@link _sessionSequencer} (same key as
813 > * `sendMessage`) so the `ChatTruncated` → `ChatTurnStarted` dispatch pair
814 > * stays ordered. Provisional sessions short-circuit.
815 > */
816 > async truncateSession(session: URI, turnId?: string): Promise<void> {
817 > const sessionId = AgentSession.id(session); claudeAgent.ts ×2
818 > await this._sessionSequencer.queue(sessionId, async () => {
819 > const existing = this._findAnySession(sessionId);
820 > if (existing && !existing.isPipelineReady) {
821 > this._logService.info(`[Claude:${sessionId}] truncateSession on a provisional session — nothing to truncate`); claudeAgent.ts ×1
822 > return;
823 > }
825 > if (turnId === undefined) {
826 > await this._removeAllTurns(session, sessionId, existing); claudeAgent.ts ×4
827 > return;
828 > }
830 > const messages = await this._sdkService.getSessionMessages(sessionId, { includeSystemMessages: true });
831 > const anchor = resolveForkAnchorUuid(messages, turnId);
832 > if (anchor === undefined) {
833 > throw new Error(`Cannot truncate session ${sessionId}: turn ${turnId} not found in transcript`); claudeAgent.ts ×1
834 > }
836 > // Operate on a live session; cold-resume an unloaded one first so
837 > // there is a single code path that sets the anchor on a live
838 > // pipeline (the next send applies it).
839 > const live = existing ?? await this._resumeSession(sessionId, session);
840 > await live.truncateToTurn(turnId, anchor); claudeAgent.ts ×1
841 > this._logService.info(`[Claude:${sessionId}] truncateSession kept [0..${turnId}] (anchor=${anchor})`); claudeAgent.ts ×2
842 > }); claudeAgent.ts ×2
843 > }
845 > /**
846 > * Remove-all ("start over") branch of {@link truncateSession}: there is no
847 > * anchor to resume at, so tear down the live Query, delete the on-disk
848 > * transcript via the SDK, then recreate a fresh provisional under the SAME
849 > * id/URI so the next `sendMessage` materializes non-resume `{ sessionId }`
850 > * on a clean transcript (keeps the id stable). `deleteSession` is eagerly
851 > * durable (unlike the lazy `turnId` path), matching its "clear / start
852 > * over" semantic. `existing` is the live session, or `undefined` on the
853 > * cold path (unloaded session). Caller serializes on {@link _sessionSequencer}.
854 > */
855 > private async _removeAllTurns(session: URI, sessionId: string, existing: ClaudeAgentSession | undefined): Promise<void> {
856 > const info = existing ? undefined : await this._sdkService.getSessionInfo(sessionId); claudeAgent.ts ×4
857 > const workingDirectory = existing?.workingDirectory ?? (info?.cwd ? URI.file(info.cwd) : undefined);
858 > if (!workingDirectory) {
859 // Mirror `_resumeSession` / fork: fail fast rather than recreate a
860 // provisional with no cwd that would only fail later at materialize.
861 throw new Error(`Cannot clear session ${sessionId}: workingDirectory missing (SDK cwd absent and no live session)`);
862 }
863 > let overlay: IClaudeSessionOverlay = {}; claudeAgent.ts ×4
864 > try {
865 > overlay = await this._metadataStore.read(session);
866 > } catch (err) {
867 this._logService.warn(`[Claude:${sessionId}] overlay read failed during remove-all; continuing with defaults`, err);
868 }
870 > // `shutdownLiveQuery` awaits the subprocess's actual exit (and its final
871 > // transcript flush), so the on-disk `<id>.jsonl` is now stable and safe
872 > // to delete: no live writer can recreate it before the next turn
873 > // respawns a fresh `--session-id <id>`.
874 > await existing?.shutdownLiveQuery();
875 > this._sessions.deleteAndDispose(sessionId);
876 > await this._sdkService.deleteSession(sessionId);
877 >
878 > await this.createSession({
879 > session,
880 > workingDirectory,
881 > ...(overlay.model ? { model: overlay.model } : {}),
882 > ...(overlay.agent ? { agent: overlay.agent } : {}),
883 > ...(overlay.permissionMode ? { config: { [ClaudeSessionConfigKey.PermissionMode]: overlay.permissionMode } } : {}),
884 > });
885 > // Re-fetch (not reuse `existing`): `existing` is the OLD session, already
886 > // torn down by `deleteAndDispose` above, and is `undefined` entirely on
887 > // the cold path. `createSession` registered a fresh instance under the
888 > // same id — prune through that live session so a single path covers both
889 > // warm and cold remove-all.
890 > await this._findAnySession(sessionId)?.pruneAllTurns();
891 > this._logService.info(`[Claude:${sessionId}] truncateSession removed all turns (deleteSession + fresh same-id)`);
892 > }
894 > // ---- Chat surface ------------------------------------------------------
895 > //
896 > // `chats` exposes the per-chat operations addressed by a single,
897 > // concrete chat channel URI (the default chat channel or a peer/subagent
898 > // URI). The default chat's SDK id is still the owning session id, derived
899 > // inside the harness from the chat URI.
900 >
901 > /**
902 > * The chat-addressed operation surface
903 > * ({@link IAgentChats}). Every method addresses a chat by a single,
904 > * already-resolved chat URI; this maps to the `(session, chat)` pair
905 > * the agent's internal SDK storage is keyed by (via
906 > * {@link _resolveChatTarget}).
907 > */
908 > readonly chats: IAgentChats = {
909 > createChat: (chat, options) => this._createChat(chat, options),
910 > fork: (chat, source: IAgentCreateChatForkSource, options?: IAgentCreateChatOptions) =>
911 > this._createChat(chat, { ...options, fork: source }), claudeAgent.ts ×1
912 > disposeChat: chatUri => { claudeAgent.ts ×91
913 > const { session, chat } = this._resolveChatTarget(chatUri); claudeAgent.ts ×1
914 > return this._disposeChat(session, chat);
915 > },
916 > sendMessage: (chatUri, prompt, workingDirectory, attachments, turnId, senderClientId) => { claudeAgent.ts ×91
917 > return this._sendMessage(chatUri, prompt, workingDirectory, attachments, turnId, senderClientId); claudeAgent.ts ×1
918 > },
919 > abort: chatUri => { claudeAgent.ts ×91
920 > return this._abortSession(chatUri); claudeAgent.ts ×1
921 > },
922 > changeModel: (chatUri, model) => { claudeAgent.ts ×91
923 > return this._changeModel(chatUri, model); claudeAgent.ts ×1
924 > },
925 > changeAgent: (chatUri, agent) => { claudeAgent.ts ×91
926 > return this._changeAgent(chatUri, agent); claudeAgent.ts ×1
927 > },
928 > getMessages: chat => this.getSessionMessages(chat), claudeAgent.ts ×91
929 > };
930 >
931 > /**
932 > * Map an already-resolved chat URI to the `(session, chat)` pair the agent's
933 > * internal SDK storage is keyed by. A peer (or subagent) chat is addressed by
934 > * its own `ahp-chat` channel URI, from which the owning session is recovered.
935 > * The default chat is addressed by its deterministic chat channel URI.
936 > */
937 > private _resolveChatTarget(chat: URI): { session: URI; chat: URI } {
938 > const parsed = parseChatUri(chat); claudeAgent.ts ×4
939 > if (!parsed) {
940 throw new Error(`Claude chat operation requires an AHP chat URI: ${chat.toString()}`);
941 }
942 > return { session: URI.parse(parsed.session), chat }; claudeAgent.ts ×4
943 > }
945 > /**
946 > * NOT started here (CONTEXT M9): `forkSession` writes the transcript to
947 > * disk and we return; the `Query` materializes lazily on the first
948 > * {@link sendMessage} via {@link _resumeSession}. `turnId` is translated
949 > * to the SDK envelope `uuid` by {@link resolveForkAnchorUuid};
950 > * `config.fork.turnIdMapping` is ignored (the SDK already remaps uuids).
951 > */
952 > private async _forkSession(config: IAgentCreateSessionConfig, fork: NonNullable<IAgentCreateSessionConfig['fork']>): Promise<IAgentCreateSessionResult> {
953 > if (isSubagentSession(fork.session)) { claudeAgent.ts ×4
954 > throw new Error('Cannot fork a subagent session'); claudeAgent.ts ×1
955 > }
956 > const sourceSessionId = AgentSession.id(fork.session); claudeAgent.ts ×1
957 > const existingSource = this._findAnySession(sourceSessionId);
958 > if (existingSource && !existingSource.isPipelineReady) { claudeAgent.ts ×4
959 > throw new Error('Cannot fork a provisional/never-sent session'); claudeAgent.ts ×1
960 > }
961 > // Serialize against the SOURCE session so the transcript read + fork claudeAgent.ts ×4
962 > // can't race an in-flight `sendMessage` mutating that session.
963 > return this._sessionSequencer.queue(sourceSessionId, async () => {
964 > const messages = await this._sdkService.getSessionMessages(sourceSessionId, { includeSystemMessages: true });
965 > const upToMessageId = resolveForkAnchorUuid(messages, fork.turnId);
966 > if (upToMessageId === undefined) {
967 > throw new Error(`Cannot fork session ${sourceSessionId}: turn ${fork.turnId} not found in transcript`); claudeAgent.ts ×1
968 > }
969 > const { sessionId: newSessionId } = await this._sdkService.forkSession(sourceSessionId, { upToMessageId }); claudeAgent.ts ×3
970 > const newSessionUri = AgentSession.uri(this.id, newSessionId);
971 >
972 > // Inherit the source's model / permissionMode / agent (create-config
973 > // overrides win) so the lazy `_resumeSession` seeds `Options` from
974 > // it. `customizationDirectory` is NOT inherited — it is the source's
975 > // per-session synced plugin dir (Phase 11); the fork re-syncs its own.
976 > let sourceOverlay: IClaudeSessionOverlay = {};
977 > try {
978 > sourceOverlay = await this._metadataStore.read(fork.session);
979 > } catch (err) {
980 this._logService.warn(`[Claude] fork: source overlay read failed for ${sourceSessionId}; continuing with defaults`, err);
981 }
982 > const model = config.model ?? sourceOverlay.model; claudeAgent.ts ×3
983 > const agent = config.agent ?? sourceOverlay.agent; claudeAgent.ts ×4
984 > const permissionMode = narrowClaudePermissionMode(config.config?.[ClaudeSessionConfigKey.PermissionMode]) ?? sourceOverlay.permissionMode;
985 > await this._metadataStore.write(newSessionUri, {
986 > ...(model ? { model } : {}),
987 > ...(permissionMode ? { permissionMode } : {}),
988 > ...(agent ? { agent } : {}),
989 > });
991 > // Resolve the forked session's working directory now so we can fail
992 > // fast (rather than at the first `sendMessage` when `_resumeSession`
993 > // requires a cwd). The Query itself starts lazily — see the JSDoc.
994 > const sdkInfo = await this._sdkService.getSessionInfo(newSessionId);
995 > const workingDirectory = sdkInfo?.cwd ? URI.file(sdkInfo.cwd) : config.workingDirectory; claudeAgent.ts ×4
996 > if (!workingDirectory) {
997 > throw new Error(`Cannot fork session ${sourceSessionId}: forked session ${newSessionId} has no working directory (SDK cwd missing and none supplied)`); claudeAgent.ts ×1
998 > }
999 > let project: IAgentSessionProjectInfo | undefined; claudeAgent.ts ×2
1000 > try {
1001 > project = await projectFromCopilotContext({ cwd: workingDirectory.fsPath }, this._gitService);
1002 > } catch (err) {
1003 this._logService.warn(`[Claude] fork: project resolution failed for ${newSessionId}; continuing without project`, err);
1004 }
1005 > return { claudeAgent.ts ×2
1006 > session: newSessionUri,
1007 > workingDirectory,
1008 > ...(project ? { project } : {}), claudeAgent.ts ×4
1009 > };
1010 > });
1013 > /**
1014 > * Builds the SDK `canUseTool` permission bridge for a session/chat. The
1015 > * resolver searches both default chats and peer chats by SDK id so a peer
1016 > * chat's tool-permission requests reach its own pending-permission registry.
1017 > */
1018 > private _makeCanUseTool(sdkSessionId: string): NonNullable<Options['canUseTool']> {
1019 > return (toolName, input, options) => claudeAgent.ts ×4
1020 > handleCanUseTool( claudeCanUseTool.ts ×3
1021 > { getSession: id => this._findSessionBySdkId(id), configurationService: this._configurationService },
1022 > sdkSessionId, toolName, input, options,
1023 > );
1026 > /**
1027 > * Builds the SDK `onElicitation` bridge for a session/chat. Mirrors
1028 > * {@link _makeCanUseTool}: resolves the session by SDK id (default and peer
1029 > * chats) and delegates to the elicitation bridge, which parks on the
1030 > * session's user-input channel. Phase 10.6.
1031 > */
1032 > private _makeOnElicitation(sdkSessionId: string): OnElicitation {
1033 > return (request, options) => claudeAgent.ts ×4
1034 > handleElicitation( claudeAgent.ts ×1
1035 > { getSession: id => this._findSessionBySdkId(id) },
1036 > sdkSessionId, request, options,
1037 > );
1040 > /**
1041 > * Promote a provisional {@link ClaudeAgentSession} into a live one.
1042 > * Called from {@link sendMessage} inside the {@link _sessionSequencer.queue}
1043 > * block, so concurrent first sends serialize naturally — exactly
1044 > * one materialize per session.
1045 > *
1046 > * Failure modes:
1047 > * - Missing session entry → programmer error, throws.
1048 > * - Missing proxy handle → caller forgot {@link authenticate}, throws.
1049 > * - Aborted before SDK init returns → {@link ClaudeAgentSession.materialize}
1050 > * disposes the `WarmQuery` and throws {@link CancellationError}.
1051 > * - Customization-directory persistence failure → fatal: the session's
1052 > * `materialize` throws, the agent drops the entry, and the error
1053 > * propagates so the caller learns about it.
1054 > * - Aborted post-metadata-write but pre-commit → second abort gate
1055 > * inside `materialize` throws so we never expose a live pipeline
1056 > * for a session the caller has already torn down.
1057 > */
1058 > private async _materializeProvisional(sessionId: string, workingDirectory?: URI): Promise<ClaudeAgentSession> {
1059 > const session = this._findAnySession(sessionId); claudeAgent.ts ×4
1060 > if (!session) {
1061 throw new Error(`Cannot materialize unknown provisional session: ${sessionId}`);
1062 }
1063 > const transport = this._ensureAuthenticated(); claudeAgent.ts ×4
1064 >
1065 > const canUseTool = this._makeCanUseTool(sessionId);
1066 > const onElicitation = this._makeOnElicitation(sessionId);
1067 > try {
1068 > await session.materialize({ transport, canUseTool, onElicitation, isResume: false, workingDirectory, serverToolHost: this._serverToolHost });
1069 > } catch (err) {
1070 > this._sessions.deleteAndDispose(sessionId); claudeAgent.ts ×1
1071 > throw err;
1072 > }
1074 > this._onDidMaterializeSession.fire({
1075 > session: session.sessionUri,
1076 > workingDirectory: session.workingDirectory,
1077 > project: session.project,
1078 > });
1079 >
1080 > return session;
1083 > /**
1084 > * Bring up a session whose state exists only on disk — created in
1085 > * another window, or before an agent-host restart. Mirror of
1086 > * `CopilotAgent._resumeSession`. Reads `workingDirectory` from the
1087 > * SDK's session record and `model` / `permissionMode` from the
1088 > * metadata overlay, constructs a provisional {@link ClaudeAgentSession},
1089 > * and calls {@link ClaudeAgentSession.materialize} with `isResume: true`
1090 > * so the SDK reloads the existing transcript instead of minting a
1091 > * fresh one.
1092 > *
1093 > * Caller must hold the session sequencer so two concurrent
1094 > * `sendMessage` calls for a freshly-resumed session collapse into
1095 > * one resume + two ordered sends.
1096 > */
1097 > private async _resumeSession(sessionId: string, sessionUri: URI): Promise<ClaudeAgentSession> {
1098 > this._logService.info(`[Claude:${sessionId}] _resumeSession — no in-memory state, rebuilding from disk`); claudeAgent.ts ×4
1099 > const transport = this._ensureAuthenticated();
1100 > const sdkInfo = await this._sdkService.getSessionInfo(sessionId);
1101 > if (!sdkInfo) {
1102 > throw new Error(`Cannot resume unknown session: ${sessionId} (not present in SDK transcript store)`); claudeAgent.ts ×1
1103 > }
1104 > const workingDirectory = sdkInfo.cwd ? URI.file(sdkInfo.cwd) : undefined; claudeAgent.ts ×4
1105 > if (!workingDirectory) {
1106 throw new Error(`Cannot resume session ${sessionId}: workingDirectory missing from SDK transcript`);
1107 }
1108 > let overlay: IClaudeSessionOverlay = {}; claudeAgent.ts ×5
1109 > try {
1110 > overlay = await this._metadataStore.read(sessionUri);
1111 > } catch (err) {
1112 this._logService.warn(`[Claude:${sessionId}] overlay read failed during resume; continuing with defaults`, err);
1113 }
1114 > const permissionMode = readClaudePermissionMode(this._configurationService, sessionUri) claudeAgent.ts ×5
1115 > ?? overlay.permissionMode
1116 > ?? 'default'; claudeAgent.ts ×1
1117 > let project: IAgentSessionProjectInfo | undefined; claudeAgent.ts ×4
1118 > try {
1119 > project = await projectFromCopilotContext({ cwd: workingDirectory.fsPath }, this._gitService);
1120 > } catch (err) { claudeAgent.ts ×5
1121 this._logService.warn(`[Claude:${sessionId}] project resolution failed during resume; continuing without project`, err);
1122 }
1124 > const session = ClaudeAgentSession.createProvisional(
1125 > sessionId,
1126 > sessionUri,
1127 > URI.parse(buildDefaultChatUri(sessionUri)),
1128 > workingDirectory,
1129 > project,
1130 > overlay.model,
1131 > overlay.agent,
1132 > undefined,
1133 > new PendingRequestRegistry<CallToolResult>(),
1134 > permissionMode,
1135 > this._metadataStore,
1136 > this._instantiationService,
1137 > );
1138 > this._seedSessionEntry(sessionId, sessionUri, session);
1139 >
1140 > const canUseTool = this._makeCanUseTool(sessionId);
1141 > const onElicitation = this._makeOnElicitation(sessionId);
1142 > try {
1143 > await session.materialize({ transport, canUseTool, onElicitation, isResume: true, serverToolHost: this._serverToolHost });
1144 > } catch (err) {
1145 this._sessions.deleteAndDispose(sessionId);
1146 throw err;
1147 }
1149 > this._onDidMaterializeSession.fire({
1150 > session: sessionUri,
1151 > workingDirectory,
1152 > project,
1153 > });
1154 >
1155 > return session;
1158 > /**
1159 > * Pull `permissionMode` out of the post-validation `IAgentCreateSessionConfig.config`
1160 > * bag, narrowing the runtime `unknown` value to the SDK's `PermissionMode`
1161 > * union (5/6 values, excluding `dontAsk`; sdk.d.ts:1560). Falls back to
1162 > * `'default'` when the bag is absent or carries something the schema
1163 > * validator shouldn't have accepted (defense-in-depth).
1164 > */
1165 > private _resolvePermissionMode(config: Record<string, unknown> | undefined): ClaudePermissionMode {
1166 > return narrowClaudePermissionMode(config?.[ClaudeSessionConfigKey.PermissionMode]) ?? 'default'; claudeAgent.ts ×5
1167 > }
1169 > disposeSession(session: URI): Promise<void> {
1170 > // Routed through {@link _disposeSequencer} so a concurrent claudeAgent.ts ×1
1171 > // {@link shutdown} already serializing teardown for this same
1172 > // session id awaits this work first (and vice versa). When the session
1173 > // has not yet been materialized, abort the controller (unblocks any
1174 > // racing `await sdk.startup()`) and drop the record. No SDK contact,
1175 > // no DB write — symmetric with `createSession`.
1176 > const sessionId = AgentSession.id(session);
1177 > return this._disposeSequencer.queue(sessionId, async () => {
1178 > await this._teardownEntry(sessionId);
1179 > this._pruneActiveClientHandles(sessionId);
1180 > });
1181 > }
1183 > /**
1184 > * Non-destructive counterpart to {@link disposeSession}: releases the
1185 > * session's in-memory resources — its live SDK subprocess (via the disposed
1186 > * pipeline) and cached entry — but preserves the on-disk session so it can
1187 > * be transparently resumed later via {@link _resumeSession}. Used by
1188 > * idle-session eviction to bound memory in long-lived host processes.
1189 > *
1190 > * No-ops for provisional sessions (never materialized, so nothing on disk to
1191 > * resume from) and for sessions with a turn in flight — tearing the pipeline
1192 > * down mid-turn would abort live work. Shares the same in-memory teardown as
1193 > * {@link disposeSession}; the destructive difference (deleting durable data)
1194 > * lives in the orchestrator, which only invokes it on dispose.
1195 > */
1196 > releaseSession(session: URI): Promise<void> {
1197 const sessionId = AgentSession.id(session);
1198 return this._disposeSequencer.queue(sessionId, async () => {
1199 const entry = this._sessions.get(sessionId);
1200 if (!entry) {
1201 return;
1202 }
1203 // Provisional sessions (default chat not materialized) have no
1204 // on-disk SDK session to resume from; releasing would lose state.
1205 if (!entry.defaultChat?.isPipelineReady) {
1206 return;
1207 }
1208 // Defensive active-turn guard: the orchestrator already skips
1209 // eviction while a turn is active, but `disposeSession` and
1210 // `sendMessage` run on separate sequencers, so a turn could be in
1211 // flight. Never tear the pipeline down under a live turn.
1212 if (entry.allChatSessions().some(chatSession => chatSession.hasActiveTurn)) {
1213 return;
1214 }
1215 this._logService.info(`[Claude:${sessionId}] Releasing idle session from memory (durable state preserved)`);
1216 await this._teardownEntry(sessionId);
1217 this._pruneActiveClientHandles(sessionId);
1218 });
1219 }
1221 > /**
1222 > * Abort and dispose a session entry — its default chat and every peer chat.
1223 > * Each peer teardown serializes on the peer's own {@link _sessionSequencer}
1224 > * key so it waits for any in-flight materialize/send rather than disposing
1225 > * the chat under it.
1226 > */
1227 > private async _teardownEntry(sessionId: string): Promise<void> {
1228 > const entry = this._sessions.get(sessionId); claudeAgent.ts ×5
1229 > if (!entry) {
1230 > return; claudeAgent.ts ×1
1231 > }
1232 > const defaultChat = entry.defaultChat; claudeAgent.ts ×3
1233 > if (defaultChat && !defaultChat.isPipelineReady) { claudeAgent.ts ×5
1234 > defaultChat.abortController.abort(); claudeAgent.ts ×1
1235 > }
1236 > await Promise.all(entry.peerChatKeys().map(chatKey => claudeAgent.ts ×3
1237 this._sessionSequencer.queue(chatKey, async () => {
1238 const peer = entry.getPeerChat(chatKey);
1239 if (peer) {
1240 if (!peer.isPipelineReady) {
1241 peer.abortController.abort();
1242 } else {
1243 peer.abort();
1244 }
1245 }
1246 entry.disposePeerChat(chatKey);
1247 })
1248 > )); claudeAgent.ts ×3
1249 > this._sessions.deleteAndDispose(sessionId);
1250 > // Drop the live backings for this session's peer chats. The chat URI
1251 > // encodes its parent session, so we recover it via `parseChatUri`.
1252 > for (const chatKey of [...this._chatBackings.keys()]) {
1253 > const parsed = parseChatUri(URI.parse(chatKey)); claudeAgent.ts ×1
1254 > if (parsed && AgentSession.id(URI.parse(parsed.session)) === sessionId) {
1255 > this._chatBackings.delete(chatKey);
1256 > }
1257 > }
1260 > // #region Multi-chat — additional (non-default) peer chats
1261 >
1262 > /**
1263 > * Create an additional peer chat within an existing session. The new chat
1264 > * is backed by its own SDK chat (a fresh one, or a fork of the
1265 > * source chat at a turn) that shares the parent session's working directory
1266 > * and inherited model / agent / permission-mode parentSession. The backing is
1267 > * recorded in the live {@link _chatBackings} map and returned as an opaque
1268 > * `providerData` blob for the orchestrator to persist; the chat's metadata
1269 > * overlay is seeded so a later lazy resume inherits the parent parentSession. The
1270 > * live {@link ClaudeAgentSession} is built lazily on the chat's first send
1271 > * (mirroring how default sessions materialize lazily).
1272 > */
1273 > private async _createChat(chat: URI, options?: IAgentCreateChatOptions): Promise<IAgentCreateChatResult | void> {
1274 > this._ensureAuthenticated(); claudeAgent.ts ×4
1275 > if (isDefaultChatUri(chat)) {
1276 > return; claudeAgent.ts ×2
1277 > }
1278 > const parsed = parseChatUri(chat); claudeAgent.ts ×12
1279 > if (!parsed) {
1280 throw new Error(`[Claude] createChat: malformed chat URI ${chat.toString()}`);
1281 }
1282 > const session = URI.parse(parsed.session); claudeAgent.ts ×12
1283 > const chatKey = chat.toString();
1284 > const parentSessionId = AgentSession.id(session);
1285 > let result: IAgentCreateChatResult | undefined;
1286 > const queueKey = options?.sideChat ? chatKey : parentSessionId; claudeAgent.ts ×4
1287 > await this._sessionSequencer.queue(queueKey, async () => {
1288 > const existing = this._chatBackings.get(chatKey); claudeAgent.ts ×12
1289 > if (existing) {
1290 > // Idempotent re-create: hand back the existing backing so the claudeAgent.ts ×1
1291 > // orchestrator re-persists a consistent blob.
1292 > result = { providerData: encodeProviderData(existing), backingSession: AgentSession.uri(this.id, existing.sdkSessionId) };
1293 > return;
1294 > }
1295 > const parentSession = await this._resolveParentSession(session, parentSessionId); claudeAgent.ts ×12
1296 > const model = options?.model ?? parentSession.model;
1297 >
1298 > let sdkSessionId: string | undefined;
1299 > let sideChat: IPersistedChat['sideChat'];
1300 > if (options?.fork) {
1301 > // If the fork point can't be resolved, fall through to a fresh claudeAgent.ts ×1
1302 > // chat rather than inheriting the whole source backend.
1303 > sdkSessionId = (await this._forkChat(session, options.fork))?.sessionId;
1304 > } else if (options?.sideChat) { claudeAgent.ts ×12
1305 > const forked = await this._forkChat(session, { source: options.sideChat.source, turnId: options.sideChat.providerAnchorTurnId ?? options.sideChat.turnId }); claudeAgent.ts ×2
1306 > sdkSessionId = forked?.sessionId;
1307 > const fallbackContext = options.sideChat.sourceContext ?? (!forked ? this._buildSideChatContext(session, options.sideChat.source, options.sideChat.turnId) : undefined);
1308 > if (!forked && !fallbackContext && !options.sideChat.partialResponse) {
1309 throw new Error(`[Claude] createChat side chat: source turn ${options.sideChat.turnId} could not be forked`);
1310 }
1311 > sideChat = { claudeAgent.ts ×2
1312 > source: options.sideChat.source.toString(),
1313 > turnId: options.sideChat.turnId,
1314 > ...(options.sideChat.selection ? { selection: options.sideChat.selection } : {}),
1315 > ...(options.sideChat.providerAnchorTurnId ? { providerAnchorTurnId: options.sideChat.providerAnchorTurnId } : {}),
1316 > inheritedTurnCount: forked?.inheritedTurnCount ?? 0,
1317 > ...(fallbackContext ? { context: fallbackContext } : {}),
1318 > ...(options.sideChat.partialResponse ? { partialResponse: options.sideChat.partialResponse } : {}),
1319 > };
1320 > }
1321 > sdkSessionId ??= generateUuid(); claudeAgent.ts ×12
1322 >
1323 > // Record the live backing and hand the opaque blob back to the
1324 > // orchestrator to persist.
1325 > const backing: IPersistedChat = { sdkSessionId, ...(model ? { model } : {}), ...(sideChat ? { sideChat } : {}) };
1326 > this._chatBackings.set(chatKey, backing);
1327 > result = { providerData: encodeProviderData(backing), backingSession: AgentSession.uri(this.id, sdkSessionId) };
1328 >
1329 > // Seed the chat's own metadata overlay so a later lazy resume (this
1330 > // process or a restart) inherits the parent's parentSession.
1331 > await this._metadataStore.write(chat, {
1332 > ...(model ? { model } : {}),
1333 > ...(parentSession.agent ? { agent: parentSession.agent } : {}),
1334 > ...(parentSession.permissionMode ? { permissionMode: parentSession.permissionMode } : {}),
1335 > });
1336 > this._logService.info(`[Claude] Created additional chat ${chat.toString()} in session ${session.toString()}${options?.fork ? ' (forked)' : ''}`);
1337 > }); claudeAgent.ts ×4
1338 > return result; claudeAgent.ts ×12
1341 > /**
1342 > * Dispose an additional peer chat, tearing down its live chat (if
1343 > * any) and dropping its live backing. The default chat cannot be disposed in
1344 > * isolation — it lives and dies with the session.
1345 > *
1346 > * Routed through {@link _sessionSequencer} (keyed on the chat URI) so it
1347 > * waits for any in-flight {@link _materializeChatLocked} or
1348 > * {@link sendMessage} to finish before tearing down — prevents
1349 > * use-after-dispose if a send is concurrently in progress. The durable
1350 > * peer-chat catalog is owned by the orchestrator now, so this only drops the
1351 > * live backing and chat.
1352 > */
1353 > private async _disposeChat(session: URI, chat: URI): Promise<void> {
1354 > if (isDefaultChatUri(chat)) { claudeAgent.ts ×4
1355 > return; claudeAgent.ts ×2
1356 > }
1357 > const chatKey = chat.toString(); claudeAgent.ts ×2
1358 > const parentSessionId = AgentSession.id(session);
1359 > await this._sessionSequencer.queue(chatKey, async () => {
1360 > const entry = this._sessions.get(parentSessionId);
1361 > const peer = entry?.getPeerChat(chatKey);
1362 > if (peer) {
1363 if (!peer.isPipelineReady) {
1364 peer.abortController.abort();
1365 } else {
1366 peer.abort();
1367 }
1368 entry!.disposePeerChat(chatKey);
1369 }
1370 > this._chatBackings.delete(chatKey); claudeAgent.ts ×2
1371 > });
1372 > // The Claude SDK exposes no delete-chat RPC, so the forked / claudeAgent.ts ×4
1373 > // fresh transcript is left on disk; without a catalog entry it is never
1374 > // resumed again.
1375 > }
1377 > /**
1378 > /**
1379 > * Resolve the inherited session settings (working directory, project, model, agent,
1380 > * permission mode) a new or resumed peer chat copies from its parent
1381 > * session. Prefers the live in-memory parent; falls back to the SDK's
1382 > * on-disk session record + metadata overlay for an unloaded parent.
1383 > */
1384 > private async _resolveParentSession(session: URI, parentSessionId: string): Promise<{ workingDirectory: URI; project: IAgentSessionProjectInfo | undefined; model: ModelSelection | undefined; agent: AgentSelection | undefined; permissionMode: ClaudePermissionMode }> {
1385 > const parent = this._findAnySession(parentSessionId); claudeAgent.ts ×12
1386 > let workingDirectory = parent?.workingDirectory;
1387 > let project = parent?.project;
1388 > if (!workingDirectory) {
1389 > const sdkInfo = await this._sdkService.getSessionInfo(parentSessionId); claudeAgent.ts ×7
1390 > workingDirectory = sdkInfo?.cwd ? URI.file(sdkInfo.cwd) : undefined;
1391 > }
1392 > if (!workingDirectory) { claudeAgent.ts ×12
1393 throw new Error(`[Claude] createChat: cannot resolve working directory for parent session ${session.toString()}`);
1394 }
1395 > if (!project) { claudeAgent.ts ×12
1396 > try {
1397 > project = await projectFromCopilotContext({ cwd: workingDirectory.fsPath }, this._gitService);
1398 > } catch (err) {
1399 this._logService.warn(`[Claude] createChat: project resolution failed for ${session.toString()}; continuing without project`, err);
1400 }
1402 > let overlay: IClaudeSessionOverlay = {};
1403 > try {
1404 > overlay = await this._metadataStore.read(session);
1405 > } catch (err) {
1406 this._logService.warn(`[Claude] createChat: parent overlay read failed for ${session.toString()}; continuing with defaults`, err);
1407 }
1408 > const permissionMode = readClaudePermissionMode(this._configurationService, session) ?? overlay.permissionMode ?? 'default'; claudeAgent.ts ×12
1409 > return { workingDirectory, project, model: overlay.model, agent: overlay.agent, permissionMode };
1410 > }
1412 > /**
1413 > * Fork the source chat's SDK chat at the requested turn into a new
1414 > * chat and return its SDK session id. Returns `undefined` (so the
1415 > * caller creates a fresh chat instead) when the source chat or the
1416 > * fork anchor cannot be resolved.
1417 > */
1418 > private async _forkChat(session: URI, fork: IAgentCreateChatOptions['fork'] & {}): Promise<{ sessionId: string; inheritedTurnCount: number } | undefined> {
1419 > const sourceSdkId = await this._resolveChatSdkId(session, fork.source); claudeAgent.ts ×6
1420 > if (!sourceSdkId) {
1421 this._logService.warn(`[Claude] createChat fork: source ${fork.source.toString()} has no SDK chat; creating fresh chat`);
1422 return undefined;
1423 }
1424 > const messages = await this._sdkService.getSessionMessages(sourceSdkId, { includeSystemMessages: true }); claudeAgent.ts ×6
1425 > const upToMessageId = resolveForkAnchorUuid(messages, fork.turnId);
1426 > if (upToMessageId === undefined) {
1427 > this._logService.warn(`[Claude] createChat fork: turn ${fork.turnId} not found in source ${sourceSdkId}; creating fresh chat`); claudeAgent.ts ×1
1428 > return undefined;
1429 > }
1430 > const { sessionId } = await this._sdkService.forkSession(sourceSdkId, { upToMessageId }); claudeAgent.ts ×10
1431 > const anchorIndex = messages.findIndex(message => message.uuid === upToMessageId);
1432 > const inheritedTurnCount = mapSessionMessagesToTurns(messages.slice(0, anchorIndex + 1), fork.source, this._logService).length;
1433 > return { sessionId, inheritedTurnCount };
1436 > /**
1437 > * Resolve the SDK chat id backing a chat URI — the session's
1438 > * default chat (the parent session's own id) or an additional peer chat
1439 > * (from the in-memory entry, else the live/legacy backing).
1440 > */
1441 > private async _resolveChatSdkId(session: URI, chatUri: URI): Promise<string | undefined> {
1442 > if (isDefaultChatUri(chatUri) || chatUri.toString() === session.toString()) { claudeAgent.ts ×6
1443 > return AgentSession.id(session);
1444 > }
1445 > const inMemory = this._findChat(session, chatUri)?.sessionId; claudeAgent.ts ×6
1446 > if (inMemory) { claudeAgent.ts ×6
1447 > return inMemory; claudeAgent.ts ×6
1448 > }
1449 return this._resolveChatBacking(chatUri)?.sdkSessionId;
1452 > private _getSourceChatState(session: URI, chatUri: URI): ChatState | undefined {
1453 > if (isDefaultChatUri(chatUri) || chatUri.toString() === session.toString()) { claudeAgent.ts ×5
1454 > return this._stateManager.getDefaultChatState(session.toString());
1455 > }
1456 return this._stateManager.getChatState(chatUri.toString());
1459 > private _buildSideChatContext(session: URI, chatUri: URI, turnId: string): string | undefined {
1460 > const state = this._getSourceChatState(session, chatUri); claudeAgent.ts ×5
1461 > if (!state) {
1462 return undefined;
1463 }
1464 > const completedIndex = state.turns.findIndex(turn => turn.id === turnId); claudeAgent.ts ×5
1465 > const boundedTurns = completedIndex >= 0
1466 > ? state.turns.slice(0, completedIndex + 1)
1467 : state.activeTurn?.id === turnId
1468 ? state.turns
1469 : undefined;
1470 > return boundedTurns ? buildSideChatSourceContext(boundedTurns, state.activeTurn?.id === turnId ? state.activeTurn : undefined) : undefined; claudeAgent.ts ×5
1471 > }
1473 > /**
1474 > * Resolves the live backing for a peer chat from the in-memory
1475 > * {@link _chatBackings} map. Returns `undefined` for a chat that has not been
1476 > * materialized via {@link materializeChat}.
1477 > */
1478 > private _resolveChatBacking(chat: URI): IPersistedChat | undefined {
1479 > return this._chatBackings.get(chat.toString()); claudeAgent.ts ×1
1480 > }
1482 > /**
1483 > * Return the in-memory entry for a session, creating a provisional (not yet
1484 > * materialized) default chat to host its peer chats if none exists — e.g. a
1485 > * peer chat is sent to after a restart before the default chat is touched.
1486 > * Serialized on the session id so concurrent peer sends share one entry.
1487 > */
1488 > private _ensureSessionEntry(session: URI): Promise<ClaudeSessionEntry> {
1489 > const sessionId = AgentSession.id(session); claudeAgent.ts ×10
1490 > return this._sessionSequencer.queue(sessionId, async () => {
1491 > const existing = this._sessions.get(sessionId);
1492 > if (existing) {
1493 > return existing; claudeAgent.ts ×1
1494 > }
1495 > const parentSession = await this._resolveParentSession(session, sessionId); claudeAgent.ts ×7
1496 > const mainSession = ClaudeAgentSession.createProvisional(
1497 > sessionId,
1498 > session,
1499 > URI.parse(buildDefaultChatUri(session)),
1500 > parentSession.workingDirectory,
1501 > parentSession.project,
1502 > parentSession.model,
1503 > parentSession.agent,
1504 > undefined,
1505 > new PendingRequestRegistry<CallToolResult>(),
1506 > parentSession.permissionMode,
1507 > this._metadataStore,
1508 > this._instantiationService,
1509 > );
1510 > return this._seedSessionEntry(sessionId, session, mainSession);
1511 > }); claudeAgent.ts ×10
1512 > }
1514 > /**
1515 > * Build + materialize the peer chat's live {@link ClaudeAgentSession},
1516 > * resuming its persisted SDK chat when one already exists on disk
1517 > * (forked or restored chats) or starting fresh otherwise. The caller MUST
1518 > * hold the per-chat (`chat.toString()`) {@link _sessionSequencer} lock so
1519 > * concurrent first sends collapse into one materialize and teardown can't
1520 > * race the build.
1521 > */
1522 > private async _materializeChatLocked(session: URI, chat: URI): Promise<ClaudeAgentSession> {
1523 > const chatKey = chat.toString(); claudeAgent.ts ×10
1524 > const entry = await this._ensureSessionEntry(session);
1525 > const existing = entry.getPeerChat(chatKey);
1526 > if (existing?.isPipelineReady) {
1527 return existing;
1528 }
1529 > const chatSession = existing ?? await this._buildProvisionalChat(session, chat, entry); claudeAgent.ts ×10
1530 > // Resume when the SDK already has a transcript for this chat
1531 > // (forked or restored); otherwise materialize a fresh one.
1532 > const sdkInfo = await this._sdkService.getSessionInfo(chatSession.sessionId);
1533 > const transport = this._ensureAuthenticated();
1534 > const canUseTool = this._makeCanUseTool(chatSession.sessionId);
1535 > const onElicitation = this._makeOnElicitation(chatSession.sessionId);
1536 > try {
1537 > await chatSession.materialize({ transport, canUseTool, onElicitation, isResume: !!sdkInfo, serverToolHost: this._serverToolHost });
1538 > } catch (err) {
1539 entry.disposePeerChat(chatKey);
1540 throw err;
1541 }
1542 > return chatSession; claudeAgent.ts ×10
1543 > }
1545 > /**
1546 > * Build a provisional peer-chat {@link ClaudeAgentSession} from its live (or
1547 > * legacy) backing + overlay: its `sessionUri` is the real parent session URI
1548 > * and its `chatChannelUri` is the chat's own channel (never overloaded),
1549 > * backed by the resolved SDK chat id. Registers it on the owning
1550 > * {@link ClaudeSessionEntry}; the caller materializes it.
1551 > */
1552 > private async _buildProvisionalChat(session: URI, chat: URI, entry: ClaudeSessionEntry): Promise<ClaudeAgentSession> {
1553 > const info = this._resolveChatBacking(chat); claudeAgent.ts ×10
1554 > if (!info) {
1555 throw new Error(`[Claude] no backing chat for chat ${chat.toString()}`);
1556 }
1557 > const parentSession = await this._resolveParentSession(session, AgentSession.id(session)); claudeAgent.ts ×10
1558 > let overlay: IClaudeSessionOverlay = {};
1559 > try {
1560 > overlay = await this._metadataStore.read(chat);
1561 > } catch (err) {
1562 this._logService.warn(`[Claude] chat overlay read failed for ${chat.toString()}; continuing with defaults`, err);
1563 }
1564 > const permissionMode = readClaudePermissionMode(this._configurationService, chat) ?? overlay.permissionMode ?? parentSession.permissionMode; claudeAgent.ts ×10
1565 > // Overlay takes precedence over the backing: `changeModel` always writes
1566 > // the overlay first (via `setModel` or `_metadataStore.write`) and then
1567 > // the backing. If the backing update is lost, the overlay already holds
1568 > // the newest model; preferring it here ensures a model change is never
1569 > // silently reverted after a restart.
1570 > const model = overlay.model ?? info.model;
1571 > const chatSession = ClaudeAgentSession.createProvisional(
1572 > info.sdkSessionId,
1573 > session,
1574 > chat,
1575 > parentSession.workingDirectory,
1576 > parentSession.project,
1577 > model,
1578 > overlay.agent ?? parentSession.agent,
1579 > undefined,
1580 > new PendingRequestRegistry<CallToolResult>(),
1581 > permissionMode,
1582 > this._metadataStore,
1583 > this._instantiationService,
1584 > );
1585 > entry.registerPeerChat(chat.toString(), this._wireEntry(chatSession));
1586 > return chatSession;
1587 > }
1589 > /**
1590 > * Update a peer chat's live backing model and push the refreshed opaque
1591 > * `providerData` blob to the orchestrator (via
1592 > * {@link onDidChangeChatData}) so the durable catalog stays in sync.
1593 > */
1594 > private async _updateChatBackingModel(chat: URI, model: ModelSelection): Promise<void> {
1595 > const backing = this._resolveChatBacking(chat); claudeAgent.ts ×3
1596 > if (!backing) {
1597 return;
1598 }
1599 > const updated: IPersistedChat = { ...backing, model }; claudeAgent.ts ×3
1600 > this._chatBackings.set(chat.toString(), updated);
1601 > this._onDidChangeChatData.fire({ chat: chat, providerData: encodeProviderData(updated) });
1602 > }
1604 > /**
1605 > * Re-attach the in-memory backing for a peer chat on session restore,
1606 > * decoding the opaque `providerData` the orchestrator persisted at creation
1607 > * (or the latest {@link onDidChangeChatData}). After this resolves the
1608 > * chat's backing SDK chat can be resumed lazily on its first send.
1609 > * Best-effort — a corrupt/unknown blob is logged and dropped rather than
1610 > * thrown.
1611 > */
1612 > async materializeChat(chat: URI, providerData: string | undefined): Promise<void> {
1613 > if (isDefaultChatUri(chat)) { claudeAgent.ts ×7
1614 return;
1615 }
1616 > const chatInfo = parseChatUri(chat); claudeAgent.ts ×7
1617 > if (!chatInfo) {
1618 return;
1619 }
1620 > if (providerData === undefined) { claudeAgent.ts ×7
1621 return;
1622 }
1623 > const backing = decodeProviderData(providerData); claudeAgent.ts ×7
1624 > if (!backing) {
1625 this._logService.warn(`[Claude] materializeChat: dropping corrupt providerData for ${chat.toString()}`);
1626 return;
1627 }
1628 > this._chatBackings.set(chat.toString(), backing); claudeAgent.ts ×7
1629 > }
1631 > // #endregion
1632 >
1633 > /**
1634 > * Test-only accessor for the materialized {@link ClaudeAgentSession}.
1635 > * Phase 6 section 5.1 Test 10 needs to inspect `_isResumed` directly because
1636 > * Phase 6 has no teardown+recreate flow yet to observe its effect
1637 > * (the flag drives `Options.resume = sessionId` in Phase 7+). Marked
1638 > * `ForTesting` so the production surface stays unaware of its
1639 > * existence; the protocol surface (`IAgent`) does not include it.
1640 > */
1641 > getSessionForTesting(session: URI): ClaudeAgentSession | undefined {
1642 > const sess = this._sessions.get(AgentSession.id(session))?.defaultChat; claudeAgent.ts ×1
1643 > return sess?.isPipelineReady ? sess : undefined;
1644 > }
1646 > /**
1647 > * Phase 13 — reconstruct the full turn history from the SDK's on-disk
1648 > * JSONL transcript. Out-of-process: no live `Query` required. Subagent
1649 > * URIs (`<parent>/subagent/<toolCallId>`) throw `TODO: Phase 12` until
1650 > * Phase 12 wires `getSubagentMessages`. Provisional sessions return `[]`.
1651 > * Resilient: any failure (transcript fetch, mapping, backfill) warn-logs
1652 > * and returns `[]` rather than propagating — mirrors `listSessions`.
1653 > */
1654 > async getSessionMessages(session: URI): Promise<readonly Turn[]> {
1655 > // Don't trigger a cold SDK download just to reconstruct a transcript claudeAgent.ts ×4
1656 > // during restore (the renderer subscribes to the last-active session
1657 > // on startup). Mirrors `listSessions` / `getSessionMetadata`: when the
1658 > // SDK isn't local yet, defer with an empty transcript. The download
1659 > // fires (with host-level progress) once the user sends the first
1660 > // message, after which the transcript re-hydrates on the next restore.
1661 > if (!(await this._sdkService.canLoadWithoutDownload())) {
1662 > this._logService.info('[Claude] SDK not downloaded yet; deferring session messages until a session triggers the download'); claudeAgent.ts ×3
1663 > return [];
1664 > }
1665 > // Additional peer chat: reconstruct its own SDK chat (resolved claudeAgent.ts ×1
1666 > // from the catalog/in-memory), routed to the chat channel URI. Shares
1667 > // the same fetch+map path as the default chat via `_reconstructTurns`.
1668 > if (isSubagentSession(session)) {
1669 > const parsed = parseSubagentSessionUri(session); claudeAgent.ts ×2
1670 > const parentSession = parsed ? this._sessions.get(AgentSession.id(parsed.parentSession))?.defaultChat : undefined;
1671 > if (!parentSession) {
1672 > // Parent session is gone (disposed or never materialized).
1673 > // The registry that holds the agentId cache lives on the
1674 > // parent session, so we cannot resolve the subagent.
1675 > this._logService.warn(`[Claude] getSessionMessages: parent session not found for subagent ${session.toString()} (registry unavailable)`);
1676 > return [];
1677 > }
1678 try {
1679 return await getSubagentTranscript(session, parentSession.subagents, this._sdkService, this._logService, CancellationToken.None);
1680 } catch (err) {
1681 this._logService.warn(`[Claude] getSubagentTranscript threw for ${session.toString()}`, err);
1682 return [];
1683 }
1686 > const chat = parseChatUri(session) ? session : URI.parse(buildDefaultChatUri(session)); claudeAgent.ts ×4
1687 > const chatInfo = parseChatUri(chat);
1688 > if (!chatInfo) {
1689 return [];
1690 }
1691 > const parentSessionUri = URI.parse(chatInfo.session); claudeAgent.ts ×2
1692 > const sessionId = AgentSession.id(parentSessionUri);
1693 > const context = this._getChatContext(chat);
1694 > if (context.isPeerChat) {
1695 > const sdkId = await this._resolveChatSdkId(parentSessionUri, chat); claudeAgent.ts ×6
1696 > if (!sdkId) {
1697 return [];
1698 }
1699 > const turns = await this._reconstructTurns(sdkId, chat, context.target); claudeAgent.ts ×6
1700 > const sideChat = this._resolveChatBacking(chat)?.sideChat;
1701 > return stripSideChatContext(turns.slice(sideChat?.inheritedTurnCount ?? 0), sideChat);
1702 > }
1704 > const sess = context.target;
1705 > if (sess && !sess.isPipelineReady) { claudeAgent.ts ×4
1706 > return []; claudeAgent.ts ×1
1707 > }
1708 > // Default chat: its SDK chat id is the session id. claudeAgent.ts ×1
1709 > return this._reconstructTurns(sessionId, parentSessionUri, sess);
1712 > /**
1713 > * Fetch a chat's SDK transcript ({@link sdkSessionId}) and map it to
1714 > * protocol {@link Turn}s routed to {@link routingUri} (the session or chat
1715 > * channel URI). When {@link primeOn} is supplied (the materialized owning
1716 > * session), its subagent registry is primed from the agentId suffixes the
1717 > * SDK encoded in Task tool_result blocks. Resilient: any failure warn-logs
1718 > * and returns `[]` rather than propagating.
1719 > */
1720 > private async _reconstructTurns(sdkSessionId: string, routingUri: URI, primeOn: ClaudeAgentSession | undefined): Promise<readonly Turn[]> {
1721 > let messages; claudeAgent.ts ×3
1722 > try {
1723 > messages = await this._sdkService.getSessionMessages(sdkSessionId, { includeSystemMessages: true });
1724 > } catch (err) {
1725 > this._logService.warn(`[Claude] getSessionMessages SDK fetch failed for ${sdkSessionId}`, err); claudeAgent.ts ×1
1726 > return [];
1727 > }
1728 > let turns: readonly Turn[]; claudeAgent.ts ×3
1729 > try {
1730 > turns = mapSessionMessagesToTurns(messages, routingUri, this._logService);
1731 > } catch (err) {
1732 // Defensive boundary: a single malformed SDK message must not
1733 // blow up the entire transcript read.
1734 this._logService.warn(`[Claude] replay mapper threw for ${sdkSessionId}`, err);
1735 return [];
1736 }
1737 > // A bug in `primeFromTranscript` MUST NOT break an otherwise-successful claudeAgent.ts ×3
1738 > // transcript read.
1739 > try {
1740 > primeOn?.subagents.primeFromTranscript(turns); claudeAgent.ts ×3
1741 > } catch (err) {
1742 this._logService.warn(`[Claude] primeFromTranscript threw for ${sdkSessionId}`, err);
1743 }
1744 > return turns; claudeAgent.ts ×3
1747 > async listSessions(): Promise<IAgentSessionMetadata[]> {
1748 > // Plan section 3.3.2: SDK is the source of truth; we deliberately do claudeAgent.ts ×2
1749 > // NOT filter entries that lack a per-session DB — external Claude Code
1750 > // CLI sessions have no DB and must still surface (Phase-5 exit
1751 > // criterion). The projected metadata is derived purely from the SDK
1752 > // entry, so no per-session overlay read is needed here.
1753 > //
1754 > // `AgentService.listSessions` fans out across all providers via
1755 > // `Promise.all` (agentService.ts:202-204). If our SDK dynamic
1756 > // import fails (corrupt install, missing optional dep) and we let
1757 > // it reject, *every* provider's session list disappears — the
1758 > // sibling Copilot provider gets nuked too. Catch and log instead.
1759 > let sdkEntries: readonly SDKSessionInfo[];
1760 > try {
1761 > // Don't trigger a cold SDK download just to populate the session
1762 > // list at startup. When the SDK isn't local yet, surface an empty
1763 > // list; the download fires (with host-level progress) once the user
1764 > // starts a session, and the next `listSessions` — driven by the
1765 > // renderer's post-turn refresh — returns the full list.
1766 > if (!(await this._sdkService.canLoadWithoutDownload())) {
1767 > this._logService.info('[Claude] SDK not downloaded yet; deferring session list until a session triggers the download'); claudeAgent.ts ×3
1768 > return [];
1769 > }
1770 > sdkEntries = await this._sdkService.listSessions(); claudeAgent.ts ×1
1771 > } catch (err) {
1772 > this._logService.warn('[Claude] SDK listSessions failed; surfacing empty list', err); claudeAgent.ts ×1
1773 > return [];
1774 > }
1775 > return sdkEntries.map(entry => this._metadataStore.project(entry)); claudeAgent.ts ×1
1778 > /**
1779 > * Phase 6.1 / Cycle D4 — per-session lookup. Mirrors
1780 > * {@link CopilotAgent.getSessionMetadata} but accepts the
1781 > * external-CLI case: a session that exists on disk via the raw
1782 > * Anthropic CLI has no per-session DB, so we MUST NOT gate on the
1783 > * sidecar (the way Copilot's variant does). The SDK is the source
1784 > * of truth for existence.
1785 > *
1786 > * The projected metadata is derived purely from the SDK entry, so no
1787 > * per-session overlay read is needed. Failures in the SDK lookup
1788 > * propagate (the caller is doing a single targeted fetch and should
1789 > * learn that the SDK module is broken).
1790 > */
1791 > async getSessionMetadata(session: URI): Promise<IAgentSessionMetadata | undefined> {
1792 > // Don't trigger a cold SDK download just to hydrate session metadata claudeAgent.ts ×2
1793 > // during restore (the renderer subscribes to the last-active session
1794 > // on startup). Mirrors `listSessions` / `getSessionMessages`: when the
1795 > // SDK isn't local yet, defer. The download fires (with host-level
1796 > // progress) once the user sends the first message, after which the
1797 > // session re-hydrates on the next restore.
1798 > if (!(await this._sdkService.canLoadWithoutDownload())) {
1799 > this._logService.info('[Claude] SDK not downloaded yet; deferring session metadata until a session triggers the download'); claudeAgent.ts ×3
1800 > return undefined;
1801 > }
1802 > const sessionId = AgentSession.id(session); claudeAgent.ts ×1
1803 > const sdkInfo = await this._sdkService.getSessionInfo(sessionId);
1804 > if (!sdkInfo) {
1805 > return undefined;
1806 > }
1807 > return this._metadataStore.project(sdkInfo);
1810 > resolveSessionConfig(_params: IAgentResolveSessionConfigParams): Promise<ResolveSessionConfigResult> {
1811 > // Decision B5 (plan section 3.3.5): Claude collapses the platform's claudeAgent.ts ×1
1812 > // `autoApprove` × `mode` two-axis approval surface onto a single
1813 > // `permissionMode` axis matching the SDK's native enum. The
1814 > // platform `Permissions` key is reused unchanged because the
1815 > // Claude SDK accepts `allowedTools` / `disallowedTools`
1816 > // natively. Skipped: AutoApprove, Mode, Isolation, Branch,
1817 > // BranchNameHint — workbench pickers key off the property names
1818 > // to decide what to render, so omitting these intentionally
1819 > // suppresses the default mode/branch UI for Claude sessions.
1820 > const sessionSchema = createSchema({
1821 > [ClaudeSessionConfigKey.PermissionMode]: schemaProperty<ClaudePermissionMode>({
1822 > type: 'string',
1823 > title: localize('claude.sessionConfig.permissionMode', "Approvals"),
1824 > description: localize('claude.sessionConfig.permissionModeDescription', "How Claude handles tool approvals."),
1825 > enum: ['default', 'acceptEdits', 'plan', 'auto', 'bypassPermissions'],
1826 > enumLabels: [
1827 > localize('claude.sessionConfig.permissionMode.default', "Ask Before Edits"),
1828 > localize('claude.sessionConfig.permissionMode.acceptEdits', "Edit Automatically"),
1829 > localize('claude.sessionConfig.permissionMode.plan', "Plan Mode"),
1830 > localize('claude.sessionConfig.permissionMode.auto', "Auto Mode"),
1831 > localize('claude.sessionConfig.permissionMode.bypassPermissions', "Bypass Permissions"),
1832 > ],
1833 > enumDescriptions: [
1834 > localize('claude.sessionConfig.permissionMode.defaultDescription', "Claude asks before editing files."),
1835 > localize('claude.sessionConfig.permissionMode.acceptEditsDescription', "Claude edits files without asking, and asks before using other tools."),
1836 > localize('claude.sessionConfig.permissionMode.planDescription', "Claude creates a plan before making changes."),
1837 > localize('claude.sessionConfig.permissionMode.autoDescription', "Claude decides whether to ask for each tool operation."),
1838 > localize('claude.sessionConfig.permissionMode.bypassPermissionsDescription', "Claude runs all tools without asking."),
1839 > ],
1840 > default: 'default',
1841 > sessionMutable: true,
1842 > }),
1843 > [SessionConfigKey.Permissions]: platformSessionSchema.definition[SessionConfigKey.Permissions],
1844 > });
1845 >
1846 > const values = sessionSchema.validateOrDefault(_params.config, {
1847 > [ClaudeSessionConfigKey.PermissionMode]: 'default' satisfies ClaudePermissionMode,
1848 > // Permissions intentionally omitted from defaults — leave
1849 > // unset so auto-approval falls through to the host-level
1850 > // default, materializing on the session only once the user
1851 > // approves a tool "in this Session".
1852 > });
1853 >
1854 > return Promise.resolve({
1855 > schema: sessionSchema.toProtocol(),
1856 > values,
1857 > });
1858 > }
1860 > sessionConfigCompletions(_params: IAgentSessionConfigCompletionsParams): Promise<SessionConfigCompletionsResult> {
1861 > // Plan section 3.3.5: Claude's only schema property is the claudeAgent.ts ×1
1862 > // `permissionMode` static enum, so dynamic completion is
1863 > // definitionally empty in Phase 5. Branch completion lands in
1864 > // Phase 6 once worktree extraction (section 8) is settled.
1865 > return Promise.resolve({ items: [] });
1866 > }
1868 > shutdown(): Promise<void> {
1869 > // Phase 6: drain provisional sessions FIRST so any in-flight claudeAgent.ts ×3
1870 > // `await sdk.startup()` (kicked off by a racing `sendMessage`)
1871 > // observes the abort and unwinds. Each provisional record's
1872 > // AbortController is wired into Options.abortController at
1873 > // materialize time, so aborting here flips the same signal the
1874 > // SDK is racing on.
1875 > //
1876 > // Then drain the materialized sessions through the existing
1877 > // per-session {@link _disposeSequencer} routing — that path
1878 > // inherits Phase 6's real async teardown (`Query.interrupt()`,
1879 > // in-flight metadata writes) once those land.
1880 > //
1881 > // The promise is memoized so concurrent callers share a single
1882 > // drain pass — see `_shutdownPromise` JSDoc.
1883 > // NOTE: declared sync (returns Promise<void>) rather than async
1884 > // so that re-entrant calls return the cached promise *identity*,
1885 > // not a fresh outer-async wrapper around it.
1886 > return this._shutdownPromise ??= (async () => {
1887 > for (const entry of this._sessions.values()) {
1888 > // Provisional chats (a default or peer whose first send's claudeAgent.ts ×2
1889 > // materialize is in-flight) race on their own abort controller —
1890 > // abort them up front so a queued `sdk.startup()` unwinds
1891 > // promptly rather than running past shutdown until its teardown
1892 > // task dequeues.
1893 > for (const chat of entry.allChatSessions()) {
1894 > if (!chat.isPipelineReady) {
1895 > chat.abortController.abort();
1896 > }
1897 > }
1898 > }
1900 > const sessionIds = [...this._sessions.keys()];
1901 > await Promise.all(sessionIds.map(sessionId =>
1902 > this._disposeSequencer.queue(sessionId, async () => { claudeAgent.ts ×2
1903 > await this._teardownEntry(sessionId);
1904 > this._pruneActiveClientHandles(sessionId);
1905 > })
1906 > )); claudeAgent.ts ×3
1907 > })();
1908 > }
1910 > private async _sendMessage(chat: URI, prompt: string, workingDirectory: URI | undefined, attachments?: readonly MessageAttachment[], turnId?: string, _senderClientId?: string): Promise<void> {
1911 > // `IAgent.sendMessage` declares `turnId?` but every production caller in claudeAgent.ts ×2
1912 > // `AgentSideEffects` supplies one. Generate a fallback so the
1913 > // session-side `QueuedRequest.turnId: string` invariant holds even if a
1914 > // hypothetical caller forgets it.
1915 > const effectiveTurnId = turnId ?? generateUuid();
1916 > const context = this._getChatContext(chat);
1917 >
1918 > // Additional peer chat: route to its own chat. Its SDK
1919 > // `session_id` is the chat's chat id, NOT the parent session's.
1920 > // Hold the per-chat lock across BOTH materialize and send (mirroring the
1921 > // default-chat path below) so concurrent sends to the same peer chat
1922 > // serialize and a racing disposeChat/disposeSession (which queue on the
1923 > // same chat key) waits for the in-flight turn instead of disposing the
1924 > // session under it.
1925 > if (context.isPeerChat) {
1926 > return this._sessionSequencer.queue(context.chatKey, async () => { claudeAgent.ts ×10
1927 > const chatSession = await this._materializeChatLocked(context.session, chat);
1928 > const sideChat = this._resolveChatBacking(chat)?.sideChat;
1929 > const turns = sideChat ? await this._reconstructTurns(chatSession.sessionId, chat, chatSession) : [];
1930 > const sdkPrompt = prepareSideChatPrompt(prompt, turns, sideChat);
1931 > await chatSession.send(this._buildSdkPrompt(chatSession.sessionId, sdkPrompt, attachments, effectiveTurnId), effectiveTurnId);
1932 > });
1933 > }
1935 > // Plan section 3.8. The sequencer scope holds across BOTH materialize
1936 > // and `session.send` so two concurrent first-message calls on the
1937 > // same session collapse into one materialize plus two ordered
1938 > // sends. A `disposeSession` racing a first send reaches its own
1939 > // dispose-sequencer eventually but the in-flight materialize
1940 > // completes first.
1941 > return this._sessionSequencer.queue(context.sessionId, async () => {
1942 > const existing = this._getChatContext(chat).target;
1943 > let session: ClaudeAgentSession;
1944 > if (existing?.isPipelineReady) {
1945 > session = existing; claudeAgent.ts ×1
1946 > } else if (existing) { claudeAgent.ts ×3
1947 > session = await this._materializeProvisional(context.sessionId, workingDirectory); claudeAgent.ts ×4
1948 > } else { claudeAgent.ts ×1
1949 > session = await this._resumeSession(context.sessionId, context.session); claudeAgent.ts ×1
1952 > await session.send(this._buildSdkPrompt(context.sessionId, prompt, attachments, effectiveTurnId), effectiveTurnId);
1953 > }); claudeAgent.ts ×3
1956 > /** Builds the SDK user message for a send, addressed to `sdkSessionId`. */
1957 > private _buildSdkPrompt(sdkSessionId: string, prompt: string, attachments: readonly MessageAttachment[] | undefined, turnId: string): SDKUserMessage {
1958 > const contentBlocks = resolvePromptToContentBlocks(prompt, attachments); claudeSdkPipeline.ts ×4
1959 > return {
1960 > type: 'user',
1961 > message: { role: 'user', content: contentBlocks },
1962 > session_id: sdkSessionId,
1963 > parent_tool_use_id: null,
1964 > // M1 / Glossary: `Turn.id ↔ SDKUserMessage.uuid`. The SDK types this
1965 > // as a branded `${string}-…` template-literal alias of Node's
1966 > // `crypto.UUID`; cast at the boundary rather than threading the brand
1967 > // up to every caller.
1968 > uuid: turnId as `${string}-${string}-${string}-${string}-${string}`,
1969 > };
1970 > }
1972 > respondToPermissionRequest(requestId: string, approved: boolean): void {
1973 > // `requestId` is the SDK's `tool_use_id` — globally unique, so a claudeAgent.ts ×2
1974 > // single matching chat is all we need. Silent on miss (workbench may
1975 > // have raced a session dispose).
1976 > for (const sess of this._allLiveSessions()) {
1977 > if (sess.respondToPermissionRequest(requestId, approved)) { claudeAgent.ts ×1
1978 > return;
1979 > }
1980 > }
1983 > respondToUserInputRequest(requestId: string, response: ChatInputResponseKind, answers?: Record<string, ChatInputAnswer>): void {
1984 > // `requestId` is the SDK's `tool_use_id` (interactive tools reuse it as claudeAgent.ts ×2
1985 > // the {@link ChatInputRequest.id}); globally unique, so a single
1986 > // matching chat is all we need. Silent on miss for the same reasons as
1987 > // {@link respondToPermissionRequest}.
1988 > for (const sess of this._allLiveSessions()) {
1989 > if (sess.respondToUserInputRequest(requestId, response, answers)) { claudeAgent.ts ×1
1990 > return;
1991 > }
1992 > }
1995 > /** Every live chat — each session's default chat and its peers. */
1996 > private _allLiveSessions(): ClaudeAgentSession[] {
1997 > const all: ClaudeAgentSession[] = []; claudeAgent.ts ×2
1998 > for (const entry of this._sessions.values()) {
1999 > all.push(...entry.allChatSessions()); claudeAgent.ts ×1
2000 > }
2001 > return all; claudeAgent.ts ×2
2002 > }
2004 > private async _abortSession(chat: URI): Promise<void> {
2005 > // Phase 9 D1: cancel via the abort controller, NOT `Query.interrupt()`. claudeAgent.ts ×2
2006 > // Abort is a control-plane operation — it must NOT serialize
2007 > // through `_sessionSequencer` because an in-flight `sendMessage`
2008 > // task is parked on its turn deferred and would deadlock the abort
2009 > // behind the very turn it's trying to cancel. Calling
2010 > // `chat.abort()` directly rejects the in-flight deferred,
2011 > // which lets the queued sendMessage task complete and frees the
2012 > // sequencer for the next caller.
2013 > const sess = this._getChatContext(chat).target;
2014 > if (!sess) {
2015 > return; claudeAgent.ts ×2
2016 > }
2017 > if (!sess.isPipelineReady) { claudeAgent.ts ×2
2018 sess.abortController.abort();
2019 return;
2020 }
2021 > sess.abort(); claudeAgent.ts ×2
2024 > setPendingMessages(chat: URI, steeringMessage: PendingMessage | undefined, _queuedMessages: readonly PendingMessage[]): void {
2025 > // Phase 9 D5: queued messages are intentionally a no-op. CONTEXT.md claudeAgent.ts ×3
2026 > // M10 + AgentSideEffects confirm queued messages are consumed
2027 > // server-side; the agent boundary always receives an empty queue.
2028 > //
2029 > // Steering targets the chat that owns the in-flight turn — the caller
2030 > // always addresses a concrete chat channel (the session's default chat
2031 > // or an additional peer chat).
2032 > const context = this._getChatContext(chat);
2033 > this._logService.info(`[Claude] setPendingMessages for ${chat.toString()}: steering=${steeringMessage?.id ?? 'none'} queued=${_queuedMessages.length}`);
2034 > if (!context.target) {
2035 > this._logService.warn(`[Claude] setPendingMessages: chat not found for ${chat.toString()}`); claudeAgent.ts ×1
2036 > return;
2037 > }
2038 > if (steeringMessage) { claudeAgent.ts ×3
2039 > context.target.injectSteering(steeringMessage); claudeSdkPipeline.ts ×3
2040 > }
2043 > /**
2044 > * Forward a user/picker `permissionMode` change to the running SDK so it
2045 > * applies to the next tool this turn, not only from the next `send()`
2046 > * (issue #321691). Only fires for client-originated changes (the host routes
2047 > * internal server writes elsewhere), so this can forward without re-entering
2048 > * a `canUseTool` callback.
2049 > *
2050 > * `permissionMode` is a **session-scoped** config value today (AHP has no
2051 > * per-chat config), so — matching Copilot's session-scoped approvals — we
2052 > * apply it to EVERY materialized chat's `Query` in the session, not just the
2053 > * one the change arrived on. A `replace` that deletes the key resolves to the
2054 > * chat's `permissionModeFallback`, the same value the next `send()` would
2055 > * apply, so live state mirrors the reducer. Provisional chats are skipped —
2056 > * their first `send()` seeds the mode into `Options.permissionMode`. Fire-and-
2057 > * forget: the SDK control round-trip isn't awaited here; the pipeline caches
2058 > * the mode so a later rebind / send re-applies it.
2059 > *
2060 > * TODO: adopt per-chat config when the protocol allows for such — see
2061 > * https://github.com/microsoft/agent-host-protocol/issues/335 — so a picker
2062 > * change scopes to its own chat instead of the whole session.
2063 > */
2064 > onSessionConfigChanged(session: URI, values: Record<string, unknown>): void {
2065 > const entry = this._sessions.get(this._getChatContext(session).sessionId); claudeAgent.ts ×4
2066 > if (!entry) {
2067 return;
2068 }
2069 > const narrowed = narrowClaudePermissionMode(values[ClaudeSessionConfigKey.PermissionMode]); claudeAgent.ts ×4
2070 > for (const chat of entry.allChatSessions()) {
2071 > if (!chat.isPipelineReady) {
2072 continue;
2073 }
2074 > const mode = narrowed ?? chat.permissionModeFallback; claudeAgent.ts ×4
2075 > chat.setPermissionMode(mode).catch(err => {
2076 this._logService.warn(`[Claude:${chat.sessionId}] mid-turn setPermissionMode(${mode}) failed`, err);
2077 > }); claudeAgent.ts ×4
2078 > }
2079 > }
2081 > private async _changeModel(chat: URI, model: ModelSelection): Promise<void> {
2082 > const context = this._getChatContext(chat); claudeAgent.ts ×4
2083 > const queueKey = context.isPeerChat ? context.chatKey : context.sessionId;
2084 > await this._sessionSequencer.queue(queueKey, async () => {
2085 > const current = this._getChatContext(chat);
2086 > const sess = current.target;
2087 > if (sess) {
2088 > await sess.setModel(model); claudeAgentSession.ts ×2
2089 > } else if (current.isPeerChat) { claudeAgent.ts ×4
2090 > await this._metadataStore.write(chat, { model }); claudeAgent.ts ×1
2091 > } else { claudeAgent.ts ×1
2092 > await this._metadataStore.write(current.session, { model }); claudeAgent.ts ×2
2093 > }
2094 > if (current.isPeerChat) { claudeAgent.ts ×4
2095 > await this._updateChatBackingModel(chat, model); claudeAgent.ts ×3
2096 > }
2097 > }); claudeAgent.ts ×4
2098 > }
2100 > /**
2101 > * Switch (or clear with `undefined`) the selected custom agent for an
2102 > * existing session. Mirrors {@link changeModel}: session owns its
2103 > * provisional/runtime branching and metadata write
2104 > * (see {@link ClaudeAgentSession.setAgent}). For external-only
2105 > * sessions (no in-memory record), the agent is persisted directly to
2106 > * the overlay so a later resume picks it up. When `chat` is an additional
2107 > * peer chat, the change targets that chat's chat.
2108 > */
2109 > private async _changeAgent(chat: URI, agent: AgentSelection | undefined): Promise<void> {
2110 > const context = this._getChatContext(chat); claudeAgent.ts ×3
2111 > const queueKey = context.isPeerChat ? context.chatKey : context.sessionId;
2112 > await this._sessionSequencer.queue(queueKey, async () => {
2113 > const current = this._getChatContext(chat);
2114 > const sess = current.target;
2115 > if (sess) {
2116 > await sess.setAgent(agent); claudeAgentSession.ts ×3
2117 > } else { claudeAgent.ts ×3
2118 > await this._metadataStore.write(current.isPeerChat ? chat : current.session, { agent: agent ?? null }); claudeSessionMetadataStore.ts ×2
2119 > }
2120 > }); claudeAgent.ts ×3
2121 > }
2123 > setServerToolHost(host: IAgentServerToolHost): void {
2124 > this._serverToolHost = host; agentService.ts ×1
2125 > }
2127 > getOrCreateActiveClient(session: URI, client: { readonly clientId: string; readonly displayName?: string }): IActiveClient {
2128 > const sessionId = AgentSession.id(session); claudeAgent.ts ×3
2129 > const key = `${sessionId}\u0000${client.clientId}`;
2130 > let handle = this._activeClientHandles.get(key);
2131 > if (!handle) {
2132 > handle = new ClaudeActiveClientHandle(
2133 > client.clientId,
2134 > client.displayName,
2135 > () => this._findAnySession(sessionId)?.getClientTools(client.clientId) ?? [],
2136 > tools => {
2137 > this._logService.info(`[Claude:${sessionId}] active client ${client.clientId} tools=[${tools.map(t => t.name).join(', ') || '(none)'}]`);
2138 > this._findAnySession(sessionId)?.setClientTools(client.clientId, tools);
2139 > },
2140 > customizations => { void this.syncClientCustomizations(session, client.clientId, [...customizations]); },
2141 > );
2142 > this._activeClientHandles.set(key, handle);
2143 > }
2144 > return handle;
2145 > }
2147 > removeActiveClient(session: URI, clientId: string): void {
2148 const sessionId = AgentSession.id(session);
2149 this._activeClientHandles.delete(`${sessionId}\u0000${clientId}`);
2150 // Tools are written synchronously, so remove them immediately. The
2151 // customization sync runs inside the session sequencer, so serialize
2152 // its removal there too — otherwise a late in-flight sync could
2153 // resurrect the removed client's customizations after it has left.
2154 this._findAnySession(sessionId)?.removeClientTools(clientId);
2155 void this._sessionSequencer.queue(sessionId, async () => {
2156 this._findAnySession(sessionId)?.removeClientCustomizations(clientId);
2157 }).catch(() => { /* session torn down */ });
2158 }
2160 > /** Drop cached active-client handles belonging to a session being torn down. */
2161 > private _pruneActiveClientHandles(sessionId: string): void {
2162 > const prefix = `${sessionId}\u0000`; claudeAgent.ts ×5
2163 > for (const key of [...this._activeClientHandles.keys()]) {
2164 > if (key.startsWith(prefix)) { claudeAgent.ts ×1
2165 > this._activeClientHandles.delete(key);
2166 > }
2167 > }
2170 > onClientToolCallComplete(session: URI, _chat: URI, toolCallId: string, result: ToolCallResult): void {
2171 > let target = session; claudeAgent.ts ×2
2172 > let parsed;
2173 > while ((parsed = parseSubagentSessionUri(target))) {
2174 > target = parsed.parentSession; claudeAgent.ts ×1
2175 > }
2176 > const sessionId = AgentSession.id(target); claudeAgent.ts ×2
2177 > const entry = this._sessions.get(sessionId);
2178 > // `AgentSideEffects` forwards every `ChatToolCallComplete` envelope
2179 > // (including SDK-owned tools); silent on miss is the expected path.
2180 > entry?.defaultChat?.completeClientToolCall(toolCallId, result);
2181 > }
2183 > async syncClientCustomizations(session: URI, clientId: string, customizations: ClientPluginCustomization[], options?: { readonly quiet?: boolean }): Promise<ISyncedCustomization[]> {
2184 > const sessionId = AgentSession.id(session); claudeAgent.ts ×2
2185 > const sess = this._findAnySession(sessionId);
2186 > if (!sess) {
2187 this._logService.warn(`[Claude:${sessionId}] syncClientCustomizations: session not found`);
2188 return [];
2189 }
2190 > // Run inside the session sequencer so that a fire-and-forget claudeAgent.ts ×2
2191 > // customization sync cannot race ahead of a first `sendMessage`: if
2192 > // `sendMessage` is already queued, the sync runs first or queues
2193 > // behind it; either way the materialize call reads the most recently
2194 > // adopted plugin set, never an empty one mid-sync.
2195 > return this._sessionSequencer.queue(sessionId, async () => {
2196 > const synced = await this._pluginManager.syncCustomizations(
2197 > clientId,
2198 > customizations,
2199 > options?.quiet ? undefined : status => this._fireCustomizationUpdated(session, { customization: status }),
2200 > );
2201 > sess.adoptClientCustomizations(clientId, synced);
2202 > return synced;
2203 > });
2204 > }
2206 > /**
2207 > * Project a per-item sync result onto a `SessionCustomizationUpdated`
2208 > * action and emit it on {@link onDidSessionProgress}. Lets the workbench
2209 > * flip each row to `Loaded` / `Error` as the underlying
2210 > * {@link IAgentPluginManager.syncCustomizations} resolves it.
2211 > */
2212 > private _fireCustomizationUpdated(session: URI, item: ISyncedCustomization): void {
2213 > this._onDidSessionProgress.fire({ claudeAgent.ts ×1
2214 > kind: 'action',
2215 > resource: session,
2216 > action: {
2217 > type: ActionType.SessionCustomizationUpdated,
2218 > customization: item.customization,
2219 > },
2220 > });
2221 > }
2223 > getCustomizations(): readonly Customization[] {
2224 > // Provider-level customization catalogue — feeds `AgentInfo.customizations` claudeAgent.ts ×1
2225 > // on `RootAgentsChanged`. Should advertise host-configured plugin refs
2226 > // (the equivalent of Copilot's `agentHost.customizations` setting).
2227 > // Claude has no such surface today; returning `[]` is correct rather
2228 > // than aggregating client-pushed refs (those live on
2229 > // `activeClient.customizations` per session).
2230 > //
2231 > // TODO: when host-level customizations become a real concept for the
2232 > // agent host, lift `PluginController` out of `copilot/copilotAgent.ts`
2233 > // into a shared service so both providers consume the same configured
2234 > // host customization list rather than each maintaining their own.
2235 > return [];
2236 > }
2238 > async getSessionCustomizations(session: URI): Promise<readonly Customization[]> {
2239 > const sess = this._findAnySession(AgentSession.id(session)); claudeAgentSession.ts ×3
2240 > return sess ? await sess.getSessionCustomizations() : [];
2241 > }
2243 > async startMcpServer(session: URI, id: string): Promise<void> {
2244 const sess = this._findAnySession(AgentSession.id(session));
2245 await sess?.startMcpServer(id);
2246 }
2248 > async stopMcpServer(session: URI, id: string): Promise<void> {
2249 const sess = this._findAnySession(AgentSession.id(session));
2250 await sess?.stopMcpServer(id);
2251 }
2253 > // #endregion
2254 >
2255 > override dispose(): void {
2256 > // Phase 6+ INVARIANT: SDK Query subprocesses (owned by individual claudeAgent.ts ×7
2257 > // ClaudeAgentSession wrappers) MUST die BEFORE the proxy handle
2258 > // is disposed. After proxy disposal the proxy may rebind on a
2259 > // different port and a still-running subprocess would silently
2260 > // lose its endpoint. See `IClaudeProxyHandle` doc in
2261 > // `claudeProxyService.ts`.
2262 > //
2263 > // Step 1: abort every provisional AbortController. These are
2264 > // the same controllers wired into `Options.abortController` at
2265 > // materialize time (sdk.d.ts:982), so any in-flight
2266 > // `await sdk.startup()` will reject and any sequencer-queued
2267 > // `_materializeProvisional` continuation will trip its
2268 > // post-startup or post-customization-write abort gates,
2269 > // disposing the WarmQuery without ever reaching
2270 > // `_sessions.set(...)`. Without this step, dispose during a
2271 > // concurrent first `sendMessage` could orphan a WarmQuery
2272 > // subprocess. (Copilot reviewer: dispose lifecycle.)
2273 > //
2274 > // Step 2: `super.dispose()` synchronously disposes the
2275 > // `_sessions` DisposableMap, firing each session wrapper's
2276 > // `dispose()` (which interrupts/asyncDisposes its WarmQuery).
2277 > //
2278 > // Step 3: only then release the proxy handle, preserving the
2279 > // wrapper-before-proxy ordering invariant. This is locked by
2280 > // test "dispose disposes the proxy handle and is idempotent".
2281 > for (const entry of this._sessions.values()) {
2282 > for (const chat of entry.allChatSessions()) { claudeAgent.ts ×2
2283 > if (!chat.isPipelineReady) {
2284 > chat.abortController.abort(); claudeAgent.ts ×1
2285 > }
2287 > }
2288 > super.dispose(); claudeAgent.ts ×7
2289 > this._proxyHandle?.dispose();
2290 > this._proxyHandle = undefined;
2291 > this._githubToken = undefined;
2292 > this._models.set([], undefined);
2293 > }
2295 >
2296 > /**
2297 > * Per-session container. Owns the session's default (main) chat and any
2298 > * additional peer chats — each a {@link ClaudeAgentSession} plus the
2299 > * event-forwarding subscriptions registered against it (e.g. the agent's
2300 > * forward subscription to the session's `onDidSessionProgress` event). A single
2301 > * {@link ClaudeAgent._sessions} map of these entries keeps all chats of a
2302 > * session together (no parallel maps), so dispatch resolves a chat by looking
2303 > * up its owning session and then the chat within it. Disposing the entry
2304 > * disposes the session AND every extra registered via
2305 > * {@link AgentSessionEntry.addDisposable}.
2306 > */
2307 > class ClaudeSessionEntry extends AgentSessionEntry<ClaudeAgentSession> {
2308 > /** Claude sessions always have a materialized default chat once seeded. */
2309 > override get defaultChat(): ClaudeAgentSession {
2310 > return super.defaultChat!; claudeAgent.ts ×1
2311 > }