177
private readonly _syncCustomizations: (customizations: readonly ClientPluginCustomization[]) => void,
178
) { }
180
>
get tools(): readonly ToolDefinition[] {
181
return this._getTools();
182
}
184
this._setTools(tools);
185
}
187
>
get customizations(): readonly ClientPluginCustomization[] {
188
return this._customizations;
189
}
190
>
set customizations(customizations: readonly ClientPluginCustomization[]) {
claudeAgent.ts
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));
360
if (!entry) {