chore: initialize qiming workspace repository

This commit is contained in:
Codex
2026-05-29 14:22:48 +08:00
commit bfd67a0f2c
10750 changed files with 1885711 additions and 0 deletions

View File

@@ -0,0 +1,67 @@
import http from "http";
import { LOCALHOST_HOSTNAME } from "../constants";
export interface FileServerHealthResult {
healthy: boolean;
error?: string;
}
/**
* 通过 GET /health 端点检查 file-server 健康状态
* 非阻塞5 秒超时
*/
export async function checkFileServerHealth(
port: number,
): Promise<FileServerHealthResult> {
return new Promise((resolve) => {
const req = http.request(
{
hostname: LOCALHOST_HOSTNAME,
port,
path: "/health",
method: "GET",
timeout: 5000,
},
(res) => {
let body = "";
res.on("data", (chunk) => (body += chunk));
res.on("end", () => {
if (res.statusCode === 200) {
try {
const data = JSON.parse(body);
if (data.status === "ok") {
resolve({ healthy: true });
} else {
resolve({
healthy: false,
error: `Unexpected status: ${data.status}`,
});
}
} catch (e) {
resolve({
healthy: false,
error: `Invalid JSON response: ${e}`,
});
}
} else {
resolve({
healthy: false,
error: `HTTP ${res.statusCode}`,
});
}
});
},
);
req.on("error", (err) => {
resolve({ healthy: false, error: err.message });
});
req.on("timeout", () => {
req.destroy();
resolve({ healthy: false, error: "Health check timeout (5s)" });
});
req.end();
});
}

View File

