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:
parent
811680f5b6
commit
c55d409991
9 changed files with 875 additions and 3 deletions
31
package-lock.json
generated
31
package-lock.json
generated
|
|
@ -1,12 +1,12 @@
|
|||
{
|
||||
"name": "gsd-pi",
|
||||
"version": "2.43.0-next.7",
|
||||
"version": "2.46.1",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "gsd-pi",
|
||||
"version": "2.43.0-next.7",
|
||||
"version": "2.46.1",
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"workspaces": [
|
||||
|
|
@ -68,6 +68,7 @@
|
|||
"node": ">=22.0.0"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@anthropic-ai/claude-agent-sdk": "^0.2.83",
|
||||
"@gsd-build/engine-darwin-arm64": ">=2.10.2",
|
||||
"@gsd-build/engine-darwin-x64": ">=2.10.2",
|
||||
"@gsd-build/engine-linux-arm64-gnu": ">=2.10.2",
|
||||
|
|
@ -77,6 +78,30 @@
|
|||
"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": {
|
||||
"version": "0.73.0",
|
||||
"resolved": "https://registry.npmjs.org/@anthropic-ai/sdk/-/sdk-0.73.0.tgz",
|
||||
|
|
@ -9166,7 +9191,7 @@
|
|||
},
|
||||
"packages/pi-coding-agent": {
|
||||
"name": "@gsd/pi-coding-agent",
|
||||
"version": "2.40.0",
|
||||
"version": "2.46.1",
|
||||
"dependencies": {
|
||||
"@mariozechner/jiti": "^2.6.2",
|
||||
"@silvia-odwyer/photon-node": "^0.3.4",
|
||||
|
|
|
|||
|
|
@ -139,6 +139,7 @@
|
|||
"typescript": "^5.4.0"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@anthropic-ai/claude-agent-sdk": "^0.2.83",
|
||||
"@gsd-build/engine-darwin-arm64": ">=2.10.2",
|
||||
"@gsd-build/engine-darwin-x64": ">=2.10.2",
|
||||
"@gsd-build/engine-linux-arm64-gnu": ">=2.10.2",
|
||||
|
|
|
|||
28
src/resources/extensions/claude-code-cli/index.ts
Normal file
28
src/resources/extensions/claude-code-cli/index.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
39
src/resources/extensions/claude-code-cli/models.ts
Normal file
39
src/resources/extensions/claude-code-cli/models.ts
Normal 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,
|
||||
},
|
||||
];
|
||||
11
src/resources/extensions/claude-code-cli/package.json
Normal file
11
src/resources/extensions/claude-code-cli/package.json
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
{
|
||||
"name": "@gsd/claude-code-cli",
|
||||
"private": true,
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"pi": {
|
||||
"extensions": [
|
||||
"./index.ts"
|
||||
]
|
||||
}
|
||||
}
|
||||
258
src/resources/extensions/claude-code-cli/partial-builder.ts
Normal file
258
src/resources/extensions/claude-code-cli/partial-builder.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
30
src/resources/extensions/claude-code-cli/readiness.ts
Normal file
30
src/resources/extensions/claude-code-cli/readiness.ts
Normal 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;
|
||||
}
|
||||
149
src/resources/extensions/claude-code-cli/sdk-types.ts
Normal file
149
src/resources/extensions/claude-code-cli/sdk-types.ts
Normal 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;
|
||||
331
src/resources/extensions/claude-code-cli/stream-adapter.ts
Normal file
331
src/resources/extensions/claude-code-cli/stream-adapter.ts
Normal 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),
|
||||
});
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue