feat: stream turns through the claude CLI
Each turn runs claude -p with stream-json in and out and maps its events onto pi's assistant message stream. A process that stops on tool calls is kept alive and continued when pi sends the results back, instead of replaying the whole transcript. API credentials are removed from the CLI environment so turns always bill to the signed-in subscription.
This commit is contained in:
@@ -0,0 +1,602 @@
|
||||
import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process";
|
||||
import { mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { createInterface } from "node:readline";
|
||||
import { isDeepStrictEqual } from "node:util";
|
||||
|
||||
import {
|
||||
createAssistantMessageEventStream,
|
||||
type Api,
|
||||
type AssistantMessage,
|
||||
type AssistantMessageEventStream,
|
||||
type Message,
|
||||
type Model,
|
||||
type SimpleStreamOptions,
|
||||
type StopReason,
|
||||
type StreamFunction,
|
||||
type ThinkingLevel,
|
||||
type Tool,
|
||||
type ToolCall,
|
||||
type ToolResultMessage,
|
||||
type TranscriptContext,
|
||||
validateToolCall,
|
||||
} from "@earendil-works/pi-ai";
|
||||
import type { AnthropicEffort } from "@earendil-works/pi-ai/api/anthropic-messages";
|
||||
import { parseStreamingJson, parseToolCallArguments } from "@earendil-works/pi-ai/utils/json-parse";
|
||||
|
||||
import { getClaudeBinary } from "./binary.ts";
|
||||
import { parseCliLine, type CliBlock, type CliEvent, type CliUsage } from "./cli-events.ts";
|
||||
import { ClaudeCodeError } from "./constants.ts";
|
||||
import { addLiveProcess, getLiveProcess, removeLiveProcess, type LiveProcess } from "./live-processes.ts";
|
||||
import { createToolBridge, toolNameFromMcp, type PendingToolCall, type ToolBridge } from "./tool-bridge.ts";
|
||||
import { toClaudeInput, toStreamJsonLine } from "./transcript.ts";
|
||||
|
||||
type RunClaudeOptions = { cwd: string; env: NodeJS.ProcessEnv };
|
||||
|
||||
export type RunClaude = (args: string[], options: RunClaudeOptions) => ChildProcessWithoutNullStreams | null;
|
||||
|
||||
// A held MCP call lasts until pi has run every tool of the message (each capped at 30s by
|
||||
// tool-execute) and called back; this must outlast that so the CLI never times out first.
|
||||
const MCP_TOOL_TIMEOUT_MS = 30 * 60_000;
|
||||
|
||||
const PRIVATE_FILE_MODE = 0o600;
|
||||
|
||||
const EFFORTS: Record<ThinkingLevel, AnthropicEffort> = {
|
||||
minimal: "low",
|
||||
low: "low",
|
||||
medium: "medium",
|
||||
high: "high",
|
||||
xhigh: "xhigh",
|
||||
max: "max",
|
||||
};
|
||||
|
||||
const STOP_REASONS: Record<string, StopReason> = {
|
||||
end_turn: "stop",
|
||||
pause_turn: "stop",
|
||||
stop_sequence: "stop",
|
||||
max_tokens: "length",
|
||||
tool_use: "toolUse",
|
||||
refusal: "error",
|
||||
};
|
||||
|
||||
const REFUSAL_MESSAGE = "The model refused to complete the request";
|
||||
const ABORT_MESSAGE = "Request was aborted";
|
||||
const START_FAILED_MESSAGE = "claude CLI failed to start";
|
||||
const NO_STOP_REASON_MESSAGE = "claude CLI ended the message without a stop reason";
|
||||
const NOT_SIGNED_IN_ERROR = "authentication_failed";
|
||||
|
||||
export function toEffort(level: ThinkingLevel | undefined): AnthropicEffort {
|
||||
return level ? EFFORTS[level] : "medium";
|
||||
}
|
||||
|
||||
type ClaudeArgsInput = {
|
||||
model: string;
|
||||
effort: AnthropicEffort;
|
||||
systemPromptFile: string;
|
||||
mcpConfigFile: string;
|
||||
allowedTools: string[];
|
||||
};
|
||||
|
||||
export function toClaudeArgs(input: ClaudeArgsInput): string[] {
|
||||
return [
|
||||
"-p",
|
||||
"--input-format",
|
||||
"stream-json",
|
||||
"--output-format",
|
||||
"stream-json",
|
||||
"--verbose",
|
||||
"--include-partial-messages",
|
||||
"--model",
|
||||
input.model,
|
||||
"--effort",
|
||||
input.effort,
|
||||
"--system-prompt-file",
|
||||
input.systemPromptFile,
|
||||
"--tools",
|
||||
"",
|
||||
"--strict-mcp-config",
|
||||
"--mcp-config",
|
||||
input.mcpConfigFile,
|
||||
"--setting-sources",
|
||||
"",
|
||||
"--disable-slash-commands",
|
||||
"--no-session-persistence",
|
||||
"--permission-prompts",
|
||||
"none",
|
||||
...(input.allowedTools.length > 0 ? ["--allowedTools", input.allowedTools.join(",")] : []),
|
||||
];
|
||||
}
|
||||
|
||||
function createOutput(model: Model<Api>): AssistantMessage {
|
||||
return {
|
||||
role: "assistant",
|
||||
content: [],
|
||||
api: model.api,
|
||||
provider: model.provider,
|
||||
model: model.id,
|
||||
usage: {
|
||||
input: 0,
|
||||
output: 0,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
totalTokens: 0,
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
|
||||
},
|
||||
stopReason: "pending",
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
}
|
||||
|
||||
function getAssistantErrorMessage(error: string, text: string): string {
|
||||
return error === NOT_SIGNED_IN_ERROR ? ClaudeCodeError.NotSignedIn : text || error;
|
||||
}
|
||||
|
||||
class ClaudeTurn {
|
||||
private readonly output: AssistantMessage;
|
||||
private readonly contentIndexes = new Map<number, number>();
|
||||
private readonly partialJson = new Map<number, string>();
|
||||
started = false;
|
||||
finished = false;
|
||||
|
||||
constructor(
|
||||
private readonly stream: AssistantMessageEventStream,
|
||||
model: Model<Api>,
|
||||
) {
|
||||
this.output = createOutput(model);
|
||||
}
|
||||
|
||||
handle(event: CliEvent): void {
|
||||
if (this.finished) return;
|
||||
|
||||
switch (event.type) {
|
||||
case "messageStart":
|
||||
this.updateUsage(event.usage);
|
||||
this.start();
|
||||
return;
|
||||
|
||||
case "blockStart":
|
||||
this.startBlock(event.index, event.block);
|
||||
return;
|
||||
|
||||
case "textDelta": {
|
||||
const found = this.getBlock(event.index);
|
||||
if (!found) return;
|
||||
|
||||
const { contentIndex, block } = found;
|
||||
if (block.type !== "text") return;
|
||||
|
||||
block.text += event.text;
|
||||
this.stream.push({ type: "text_delta", contentIndex, delta: event.text, partial: this.output });
|
||||
return;
|
||||
}
|
||||
|
||||
case "thinkingDelta": {
|
||||
const found = this.getBlock(event.index);
|
||||
if (!found) return;
|
||||
|
||||
const { contentIndex, block } = found;
|
||||
if (block.type !== "thinking") return;
|
||||
|
||||
block.thinking += event.thinking;
|
||||
this.stream.push({ type: "thinking_delta", contentIndex, delta: event.thinking, partial: this.output });
|
||||
return;
|
||||
}
|
||||
|
||||
case "toolInputDelta": {
|
||||
const found = this.getBlock(event.index);
|
||||
if (!found) return;
|
||||
|
||||
const { contentIndex, block } = found;
|
||||
if (block.type !== "toolCall") return;
|
||||
|
||||
const partialJson = (this.partialJson.get(event.index) ?? "") + event.partialJson;
|
||||
this.partialJson.set(event.index, partialJson);
|
||||
block.arguments = parseStreamingJson(partialJson);
|
||||
this.stream.push({ type: "toolcall_delta", contentIndex, delta: event.partialJson, partial: this.output });
|
||||
return;
|
||||
}
|
||||
|
||||
case "blockStop":
|
||||
this.stopBlock(event.index);
|
||||
return;
|
||||
|
||||
case "messageDelta":
|
||||
this.updateUsage(event.usage);
|
||||
if (event.stopReason) this.setStopReason(event.stopReason);
|
||||
return;
|
||||
|
||||
case "messageStop":
|
||||
this.finish();
|
||||
return;
|
||||
|
||||
case "assistantError":
|
||||
this.fail(getAssistantErrorMessage(event.error, event.text), "error");
|
||||
return;
|
||||
|
||||
case "resultError":
|
||||
this.fail(event.text, "error");
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
getToolCalls(): ToolCall[] {
|
||||
if (!this.finished || this.output.stopReason !== "toolUse") return [];
|
||||
|
||||
return this.output.content.filter((block) => block.type === "toolCall");
|
||||
}
|
||||
|
||||
fail(message: string, reason: "aborted" | "error"): void {
|
||||
if (this.finished) return;
|
||||
|
||||
this.finished = true;
|
||||
this.output.stopReason = reason;
|
||||
this.output.errorMessage = message;
|
||||
this.stream.push({ type: "error", reason, error: this.output });
|
||||
this.stream.end();
|
||||
}
|
||||
|
||||
private start(): void {
|
||||
if (this.started) return;
|
||||
|
||||
this.started = true;
|
||||
this.stream.push({ type: "start", partial: this.output });
|
||||
}
|
||||
|
||||
private startBlock(index: number, block: CliBlock): void {
|
||||
const contentIndex = this.output.content.length;
|
||||
this.contentIndexes.set(index, contentIndex);
|
||||
|
||||
switch (block.type) {
|
||||
case "text":
|
||||
this.output.content.push({ type: "text", text: "" });
|
||||
this.stream.push({ type: "text_start", contentIndex, partial: this.output });
|
||||
return;
|
||||
|
||||
case "thinking":
|
||||
this.output.content.push({ type: "thinking", thinking: "" });
|
||||
this.stream.push({ type: "thinking_start", contentIndex, partial: this.output });
|
||||
return;
|
||||
|
||||
case "toolUse":
|
||||
this.output.content.push({ type: "toolCall", id: block.id, name: toolNameFromMcp(block.name), arguments: {} });
|
||||
this.stream.push({ type: "toolcall_start", contentIndex, partial: this.output });
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
private getBlock(index: number): { contentIndex: number; block: AssistantMessage["content"][number] } | null {
|
||||
const contentIndex = this.contentIndexes.get(index);
|
||||
if (contentIndex === undefined) return null;
|
||||
|
||||
const block = this.output.content[contentIndex];
|
||||
return block ? { contentIndex, block } : null;
|
||||
}
|
||||
|
||||
private stopBlock(index: number): void {
|
||||
const found = this.getBlock(index);
|
||||
if (!found) return;
|
||||
|
||||
const { contentIndex, block } = found;
|
||||
this.contentIndexes.delete(index);
|
||||
|
||||
switch (block.type) {
|
||||
case "text":
|
||||
this.stream.push({ type: "text_end", contentIndex, content: block.text, partial: this.output });
|
||||
return;
|
||||
|
||||
case "thinking":
|
||||
this.stream.push({ type: "thinking_end", contentIndex, content: block.thinking, partial: this.output });
|
||||
return;
|
||||
|
||||
case "toolCall":
|
||||
Object.assign(block, parseToolCallArguments(this.partialJson.get(index) ?? ""));
|
||||
this.partialJson.delete(index);
|
||||
this.stream.push({ type: "toolcall_end", contentIndex, toolCall: block satisfies ToolCall, partial: this.output });
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
private updateUsage(usage: CliUsage): void {
|
||||
Object.assign(this.output.usage, usage);
|
||||
|
||||
const { input, output, cacheRead, cacheWrite } = this.output.usage;
|
||||
this.output.usage.totalTokens = input + output + cacheRead + cacheWrite;
|
||||
}
|
||||
|
||||
private setStopReason(stopReason: string): void {
|
||||
const mapped = STOP_REASONS[stopReason];
|
||||
|
||||
if (!mapped) {
|
||||
this.output.stopReason = "error";
|
||||
this.output.errorMessage = `Unhandled stop reason: ${stopReason}`;
|
||||
return;
|
||||
}
|
||||
|
||||
this.output.stopReason = mapped;
|
||||
}
|
||||
|
||||
private finish(): void {
|
||||
const { stopReason } = this.output;
|
||||
|
||||
if (stopReason === "error") {
|
||||
this.fail(this.output.errorMessage ?? REFUSAL_MESSAGE, "error");
|
||||
return;
|
||||
}
|
||||
|
||||
if (stopReason === "pending" || stopReason === "aborted") {
|
||||
this.fail(this.output.errorMessage ?? NO_STOP_REASON_MESSAGE, "error");
|
||||
return;
|
||||
}
|
||||
|
||||
this.finished = true;
|
||||
this.stream.push({ type: "done", reason: stopReason, message: this.output });
|
||||
this.stream.end();
|
||||
}
|
||||
}
|
||||
|
||||
type TurnFiles = { cwd: string; privateDir: string; systemPromptFile: string; mcpConfigFile: string };
|
||||
|
||||
async function createTurnFiles(systemPrompt: string, mcpConfig: string): Promise<TurnFiles> {
|
||||
const cwd = await mkdtemp(join(tmpdir(), "claude-code-cwd-"));
|
||||
const privateDir = await mkdtemp(join(tmpdir(), "claude-code-private-"));
|
||||
|
||||
const systemPromptFile = join(privateDir, "system-prompt.md");
|
||||
const mcpConfigFile = join(privateDir, "mcp.json");
|
||||
|
||||
await writeFile(systemPromptFile, systemPrompt, { mode: PRIVATE_FILE_MODE });
|
||||
await writeFile(mcpConfigFile, mcpConfig, { mode: PRIVATE_FILE_MODE });
|
||||
|
||||
return { cwd, privateDir, systemPromptFile, mcpConfigFile };
|
||||
}
|
||||
|
||||
async function removeTurnResources(bridge: ToolBridge, files: TurnFiles): Promise<void> {
|
||||
await bridge.close();
|
||||
await rm(files.cwd, { recursive: true, force: true });
|
||||
await rm(files.privateDir, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
type UnansweredToolCall = { toolCall: ToolCall; result: ToolResultMessage };
|
||||
|
||||
// The CLI answers calls to undeclared tools or with invalid arguments itself, without reaching the bridge.
|
||||
function isValidToolCall(tools: Tool[], toolCall: ToolCall): boolean {
|
||||
try {
|
||||
validateToolCall(tools, toolCall);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function isSameToolCall(toolCall: ToolCall, call: PendingToolCall): boolean {
|
||||
return toolCall.name === call.name && isDeepStrictEqual(toolCall.arguments, call.arguments);
|
||||
}
|
||||
|
||||
class ClaudeProcess implements LiveProcess {
|
||||
toolCallIds: string[] = [];
|
||||
private stderr = "";
|
||||
private closed = false;
|
||||
private signal: AbortSignal | undefined;
|
||||
|
||||
constructor(
|
||||
private readonly child: ChildProcessWithoutNullStreams,
|
||||
private readonly bridge: ToolBridge,
|
||||
private readonly files: TurnFiles,
|
||||
private readonly tools: Tool[],
|
||||
private turn: ClaudeTurn,
|
||||
) {}
|
||||
|
||||
start(stdinLine: string, signal: AbortSignal | undefined): void {
|
||||
this.child.once("close", (code: number | null) => this.handleClose(`claude CLI exited with code ${code}`));
|
||||
this.child.once("error", (error) => {
|
||||
console.error("claude CLI process failed:", error);
|
||||
this.turn.fail(START_FAILED_MESSAGE, "error");
|
||||
this.handleClose(START_FAILED_MESSAGE);
|
||||
});
|
||||
|
||||
this.child.stderr.setEncoding("utf8");
|
||||
this.child.stderr.on("data", (chunk: string) => (this.stderr += chunk));
|
||||
|
||||
this.child.stdin.on("error", (error) => console.error("claude CLI stdin closed early:", error));
|
||||
this.child.stdin.end(`${stdinLine}\n`);
|
||||
|
||||
createInterface({ input: this.child.stdout }).on("line", (line) => this.handleLine(line));
|
||||
|
||||
this.setSignal(signal);
|
||||
}
|
||||
|
||||
continueTurn(turn: ClaudeTurn, toolResults: ToolResultMessage[], signal: AbortSignal | undefined): void {
|
||||
removeLiveProcess(this);
|
||||
|
||||
const unanswered = this.turn.getToolCalls().flatMap((toolCall) => {
|
||||
const result = toolResults.find((toolResult) => toolResult.toolCallId === toolCall.id);
|
||||
return result ? [{ toolCall, result }] : [];
|
||||
});
|
||||
|
||||
this.turn = turn;
|
||||
this.setSignal(signal);
|
||||
|
||||
this.answerToolCalls(turn, unanswered).catch((error: unknown) => {
|
||||
console.error("claude CLI tool results failed:", error);
|
||||
turn.fail(START_FAILED_MESSAGE, "error");
|
||||
this.kill();
|
||||
});
|
||||
}
|
||||
|
||||
kill(): void {
|
||||
removeLiveProcess(this);
|
||||
this.child.kill();
|
||||
}
|
||||
|
||||
private readonly abort = () => {
|
||||
this.turn.fail(ABORT_MESSAGE, "aborted");
|
||||
this.kill();
|
||||
};
|
||||
|
||||
private setSignal(signal: AbortSignal | undefined): void {
|
||||
this.signal?.removeEventListener("abort", this.abort);
|
||||
this.signal = signal;
|
||||
signal?.addEventListener("abort", this.abort, { once: true });
|
||||
|
||||
if (signal?.aborted) this.abort();
|
||||
}
|
||||
|
||||
private handleLine(line: string): void {
|
||||
const event = parseCliLine(line);
|
||||
if (!event) return;
|
||||
|
||||
// Output after a finished message means the CLI moved on without waiting for our tool results.
|
||||
if (this.turn.finished) {
|
||||
this.kill();
|
||||
return;
|
||||
}
|
||||
|
||||
this.turn.handle(event);
|
||||
if (!this.turn.finished) return;
|
||||
|
||||
const toolCalls = this.turn.getToolCalls();
|
||||
this.toolCallIds = toolCalls.map((toolCall) => toolCall.id);
|
||||
|
||||
const reachesBridge = toolCalls.length > 0 && toolCalls.every((toolCall) => isValidToolCall(this.tools, toolCall));
|
||||
if (!reachesBridge) {
|
||||
this.kill();
|
||||
return;
|
||||
}
|
||||
|
||||
addLiveProcess(this);
|
||||
}
|
||||
|
||||
private async answerToolCalls(turn: ClaudeTurn, unanswered: UnansweredToolCall[]): Promise<void> {
|
||||
while (unanswered.length > 0 && !turn.started) {
|
||||
const pending = this.bridge.getPendingToolCalls();
|
||||
let answered = false;
|
||||
|
||||
for (const call of pending) {
|
||||
const index = unanswered.findIndex(({ toolCall }) => isSameToolCall(toolCall, call));
|
||||
const match = unanswered[index];
|
||||
if (!match) continue;
|
||||
|
||||
this.bridge.resolveToolCall(call, match.result);
|
||||
unanswered.splice(index, 1);
|
||||
answered = true;
|
||||
}
|
||||
|
||||
if (answered) continue;
|
||||
|
||||
// Rejects only once the bridge has closed, which means the process is already gone.
|
||||
const next = await this.bridge.waitForToolCalls(pending.length + 1).catch(() => null);
|
||||
if (!next) return;
|
||||
}
|
||||
}
|
||||
|
||||
private handleClose(message: string): void {
|
||||
if (this.closed) return;
|
||||
|
||||
this.closed = true;
|
||||
removeLiveProcess(this);
|
||||
this.signal?.removeEventListener("abort", this.abort);
|
||||
|
||||
if (this.stderr.trim()) console.error("claude CLI stderr:", this.stderr.trim());
|
||||
|
||||
this.turn.fail(message, "error");
|
||||
|
||||
removeTurnResources(this.bridge, this.files).catch((error: unknown) => {
|
||||
console.error("claude CLI cleanup failed:", error);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function continueLiveProcess(turn: ClaudeTurn, messages: Message[], signal: AbortSignal | undefined): boolean {
|
||||
const assistantIndex = messages.findLastIndex((message) => message.role === "assistant");
|
||||
const assistant = messages[assistantIndex];
|
||||
if (assistant?.role !== "assistant") return false;
|
||||
|
||||
const toolCallIds = assistant.content.flatMap((block) => (block.type === "toolCall" ? [block.id] : []));
|
||||
const process = getLiveProcess(toolCallIds);
|
||||
if (!(process instanceof ClaudeProcess)) return false;
|
||||
|
||||
const trailing = messages.slice(assistantIndex + 1);
|
||||
const toolResults = trailing.filter((message) => message.role === "toolResult");
|
||||
|
||||
const onlyToolResults = toolResults.length === trailing.length;
|
||||
const answersEveryCall = toolCallIds.every((id) => toolResults.some((toolResult) => toolResult.toolCallId === id));
|
||||
|
||||
if (!onlyToolResults || !answersEveryCall) {
|
||||
process.kill();
|
||||
return false;
|
||||
}
|
||||
|
||||
process.continueTurn(turn, toolResults, signal);
|
||||
return true;
|
||||
}
|
||||
|
||||
// Either one takes precedence over the logged-in subscription, so a key saved on the Keys page
|
||||
// would bill turns to the API account.
|
||||
const API_CREDENTIAL_ENV_NAMES = ["ANTHROPIC_API_KEY", "ANTHROPIC_AUTH_TOKEN"];
|
||||
|
||||
function toClaudeEnv(env: NodeJS.ProcessEnv): NodeJS.ProcessEnv {
|
||||
const claudeEnv: NodeJS.ProcessEnv = { ...env, MCP_TOOL_TIMEOUT: String(MCP_TOOL_TIMEOUT_MS) };
|
||||
|
||||
for (const name of API_CREDENTIAL_ENV_NAMES) delete claudeEnv[name];
|
||||
|
||||
return claudeEnv;
|
||||
}
|
||||
|
||||
async function startProcess(
|
||||
runClaude: RunClaude,
|
||||
turn: ClaudeTurn,
|
||||
model: Model<Api>,
|
||||
context: TranscriptContext,
|
||||
options: SimpleStreamOptions | undefined,
|
||||
): Promise<void> {
|
||||
const input = toClaudeInput(context.messages);
|
||||
const bridge = await createToolBridge(input.tools);
|
||||
const files = await createTurnFiles(input.systemPrompt, bridge.mcpConfig);
|
||||
|
||||
let child: ChildProcessWithoutNullStreams | null;
|
||||
try {
|
||||
const args = toClaudeArgs({
|
||||
model: model.id,
|
||||
effort: toEffort(options?.reasoning),
|
||||
systemPromptFile: files.systemPromptFile,
|
||||
mcpConfigFile: files.mcpConfigFile,
|
||||
allowedTools: bridge.allowedTools,
|
||||
});
|
||||
child = runClaude(args, { cwd: files.cwd, env: toClaudeEnv(process.env) });
|
||||
} catch (error) {
|
||||
await removeTurnResources(bridge, files);
|
||||
throw error;
|
||||
}
|
||||
|
||||
if (!child) {
|
||||
turn.fail(ClaudeCodeError.CliNotFound, "error");
|
||||
await removeTurnResources(bridge, files);
|
||||
return;
|
||||
}
|
||||
|
||||
new ClaudeProcess(child, bridge, files, input.tools, turn).start(toStreamJsonLine(input.userContent), options?.signal);
|
||||
}
|
||||
|
||||
export function createClaudeCodeStreamFunction(runClaude: RunClaude): StreamFunction<Api, SimpleStreamOptions> {
|
||||
return (model, context, options) => {
|
||||
const stream = createAssistantMessageEventStream();
|
||||
const turn = new ClaudeTurn(stream, model);
|
||||
|
||||
if (continueLiveProcess(turn, context.messages, options?.signal)) return stream;
|
||||
|
||||
startProcess(runClaude, turn, model, context, options).catch((error: unknown) => {
|
||||
console.error("claude CLI turn failed:", error);
|
||||
turn.fail(START_FAILED_MESSAGE, "error");
|
||||
});
|
||||
|
||||
return stream;
|
||||
};
|
||||
}
|
||||
|
||||
function spawnClaude(args: string[], options: RunClaudeOptions): ChildProcessWithoutNullStreams | null {
|
||||
const binary = getClaudeBinary();
|
||||
|
||||
return binary ? spawn(binary, args, { ...options, stdio: "pipe" }) : null;
|
||||
}
|
||||
|
||||
export const streamClaudeCode = createClaudeCodeStreamFunction(spawnClaude);
|
||||
Reference in New Issue
Block a user