feat(provider): add Claude Code CLI provider extension

Implements Phase 1 of the Claude Code subscription-as-provider integration
(issue #2509). Users with a Claude Code subscription (Pro/Max/Team) can
use subsidized inference through GSD's UI via the official Agent SDK.

The extension registers a provider with authMode: "externalCli" that
delegates to the user's locally-installed claude CLI. The SDK runs the
full agentic loop (multi-turn, tool execution) in one streamSimple call.
Tool calls stream in real-time for TUI visibility but are stripped from
the final AssistantMessage so the agent loop ends cleanly without local
tool dispatch.

Zero core changes — pure extension-based implementation.

Closes #2509

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Lex Christopherson 2026-03-25 12:07:08 -06:00
parent 811680f5b6
commit c55d409991
9 changed files with 875 additions and 3 deletions

31
package-lock.json generated
View file

@ -1,12 +1,12 @@
{ {
"name": "gsd-pi", "name": "gsd-pi",
"version": "2.43.0-next.7", "version": "2.46.1",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "gsd-pi", "name": "gsd-pi",
"version": "2.43.0-next.7", "version": "2.46.1",
"hasInstallScript": true, "hasInstallScript": true,
"license": "MIT", "license": "MIT",
"workspaces": [ "workspaces": [
@ -68,6 +68,7 @@
"node": ">=22.0.0" "node": ">=22.0.0"
}, },
"optionalDependencies": { "optionalDependencies": {
"@anthropic-ai/claude-agent-sdk": "^0.2.83",
"@gsd-build/engine-darwin-arm64": ">=2.10.2", "@gsd-build/engine-darwin-arm64": ">=2.10.2",
"@gsd-build/engine-darwin-x64": ">=2.10.2", "@gsd-build/engine-darwin-x64": ">=2.10.2",
"@gsd-build/engine-linux-arm64-gnu": ">=2.10.2", "@gsd-build/engine-linux-arm64-gnu": ">=2.10.2",
@ -77,6 +78,30 @@
"koffi": "^2.9.0" "koffi": "^2.9.0"
} }
}, },
"node_modules/@anthropic-ai/claude-agent-sdk": {
"version": "0.2.83",
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk/-/claude-agent-sdk-0.2.83.tgz",
"integrity": "sha512-O8g56htGMxrwbjCbqUqRBMNC0O98B7SkPnfQC7vmo3w2DVnUrBj3qat/IBLB8SI4sjVSZHeJrcK7+ozsCzStSw==",
"license": "SEE LICENSE IN README.md",
"optional": true,
"engines": {
"node": ">=18.0.0"
},
"optionalDependencies": {
"@img/sharp-darwin-arm64": "^0.34.2",
"@img/sharp-darwin-x64": "^0.34.2",
"@img/sharp-linux-arm": "^0.34.2",
"@img/sharp-linux-arm64": "^0.34.2",
"@img/sharp-linux-x64": "^0.34.2",
"@img/sharp-linuxmusl-arm64": "^0.34.2",
"@img/sharp-linuxmusl-x64": "^0.34.2",
"@img/sharp-win32-arm64": "^0.34.2",
"@img/sharp-win32-x64": "^0.34.2"
},
"peerDependencies": {
"zod": "^4.0.0"
}
},
"node_modules/@anthropic-ai/sdk": { "node_modules/@anthropic-ai/sdk": {
"version": "0.73.0", "version": "0.73.0",
"resolved": "https://registry.npmjs.org/@anthropic-ai/sdk/-/sdk-0.73.0.tgz", "resolved": "https://registry.npmjs.org/@anthropic-ai/sdk/-/sdk-0.73.0.tgz",
@ -9166,7 +9191,7 @@
}, },
"packages/pi-coding-agent": { "packages/pi-coding-agent": {
"name": "@gsd/pi-coding-agent", "name": "@gsd/pi-coding-agent",
"version": "2.40.0", "version": "2.46.1",
"dependencies": { "dependencies": {
"@mariozechner/jiti": "^2.6.2", "@mariozechner/jiti": "^2.6.2",
"@silvia-odwyer/photon-node": "^0.3.4", "@silvia-odwyer/photon-node": "^0.3.4",

View file

@ -139,6 +139,7 @@
"typescript": "^5.4.0" "typescript": "^5.4.0"
}, },
"optionalDependencies": { "optionalDependencies": {
"@anthropic-ai/claude-agent-sdk": "^0.2.83",
"@gsd-build/engine-darwin-arm64": ">=2.10.2", "@gsd-build/engine-darwin-arm64": ">=2.10.2",
"@gsd-build/engine-darwin-x64": ">=2.10.2", "@gsd-build/engine-darwin-x64": ">=2.10.2",
"@gsd-build/engine-linux-arm64-gnu": ">=2.10.2", "@gsd-build/engine-linux-arm64-gnu": ">=2.10.2",

View file

@ -0,0 +1,28 @@
/**
* Claude Code CLI Provider Extension
*
* Registers a model provider that delegates inference to the user's
* locally-installed Claude Code CLI via the official Agent SDK.
*
* Users with a Claude Code subscription (Pro/Max/Team) get access to
* subsidized inference through GSD's UI no API key required.
*
* TOS-compliant: uses Anthropic's official `@anthropic-ai/claude-agent-sdk`,
* never touches credentials, never offers a login flow.
*/
import type { ExtensionAPI } from "@gsd/pi-coding-agent";
import { CLAUDE_CODE_MODELS } from "./models.js";
import { isClaudeCodeReady } from "./readiness.js";
import { streamViaClaudeCode } from "./stream-adapter.js";
export default function claudeCodeCli(pi: ExtensionAPI) {
pi.registerProvider("claude-code", {
authMode: "externalCli",
api: "anthropic-messages",
baseUrl: "local://claude-code",
isReady: isClaudeCodeReady,
streamSimple: streamViaClaudeCode,
models: CLAUDE_CODE_MODELS,
});
}

View file

@ -0,0 +1,39 @@
/**
* Model definitions for the Claude Code CLI provider.
*
* Costs are zero because inference is covered by the user's Claude Code
* subscription. The SDK's `result` message still provides token counts
* for display in the TUI.
*/
const ZERO_COST = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 };
export const CLAUDE_CODE_MODELS = [
{
id: "claude-opus-4-20250514",
name: "Claude Opus 4 (via Claude Code)",
reasoning: true,
input: ["text", "image"] as ("text" | "image")[],
cost: ZERO_COST,
contextWindow: 200_000,
maxTokens: 32_768,
},
{
id: "claude-sonnet-4-20250514",
name: "Claude Sonnet 4 (via Claude Code)",
reasoning: true,
input: ["text", "image"] as ("text" | "image")[],
cost: ZERO_COST,
contextWindow: 200_000,
maxTokens: 16_384,
},
{
id: "claude-haiku-4-5-20251001",
name: "Claude Haiku 4.5 (via Claude Code)",
reasoning: false,
input: ["text", "image"] as ("text" | "image")[],
cost: ZERO_COST,
contextWindow: 200_000,
maxTokens: 8_192,
},
];

View file

@ -0,0 +1,11 @@
{
"name": "@gsd/claude-code-cli",
"private": true,
"version": "1.0.0",
"type": "module",
"pi": {
"extensions": [
"./index.ts"
]
}
}

View file

@ -0,0 +1,258 @@
/**
* Content-block mapping helpers and streaming state tracker.
*
* Translates the Claude Agent SDK's `BetaRawMessageStreamEvent` sequence
* into GSD's `AssistantMessageEvent` deltas for incremental TUI rendering.
*/
import type {
AssistantMessage,
AssistantMessageEvent,
ServerToolUseContent,
StopReason,
TextContent,
ThinkingContent,
ToolCall,
Usage,
WebSearchResultContent,
} from "@gsd/pi-ai";
import type { BetaContentBlock, BetaRawMessageStreamEvent, NonNullableUsage } from "./sdk-types.js";
// ---------------------------------------------------------------------------
// Content-block mapping helpers
// ---------------------------------------------------------------------------
/**
* Convert a single BetaContentBlock to the corresponding GSD content type.
*/
export function mapContentBlock(
block: BetaContentBlock,
): TextContent | ThinkingContent | ToolCall | ServerToolUseContent | WebSearchResultContent {
switch (block.type) {
case "text":
return { type: "text", text: block.text } satisfies TextContent;
case "thinking":
return {
type: "thinking",
thinking: block.thinking,
...(block.signature ? { thinkingSignature: block.signature } : {}),
} satisfies ThinkingContent;
case "tool_use":
return {
type: "toolCall",
id: block.id,
name: block.name,
arguments: block.input,
} satisfies ToolCall;
case "server_tool_use":
return {
type: "serverToolUse",
id: block.id,
name: block.name,
input: block.input,
} satisfies ServerToolUseContent;
case "web_search_tool_result":
return {
type: "webSearchResult",
toolUseId: block.tool_use_id,
content: block.content,
} satisfies WebSearchResultContent;
default: {
const unknown = block as Record<string, unknown>;
return { type: "text", text: `[unknown content block: ${JSON.stringify(unknown)}]` };
}
}
}
export function mapStopReason(reason: string | null): StopReason {
switch (reason) {
case "end_turn":
case "stop_sequence":
return "stop";
case "max_tokens":
return "length";
case "tool_use":
return "toolUse";
default:
return "stop";
}
}
/**
* Convert SDK usage + total_cost_usd into GSD's Usage shape.
*
* The SDK does not break cost down per-bucket, so all cost is
* attributed to `cost.total`.
*/
export function mapUsage(sdkUsage: NonNullableUsage, totalCostUsd: number): Usage {
return {
input: sdkUsage.input_tokens,
output: sdkUsage.output_tokens,
cacheRead: sdkUsage.cache_read_input_tokens,
cacheWrite: sdkUsage.cache_creation_input_tokens,
totalTokens:
sdkUsage.input_tokens +
sdkUsage.output_tokens +
sdkUsage.cache_read_input_tokens +
sdkUsage.cache_creation_input_tokens,
cost: {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
total: totalCostUsd,
},
};
}
// ---------------------------------------------------------------------------
// Zero-cost usage constant
// ---------------------------------------------------------------------------
export const ZERO_USAGE: Usage = {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
totalTokens: 0,
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
};
// ---------------------------------------------------------------------------
// Streaming partial-message state tracker
// ---------------------------------------------------------------------------
/**
* Mutable accumulator that tracks the partial AssistantMessage being built
* from a sequence of stream_event messages. Produces AssistantMessageEvent
* deltas that the TUI can render incrementally.
*/
export class PartialMessageBuilder {
private partial: AssistantMessage;
/** Map from stream-event `index` to our content array index. */
private indexMap = new Map<number, number>();
/** Accumulated JSON input string per tool_use block (keyed by stream index). */
private toolJsonAccum = new Map<number, string>();
constructor(model: string) {
this.partial = {
role: "assistant",
content: [],
api: "anthropic-messages",
provider: "claude-code",
model,
usage: { ...ZERO_USAGE },
stopReason: "stop",
timestamp: Date.now(),
};
}
get message(): AssistantMessage {
return this.partial;
}
/**
* Feed a BetaRawMessageStreamEvent and return the corresponding
* AssistantMessageEvent (or null if the event is not mapped).
*/
handleEvent(event: BetaRawMessageStreamEvent): AssistantMessageEvent | null {
const streamIndex = event.index ?? 0;
switch (event.type) {
// ---- Block start ----
case "content_block_start": {
const block = event.content_block;
if (!block) return null;
const contentIndex = this.partial.content.length;
this.indexMap.set(streamIndex, contentIndex);
if (block.type === "text") {
this.partial.content.push({ type: "text", text: "" });
return { type: "text_start", contentIndex, partial: this.partial };
}
if (block.type === "thinking") {
this.partial.content.push({ type: "thinking", thinking: "" });
return { type: "thinking_start", contentIndex, partial: this.partial };
}
if (block.type === "tool_use") {
this.toolJsonAccum.set(streamIndex, "");
this.partial.content.push({
type: "toolCall",
id: block.id,
name: block.name,
arguments: {},
});
return { type: "toolcall_start", contentIndex, partial: this.partial };
}
if (block.type === "server_tool_use") {
this.partial.content.push({
type: "serverToolUse",
id: block.id,
name: block.name,
input: block.input,
});
return { type: "server_tool_use", contentIndex, partial: this.partial };
}
return null;
}
// ---- Block delta ----
case "content_block_delta": {
const contentIndex = this.indexMap.get(streamIndex);
if (contentIndex === undefined) return null;
const delta = event.delta;
if (!delta) return null;
if (delta.type === "text_delta" && typeof delta.text === "string") {
const existing = this.partial.content[contentIndex] as TextContent;
existing.text += delta.text;
return { type: "text_delta", contentIndex, delta: delta.text, partial: this.partial };
}
if (delta.type === "thinking_delta" && typeof delta.thinking === "string") {
const existing = this.partial.content[contentIndex] as ThinkingContent;
existing.thinking += delta.thinking;
return { type: "thinking_delta", contentIndex, delta: delta.thinking, partial: this.partial };
}
if (delta.type === "input_json_delta" && typeof delta.partial_json === "string") {
const accum = (this.toolJsonAccum.get(streamIndex) ?? "") + delta.partial_json;
this.toolJsonAccum.set(streamIndex, accum);
return { type: "toolcall_delta", contentIndex, delta: delta.partial_json, partial: this.partial };
}
return null;
}
// ---- Block stop ----
case "content_block_stop": {
const contentIndex = this.indexMap.get(streamIndex);
if (contentIndex === undefined) return null;
const block = this.partial.content[contentIndex];
if (block.type === "text") {
return { type: "text_end", contentIndex, content: block.text, partial: this.partial };
}
if (block.type === "thinking") {
return { type: "thinking_end", contentIndex, content: block.thinking, partial: this.partial };
}
if (block.type === "toolCall") {
const jsonStr = this.toolJsonAccum.get(streamIndex) ?? "{}";
try {
block.arguments = JSON.parse(jsonStr);
} catch {
block.arguments = { _raw: jsonStr };
}
return { type: "toolcall_end", contentIndex, toolCall: block, partial: this.partial };
}
return null;
}
default:
return null;
}
}
}

View file

@ -0,0 +1,30 @@
/**
* Readiness check for the Claude Code CLI provider.
*
* Verifies the `claude` binary is installed and responsive.
* Result is cached for 30 seconds to avoid shelling out on every
* model-availability check.
*/
import { execSync } from "node:child_process";
let cachedReady: boolean | null = null;
let lastCheckMs = 0;
const CHECK_INTERVAL_MS = 30_000;
export function isClaudeCodeReady(): boolean {
const now = Date.now();
if (cachedReady !== null && now - lastCheckMs < CHECK_INTERVAL_MS) {
return cachedReady;
}
try {
execSync("claude --version", { timeout: 5_000, stdio: "pipe" });
cachedReady = true;
} catch {
cachedReady = false;
}
lastCheckMs = now;
return cachedReady;
}

View file

@ -0,0 +1,149 @@
/**
* Lightweight type mirrors for the Claude Agent SDK.
*
* These stubs allow the extension to compile without a hard dependency on
* `@anthropic-ai/claude-agent-sdk`. The real SDK is imported dynamically
* at runtime in stream-adapter.ts.
*/
/** UUID branded string from the SDK. */
export type UUID = string;
/** BetaMessage from the Anthropic SDK, as wrapped by SDKAssistantMessage. */
export interface BetaMessage {
id: string;
type: "message";
role: "assistant";
content: BetaContentBlock[];
model: string;
stop_reason: "end_turn" | "max_tokens" | "stop_sequence" | "tool_use" | null;
usage: { input_tokens: number; output_tokens: number };
}
export type BetaContentBlock =
| { type: "text"; text: string }
| { type: "thinking"; thinking: string; signature?: string }
| { type: "tool_use"; id: string; name: string; input: Record<string, unknown> }
| { type: "server_tool_use"; id: string; name: string; input: unknown }
| { type: "web_search_tool_result"; tool_use_id: string; content: unknown };
/** Streaming event emitted when includePartialMessages is true. */
export interface BetaRawMessageStreamEvent {
type: string;
index?: number;
content_block?: BetaContentBlock;
delta?: Record<string, unknown>;
}
export interface SDKAssistantMessage {
type: "assistant";
uuid: UUID;
session_id: string;
message: BetaMessage;
parent_tool_use_id: string | null;
error?: { type: string; message: string };
}
export interface SDKUserMessage {
type: "user";
uuid?: UUID;
session_id: string;
message: unknown;
parent_tool_use_id: string | null;
isSynthetic?: boolean;
tool_use_result?: unknown;
}
export interface SDKSystemMessage {
type: "system";
subtype: "init";
[key: string]: unknown;
}
export interface SDKStatusMessage {
type: "system";
subtype: "status";
status: "compacting" | null;
uuid: UUID;
session_id: string;
}
export interface SDKPartialAssistantMessage {
type: "stream_event";
event: BetaRawMessageStreamEvent;
parent_tool_use_id: string | null;
uuid: UUID;
session_id: string;
}
export interface SDKToolProgressMessage {
type: "tool_progress";
tool_use_id: string;
tool_name: string;
parent_tool_use_id: string | null;
elapsed_time_seconds: number;
task_id?: string;
uuid: UUID;
session_id: string;
}
export interface NonNullableUsage {
input_tokens: number;
output_tokens: number;
cache_read_input_tokens: number;
cache_creation_input_tokens: number;
}
export type SDKResultMessage =
| {
type: "result";
subtype: "success";
uuid: UUID;
session_id: string;
duration_ms: number;
duration_api_ms: number;
is_error: boolean;
num_turns: number;
result: string;
stop_reason: string | null;
total_cost_usd: number;
usage: NonNullableUsage;
}
| {
type: "result";
subtype:
| "error_max_turns"
| "error_during_execution"
| "error_max_budget_usd"
| "error_max_structured_output_retries";
uuid: UUID;
session_id: string;
duration_ms: number;
duration_api_ms: number;
is_error: boolean;
num_turns: number;
stop_reason: string | null;
total_cost_usd: number;
usage: NonNullableUsage;
errors: string[];
};
/** Catch-all for SDK message types we don't map. */
export interface SDKOtherMessage {
type: string;
[key: string]: unknown;
}
/**
* Union of all SDK message types this extension handles.
* Mirrors the real `SDKMessage` from `@anthropic-ai/claude-agent-sdk`.
*/
export type SDKMessage =
| SDKAssistantMessage
| SDKUserMessage
| SDKResultMessage
| SDKSystemMessage
| SDKStatusMessage
| SDKPartialAssistantMessage
| SDKToolProgressMessage
| SDKOtherMessage;

View file

@ -0,0 +1,331 @@
/**
* Stream adapter: bridges the Claude Agent SDK into GSD's streamSimple contract.
*
* The SDK runs the full agentic loop (multi-turn, tool execution, compaction)
* in one call. This adapter translates the SDK's streaming output into
* AssistantMessageEvents for TUI rendering, then strips tool-call blocks from
* the final AssistantMessage so GSD's agent loop doesn't try to dispatch them.
*/
import type {
AssistantMessage,
AssistantMessageEvent,
AssistantMessageEventStream,
Context,
Model,
SimpleStreamOptions,
} from "@gsd/pi-ai";
import { EventStream } from "@gsd/pi-ai";
import { PartialMessageBuilder, ZERO_USAGE, mapUsage } from "./partial-builder.js";
import type {
SDKAssistantMessage,
SDKMessage,
SDKPartialAssistantMessage,
SDKResultMessage,
SDKSystemMessage,
SDKStatusMessage,
SDKUserMessage,
} from "./sdk-types.js";
// ---------------------------------------------------------------------------
// Stream factory
// ---------------------------------------------------------------------------
/**
* Construct an AssistantMessageEventStream using EventStream directly.
* (The class itself is only re-exported as a type from the @gsd/pi-ai barrel.)
*/
function createAssistantStream(): AssistantMessageEventStream {
return new EventStream<AssistantMessageEvent, AssistantMessage>(
(event) => event.type === "done" || event.type === "error",
(event) => {
if (event.type === "done") return event.message;
if (event.type === "error") return event.error;
throw new Error("Unexpected event type for final result");
},
) as AssistantMessageEventStream;
}
// ---------------------------------------------------------------------------
// Prompt extraction
// ---------------------------------------------------------------------------
/**
* Extract the last user prompt text from GSD's context messages.
* The SDK manages its own conversation history we only send
* the latest user message as the prompt.
*/
function extractLastUserPrompt(context: Context): string {
for (let i = context.messages.length - 1; i >= 0; i--) {
const msg = context.messages[i];
if (msg.role === "user") {
if (typeof msg.content === "string") return msg.content;
if (Array.isArray(msg.content)) {
const textParts = msg.content
.filter((part: any) => part.type === "text")
.map((part: any) => part.text);
if (textParts.length > 0) return textParts.join("\n");
}
}
}
return "";
}
// ---------------------------------------------------------------------------
// Error helper
// ---------------------------------------------------------------------------
function makeErrorMessage(model: string, errorMsg: string): AssistantMessage {
return {
role: "assistant",
content: [{ type: "text", text: `Claude Code error: ${errorMsg}` }],
api: "anthropic-messages",
provider: "claude-code",
model,
usage: { ...ZERO_USAGE },
stopReason: "error",
errorMessage: errorMsg,
timestamp: Date.now(),
};
}
// ---------------------------------------------------------------------------
// streamSimple implementation
// ---------------------------------------------------------------------------
/**
* GSD streamSimple function that delegates to the Claude Agent SDK.
*
* Emits AssistantMessageEvent deltas for real-time TUI rendering
* (thinking, text, tool calls). The final AssistantMessage has tool-call
* blocks stripped so the agent loop ends the turn without local dispatch.
*/
export function streamViaClaudeCode(
model: Model<any>,
context: Context,
options?: SimpleStreamOptions,
): AssistantMessageEventStream {
const stream = createAssistantStream();
void pumpSdkMessages(model, context, options, stream);
return stream;
}
async function pumpSdkMessages(
model: Model<any>,
context: Context,
options: SimpleStreamOptions | undefined,
stream: AssistantMessageEventStream,
): Promise<void> {
const modelId = model.id;
let builder: PartialMessageBuilder | null = null;
/** Track the last text content seen across all assistant turns for the final message. */
let lastTextContent = "";
let lastThinkingContent = "";
try {
// Dynamic import — the SDK is an optional dependency.
const sdkModule = "@anthropic-ai/claude-agent-sdk";
const sdk = (await import(/* webpackIgnore: true */ sdkModule)) as {
query: (args: {
prompt: string | AsyncIterable<unknown>;
options?: Record<string, unknown>;
}) => AsyncIterable<SDKMessage>;
};
// Bridge GSD's AbortSignal to SDK's AbortController
const controller = new AbortController();
if (options?.signal) {
options.signal.addEventListener("abort", () => controller.abort(), { once: true });
}
const prompt = extractLastUserPrompt(context);
const queryResult = sdk.query({
prompt,
options: {
model: modelId,
includePartialMessages: true,
persistSession: false,
abortController: controller,
cwd: process.cwd(),
permissionMode: "bypassPermissions",
allowDangerouslySkipPermissions: true,
settingSources: ["project"],
systemPrompt: { type: "preset", preset: "claude_code" },
env: { CLAUDE_AGENT_SDK_CLIENT_APP: "gsd" },
betas: modelId.includes("sonnet") ? ["context-1m-2025-08-07"] : [],
},
});
// Emit start with an empty partial
const initialPartial: AssistantMessage = {
role: "assistant",
content: [],
api: "anthropic-messages",
provider: "claude-code",
model: modelId,
usage: { ...ZERO_USAGE },
stopReason: "stop",
timestamp: Date.now(),
};
stream.push({ type: "start", partial: initialPartial });
for await (const msg of queryResult as AsyncIterable<SDKMessage>) {
if (options?.signal?.aborted) break;
switch (msg.type) {
// -- Init --
case "system": {
// Nothing to emit — the stream is already started.
break;
}
// -- Streaming partial messages --
case "stream_event": {
const partial = msg as SDKPartialAssistantMessage;
if (partial.parent_tool_use_id !== null) break; // skip subagent
const event = partial.event;
// New assistant turn starts with message_start
if (event.type === "message_start") {
builder = new PartialMessageBuilder(
(event as any).message?.model ?? modelId,
);
break;
}
if (!builder) break;
const assistantEvent = builder.handleEvent(event);
if (assistantEvent) {
stream.push(assistantEvent);
}
break;
}
// -- Complete assistant message (non-streaming fallback) --
case "assistant": {
const sdkAssistant = msg as SDKAssistantMessage;
if (sdkAssistant.parent_tool_use_id !== null) break;
// Capture text content from complete messages
for (const block of sdkAssistant.message.content) {
if (block.type === "text") {
lastTextContent = block.text;
} else if (block.type === "thinking") {
lastThinkingContent = block.thinking;
}
}
break;
}
// -- User message (synthetic tool result — signals turn boundary) --
case "user": {
const userMsg = msg as SDKUserMessage;
if (userMsg.parent_tool_use_id !== null) break;
// Capture accumulated text from the builder before resetting
if (builder) {
for (const block of builder.message.content) {
if (block.type === "text" && block.text) {
lastTextContent = block.text;
} else if (block.type === "thinking" && block.thinking) {
lastThinkingContent = block.thinking;
}
}
}
builder = null;
break;
}
// -- Result (terminal) --
case "result": {
const result = msg as SDKResultMessage;
// Build final message with text/thinking only (strip tool calls)
const finalContent: AssistantMessage["content"] = [];
// Use builder's accumulated content if available, falling back to captured text
if (builder) {
for (const block of builder.message.content) {
if (block.type === "text" && block.text) {
lastTextContent = block.text;
} else if (block.type === "thinking" && block.thinking) {
lastThinkingContent = block.thinking;
}
}
}
if (lastThinkingContent) {
finalContent.push({ type: "thinking", thinking: lastThinkingContent });
}
if (lastTextContent) {
finalContent.push({ type: "text", text: lastTextContent });
}
// Fallback: use the SDK's result text if we have no content
if (finalContent.length === 0 && result.subtype === "success" && result.result) {
finalContent.push({ type: "text", text: result.result });
}
const finalMessage: AssistantMessage = {
role: "assistant",
content: finalContent,
api: "anthropic-messages",
provider: "claude-code",
model: modelId,
usage: mapUsage(result.usage, result.total_cost_usd),
stopReason: result.is_error ? "error" : "stop",
timestamp: Date.now(),
};
if (result.is_error) {
const errText =
"errors" in result
? (result as any).errors?.join("; ")
: result.subtype;
finalMessage.errorMessage = errText;
stream.push({ type: "error", reason: "error", error: finalMessage });
} else {
stream.push({ type: "done", reason: "stop", message: finalMessage });
}
return;
}
default:
break;
}
}
// Generator exhausted without a result message (unexpected)
const fallbackContent: AssistantMessage["content"] = [];
if (lastTextContent) {
fallbackContent.push({ type: "text", text: lastTextContent });
}
if (fallbackContent.length === 0) {
fallbackContent.push({ type: "text", text: "(Claude Code session ended without a response)" });
}
const fallback: AssistantMessage = {
role: "assistant",
content: fallbackContent,
api: "anthropic-messages",
provider: "claude-code",
model: modelId,
usage: { ...ZERO_USAGE },
stopReason: "stop",
timestamp: Date.now(),
};
stream.push({ type: "done", reason: "stop", message: fallback });
} catch (err) {
const errorMsg = err instanceof Error ? err.message : String(err);
stream.push({
type: "error",
reason: "error",
error: makeErrorMessage(modelId, errorMsg),
});
}
}