/** * ACP Client - Agent Client Protocol connection manager for Electron * * Spawns an ACP-compatible agent binary as a subprocess and communicates * via NDJSON over stdio. Uses @agentclientprotocol/sdk's ClientSideConnection + ndJsonStream. * * Supported engines: * - claude-code: spawn `claude-code-acp-ts` (npm-local dependency) * - qimingcode: spawn `qimingcode acp` (npm-local dependency) * * References Tauri client's rcoder_impl.rs pattern: * - Binaries installed as npm-local dependencies in ~/.qimingclaw/node_modules/.bin/ * - CLAUDE_CODE_ACP_PATH env var points to claude-code-acp-ts binary * - ACP protocol uses NDJSON (newline-delimited JSON) over stdin/stdout */ import { spawn, ChildProcess } from "child_process"; import { Readable, Writable, Transform } from "stream"; import * as path from "path"; import * as os from "os"; import * as fs from "fs"; import { app } from "electron"; import log from "electron-log"; import { getAppEnv, getQimingCodeBundledBinPath, getNodeBinPathWithFallback, getClaudeCodeAcpBundledDir, } from "../../system/dependencies"; import { APP_DATA_DIR_NAME, LOGS_DIR_NAME } from "../../constants"; import { APP_NAME_IDENTIFIER } from "../../../../shared/constants"; import { isWindows } from "../../system/shellEnv"; import { createPlatformAdapter } from "../../system/platformAdapter"; import { spawnJsFile, resolveNpmPackageEntry } from "../../utils/spawnNoWindow"; import { processRegistry } from "../../system/processRegistry"; import { killProcessTreeGraceful } from "../../utils/processTree"; import { perfEmitter } from "../perf/perfEmitter"; import { firstTokenTrace } from "../perf/firstTokenTrace"; import { buildSandboxedSpawnArgs } from "../../sandbox/sandboxProcessWrapper"; import type { SandboxProcessConfig } from "@shared/types/sandbox"; function extractSessionIdFromLine(line: string): string | undefined { try { const obj = JSON.parse(line) as { sessionId?: unknown; params?: { sessionId?: unknown }; result?: { sessionId?: unknown }; data?: { sessionId?: unknown }; update?: { sessionId?: unknown }; }; const candidates = [ obj.sessionId, obj.params?.sessionId, obj.result?.sessionId, obj.data?.sessionId, obj.update?.sessionId, ]; for (const c of candidates) { if (typeof c === "string" && c) return c; } return undefined; } catch { return undefined; } } type McpTransportStatus = "stable" | "reconnecting"; interface McpTransportTelemetry { status: McpTransportStatus; lastDisconnectAt?: number; lastReconnectAttemptAt?: number; lastReconnectAt?: number; lastSignal?: string; } const mcpTransportTelemetry = new WeakMap< ChildProcess, McpTransportTelemetry >(); export interface McpTransportSnapshot { status: McpTransportStatus; lastDisconnectAt?: number; lastReconnectAttemptAt?: number; lastReconnectAt?: number; lastSignal?: string; } function ensureTelemetry(proc: ChildProcess): McpTransportTelemetry { const existing = mcpTransportTelemetry.get(proc); if (existing) return existing; const initial: McpTransportTelemetry = { status: "stable" }; mcpTransportTelemetry.set(proc, initial); return initial; } function updateMcpTransportTelemetry(proc: ChildProcess, line: string): void { const telemetry = ensureTelemetry(proc); const lower = line.toLowerCase(); const now = Date.now(); const preview = line.length > 240 ? line.slice(0, 240) + "..." : line; const isDisconnect = (lower.includes("transport error") && (lower.includes("sse stream disconnected") || lower.includes("typeerror: terminated"))) || lower.includes("sse stream disconnected"); if (isDisconnect) { telemetry.status = "reconnecting"; telemetry.lastDisconnectAt = now; telemetry.lastSignal = preview; return; } if (lower.includes("reconnecting in")) { telemetry.status = "reconnecting"; telemetry.lastReconnectAttemptAt = now; telemetry.lastSignal = preview; return; } const isReconnected = lower.includes("mcp session reconnected") || lower.includes("connected via streamablehttpclienttransport"); if (isReconnected) { telemetry.status = "stable"; telemetry.lastReconnectAt = now; telemetry.lastSignal = preview; } } export function getMcpTransportSnapshot( proc?: ChildProcess | null, ): McpTransportSnapshot | null { if (!proc) return null; const telemetry = mcpTransportTelemetry.get(proc); if (!telemetry) return null; return { status: telemetry.status, lastDisconnectAt: telemetry.lastDisconnectAt, lastReconnectAttemptAt: telemetry.lastReconnectAttemptAt, lastReconnectAt: telemetry.lastReconnectAt, lastSignal: telemetry.lastSignal, }; } export function isMcpReconnectWindowActive( proc?: ChildProcess | null, windowMs = 4000, ): boolean { if (!proc) return false; const telemetry = mcpTransportTelemetry.get(proc); if (!telemetry) return false; const now = Date.now(); const hasRecentDisconnect = telemetry.lastDisconnectAt !== undefined && now - telemetry.lastDisconnectAt <= windowMs; const hasRecentReconnect = telemetry.lastReconnectAt !== undefined && now - telemetry.lastReconnectAt <= Math.min(windowMs, 1500); return ( telemetry.status === "reconnecting" || hasRecentDisconnect || hasRecentReconnect ); } // ==================== Types ==================== /** ACP MCP Server types (matching @agentclientprotocol/sdk schema) */ export interface AcpEnvVariable { name: string; value: string; } export interface AcpHttpHeader { name: string; value: string; } export type AcpMcpServer = | { name: string; command: string; args: string[]; env: AcpEnvVariable[] } | { name: string; url: string; headers: AcpHttpHeader[]; type: "http" } | { name: string; url: string; headers: AcpHttpHeader[]; type: "sse" }; /** ACP SDK module shape (loaded dynamically since it's ESM) */ export interface AcpSdkModule { ndJsonStream: ( input: WritableStream, output: ReadableStream, ) => unknown; ClientSideConnection: new ( clientFactory: (agent: unknown) => unknown, stream: unknown, ) => AcpClientSideConnection; PROTOCOL_VERSION: number; } /** ClientSideConnection interface */ export interface AcpClientSideConnection { initialize(params: { protocolVersion: number; clientCapabilities?: Record; }): Promise<{ protocolVersion: number; agentCapabilities?: Record; }>; newSession(params: { cwd: string; mcpServers: Array; _meta?: { [key: string]: unknown } | null; }): Promise<{ sessionId: string }>; prompt(params: { sessionId: string; prompt: Array<{ type: string; text?: string; uri?: string; mimeType?: string; }>; _meta?: { [key: string]: unknown } | null; }): Promise<{ stopReason: string }>; cancel(params: { sessionId: string }): Promise; closed: Promise; } /** ACP Client handler interface (callbacks from agent → client) */ export interface AcpClientHandler { sessionUpdate?(params: { sessionId: string; update: AcpSessionUpdate; }): Promise; requestPermission?( params: AcpPermissionRequest, ): Promise; readTextFile?(params: { sessionId: string; uri: string; }): Promise<{ content: string }>; writeTextFile?(params: { sessionId: string; uri: string; content: string; }): Promise>; // --- ACP Terminal API (terminal/* methods) --- createTerminal?(params: { sessionId: string; command: string; args?: string[]; env?: Array<{ name: string; value: string }>; cwd?: string | null; outputByteLimit?: number | null; }): Promise<{ terminalId: string }>; terminalOutput?(params: { sessionId: string; terminalId: string }): Promise<{ output: string; truncated: boolean; exitStatus?: { exitCode: number | null; signal: string | null; } | null; }>; waitForTerminalExit?(params: { sessionId: string; terminalId: string; }): Promise<{ exitCode: number | null; signal: string | null }>; killTerminal?(params: { sessionId: string; terminalId: string; }): Promise>; releaseTerminal?(params: { sessionId: string; terminalId: string; }): Promise>; } /** ACP session update types */ export type AcpSessionUpdate = | AcpAgentMessageChunk | AcpAgentThoughtChunk | AcpToolCall | AcpToolCallUpdate | AcpSessionInfoUpdate | AcpUsageUpdate | { sessionUpdate: string; [key: string]: unknown }; export interface AcpAgentMessageChunk { sessionUpdate: "agent_message_chunk"; content: { type: string; text?: string }; } export interface AcpAgentThoughtChunk { sessionUpdate: "agent_thought_chunk"; content: { type: string; text?: string }; } export interface AcpToolCall { sessionUpdate: "tool_call"; toolCallId: string; title: string; kind?: string; status: string; rawInput?: unknown; content?: Array<{ type: string; [key: string]: unknown }>; locations?: unknown[]; } export interface AcpToolCallUpdate { sessionUpdate: "tool_call_update"; toolCallId: string; status: string; /** qimingcode sends kind/title in update even though spec only requires them in tool_call */ kind?: string; title?: string; rawInput?: unknown; rawOutput?: unknown; locations?: Array<{ path?: string; [key: string]: unknown }>; content?: Array<{ type: string; [key: string]: unknown }>; } export interface AcpSessionInfoUpdate { sessionUpdate: "session_info_update"; title?: string; [key: string]: unknown; } export interface AcpUsageUpdate { sessionUpdate: "usage_update"; [key: string]: unknown; } export type AcpPermissionOptionKind = | "allow_once" | "allow_always" | "reject_once" | "reject_always"; export interface AcpPermissionOption { optionId: string; kind: AcpPermissionOptionKind; name: string; } export interface AcpPermissionRequest { sessionId: string; toolCall: { toolCallId: string; title?: string | null; kind?: string | null; rawInput?: unknown; }; options: AcpPermissionOption[]; } export interface AcpPermissionResponse { outcome: { outcome: "selected" | "cancelled"; optionId?: string; }; } /** Config for creating an ACP connection */ export interface AcpConnectionConfig { /** Binary path to spawn (e.g. claude-code-acp-ts or qimingcode) */ binPath: string; /** Binary arguments (e.g. [] for claude-code-acp-ts, ['acp'] for qimingcode) */ binArgs: string[]; /** Whether the binary is a native executable (not a JS file) */ isNative?: boolean; workspaceDir: string; apiKey?: string; baseUrl?: string; model?: string; apiProtocol?: string; env?: Record; /** Engine type for process registry tracking */ engineType?: "claude-code" | "qimingcode"; /** Purpose of this process (for process registry) */ purpose?: "engine"; /** Sandbox wrapping configuration (omit to disable) */ sandbox?: SandboxProcessConfig; } /** Result of creating an ACP connection */ export interface AcpConnectionResult { connection: AcpClientSideConnection; process: ChildProcess; /** Isolated HOME directory created for this ACP process (for cleanup) */ isolatedHome: string; /** * 🔧 FIX: Cleanup function to properly dispose of the ACP process. * Removes all event listeners to prevent handle leaks. * * IMPORTANT: Call this before destroying the process to release Windows handles! */ cleanup: () => void; /** Cleanup sandbox resources (temp profiles, etc.) */ sandboxCleanup?: () => void; } // ==================== SDK Loader ==================== let _acpSdkPromise: Promise | null = null; /** * Dynamically load the ACP SDK (@agentclientprotocol/sdk). * * Uses `new Function('s', 'return import(s)')` to bypass CJS → ESM restriction * (tsc compiles import() to require() in CJS mode). */ export function loadAcpSdk(): Promise { if (!_acpSdkPromise) { const dynamicImport = new Function( "specifier", "return import(specifier)", ) as (specifier: string) => Promise; _acpSdkPromise = dynamicImport("@agentclientprotocol/sdk").catch( (error) => { log.error("[AcpClient] Failed to load ACP SDK:", error); _acpSdkPromise = null; throw error; }, ); } return _acpSdkPromise; } // ==================== Binary Path ==================== /** * Get ACP package directory * 优先使用应用内集成的 bundled 路径,回退到 node_modules */ function getAcpPackageDir(packageName: string): string | null { // claude-code-acp-ts: 优先检查 bundled 目录 if (packageName === "claude-code-acp-ts") { const bundledDir = getClaudeCodeAcpBundledDir(); if (bundledDir) { return bundledDir; } } // 回退到 node_modules const nodeModules = path.join( app.getPath("home"), APP_DATA_DIR_NAME, "node_modules", ); const packageDir = path.join(nodeModules, packageName); return fs.existsSync(packageDir) ? packageDir : null; } function getQimingCodePersistentLogDir(): string { return path.join( app.getPath("home"), APP_DATA_DIR_NAME, LOGS_DIR_NAME, "qimingcode", ); } /** * Resolve ACP binary path for a given engine type. * * - claude-code → ~/.qimingclaw/node_modules/claude-code-acp-ts (JS entry, spawned via node) * - qimingcode → native Go binary (spawned directly, not via node) * * For qimingcode, we resolve the platform-specific native binary directly * instead of going through the JS wrapper (qimingcode/bin/qimingcode). * * Reasons: * 1. The JS wrapper uses `spawnSync(binary, args, { stdio: 'inherit' })` * without `windowsHide: true`, causing console popup on Windows. * 2. The intermediate Node.js process adds an unnecessary process layer * that can interfere with SIGTERM propagation and stdio piping. * 3. Spawning the native binary directly is faster and more reliable. * * Returns `isNative: true` when the binary should be spawned directly * (not via `node`). */ export function resolveAcpBinary(engine: "claude-code" | "qimingcode"): { binPath: string; binArgs: string[]; isNative: boolean; } { if (engine === "claude-code") { const packageDir = getAcpPackageDir("claude-code-acp-ts"); const entryPath = packageDir ? resolveNpmPackageEntry(packageDir, "claude-code-acp-ts") : null; return { binPath: entryPath || "", binArgs: [], isNative: false, }; } // qimingcode: resolve platform-specific native binary directly const nativePath = resolveQimingCodeNativeBinary(); if (nativePath) { log.info(`[AcpClient] qimingcode: using native binary: ${nativePath}`); return { binPath: nativePath, binArgs: ["acp"], isNative: true, }; } // Fallback: use JS wrapper (will have Windows popup issue) log.warn( "[AcpClient] qimingcode: native binary not found, falling back to JS wrapper", ); const packageDir = getAcpPackageDir("qimingcode"); const entryPath = packageDir ? resolveNpmPackageEntry(packageDir, "qimingcode") : null; return { binPath: entryPath || "", binArgs: ["acp"], isNative: false, }; } /** * Resolve the platform-specific qimingcode native binary path. * * qimingcode npm package uses optionalDependencies with platform-specific packages: * - qimingcode-darwin-arm64/bin/qimingcode * - qimingcode-windows-x64/bin/qimingcode.exe * - qimingcode-linux-x64/bin/qimingcode * etc. * * Logic mirrors qimingcode/bin/qimingcode JS wrapper. */ function resolveQimingCodeNativeBinary(): string | null { // 应用内打包的二进制(唯一来源) const bundledPath = getQimingCodeBundledBinPath(); if (bundledPath) { log.info("[AcpClient] qimingcode: using bundled binary:", bundledPath); return bundledPath; } log.error("[AcpClient] qimingcode: bundled binary not found"); return null; } // ==================== Connection Factory ==================== /** * Create an ACP connection by spawning an agent binary and establishing * a ClientSideConnection over NDJSON stdin/stdout. * * Works with any ACP-compatible binary: * - claude-code-acp-ts (no args) * - qimingcode acp (args: ['acp']) */ export async function createAcpConnection( config: AcpConnectionConfig, clientHandler: AcpClientHandler, ): Promise { const { binPath, binArgs } = config; let effectiveBinArgs = [...binArgs]; if (!fs.existsSync(binPath)) { throw new Error( `ACP binary not found at: ${binPath}. Please install it first.`, ); } if (config.engineType === "qimingcode" && firstTokenTrace.isDeepMode()) { if (!effectiveBinArgs.includes("--print-logs")) { effectiveBinArgs.push("--print-logs"); } if (!effectiveBinArgs.includes("--log-level")) { effectiveBinArgs.push( "--log-level", process.env.QIMING_TRACE_QIMINGCODE_LOG_LEVEL || "DEBUG", ); } if (!effectiveBinArgs.includes("--log-dir")) { const traceLogDir = firstTokenTrace.getQimingCodeLogDir(); if (traceLogDir) { fs.mkdirSync(traceLogDir, { recursive: true }); effectiveBinArgs.push("--log-dir", traceLogDir); } } firstTokenTrace.trace( "acp.conn.qimingcode.trace_flags", { engine: config.engineType }, { binPath, args: effectiveBinArgs }, ); } const setupTimer = perfEmitter.start(); // Build isolated environment (aligned with rcoder + engineManager pattern) // 1. Start with getAppEnv() for complete isolation (node/npm/uv paths, no system PATH) // 2. Create isolated HOME/config dir so Claude Code uses empty config (not user's global ~/.claude/) // 3. Only inject model vars from ACP-provided config // Create isolated HOME directory with empty .claude/ config // This prevents Claude Code from reading user's global ~/.claude/settings.json const runId = `acp-${Date.now()}-${Math.random().toString(36).substring(2, 8)}`; const isolatedHome = path.join( os.tmpdir(), `${APP_NAME_IDENTIFIER}-${runId}`, ); fs.mkdirSync(path.join(isolatedHome, ".claude"), { recursive: true }); // 获取应用隔离环境变量(包含隔离的 PATH、npm、uv 配置等) const appEnv = getAppEnv(); // 构建最终环境变量:以 appEnv 为基础,添加 ACP 特定配置 const env: Record = { ...appEnv, // 包含完全隔离的 PATH、NODE_PATH、npm/uv 配置等 // Isolated HOME — Claude Code won't read user's global config HOME: isolatedHome, USERPROFILE: isolatedHome, // Windows // XDG 目录(Unix/Linux 标准,Windows 上也设置以兼容可能的工具) XDG_CONFIG_HOME: path.join(isolatedHome, ".config"), XDG_DATA_HOME: path.join(isolatedHome, ".local", "share"), XDG_CACHE_HOME: path.join(isolatedHome, ".cache"), // 引擎配置目录 CLAUDE_CONFIG_DIR: path.join(isolatedHome, ".claude"), QIMINGCODE_CONFIG_DIR: path.join(isolatedHome, ".qimingcode"), // Disable non-essential traffic (aligned with rcoder ENV_DISABLE_NONESSENTIAL) CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC: "1", }; // Set TMPDIR to isolatedHome/tmp so that sandboxed engines (e.g. claude-code // under macOS seatbelt strict mode) create temp files inside a writable path. // Without this, os.tmpdir() resolves to /private/tmp which is NOT in the // seatbelt writablePaths, causing EPERM on mkdir for Bash/Glob tools. const isolatedTmp = path.join(isolatedHome, "tmp"); fs.mkdirSync(isolatedTmp, { recursive: true }); env.TMPDIR = isolatedTmp; env.TEMP = isolatedTmp; env.TMP = isolatedTmp; const isQimingCodeEngine = config.engineType === "qimingcode"; // Set model/api vars from ACP config only (never from user's global env) if (config.apiKey) { env.ANTHROPIC_API_KEY = config.apiKey; env.ANTHROPIC_AUTH_TOKEN = config.apiKey; } if (config.baseUrl) env.ANTHROPIC_BASE_URL = config.baseUrl; // config.model 必须由上层设置(ensureEngineForRequest 从 model_provider 或 agent_config env 提取) if (config.model) { env.ANTHROPIC_MODEL = config.model; } else { log.warn( "[AcpClient] ⚠️ config.model not set, engine will use built-in default model (not recommended)", ); } if (config.env) Object.assign(env, config.env); // qimingcode runs on opencode and prefers OPENCODE_MODEL. // For openai-compatible models, OPENAI_* creds are required for provider autoload. if (isQimingCodeEngine) { if (config.model && !env.OPENCODE_MODEL) { env.OPENCODE_MODEL = config.model; } const effectiveModel = env.OPENCODE_MODEL || config.model || ""; const apiProtocol = (config.apiProtocol || "").toLowerCase(); const isOpenAICompatible = apiProtocol === "openai" || effectiveModel.startsWith("openai-compatible/"); if (isOpenAICompatible) { if (config.apiKey && !env.OPENAI_API_KEY) { env.OPENAI_API_KEY = config.apiKey; } if (config.baseUrl && !env.OPENAI_BASE_URL) { env.OPENAI_BASE_URL = config.baseUrl; } // Compatibility aliases used by some opencode paths. if (env.OPENAI_API_KEY && !env.OPENCODE_OPENAI_API_KEY) { env.OPENCODE_OPENAI_API_KEY = env.OPENAI_API_KEY; } if (env.OPENAI_BASE_URL && !env.OPENCODE_OPENAI_API_BASE) { env.OPENCODE_OPENAI_API_BASE = env.OPENAI_BASE_URL; } } } // Ensure qimingcode writes detailed logs to persistent app logs directory. // Without this, logs default to isolated XDG paths under /tmp and are removed on destroy. if (config.engineType === "qimingcode" && !env.OPENCODE_LOG_DIR) { const persistentLogDir = getQimingCodePersistentLogDir(); fs.mkdirSync(persistentLogDir, { recursive: true }); env.OPENCODE_LOG_DIR = persistentLogDir; } // Set CLAUDE_CODE_ACP_PATH for claude-code-acp-ts (matching Tauri's rcoder pattern) if (binPath.includes("claude-code-acp-ts")) { env.CLAUDE_CODE_ACP_PATH = binPath; } const envMs = setupTimer.end("acp.conn.env", { engine: config.engineType ?? "unknown", }); // 打印最终生效的模型配置(关键调试信息) log.info("[AcpClient] 🚀 Spawning ACP binary", { binPath, binArgs: effectiveBinArgs, cwd: config.workspaceDir, isolatedHome, ANTHROPIC_MODEL: env.ANTHROPIC_MODEL || "(not set)", ANTHROPIC_BASE_URL: env.ANTHROPIC_BASE_URL || "(not set)", ANTHROPIC_API_KEY: env.ANTHROPIC_API_KEY ? env.ANTHROPIC_API_KEY.slice( 0, Math.min(8, Math.floor(env.ANTHROPIC_API_KEY.length / 2)), ) + "..." : "(not set)", OPENCODE_MODEL: env.OPENCODE_MODEL || "(not set)", OPENCODE_LOG_DIR: env.OPENCODE_LOG_DIR || "(not set)", OPENAI_BASE_URL: env.OPENAI_BASE_URL || "(not set)", OPENAI_API_KEY: env.OPENAI_API_KEY ? env.OPENAI_API_KEY.slice( 0, Math.min(8, Math.floor(env.OPENAI_API_KEY.length / 2)), ) + "..." : "(not set)", OPENCODE_OPENAI_API_BASE: env.OPENCODE_OPENAI_API_BASE || "(not set)", OPENCODE_OPENAI_API_KEY: env.OPENCODE_OPENAI_API_KEY ? env.OPENCODE_OPENAI_API_KEY.slice( 0, Math.min(8, Math.floor(env.OPENCODE_OPENAI_API_KEY.length / 2)), ) + "..." : "(not set)", }); // 1. Spawn ACP binary // On Unix, use detached: true so the child gets its own process group, // enabling process.kill(-pid) to kill the entire tree on cleanup. const spawnTimer = perfEmitter.start(); const useDetached = !isWindows; let proc: ChildProcess; let sandboxCleanup: (() => void) | undefined; // --- Sandbox wrapping --- // 沙箱启用时,将引擎二进制包装在 sandbox-exec / bwrap / qiming sandbox helper 中 let spawnCommand = binPath; let spawnArgs = effectiveBinArgs; let sandboxed = false; if (config.sandbox?.enabled) { try { // 对于 JS 引擎(非 native),使用 node 二进制作为底层 command。 // 避免沙箱内运行 Electron 主程序导致 IOKit/PNG 操作崩溃(SIGSEGV)。 // 对于原生引擎(qimingcode Go 二进制),直接使用 binPath。 const effectiveCommand = config.isNative ? binPath : (getNodeBinPathWithFallback() ?? "node"); const effectiveSpawnArgs = config.isNative ? effectiveBinArgs : [binPath, ...effectiveBinArgs]; // Extra writable paths for seatbelt profile: // 1. isolatedHome — engine config/cache // 2. App data directory (~/.qimingclaw) — engine logs, npm packages, config // 3. System temp directories — engines may create temp files here for tool // execution. Cross-platform: os.tmpdir() + realpath, plus macOS-specific // /tmp and /private/tmp (some engines hardcode these instead of os.tmpdir()). const extraWritable: string[] = [isolatedHome, os.tmpdir()]; // App data directory (e.g. ~/.qimingclaw) — engine writes logs, npm packages, etc. const appDataDir = path.join(app.getPath("home"), APP_DATA_DIR_NAME); extraWritable.push(appDataDir); try { extraWritable.push(fs.realpathSync(os.tmpdir())); } catch { /* realpath resolution failed, skip */ } if (createPlatformAdapter().isMacOS) { extraWritable.push("/tmp", "/private/tmp"); } const wrapped = await buildSandboxedSpawnArgs( effectiveCommand, effectiveSpawnArgs, config.workspaceDir, config.sandbox, extraWritable, ); spawnCommand = wrapped.command; spawnArgs = wrapped.args; sandboxCleanup = wrapped.cleanupSandbox; sandboxed = spawnCommand !== effectiveCommand; log.info("[AcpClient] Sandbox wrapping applied:", { type: config.sandbox.type, originalCommand: binPath, wrappedCommand: spawnCommand, sandboxed, }); } catch (sandboxError) { log.error("[AcpClient] Sandbox wrapping failed:", sandboxError); // fallback 当前由 acpEngine.ts 固定为 "degrade_to_off", // "fail_closed" 分支目前不可达,保留以备将来恢复配置项。 if (config.sandbox.fallback === "fail_closed") { throw new Error( `Sandbox setup failed: ${sandboxError instanceof Error ? sandboxError.message : String(sandboxError)}`, ); } // degrade_to_off: 继续无沙箱启动 log.warn("[AcpClient] Degrading to non-sandboxed execution"); } } if (sandboxed) { // 沙箱已包装:直接 spawn(沙箱 wrapper 本身就是 command) proc = spawn(spawnCommand, spawnArgs, { cwd: config.workspaceDir, env, stdio: ["pipe", "pipe", "pipe"], windowsHide: true, detached: useDetached, }); log.info( `[AcpClient] Spawned sandboxed process: ${spawnCommand} (detached=${useDetached})`, ); } else if (config.isNative) { // Native binary (e.g. qimingcode Go binary): spawn directly, no node wrapper // This avoids Windows console popup and eliminates the intermediate process proc = spawn(binPath, effectiveBinArgs, { cwd: config.workspaceDir, env, stdio: ["pipe", "pipe", "pipe"], windowsHide: true, detached: useDetached, }); log.info( `[AcpClient] Spawned native binary directly: ${binPath} (detached=${useDetached})`, ); } else { // JS file (e.g. claude-code-acp-ts): spawn via node using spawnJsFile proc = spawnJsFile(binPath, effectiveBinArgs, { cwd: config.workspaceDir, env, stdio: ["pipe", "pipe", "pipe"], detached: useDetached, }); } // Unref so the parent process can exit without waiting for the child // (we manage cleanup explicitly via killProcessTree) if (useDetached) { proc.unref(); } const spawnMs = spawnTimer.end("acp.conn.spawn", { engine: config.engineType ?? "unknown", native: !!config.isNative, }); // Register process in the process registry for orphan detection if (proc.pid) { processRegistry.register(proc.pid, { engineId: runId, engineType: config.engineType ?? "claude-code", purpose: config.purpose ?? "engine", }); } ensureTelemetry(proc); // Log stderr — 详细输出所有内容 proc.stderr?.on("data", (data: Buffer) => { const text = data.toString().trim(); if (!text) return; const lower = text.toLowerCase(); if ( lower.includes("error") || lower.includes("failed") || lower.includes("enoent") || lower.includes("spawn") || lower.includes("mcp server") || lower.includes("certificate") || lower.includes("models.dev") || lower.includes("providers") || lower.includes("rate limit") || lower.includes("使用上限") ) { log.error("[AcpClient stderr] 🔴", text); } else { log.warn("[AcpClient stderr]", text); } for (const line of text.split("\n")) { const trimmed = line.trim(); if (!trimmed) continue; updateMcpTransportTelemetry(proc, trimmed); } if (firstTokenTrace.isDeepMode()) { for (const line of text.split("\n")) { const trimmed = line.trim(); if (!trimmed) continue; const preview = trimmed.length > 1000 ? trimmed.substring(0, 1000) + "..." : trimmed; firstTokenTrace.trace( "acp.stderr.line", { engine: config.engineType, sessionId: extractSessionIdFromLine(trimmed), }, { pid: proc.pid, line: preview }, ); } } }); proc.on("error", (error) => { log.error("[AcpClient] Process error:", error); }); proc.on("exit", (code, signal) => { log.info("[AcpClient] Process exited", { code, signal }); }); /** * 使用 Transform 流对 stdout 做“仅打日志并透传”,避免与 SDK 争抢同一 Readable。 * 若直接对 proc.stdout 同时 on('data') 和 Readable.toWeb(proc.stdout), * Node 会形成两个消费者竞争,session/update 通知可能被 data 监听器消费, * SDK 收不到,导致没有 agent_message_chunk / agent_thought_chunk,前端无消息返回。 * 0.8.1 能正常返回正是因为只有 SDK 一个消费者;新版曾加的 stdout 调试监听器导致回退。 */ const stdoutLogTransform = new Transform({ transform(chunk: Buffer, _encoding, callback) { const text = chunk.toString(); for (const line of text.split("\n")) { const trimmed = line.trim(); if (!trimmed) continue; const preview = trimmed.length > 500 ? trimmed.substring(0, 500) + "..." : trimmed; log.info("[AcpClient stdout] 📥", preview); if (firstTokenTrace.isDeepMode()) { firstTokenTrace.trace( "acp.stdout.line", { engine: config.engineType, sessionId: extractSessionIdFromLine(trimmed), }, { pid: proc.pid, line: preview }, ); } } this.push(chunk); callback(); }, }); proc.stdout!.pipe(stdoutLogTransform); // Debug: log raw stdin NDJSON lines sent to ACP process (stdin 只写不读,无多消费者问题) const originalStdinWrite = proc.stdin!.write.bind(proc.stdin!); proc.stdin!.write = function (chunk: any, ...args: any[]) { const text = typeof chunk === "string" ? chunk : Buffer.from(chunk).toString(); for (const line of text.split("\n")) { const trimmed = line.trim(); if (!trimmed) continue; const preview = trimmed.length > 500 ? trimmed.substring(0, 500) + "..." : trimmed; log.info("[AcpClient stdin] 📤", preview); if (firstTokenTrace.isDeepMode()) { firstTokenTrace.trace( "acp.stdin.line", { engine: config.engineType, sessionId: extractSessionIdFromLine(trimmed), }, { pid: proc.pid, line: preview }, ); } } return originalStdinWrite(chunk, ...args); } as any; // 2. Convert Node streams → Web streams(仅从 stdoutLogTransform 读,保证唯一消费者) // Wrap post-spawn setup in try/catch: if anything fails after spawn, // we must kill the process and unregister it to prevent orphans. const bridgeTimer = perfEmitter.start(); let readable: ReadableStream; let writable: WritableStream; let acp: any; let stream: any; let connection: AcpClientSideConnection; try { const ioBridgeTimer = perfEmitter.start(); readable = Readable.toWeb(stdoutLogTransform) as ReadableStream; writable = Writable.toWeb(proc.stdin!) as WritableStream; ioBridgeTimer.end("acp.conn.ioBridge", { engine: config.engineType ?? "unknown", }); // 3. Load ACP SDK and create NDJSON stream const sdkTimer = perfEmitter.start(); acp = await loadAcpSdk(); sdkTimer.end("acp.conn.sdkLoad", { engine: config.engineType ?? "unknown", }); const streamTimer = perfEmitter.start(); stream = acp.ndJsonStream(writable, readable); streamTimer.end("acp.conn.ndjson", { engine: config.engineType ?? "unknown", }); // 4. Create ClientSideConnection with client handler const connTimer = perfEmitter.start(); connection = new acp.ClientSideConnection( (_agent: unknown) => clientHandler, stream, ) as AcpClientSideConnection; connTimer.end("acp.conn.connection", { engine: config.engineType ?? "unknown", }); } catch (e) { // Post-spawn setup failed — kill the spawned process to prevent orphan log.error("[AcpClient] Post-spawn setup failed, killing process:", e); if (proc.pid) { processRegistry.unregister(proc.pid); await killProcessTreeGraceful(proc.pid, 3000).catch(() => {}); } else { proc.kill(); } throw e; } const bridgeMs = bridgeTimer.end("acp.conn.bridgeTotal", { engine: config.engineType ?? "unknown", }); perfEmitter.duration("acp.conn.create.total", envMs + spawnMs + bridgeMs, { engine: config.engineType ?? "unknown", }); // 🔧 FIX: Create cleanup function to properly dispose of event listeners // This prevents handle leaks by removing all event listeners before process termination const cleanup = () => { try { // Remove stdout listener (prevents handle leak) proc.stdout?.removeAllListeners(); // Remove stderr listener (prevents handle leak) proc.stderr?.removeAllListeners(); // Remove stdin listener (also restores the wrapped write function) proc.stdin?.removeAllListeners(); // Remove process-level listeners (error, exit) proc.removeAllListeners(); mcpTransportTelemetry.delete(proc); log.info( "[AcpClient] 🧹 Cleaned up event listeners to prevent handle leaks", ); } catch (e) { log.warn("[AcpClient] Cleanup error:", e); } }; return { connection, process: proc, isolatedHome, cleanup, sandboxCleanup, }; }