@@ -0,0 +1,353 @@
/**
* GUI Agent Server 管理器Electron 集成)
*
* 提供非 Windows 平台下 agent-gui-server (Node.js MCP server) 的启动、停止和状态管理。
* agent-gui-server 提供 GUI 桌面自动化能力(截图、鼠标、键盘等 MCP tools
*
* 端口通过 settings 中的 guiMcpPort 配置(默认 60008与 agent 会话注入的 MCP URL 对齐。
* API Key 通过 GUI_AGENT_API_KEY 环境变量传入(复用 anthropic_api_key
*/
import * as fs from "fs";
import * as path from "path";
import log from "electron-log";
import { t } from "../i18n";
import { ManagedProcess } from "../../processManager";
import {
getResourcesPath,
getNodeBinPathWithFallback,
getAppEnv,
} from "../system/dependencies";
import { isWindows } from "../system/shellEnv";
import { getDb, readSetting } from "../../db";
import { DEFAULT_GUI_MCP_PORT } from "@shared/constants";
import { killProcessTreesListeningOnTcpPort } from "../utils/processTree";
/** GUI Agent Server 进程名称 */
const PROCESS_NAME = "gui-agent-server";
/**
* 从 DB 读取 GUI MCP 端口配置
*/
export function getGuiMcpPort(): number {
try {
const config = readSetting("step1_config") as {
guiMcpPort?: number;
} | null;
return config?.guiMcpPort ?? DEFAULT_GUI_MCP_PORT;
} catch {
return DEFAULT_GUI_MCP_PORT;
}
}
/**
* 获取 agent-gui-server 的入口 JS 文件路径
*
* 打包后: process.resourcesPath/agent-gui-server/dist/index.js
* 开发时: {cwd}/resources/agent-gui-server/dist/index.js
*/
function getGuiAgentServerEntryPath(): string | null {
const resourcesPath = getResourcesPath();
const entryPath = path.join(
resourcesPath,
"agent-gui-server",
"dist",
"index.js",
);
if (fs.existsSync(entryPath)) {
return entryPath;
}
log.warn(`[GuiAgentServer] Entry file not found: ${entryPath}`);
return null;
}
/**
* 从 SQLite 获取 API Key优先 gui_agent_vision_model 中的 apiKey
*/
function getApiKeyFromDb(): string | null {
try {
const db = getDb();
if (!db) return null;
// 优先读取 gui_agent_vision_model 配置中的 apiKey
const visionConfigRow = db
.prepare("SELECT value FROM settings WHERE key = ?")
.get("gui_agent_vision_model") as { value: string } | undefined;
if (visionConfigRow) {
const config = JSON.parse(visionConfigRow.value);
if (config?.apiKey) return config.apiKey;
}
// 回退到全局 anthropic_api_key
const apiKeyRow = db
.prepare("SELECT value FROM settings WHERE key = ?")
.get("anthropic_api_key") as { value: string } | undefined;
return apiKeyRow?.value ?? null;
} catch (e) {
log.warn("[GuiAgentServer] Failed to read API Key:", e);
return null;
}
}
/**
* 检查视觉模型是否已配置
*/
function checkVisionModelConfigured(): boolean {
try {
const db = getDb();
if (!db) return false;
const visionConfigRow = db
.prepare("SELECT value FROM settings WHERE key = ?")
.get("gui_agent_vision_model") as { value: string } | undefined;
if (visionConfigRow) {
const config = JSON.parse(visionConfigRow.value);
if (config?.apiKey && config?.model) return true;
}
return false;
} catch (e) {
return false;
}
}
/**
* 获取视觉模型名称
*/
function getVisionModelName(): string | null {
try {
const db = getDb();
if (!db) return null;
const visionConfigRow = db
.prepare("SELECT value FROM settings WHERE key = ?")
.get("gui_agent_vision_model") as { value: string } | undefined;
if (visionConfigRow) {
const config = JSON.parse(visionConfigRow.value);
if (config?.model) return config.model;
}
return null;
} catch (e) {
return null;
}
}
// ========== 状态管理 ==========
let processInstance: ManagedProcess | null = null;
let running = false;
let runningPort: number | null = null;
let lastError: string | null = null;
/**
* 启动 GUI Agent Server
*
* 仅在非 Windows 平台有效。Windows 平台使用 windows-mcp见 windowsMcp.ts
*/
export async function startGuiAgentServer(): Promise<{
success: boolean;
port?: number;
error?: string;
}> {
// Windows 平台跳过(使用 windows-mcp
if (isWindows()) {
log.info("[GuiAgentServer] Skipped: Windows platform uses windows-mcp");
return { success: true };
}
// 已运行则直接返回
if (running && processInstance && runningPort !== null) {
return { success: true, port: runningPort };
}
// 读取配置的端口
const port = getGuiMcpPort();
// 启动前清理占用该端口的残留进程(跨平台实现)
try {
log.info(`[GuiAgentServer] Pre-start port sweep for ${port}...`);
await killProcessTreesListeningOnTcpPort(port);
await new Promise((r) => setTimeout(r, 450));
} catch (e) {
log.warn("[GuiAgentServer] Pre-start port sweep:", e);
}
// 解析入口文件
const entryPath = getGuiAgentServerEntryPath();
if (!entryPath) {
const error = t("Claw.GUIAgent.entryNotFound");
log.error("[GuiAgentServer] GUI Agent entry file not found");
return { success: false, error };
}
// 检查 Node.js 可用性
const nodeBinPath = getNodeBinPathWithFallback();
if (!nodeBinPath) {
const error = t("Claw.GUIAgent.nodeNotFound");
log.error("[GuiAgentServer] Node.js binary not found");
return { success: false, error };
}
// 获取 API Key
const apiKey = getApiKeyFromDb();
if (!apiKey) {
log.warn("[GuiAgentServer] API key missing (GUI Agent may be limited)");
}
// 检查视觉模型是否配置
const hasVisionModel = checkVisionModelConfigured();
const visionModelName = getVisionModelName();
// 构建环境变量
const env: Record<string, string> = {
...getAppEnv(),
GUI_AGENT_API_KEY: apiKey ?? "",
// 明确指定传输协议和端口
GUI_AGENT_TRANSPORT: "http",
GUI_AGENT_PORT: port.toString(),
// 传递视觉模型名称,无配置则为空(用于在 MCP 工具列表中禁用 gui_analyze_screen
GUI_AGENT_VISION_MODEL: hasVisionModel ? (visionModelName ?? "") : "",
// 禁用遥测
ANONYMIZED_TELEMETRY: "false",
};
// 构建启动参数
const args = [entryPath, "--port", port.toString(), "--transport", "http"];
// 创建并启动进程
processInstance = new ManagedProcess(PROCESS_NAME);
log.info(`[GuiAgentServer] Starting on port ${port}...`);
log.info(`[GuiAgentServer] Entry: ${entryPath}`);
try {
const result = await processInstance.start({
command: nodeBinPath,
args,
env,
startupDelayMs: 5000, // GUI Agent Server 需要较长的启动时间
onStdoutLine: (line) => {
// 转发到 electron-log
if (line.trim()) {
log.info(`[GuiAgentServer] ${line}`);
}
},
});
if (!result.success) {
lastError = result.error ?? "Failed to start";
running = false;
runningPort = null;
processInstance = null;
log.error(`[GuiAgentServer] Failed to start: ${lastError}`);
return { success: false, error: lastError };
}
running = true;
runningPort = port;
lastError = null;
log.info(`[GuiAgentServer] Started successfully on port ${port}`);
return { success: true, port };
} catch (e) {
const errorMsg = e instanceof Error ? e.message : String(e);
lastError = errorMsg;
running = false;
runningPort = null;
processInstance = null;
log.error(`[GuiAgentServer] Start exception: ${errorMsg}`);
return { success: false, error: errorMsg };
}
}
/**
* 停止 GUI Agent Server
*/
export async function stopGuiAgentServer(): Promise<{
success: boolean;
error?: string;
}> {
if (!running || !processInstance) {
running = false;
runningPort = null;
return { success: true };
}
log.info("[GuiAgentServer] Stopping...");
// 兜底ManagedProcess 进程可能已退出但端口仍被占用(如子进程残留)
try {
await killProcessTreesListeningOnTcpPort(getGuiMcpPort());
} catch (e) {
log.warn("[GuiAgentServer] TCP port sweep after stop:", e);
}
try {
const result = await processInstance.stopAsync();
running = false;
runningPort = null;
processInstance = null;
if (result.success) {
log.info("[GuiAgentServer] Stopped successfully");
return { success: true };
} else {
log.error(`[GuiAgentServer] Stop failed: ${result.message}`);
return { success: false, error: result.message };
}
} catch (e) {
const errorMsg = e instanceof Error ? e.message : String(e);
running = false;
runningPort = null;
processInstance = null;
log.error(`[GuiAgentServer] Stop exception: ${errorMsg}`);
return { success: false, error: errorMsg };
}
}
/**
* 获取 GUI Agent Server 运行状态
*/
export function getGuiAgentServerStatus(): {
running: boolean;
port?: number;
error?: string | null;
} {
return {
running,
port: running ? (runningPort ?? undefined) : undefined,
error: lastError,
};
}
/**
* 获取 GUI Agent Server MCP URL供 Agent 引擎注入使用)
*
* 返回 http://127.0.0.1:{runningPort}/mcp使用实际运行的端口。
* 仅在非 Windows 平台且内嵌 gui 服务已启动时返回非 null。
* Windows 桌面自动化由 windows-mcp 提供,请使用 getWindowsMcpUrl()(见 acpEngine
*/
export function getGuiAgentServerUrl(): string | null {
if (isWindows()) {
return null;
}
if (!running || runningPort === null) {
return null;
}
return `http://127.0.0.1:${runningPort}/mcp`;
}
/**
* 检查 GUI Agent Server 是否可用(入口文件存在且非 Windows
*/
export function isGuiAgentServerAvailable(): boolean {
if (isWindows()) {
return false;
}
const entryPath = getGuiAgentServerEntryPath();
return entryPath !== null;
}

