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",
|
"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",
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
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