openpencil/apps/web/server/utils/opencode-client.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

108 lines
3 KiB
TypeScript

/**
* Shared OpenCode client manager.
* Reuses an existing server on port 4096; starts one on a random port as fallback.
* Tracks spawned servers so they can be cleaned up on process exit.
*/
import { execSync } from 'node:child_process';
import { existsSync } from 'node:fs';
import { join } from 'node:path';
import { homedir } from 'node:os';
const activeServers = new Set<{ close(): void }>();
// Clean up spawned OpenCode servers on process exit
function cleanup() {
for (const server of activeServers) {
try {
server.close();
} catch {
/* ignore */
}
}
activeServers.clear();
}
process.on('beforeExit', cleanup);
process.on('SIGTERM', cleanup);
process.on('SIGINT', cleanup);
const isWindows = process.platform === 'win32';
/** Cached resolved binary path */
let _resolvedBinary: string | undefined | null = null;
/** Resolve the opencode binary, with caching. */
function resolveOpencodeBinary(): string | undefined {
if (_resolvedBinary !== null) return _resolvedBinary ?? undefined;
// PATH lookup
try {
const cmd = isWindows ? 'where opencode 2>nul' : 'which opencode 2>/dev/null';
const result = execSync(cmd, { encoding: 'utf-8', timeout: 5000 })
.trim()
.split(/\r?\n/)[0]
?.trim();
if (result && existsSync(result)) {
_resolvedBinary = result;
return result;
}
} catch {
/* not on PATH */
}
// Common install locations
const home = homedir();
const candidates = isWindows
? [
join(process.env.APPDATA || '', 'npm', 'opencode.cmd'),
join(process.env.NVM_SYMLINK || '', 'opencode.cmd'),
join(process.env.FNM_MULTISHELL_PATH || '', 'opencode.cmd'),
join(home, 'scoop', 'shims', 'opencode.exe'),
]
: [
join(home, '.opencode', 'bin', 'opencode'),
join(home, '.npm-global', 'bin', 'opencode'),
'/usr/local/bin/opencode',
'/opt/homebrew/bin/opencode',
join(home, '.local', 'bin', 'opencode'),
];
for (const c of candidates) {
if (c && existsSync(c)) {
_resolvedBinary = c;
return c;
}
}
_resolvedBinary = undefined;
return undefined;
}
export async function getOpencodeClient(binaryPath?: string) {
const { createOpencodeClient, createOpencode } = await import('../opencode/index');
// Try connecting to an existing server first
try {
const client = createOpencodeClient();
await client.config.providers(); // probe
return { client, server: undefined };
} catch {
// No running server — start a temporary one on a random port
const resolvedPath = binaryPath ?? resolveOpencodeBinary();
const timeout = isWindows ? 15_000 : 5000;
const oc = await createOpencode({ port: 0, binaryPath: resolvedPath, timeout });
activeServers.add(oc.server);
return { client: oc.client, server: oc.server };
}
}
export function releaseOpencodeServer(server: { close(): void } | undefined) {
if (!server) return;
try {
server.close();
} catch {
/* ignore */
}
activeServers.delete(server);
}