View File

@@ -0,0 +1,10 @@
// Main process package management services
export {
mcpProxyManager,
DEFAULT_MCP_PROXY_CONFIG,
type McpServersConfig,
} from './mcp';
export { getAppPaths, isInstalledLocally } from './packageLocator';
// Re-export ports from constants
export { DEFAULT_MCP_PROXY_PORT, DEFAULT_MCP_PROXY_HOST } from '../constants';

View File

@@ -0,0 +1,84 @@
import { readSetting } from "../../db";
import { t } from "../i18n";
import type { HttpResult } from "@shared/types/computerTypes";
import log from "electron-log";
function isHttpResult(value: unknown): value is HttpResult<unknown> {
if (!value || typeof value !== "object" || Array.isArray(value)) return false;
const body = value as Record<string, unknown>;
return (
typeof body.code === "string" &&
typeof body.message === "string" &&
"data" in body
);
}
/**
* 检查 Lanproxy 通道健康状态
*/
export async function checkLanproxyHealth(savedKey: string): Promise<{
healthy: boolean;
error?: string;
}> {
const serverHost = readSetting("lanproxy.server_host") as string | null;
if (!serverHost) {
return { healthy: false, error: t("Claw.Lanproxy.missingServerConfig") };
}
try {
// serverHost 可能是纯域名(如 testagent.xspaceagi.com或带协议如 https://testagent.xspaceagi.com
const protocol = serverHost.startsWith("https") ? "https" : "http";
const normalizedHost = serverHost.replace(/^https?:\/\//, "");
const url = `${protocol}://${normalizedHost}/api/sandbox/config/health/${savedKey}`;
const response = await fetch(url, {
method: "GET",
signal: AbortSignal.timeout(10000),
});
if (!response.ok) {
return {
healthy: false,
error: `HTTP ${response.status}`,
};
}
let data: unknown;
try {
data = await response.json();
log.info("[LanproxyHealth] Health API response", {
status: response.status,
data,
});
} catch {
return {
healthy: false,
error: "Invalid JSON in health response",
};
}
if (!isHttpResult(data)) {
return {
healthy: false,
error: "Unexpected health response",
};
}
const body = data;
if (body.code === "0000") {
return { healthy: true };
}
const apiMessage =
typeof body.message === "string" && body.message.trim()
? body.message
: undefined;
const codeWithBracket = `[${body.code}]`;
return {
healthy: false,
error: apiMessage
? `${codeWithBracket} ${apiMessage}`
: `${codeWithBracket} Health check failed`,
};
} catch (e) {
return {
healthy: false,
error: e instanceof Error ? e.message : String(e),
};
}
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,68 @@
/**
* MCP Helper Utilities
*
* Pure functions for MCP server configuration comparison and filtering.
* No Electron or side-effectful dependencies — safe to import anywhere,
* including test files without additional mocks.
*/
import * as path from 'path';
import type { McpServerEntry } from './mcp';
/**
* 从 MCP server 配置中过滤掉 bridge 聚合入口
* command 为 "mcp-proxy" 或其绝对路径形式)。
*
* 用于 detectConfigChange 和 engineRawMcpServers 的原始格式快照存储。
*/
export function filterBridgeEntries(
servers: Record<string, McpServerEntry>,
): Record<string, McpServerEntry> {
const result: Record<string, McpServerEntry> = {};
for (const [name, entry] of Object.entries(servers)) {
if ('command' in entry && (entry.command === 'mcp-proxy' || path.basename(entry.command) === 'mcp-proxy')) continue;
result[name] = entry;
}
return result;
}
/**
* 比较两组原始 MCP server 配置是否语义等价。
*
* 只比较决定进程行为的字段command / args / allowTools / denyTools / url远程 server
* 故意忽略 envenv 由 getAppEnv() 每次注入,内容固定,不触发引擎重建。
*
* @param a 本次请求传入的原始 MCP servers已过滤掉 mcp-proxy 入口)
* @param b 上次存储的原始 MCP serversundefined 表示首次(无历史快照)
*/
export function rawMcpServersEqual(
a: Record<string, McpServerEntry>,
b: Record<string, McpServerEntry> | undefined,
): boolean {
if (!b) return false;
const aKeys = Object.keys(a).sort();
const bKeys = Object.keys(b).sort();
if (aKeys.join('\0') !== bKeys.join('\0')) return false;
for (const key of aKeys) {
const ea = a[key];
const eb = b[key];
// 远程 server只比较 URL
if ('url' in ea || 'url' in eb) {
if (('url' in ea ? ea.url : undefined) !== ('url' in eb ? eb.url : undefined)) return false;
continue;
}
// stdio server比较 command / args / allowTools / denyTools
// 同名 key 类型不一致(一方有 command另一方没有→ 视为不等,触发重建
if (!('command' in ea) || !('command' in eb)) return false;
if (ea.command !== eb.command) return false;
if (JSON.stringify(ea.args ?? []) !== JSON.stringify(eb.args ?? [])) return false;
// 对数组型字段排序后比较,避免顺序差异误判
const sortedAllowA = [...(ea.allowTools ?? [])].sort().join('\0');
const sortedAllowB = [...(eb.allowTools ?? [])].sort().join('\0');
if (sortedAllowA !== sortedAllowB) return false;
const sortedDenyA = [...(ea.denyTools ?? [])].sort().join('\0');
const sortedDenyB = [...(eb.denyTools ?? [])].sort().join('\0');
if (sortedDenyA !== sortedDenyB) return false;
}
return true;
}

View File

@@ -0,0 +1,482 @@
import * as path from 'path';
import * as fs from 'fs';
import * as os from 'os';
import { spawn } from 'child_process';
import { getAppEnv, getResourcesPath } from '../system/dependencies';
import { APP_DATA_DIR_NAME } from '../constants';
import { isWindows } from '../system/shellEnv';
// ==================== App Paths ====================
export interface AppPaths {
appData: string;
nodeModules: string;
mcpModules: string;
temp: string;
logs: string;
workspaces: string;
}
let appPaths: AppPaths | null = null;
export function getAppPaths(basePath?: string): AppPaths {
if (appPaths) return appPaths;
const base = basePath || os.homedir();
appPaths = {
appData: path.join(base, APP_DATA_DIR_NAME),
nodeModules: path.join(base, APP_DATA_DIR_NAME, 'node_modules'),
mcpModules: path.join(base, APP_DATA_DIR_NAME, 'node_modules', 'mcp-servers'),
temp: path.join(base, APP_DATA_DIR_NAME, 'temp'),
logs: path.join(base, APP_DATA_DIR_NAME, 'logs'),
workspaces: path.join(base, APP_DATA_DIR_NAME, 'workspaces'),
};
// Ensure directories exist
for (const [, dir] of Object.entries(appPaths)) {
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true });
}
}
return appPaths;
}
// ==================== Package Detection ====================
export interface PackageInfo {
name: string;
version?: string;
path: string;
isLocal: boolean;
isGlobal: boolean;
}
/**
* Get executable path - check local first, then global
* This ensures we use the app's own installation
*/
export function getExecutablePath(packageName: string): string | null {
const dirs = getAppPaths();
// 1. Check local MCP modules
const localBin = path.join(dirs.mcpModules, '.bin', packageName);
if (isWindows()) {
if (fs.existsSync(localBin + '.cmd')) return localBin + '.cmd';
if (fs.existsSync(localBin + '.exe')) return localBin + '.exe';
}
if (fs.existsSync(localBin)) return localBin;
// 2. Check local node_modules
const localNodeBin = path.join(dirs.nodeModules, '.bin', packageName);
if (isWindows()) {
if (fs.existsSync(localNodeBin + '.cmd')) return localNodeBin + '.cmd';
if (fs.existsSync(localNodeBin + '.exe')) return localNodeBin + '.exe';
}
if (fs.existsSync(localNodeBin)) return localNodeBin;
// 3. Return null - package not found locally
return null;
}
/**
* Get package JS entry file path from package.json bin field.
*
* This resolves the actual JS entry file instead of using .cmd wrappers,
* which avoids CMD window popup on Windows.
*
* @param packageName - The npm package name
* @param binName - Optional bin name (defaults to packageName)
* @returns The absolute path to the JS entry file, or null if not found
*/
export function getPackageJsEntryPath(packageName: string, binName?: string): string | null {
const dirs = getAppPaths();
// Check in main node_modules
const packageDir = path.join(dirs.nodeModules, packageName);
if (fs.existsSync(packageDir)) {
const entry = resolveBinEntry(packageDir, binName || packageName);
if (entry) return entry;
}
// Check in mcp-servers
const mcpPackageDir = path.join(dirs.mcpModules, packageName);
if (fs.existsSync(mcpPackageDir)) {
const entry = resolveBinEntry(mcpPackageDir, binName || packageName);
if (entry) return entry;
}
return null;
}
/**
* Resolve bin entry from package.json
*/
function resolveBinEntry(packageDir: string, binName: string): string | null {
const pkgJsonPath = path.join(packageDir, 'package.json');
if (!fs.existsSync(pkgJsonPath)) {
return null;
}
try {
const pkg = JSON.parse(fs.readFileSync(pkgJsonPath, 'utf-8'));
// Get bin path from package.json
let binPath: string | undefined;
if (typeof pkg.bin === 'string') {
binPath = pkg.bin;
} else if (pkg.bin && typeof pkg.bin === 'object') {
// bin: { "command-name": "./path/to/file.js" }
const binValue = pkg.bin[binName] ?? Object.values(pkg.bin)[0];
if (typeof binValue === 'string') {
binPath = binValue;
}
}
if (binPath) {
const entryPath = path.join(packageDir, binPath);
if (fs.existsSync(entryPath)) {
return entryPath;
}
}
// Fallback: try common entry file locations
const fallbacks = [
path.join(packageDir, 'index.js'),
path.join(packageDir, 'cli.js'),
path.join(packageDir, 'dist', 'index.js'),
path.join(packageDir, 'bin', binName),
path.join(packageDir, 'bin', `${binName}.js`),
];
for (const p of fallbacks) {
if (fs.existsSync(p)) return p;
}
} catch (e) {
// Failed to parse package.json - this is expected for malformed packages
// Silent fail as we have fallback mechanisms
}
return null;
}
/**
* Check if a package is installed locally (in app directory)
*/
export function isInstalledLocally(packageName: string): boolean {
const dirs = getAppPaths();
// Check in mcp-servers
const mcpPath = path.join(dirs.mcpModules, packageName);
if (fs.existsSync(mcpPath)) return true;
// Check in main node_modules
const nodePath = path.join(dirs.nodeModules, packageName);
if (fs.existsSync(nodePath)) return true;
return false;
}
/**
* Check if a package is installed globally (system-wide)
*/
export async function isInstalledGlobally(packageName: string): Promise<boolean> {
return new Promise((resolve) => {
const npmCmd = isWindows() ? 'npm.cmd' : 'npm';
const args = ['list', '-g', '--depth=0', packageName];
const proc = spawn(npmCmd, args, {
stdio: ['ignore', 'pipe', 'ignore'],
shell: isWindows(),
});
let stdout = '';
proc.stdout?.on('data', (data) => {
stdout += data.toString();
});
proc.on('close', (code) => {
resolve(code === 0 && stdout.includes(packageName));
});
proc.on('error', () => {
resolve(false);
});
});
}
/**
* Get detailed package info - local vs global
*/
export async function getPackageInfo(packageName: string): Promise<PackageInfo> {
const dirs = getAppPaths();
// Check local first
const localPath = path.join(dirs.mcpModules, packageName);
const localNodePath = path.join(dirs.nodeModules, packageName);
let localVersion: string | undefined;
if (fs.existsSync(localPath)) {
try {
const pkg = JSON.parse(fs.readFileSync(path.join(localPath, 'package.json'), 'utf-8'));
localVersion = pkg.version;
} catch {}
} else if (fs.existsSync(localNodePath)) {
try {
const pkg = JSON.parse(fs.readFileSync(path.join(localNodePath, 'package.json'), 'utf-8'));
localVersion = pkg.version;
} catch {}
}
const isLocal = fs.existsSync(localPath) || fs.existsSync(localNodePath);
const isGlobal = await isInstalledGlobally(packageName);
return {
name: packageName,
version: localVersion,
path: isLocal ? (fs.existsSync(localPath) ? localPath : localNodePath) : '',
isLocal,
isGlobal,
};
}
/**
* Get version of locally installed package
*/
export function getLocalVersion(packageName: string): string | null {
const dirs = getAppPaths();
// Check mcp-modules first
const mcpPath = path.join(dirs.mcpModules, packageName, 'package.json');
if (fs.existsSync(mcpPath)) {
try {
const pkg = JSON.parse(fs.readFileSync(mcpPath, 'utf-8'));
return pkg.version || null;
} catch {}
}
// Check node_modules
const nodePath = path.join(dirs.nodeModules, packageName, 'package.json');
if (fs.existsSync(nodePath)) {
try {
const pkg = JSON.parse(fs.readFileSync(nodePath, 'utf-8'));
return pkg.version || null;
} catch {}
}
return null;
}
/**
* Get version of globally installed package
*/
export async function getGlobalVersion(packageName: string): Promise<string | null> {
return new Promise((resolve) => {
const npmCmd = isWindows() ? 'npm.cmd' : 'npm';
const args = ['view', packageName, 'version', '--json'];
const proc = spawn(npmCmd, args, {
stdio: ['ignore', 'pipe', 'ignore'],
shell: isWindows(),
});
let stdout = '';
proc.stdout?.on('data', (data) => {
stdout += data.toString();
});
proc.on('close', () => {
try {
const version = JSON.parse(stdout.trim());
resolve(version || null);
} catch {
resolve(null);
}
});
proc.on('error', () => {
resolve(null);
});
});
}
/**
* Compare versions and return update info
*/
export async function checkForUpdate(packageName: string): Promise<{
hasUpdate: boolean;
localVersion: string | null;
latestVersion: string | null;
}> {
const localVersion = getLocalVersion(packageName);
const latestVersion = await getGlobalVersion(packageName);
if (!localVersion || !latestVersion) {
return { hasUpdate: false, localVersion, latestVersion };
}
// Simple version compare (would use semver in production)
const hasUpdate = localVersion !== latestVersion;
return { hasUpdate, localVersion, latestVersion };
}
/**
* Get detailed version info for all MCP packages
*/
export async function getAllMCPVersions(): Promise<Record<string, {
local: string | null;
global: string | null;
latest: string | null;
hasUpdate: boolean;
}>> {
const mcpPackages = [
'@modelcontextprotocol/server-filesystem',
'@modelcontextprotocol/server-brave-search',
'@modelcontextprotocol/server-github',
'@modelcontextprotocol/server-sqlite',
'@modelcontextprotocol/server-puppeteer',
'@modelcontextprotocol/server-fetch',
];
const results: Record<string, any> = {};
for (const pkg of mcpPackages) {
const localVersion = getLocalVersion(pkg);
const latestVersion = await getGlobalVersion(pkg);
results[pkg] = {
local: localVersion,
global: null, // Would need separate check
latest: latestVersion,
hasUpdate: localVersion !== latestVersion && latestVersion !== null,
};
}
return results;
}
/**
* Get list of locally installed packages
*/
export function getLocalPackages(): string[] {
const dirs = getAppPaths();
const packages: string[] = [];
// Check mcp-modules
if (fs.existsSync(dirs.mcpModules)) {
const entries = fs.readdirSync(dirs.mcpModules);
for (const entry of entries) {
if (!entry.startsWith('.')) {
const stat = fs.statSync(path.join(dirs.mcpModules, entry));
if (stat.isDirectory()) {
packages.push(entry);
}
}
}
}
return packages;
}
/**
* Spawn a command using LOCAL executable only
* Throws error if package not found locally
*/
export function spawnLocal(
packageName: string,
args: string[],
options?: {
cwd?: string;
env?: Record<string, string>;
registry?: string;
}
): ReturnType<typeof spawn> {
const dirs = getAppPaths();
// Find local executable
let exePath: string;
let useArgs: string[];
if (packageName === 'npx' || packageName === 'npm' || packageName === 'yarn' || packageName === 'pnpm') {
// For package managers, use local installation
const localBin = path.join(dirs.nodeModules, '.bin', packageName);
if (isWindows()) {
exePath = localBin + '.cmd';
} else {
exePath = localBin;
}
// Check if exists
if (!fs.existsSync(exePath)) {
// Fallback to system, but warn
console.warn(`[Warning] Local ${packageName} not found, using system`);
exePath = packageName;
}
useArgs = args;
} else {
// For other packages, use npx
exePath = path.join(dirs.nodeModules, '.bin', 'npx');
if (isWindows()) {
exePath += '.cmd';
}
if (!fs.existsSync(exePath)) {
exePath = 'npx';
}
useArgs = ['-y', packageName, ...args];
}
// Build environment — getAppEnv() 提供完整的应用内隔离环境
const env: Record<string, string | undefined> = {
...process.env,
...getAppEnv(),
...options?.env,
};
// Set npm registry if specified
if (options?.registry) {
env.NPM_CONFIG_REGISTRY = options.registry;
}
return spawn(exePath, useArgs, {
cwd: options?.cwd || dirs.mcpModules,
env,
stdio: ['ignore', 'pipe', 'pipe'],
shell: isWindows(),
});
}
// ==================== Bundled MCP Proxy ====================
/**
* 获取应用内集成的 qiming-mcp-stdio-proxy 目录
*
* 打包后: process.resourcesPath/qiming-mcp-stdio-proxy/
* 开发时: resources/qiming-mcp-stdio-proxy/
*
* @returns 目录路径(存在且含 package.json或 null
*/
export function getBundledMcpProxyDir(): string | null {
const bundledDir = path.join(getResourcesPath(), 'qiming-mcp-stdio-proxy');
if (fs.existsSync(path.join(bundledDir, 'package.json'))) {
return bundledDir;
}
return null;
}
export default {
getAppPaths,
getExecutablePath,
isInstalledLocally,
isInstalledGlobally,
getPackageInfo,
getLocalPackages,
spawnLocal,
};

