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

129 lines
3.1 KiB
TypeScript

import { spawn } from 'node:child_process';
import { type Config } from './gen/types.gen';
export type ServerOptions = {
hostname?: string;
port?: number;
signal?: AbortSignal;
timeout?: number;
config?: Config;
/** Absolute path to the opencode binary (avoids PATH lookup issues in Electron). */
binaryPath?: string;
};
export type TuiOptions = {
project?: string;
model?: string;
session?: string;
agent?: string;
signal?: AbortSignal;
config?: Config;
};
export async function createOpencodeServer(options?: ServerOptions) {
options = Object.assign(
{
hostname: '127.0.0.1',
port: 4096,
timeout: 5000,
},
options ?? {},
);
const args = [`serve`, `--hostname=${options.hostname}`, `--port=${options.port}`];
if (options.config?.logLevel) args.push(`--log-level=${options.config.logLevel}`);
const cmd = options.binaryPath ?? 'opencode';
const proc = spawn(cmd, args, {
shell: process.platform === 'win32',
signal: options.signal,
env: {
...process.env,
OPENCODE_CONFIG_CONTENT: JSON.stringify(options.config ?? {}),
},
});
const url = await new Promise<string>((resolve, reject) => {
const id = setTimeout(() => {
reject(new Error(`Timeout waiting for server to start after ${options.timeout}ms`));
}, options.timeout);
let output = '';
proc.stdout?.on('data', (chunk) => {
output += chunk.toString();
const lines = output.split('\n');
for (const line of lines) {
if (line.startsWith('opencode server listening')) {
const match = line.match(/on\s+(https?:\/\/[^\s]+)/);
if (!match) {
throw new Error(`Failed to parse server url from output: ${line}`);
}
clearTimeout(id);
resolve(match[1]!);
return;
}
}
});
proc.stderr?.on('data', (chunk) => {
output += chunk.toString();
});
proc.on('exit', (code) => {
clearTimeout(id);
let msg = `Server exited with code ${code}`;
if (output.trim()) {
msg += `\nServer output: ${output}`;
}
reject(new Error(msg));
});
proc.on('error', (error) => {
clearTimeout(id);
reject(error);
});
if (options.signal) {
options.signal.addEventListener('abort', () => {
clearTimeout(id);
reject(new Error('Aborted'));
});
}
});
return {
url,
close() {
proc.kill();
},
};
}
export function createOpencodeTui(options?: TuiOptions) {
const args = [];
if (options?.project) {
args.push(`--project=${options.project}`);
}
if (options?.model) {
args.push(`--model=${options.model}`);
}
if (options?.session) {
args.push(`--session=${options.session}`);
}
if (options?.agent) {
args.push(`--agent=${options.agent}`);
}
const proc = spawn(`opencode`, args, {
shell: process.platform === 'win32',
signal: options?.signal,
stdio: 'inherit',
env: {
...process.env,
OPENCODE_CONFIG_CONTENT: JSON.stringify(options?.config ?? {}),
},
});
return {
close() {
proc.kill();
},
};
}