openpencil/apps/cli/src/connection.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

88 lines
2.3 KiB
TypeScript

/** Port file discovery and app health check. */
import { readFile, unlink } from 'node:fs/promises';
import { join } from 'node:path';
import { homedir } from 'node:os';
const PORT_FILE_DIR = '.openpencil';
const PORT_FILE_NAME = '.port';
const PORT_FILE_PATH = join(homedir(), PORT_FILE_DIR, PORT_FILE_NAME);
const APP_BASE_URLS = ['http://127.0.0.1', 'http://localhost'];
async function getReachableAppUrl(port: number): Promise<string | null> {
for (const baseUrl of APP_BASE_URLS) {
const url = `${baseUrl}:${port}/api/mcp/server`;
for (let attempt = 0; attempt < 5; attempt++) {
try {
const res = await fetch(url, {
signal: AbortSignal.timeout(500),
});
if (res.ok) return `${baseUrl}:${port}`;
} catch {
// App may still be starting, or the port file may be stale.
}
if (attempt < 4) {
await new Promise((resolve) => setTimeout(resolve, 200));
}
}
}
return null;
}
function isPidAlive(pid: number): boolean {
try {
process.kill(pid, 0);
return true;
} catch (err: unknown) {
return (err as NodeJS.ErrnoException).code === 'EPERM';
}
}
export interface AppInfo {
port: number;
pid: number;
timestamp: number;
url: string;
}
/** Read port file and return app info, or null if no running instance. */
export async function getAppInfo(): Promise<AppInfo | null> {
try {
const raw = await readFile(PORT_FILE_PATH, 'utf-8');
const { port, pid, timestamp } = JSON.parse(raw) as {
port: number;
pid: number;
timestamp: number;
};
const url = await getReachableAppUrl(port);
if (url) {
return { port, pid, timestamp, url };
}
if (!isPidAlive(pid)) {
try {
await unlink(PORT_FILE_PATH);
} catch {
// Ignore stale port file cleanup failures.
}
return null;
}
return null;
} catch {
return null;
}
}
/** Get app URL or throw if not running. */
export async function requireApp(): Promise<string> {
const info = await getAppInfo();
if (!info) {
throw new Error('No running OpenPencil instance found. Run `openpencil start` first.');
}
return info.url;
}
/** Quick check if app is running. */
export async function isAppRunning(): Promise<boolean> {
return (await getAppInfo()) !== null;
}