View File

@@ -0,0 +1,213 @@
import { app } from "electron";
import * as path from "path";
import * as fs from "fs";
import { spawn } from "child_process";
import { getAppEnv } from "../system/dependencies";
import { APP_DATA_DIR_NAME } from "../constants";
import { isWindows } from "../system/shellEnv";
export interface AppPaths {
appData: string; // 应用数据目录
nodeModules: string; // 本地 node_modules
temp: string; // 临时目录
logs: string; // 日志目录
workspaces: string; // 工作空间
}
// Get application paths
export function getAppPaths(): AppPaths {
const appData =
app?.getPath("userData") ||
path.join(process.env.HOME || "", APP_DATA_DIR_NAME);
return {
appData,
nodeModules: path.join(appData, "node_modules"),
temp: path.join(appData, "temp"),
logs: path.join(appData, "logs"),
workspaces: path.join(appData, "workspaces"),
};
}
// Ensure directories exist
export function ensureAppDirs(): AppPaths {
const dirs = getAppPaths();
for (const [key, dir] of Object.entries(dirs)) {
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true });
}
}
return dirs;
}
// Get npm/pnpm executable path (use local if available)
export function getPackageManager(): string {
// Check for pnpm first, then yarn, then npm
const appDirs = getAppPaths();
// Check local installation
const localNpm = path.join(appDirs.nodeModules, ".bin", "npm");
const localPnpm = path.join(appDirs.nodeModules, ".bin", "pnpm");
const localYarn = path.join(appDirs.nodeModules, ".bin", "yarn");
if (fs.existsSync(localPnpm)) return localPnpm;
if (fs.existsSync(localYarn)) return localYarn;
if (fs.existsSync(localNpm)) return localNpm;
// Fallback to system
return isWindows() ? "npm.cmd" : "npm";
}
// Install package locally (not globally)
export async function installPackage(
packageName: string,
options?: {
registry?: string;
cwd?: string;
},
): Promise<{ success: boolean; error?: string }> {
const dirs = ensureAppDirs();
const cwd = options?.cwd || dirs.nodeModules;
const registry = options?.registry;
// Ensure node_modules exists
if (!fs.existsSync(cwd)) {
fs.mkdirSync(cwd, { recursive: true });
}
// Build command
const npmCmd = isWindows() ? "npm.cmd" : "npm";
const args = ["install", "--save", "--no-save", packageName];
if (registry) {
args.push(`--registry=${registry}`);
}
return new Promise((resolve) => {
const proc = spawn(npmCmd, args, {
cwd,
env: {
...process.env,
...getAppEnv(),
},
stdio: "pipe",
shell: isWindows(),
});
let stdout = "";
let stderr = "";
proc.stdout?.on("data", (data) => {
stdout += data.toString();
});
proc.stderr?.on("data", (data) => {
stderr += data.toString();
});
proc.on("error", (error) => {
console.error(`[Install] Error:`, error);
resolve({ success: false, error: error.message });
});
proc.on("close", (code) => {
if (code === 0) {
resolve({ success: true });
} else {
console.error(`[Install] Failed: ${stderr}`);
resolve({ success: false, error: stderr || "Install failed" });
}
});
});
}
// Uninstall package
export async function uninstallPackage(
packageName: string,
options?: {
cwd?: string;
},
): Promise<{ success: boolean; error?: string }> {
const dirs = getAppPaths();
const cwd = options?.cwd || dirs.nodeModules;
const npmCmd = isWindows() ? "npm.cmd" : "npm";
const args = ["uninstall", packageName];
return new Promise((resolve) => {
const proc = spawn(npmCmd, args, {
cwd,
env: { ...process.env, ...getAppEnv() },
stdio: "pipe",
shell: isWindows(),
});
let stderr = "";
proc.stderr?.on("data", (data) => {
stderr += data.toString();
});
proc.on("error", (error) => {
resolve({ success: false, error: error.message });
});
proc.on("close", (code) => {
if (code === 0) {
resolve({ success: true });
} else {
resolve({ success: false, error: stderr });
}
});
});
}
// Check if package is installed locally
export function isPackageInstalled(packageName: string): boolean {
const dirs = getAppPaths();
const packagePath = path.join(dirs.nodeModules, packageName);
return fs.existsSync(packagePath);
}
// Get installed packages
export function getInstalledPackages(): string[] {
const dirs = getAppPaths();
const packageJsonPath = path.join(dirs.nodeModules, "package.json");
if (!fs.existsSync(packageJsonPath)) {
return [];
}
try {
const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, "utf-8"));
return [
...Object.keys(packageJson.dependencies || {}),
...Object.keys(packageJson.devDependencies || {}),
];
} catch {
return [];
}
}
// Clean up temporary files
export function cleanupTemp(): void {
const dirs = getAppPaths();
if (fs.existsSync(dirs.temp)) {
fs.rmSync(dirs.temp, { recursive: true, force: true });
fs.mkdirSync(dirs.temp, { recursive: true });
}
}
export default {
getAppPaths,
ensureAppDirs,
getPackageManager,
installPackage,
uninstallPackage,
isPackageInstalled,
getInstalledPackages,
cleanupTemp,
};

