- prompt/prompt_async 接口找不到内存 session 时自动从磁盘历史恢复 - 从 transcript 读取历史 session 的原始 cwd,保证子进程在正确目录启动 - resume 时不传 --session-id 避免与 --resume 冲突(csc 限制) - sessionManager.createSession 支持传入指定 sessionId,resume 时复用历史 id Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
872 lines
25 KiB
TypeScript
872 lines
25 KiB
TypeScript
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<string, unknown>
|
||
title: string
|
||
description: string
|
||
suggestions: Record<string, unknown>[]
|
||
}
|
||
|
||
export type PendingQuestion = {
|
||
requestId: string
|
||
sessionId: string
|
||
mcpServerName: string
|
||
message: string
|
||
mode: string
|
||
requestedSchema: Record<string, unknown>
|
||
}
|
||
|
||
export type SessionHandleOptions = {
|
||
sessionId: string
|
||
cwd: string
|
||
model?: string
|
||
permissionMode?: string
|
||
systemPrompt?: string
|
||
resumeSessionId?: string
|
||
resumeSessionAt?: string
|
||
hooks?: Record<string, unknown>
|
||
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<void> {
|
||
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<string, string>; 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
|
||
/** 子进程实际的工作目录,dev 模式下与 cwd 不同(csc 根目录),用于定位 transcript 文件 */
|
||
private _spawnCwd: string | undefined
|
||
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<string, PendingPermission>()
|
||
private pendingQuestions = new Map<string, PendingQuestion>()
|
||
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<MessageListener>()
|
||
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
|
||
}
|
||
|
||
get spawnCwd(): string {
|
||
return this._spawnCwd ?? this.cwd
|
||
}
|
||
|
||
async waitReady(timeoutMs = 30000): Promise<void> {
|
||
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
|
||
const origReject = this.initReject
|
||
this.initResolve = (data: InitData) => {
|
||
clearTimeout(timer)
|
||
origResolve?.(data)
|
||
resolve()
|
||
}
|
||
this.initReject = (err: unknown) => {
|
||
clearTimeout(timer)
|
||
origReject?.(err)
|
||
reject(err)
|
||
}
|
||
})
|
||
}
|
||
|
||
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<string, unknown>): 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',
|
||
// resume 时不传 --session-id,csc 会继承历史 session 的 id
|
||
...(this.opts.resumeSessionId ? [] : ['--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._spawnCwd = this.opts.cwd
|
||
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.initReject) {
|
||
const reject = this.initReject
|
||
this.initResolve = null
|
||
this.initReject = null
|
||
process.stderr.write(`[serve:${this.sessionId}] exit_code=${code} cwd=${this.cwd} stderr:\n${this.lastStderr.slice(-5).join('\n')}\n`)
|
||
reject(new Error(`Process exited with code ${code}`))
|
||
}
|
||
if (this.promptReject) {
|
||
this.promptReject(new Error(`Process exited with code ${code}`))
|
||
}
|
||
})
|
||
|
||
this.child.on('error', err => {
|
||
logError(err)
|
||
this._status = 'stopped'
|
||
if (this.initReject) {
|
||
const reject = this.initReject
|
||
this.initResolve = null
|
||
this.initReject = null
|
||
reject(err)
|
||
}
|
||
if (this.promptReject) {
|
||
this.promptReject(err)
|
||
}
|
||
})
|
||
|
||
this.initRequestId = crypto.randomUUID()
|
||
this.sendInitialize()
|
||
|
||
const timeout = setTimeout(() => {
|
||
if (this.initReject) {
|
||
const reject = this.initReject
|
||
this.initResolve = null
|
||
this.initReject = null
|
||
this._status = 'stopped'
|
||
this.emitEvent('deleted', {
|
||
status: 'stopped',
|
||
reason: 'init_timeout',
|
||
})
|
||
reject(new Error(`Session ${this.sessionId} init timed out`))
|
||
}
|
||
}, 30000)
|
||
|
||
this.initResolve = (data: InitData) => {
|
||
clearTimeout(timeout)
|
||
this.initReject = null
|
||
}
|
||
this.initReject = (_err: unknown) => {
|
||
clearTimeout(timeout)
|
||
this.initResolve = null
|
||
}
|
||
}
|
||
|
||
async start(): Promise<void> {
|
||
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<string, unknown> = {
|
||
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<string, unknown> | 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<Record<string, string>>)[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<string, unknown> | 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<string, unknown>)
|
||
}
|
||
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('<local-command-stdout>')) 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<string, unknown> | 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<string, unknown>) ?? {},
|
||
title: `${request.tool_name}: ${JSON.stringify(request.input).slice(0, 80)}`,
|
||
description: `Execute ${request.tool_name}`,
|
||
suggestions:
|
||
(request.permission_suggestions as Record<string, unknown>[]) ?? [],
|
||
}
|
||
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<string, unknown>) ?? {},
|
||
}
|
||
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<string, unknown>) => void
|
||
reject: (error: Error) => void
|
||
timeout: ReturnType<typeof setTimeout>
|
||
}
|
||
>()
|
||
|
||
setTitle(title: string): void {
|
||
this._title = title
|
||
}
|
||
|
||
async sendControlRequest(
|
||
request: Record<string, unknown>,
|
||
timeoutMs = 10000,
|
||
): Promise<Record<string, unknown>> {
|
||
const requestId = crypto.randomUUID()
|
||
this.writeStdin(
|
||
jsonStringify({
|
||
type: 'control_request',
|
||
request_id: requestId,
|
||
request,
|
||
}),
|
||
)
|
||
return this.waitForControlResponse(requestId, timeoutMs)
|
||
}
|
||
|
||
async setModel(model: string): Promise<void> {
|
||
this._model = model
|
||
await this.sendControlRequest({ subtype: 'set_model', model })
|
||
}
|
||
|
||
async setPermissionMode(mode: string): Promise<void> {
|
||
this._permissionMode = mode
|
||
await this.sendControlRequest({ subtype: 'set_permission_mode', mode })
|
||
}
|
||
|
||
async getMcpStatus(): Promise<unknown[]> {
|
||
const response = await this.sendControlRequest({ subtype: 'mcp_status' })
|
||
return (response.mcpServers as unknown[]) ?? []
|
||
}
|
||
|
||
private waitForControlResponse(
|
||
requestId: string,
|
||
timeoutMs = 10000,
|
||
): Promise<Record<string, unknown>> {
|
||
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<void> {
|
||
if (!this.child || this.child.killed) return
|
||
|
||
const interrupt = jsonStringify({
|
||
type: 'control_request',
|
||
request_id: crypto.randomUUID(),
|
||
request: { subtype: 'interrupt' },
|
||
})
|
||
this.writeStdin(interrupt)
|
||
|
||
if (!this.promptResolve) return
|
||
|
||
await new Promise<void>(resolve => {
|
||
const timeout = setTimeout(() => {
|
||
this.kill()
|
||
resolve()
|
||
}, 2000)
|
||
const originalResolve = this.promptResolve
|
||
this.promptResolve = (value) => {
|
||
clearTimeout(timeout)
|
||
originalResolve?.(value)
|
||
resolve()
|
||
}
|
||
})
|
||
}
|
||
|
||
replyPermission(
|
||
requestId: string,
|
||
behavior: 'allow' | 'deny',
|
||
opts?: {
|
||
updatedInput?: Record<string, unknown>
|
||
updatedPermissions?: Record<string, unknown>[]
|
||
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<string, unknown>,
|
||
): 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()
|
||
}
|
||
}
|