Built-in design generation now runs as an agentic MCP tool-loop (reusing the agent-rs BuiltInProvider), gated behind OPENPENCIL_DESIGN_AGENT_LOOP / the Settings experimental toggle; the orchestrator stays the default. - design-agent system prompt + in-process design toolset (parity-locked with the MCP surface) + flag-gated Intent::Design routing - spawn_agents execution as sequential sub-loops + live creation-mode badges (per-agent glow + 'N/M designing...' header) - new MCP tools: get_guidelines, ToolSearch, get_screenshot, get_editor_state, export_nodes, spawn_agents; style-guide local audit - #27 AI panel restyle: rounded tool cards + green check-rings, gray user bubbles, model-pill bottom toolbar, header, empty-state pills, the PARALLEL AGENTS (agent_team_size) 1x-6x chip dropdown - multi-chat tabs: ChatSessions model (Deref-to-active) + tab row UI (switch / close / + / Cmd+T) with each run bound to its tab Large checkpoint commit spanning the working tree (Rust shell crates).
192 lines
5.7 KiB
TypeScript
192 lines
5.7 KiB
TypeScript
import { spawn } from 'node:child_process';
|
|
import { existsSync, writeFileSync, unlinkSync, readFileSync } from 'node:fs';
|
|
import { networkInterfaces } from 'node:os';
|
|
import { join, resolve, dirname } from 'node:path';
|
|
import { fileURLToPath } from 'node:url';
|
|
import { tmpdir } from 'node:os';
|
|
|
|
// ESM-compatible __dirname polyfill
|
|
const __filename = fileURLToPath(import.meta.url);
|
|
const __dirname = dirname(__filename);
|
|
|
|
// PID/Port files for tracking the detached MCP server process across restarts
|
|
const MCP_PID_FILE = join(tmpdir(), 'openpencil-mcp-server.pid');
|
|
const MCP_PORT_FILE = join(tmpdir(), 'openpencil-mcp-server.port');
|
|
|
|
/** Resolve the MCP server script path across dev, web build, and Electron production. */
|
|
function resolveMcpServerScript(): string {
|
|
// Electron production: extraResources
|
|
const electronResources = process.env.ELECTRON_RESOURCES_PATH;
|
|
if (electronResources) {
|
|
const p = join(electronResources, 'mcp-server.cjs');
|
|
if (existsSync(p)) return p;
|
|
}
|
|
// dev + web build (from cwd) — monorepo outputs to out/
|
|
// In dev mode, CWD is apps/web/ (due to "cd apps/web && ..." in dev script),
|
|
// so also check ../../out/ to reach the monorepo root.
|
|
for (const base of [
|
|
resolve(process.cwd(), 'out', 'mcp-server.cjs'),
|
|
resolve(process.cwd(), '..', '..', 'out', 'mcp-server.cjs'),
|
|
]) {
|
|
if (existsSync(base)) return base;
|
|
}
|
|
// Fallback: relative to this file (Nitro bundled output)
|
|
const fromFile = resolve(__dirname, '..', '..', '..', 'out', 'mcp-server.cjs');
|
|
if (existsSync(fromFile)) return fromFile;
|
|
return resolve(process.cwd(), 'out', 'mcp-server.cjs');
|
|
}
|
|
|
|
/** Get the first non-internal IPv4 address (LAN IP). */
|
|
export function getLocalIp(): string | null {
|
|
const nets = networkInterfaces();
|
|
for (const name of Object.keys(nets)) {
|
|
for (const net of nets[name] ?? []) {
|
|
if (net.family === 'IPv4' && !net.internal) {
|
|
return net.address;
|
|
}
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
/** Check if a process with the given PID is running. */
|
|
function isProcessRunning(pid: number): boolean {
|
|
try {
|
|
// Signal 0 checks existence without actually sending a signal
|
|
process.kill(pid, 0);
|
|
return true;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/** Read PID from file if it exists and process is still running. */
|
|
function getRunningPid(): { pid: number; port: number } | null {
|
|
try {
|
|
if (!existsSync(MCP_PID_FILE)) return null;
|
|
const pid = parseInt(readFileSync(MCP_PID_FILE, 'utf-8').trim(), 10);
|
|
if (isNaN(pid) || !isProcessRunning(pid)) {
|
|
// Stale PID file - clean up
|
|
try {
|
|
unlinkSync(MCP_PID_FILE);
|
|
} catch {
|
|
/* ignore */
|
|
}
|
|
try {
|
|
unlinkSync(MCP_PORT_FILE);
|
|
} catch {
|
|
/* ignore */
|
|
}
|
|
return null;
|
|
}
|
|
const port = existsSync(MCP_PORT_FILE)
|
|
? parseInt(readFileSync(MCP_PORT_FILE, 'utf-8').trim(), 10)
|
|
: 3100;
|
|
return { pid, port: isNaN(port) ? 3100 : port };
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
export function getMcpServerStatus(): {
|
|
running: boolean;
|
|
port: number | null;
|
|
localIp: string | null;
|
|
} {
|
|
const info = getRunningPid();
|
|
if (!info) {
|
|
return { running: false, port: null, localIp: null };
|
|
}
|
|
return { running: true, port: info.port, localIp: getLocalIp() };
|
|
}
|
|
|
|
export function startMcpHttpServer(port: number): {
|
|
running: boolean;
|
|
port: number;
|
|
localIp: string | null;
|
|
error?: string;
|
|
} {
|
|
// Check if already running
|
|
const existing = getRunningPid();
|
|
if (existing) {
|
|
return { running: true, port: existing.port, localIp: getLocalIp() };
|
|
}
|
|
|
|
const serverScript = resolveMcpServerScript();
|
|
|
|
try {
|
|
// CRITICAL: Use detached mode with unref() so the MCP server survives
|
|
// independently of the parent Nitro process. This prevents the server
|
|
// from dying when:
|
|
// 1. The UI settings dialog is closed
|
|
// 2. The user interacts with the editor canvas
|
|
// 3. The Nitro server restarts or hot-reloads
|
|
// 4. The Electron app sends SIGTERM to Nitro on window close
|
|
//
|
|
// The process runs in its own session and writes its PID to a file
|
|
// for later tracking and graceful shutdown.
|
|
const child = spawn(process.execPath, [serverScript, '--http', '--port', String(port)], {
|
|
stdio: ['ignore', 'ignore', 'ignore'],
|
|
env: {
|
|
...process.env,
|
|
// In Electron, process.execPath is the Electron binary.
|
|
// ELECTRON_RUN_AS_NODE makes it behave as plain Node.js.
|
|
ELECTRON_RUN_AS_NODE: '1',
|
|
},
|
|
detached: true,
|
|
windowsHide: true,
|
|
});
|
|
|
|
// Allow parent to exit independently of child
|
|
child.unref();
|
|
|
|
// Write PID to file for later tracking (after brief delay to ensure startup)
|
|
const childPid = child.pid;
|
|
if (childPid) {
|
|
setTimeout(() => {
|
|
try {
|
|
if (isProcessRunning(childPid)) {
|
|
writeFileSync(MCP_PID_FILE, String(childPid), 'utf-8');
|
|
writeFileSync(MCP_PORT_FILE, String(port), 'utf-8');
|
|
}
|
|
} catch {
|
|
/* ignore write errors */
|
|
}
|
|
}, 100);
|
|
}
|
|
|
|
return { running: true, port, localIp: getLocalIp() };
|
|
} catch (err) {
|
|
return {
|
|
running: false,
|
|
port,
|
|
localIp: null,
|
|
error: err instanceof Error ? err.message : String(err),
|
|
};
|
|
}
|
|
}
|
|
|
|
export function stopMcpHttpServer(): { running: false } {
|
|
const info = getRunningPid();
|
|
if (info) {
|
|
try {
|
|
// Use process.kill which is cross-platform and safe
|
|
process.kill(info.pid, 'SIGTERM');
|
|
} catch {
|
|
/* process may have already exited */
|
|
}
|
|
// Clean up PID/Port files
|
|
try {
|
|
unlinkSync(MCP_PID_FILE);
|
|
} catch {
|
|
/* ignore */
|
|
}
|
|
try {
|
|
unlinkSync(MCP_PORT_FILE);
|
|
} catch {
|
|
/* ignore */
|
|
}
|
|
}
|
|
return { running: false };
|
|
}
|