/** * Main query implementation for Claude Code SDK * Handles spawning Claude process and managing message streams */ import { spawn, type ChildProcessWithoutNullStreams } from 'node:child_process' import { createInterface } from 'node:readline' import { existsSync } from 'node:fs' import { Stream } from './stream' import { type QueryOptions, type QueryPrompt, type SDKMessage, type ControlResponseHandler, type SDKControlRequest, type ControlRequest, type SDKControlResponse, type CanCallToolCallback, type CanUseToolControlRequest, type CanUseToolControlResponse, type ControlCancelRequest, type PermissionResult, AbortError } from './types' import { getDefaultClaudeCodePath, logDebug, streamToStdin } from './utils' import { withBunRuntimeEnv } from '@/utils/bunRuntime' import { killProcessByChildProcess } from '@/utils/process' import { stripNewlinesForWindowsShellArg } from '@/utils/shellEscape' import type { Writable } from 'node:stream' import { logger } from '@/ui/logger' import { appendMcpConfigArg } from '../utils/mcpConfig' /** * Query class manages Claude Code process interaction */ export class Query implements AsyncIterableIterator { private pendingControlResponses = new Map() private cancelControllers = new Map() private sdkMessages: AsyncIterableIterator private inputStream = new Stream() private canCallTool?: CanCallToolCallback constructor( private childStdin: Writable | null, private childStdout: NodeJS.ReadableStream, private processExitPromise: Promise, canCallTool?: CanCallToolCallback ) { this.canCallTool = canCallTool this.readMessages() this.sdkMessages = this.readSdkMessages() } /** * Set an error on the stream */ setError(error: Error): void { this.inputStream.error(error) } /** * AsyncIterableIterator implementation */ next(...args: [] | [undefined]): Promise> { return this.sdkMessages.next(...args) } return(value?: any): Promise> { if (this.sdkMessages.return) { return this.sdkMessages.return(value) } return Promise.resolve({ done: true, value: undefined }) } throw(e: any): Promise> { if (this.sdkMessages.throw) { return this.sdkMessages.throw(e) } return Promise.reject(e) } [Symbol.asyncIterator](): AsyncIterableIterator { return this.sdkMessages } /** * Read messages from Claude process stdout */ private async readMessages(): Promise { const rl = createInterface({ input: this.childStdout }) try { for await (const line of rl) { if (line.trim()) { try { const message = JSON.parse(line) as SDKMessage | SDKControlResponse if (message.type === 'control_response') { const controlResponse = message as SDKControlResponse const handler = this.pendingControlResponses.get(controlResponse.response.request_id) if (handler) { handler(controlResponse.response) } continue } else if (message.type === 'control_request') { await this.handleControlRequest(message as unknown as CanUseToolControlRequest) continue } else if (message.type === 'control_cancel_request') { this.handleControlCancelRequest(message as unknown as ControlCancelRequest) continue } this.inputStream.enqueue(message) } catch (e) { logger.debug(line) } } } await this.processExitPromise } catch (error) { this.inputStream.error(error as Error) } finally { this.inputStream.done() this.cleanupControllers() rl.close() } } /** * Async generator for SDK messages */ private async *readSdkMessages(): AsyncIterableIterator { for await (const message of this.inputStream) { yield message } } /** * Send interrupt request to Claude */ async interrupt(): Promise { if (!this.childStdin) { throw new Error('Interrupt requires --input-format stream-json') } await this.request({ subtype: 'interrupt' }, this.childStdin) } /** * Send control request to Claude process */ private request(request: ControlRequest, childStdin: Writable): Promise { const requestId = Math.random().toString(36).substring(2, 15) const sdkRequest: SDKControlRequest = { request_id: requestId, type: 'control_request', request } return new Promise((resolve, reject) => { this.pendingControlResponses.set(requestId, (response) => { if (response.subtype === 'success') { resolve(response) } else { reject(new Error(response.error)) } }) childStdin.write(JSON.stringify(sdkRequest) + '\n') }) } /** * Handle incoming control requests for tool permissions * Replicates the exact logic from the SDK's handleControlRequest method */ private async handleControlRequest(request: CanUseToolControlRequest): Promise { if (!this.childStdin) { logDebug('Cannot handle control request - no stdin available') return } const controller = new AbortController() this.cancelControllers.set(request.request_id, controller) try { const response = await this.processControlRequest(request, controller.signal) const controlResponse: CanUseToolControlResponse = { type: 'control_response', response: { subtype: 'success', request_id: request.request_id, response } } this.childStdin.write(JSON.stringify(controlResponse) + '\n') } catch (error) { const controlErrorResponse: CanUseToolControlResponse = { type: 'control_response', response: { subtype: 'error', request_id: request.request_id, error: error instanceof Error ? error.message : String(error) } } this.childStdin.write(JSON.stringify(controlErrorResponse) + '\n') } finally { this.cancelControllers.delete(request.request_id) } } /** * Handle control cancel requests * Replicates the exact logic from the SDK's handleControlCancelRequest method */ private handleControlCancelRequest(request: ControlCancelRequest): void { const controller = this.cancelControllers.get(request.request_id) if (controller) { controller.abort() this.cancelControllers.delete(request.request_id) } } /** * Process control requests based on subtype * Replicates the exact logic from the SDK's processControlRequest method */ private async processControlRequest(request: CanUseToolControlRequest, signal: AbortSignal): Promise { if (request.request.subtype === 'can_use_tool') { if (!this.canCallTool) { throw new Error('canCallTool callback is not provided.') } return this.canCallTool(request.request.tool_name, request.request.input, { signal }) } throw new Error('Unsupported control request subtype: ' + request.request.subtype) } /** * Cleanup method to abort all pending control requests */ private cleanupControllers(): void { for (const [requestId, controller] of this.cancelControllers.entries()) { controller.abort() this.cancelControllers.delete(requestId) } } } /** * Main query function to interact with Claude Code */ export function query(config: { prompt: QueryPrompt options?: QueryOptions }): Query { const { prompt, options: { additionalDirectories = [], allowedTools = [], appendSystemPrompt, customSystemPrompt, cwd, disallowedTools = [], maxTurns, mcpServers, pathToClaudeCodeExecutable = getDefaultClaudeCodePath(), permissionMode = 'default', continue: continueConversation, resume, model, fallbackModel, settingsPath, strictMcpConfig, canCallTool } = {} } = config // Set entrypoint if not already set if (!process.env.CLAUDE_CODE_ENTRYPOINT) { process.env.CLAUDE_CODE_ENTRYPOINT = 'sdk-ts' } // Build command arguments const args = ['--output-format', 'stream-json', '--verbose'] let cleanupMcpConfig: (() => void) | null = null if (customSystemPrompt) args.push('--system-prompt', stripNewlinesForWindowsShellArg(customSystemPrompt)) if (appendSystemPrompt) args.push('--append-system-prompt', stripNewlinesForWindowsShellArg(appendSystemPrompt)) if (maxTurns) args.push('--max-turns', maxTurns.toString()) if (model) args.push('--model', model) if (canCallTool) { if (typeof prompt === 'string') { throw new Error('canCallTool callback requires --input-format stream-json. Please set prompt as an AsyncIterable.') } args.push('--permission-prompt-tool', 'stdio') } if (continueConversation) args.push('--continue') if (resume) args.push('--resume', resume) if (settingsPath) args.push('--settings', settingsPath) if (allowedTools.length > 0) args.push('--allowedTools', allowedTools.join(',')) if (disallowedTools.length > 0) args.push('--disallowedTools', disallowedTools.join(',')) if (additionalDirectories.length > 0) args.push('--add-dir', ...additionalDirectories) if (strictMcpConfig) args.push('--strict-mcp-config') if (permissionMode) args.push('--permission-mode', permissionMode) if (fallbackModel) { if (model && fallbackModel === model) { throw new Error('Fallback model cannot be the same as the main model. Please specify a different model for fallbackModel option.') } args.push('--fallback-model', fallbackModel) } // Handle prompt input if (typeof prompt === 'string') { args.push('--print', stripNewlinesForWindowsShellArg(prompt.trim())) } else { args.push('--input-format', 'stream-json') } // Determine how to spawn Claude Code // - If it's just 'claude' command → spawn('claude', args) with shell on Windows // - If it's a full path to binary or script → spawn(path, args) const isCommandOnly = pathToClaudeCodeExecutable === 'claude' // Validate executable path (skip for command-only mode) if (!isCommandOnly && !existsSync(pathToClaudeCodeExecutable)) { throw new ReferenceError(`Claude Code executable not found at ${pathToClaudeCodeExecutable}. Is options.pathToClaudeCodeExecutable set?`) } const spawnCommand = pathToClaudeCodeExecutable const spawnArgs = args cleanupMcpConfig = appendMcpConfigArg(spawnArgs, mcpServers) // Spawn Claude Code process const spawnEnv = withBunRuntimeEnv(process.env, { allowBunBeBun: false }) logDebug(`Spawning Claude Code process: ${spawnCommand} ${spawnArgs.join(' ')}`) const child = spawn(spawnCommand, spawnArgs, { cwd, stdio: ['pipe', 'pipe', 'pipe'], signal: config.options?.abort, env: spawnEnv, // Use shell on Windows for command resolution shell: process.platform === 'win32' }) as ChildProcessWithoutNullStreams // Handle stdin let childStdin: Writable | null = null if (typeof prompt === 'string') { child.stdin.end() } else { streamToStdin(prompt, child.stdin, config.options?.abort) childStdin = child.stdin } // Handle stderr in debug mode if (process.env.DEBUG) { child.stderr.on('data', (data) => { console.error('Claude Code stderr:', data.toString()) }) } // Setup cleanup const cleanup = () => { if (!child.killed) { void killProcessByChildProcess(child) } } config.options?.abort?.addEventListener('abort', cleanup) process.on('exit', cleanup) // Handle process exit const processExitPromise = new Promise((resolve) => { child.on('close', (code) => { if (config.options?.abort?.aborted) { query.setError(new AbortError('Claude Code process aborted by user')) } if (code !== 0) { query.setError(new Error(`Claude Code process exited with code ${code}`)) } else { resolve() } }) }) // Create query instance const query = new Query(childStdin, child.stdout, processExitPromise, canCallTool) // Handle process errors child.on('error', (error) => { cleanupMcpConfig?.() if (config.options?.abort?.aborted) { query.setError(new AbortError('Claude Code process aborted by user')) } else { query.setError(new Error(`Failed to spawn Claude Code process: ${error.message}`)) } }) // Cleanup on exit processExitPromise.finally(() => { cleanup() config.options?.abort?.removeEventListener('abort', cleanup) if (process.env.CLAUDE_SDK_MCP_SERVERS) { delete process.env.CLAUDE_SDK_MCP_SERVERS } cleanupMcpConfig?.() }) return query }