mirror of
https://github.com/wu736139669/hapi.git
synced 2026-08-05 06:24:37 +00:00
419 lines
14 KiB
TypeScript
419 lines
14 KiB
TypeScript
/**
|
|
* 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<SDKMessage> {
|
|
private pendingControlResponses = new Map<string, ControlResponseHandler>()
|
|
private cancelControllers = new Map<string, AbortController>()
|
|
private sdkMessages: AsyncIterableIterator<SDKMessage>
|
|
private inputStream = new Stream<SDKMessage>()
|
|
private canCallTool?: CanCallToolCallback
|
|
|
|
constructor(
|
|
private childStdin: Writable | null,
|
|
private childStdout: NodeJS.ReadableStream,
|
|
private processExitPromise: Promise<void>,
|
|
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<IteratorResult<SDKMessage>> {
|
|
return this.sdkMessages.next(...args)
|
|
}
|
|
|
|
return(value?: any): Promise<IteratorResult<SDKMessage>> {
|
|
if (this.sdkMessages.return) {
|
|
return this.sdkMessages.return(value)
|
|
}
|
|
return Promise.resolve({ done: true, value: undefined })
|
|
}
|
|
|
|
throw(e: any): Promise<IteratorResult<SDKMessage>> {
|
|
if (this.sdkMessages.throw) {
|
|
return this.sdkMessages.throw(e)
|
|
}
|
|
return Promise.reject(e)
|
|
}
|
|
|
|
[Symbol.asyncIterator](): AsyncIterableIterator<SDKMessage> {
|
|
return this.sdkMessages
|
|
}
|
|
|
|
/**
|
|
* Read messages from Claude process stdout
|
|
*/
|
|
private async readMessages(): Promise<void> {
|
|
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<SDKMessage> {
|
|
for await (const message of this.inputStream) {
|
|
yield message
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Send interrupt request to Claude
|
|
*/
|
|
async interrupt(): Promise<void> {
|
|
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<SDKControlResponse['response']> {
|
|
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<void> {
|
|
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<PermissionResult> {
|
|
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<void>((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
|
|
}
|