1090 lines
35 KiB
TypeScript
1090 lines
35 KiB
TypeScript
/**
|
||
* 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,
|
||
};
|
||
}
|