View File

@@ -0,0 +1,120 @@
/**
* PersistentMcpBridge — thin wrapper
*
* The implementation lives in qiming-mcp-stdio-proxy, installed to ~/.qimingclaw/node_modules.
* This module creates a singleton with electron-log injected as the logger.
* Uses dynamic require from ~/.qimingclaw so the app does not bundle the package.
*/
import * as path from "path";
import log from "electron-log";
import { getAppPaths, getBundledMcpProxyDir } from "./packageLocator";
const PKG_NAME = "qiming-mcp-stdio-proxy";
/**
* Wrap electron-log so high-frequency messages (e.g. "New HTTP session")
* are downgraded from info → debug to reduce log noise.
*/
const NOISY_PATTERNS = [/New HTTP session/];
function createQuietLogger(): typeof log {
const quiet = Object.create(log) as typeof log;
const originalInfo = log.info.bind(log);
quiet.info = (...args: Parameters<typeof log.info>) => {
const first = args[0];
if (
typeof first === "string" &&
NOISY_PATTERNS.some((p) => p.test(first))
) {
log.debug(...args);
return;
}
originalInfo(...args);
};
return quiet;
}
/** Lazy-loaded singleton instance (from qiming-mcp-stdio-proxy) */
let instance: {
start: (args: unknown) => Promise<void>;
stop: () => Promise<void>;
isRunning: () => boolean;
getBridgeUrl: (name: string) => string | null;
} | null = null;
function getInstance(): NonNullable<typeof instance> {
if (instance) return instance;
// 1. 应用内集成版本bundled resources
const bundledDir = getBundledMcpProxyDir();
if (bundledDir) {
try {
// eslint-disable-next-line @typescript-eslint/no-require-imports
const pkg = require(bundledDir);
if (pkg.PersistentMcpBridge) {
log.info(
`[PersistentMcpBridge] Using bundled integration: ${bundledDir}`,
);
instance = new pkg.PersistentMcpBridge(
createQuietLogger(),
) as NonNullable<typeof instance>;
return instance;
}
} catch (err) {
log.warn(
`[PersistentMcpBridge] Bundled integration load failed, falling back to node_modules:`,
err instanceof Error ? err.message : String(err),
);
}
}
// 2. 回退兼容: ~/.qimingbot/node_modules
const nodeModules = getAppPaths().nodeModules;
const pkgPath = path.join(nodeModules, PKG_NAME);
try {
// eslint-disable-next-line @typescript-eslint/no-require-imports
const pkg = require(pkgPath);
if (!pkg.PersistentMcpBridge) {
throw new Error(`${PKG_NAME}: PersistentMcpBridge export not found`);
}
log.info(
`[PersistentMcpBridge] Using ~/.qimingbot path (legacy fallback): ${pkgPath}`,
);
instance = new pkg.PersistentMcpBridge(createQuietLogger()) as NonNullable<
typeof instance
>;
return instance;
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
log.error(`[PersistentMcpBridge] Failed to load ${PKG_NAME}:`, msg);
throw new Error(
`${PKG_NAME} not installed or failed to load.${msg ? ` (${msg})` : ""}`,
);
}
}
export const persistentMcpBridge = {
async start(
args: Parameters<NonNullable<typeof instance>["start"]>[0],
): Promise<void> {
return getInstance().start(args);
},
async stop(): Promise<void> {
return getInstance().stop();
},
isRunning(): boolean {
try {
return getInstance().isRunning();
} catch {
return false;
}
},
getBridgeUrl(name: string): string | null {
try {
return getInstance().getBridgeUrl(name);
} catch {
return null;
}
},
};

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,74 @@
/**
* Windows-MCP 进程运行器Electron 实现)
*
* 实现 agent-gui-server 中定义的 ProcessRunner 接口,
* 使用 ManagedProcess 管理子进程生命周期。
*/
import type {
ProcessRunner,
ProcessConfig,
StartResult,
StopResult,
} from "agent-gui-server";
import log from "electron-log";
import { ManagedProcess } from "../../processManager";
import { killProcessTreeGraceful } from "../utils/processTree";
/**
* Electron 进程运行器
*
* 使用 ManagedProcess 管理子进程,支持:
* - 跨平台无窗口启动
* - 自动日志收集
* - 优雅停止和强制终止
*/
export class ElectronProcessRunner implements ProcessRunner {
private process: ManagedProcess;
constructor() {
this.process = new ManagedProcess("windows-mcp");
}
/**
* 启动进程
*/
async start(config: ProcessConfig): Promise<StartResult> {
const result = await this.process.start({
command: config.command,
args: config.args,
env: config.env,
cwd: config.cwd,
startupDelayMs: 5000, // windows-mcp 需要更长的启动时间
});
if (result.success) {
return { success: true };
}
return { success: false, error: result.error };
}
/**
* 停止进程
*
* Windows 上 uv 会拉起 python/windows-mcp 子进程,仅 kill 父进程常导致子进程残留并占用 GUI MCP 端口10048
*/
async stop(): Promise<StopResult> {
const st = this.process.status();
if (st.running && st.pid != null) {
await killProcessTreeGraceful(st.pid, 5000).catch((e) => {
log.warn("[windows-mcp] killProcessTreeGraceful:", e);
});
}
const result = await this.process.stopAsync();
return { success: result.success, error: result.message };
}
/**
* 获取进程 PID
*/
getPid(): number | null {
return this.process.pid ?? null;
}
}