import { type ChildProcess, spawn } from 'child_process' import { createInterface } from 'readline' import { jsonParse, jsonStringify } from '../utils/slowOperations.js' import { logError } from '../utils/log.js' import type { EventBus } from './eventBus.js' import type { SessionState } from './types.js' type StdoutMessage = { type: string [key: string]: unknown } type MessageListener = (msg: StdoutMessage) => void export type InitData = { commands?: Array<{ name: string; description: string; argumentHint?: string }> agents?: Array<{ name: string; description?: string; model?: string }> models?: unknown account?: { email?: string organization?: string subscriptionType?: string tokenSource?: string apiKeySource?: string apiProvider?: string } output_style?: string available_output_styles?: string[] pid?: number } export type PendingPermission = { requestId: string sessionId: string toolName: string toolUseId: string input: Record title: string description: string suggestions: Record[] } export type PendingQuestion = { requestId: string sessionId: string mcpServerName: string message: string mode: string requestedSchema: Record } export type SessionHandleOptions = { sessionId: string cwd: string model?: string permissionMode?: string systemPrompt?: string resumeSessionId?: string resumeSessionAt?: string hooks?: Record eventBus: EventBus execPath: string scriptArgs: string[] verbose?: boolean silent?: boolean onInit?: (data: InitData) => void } export function getScriptArgsForChild(): string[] { const argv1 = process.argv[1] if (!argv1) return [] if (argv1.endsWith('.ts') || argv1.endsWith('.tsx') || argv1.includes('/') || argv1.includes('\\')) { return [argv1] } return [] } export function getChildSpawnArgs(): { execPath: string; scriptArgs: string[] } { const execPath = process.execPath const scriptArgs = getScriptArgsForChild() return { execPath, scriptArgs } } export async function saveChildSpawnPrefix(): Promise { if (process.env._CSC_CHILD_SPAWN_PREFIX) return const { execPath, scriptArgs } = getChildSpawnArgs() let defineArgs: string[] = [] let featureArgs: string[] = [] try { const definesMod = await import('../../scripts/defines.js') as { getMacroDefines: () => Record; DEFAULT_BUILD_FEATURES: readonly string[] } const defines = definesMod.getMacroDefines() defineArgs = Object.entries(defines).flatMap(([k, v]) => ['-d', `${k}:${v}`]) const features = definesMod.DEFAULT_BUILD_FEATURES featureArgs = features.flatMap((f: string) => ['--feature', f]) } catch {} const envFeatures = Object.entries(process.env) .filter(([k]) => k.startsWith('FEATURE_') && k.slice(8)) .map(([k]) => ['--feature', k.slice(8)] as [string, string]) .flat() const allFeatureArgs = [...featureArgs, ...envFeatures] const prefix = JSON.stringify({ execPath, scriptArgs, defineArgs, featureArgs: allFeatureArgs }) process.env._CSC_CHILD_SPAWN_PREFIX = prefix } function loadChildSpawnPrefix(): { execPath: string; scriptArgs: string[]; defineArgs?: string[]; featureArgs?: string[] } | null { const raw = process.env._CSC_CHILD_SPAWN_PREFIX if (!raw) return null try { return JSON.parse(raw) as { execPath: string; scriptArgs: string[]; defineArgs?: string[]; featureArgs?: string[] } } catch { return null } } export class SessionHandle { readonly sessionId: string readonly cwd: string private child: ChildProcess | null = null private _status: SessionState = 'starting' private _model?: string private _providerId?: string private _permissionMode?: string private _title?: string private _costUsd = 0 private _inputTokens = 0 private _outputTokens = 0 private createdAt = Date.now() private lastActiveAt = Date.now() private eventBus: EventBus private pendingPermissions = new Map() private pendingQuestions = new Map() private promptResolve: | ((value: { done: boolean; error?: string }) => void) | null = null private promptReject: ((reason: unknown) => void) | null = null private verbose: boolean private lastStderr: string[] = [] private listeners = new Set() private _initData: InitData | null = null private initRequestId: string | null = null private initResolve: ((data: InitData) => void) | null = null private initReject: ((reason: unknown) => void) | null = null private _prompting = false private _lastMessageUuid: string | null = null get status(): SessionState { return this._status } get model(): string | undefined { return this._model } get permissionMode(): string | undefined { return this._permissionMode } get title(): string | undefined { return this._title } get costUsd(): number { return this._costUsd } get inputTokens(): number { return this._inputTokens } get messageCount(): number { return 0 } get usage() { return { input_tokens: this._inputTokens, output_tokens: this._outputTokens, } } get initData(): InitData | null { return this._initData } get prompting(): boolean { return this._prompting } get lastMessageUuid(): string | null { return this._lastMessageUuid } get ready(): boolean { return this._status === 'running' } get silent(): boolean { return this.opts.silent ?? false } async waitReady(timeoutMs = 30000): Promise { if (this._status === 'running') return if (this._status === 'stopped') { throw new Error(`Session ${this.sessionId} is stopped`) } return new Promise((resolve, reject) => { const timer = setTimeout(() => { reject(new Error(`Session ${this.sessionId} init timed out`)) }, timeoutMs) const origResolve = this.initResolve this.initResolve = (data: InitData) => { clearTimeout(timer) origResolve?.(data) resolve() } }) } constructor(private opts: SessionHandleOptions) { this.sessionId = opts.sessionId this.cwd = opts.cwd this.eventBus = opts.eventBus this._model = opts.model this._permissionMode = opts.permissionMode ?? 'acceptEdits' this.verbose = opts.verbose ?? false } private emitEvent(event: string, data: Record): void { if (this.opts.silent) return this.eventBus.publishSessionEvent(this.sessionId, event, data) } onMessage(listener: MessageListener): () => void { this.listeners.add(listener) return () => { this.listeners.delete(listener) } } spawn(): void { const printArgs = [ '--print', '--input-format', 'stream-json', '--output-format', 'stream-json', '--session-id', this.sessionId, ...(this.opts.model ? ['--model', this.opts.model] : []), '--permission-mode', this._permissionMode, '--permission-prompt-tool', 'stdio', ...(this.opts.resumeSessionId ? ['--resume', this.opts.resumeSessionId] : []), ...(this.opts.resumeSessionAt ? ['--resume-session-at', this.opts.resumeSessionAt] : []), '--verbose', ] const env: NodeJS.ProcessEnv = { ...process.env, CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC: '1', } if (this.opts.systemPrompt) { env.CLAUDE_CODE_SYSTEM_PROMPT = this.opts.systemPrompt } const saved = loadChildSpawnPrefix() const defineArgs = saved?.defineArgs ?? [] const featureArgs = saved?.featureArgs ?? [] const spawnArgs = [...defineArgs, ...featureArgs, ...this.opts.scriptArgs, ...printArgs] this.child = spawn(this.opts.execPath, spawnArgs, { cwd: this.opts.cwd, stdio: ['pipe', 'pipe', 'pipe'], env, windowsHide: true, }) this.setupStdout() this.setupStderr() this.child.on('close', (code, signal) => { if (this._status !== 'stopped') { this._status = 'stopped' this.emitEvent('deleted', { status: 'stopped', exit_code: code, signal: signal ?? null, }) } if (this.initResolve) { this.initResolve = null } if (this.promptReject) { this.promptReject(new Error(`Process exited with code ${code}`)) } }) this.child.on('error', err => { logError(err) this._status = 'stopped' if (this.initResolve) { this.initResolve = null } if (this.promptReject) { this.promptReject(err) } }) this.initRequestId = crypto.randomUUID() this.sendInitialize() const timeout = setTimeout(() => { this.initResolve = null this.initReject = null this._status = 'stopped' this.emitEvent('deleted', { status: 'stopped', reason: 'init_timeout', }) }, 30000) this.initResolve = (data: InitData) => { clearTimeout(timeout) this.initReject = null } this.initReject = (_err: unknown) => { clearTimeout(timeout) this.initResolve = null } } async start(): Promise { if (!this.child) { this.spawn() } if (this._status === 'running') return return new Promise((resolve, reject) => { const origResolve = this.initResolve const origReject = this.initReject this.initResolve = (data: InitData) => { origResolve?.(data) resolve() } this.initReject = (err: unknown) => { origReject?.(err) reject(err) } }) } private sendInitialize(): void { const request: Record = { subtype: 'initialize', } if (this.opts.hooks) { request.hooks = this.opts.hooks } const msg = jsonStringify({ type: 'control_request', request_id: this.initRequestId, request, }) this.writeStdin(msg) } private setupStdout(): void { if (!this.child?.stdout) return const rl = createInterface({ input: this.child.stdout }) rl.on('line', line => { if (!line.trim()) return let msg: StdoutMessage try { msg = jsonParse(line) as StdoutMessage } catch { return } this.routeMessage(msg) }) } private setupStderr(): void { if (!this.child?.stderr) return const rl = createInterface({ input: this.child.stderr }) rl.on('line', line => { if (this.verbose) { process.stderr.write(`[serve:${this.sessionId}] ${line}\n`) } if (this.lastStderr.length >= 20) { this.lastStderr.shift() } this.lastStderr.push(line) }) } private emitMessage(msg: StdoutMessage): void { for (const listener of this.listeners) { try { listener(msg) } catch {} } } private routeMessage(msg: StdoutMessage): void { if ( this._status === 'starting' && msg.type === 'control_response' && this.initResolve ) { const response = msg.response as Record | undefined if (response?.subtype === 'success' && response?.request_id === this.initRequestId) { const initData = (response.response ?? {}) as InitData this._initData = initData if (Array.isArray(initData.models) && initData.models.length > 0 && !this._model) { const first = (initData.models as Array>)[0] this._model = first?.value ?? first?.name } if (initData.account?.apiProvider) { this._providerId = initData.account.apiProvider } this._status = 'running' this.lastActiveAt = Date.now() const resolve = this.initResolve this.initResolve = null this.initReject = null resolve?.(initData) this.emitEvent('ready', { status: 'running', model: this._model, provider_id: this._providerId, }) this.opts.onInit?.(initData) return } } if (msg.type === 'control_response') { const response = msg.response as Record | undefined const requestId = response?.request_id as string | undefined if (requestId && this.pendingControlResponses.has(requestId)) { const pending = this.pendingControlResponses.get(requestId)! this.pendingControlResponses.delete(requestId) clearTimeout(pending.timeout) if (response?.subtype === 'error') { pending.reject(new Error((response.error as string) ?? 'Unknown error')) } else { pending.resolve((response?.response ?? {}) as Record) } return } } this.emitMessage(msg) switch (msg.type) { case 'assistant': { this.lastActiveAt = Date.now() this._status = 'running' if (msg.uuid && typeof msg.uuid === 'string') { this._lastMessageUuid = msg.uuid } if (!msg.model && this._model) { msg = { ...msg, model: this._model } } if (!msg.provider_id && this._providerId) { msg = { ...msg, provider_id: this._providerId } } this.emitEvent('message', msg) break } case 'user': { if (msg.isReplay) break const content = typeof msg.content === 'string' ? msg.content : '' if (content.includes('')) break this.emitEvent('message', msg) break } case 'result': { this.lastActiveAt = Date.now() this._status = 'running' const cost = msg.cost_usd as number | undefined const usage = msg.usage as | { input_tokens?: number; output_tokens?: number } | undefined if (cost) this._costUsd += cost if (usage?.input_tokens) this._inputTokens += usage.input_tokens if (usage?.output_tokens) this._outputTokens += usage.output_tokens this.emitEvent('result', msg) if (this.promptResolve) { this.promptResolve({ done: true }) this.promptResolve = null this.promptReject = null } break } case 'control_request': { const request = msg.request as Record | undefined const requestId = msg.request_id as string if (request?.subtype === 'can_use_tool') { const perm: PendingPermission = { requestId, sessionId: this.sessionId, toolName: (request.tool_name as string) ?? 'Unknown', toolUseId: (request.tool_use_id as string) ?? '', input: (request.input as Record) ?? {}, title: `${request.tool_name}: ${JSON.stringify(request.input).slice(0, 80)}`, description: `Execute ${request.tool_name}`, suggestions: (request.permission_suggestions as Record[]) ?? [], } this.pendingPermissions.set(requestId, perm) this.emitEvent('control_request', { request_id: requestId, request }) } else if (request?.subtype === 'elicitation') { const question: PendingQuestion = { requestId, sessionId: this.sessionId, mcpServerName: (request.mcp_server_name as string) ?? '', message: (request.message as string) ?? '', mode: (request.mode as string) ?? 'form', requestedSchema: (request.requested_schema as Record) ?? {}, } this.pendingQuestions.set(requestId, question) this.emitEvent('control_request', { request_id: requestId, request }) } else if (request?.subtype === 'hook_callback') { this.emitEvent('control_request', { request_id: requestId, request }) const callbackId = request.callback_id as string | undefined if (!callbackId || callbackId === 'AUTO_APPROVE_CALLBACK_ID') { this.writeStdin(jsonStringify({ type: 'control_response', response: { subtype: 'success', request_id: requestId, response: { hookSpecificOutput: { hookEventName: 'PreToolUse', permissionDecision: 'allow', permissionDecisionReason: 'Auto-approved by serve', }, }, }, })) } else { this.writeStdin(jsonStringify({ type: 'control_response', response: { subtype: 'success', request_id: requestId, response: { hookSpecificOutput: { hookEventName: 'PreToolUse', permissionDecision: 'ask', permissionDecisionReason: 'Forwarding to can_use_tool', }, }, }, })) } } break } case 'control_cancel_request': { const cancelRequestId = msg.request_id as string this.pendingPermissions.delete(cancelRequestId) this.pendingQuestions.delete(cancelRequestId) break } case 'stream_event': { this.lastActiveAt = Date.now() this.emitEvent('stream_event', msg) break } default: { break } } } private pendingControlResponses = new Map< string, { resolve: (response: Record) => void reject: (error: Error) => void timeout: ReturnType } >() setTitle(title: string): void { this._title = title } async sendControlRequest( request: Record, timeoutMs = 10000, ): Promise> { const requestId = crypto.randomUUID() this.writeStdin( jsonStringify({ type: 'control_request', request_id: requestId, request, }), ) return this.waitForControlResponse(requestId, timeoutMs) } async setModel(model: string): Promise { this._model = model await this.sendControlRequest({ subtype: 'set_model', model }) } async setPermissionMode(mode: string): Promise { this._permissionMode = mode await this.sendControlRequest({ subtype: 'set_permission_mode', mode }) } async getMcpStatus(): Promise { const response = await this.sendControlRequest({ subtype: 'mcp_status' }) return (response.mcpServers as unknown[]) ?? [] } private waitForControlResponse( requestId: string, timeoutMs = 10000, ): Promise> { return new Promise((resolve, reject) => { const timeout = setTimeout(() => { this.pendingControlResponses.delete(requestId) reject(new Error(`Control response timed out for ${requestId}`)) }, timeoutMs) this.pendingControlResponses.set(requestId, { resolve, reject, timeout }) }) } async prompt(content: string): Promise<{ done: boolean; error?: string }> { if (this._status === 'stopped') { throw new Error( `Session ${this.sessionId} is stopped`, ) } if (this._status !== 'running') { await this.waitReady() } if (this._prompting) { throw new Error( `Session ${this.sessionId} is already processing a prompt`, ) } this._prompting = true this.lastActiveAt = Date.now() const userMsg = jsonStringify({ type: 'user', content, uuid: crypto.randomUUID(), session_id: this.sessionId, message: { role: 'user', content }, parent_tool_use_id: null, }) this.emitEvent('message', { type: 'user', content, session_id: this.sessionId, model: this._model ?? '', provider_id: this._providerId ?? '', }) this.writeStdin(userMsg) return new Promise((resolve, reject) => { this.promptResolve = (value) => { this._prompting = false resolve(value) } this.promptReject = (reason) => { this._prompting = false reject(reason) } }) } async abort(): Promise { if (!this.child || this.child.killed) return const interrupt = jsonStringify({ type: 'control_request', request_id: crypto.randomUUID(), request: { subtype: 'interrupt' }, }) this.writeStdin(interrupt) await new Promise(resolve => { const timeout = setTimeout(() => { this.kill() resolve() }, 5000) const check = setInterval(() => { if (!this.child || this.child.killed) { clearTimeout(timeout) clearInterval(check) resolve() } }, 200) }) } replyPermission( requestId: string, behavior: 'allow' | 'deny', opts?: { updatedInput?: Record updatedPermissions?: Record[] message?: string interrupt?: boolean decisionClassification?: string }, ): void { const response = { type: 'control_response', response: { subtype: 'success', request_id: requestId, response: behavior === 'allow' ? { behavior: 'allow', updatedInput: opts?.updatedInput ?? {}, ...(opts?.updatedPermissions ? { updatedPermissions: opts.updatedPermissions } : {}), ...(opts?.decisionClassification ? { decisionClassification: opts.decisionClassification } : {}), } : { behavior: 'deny', message: opts?.message ?? 'Denied', ...(opts?.interrupt ? { interrupt: true } : {}), }, }, } this.writeStdin(jsonStringify(response)) this.pendingPermissions.delete(requestId) this.emitEvent('permission_replied', { request_id: requestId, behavior, }) } replyQuestion( requestId: string, action: 'accept' | 'decline', content?: Record, ): void { const response = { type: 'control_response', response: { subtype: 'success', request_id: requestId, response: action === 'accept' ? { action: 'accept', content } : { action: 'decline' }, }, } this.writeStdin(jsonStringify(response)) this.pendingQuestions.delete(requestId) this.emitEvent('question_replied', { request_id: requestId, action, }) } kill(): void { if (this.child && !this.child.killed) { if (process.platform === 'win32') { this.child.kill() } else { this.child.kill('SIGTERM') } } this._status = 'stopped' this.rejectAllPendingControlResponses(new Error('Session killed')) } forceKill(): void { if (this.child && !this.child.killed) { if (process.platform === 'win32') { this.child.kill() } else { this.child.kill('SIGKILL') } } this._status = 'stopped' this.rejectAllPendingControlResponses(new Error('Session force killed')) } getPendingPermissions(): PendingPermission[] { return [...this.pendingPermissions.values()] } getPendingPermission(requestId: string): PendingPermission | undefined { return this.pendingPermissions.get(requestId) } getPendingQuestions(): PendingQuestion[] { return [...this.pendingQuestions.values()] } getPendingQuestion(requestId: string): PendingQuestion | undefined { return this.pendingQuestions.get(requestId) } getLastStderr(): string[] { return [...this.lastStderr] } getInfo() { return { session_id: this.sessionId, status: this._status, cwd: this.cwd, title: this._title, model: this._model, permission_mode: this._permissionMode, created_at: this.createdAt, last_active_at: this.lastActiveAt, cost_usd: this._costUsd, input_tokens: this._inputTokens, output_tokens: this._outputTokens, last_message_uuid: this._lastMessageUuid, } } private writeStdin(data: string): void { if (this.child?.stdin && !this.child.stdin.destroyed) { const flushed = this.child.stdin.write(data + '\n') if (!flushed) { this.child.stdin.once('drain', () => {}) } } } private rejectAllPendingControlResponses(error: Error): void { for (const [id, pending] of this.pendingControlResponses) { clearTimeout(pending.timeout) pending.reject(error) } this.pendingControlResponses.clear() } }