Files
qiming/qimingclaw/crates/agent-electron-client/src/main/services/engines/acp/acpClient.ts

1090 lines
35 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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<Uint8Array>,
) => unknown;
ClientSideConnection: new (
clientFactory: (agent: unknown) => unknown,
stream: unknown,
) => AcpClientSideConnection;
PROTOCOL_VERSION: number;
}
/** ClientSideConnection interface */
export interface AcpClientSideConnection {
initialize(params: {
protocolVersion: number;
clientCapabilities?: Record<string, unknown>;
}): Promise<{
protocolVersion: number;
agentCapabilities?: Record<string, unknown>;
}>;
newSession(params: {
cwd: string;
mcpServers: Array<AcpMcpServer>;
_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<void>;
closed: Promise<void>;
}
/** ACP Client handler interface (callbacks from agent → client) */
export interface AcpClientHandler {
sessionUpdate?(params: {
sessionId: string;
update: AcpSessionUpdate;
}): Promise<void>;
requestPermission?(
params: AcpPermissionRequest,
): Promise<AcpPermissionResponse>;
readTextFile?(params: {
sessionId: string;
uri: string;
}): Promise<{ content: string }>;
writeTextFile?(params: {
sessionId: string;
uri: string;
content: string;
}): Promise<Record<string, never>>;
// --- 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<Record<string, never>>;
releaseTerminal?(params: {
sessionId: string;
terminalId: string;
}): Promise<Record<string, never>>;
}
/** 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<string, string>;
/** 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<AcpSdkModule> | 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<AcpSdkModule> {
if (!_acpSdkPromise) {
const dynamicImport = new Function(
"specifier",
"return import(specifier)",
) as (specifier: string) => Promise<AcpSdkModule>;
_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<AcpConnectionResult> {
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<string, string> = {
...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<Uint8Array>;
let writable: WritableStream;
let acp: any;
let stream: any;
let connection: AcpClientSideConnection;
try {
const ioBridgeTimer = perfEmitter.start();
readable = Readable.toWeb(stdoutLogTransform) as ReadableStream<Uint8Array>;
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,
};
}