openpencil/apps/web/server/utils/mcp-server-manager.ts
Kayshen-X a7d73ebb62 feat(ai): pencil-style agentic design tool-loop, multi-chat tabs, #27 panel restyle
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).
2026-07-02 21:21:06 +08:00

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 };
}