claudeSdkPipeline.ts ×26

Frontier kind: Code frontier

unlabeled · c_94c04d138987

217 tests · 28083 LOC · 127 files · introduces 0 tests · 358 LOC · 1 file

Introduces — evidence that enters the hierarchy at this concept

Code
26 ranges358 lines · 1 files
Tests
0 tests

Contains — complete concept membership

All code (extent)
2302 ranges28083 lines · 127 files · Browse complete extent
All tests (intent)
217 testsBrowse complete intent

Neighbourhood graph

The orange circle is the focus. Violet and green circles are every ancestor and descendant, broader and narrower, at any distance; blue squares and pink diamonds are the introduced files and exact introduced tests of every visible concept, not only the focus's. Arrows point from broader to narrower concepts and bridge only concepts omitted from this view. Undirected links show source or test introduction. Concept and file size follows LOC; exact test nodes use test-count units.

Introduced files, introduced tests, and structurally relevant concept specialization

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 native relationship evidence on this page.

Graph controls are ready.

Interactive rendering requires JavaScript and WebGL. Use the native relationship evidence on this page while the interactive map is unavailable.

Native relationship evidence

Every exact file and test below is linked only from the concept that introduces it.

Introduced tests

Every collected test enters the hierarchy at exactly one concept.

No tests are introduced at this concept. Its intent tests are introduced by other concepts.

Introduced code

Every collected source range enters the hierarchy at exactly one concept.

1 file ranked by introduced lines: 358 introduced LOC across 26 ranges. Expand a file to inspect source; the > gutter marks introduced lines.

src/vs/platform/agentHost/node/claude/claudeSdkPipeline.ts 358 introduced LOC · 26 ranges

Open complete file

1 > /*--------------------------------------------------------------------------------------------- claudeSdkPipeline.ts
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 { AgentInfo, McpServerStatus, PermissionMode, Query, SDKUserMessage, SlashCommand, WarmQuery } from '@anthropic-ai/claude-agent-sdk';
7 > import { CancellationError, isCancellationError } from '../../../../base/common/errors.js';
8 > import { Emitter, Event } from '../../../../base/common/event.js';
9 > import { Disposable, IReference, toDisposable } from '../../../../base/common/lifecycle.js';
10 > import { StopWatch } from '../../../../base/common/stopwatch.js';
11 > import { URI } from '../../../../base/common/uri.js';
12 > import { IInstantiationService } from '../../../instantiation/common/instantiation.js';
13 > import { ILogService } from '../../../log/common/log.js';
14 > import { ClaudeRuntimeEffortLevel } from '../../common/claudeModelConfig.js';
15 > import { AgentSignal } from '../../common/agentService.js';
16 > import { ISessionDatabase } from '../../common/sessionDataService.js';
17 > import { ActionType } from '../../common/state/sessionActions.js';
18 > import { DeferredPromise } from '../../../../base/common/async.js';
19 > import { ClaudePromptQueue, IPendingSdkMessage } from './claudePromptQueue.js';
20 > import { ClaudeSdkMessageRouter } from './claudeSdkMessageRouter.js';
21 > import type { SubagentRegistry } from './claudeSubagentRegistry.js';
22 >
23 > /**
24 > * Callback the agent supplies via {@link ClaudeSdkPipeline.attachRematerializer}
25 > * so the pipeline can rebuild its underlying {@link WarmQuery} /
26 > * {@link AbortController} on abort or crash recovery without depending on
27 > * the materializer service directly. The callback MUST start the SDK in
28 > * `resume` mode (i.e. pass `Options.resume = sessionId` instead of
29 > * `Options.sessionId`) and MUST NOT re-fire the agent's
30 > * `onDidMaterializeSession` event — that event is once-per-provisional
31 > * promotion (see `claudeAgent.ts` materialize path).
32 > */
33 > export interface IRematerializer {
34 > (reason: 'restart' | 'recover'): Promise<{ readonly warm: WarmQuery; readonly abortController: AbortController }>;
35 > }
36 >
37 > /**
38 > * Owns one SDK Query lifecycle for a Claude session. Knows nothing about
39 > * protocol turns, the workbench mapper, file-edit observers, or
40 > * permission registries — the consuming session subscribes to
41 > * {@link onDidProduceSignal} and fans out to its own collaborators.
42 > *
43 > * Responsibilities:
44 > * • Hold the {@link WarmQuery} + {@link AbortController} for the
45 > * active SDK subprocess. Both are mutable: rebind on abort/crash
46 > * recovery via the supplied {@link IRematerializer}.
47 > * • Drive a {@link ClaudePromptQueue} whose iterable is handed to
48 > * `WarmQuery.query()`.
49 > * • Apply the current model / effort / permissionMode to the SDK
50 > * eagerly when the consumer calls {@link setModel} /
51 > * {@link setEffort} / {@link setPermissionMode}. The SDK only takes
52 > * these into account on the NEXT user request, so mid-turn calls
53 > * are safe — no need to align the SDK setter with the prompt yield.
54 > * Re-applied to a fresh Query on rebind.
55 > * • Drain the SDK message stream, dispatch each message to the
56 > * {@link ClaudeSdkMessageRouter}, settle the matching entry's
57 > * deferred on `result`, and emit `ChatTurnComplete` only when
58 > * the queue fully drains (intermediate results during steering
59 > * preemption do NOT fire turn-complete — CONTEXT.md M10).
60 > *
61 > * Disposing the pipeline aborts the controller (terminating the SDK
62 > * subprocess per `sdk.d.ts:982`) and async-disposes the WarmQuery.
63 > */
64 > /**
65 > * Snapshot of everything the SDK has currently resolved for this
66 > * session. Returned by {@link ClaudeSdkPipeline.snapshotResolvedCustomizations}.
67 > */
68 > export interface ISdkResolvedCustomizations {
69 > readonly commands: readonly SlashCommand[];
70 > readonly agents: readonly AgentInfo[];
71 > readonly mcpServers: readonly McpServerStatus[];
72 > /**
73 > * Native plugins the live session actually loaded, as reported by the
74 > * SDK `system/init` message. Used to filter the disk-discovered native
75 > * plugins post-materialize: a plugin declared in `enabledPlugins` but
76 > * absent here (bad path, manifest error, untrusted workspace) is hidden.
77 > *
78 > * `source` is the plugin id (`<plugin>@<marketplace>`) and is the
79 > * authoritative match key — the SDK's `path` is unreliable for
80 > * workspace-`local`-scoped plugins (it can report a non-cache path). The
81 > * SDK `.d.ts` types the element as `{ name, path }` but the runtime adds
82 > * `source`, so it is captured as optional.
83 > */
84 > readonly plugins: readonly { readonly name: string; readonly path: string; readonly source?: string }[];
85 > }
86 >
87 > export class ClaudeSdkPipeline extends Disposable {
88 > /**
89 > * Phase 11 — hot-swap the SDK's plugin set in place via
90 > * `Query.reloadPlugins()`. Commands / agents / mcpServers added or
91 > * removed by the new plugin set become visible to the SDK
92 > * immediately, without a session restart. Throws if the query is
93 > * not yet bound (session not materialized).
94 > */
95 > async reloadPlugins(): Promise<void> {
96 > const query = await this._ensureQueryBound();
97 > await query.reloadPlugins();
98 > }
99 >
100 > /**
101 > * Phase 11 — snapshot the SDK's currently-resolved customization
102 > * surface (slash commands / skills, subagents, MCP servers). This
103 > * is the SDK's view of "what does this session actually have
104 > * access to right now" — covers everything the SDK loaded itself
105 > * (`~/.claude/**`, `.claude/agents/`, `settings.json` MCP) AND
106 > * anything we fed in via `Options.plugins`. The host overlays
107 > * client-side enablement separately.
108 > */
109 > async snapshotResolvedCustomizations(): Promise<ISdkResolvedCustomizations> {
110 const query = await this._ensureQueryBound();
111 const [commands, agents, mcpServers] = await Promise.all([
116 return { commands, agents, mcpServers, plugins: this._initPlugins };
117 }
119 > async startMcpServer(serverName: string): Promise<boolean> {
120 const query = await this._ensureQueryBound();
121 return this._applyMcpServerEnablement(query, serverName, true);
122 }
124 > async stopMcpServer(serverName: string): Promise<boolean> {
125 const query = await this._ensureQueryBound();
126 return this._applyMcpServerEnablement(query, serverName, false);
127 }
129 > async reconcileMcpServerEnablement(desired: ReadonlyMap<string, boolean>): Promise<boolean> {
130 const query = await this._ensureQueryBound();
131 const observed = new Map((await query.mcpServerStatus()).map(server => [server.name, server.status !== 'disabled']));
140 return true;
141 }
143 > private async _applyMcpServerEnablement(query: Query, serverName: string, enabled: boolean): Promise<boolean> {
144 if (!query.toggleMcpServer || (enabled && !query.reconnectMcpServer)) {
145 return false;
151 return true;
152 }
154 > /**
155 > * Bind the SDK Query if needed, recovering a dead one first. Mirrors the
156 > * gate in {@link send}: if the pipeline is marked for rebind (after an
157 > * abort/crash the `_query` handle is retained for teardown but its stream
158 > * is dead), rebuild via the rematerializer so pre-flight helpers never
159 > * operate on a disposed stream. Then lazily bind if nothing is bound yet.
160 > */
161 > private async _ensureQueryBound(): Promise<Query> {
162 if (this._needsRebind) {
163 await this._rebindQuery('recover');
169 return this._query!;
170 }
172 > /**
173 > * Bind a fresh SDK stream off the current warm subprocess. The stream is
174 > * long-lived: it spans every turn until a rebind swaps the subprocess (the
175 > * prompt iterable parks between turns rather than ending), so {@link _query}
176 > * tracks the lifetime of {@link _warm} and is only swapped here.
177 > */
178 > private _bindWarmQuery(): Query {
179 const query = this._warm.query(this._queue.iterable);
180 this._query = query;
181 return query;
182 }
184 > /**
185 > * The SDK stream bound to the current {@link _warm} subprocess, or
186 > * `undefined` before the first bind. Health is tracked separately by
187 > * {@link _needsRebind}: a non-`undefined` `_query` with `_needsRebind`
188 > * set is a *dead* stream awaiting rebuild. Cleared only on dispose.
189 > */
190 > private _query: Query | undefined;
191 > private _warm: WarmQuery;
192 > private _abortController: AbortController;
193 >
194 > private readonly _queue: ClaudePromptQueue;
195 >
196 > /** Flips to `true` on the first `system:init` SDK message. Drives `Options.resume` decisions for downstream phases. */
197 > private _isResumed = false;
198 >
199 > /**
200 > * Native plugins reported by the most recent `system:init` message.
201 > * Captured on *every* init (including resume) so the post-materialize
202 > * native-plugin filter always reflects the live set. `source` is the
203 > * plugin id and is the reliable match key (see {@link ISdkResolvedCustomizations}).
204 > */
205 > private _initPlugins: readonly { readonly name: string; readonly path: string; readonly source?: string }[] = [];
206 >
207 > /** Last model / effort / permission mode applied to the SDK via the runtime setters. Reset on rebind. */
208 > private _appliedModel: string | undefined;
209 > private _appliedEffort: ClaudeRuntimeEffortLevel | undefined;
210 > private _appliedPermissionMode: PermissionMode | undefined;
211 >
212 > /** Current values the consumer has asked for. Replayed to a fresh Query on bind / rebind. */
213 > private _currentModel: string | undefined;
214 > private _currentEffort: ClaudeRuntimeEffortLevel | undefined;
215 > private _currentPermissionMode: PermissionMode | undefined;
216 >
217 > private _rematerializer: IRematerializer | undefined;
218 >
219 > /** Set when the consumer loop ends in error (cancellation OR crash). Read by {@link send} to trigger rebind. */
220 > private _needsRebind = false;
221 >
222 > /** Tracks whether the consumer loop is currently draining {@link _query}. */
223 > private _consumerLoopRunning = false;
224 >
225 > private readonly _onDidProduceSignal = this._register(new Emitter<AgentSignal>());
226 > /**
227 > * Single fan-out for every {@link AgentSignal} this session produces:
228 > * • Router-mapped per-message signals (response parts, tool calls,
229 > * pending confirmations, etc.).
230 > * • `ChatTurnComplete` action, fired when the LAST entry in the
231 > * queue drains via `result` (intermediate results during steering
232 > * preempt do NOT fire — CONTEXT.md M10).
233 > * • `steering_consumed` signal, fired the moment the iterable yields
234 > * a steering entry to the SDK.
235 > */
236 > readonly onDidProduceSignal: Event<AgentSignal> = this._onDidProduceSignal.event;
237 >
238 > private readonly _router: ClaudeSdkMessageRouter;
239 >
240 > constructor(
241 readonly sessionId: string,
242 readonly sessionUri: URI,
276 }));
277 }
279 > get isResumed(): boolean { return this._isResumed; }
280 >
281 > get isAborted(): boolean { return this._abortController.signal.aborted; }
282 >
283 > /**
284 > * Whether a turn is currently in flight or queued. False between turns (the
285 > * warm query parks with a drained queue). Used by non-destructive idle
286 > * release to avoid tearing the pipeline down mid-turn.
287 > */
288 > get hasActiveTurn(): boolean { return !this._queue.isEmpty; }
289 >
290 > /**
291 > * Abort the live SDK subprocess and **await its actual exit**.
292 > *
293 > * `WarmQuery[Symbol.asyncDispose]()` calls the query's `close()`, which
294 > * *fires* the SDK cleanup but does not await it — so it returns while the
295 > * subprocess is still shutting down (and still re-flushing its transcript).
296 > * `Query.return()` awaits the same (memoized) cleanup, which in turn awaits
297 > * `transport.waitForExit()` — the OS process actually exiting after its
298 > * final transcript flush. Awaiting that is what lets a caller safely reuse
299 > * the `--session-id` (the CLI rejects a fresh spawn while `<id>.jsonl`
300 > * still exists, and the dying process would otherwise recreate it).
301 > */
302 > async shutdownAndWait(): Promise<void> {
303 this._abortController.abort();
304 try {
309 }
310 }
312 > /**
313 > * Phase 10 \u2014 narrow public wrapper around the internal
314 > * {@link _rebindQuery} so {@link ClaudeAgentSession.rebindForClientTools}
315 > * can drive a yield-restart without exposing the private rebind
316 > * machinery to every collaborator.
317 > */
318 > rebindForRestart(): Promise<void> {
319 return this._rebindQuery('restart');
320 }
322 > /**
323 > * Phase 10 — update the resolver the stream mapper uses to stamp the
324 > * owning workbench `clientId` onto subsequent `ChatToolCallStart` events.
325 > */
326 > setClientToolOwner(clientToolOwner: ((toolName: string) => string | undefined) | undefined): void {
327 this._router.setClientToolOwner(clientToolOwner);
328 }
330 > /** Attach the rematerializer hook for abort / crash recovery. Optional — tests that exercise only the dispose path skip this. */
331 > attachRematerializer(rematerializer: IRematerializer): void {
332 this._rematerializer = rematerializer;
333 }
335 > /**
336 > * Seed the current + applied config from materialize-time `Options`.
337 > * The SDK already starts with these values, so we mark them as both
338 > * "current" (what the consumer wants) and "applied" (what the SDK has)
339 > * to avoid a redundant `setModel` / `applyFlagSettings` on first use.
340 > */
341 > seedCurrentConfig(model: string | undefined, effort: ClaudeRuntimeEffortLevel | undefined, permissionMode: PermissionMode | undefined): void {
342 this._currentModel = model;
343 this._currentEffort = effort;
347 this._appliedPermissionMode = permissionMode;
348 }
350 > /**
351 > * Eagerly push a model change to the SDK. Safe to call mid-turn:
352 > * `Query.setModel` only takes effect on the NEXT user request. No-op
353 > * if the value is unchanged. Buffered as `_currentModel` until the
354 > * Query is bound (and replayed on rebind).
355 > */
356 > async setModel(model: string): Promise<void> {
357 this._currentModel = model;
358 if (this._query && !this._needsRebind && model !== this._appliedModel) {
365 }
366 }
368 > /**
369 > * Eagerly push an effort-level change to the SDK via
370 > * `applyFlagSettings({ effortLevel })`. Same mid-turn safety as
371 > * {@link setModel}.
372 > *
373 > * `undefined` means "clear the effort the SDK is currently applying" —
374 > * issued as `applyFlagSettings({ effortLevel: null })` (sdk.d.ts:2263:
375 > * passing `null` clears a key from the flag layer). This is what makes a
376 > * switch to a model that does not support reasoning effort (e.g. Haiku)
377 > * drop a `'high'` left over from a prior effort-capable model instead of
378 > * replaying it onto a model the API will 400 on.
379 > */
380 > async setEffort(effort: ClaudeRuntimeEffortLevel | undefined): Promise<void> {
381 this._currentEffort = effort;
382 if (this._query && !this._needsRebind && effort !== this._appliedEffort) {
389 }
390 }
392 > /**
393 > * Queue a user prompt for the SDK. Resolves when the matching
394 > * `result` message arrives.
395 > *
396 > * If a previous turn aborted or crashed, this triggers a rebind via
397 > * the attached rematerializer before queueing.
398 > */
399 > async send(prompt: SDKUserMessage, turnId: string): Promise<void> {
400 if (this._needsRebind) {
401 await this._rebindQuery('recover');
418 return this._queue.push(entry);
419 }
421 > /**
422 > * Push a `priority: 'now'` steering message into the iterable. The
423 > * caller pre-builds the {@link SDKUserMessage} (the pipeline is SDK
424 > * messaging-shaped, not protocol-shaped). `pendingMessageId` is the
425 > * protocol `PendingMessage.id` that {@link onSteeringConsumed} will
426 > * carry when the SDK accepts the message.
427 > *
428 > * No-op if the pipeline is aborted or no in-flight / queued request
429 > * exists to inherit a `turnId` from (CONTEXT.md M10: steering folds
430 > * into the in-progress protocol Turn).
431 > */
432 > injectSteering(prompt: SDKUserMessage, pendingMessageId: string): void {
433 if (this._abortController.signal.aborted) {
434 this._logService.warn(`[Claude:${this.sessionId}] injectSteering: dropped (controller aborted) id=${pendingMessageId}`);
455 this._logService.info(`[Claude:${this.sessionId}] injectSteering: enqueued id=${pendingMessageId} sdkUuid=${sdkUuid}`);
456 }
458 > /**
459 > * Cancel the in-flight SDK turn via the abort controller. Mirrors
460 > * the production reference (`claudeCodeAgent.ts:719`). Drops every
461 > * pending entry's deferred (rejected with `CancellationError`),
462 > * marks the pipeline for rebind on next {@link send}. Idempotent.
463 > *
464 > * Safe to call during rebind: {@link _rebindQuery} swaps in a fresh
465 > * placeholder {@link AbortController} before awaiting the
466 > * rematerializer, so an abort issued during recovery lands on that
467 > * placeholder and is honored when the freshly-built pair arrives
468 > * (the rebind discards the new pair and surfaces a cancellation).
469 > */
470 > abort(): void {
471 if (this._abortController.signal.aborted) {
472 return;
478 this._needsRebind = true;
479 }
481 > /**
482 > * Forwards to {@link Query.setPermissionMode} once the query is
483 > * bound; the value is also remembered so it's re-applied after a
484 > * rebind. Permission mode is whole-session (not per-entry).
485 > */
486 > async setPermissionMode(mode: PermissionMode): Promise<void> {
487 this._currentPermissionMode = mode;
488 if (this._query && !this._needsRebind && mode !== this._appliedPermissionMode) {
491 }
492 }
494 > private _wireAbortHandler(controller: AbortController): void {
495 controller.signal.addEventListener('abort', () => {
496 this._queue.notifyAborted();
497 }, { once: true });
498 }
500 > private _ensureConsumerLoop(): void {
501 if (this._consumerLoopRunning) {
502 return;
505 this._runConsumerLoop();
506 }
508 > /**
509 > * Runs one {@link _processMessages} pass over the live {@link _query} and,
510 > * when it ends, decides whether to hand off to a fresh pass.
511 > *
512 > * A rebind ({@link _rebindQuery}) swaps in a new `_query` while the loop is
513 > * still draining the OLD (now-disposed) one; that old pass then ends with
514 > * the "stream ended without a result" guard. Because `_consumerLoopRunning`
515 > * stays `true` for the whole handoff, the {@link send} that queued the
516 > * post-rebind prompt already saw {@link _ensureConsumerLoop} no-op — so if
517 > * this pass just stopped, nothing would ever read the new query and `send`
518 > * would hang. Detect the swap (current `_query` differs from the one this
519 > * pass bound) and re-arm for it instead. Abort / crash / dispose leave
520 > * `_query` cleared (or the store disposed), so they fall through to stop.
521 > */
522 > private _runConsumerLoop(): void {
523 const boundQuery = this._query;
524 void this._processMessages()
532 });
533 }
535 > /**
536 > * Push the current model / effort / permissionMode to the SDK if they
537 > * diverge from what was last applied. Called after binding a fresh
538 > * Query (initial first-send and after rebind). Failures are logged.
539 > */
540 > private async _replayCurrentConfig(): Promise<void> {
541 try {
542 if (this._currentModel !== undefined && this._currentModel !== this._appliedModel) {
556 }
557 }
559 > /**
560 > * Dispose the dead SDK plumbing and rebuild via the agent-supplied
561 > * rematerializer in `resume` mode. Re-applies the current model /
562 > * effort / permission mode to the fresh Query.
563 > */
564 > private async _rebindQuery(reason: 'restart' | 'recover'): Promise<void> {
565 if (!this._rematerializer) {
566 throw new Error(`ClaudeSdkPipeline.rebind: no rematerializer attached (reason=${reason})`);
614 await this._replayCurrentConfig();
615 }
617 > /**
618 > * Consumer loop. Drains the SDK iterator, dispatches each message
619 > * to the {@link ClaudeSdkMessageRouter} (awaited so async file-edit
620 > * observation completes before the next message), settles the head
621 > * entry's deferred on `result`, and fires `ChatTurnComplete` only
622 > * when the queue fully drains.
623 > *
624 > * On any uncaught error (cancellation, transport failure, or the
625 > * post-loop "stream ended without result" guard) the catch block
626 > * rejects every pending entry's deferred with the same error and
627 > * marks `_needsRebind=true`. Cancellation is swallowed (don't
628 > * rethrow); other errors propagate to the void caller's `.catch` for
629 > * logging.
630 > */
631 > private async _processMessages(): Promise<void> {
632 const query = this._query;
633 if (!query) {